"""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)