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

81 lines
3.8 KiB
Python

"""Was ein Tool während eines Runs braucht; Quellen legt der Server fest, nie das Modell (→ Konzept 8.5)."""
import uuid
from dataclasses import dataclass, field
from typing import Any
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.errors import Invalid
from app.data_sources import service as sources
from app.data_sources.connectors.git_repo import GitRepo, RepoCache
from app.data_sources.models import DataSource
@dataclass
class ToolContext:
settings: Settings
vault: Vault
workspace_id: int
# Nur getestete Quellen dieses Workspace
sources: list[DataSource]
cache: RepoCache
# Festgehaltener Commit je Quelle: alle Tools eines Runs sehen denselben Stand
snapshots: dict[int, str] = field(default_factory=dict)
run_id: uuid.UUID | None = None
# Wer den Run gestartet hat; beantragt Schreibaktionen
user_id: int | None = None
# Für den Workspace freigeschaltete MCP-Server: Kurzname -> Ziel (URL, Token, Timeout)
mcp: dict[str, Any] = field(default_factory=dict)
# Erfolgreich umgewandelte Uploads des Workspace (DuckDB-Tabellen)
tables: list[Any] = field(default_factory=list)
# Chat des Runs: Git-Tools lesen den Chat-Branch, commit_change schreibt darauf
db: Any = None
chat_session_id: int | None = None
# Arten der Hugin-Tools, die festgeschriebene Pakete freischalten (hugin, hugin:skill)
hugin: set[str] = field(default_factory=set)
# Ausgangs-Branch je Quelle, wenn der Chat auf einem bestehenden Branch arbeitet (sonst Ziel-Branch)
bases: dict[int, str] = field(default_factory=dict)
# Abgeglichene Ordner, die dieser Run lesen darf (app.documents.service.for_run)
documents: bool = False
@property
def kinds(self) -> set[str]:
"""Arten, deren Tools in diesem Run aufgerufen werden dürfen."""
return ({s.kind for s in self.sources} | {f"mcp:{name}" for name in self.mcp}
| ({"uploads"} if self.tables else set()) | self.hugin | ({"docs"} if self.documents else set()))
def source(self, kind: str, name: str | None) -> DataSource:
candidates = [s for s in self.sources if s.kind == kind]
if name:
chosen = [s for s in candidates if s.name == name]
if not chosen:
known = ", ".join(s.name for s in candidates) or "keine"
raise Invalid(f"Quelle {name!r} gibt es in diesem Workspace nicht. Verfügbar: {known}.")
return chosen[0]
if len(candidates) != 1:
raise Invalid("Bitte die Quelle angeben: " + ", ".join(s.name for s in candidates))
return candidates[0]
def by_id(self, source_id: int | None) -> DataSource:
for source in self.sources:
if source.id == source_id:
return source
raise Invalid("Die Quelle dieses Vorschlags ist nicht mehr verbunden oder nicht mehr getestet.")
def repo_for(self, source: DataSource) -> GitRepo:
return self.cache.repo(source.id, sources.connector(source, self.settings, self.vault))
def git(self, name: str | None) -> tuple[GitRepo, str]:
"""Repository und der Stand, den die Tools lesen: der Branch des Chats, falls die KI schon
committet hat, sonst der zu Beginn des Runs festgehaltene Stand des Ziel-Branches."""
source = self.source("git", name)
repo = self.repo_for(source)
if source.id not in self.snapshots:
self.snapshots[source.id] = repo.sync(self.bases.get(source.id))
if self.db is not None and self.chat_session_id is not None:
from app.changes import service as changes
head = changes.head(self.db, repo, self.chat_session_id, source.id, self.settings)
if head:
return repo, head
return repo, self.snapshots[source.id]