From 4c4e415975ff1ade9beff1f622ecbbd35c28981b Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 11 Sep 2026 17:06:40 -0400 Subject: [PATCH] feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79) --- CHANGELOG.md | 13 ++ backend/agent/loop.py | 69 +++++- backend/ai_chat.py | 106 ++++++++++ backend/bookslm_routes.py | 113 +++++----- backend/main.py | 173 ++++----------- backend/services/errors.py | 29 +++ backend/services/files.py | 86 ++++++++ backend/services/paths.py | 45 ++++ backend/services/search.py | 41 ++++ backend/services/vaults.py | 102 +++++++++ backend/tools/context.py | 32 +-- backend/tools/registry.py | 16 ++ backend/tools/service.py | 105 +++------- docs/AI_ARCHITECTURE_GUIDE.md | 25 ++- docs/ROADMAP.md | 6 +- docs/features/ai-tools-mcp.md | 10 +- frontend/index.html | 5 + frontend/js/bookslm.js | 381 +++++++++++++++++++++++++++------- frontend/locales/en.json | 6 + frontend/locales/fr.json | 6 + frontend/style.css | 15 ++ tests/frontend/ai.test.mjs | 94 +++++++++ tests/test_agent_loop.py | 59 ++++++ tests/test_ai_chat.py | 92 ++++++++ tests/test_api_main.py | 6 +- tests/test_bookslm.py | 62 ++++++ tests/test_services.py | 154 ++++++++++++++ 27 files changed, 1465 insertions(+), 386 deletions(-) create mode 100644 backend/services/errors.py create mode 100644 backend/services/files.py create mode 100644 backend/services/paths.py create mode 100644 backend/services/search.py create mode 100644 backend/services/vaults.py create mode 100644 tests/test_services.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f9885..e281865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). fallback automatique en chat simple si le provider rejette les tools. Tests : `tests/test_agent_loop.py`, `tests/test_ai_chat.py`, `tests/test_bookslm.py`. +- **#79 A2/B4/B5 — services partagés, SSE streaming & confirmations UI** — extraction de la logique + lecture/recherche dans `backend/services/` (`errors.py`, `paths.py`, `vaults.py`, `files.py`, + `search.py`) : routes REST et outils IA consomment la même source ; `ServiceError` mappée vers + `HTTPException` (routes) et `ToolError` (outils). `/api/ai/bookslm/chat` **streame réellement** les + tokens (`stream_completion`, OpenAI-compat + Gemini ; GZip ignoré pour les SSE BooksLM). Mode agent + côté UI (toggle, événements `tool`/`confirmation`, carte Apply + aperçu diff LCS) et reprise + `confirm`/`confirm_messages` de l'agent loop (confirmation one-shot). Tests : `tests/test_services.py`, + `tests/test_ai_chat.py`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`. + - **#79 G — Sélection fournisseur/modèle par défaut** — `ai_default_provider` + `ai_default_models` persistés dans `data/config.json`, rechargement à chaud dans `backend/ai.py`, sélecteurs « Fournisseur par défaut » / « Modèle par défaut » dans `#cfg-ai`, i18n FR/EN. @@ -46,6 +55,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). renderer bloc/inline complet ; l'en-tête expose un bouton « Historique des sessions » ; les badges de sources ouvrent désormais réellement le fichier (événement `obsigate:open-file`). +- **Assistant IA (`frontend/js/bookslm.js`)** — bouton « mode agent » dans l'en-tête (persisté en + `localStorage`) : l'envoi bascule vers `/api/ai/bookslm/agent`, affiche la trace des appels d'outils + et les cartes de confirmation (Apply + aperçu diff) ; `/chat` reste le défaut. i18n FR/EN. + ### Corrigé - **Typage backend (mypy) — 33 erreurs corrigées** (`backend/main.py`, `indexer.py`, diff --git a/backend/agent/loop.py b/backend/agent/loop.py index 9eb17bc..adeb963 100644 --- a/backend/agent/loop.py +++ b/backend/agent/loop.py @@ -98,6 +98,59 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[ } +def _execute_confirmed( + ctx: ToolContext, + confirm_pending: dict[str, Any], + convo: list[dict[str, Any]], + executed: list[ToolCallRecord], + on_tool_call: Callable[[ToolCallRecord], None] | None, +) -> None: + """Apply a previously-paused mutating tool call and feed its result back. + + The pending payload is the ``error`` object emitted by a ``confirmation`` + event. The assistant tool-call message is expected to already be in + ``convo`` (it is part of the snapshot returned with the confirmation). + """ + from backend.ai_chat import ToolCall + + error = confirm_pending.get("error", confirm_pending) + name = error.get("tool") + arguments = error.get("arguments") or {} + call_id = error.get("id") or "call_pending" + + if not name: + raise ToolError("Malformed confirmation payload", code="invalid_confirmation") + + # Make sure the assistant tool-call message is present in the snapshot. + if not any( + m.get("role") == "assistant" and any( + tc.get("id") == call_id for tc in (m.get("tool_calls") or []) + ) + for m in convo + ): + convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)])) + + try: + result = call_tool(name, ctx, arguments, confirm=True) + payload = result.data + ok = True + except ToolError as e: + payload = e.to_dict() + ok = False + + record = ToolCallRecord(name=name, arguments=arguments, ok=ok, result=payload) + executed.append(record) + if on_tool_call is not None: + on_tool_call(record) + + convo.append({ + "role": "tool", + "tool_call_id": call_id, + "name": name, + "content": json.dumps(_truncate(payload), ensure_ascii=False, default=str), + }) + + async def run_agent( messages: list[dict[str, Any]], *, @@ -106,6 +159,8 @@ async def run_agent( tools: list[dict[str, Any]] | None = None, max_iterations: int = DEFAULT_MAX_ITERATIONS, on_tool_call: Callable[[ToolCallRecord], None] | None = None, + resume_messages: list[dict[str, Any]] | None = None, + confirm_pending: dict[str, Any] | None = None, ) -> AgentResult: """Run the tool-calling loop until completion. @@ -119,6 +174,10 @@ async def run_agent( pass ``[]`` to disable tool calling (plain chat). max_iterations: Hard cap on LLM round-trips. on_tool_call: Optional callback invoked after each executed tool call. + resume_messages: Conversation snapshot from a paused run (returned with + a ``confirmation`` event). When set, the loop resumes from it. + confirm_pending: Pending mutating tool call to apply before resuming + (two-step propose/apply). Returns: An :class:`AgentResult`. ``stopped`` is ``done``, ``max_iterations`` or @@ -129,9 +188,12 @@ async def run_agent( if tools is None: tools = get_tool_schemas(scope=ToolScope.IN_APP) - convo = [dict(m) for m in messages] + convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)] executed: list[ToolCallRecord] = [] + if confirm_pending: + _execute_confirmed(ctx, confirm_pending, convo, executed, on_tool_call) + for iteration in range(1, max_iterations + 1): response = await llm(convo, tools) @@ -153,13 +215,16 @@ async def run_agent( ok = True except ToolConfirmationRequired as e: logger.info(f"Agent paused: confirmation required for '{call.name}'") + pending = e.to_dict() + # Include the tool-call id so the client can echo it back. + pending["error"]["id"] = call.id return AgentResult( content=response.content or "", messages=convo, tool_calls=executed, iterations=iteration, stopped=STOP_CONFIRMATION_REQUIRED, - pending=e.to_dict(), + pending=pending, ) except ToolError as e: payload = e.to_dict() diff --git a/backend/ai_chat.py b/backend/ai_chat.py index 01e34f1..19edf6a 100644 --- a/backend/ai_chat.py +++ b/backend/ai_chat.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import logging +from collections.abc import AsyncIterator from dataclasses import dataclass, field from typing import Any @@ -232,3 +233,108 @@ async def _post_json(url: str, headers: dict[str, str] | None, payload: dict[str resp = await client.post(url, headers=headers or {}, json=payload) resp.raise_for_status() return resp.json() + + +# ── Streaming (SSE token stream) ──────────────────────────────────────── + + +async def stream_completion( + messages: list[dict[str, Any]], + *, + provider: str | None = None, + model: str | None = None, + temperature: float = 0.3, + max_tokens: int = 4096, +) -> AsyncIterator[str]: + """Yield content deltas from a chat completion as they arrive. + + Only text content is streamed (no tool calling): this backs the plain + ``/api/ai/bookslm/chat`` endpoint. The tool-calling ``/agent`` endpoint + keeps using :func:`chat_completion` because tool calls need the complete + response before they can be executed. + """ + cfg = _get_provider_config(provider) # type: ignore[arg-type] + resolved_model = model or cfg["model"] + + if cfg["name"] == "gemini": + stream = _gemini_stream(messages, resolved_model, temperature, max_tokens) + else: + stream = _openai_stream(messages, cfg, resolved_model, temperature, max_tokens) + + async for chunk in stream: + yield chunk + + +async def _openai_stream( + messages: list[dict[str, Any]], + cfg: dict[str, Any], + model: str, + temperature: float, + max_tokens: int, +) -> AsyncIterator[str]: + """Stream an OpenAI-compatible ``/chat/completions`` response.""" + headers = _build_headers(cfg) + url = f"{cfg['base_url']}/chat/completions" + payload: dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "stream": True, + } + + async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, headers=headers, json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line or not line.startswith("data:"): + continue + raw = line[5:].strip() + if raw == "[DONE]": + break + try: + chunk = json.loads(raw) + except json.JSONDecodeError: + continue + choices = chunk.get("choices") or [] + if not choices: + continue + delta = choices[0].get("delta") or {} + content = delta.get("content") + if content: + yield content + + +async def _gemini_stream( + messages: list[dict[str, Any]], + model: str, + temperature: float, + max_tokens: int, +) -> AsyncIterator[str]: + """Stream Gemini's ``streamGenerateContent`` response (SSE).""" + cfg = PROVIDERS["gemini"] + system, contents = _gemini_contents(messages) + payload: dict[str, Any] = { + "contents": contents, + "generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens}, + } + if system: + payload["system_instruction"] = {"parts": [{"text": system}]} + + url = f"{cfg['base_url']}/models/{model}:streamGenerateContent?alt=sse&key={cfg['api_key']}" + async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line or not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw: + continue + try: + chunk = json.loads(raw) + except json.JSONDecodeError: + continue + for candidate in chunk.get("candidates") or []: + for part in candidate.get("content", {}).get("parts", []): + text = part.get("text") + if text: + yield text diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index a89223a..aa79513 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -3,13 +3,14 @@ import json import logging from pathlib import Path +from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from backend.agent.loop import run_agent -from backend.ai_chat import chat_completion +from backend.ai_chat import chat_completion, stream_completion from backend.auth.middleware import check_vault_access, require_auth from backend.bookslm import ( build_general_system_prompt, @@ -63,6 +64,16 @@ class BooksLMChatRequest(BaseModel): default=None, description="Model name to use for this request. If not set, uses the provider's default model.", ) + confirm: dict[str, Any] | None = Field( + default=None, + description="Pending tool confirmation to apply (two-step propose/apply). " + "Shape: the ``error`` object of a previous ``confirmation`` event.", + ) + confirm_messages: list[dict[str, Any]] | None = Field( + default=None, + description="Conversation snapshot returned alongside a ``confirmation`` event, " + "echoed back to resume the agent run.", + ) def _normalize_mode(mode: str | None) -> str: @@ -169,66 +180,37 @@ async def api_bookslm_chat( """ system_prompt = _resolve_system_prompt(req, current_user) - # Call AI provider - from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini - - # Build messages with conversation history - messages_text = "" - if req.conversation_history: - for turn in req.conversation_history: - role = turn.get("role", "user") - content = turn.get("content", "") - if role == "user": - messages_text += f"\n\nUtilisateur : {content}" - elif role == "assistant": - messages_text += f"\n\nAssistant : {content}" - - # Current message - user_prompt = req.message - if messages_text: - user_prompt = f"Historique de la conversation :{messages_text}\n\nQuestion actuelle : {req.message}" + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] + for turn in req.conversation_history: + role = turn.get("role") + content = turn.get("content", "") + if role in ("user", "assistant") and content: + messages.append({"role": role, "content": content}) + messages.append({"role": "user", "content": req.message}) async def generate_sse(): try: - # Resolve provider: explicit override wins, else default. - # Fall back to first available if the requested one isn't configured. - cfg_name = (req.provider or DEFAULT_PROVIDER).lower() - if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"): - # Try next available provider - for pname, pcfg in PROVIDERS.items(): - if pcfg.get("api_key") and pname != "gemini": - cfg_name = pname - break - else: - # No provider available at all - err = "Aucun fournisseur AI configuré (clés API manquantes)" - error_data = json.dumps({"error": err}, ensure_ascii=False) - yield f"event: error\ndata: {error_data}\n\n" - return + # Resolve provider: explicit override wins, else first available. + cfg_name = _resolve_provider_name(req.provider) + if cfg_name is None: + err = "Aucun fournisseur AI configuré (clés API manquantes)" + error_data = json.dumps({"error": err}, ensure_ascii=False) + yield f"event: error\ndata: {error_data}\n\n" + return - # Optional per-request model override - original_model = None - if req.model and cfg_name in PROVIDERS: - original_model = PROVIDERS[cfg_name].get("model") - PROVIDERS[cfg_name]["model"] = req.model - try: - if cfg_name == "gemini": - response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096) - else: - response = await _call_deepseek_openrouter( - user_prompt, system_prompt, - provider=cfg_name, - temperature=0.3, - max_tokens=4096, - ) - finally: - # Restore the original model so other calls aren't affected - if original_model is not None and cfg_name in PROVIDERS: - PROVIDERS[cfg_name]["model"] = original_model - - # Send the full response as a single SSE event - data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False) - yield f"event: message\ndata: {data}\n\n" + # Stream token deltas as they arrive from the provider. + async for token in stream_completion( + messages, + provider=cfg_name, + model=req.model, + temperature=0.3, + max_tokens=4096, + ): + data = json.dumps( + {"token": token, "provider": cfg_name, "model": req.model or ""}, + ensure_ascii=False, + ) + yield f"event: message\ndata: {data}\n\n" yield "event: done\ndata: {}\n\n" except Exception as e: logger.error(f"BooksLM chat error: {e}") @@ -260,7 +242,9 @@ async def api_bookslm_agent( Same context as ``/chat`` but the model may call tools (read/search the vault) through the shared tool layer. Emits one ``tool`` event per executed tool call, then a final ``message`` event. Mutating tools pause the run with - a ``confirmation`` event (two-step propose/apply). + a ``confirmation`` event (two-step propose/apply) carrying the pending call + and the conversation snapshot; the client resumes by echoing them back in + ``confirm`` / ``confirm_messages``. """ system_prompt = _resolve_system_prompt(req, current_user) @@ -295,7 +279,13 @@ async def api_bookslm_agent( yield f"event: error\ndata: {error_data}\n\n" return - result = await run_agent(messages, ctx=ctx, llm=_llm) + result = await run_agent( + messages, + ctx=ctx, + llm=_llm, + resume_messages=req.confirm_messages, + confirm_pending=req.confirm, + ) for rec in result.tool_calls: payload = json.dumps( @@ -305,7 +295,10 @@ async def api_bookslm_agent( yield f"event: tool\ndata: {payload}\n\n" if result.stopped == "confirmation_required": - pending = json.dumps(result.pending or {}, ensure_ascii=False) + pending = json.dumps( + {"pending": result.pending or {}, "messages": result.messages}, + ensure_ascii=False, + ) yield f"event: confirmation\ndata: {pending}\n\n" else: data = json.dumps( diff --git a/backend/main.py b/backend/main.py index ff21565..c160d10 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,7 +20,7 @@ from typing import Any import frontmatter import mistune from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request -from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from starlette.middleware.base import BaseHTTPMiddleware @@ -99,12 +99,15 @@ from backend.schemas import ( ) from backend.search import ( advanced_search, - get_all_tags, init_inverted_index, - search, suggest_tags, suggest_titles, ) +from backend.services.errors import ServiceError +from backend.services.files import read_raw_file +from backend.services.search import list_tags as service_list_tags +from backend.services.search import search_vaults +from backend.services.vaults import browse_directory, list_accessible_vaults from backend.vault_settings import ( get_vault_setting, update_vault_setting, @@ -737,6 +740,12 @@ def _custom_openapi(): app.openapi = _custom_openapi # type: ignore[method-assign] + +@app.exception_handler(ServiceError) +async def _service_error_handler(request: Request, exc: ServiceError): + """Map shared-layer domain errors to HTTP responses (``{"detail": ...}``).""" + return JSONResponse(status_code=exc.status, content={"detail": exc.message}) + # GZip compression — reduces bandwidth by ~70% for text responses # Custom wrapper: skip compression for SSE streams (/api/events) from fastapi.middleware.gzip import GZipMiddleware @@ -749,8 +758,16 @@ class SSESafeGZipMiddleware(GZipMiddleware): GZip buffering breaks incremental streaming required by SSE. We detect SSE endpoints by path and bypass compression entirely. """ + # SSE endpoints that must not be buffered by GZip. + _SSE_PATHS = ( + "/api/events", + "/api/admin/stream", + "/api/ai/bookslm/chat", + "/api/ai/bookslm/agent", + ) + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] == "http" and scope.get("path") in ("/api/events", "/api/admin/stream"): + if scope["type"] == "http" and scope.get("path") in self._SSE_PATHS: # Bypass GZip: passthrough directly to the inner app await self.app(scope, receive, send) else: @@ -864,11 +881,13 @@ def _content_disposition(disposition: str, filename: str) -> str: return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}" -def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path: +def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: """Resolve a relative path safely within the vault root. - Prevents directory traversal attacks by ensuring the resolved - absolute path is a descendant of *vault_root*. + Thin wrapper around the shared :func:`backend.services.paths.resolve_safe_path` + (single implementation used by both routes and tools). The raised + :class:`ServiceError` is mapped to an ``HTTPException`` response by the + global exception handler in this module. Args: vault_root: The vault's root directory (absolute). @@ -876,34 +895,10 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path: Returns: Resolved absolute ``Path``. - - Raises: - HTTPException(403): When the resolved path escapes the vault root. """ - # Construct the full path without resolving symlinks first - full_path = vault_root / relative_path - - # Resolve both paths to handle symlinks - try: - resolved = full_path.resolve(strict=False) - vault_resolved = vault_root.resolve(strict=False) - except Exception as e: - logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}") - raise HTTPException(status_code=500, detail=f"Path resolution error: {e!s}") - - # Check if resolved path is within vault using string comparison (case-insensitive on Windows) - try: - # This will raise ValueError if resolved is not relative to vault_resolved - resolved.relative_to(vault_resolved) - except ValueError: - # Try case-insensitive comparison for Windows/Docker compatibility - resolved_str = str(resolved).lower() - vault_str = str(vault_resolved).lower() - if not resolved_str.startswith(vault_str): - logger.warning(f"Path outside vault - vault: {vault_resolved}, requested: {relative_path}, resolved: {resolved}") - raise HTTPException(status_code=403, detail="Access denied: path outside vault") - - return resolved + from backend.services.paths import resolve_safe_path as _service_resolve + + return _service_resolve(vault_root, relative_path) def _backup_file(file_path: Path, vault_name: str, relative_path: str): @@ -1271,18 +1266,7 @@ async def api_vaults(current_user=Depends(require_auth)): Returns: List of vault summary objects filtered by user permissions. """ - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) - result = [] - for name, data in index.items(): - if "*" in user_vaults or name in user_vaults: - v_type = data.get("config", {}).get("type", "VAULT") - result.append({ - "name": name, - "file_count": len(data["files"]), - "tag_count": len(data["tags"]), - "type": v_type, - }) - return result + return list_accessible_vaults(current_user) def humanize_mtime(mtime: float) -> str: @@ -1516,57 +1500,7 @@ async def api_browse(vault_name: str, path: str = "", current_user=Depends(requi """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - # Path traversal protection - target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve() - - if not target.exists(): - raise HTTPException(status_code=404, detail=f"Path not found: {path}") - - # Get vault settings for hideHiddenFiles - settings = get_vault_setting(vault_name) or {} - hide_hidden = settings.get("hideHiddenFiles", False) - - items = [] - try: - for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): - # Skip hidden files/dirs if the setting is enabled - if hide_hidden and entry.name.startswith("."): - continue - rel = str(entry.relative_to(vault_root)).replace("\\", "/") - if entry.is_dir(): - # Count only direct children (files and subdirs) for performance - try: - file_count = sum( - 1 for child in entry.iterdir() - if (not hide_hidden or not child.name.startswith(".")) - and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile")) - or child.is_dir()) - ) - except PermissionError: - file_count = 0 - items.append({ - "name": entry.name, - "path": rel, - "type": "directory", - "children_count": file_count, - }) - elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"): - items.append({ - "name": entry.name, - "path": rel, - "type": "file", - "size": entry.stat().st_size, - "extension": entry.suffix.lower(), - }) - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied") - - return {"vault": vault_name, "path": path, "items": items} + return browse_directory(vault_name, path) # Map file extensions to highlight.js language hints @@ -1599,33 +1533,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, path) - - if not file_path.exists() or not file_path.is_file(): - raise HTTPException(status_code=404, detail=f"File not found: {path}") - - try: - raw = file_path.read_text(encoding="utf-8", errors="replace") - except PermissionError as e: - logger.error(f"Permission denied reading raw file {path}: {e}") - raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}") - except UnicodeDecodeError: - # Binary file - try to read as binary and decode with errors='replace' - try: - raw = file_path.read_bytes().decode("utf-8", errors="replace") - except Exception as e: - logger.error(f"Error reading binary raw file {path}: {e}") - raise HTTPException(status_code=500, detail=f"Cannot read file: {e!s}") - except Exception as e: - logger.error(f"Unexpected error reading raw file {path}: {e}") - raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}") - - return {"vault": vault_name, "path": path, "raw": raw} + return read_raw_file(vault_name, path) @app.get("/api/file/{vault_name}/download", response_class=FileResponse) @@ -3120,18 +3028,12 @@ async def api_search( ``SearchResponse`` with ranked results and snippets. """ loop = asyncio.get_event_loop() - # Fetch full result set (capped at DEFAULT_SEARCH_LIMIT internally) - all_results = await loop.run_in_executor( + # Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and + # paginate in the shared service so routes and tools share the same logic. + return await loop.run_in_executor( _search_executor, - partial(search, q, vault_filter=vault, tag_filter=tag), + partial(search_vaults, q, vault, tag, limit, offset), ) - total = len(all_results) - page = all_results[offset: offset + limit] - return { - "query": q, "vault_filter": vault, "tag_filter": tag, - "count": len(page), "total": total, "offset": offset, "limit": limit, - "results": page, - } @app.get("/api/tags", response_model=TagsResponse) @@ -3144,8 +3046,7 @@ async def api_tags(vault: str | None = Query(None, description="Vault filter"), Returns: ``TagsResponse`` with tags sorted by descending count. """ - tags = get_all_tags(vault_filter=vault) - return {"vault_filter": vault, "tags": tags} + return {"vault_filter": vault, "tags": service_list_tags(vault)} @app.get("/api/tree-search", response_model=TreeSearchResponse) diff --git a/backend/services/errors.py b/backend/services/errors.py new file mode 100644 index 0000000..a36217e --- /dev/null +++ b/backend/services/errors.py @@ -0,0 +1,29 @@ +"""Domain errors shared by the reusable service layer. + +Services are transport-agnostic: they raise :class:`ServiceError` carrying a +stable ``code`` and an HTTP ``status`` hint. The REST layer maps it to an +``HTTPException`` (via the global handler in ``backend.main``) while the tool +layer maps it to a :class:`backend.tools.context.ToolError`. +""" + +from __future__ import annotations + +from typing import Any + + +class ServiceError(Exception): + """Base error for the shared business-logic services.""" + + def __init__( + self, + message: str, + *, + code: str = "service_error", + status: int = 400, + details: dict[str, Any] | None = None, + ): + super().__init__(message) + self.message = message + self.code = code + self.status = status + self.details = details or {} diff --git a/backend/services/files.py b/backend/services/files.py new file mode 100644 index 0000000..81023b8 --- /dev/null +++ b/backend/services/files.py @@ -0,0 +1,86 @@ +"""File reading services shared by REST routes and the AI tool layer.""" + +from __future__ import annotations + +import logging +from typing import Any + +from backend.services.errors import ServiceError +from backend.services.paths import resolve_safe_path +from backend.services.vaults import get_vault_root + +logger = logging.getLogger("obsigate.services.files") + + +def read_raw_file(vault_name: str, path: str) -> dict[str, Any]: + """Return the raw text content of a vault file (no redaction).""" + root = get_vault_root(vault_name) + file_path = resolve_safe_path(root, path) + + if not file_path.exists() or not file_path.is_file(): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + try: + raw = file_path.read_text(encoding="utf-8", errors="replace") + except PermissionError as e: + logger.error(f"Permission denied reading raw file {path}: {e}") + raise ServiceError(f"Permission denied: cannot read file {path}", code="permission_denied", status=403) from e + except UnicodeDecodeError: + try: + raw = file_path.read_bytes().decode("utf-8", errors="replace") + except Exception as e: + logger.error(f"Error reading binary raw file {path}: {e}") + raise ServiceError(f"Cannot read file: {e!s}", code="read_error", status=500) from e + except Exception as e: + logger.error(f"Unexpected error reading raw file {path}: {e}") + raise ServiceError(f"Error reading file: {e!s}", code="read_error", status=500) from e + + return {"vault": vault_name, "path": path, "raw": raw} + + +def read_file_text( + vault_name: str, + path: str, + *, + redact: bool = True, + max_bytes: int | None = None, +) -> dict[str, Any]: + """Return a vault file's text content, optionally redacted and size-capped. + + Raises: + ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read + error (500). + """ + root = get_vault_root(vault_name) + target = resolve_safe_path(root, path) + + if not target.exists() or not target.is_file(): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + size = target.stat().st_size + if max_bytes is not None and size > max_bytes: + raise ServiceError( + f"File too large ({size} bytes > {max_bytes})", + code="file_too_large", + status=413, + details={"vault": vault_name, "path": path, "size": size}, + ) + + content = target.read_text(encoding="utf-8", errors="replace") + + if redact: + from backend.secret_redactor import redact_file_content + + content = redact_file_content(content, path) + + return {"vault": vault_name, "path": path, "size": size, "content": content} diff --git a/backend/services/paths.py b/backend/services/paths.py new file mode 100644 index 0000000..2a1049c --- /dev/null +++ b/backend/services/paths.py @@ -0,0 +1,45 @@ +"""Vault path resolution shared by the REST routes and the AI tool layer. + +This is the single implementation of the anti path-traversal check. Routes map +:class:`ServiceError` to ``HTTPException`` and tools map it to +:class:`backend.tools.context.ToolError`. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from backend.services.errors import ServiceError + +logger = logging.getLogger("obsigate.services.paths") + + +def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: + """Resolve a vault-relative path, rejecting traversal outside the vault. + + Raises: + ServiceError: ``path_error`` (500) when the path cannot be resolved, + ``path_outside_vault`` (403) when it escapes the vault root. + """ + full_path = vault_root / (relative_path or "") + try: + resolved = full_path.resolve(strict=False) + root = vault_root.resolve(strict=False) + except Exception as e: + logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}") + raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e + + try: + resolved.relative_to(root) + except ValueError: + # Case-insensitive fallback for Windows / Docker path casing. + if not str(resolved).lower().startswith(str(root).lower()): + logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}") + raise ServiceError( + "Access denied: path outside vault", + code="path_outside_vault", + status=403, + details={"path": relative_path}, + ) from None + return resolved diff --git a/backend/services/search.py b/backend/services/search.py new file mode 100644 index 0000000..fc09805 --- /dev/null +++ b/backend/services/search.py @@ -0,0 +1,41 @@ +"""Search services shared by REST routes and the AI tool layer.""" + +from __future__ import annotations + +from typing import Any + + +def search_vaults( + q: str, + vault: str = "all", + tag: str | None = None, + limit: int = 50, + offset: int = 0, +) -> dict[str, Any]: + """Full-text search with pagination, returned as the API response payload. + + No permission filtering is applied here: callers that need it (the tool + layer) filter the ``results`` list themselves. + """ + from backend.search import search + + all_results = search(q, vault_filter=vault, tag_filter=tag) + total = len(all_results) + page = all_results[offset: offset + limit] + return { + "query": q, + "vault_filter": vault, + "tag_filter": tag, + "count": len(page), + "total": total, + "offset": offset, + "limit": limit, + "results": page, + } + + +def list_tags(vault: str | None = None) -> dict[str, int]: + """Return tag → count, optionally restricted to a single vault.""" + from backend.search import get_all_tags + + return get_all_tags(vault_filter=vault) diff --git a/backend/services/vaults.py b/backend/services/vaults.py new file mode 100644 index 0000000..dd802a3 --- /dev/null +++ b/backend/services/vaults.py @@ -0,0 +1,102 @@ +"""Vault listing and directory browsing services. + +Single source of truth consumed by both the REST routes (``/api/vaults``, +``/api/browse/{vault}``) and the AI tool layer (``list_vaults``, +``list_directory``). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from backend.services.errors import ServiceError +from backend.services.paths import resolve_safe_path + + +def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]: + """Return the vaults *user* may access, with summary metadata.""" + from backend.auth.middleware import check_vault_access + from backend.indexer import index + + result: list[dict[str, Any]] = [] + for name, data in index.items(): + if not check_vault_access(name, user): + continue + result.append({ + "name": name, + "file_count": len(data.get("files", [])), + "tag_count": len(data.get("tags", {})), + "type": data.get("config", {}).get("type", "VAULT"), + }) + return result + + +def get_vault_root(vault_name: str) -> Path: + """Return the filesystem root of *vault_name* or raise ``not_found``.""" + from backend.indexer import get_vault_data + + data = get_vault_data(vault_name) + if not data: + raise ServiceError( + f"Vault '{vault_name}' not found", + code="not_found", + status=404, + details={"vault": vault_name}, + ) + return Path(data["path"]) + + +def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]: + """Return the direct children of a vault directory (directories first).""" + from backend.indexer import SUPPORTED_EXTENSIONS + from backend.vault_settings import get_vault_setting + + root = get_vault_root(vault_name) + target = resolve_safe_path(root, path) if path else root.resolve() + + if not target.exists(): + raise ServiceError( + f"Path not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + hide_hidden = (get_vault_setting(vault_name) or {}).get("hideHiddenFiles", False) + + items: list[dict[str, Any]] = [] + try: + for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + if hide_hidden and entry.name.startswith("."): + continue + rel = str(entry.relative_to(root)).replace("\\", "/") + if entry.is_dir(): + # Count only direct children (files and subdirs) for performance. + try: + file_count = sum( + 1 for child in entry.iterdir() + if (not hide_hidden or not child.name.startswith(".")) + and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile")) + or child.is_dir()) + ) + except PermissionError: + file_count = 0 + items.append({ + "name": entry.name, + "path": rel, + "type": "directory", + "children_count": file_count, + }) + elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"): + items.append({ + "name": entry.name, + "path": rel, + "type": "file", + "size": entry.stat().st_size, + "extension": entry.suffix.lower(), + }) + except PermissionError: + raise ServiceError("Permission denied", code="permission_denied", status=403) from None + + return {"vault": vault_name, "path": path, "items": items} diff --git a/backend/tools/context.py b/backend/tools/context.py index 54a7a61..3bc5342 100644 --- a/backend/tools/context.py +++ b/backend/tools/context.py @@ -15,6 +15,8 @@ from pathlib import Path from typing import Any from backend.auth.middleware import check_vault_access +from backend.services.errors import ServiceError +from backend.services.paths import resolve_safe_path as _resolve_service_path logger = logging.getLogger("obsigate.tools") @@ -138,32 +140,20 @@ class ToolContext: ) -def resolve_safe_path(vault_root: Path, relative_path: str) -> Path: +def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: """Resolve a vault-relative path, rejecting traversal outside the vault. - Mirrors ``backend.main._resolve_safe_path`` but raises a domain error - instead of ``HTTPException`` so the tool layer stays transport-agnostic. + Delegates to the shared :func:`backend.services.paths.resolve_safe_path` + and maps its :class:`ServiceError` to tool domain errors so the tool layer + stays transport-agnostic. Raises: ToolPermissionError: When the resolved path escapes the vault root. ToolError: When the path cannot be resolved. """ - full_path = vault_root / (relative_path or "") try: - resolved = full_path.resolve(strict=False) - root = vault_root.resolve(strict=False) - except Exception as e: - raise ToolError(f"Path resolution error: {e}", code="path_error") from e - - try: - resolved.relative_to(root) - except ValueError: - # Case-insensitive fallback for Windows / Docker path casing. - if not str(resolved).lower().startswith(str(root).lower()): - logger.warning(f"Path outside vault: vault={root}, requested={relative_path}") - raise ToolPermissionError( - "Access denied: path outside vault", - code="path_outside_vault", - details={"path": relative_path}, - ) from None - return resolved + return _resolve_service_path(vault_root, relative_path) + except ServiceError as e: + if e.code in ("path_outside_vault", "permission_denied"): + raise ToolPermissionError(e.message, code=e.code, details=e.details) from e + raise ToolError(e.message, code=e.code, details=e.details) from e diff --git a/backend/tools/registry.py b/backend/tools/registry.py index 7e441c6..28ebfec 100644 --- a/backend/tools/registry.py +++ b/backend/tools/registry.py @@ -18,6 +18,7 @@ from typing import Any from pydantic import BaseModel, ValidationError +from backend.services.errors import ServiceError from backend.tools.audit import log_tool_call from backend.tools.context import ( ToolConfirmationRequired, @@ -136,6 +137,17 @@ def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: b logger.debug(f"Tool audit failed for '{spec.name}': {e}") +def _map_service_error(e: ServiceError) -> ToolError: + """Map a shared-layer :class:`ServiceError` to a tool domain error.""" + if e.code == "not_found": + return ToolNotFoundError(e.message, details=e.details) + if e.code in ("permission_denied", "path_outside_vault", "vault_access_denied"): + return ToolPermissionError(e.message, code=e.code, details=e.details) + if e.code == "invalid_arguments": + return ToolValidationError(e.message, details=e.details) + return ToolError(e.message, code=e.code, details=e.details) + + def call_tool( name: str, ctx: ToolContext, @@ -191,6 +203,10 @@ def call_tool( except ToolError as e: _audit(ctx, spec, arguments, ok=False, error=e.code) raise + except ServiceError as e: + mapped = _map_service_error(e) + _audit(ctx, spec, arguments, ok=False, error=mapped.code) + raise mapped from e except Exception as e: logger.error(f"Tool '{name}' failed: {e}") exec_error = ToolError(f"Tool '{name}' failed: {e}", code="tool_execution_error") diff --git a/backend/tools/service.py b/backend/tools/service.py index 3ac5157..8fd4423 100644 --- a/backend/tools/service.py +++ b/backend/tools/service.py @@ -1,24 +1,21 @@ """Built-in tool services (Phase 0 — read/search). These functions are the single source of truth consumed by both the in-app -assistant and the MCP server. They delegate to the existing core modules -(``backend.indexer``, ``backend.search``) rather than duplicating route logic. +assistant and the MCP server. They delegate to the shared business-logic +services (``backend.services``) so routes and tools never diverge. Mutating tools (create/edit/rename/move/delete) are added in later phases. """ from __future__ import annotations import logging -from pathlib import Path from typing import Any -from backend.tools.context import ( - ToolContext, - ToolError, - ToolNotFoundError, - ToolRisk, - resolve_safe_path, -) +from backend.services.files import read_file_text +from backend.services.search import list_tags as _list_tags +from backend.services.search import search_vaults +from backend.services.vaults import browse_directory, list_accessible_vaults +from backend.tools.context import ToolContext, ToolRisk from backend.tools.registry import tool from backend.tools.schemas import ( ListDirectoryInput, @@ -34,15 +31,6 @@ logger = logging.getLogger("obsigate.tools.service") TOOL_MAX_READ_BYTES = 200_000 -def _vault_data_or_raise(vault: str) -> dict[str, Any]: - from backend.indexer import get_vault_data - - data = get_vault_data(vault) - if not data: - raise ToolNotFoundError(f"Vault '{vault}' not found", details={"vault": vault}) - return data - - @tool( name="list_vaults", description="List the vaults the current user is allowed to access.", @@ -51,15 +39,10 @@ def _vault_data_or_raise(vault: str) -> dict[str, Any]: ) def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]: """Return accessible vaults with a file count.""" - from backend.indexer import get_vault_data, get_vault_names - - vaults: list[dict[str, Any]] = [] - for name in get_vault_names(): - if not ctx.has_vault_access(name): - continue - data = get_vault_data(name) or {} - vaults.append({"name": name, "file_count": len(data.get("files", []))}) - return vaults + return [ + {"name": v["name"], "file_count": v["file_count"]} + for v in list_accessible_vaults(ctx.user) + ] @tool( @@ -71,28 +54,11 @@ def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, An ) def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]: """Return the entries of a vault directory (direct children only).""" - data = _vault_data_or_raise(params.vault) - root = Path(data["path"]) - target = resolve_safe_path(root, params.path) if params.path else root.resolve() - - if not target.exists() or not target.is_dir(): - raise ToolNotFoundError(f"Directory not found: {params.path}", details={"vault": params.vault, "path": params.path}) - - from backend.vault_settings import get_vault_setting - - hide_hidden = (get_vault_setting(params.vault) or {}).get("hideHiddenFiles", False) - - items: list[dict[str, Any]] = [] - for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): - if hide_hidden and entry.name.startswith("."): - continue - rel = str(entry.relative_to(root)).replace("\\", "/") - items.append({ - "name": entry.name, - "path": rel, - "type": "directory" if entry.is_dir() else "file", - }) - return items + data = browse_directory(params.vault, params.path) + return [ + {"name": item["name"], "path": item["path"], "type": item["type"]} + for item in data["items"] + ] @tool( @@ -104,27 +70,12 @@ def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[st ) def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]: """Return the (redacted) content of a vault file.""" - data = _vault_data_or_raise(params.vault) - root = Path(data["path"]) - target = resolve_safe_path(root, params.path) - - if not target.exists() or not target.is_file(): - raise ToolNotFoundError(f"File not found: {params.path}", details={"vault": params.vault, "path": params.path}) - - size = target.stat().st_size - if size > TOOL_MAX_READ_BYTES: - raise ToolError( - f"File too large ({size} bytes > {TOOL_MAX_READ_BYTES})", - code="file_too_large", - details={"vault": params.vault, "path": params.path, "size": size}, - ) - - content = target.read_text(encoding="utf-8", errors="replace") - - from backend.secret_redactor import redact_file_content - - content = redact_file_content(content, params.path) - return {"vault": params.vault, "path": params.path, "size": size, "content": content} + return read_file_text( + params.vault, + params.path, + redact=True, + max_bytes=TOOL_MAX_READ_BYTES, + ) @tool( @@ -135,10 +86,8 @@ def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]: ) def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[str, Any]]: """Return ranked search results, filtered to accessible vaults.""" - from backend.search import search - - results = search(params.q, vault_filter=params.vault, tag_filter=params.tag, limit=params.limit) - return [r for r in results if ctx.has_vault_access(r["vault"])] + payload = search_vaults(params.q, vault=params.vault, tag=params.tag, limit=params.limit) + return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])] @tool( @@ -149,11 +98,9 @@ def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[ ) def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]: """Return tags sorted by descending count.""" - from backend.search import get_all_tags - if params.vault and params.vault != "all": ctx.require_vault_access(params.vault) - return [{"tag": tag, "count": count} for tag, count in get_all_tags(params.vault).items()] + return [{"tag": tag, "count": count} for tag, count in _list_tags(params.vault).items()] from backend.indexer import get_vault_names @@ -161,6 +108,6 @@ def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]: for name in get_vault_names(): if not ctx.has_vault_access(name): continue - for tag, count in get_all_tags(name).items(): + for tag, count in _list_tags(name).items(): merged[tag] = merged.get(tag, 0) + count return [{"tag": tag, "count": count} for tag, count in sorted(merged.items(), key=lambda x: -x[1])] diff --git a/docs/AI_ARCHITECTURE_GUIDE.md b/docs/AI_ARCHITECTURE_GUIDE.md index 7102a59..0bcb237 100644 --- a/docs/AI_ARCHITECTURE_GUIDE.md +++ b/docs/AI_ARCHITECTURE_GUIDE.md @@ -82,7 +82,7 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même 1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes. 2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle. -3. **SSE non réellement streaming** : la réponse complète est envoyée en un seul événement — `backend/bookslm_routes.py:211`. +3. **SSE** : ~~non réellement streaming~~ → **corrigé** (B4) : `ai_chat.stream_completion` alimente `/api/ai/bookslm/chat` token par token ; le tool-calling (`/agent`) reste non-streaming (les appels d'outils exigent la réponse complète). 4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI). --- @@ -93,6 +93,22 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services. +**Nouveau module `backend/services/` (A2 — lecture/recherche) :** + +``` +backend/services/ +├── errors.py # ServiceError (code + status HTTP) +├── paths.py # resolve_safe_path (anti path-traversal, source unique) +├── vaults.py # list_accessible_vaults, browse_directory, get_vault_root +├── files.py # read_raw_file, read_file_text (redaction + quota) +└── search.py # search_vaults (pagination), list_tags +``` + +Les routes `/api/vaults`, `/api/browse/{vault}`, `/api/file/{vault}/raw`, `/api/search` et +`/api/tags` en sont de simples wrappers, tout comme les outils `list_vaults`, `list_directory`, +`read_file`, `search_fulltext` et `list_tags`. Un `ServiceError` est traduit en `HTTPException` +(handler global de `backend/main.py`) ou en `ToolError` (`backend/tools/registry.py`). + **Nouveau module `backend/tools/` :** ``` @@ -129,7 +145,9 @@ boucle (max N itérations): ``` - Fallback protocole texte (`obsigate-action`) si le modèle ne supporte pas les tools. -- SSE réellement streaming. +- SSE réellement streaming sur `/chat` (`ai_chat.stream_completion`) ; `/agent` reste buffered + (les appels d'outils exigent la réponse complète avant exécution). Confirmations UI en deux temps + (`confirmation` → `confirm`/`confirm_messages`). ### 3.4 Couche 4 — Serveur MCP @@ -288,7 +306,8 @@ Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités. ## 9. Références - `backend/ai.py`, `backend/ai_routes.py` — couche fournisseurs + actions éditeur -- `backend/ai_chat.py` — chat completion provider-agnostique avec tool calling (OpenAI-compat + Gemini) +- `backend/services/` — logique métier partagée (vaults, files, search) consommée par les routes et les outils +- `backend/ai_chat.py` — chat completion provider-agnostique avec tool calling et streaming (OpenAI-compat + Gemini) - `backend/agent/loop.py` — agent loop in-app (multi-étapes, LLM injectable) - `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel (+ endpoint `/agent`) - `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 23695c0..d211f38 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,11 +43,9 @@ ### 79. Assistant IA — Outils (function calling) & serveur MCP - **Effort :** 10-15 jours | **Impact :** 🟡 -- **Statut :** 🔵 Phase 0 + B1/B2/B3/B7 + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) +- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) - **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**. - **Reste à faire :** - - [ ] **A2.** Extraire la logique métier des routes `backend/main.py` en services réutilisables (partiel) - - [ ] **B4.** SSE réellement streaming ; **B5.** confirmations UI (carte Apply, diff) ; **B6.** outils navigation `open_file`/`reveal_in_tree` - [ ] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags) - [ ] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step - [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault) @@ -182,7 +180,7 @@ |---|---|---| | ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours | -| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours | +| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours | | ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | | **Total restant** | **6 items + finitions** | **~27-39 jours** | diff --git a/docs/features/ai-tools-mcp.md b/docs/features/ai-tools-mcp.md index 3260bfc..268492a 100644 --- a/docs/features/ai-tools-mcp.md +++ b/docs/features/ai-tools-mcp.md @@ -1,6 +1,6 @@ # #79 — Assistant IA — Outils (function calling) & serveur MCP -> **Statut :** 🔵 En cours — Phase 0 + B1/B2/B3/B6/B7 + G livrés (2026-09-11) +> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + G livrés (2026-09-11) > **Effort :** 10-15 jours | **Impact :** 🟡 > **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md) @@ -9,17 +9,17 @@ ## A. Fondations — couche d'outils partagée (2-3 jours) — ✅ livré (2026-09-11) - [x] **A1.** Créer `backend/tools/` : `context.py` (`ToolContext` : user, allowed_vaults, mode, confirmed), `registry.py` (décorateur `@tool` + schéma JSON), `schemas.py`, `service.py`, `audit.py` - - [ ] **A2.** Extraire la logique métier des routes de `backend/main.py` en fonctions de service réutilisables (les routes deviennent des wrappers) — *partiel : services lecture/recherche livrés (`list_vaults`, `list_directory`, `read_file`, `search_fulltext`, `list_tags`) ; refactor des routes `main.py` à suivre* + - [x] **A2.** Extraire la logique métier des routes de `backend/main.py` en fonctions de service réutilisables (les routes deviennent des wrappers) — `backend/services/` (`errors.py`, `paths.py`, `vaults.py`, `files.py`, `search.py`) ; routes `/api/vaults`, `/api/browse`, `/api/file/{vault}/raw`, `/api/search`, `/api/tags` et outils `list_vaults`/`list_directory`/`read_file`/`search_fulltext`/`list_tags` délèguent à la même couche. Les `ServiceError` sont mappées vers `HTTPException` (routes) et `ToolError` (outils). - [x] **A3.** Contexte de permissions : chaque outil applique `check_vault_access` + `resolve_safe_path` - [x] **A4.** Audit : journalisation JSONL de chaque appel d'outil (qui, quoi, vault, résultat) — action `ai_tool_call`, arguments sensibles résumés - [x] **A5.** Tests unitaires du registry + services (sans IA) — `tests/test_tools.py` (30 tests) -## B. Function calling in-app (3-4 jours) — 🔵 partiel (B1/B2/B3/B7 livrés 2026-09-11) +## B. Function calling in-app (3-4 jours) — ✅ livré (2026-09-11) - [x] **B1.** Abstraction tool-calling provider-agnostique : `backend/ai_chat.py` (`chat_completion`, `ToolCall`, `LLMResponse`) — OpenAI-compat (`tools`/`tool_choice`, parsing `tool_calls`) + Gemini (`functionDeclarations`/`functionCall`) - [x] **B2.** Agent loop `backend/agent/loop.py` : boucle tool→résultat→tool, limite d'itérations (10), truncation des résultats ; endpoint opt-in `POST /api/ai/bookslm/agent` (events SSE `tool`/`message`/`confirmation`) - [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend - - [ ] **B4.** SSE réellement streaming (corriger `bookslm_routes.py` — le message final reste envoyé en un seul événement) - - [ ] **B5.** Confirmations UI : outils `read` auto, outils `write` via carte Apply (`bookslm.js:515`), aperçu diff pour `edit_file` + - [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM. + - [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.* - [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md)) - [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`) diff --git a/frontend/index.html b/frontend/index.html index d67b3c3..57f033a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4203,6 +4203,11 @@ (recharger ou supprimer) ; « + » démarre une nouvelle conversation. +
  • + Le bouton « mode agent » active les outils (lire, lister, + chercher) ; les actions de modification demandent une + confirmation avec aperçu des changements. +
  • diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 1a11b8e..df16606 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -74,6 +74,36 @@ class BooksLM { this._sessions = []; this._currentSessionId = null; this._pendingNewSession = false; + // Agent mode: routes chat through /api/ai/bookslm/agent so the model can + // call read/search tools and propose mutations (confirmation cards). + this._agentMode = this._readAgentMode(); + } + + _readAgentMode() { + try { + return localStorage.getItem('obsigate-bookslm-agent') === 'true'; + } catch { + return false; + } + } + + _toggleAgentMode() { + this._agentMode = !this._agentMode; + try { + localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false'); + } catch { /* private mode */ } + this._updateAgentToggle(); + } + + _updateAgentToggle() { + if (!this._panel) return; + const btn = this._panel.querySelector('.bookslm-btn-agent'); + if (!btn) return; + btn.classList.toggle('active', this._agentMode); + const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off'); + btn.title = label; + btn.setAttribute('aria-label', label); + btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false'); } // ── Public API ────────────────────────────────────────────────────── @@ -376,6 +406,7 @@ class BooksLM {
    + @@ -409,6 +440,7 @@ class BooksLM { panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close()); panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation()); + panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode()); panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => { e.stopPropagation(); this._toggleHistoryMenu(); @@ -567,12 +599,18 @@ class BooksLM { if (msg.role === 'assistant') { const { text, actions } = this._extractActions(msg.content || ''); bubble.innerHTML = this._renderMarkdown(text); + if (msg.toolCalls && msg.toolCalls.length) { + bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild); + } if (actions.length) { const actionsDiv = document.createElement('div'); actionsDiv.className = 'bookslm-actions'; actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action))); bubble.appendChild(actionsDiv); } + if (msg.confirmation) { + bubble.appendChild(this._renderConfirmationCard(msg)); + } if (msg.sources && msg.sources.length) { const sourcesDiv = document.createElement('div'); sourcesDiv.className = 'bookslm-sources'; @@ -673,6 +711,172 @@ class BooksLM { } catch { /* tree refresh is best-effort */ } } + // ── Tool activity & confirmations (agent mode) ────────────────────── + + _renderToolActivity(toolCalls) { + const wrap = document.createElement('div'); + wrap.className = 'bookslm-tool-trace'; + for (const call of toolCalls) { + const line = document.createElement('div'); + line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : ''); + line.textContent = `${call.ok === false ? '⚠' : '🔧'} ${t('ai.tool_call', { name: call.name })}`; + if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name }); + wrap.appendChild(line); + } + return wrap; + } + + _renderConfirmationCard(msg) { + const conf = msg.confirmation; + const pending = conf.pending || {}; + const error = pending.error || pending; + const tool = error.tool || 'action'; + const args = error.arguments || {}; + + const card = document.createElement('div'); + card.className = 'bookslm-action bookslm-confirm'; + + const meta = document.createElement('div'); + meta.className = 'bookslm-action-meta'; + meta.innerHTML = '🔒'; + const textEl = document.createElement('span'); + textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : ''); + meta.appendChild(textEl); + card.appendChild(meta); + + const diffHost = document.createElement('div'); + diffHost.className = 'bookslm-confirm-diff'; + if (conf._diffHtml) { + diffHost.innerHTML = conf._diffHtml; + } else if (!conf._diffLoading) { + conf._diffLoading = true; + this._fillConfirmationDiff(args, conf).then(() => this._renderMessages()); + } + card.appendChild(diffHost); + + const apply = document.createElement('button'); + apply.className = 'bookslm-action-apply'; + apply.textContent = t('ai.action_apply'); + apply.addEventListener('click', async () => { + apply.disabled = true; + apply.textContent = t('ai.action_applying'); + try { + await this._applyConfirmation(msg); + apply.textContent = t('ai.action_applied'); + } catch (e) { + apply.disabled = false; + apply.textContent = t('ai.action_apply'); + showToast(t('ai.action_failed', { error: e.message }), 'error'); + } + }); + card.appendChild(apply); + return card; + } + + async _fillConfirmationDiff(args, conf) { + const proposed = args.content; + if (typeof proposed !== 'string' || !args.vault || !args.path) return; + let current = null; + try { + current = await this._fetchCurrentContent(args.vault, args.path); + } catch { + current = null; + } + const title = `
    ${this._escapeHtml(t('ai.diff_preview'))}
    `; + if (current == null) { + // New file (or unreadable): show everything as an addition. + conf._diffHtml = title + `
    ${this._renderDiffLines([{ t: '+', s: proposed }])}
    `; + return; + } + const lines = this._lineDiff(current, proposed); + conf._diffHtml = title + `
    ${this._renderDiffLines(lines)}
    `; + } + + async _fetchCurrentContent(vault, path) { + const data = await api(`/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`); + return data && typeof data.raw === 'string' ? data.raw : ''; + } + + /** Minimal line diff (LCS) between the current and the proposed content. */ + _lineDiff(oldText, newText) { + const a = String(oldText || '').split('\n'); + const b = String(newText || '').split('\n'); + const n = a.length; + const m = b.length; + const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const out = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { out.push({ t: ' ', s: a[i] }); i++; j++; } + else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: '-', s: a[i] }); i++; } + else { out.push({ t: '+', s: b[j] }); j++; } + } + while (i < n) out.push({ t: '-', s: a[i++] }); + while (j < m) out.push({ t: '+', s: b[j++] }); + return out; + } + + _renderDiffLines(lines) { + const cap = 400; + const shown = lines.length > cap ? lines.slice(0, cap) : lines; + let html = shown.map((l) => { + const cls = l.t === '+' ? 'bookslm-diff-add' : (l.t === '-' ? 'bookslm-diff-del' : 'bookslm-diff-ctx'); + return `${this._escapeHtml(l.t + ' ' + l.s)}`; + }).join('\n'); + if (lines.length > cap) { + html += `\n… ${lines.length - cap}`; + } + return html; + } + + async _applyConfirmation(msg) { + const conf = msg.confirmation; + if (!conf || conf._applying) return; + conf._applying = true; + + const payload = { + ...(msg.payload || {}), + confirm: conf.pending, + confirm_messages: conf.messages, + }; + + this._isLoading = true; + this._abortCtrl = new AbortController(); + const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send'); + if (sendBtn) sendBtn.disabled = true; + + const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: null }; + try { + let resp = await this._postChat(payload); + if (resp.status === 401 && AuthManager._authEnabled) { + await AuthManager.refreshAccessToken(); + resp = await this._postChat(payload); + } + if (!resp.ok) { + let detail = `HTTP ${resp.status}`; + try { detail = (await resp.json()).detail || detail; } catch { /* */ } + throw new Error(detail); + } + // The confirmation is resolved: drop the card and show the continuation. + msg.confirmation = null; + this._messages.push(continuation); + await this._streamResponse(resp, continuation, payload); + } finally { + conf._applying = false; + this._isLoading = false; + this._abortCtrl = null; + if (sendBtn) sendBtn.disabled = false; + this._renderMessages(); + this._saveHistory(); + } + } + // ── Messaging ─────────────────────────────────────────────────────── async _sendMessage() { @@ -694,7 +898,26 @@ class BooksLM { this._messages.push({ role: 'user', content: text }); this._renderMessages(); - const assistantMsg = { role: 'assistant', content: '', sources: [] }; + let provider = null; + let model = null; + try { + const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); + provider = picker.provider || null; + model = picker.model || null; + } catch { /* */ } + + const payload = { + mode: this._mode, + vault: this._vault, + directory: this._directory, + context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], + message: text, + conversation_history: history, + provider, + model, + }; + + const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload }; this._messages.push(assistantMsg); this._renderMessages(); this._isLoading = true; @@ -705,39 +928,10 @@ class BooksLM { this._abortCtrl = new AbortController(); try { - let provider = null; - let model = null; - try { - const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); - provider = picker.provider || null; - model = picker.model || null; - } catch { /* */ } - - const payload = { - mode: this._mode, - vault: this._vault, - directory: this._directory, - context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], - message: text, - conversation_history: history, - provider, - model, - }; - - const doFetch = () => { - const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) }; - return fetch('/api/ai/bookslm/chat', { - method: 'POST', - headers, - credentials: 'include', - body: JSON.stringify(payload), - signal: this._abortCtrl.signal, - }); - }; - let resp = await doFetch(); + let resp = await this._postChat(payload); if (resp.status === 401 && AuthManager._authEnabled) { await AuthManager.refreshAccessToken(); - resp = await doFetch(); + resp = await this._postChat(payload); } if (!resp.ok) { let detail = `HTTP ${resp.status}`; @@ -745,46 +939,7 @@ class BooksLM { throw new Error(detail); } - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let currentEvent = 'message'; - - const handleLine = (line) => { - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - return; - } - if (!line.startsWith('data:')) return; - const raw = line.slice(5).trim(); - if (!raw || raw === '[DONE]') return; - let data; - try { - data = JSON.parse(raw); - } catch { - assistantMsg.content += raw; - return; - } - if (currentEvent === 'error' || data.error) { - const msg = data.error || t('bookslm.error'); - assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg; - return; - } - const chunk = data.token || data.content || ''; - if (chunk) assistantMsg.content += chunk; - if (data.sources) assistantMsg.sources = data.sources; - }; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) handleLine(line); - this._renderMessages(); - } - if (buffer) handleLine(buffer); + await this._streamResponse(resp, assistantMsg, payload); if (!assistantMsg.sources.length && this._contextFiles.length) { const contentLower = assistantMsg.content.toLowerCase(); @@ -809,6 +964,78 @@ class BooksLM { this._saveHistory(); } + /** POST to /chat or /agent depending on the agent-mode toggle. */ + _postChat(payload) { + const endpoint = this._agentMode ? '/api/ai/bookslm/agent' : '/api/ai/bookslm/chat'; + const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) }; + return fetch(endpoint, { + method: 'POST', + headers, + credentials: 'include', + body: JSON.stringify(payload), + signal: this._abortCtrl ? this._abortCtrl.signal : undefined, + }); + } + + /** + * Consume an SSE response, feeding tokens/tool/confirmation events into + * `assistantMsg`. Shared by the initial send and the confirmation resume. + */ + async _streamResponse(resp, assistantMsg, payload) { + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let currentEvent = 'message'; + + const handleLine = (line) => { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + return; + } + if (!line.startsWith('data:')) return; + const raw = line.slice(5).trim(); + if (!raw || raw === '[DONE]') return; + let data; + try { + data = JSON.parse(raw); + } catch { + assistantMsg.content += raw; + return; + } + + if (currentEvent === 'tool') { + assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} }); + return; + } + if (currentEvent === 'confirmation') { + assistantMsg.confirmation = { + pending: data.pending || data, + messages: data.messages || [], + }; + return; + } + if (currentEvent === 'error' || data.error) { + const msg = data.error || t('bookslm.error'); + assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg; + return; + } + const chunk = data.token || data.content || ''; + if (chunk) assistantMsg.content += chunk; + if (data.sources) assistantMsg.sources = data.sources; + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) handleLine(line); + this._renderMessages(); + } + if (buffer) handleLine(buffer); + } + // ── Markdown (self-contained renderer) ────────────────────────────── // // Renders the assistant answer as formatted HTML (headings, lists, tables, @@ -1167,14 +1394,20 @@ class BooksLM { } catch { /* quota / private mode */ } } + /** Strip transient (large) fields before persisting a conversation. */ + _persistableMessages() { + return this._messages.map(({ confirmation, payload, ...rest }) => rest); + } + _saveHistory() { + const persisted = this._persistableMessages(); const session = this._currentSession(); if (session) { - session.messages = this._messages; + session.messages = persisted; session.updatedAt = Date.now(); if (!session.title) session.title = this._deriveTitle(this._messages); } else { - const created = this._createSession(this._messages); + const created = this._createSession(persisted); created.title = this._deriveTitle(this._messages); this._sessions.unshift(created); this._currentSessionId = created.id; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index d007aff..fa22f3f 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -86,6 +86,8 @@ "ai.action_created_dir": "Folder created: {path}", "ai.action_created_file": "File created: {path}", "ai.action_failed": "Action failed: {error}", + "ai.agent_mode_off": "Agent mode off (read/search + actions)", + "ai.agent_mode_on": "Agent mode on (read/search tools + actions)", "ai.casual": "Casual tone", "ai.close": "Close assistant", "ai.completion_added": "AI: completion added", @@ -94,6 +96,7 @@ "ai.context_general_desc": "Questions about the app and file creation", "ai.continue": "Continue", "ai.custom_rewrite": "Custom rewrite", + "ai.diff_preview": "Change preview", "ai.done": "AI: text processed", "ai.edit": "Edit", "ai.error_invalid_key": "Invalid API key", @@ -149,6 +152,8 @@ "ai.to_list": "Convert to list", "ai.to_table": "Convert to table", "ai.tone_hint": "Changes the text tone", + "ai.tool_call": "Tool: {name}", + "ai.tool_call_failed": "Tool {name} failed", "ai.toolbar_edit": "Edit", "ai.toolbar_generate": "Generate", "ai.toolbar_rewrite": "Rewrite", @@ -1212,6 +1217,7 @@ "help.assistant_markdown": "Formatted answers: headings, lists, tables, quotes and code blocks.", "help.assistant_links": "Cited files and paths are links: click a file to open it, a folder to reveal it in the tree.", "help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.", + "help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.", "help.toolbar_section": "AI toolbar", "help.tree_section": "Vault tree", "help.use_cases": "Use cases", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index f64b53b..e0b77a8 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -86,6 +86,8 @@ "ai.action_created_dir": "Dossier créé : {path}", "ai.action_created_file": "Fichier créé : {path}", "ai.action_failed": "Échec de l'action : {error}", + "ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)", + "ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)", "ai.casual": "Ton décontracté", "ai.close": "Fermer l'assistant", "ai.completion_added": "AI: complétion ajoutée", @@ -94,6 +96,7 @@ "ai.context_general_desc": "Questions sur l'application et création de fichiers", "ai.continue": "Continuer", "ai.custom_rewrite": "Réécriture personnalisée", + "ai.diff_preview": "Aperçu des modifications", "ai.done": "AI: texte traité", "ai.edit": "Éditer", "ai.error_invalid_key": "clé API invalide", @@ -149,6 +152,8 @@ "ai.to_list": "Convertir en liste", "ai.to_table": "Convertir en tableau", "ai.tone_hint": "Change le ton du texte", + "ai.tool_call": "Outil : {name}", + "ai.tool_call_failed": "Outil {name} en échec", "ai.toolbar_edit": "Éditer", "ai.toolbar_generate": "Générer", "ai.toolbar_rewrite": "Réécrire", @@ -1212,6 +1217,7 @@ "help.assistant_markdown": "Réponses formatées : titres, listes, tableaux, citations et blocs de code.", "help.assistant_links": "Les fichiers et chemins cités sont des liens : cliquez sur un fichier pour l'ouvrir, sur un dossier pour le révéler dans l'arborescence.", "help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.", + "help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.", "help.toolbar_section": "Barre d'outils AI", "help.tree_section": "Arborescence des vaults", "help.use_cases": "Cas d'usage", diff --git a/frontend/style.css b/frontend/style.css index 330d39d..6b50057 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9222,6 +9222,21 @@ body.popup-mode .content-area { background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; } .bookslm-action-apply:disabled { opacity: 0.6; cursor: default; } .bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); } +/* Agent-mode toggle in the header. */ +.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); } +/* Tool-call trace shown above an assistant answer (agent mode). */ +.bookslm-tool-trace { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; } +.bookslm-tool-line { font-size: 11px; color: var(--text-secondary); } +.bookslm-tool-line.failed { color: #f87171; } +/* Mutation confirmation card (two-step propose/apply). */ +.bookslm-confirm { flex-wrap: wrap; } +.bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; } +.bookslm-diff-title { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; } +.bookslm-diff { background: rgba(0,0,0,0.25); border-radius: 6px; padding: 8px; + overflow-x: auto; font-size: 12px; line-height: 1.4; max-height: 240px; margin: 0; } +.bookslm-diff-add { color: #4ade80; display: block; } +.bookslm-diff-del { color: #f87171; display: block; } +.bookslm-diff-ctx { color: var(--text-secondary); display: block; } .bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; } .bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; } diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index b16fff4..d7b54fb 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -513,6 +513,100 @@ async function main() { assert.equal(b._messages[0].content, "Ancienne conversation"); }); + // ── 14. Agent mode & confirmations (B5) ── + await test("agent mode toggle persists and switches the endpoint", async () => { + localStorage.clear(); + const b = new BooksLM(); + assert.equal(b._agentMode, false, "agent mode off by default"); + b._toggleAgentMode(); + assert.equal(b._agentMode, true); + assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true"); + const b2 = new BooksLM(); + assert.equal(b2._agentMode, true, "persisted across instances"); + + let url = null; + globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; }; + b2._abortCtrl = null; + await b2._postChat({ message: "x" }); + assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent"); + b2._agentMode = false; + await b2._postChat({ message: "x" }); + assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat"); + localStorage.clear(); + }); + + await test("header exposes the agent-mode toggle", () => { + const b = new BooksLM(); + const panel = b._render(); + b._panel = panel; + document.body.appendChild(panel); + const btn = panel.querySelector(".bookslm-btn-agent"); + assert.ok(btn, "agent toggle button present"); + assert.equal(btn.getAttribute("aria-pressed"), "false"); + btn.click(); + assert.equal(btn.getAttribute("aria-pressed"), "true"); + panel.remove(); + localStorage.clear(); + }); + + await test("_lineDiff marks additions and deletions", () => { + const b = new BooksLM(); + const diff = b._lineDiff("a\nb\nc", "a\nB\nc\nd"); + const adds = diff.filter((l) => l.t === "+").map((l) => l.s); + const dels = diff.filter((l) => l.t === "-").map((l) => l.s); + assert.ok(dels.includes("b"), "removed line marked"); + assert.ok(adds.includes("B"), "changed line marked as addition"); + assert.ok(adds.includes("d"), "appended line marked"); + }); + + await test("confirmation card resumes the agent run with confirm payload", async () => { + localStorage.clear(); + const encoder = new TextEncoder(); + let posted = null; + const sse = + 'event: tool\ndata: {"name":"edit_file","ok":true,"arguments":{}}\n\n' + + 'event: message\ndata: {"token":"Fait."}\n\n' + + "event: done\ndata: {}\n\n"; + globalThis.fetch = async (url, opts) => { + if (String(url).includes("/api/ai/bookslm/agent")) { + posted = JSON.parse(opts.body); + const chunks = [encoder.encode(sse)]; + const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }) }; + return { ok: true, status: 200, body: { getReader: () => reader } }; + } + return { ok: true, status: 200, json: async () => ({ raw: "old content" }) }; + }; + + const b = new BooksLM(); + b._agentMode = true; + b._panel = b._render(); + document.body.appendChild(b._panel); + const msg = { + role: "assistant", + content: "", + sources: [], + toolCalls: [], + confirmation: { + pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "new content" }, id: "c1" } }, + messages: [{ role: "system", content: "s" }], + }, + payload: { message: "edit", vault: "V", mode: "general", conversation_history: [] }, + }; + b._messages = [msg]; + + await b._applyConfirmation(msg); + + assert.equal(msg.confirmation, null, "confirmation resolved"); + assert.ok(posted, "resumed via /agent"); + assert.equal(posted.confirm.error.tool, "edit_file"); + assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]); + const cont = b._messages[b._messages.length - 1]; + assert.equal(cont.content, "Fait."); + assert.equal(cont.toolCalls.length, 1, "tool trace captured"); + b._panel.remove(); + localStorage.clear(); + }); + // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) { diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index 4693ed8..04d8e88 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -186,6 +186,65 @@ class TestConfirmationAndLimits: # ═══════════════════════════════════════════════════════════════════ +class TestConfirmationResume: + @pytest.mark.asyncio + async def test_resume_applies_pending_and_continues(self, monkeypatch): + seen = {} + + def handler(ctx, params): + seen["ctx_confirmed"] = ctx.confirmed + return {"done": True} + + _register(monkeypatch, "_write", handler, risk=ToolRisk.WRITE) + + # First run pauses on the mutating tool. + llm1 = ScriptedLLM([LLMResponse(tool_calls=[ToolCall(id="1", name="_write", arguments={"x": 1})])]) + paused = await run_agent([{"role": "user", "content": "write"}], ctx=_ctx(), llm=llm1) + assert paused.stopped == STOP_CONFIRMATION_REQUIRED + assert paused.pending["error"]["id"] == "1" + + # Resume: the pending call is applied (one-shot confirm), then the loop + # continues and produces the final answer. + llm2 = ScriptedLLM([LLMResponse(content="applied")]) + resumed = await run_agent( + [{"role": "user", "content": "write"}], + ctx=_ctx(), + llm=llm2, + resume_messages=paused.messages, + confirm_pending=paused.pending, + ) + assert resumed.stopped == STOP_DONE + assert resumed.content == "applied" + assert len(resumed.tool_calls) == 1 + assert resumed.tool_calls[0].ok is True + # The one-shot confirmation must not leak into the context. + assert seen["ctx_confirmed"] is False + # The tool result is fed back to the model on the resumed turn. + tool_msgs = [m for m in llm2.calls[0]["messages"] if m.get("role") == "tool"] + assert len(tool_msgs) == 1 + assert tool_msgs[0]["tool_call_id"] == "1" + + @pytest.mark.asyncio + async def test_resume_without_assistant_message_reconstructs_it(self, monkeypatch): + _register(monkeypatch, "_write", lambda ctx, params: {"done": True}, risk=ToolRisk.WRITE) + llm = ScriptedLLM([LLMResponse(content="ok")]) + pending = {"error": {"tool": "_write", "arguments": {}, "id": "call_9"}} + result = await run_agent( + [{"role": "user", "content": "write"}], + ctx=_ctx(), + llm=llm, + resume_messages=[{"role": "user", "content": "write"}], + confirm_pending=pending, + ) + assert result.stopped == STOP_DONE + assistant_tool_msgs = [ + m for m in llm.calls[0]["messages"] + if m.get("role") == "assistant" and m.get("tool_calls") + ] + assert len(assistant_tool_msgs) == 1 + assert assistant_tool_msgs[0]["tool_calls"][0]["id"] == "call_9" + + class TestAgentPermissions: @pytest.mark.asyncio async def test_permission_denied_recorded(self, client): diff --git a/tests/test_ai_chat.py b/tests/test_ai_chat.py index 0ab153d..d72303b 100644 --- a/tests/test_ai_chat.py +++ b/tests/test_ai_chat.py @@ -199,3 +199,95 @@ class TestGeminiToolsConversion: converted = _gemini_tools(TOOLS) assert converted[0]["functionDeclarations"][0]["name"] == "read_file" assert converted[0]["functionDeclarations"][0]["description"] == "Read a file" + + +# ═══════════════════════════════════════════════════════════════════ +# Streaming (B4) +# ═══════════════════════════════════════════════════════════════════ + + +class _StreamResponse: + """Fake httpx streaming response yielding pre-scripted SSE lines.""" + + def __init__(self, lines, status=200): + self.lines = lines + self.status_code = status + + def raise_for_status(self): + if self.status_code >= 400: + raise _http_error(self.status_code) + + async def aiter_lines(self): + for line in self.lines: + yield line + + +class _FakeAsyncClient: + """Minimal stand-in for ``httpx.AsyncClient`` used in stream mode.""" + + captured: dict = {} + response: _StreamResponse | None = None + + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def stream(self, method, url, headers=None, json=None): + _FakeAsyncClient.captured = {"method": method, "url": url, "headers": headers, "json": json} + resp = _FakeAsyncClient.response + + class _Ctx: + async def __aenter__(self_inner): + return resp + + async def __aexit__(self_inner, *exc): + return False + + return _Ctx() + + +class TestStreamCompletion: + @pytest.mark.asyncio + async def test_openai_stream_yields_deltas(self, monkeypatch): + monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG) + monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient) + _FakeAsyncClient.response = _StreamResponse([ + 'data: {"choices":[{"delta":{"content":"Bon"}}]}', + "", + 'data: {"choices":[{"delta":{"content":"jour"}}]}', + "data: not-json", + "data: [DONE]", + ]) + + chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])] + assert "".join(chunks) == "Bonjour" + assert _FakeAsyncClient.captured["json"]["stream"] is True + + @pytest.mark.asyncio + async def test_gemini_stream_yields_text(self, monkeypatch): + monkeypatch.setitem(ai_chat.PROVIDERS, "gemini", GEMINI_CFG) + monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: GEMINI_CFG) + monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient) + _FakeAsyncClient.response = _StreamResponse([ + 'data: {"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}', + 'data: {"candidates":[{"content":{"parts":[{"text":"lut"}]}}]}', + ]) + + chunks = [c async for c in ai_chat.stream_completion([{"role": "user", "content": "hi"}])] + assert "".join(chunks) == "salut" + assert "streamGenerateContent" in _FakeAsyncClient.captured["url"] + + @pytest.mark.asyncio + async def test_stream_raises_on_http_error(self, monkeypatch): + monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG) + monkeypatch.setattr(ai_chat.httpx, "AsyncClient", _FakeAsyncClient) + _FakeAsyncClient.response = _StreamResponse([], status=500) + + with pytest.raises(httpx.HTTPStatusError): + async for _ in ai_chat.stream_completion([{"role": "user", "content": "hi"}]): + pass diff --git a/tests/test_api_main.py b/tests/test_api_main.py index 69d3250..8ad6a9d 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -621,13 +621,15 @@ class TestResolveSafePath: def test_resolve_traversal_attempt(self): from backend.main import _resolve_safe_path + from backend.services.errors import ServiceError from pathlib import Path root = Path("/vaults/TestVault") try: _resolve_safe_path(root, "../../etc/passwd") assert False, "Should have raised" - except Exception as e: - assert "traversal" in str(e).lower() or "403" in str(e) + except ServiceError as e: + assert e.status == 403 + assert e.code == "path_outside_vault" class TestBackupFile: diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index c10ba31..b5a2aa6 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -823,3 +823,65 @@ class TestBooksLMAgentEndpoint: assert resp.status_code == 200 assert "event: confirmation" in resp.text assert "_agent_write" in resp.text + + def test_agent_confirmation_resume(self, bookslm_client, monkeypatch): + import re + + import backend.bookslm_routes as routes + from backend.ai_chat import LLMResponse, ToolCall + from backend.tools import registry + from backend.tools.api import ToolRisk + from backend.tools.registry import ToolSpec + from backend.tools.schemas import ListVaultsInput + + executed = {} + + def handler(ctx, params): + executed["ran"] = True + return {"ok": True} + + spec = ToolSpec( + name="_agent_write_resume", + description="write for tests", + input_model=ListVaultsInput, + handler=handler, + risk=ToolRisk.WRITE, + ) + monkeypatch.setitem(registry._REGISTRY, "_agent_write_resume", spec) + + responses = [LLMResponse(tool_calls=[ToolCall(id="c1", name="_agent_write_resume", arguments={})])] + + async def fake_chat_completion(messages, **kwargs): + return responses.pop(0) + + monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) + monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") + + token, _ = _login_bookslm(bookslm_client) + payload = {"vault": "TestVault", "directory": "", "message": "crée un fichier", "mode": "directory"} + resp = bookslm_client.post( + "/api/ai/bookslm/agent", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + assert "event: confirmation" in resp.text + assert "_agent_write_resume" in resp.text + assert executed.get("ran") is not True + + match = re.search(r"event: confirmation\ndata: (.*)", resp.text) + assert match, resp.text + confirmation = json.loads(match.group(1)) + assert confirmation["pending"]["error"]["tool"] == "_agent_write_resume" + + # Resume: apply the pending call, then the model answers. + responses.append(LLMResponse(content="C'est fait.")) + resume_payload = dict(payload, confirm=confirmation["pending"], confirm_messages=confirmation["messages"]) + resp2 = bookslm_client.post( + "/api/ai/bookslm/agent", + json=resume_payload, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp2.status_code == 200 + assert executed.get("ran") is True + assert "C'est fait." in resp2.text + assert "event: message" in resp2.text diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..0843aae --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,154 @@ +# tests/test_services.py — Unit tests for the shared business-logic services (A2) +"""Tests for backend.services: vaults, files, search, and error mapping. + +The index-dependent tests reuse the ``client`` fixture from conftest, which +builds the in-memory index for a ``TestVault``. +""" + +from pathlib import Path + +import pytest + +from backend.services.errors import ServiceError +from backend.services.files import read_file_text, read_raw_file +from backend.services.search import list_tags, search_vaults +from backend.services.vaults import browse_directory, list_accessible_vaults + +ADMIN = {"username": "admin", "role": "admin", "vaults": ["*"]} +LIMITED = {"username": "limited", "vaults": ["OtherVault"]} + + +# ═══════════════════════════════════════════════════════════════════ +# Vaults +# ═══════════════════════════════════════════════════════════════════ + + +class TestListAccessibleVaults: + def test_admin_sees_vault(self, client): + names = {v["name"] for v in list_accessible_vaults(ADMIN)} + assert "TestVault" in names + + def test_limited_user_sees_none(self, client): + assert list_accessible_vaults(LIMITED) == [] + + def test_metadata_fields(self, client): + vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault") + assert set(vault) == {"name", "file_count", "tag_count", "type"} + assert vault["file_count"] >= 1 + + +class TestBrowseDirectory: + def test_root_lists_children(self, client): + data = browse_directory("TestVault", "") + names = {i["name"] for i in data["items"]} + assert "note1.md" in names + assert "Projets" in names + + def test_missing_path_raises_not_found(self, client): + with pytest.raises(ServiceError) as exc: + browse_directory("TestVault", "nope") + assert exc.value.code == "not_found" + + def test_unknown_vault_raises_not_found(self, client): + with pytest.raises(ServiceError) as exc: + browse_directory("MissingVault", "") + assert exc.value.code == "not_found" + + +# ═══════════════════════════════════════════════════════════════════ +# Files +# ═══════════════════════════════════════════════════════════════════ + + +class TestReadFile: + def test_read_text_redacts_secrets(self, client): + from backend.indexer import get_vault_data + + root = Path(get_vault_data("TestVault")["path"]) + secret = root / "svc_secret.md" + fake_jwt = "eyJ" + "a" * 30 + "." + "b" * 30 + "." + "c" * 30 + secret.write_text(f"token: {fake_jwt}\n", encoding="utf-8") + try: + data = read_file_text("TestVault", "svc_secret.md") + assert "[JWT MASQUÉ]" in data["content"] + finally: + secret.unlink() + + def test_read_text_too_large(self, client): + from backend.indexer import get_vault_data + + root = Path(get_vault_data("TestVault")["path"]) + big = root / "svc_big.txt" + big.write_text("x" * 1000, encoding="utf-8") + try: + with pytest.raises(ServiceError) as exc: + read_file_text("TestVault", "svc_big.txt", max_bytes=10) + assert exc.value.code == "file_too_large" + finally: + big.unlink() + + def test_read_raw_no_redaction(self, client): + data = read_raw_file("TestVault", "note1.md") + assert "Python" in data["raw"] + + def test_read_missing_raises_not_found(self, client): + with pytest.raises(ServiceError) as exc: + read_raw_file("TestVault", "missing.md") + assert exc.value.code == "not_found" + + def test_traversal_is_rejected(self, client): + with pytest.raises(ServiceError) as exc: + read_raw_file("TestVault", "../../etc/passwd") + assert exc.value.code == "path_outside_vault" + + +# ═══════════════════════════════════════════════════════════════════ +# Search +# ═══════════════════════════════════════════════════════════════════ + + +class TestSearchService: + def test_pagination(self, client): + data = search_vaults("Python", limit=1, offset=0) + assert data["limit"] == 1 + assert len(data["results"]) <= 1 + assert data["total"] >= 1 + + def test_returns_api_shape(self, client): + data = search_vaults("Python") + assert set(data) >= {"query", "vault_filter", "tag_filter", "count", "total", "offset", "limit", "results"} + + def test_tags(self, client): + tags = list_tags("TestVault") + assert "python" in tags + + +# ═══════════════════════════════════════════════════════════════════ +# Service → Tool error mapping +# ═══════════════════════════════════════════════════════════════════ + + +class TestServiceErrorMapping: + def test_not_found_maps_to_tool_not_found(self, client): + from backend.tools.api import ToolContext, ToolNotFoundError, call_tool + + ctx = ToolContext(user=ADMIN, audit_enabled=False) + with pytest.raises(ToolNotFoundError): + call_tool("read_file", ctx, {"vault": "TestVault", "path": "nope.md"}) + + def test_file_too_large_preserves_code(self, client, monkeypatch): + from backend.indexer import get_vault_data + from backend.tools import service as service_mod + from backend.tools.api import ToolContext, ToolError, call_tool + + monkeypatch.setattr(service_mod, "TOOL_MAX_READ_BYTES", 10) + root = Path(get_vault_data("TestVault")["path"]) + big = root / "svc_tool_big.txt" + big.write_text("x" * 1000, encoding="utf-8") + try: + ctx = ToolContext(user=ADMIN, audit_enabled=False) + with pytest.raises(ToolError) as exc: + call_tool("read_file", ctx, {"vault": "TestVault", "path": "svc_tool_big.txt"}) + assert exc.value.code == "file_too_large" + finally: + big.unlink()