bifroest/app/tools/registry.py
2026-10-27 08:34:17 +01:00

190 lines
7.7 KiB
Python

"""Eingebaute Tools: Definition, Schema und Hash (→ Konzept 7.2, 7.4).
Argumente sind Pydantic-Modelle: daraus entsteht das JSON-Schema für das Modell,
und dieselben Modelle validieren jeden Aufruf serverseitig.
"""
import hashlib
import json
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date, datetime, time
from decimal import Decimal
from typing import Any
import jsonschema
from pydantic import BaseModel, ConfigDict
class ToolError(Exception):
"""Fehler, dessen Meldung das Modell sehen darf (z. B. Datei fehlt, Server nicht erreichbar)."""
class TableResult(str):
"""Text für das Modell plus die Tabelle dahinter, damit die Oberfläche das Ergebnis
als Tabelle, CSV und Diagramm zeigen kann (gespeichert am Tool-Aufruf)."""
columns: list[str]
rows: list[tuple[Any, ...]]
truncated: bool
def __new__(cls, text: str, columns: list[str], rows: list[tuple[Any, ...]], truncated: bool) -> "TableResult":
result = super().__new__(cls, text)
result.columns, result.rows, result.truncated = columns, rows, truncated
return result
def stored(self) -> dict[str, Any]:
return {"columns": self.columns, "rows": [[plain(v) for v in row] for row in self.rows],
"truncated": self.truncated}
def plain(value: Any) -> Any:
"""Zellwert JSON-tauglich; Zahlen bleiben Zahlen (für Diagramme), lange Texte gekürzt."""
if value is None or isinstance(value, bool | int | float):
return value
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime | date | time):
return value.isoformat()
if isinstance(value, bytes | bytearray | memoryview):
return f"<{len(value)} Bytes>"
return str(value)[:CELL_CHARS]
# Gespeicherte Zellen (Anzeige und CSV) werden auf so viele Zeichen gekürzt
CELL_CHARS = 1000
class Args(BaseModel):
# Unbekannte Felder abweisen, statt sie still zu ignorieren
model_config = ConfigDict(extra="forbid")
@dataclass(frozen=True)
class Proposal:
"""Vorbereitete Schreibaktion: unveränderlicher Snapshot für den Freigabeantrag (→ Konzept 10.4)."""
source_id: int | None
arguments: dict[str, Any] # geprüft und normalisiert
preview: str # aus den echten Parametern erzeugt, z. B. der Diff
preconditions: dict[str, Any] # z. B. Basis-Commit; vor der Ausführung erneut geprüft
summary: str = "" # vom Modell formuliert, nur als Zusatz
@dataclass(frozen=True)
class Tool:
"""run führt direkt aus (bei ALLOW). prepare und execute bilden den Weg über eine Freigabe:
prepare baut den Snapshot ohne Wirkung nach außen, execute wirkt erst nach der Freigabe.
Tools nur mit prepare laufen immer über einen Antrag; execute muss dort
idempotent sein, weil es beim Abgleich nach einem Absturz erneut aufgerufen wird."""
name: str
kind: str # Art der Quelle (git) bzw. mcp:<server>; nur angeboten, wenn im Workspace vorhanden
description: str
args: type[Args] | None
suggested_risk: str
run: Callable[[Any, Any], str] | None = None
prepare: Callable[[Any, Any], Proposal] | None = None
execute: Callable[[Any, Any], tuple[str, dict[str, Any]]] | None = None
# Fertiges JSON-Schema statt args, z. B. von einem MCP-Server
input_schema: dict[str, Any] | None = None
# Ausgabe enthält fremde Inhalte (Dateien, Daten, Dienste) und wird eingefasst (→ app.security.untrusted);
# False nur für Tools, deren Ausgabe die Plattform selbst formuliert (fremde Teile fassen sie selbst ein)
untrusted: bool = True
# MCP-Tools: Fingerabdruck über Dienst und Definition; gleiche Tools erben die Einstufung
fingerprint: str | None = None
@property
def writes(self) -> bool:
"""Nur über einen Antrag ausführbar."""
return self.run is None
@property
def approvable(self) -> bool:
"""Über einen Antrag ausführbar: mit eigenem prepare oder über den allgemeinen Weg für run-Tools."""
return self.prepare is not None or self.run is not None
def propose(self, context: Any, args: Any) -> Proposal:
"""Snapshot für den Antrag. Ohne eigenes prepare: die geprüften Argumente als Vorschau;
ausgeführt wird nach der Freigabe genau dieser Aufruf (run)."""
if self.prepare is not None:
return self.prepare(context, args)
arguments = args.model_dump(mode="json", exclude_none=True) if hasattr(args, "model_dump") else dict(args)
source = arguments.get("source")
source_id = context.source(self.kind, source).id if self.kind in SOURCE_KINDS else None
return Proposal(source_id=source_id, arguments=arguments,
preview=json.dumps(arguments, indent=2, ensure_ascii=False), preconditions={})
def carry_out(self, context: Any, snapshot: dict[str, Any]) -> tuple[str, dict[str, Any]]:
"""Nach der Freigabe ausführen; allgemeiner Weg: run mit den freigegebenen Argumenten."""
if self.execute is not None:
return self.execute(context, snapshot)
arguments = dict(snapshot["arguments"])
if snapshot.get("source_id") is not None:
# Quelle kann umbenannt worden sein: maßgeblich ist die ID im Antrag
arguments["source"] = context.by_id(snapshot["source_id"]).name
return self.run(context, self.validate(arguments)), {}
@property
def schema(self) -> dict[str, Any]:
if self.input_schema is not None:
return self.input_schema
return clean(self.args.model_json_schema())
def validate(self, arguments: dict[str, Any]) -> Any:
"""Argumente serverseitig prüfen (→ Konzept 7.2); Fehler als ValueError mit lesbarer Meldung."""
if self.args is not None:
return self.args.model_validate(arguments)
try:
jsonschema.validate(arguments, self.schema)
except jsonschema.ValidationError as e:
where = ".".join(str(p) for p in e.absolute_path) or "Argumente"
raise ToolError(f"Ungültige Argumente: {where}: {e.message[:300]}") from e
except jsonschema.SchemaError as e:
raise ToolError("Das Tool hat ein ungültiges Schema.") from e
return arguments
@property
def hash(self) -> str:
definition = json.dumps({"name": self.name, "description": self.description, "schema": self.schema},
sort_keys=True, ensure_ascii=False)
return hashlib.sha256(definition.encode()).hexdigest()
def function(self) -> dict[str, Any]:
"""Function Tool im Format der Responses API."""
return {"type": "function", "name": self.name, "description": self.description,
"parameters": self.schema, "strict": False}
def clean(schema: Any, properties: bool = False) -> Any:
"""Von Pydantic erzeugte Titel entfernen (unnötige Tokens, keine Bedeutung für das Modell).
Unter "properties" sind die Schlüssel Feldnamen: ein Feld namens title bleibt erhalten.
"""
if isinstance(schema, dict):
return {k: clean(v, properties=k == "properties" and not properties) for k, v in schema.items()
if properties or k != "title"}
if isinstance(schema, list):
return [clean(v) for v in schema]
return schema
TOOLS: dict[str, Tool] = {}
# Quellenarten, deren Tools ein Argument source haben (für Regeln je Quelle)
SOURCE_KINDS = ("git",)
def register(tool: Tool) -> Tool:
TOOLS[tool.name] = tool
return tool
def load() -> dict[str, Tool]:
"""Alle eingebauten Tools registrieren (Import mit Nebenwirkung, einmalig)."""
import app.changes.tools
import app.chat.plan
import app.data_sources.git_tools
import app.documents.tools
import app.hugin.tools
import app.uploads.tools # noqa: F401
return TOOLS