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

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)