From 9754cbcfaeeac88a83feb29c19c96971a215de5b Mon Sep 17 00:00:00 2001 From: team3 Date: Fri, 3 Jul 2026 11:55:38 +0200 Subject: [PATCH] update --- Makefile | 16 +++++++--- backend/agents.py | 30 +++++++++++++++++++ backend/tests/test_events.py | 57 ++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 6ed050c..b60dd31 100644 --- a/Makefile +++ b/Makefile @@ -62,13 +62,19 @@ ollama: ollama pull qwen3.5:9b @echo "Ollama bereit — Provider 'Lokal' ist aktiv (Modelle anpassen: backend/config.py + dev-ops/opencode.json)." +# Remote wird für die DB-Kopie gestoppt (sonst reißt der Snapshot mitten im Schreibvorgang) +# und danach wieder gestartet. sync: stop - @mkdir -p storage/themen + @mkdir -p storage/themen storage/topics uni @rm -f storage/creator.db-shm storage/creator.db-wal + ssh root@178.104.67.87 'cd /var/www/creator && docker compose down' rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db storage/ - rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db-wal storage/ + -rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db-wal storage/ rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/themen/ storage/themen/ - @echo "Sync abgeschlossen." + rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/topics/ storage/topics/ + rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/uni/ uni/ + ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d' + @echo "Sync abgeschlossen — Remote läuft wieder." # Projekte vom Server holen: `make sync-projects` (auch: `make sync projects`). # Mit --delete — exakter Spiegel: lokale Projekte ohne Server-Pendant werden gelöscht. @@ -84,13 +90,15 @@ sync-all: sync sync-projects # Stoppt den Remote-Server (DB-Sicherheit), pusht DB + Themen + Projekte, startet ihn neu. # --delete = exakter Spiegel: Remote-Dateien ohne lokales Pendant werden gelöscht. sync-all-reverse: stop - @echo "ACHTUNG: überschreibt den Remote-Stand (DB, themen/, projects/) mit dem lokalen." + @echo "ACHTUNG: überschreibt den Remote-Stand (DB, themen/, topics/, uni/, projects/) mit dem lokalen." @[ -f storage/creator.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; } ssh root@178.104.67.87 'cd /var/www/creator && docker compose down' ssh root@178.104.67.87 'rm -f /var/www/creator/storage/creator.db-shm /var/www/creator/storage/creator.db-wal' rsync -avz --progress storage/creator.db root@178.104.67.87:/var/www/creator/storage/ @[ -f storage/creator.db-wal ] && rsync -avz --progress storage/creator.db-wal root@178.104.67.87:/var/www/creator/storage/ || true rsync -avz --progress --delete storage/themen/ root@178.104.67.87:/var/www/creator/storage/themen/ + rsync -avz --progress --delete storage/topics/ root@178.104.67.87:/var/www/creator/storage/topics/ + rsync -avz --progress --delete uni/ root@178.104.67.87:/var/www/creator/uni/ rsync -avz --progress --delete projects/ root@178.104.67.87:/var/www/creator/projects/ ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d --build' @echo "Reverse-Sync abgeschlossen — Remote läuft wieder." diff --git a/backend/agents.py b/backend/agents.py index ea62cd2..393a5a5 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -15,6 +15,7 @@ import os import re import shutil import signal +import sqlite3 import tempfile import time import urllib.request @@ -289,6 +290,9 @@ async def run_agent( meta = {"provider": provider, "model": model, "role": role, "rc": rc} if err_tail: meta["stderr"] = err_tail + if PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent + if (tok := await asyncio.to_thread(_session_tokens, agent_key)): + meta["tokens"] = tok await on_event(topic=scope, kind="agent", key=agent_key, label=label, status=status, dur_ms=int((time.monotonic() - start) * 1000), wait_ms=wait_ms, meta=meta) @@ -377,6 +381,31 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label) +_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db" + + +def _session_tokens(agent_key: str) -> dict | None: + """Token counters of the newest OpenCode session titled `agent_key` (set via run --title). + Best-effort read-only lookup — None when DB/row is missing; never fails the agent. + Timeouts count too: their sessions consumed tokens, that waste should be visible.""" + try: + con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1) + try: + row = con.execute( + "SELECT tokens_input, tokens_output, tokens_reasoning," + " tokens_cache_read, tokens_cache_write" + " FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1", + (agent_key,)).fetchone() + finally: + con.close() + except Exception: + return None + if row is None: + return None + keys = ("input", "output", "reasoning", "cache_read", "cache_write") + return {k: int(v or 0) for k, v in zip(keys, row)} + + async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None, label: str = "") -> tuple[int, str, str]: cfg = PROVIDERS[provider] # Prompt via temp file instead of argv (ARG_MAX protection for large project prompts) @@ -391,6 +420,7 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str "-m", model, "--agent", _OPENCODE_AGENTS.get(capabilities, "text"), "--dangerously-skip-permissions", + "--title", agent_key, # token accounting: joins the OpenCode session to our event "-f", str(prompt_path), ] if on_line is not None: diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index 39b3416..7ef9da8 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -226,3 +226,60 @@ async def test_remove_guide_format_clears_everything(testdb, monkeypatch): assert res["removed"] == 3 assert await db.list_guides() == [] or all(g["topic"] != TOPIC for g in await db.list_guides()) assert await db.list_guide_cards(TOPIC, "Guide") == [] + + +# ── Token-Logging pro Agent (OpenCode-Session → Event-Meta) ────────────────────────── + +def test_session_tokens_reads_newest(tmp_path, monkeypatch): + """--title = Agent-Key: neueste Session gewinnt (Retry); fehlende DB/Zeile → None.""" + import sqlite3 + dbf = tmp_path / "oc.db" + con = sqlite3.connect(dbf) + con.execute("CREATE TABLE session (title TEXT, time_created INT, tokens_input INT," + " tokens_output INT, tokens_reasoning INT, tokens_cache_read INT, tokens_cache_write INT)") + con.execute("INSERT INTO session VALUES ('k', 1, 1, 1, 0, 10, 0)") + con.execute("INSERT INTO session VALUES ('k', 2, 7, 3, 0, 99, 5)") + con.commit() + con.close() + monkeypatch.setattr(agents, "_OPENCODE_DB", dbf) + assert agents._session_tokens("k") == {"input": 7, "output": 3, "reasoning": 0, + "cache_read": 99, "cache_write": 5} + assert agents._session_tokens("fehlt") is None + monkeypatch.setattr(agents, "_OPENCODE_DB", tmp_path / "nope.db") + assert agents._session_tokens("k") is None + + +async def test_opencode_cmd_sets_title(monkeypatch): + """Der Agent-Key wird Session-Titel — der Join-Schlüssel fürs Token-Logging.""" + seen = {} + + async def fake_comm(agent_key, cmd, stdin, timeout, stagger=False, on_line=None, label="", env=None): + seen["cmd"] = cmd + return 0, "", "" + + monkeypatch.setattr(agents, "_communicate", fake_comm) + await agents._run_opencode("blocks-t-z", "p", 5, "minimax", "m", "none") + i = seen["cmd"].index("--title") + assert seen["cmd"][i + 1] == "blocks-t-z" + + +async def test_run_agent_logs_opencode_tokens(testdb, monkeypatch): + """OpenCode-Lauf: Token-Zähler der Session landen im Event-Meta.""" + recorded = [] + + async def sink(**kw): + recorded.append(kw) + + async def fake_oc(agent_key, prompt, timeout, provider, model, capabilities, on_line=None, label=""): + return 0, "out", "" + + monkeypatch.setattr(agents, "on_event", sink) + monkeypatch.setattr(agents, "_run_opencode", fake_oc) + monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true") + monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "test-model")) + monkeypatch.setattr(agents, "_session_tokens", + lambda k: {"input": 5, "output": 2, "reasoning": 0, + "cache_read": 100, "cache_write": 0}) + rc, *_ = await agents.run_agent("blocks-t-tok", "p", 5, provider="minimax", scope=TOPIC) + assert rc == 0 + assert recorded and recorded[0]["meta"]["tokens"]["cache_read"] == 100