feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:06:40 -04:00
parent c55e3e0cbc
commit 4c4e415975
27 changed files with 1465 additions and 386 deletions
+67 -2
View File
@@ -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()
+106
View File
@@ -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
+53 -60
View File
@@ -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(
+37 -136
View File
@@ -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)
+29
View File
@@ -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 {}
+86
View File
@@ -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}
+45
View File
@@ -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
+41
View File
@@ -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)
+102
View File
@@ -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}
+11 -21
View File
@@ -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
+16
View File
@@ -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")
+26 -79
View File
@@ -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])]