190 lines
7.7 KiB
Python
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
|