"""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:; 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