147 lines
7.2 KiB
Python
147 lines
7.2 KiB
Python
"""Einen Tool-Aufruf des Modells prüfen, ausführen und protokollieren (→ Konzept 7.2, 7.8, 8.3).
|
|
|
|
Das Ergebnis geht als Text an das Modell zurück, auch bei Ablehnung oder Fehler,
|
|
damit es reagieren kann. Interne Fehlerdetails gehen nicht an das Modell.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from app import audit
|
|
from app.approvals import service as approvals
|
|
from app.core import metrics
|
|
from app.core.database import Database
|
|
from app.core.errors import Invalid
|
|
from app.security import untrusted
|
|
from app.tools import service as tools
|
|
from app.tools.context import ToolContext
|
|
from app.tools.models import DENY, REQUIRE_APPROVAL, ToolCall
|
|
from app.tools.registry import SOURCE_KINDS, TableResult, ToolError
|
|
from app.workspaces.models import Workspace
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Outcome:
|
|
output: str
|
|
status: str # ok | error | denied | pending (wartet auf Freigabe)
|
|
arguments: dict[str, Any]
|
|
approval_id: int | None = None
|
|
result: dict[str, Any] | None = None # Tabelle hinter dem Text (TableResult)
|
|
|
|
|
|
def parse(raw: str) -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(raw or "{}")
|
|
except json.JSONDecodeError as e:
|
|
raise Invalid(f"Argumente sind kein gültiges JSON: {e.msg}") from e
|
|
if not isinstance(value, dict):
|
|
raise Invalid("Argumente müssen ein JSON-Objekt sein.")
|
|
return value
|
|
|
|
|
|
def validation_message(error: ValidationError) -> str:
|
|
problems = [f"{'.'.join(str(p) for p in e['loc']) or 'Argumente'}: {e['msg']}" for e in error.errors()]
|
|
return "Ungültige Argumente: " + "; ".join(problems[:5])
|
|
|
|
|
|
def limit(text: str, chars: int) -> str:
|
|
if len(text) <= chars:
|
|
return text
|
|
return text[:chars] + f"\n… Ergebnis nach {chars} Zeichen gekürzt; bitte die Anfrage eingrenzen."
|
|
|
|
|
|
def execute(db: Database, context: ToolContext, run_id: uuid.UUID, call_id: str, name: str, raw: str) -> Outcome:
|
|
started = time.monotonic()
|
|
arguments: dict[str, Any] = {}
|
|
with db.session() as session:
|
|
verdict = tools.evaluate(session, session.get(Workspace, context.workspace_id), name)
|
|
try:
|
|
arguments = parse(raw)
|
|
with db.session() as session:
|
|
tool = tools.catalog(session).get(name)
|
|
source_id = source_of(context, tool, arguments)
|
|
if source_id is not None:
|
|
# Regeln für die konkrete Quelle (→ Konzept 10.3)
|
|
verdict = tools.evaluate(session, session.get(Workspace, context.workspace_id), name, source_id)
|
|
via_approval = tool is not None and (tool.writes or verdict.decision == REQUIRE_APPROVAL)
|
|
if verdict.decision == DENY or tool is None or (via_approval and not tool.approvable) or \
|
|
tool.kind not in context.kinds:
|
|
raise PermissionError(verdict.reason if tool else "unbekanntes Tool")
|
|
args = tool.validate(arguments)
|
|
if via_approval:
|
|
outcome = propose(db, context, run_id, call_id, tool, args, verdict)
|
|
else:
|
|
output = tool.run(context, args)
|
|
text = limit(output, context.settings.tool_max_output_chars)
|
|
outcome = Outcome(untrusted.fence(text, name) if tool.untrusted else text, "ok", arguments,
|
|
result=output.stored() if isinstance(output, TableResult) else None)
|
|
except approvals.Unclear:
|
|
raise
|
|
except PermissionError as e:
|
|
# z. B. ein ausgedachtes Tool wie run_python oder bash (→ Konzept 7.8)
|
|
outcome = Outcome(f"Tool {name!r} ist in diesem Workspace nicht verfügbar ({e}). "
|
|
"Code-Ausführung gibt es nicht; nutze die angebotenen Tools.", "denied", arguments)
|
|
except ValidationError as e:
|
|
outcome = Outcome(validation_message(e), "error", arguments)
|
|
except (Invalid, ToolError) as e:
|
|
outcome = Outcome(f"Fehler: {e}", "error", arguments)
|
|
except Exception:
|
|
log.exception("Tool %s fehlgeschlagen (Run %s)", name, run_id)
|
|
outcome = Outcome("Fehler: Das Tool ist unerwartet fehlgeschlagen.", "error", arguments)
|
|
record(db, context, run_id, call_id, name, outcome, verdict, started)
|
|
if outcome.approval_id is not None and outcome.status != "pending":
|
|
# Durch Policy erlaubte Schreibaktion: gleich ausführen
|
|
output, status = approvals.carry_out(db, context, outcome.approval_id)
|
|
outcome = Outcome(output, status, outcome.arguments, outcome.approval_id)
|
|
return outcome
|
|
|
|
|
|
def source_of(context: ToolContext, tool, arguments: dict[str, Any]) -> int | None:
|
|
"""Quelle, auf die ein Aufruf zielt; None, wenn das Tool keine Quelle hat oder sie offen ist."""
|
|
if tool is None or tool.kind not in SOURCE_KINDS or tool.kind not in context.kinds:
|
|
return None
|
|
name = arguments.get("source")
|
|
return context.source(tool.kind, name if isinstance(name, str) else None).id
|
|
|
|
|
|
def propose(db: Database, context: ToolContext, run_id: uuid.UUID, call_id: str, tool, args,
|
|
verdict: tools.Verdict) -> Outcome:
|
|
"""Schreibende Tools: Snapshot bauen und Freigabeantrag anlegen (→ Konzept 10.6)."""
|
|
proposal = tool.propose(context, args)
|
|
with db.session() as session:
|
|
if approvals.rejected_before(session, run_id, tool.name, proposal.arguments):
|
|
raise Invalid("Genau dieser Vorschlag wurde in diesem Run bereits abgelehnt.")
|
|
workspace = session.get(Workspace, context.workspace_id)
|
|
approval = approvals.propose(session, run_id=run_id, workspace=workspace, user_id=context.user_id,
|
|
call_id=call_id, tool=tool.name, proposal=proposal, verdict=verdict,
|
|
hours=context.settings.approval_hours)
|
|
pending = approval.status == approvals.PENDING
|
|
approval_id = approval.id
|
|
return Outcome("" if pending else "freigegeben", "pending" if pending else "approved", proposal.arguments,
|
|
approval_id)
|
|
|
|
|
|
def record(db: Database, context: ToolContext, run_id: uuid.UUID, call_id: str, name: str, outcome: Outcome,
|
|
verdict: tools.Verdict, started: float) -> None:
|
|
seconds = time.monotonic() - started
|
|
metrics.tool_calls.labels(metrics.tool_label(name), outcome.status).inc()
|
|
metrics.tool_duration.labels(metrics.tool_label(name)).observe(seconds)
|
|
log.info("Tool-Aufruf %s: %s", name[:200], outcome.status, extra={
|
|
"event": "tool_call", "tool": name[:200], "status": outcome.status, "decision": verdict.decision,
|
|
"duration_ms": round(seconds * 1000)})
|
|
with db.session() as session:
|
|
session.add(ToolCall(run_id=run_id, workspace_id=context.workspace_id, call_id=call_id[:200],
|
|
tool=name[:200], arguments=outcome.arguments, decision=verdict.decision, status=outcome.status,
|
|
output=outcome.output[:4000], result=outcome.result,
|
|
duration_ms=int((time.monotonic() - started) * 1000)))
|
|
if outcome.status == "denied":
|
|
audit.record(session, "tool.denied", workspace_id=context.workspace_id, target_kind="tool",
|
|
target_id=name[:200], run_id=str(run_id), reason=verdict.reason)
|