diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 7a60b85e148f80966a550e5ab6a762a907c69ca6..0000000000000000000000000000000000000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -*.pyc diff --git a/BUILD_TRIGGER b/BUILD_TRIGGER deleted file mode 100644 index 9b38fe7193aa3a38b7aac7239520b5925ebb6fbd..0000000000000000000000000000000000000000 --- a/BUILD_TRIGGER +++ /dev/null @@ -1 +0,0 @@ -catch-up-message-loss-fix-v4-rebuild-xing-nemotron-vl \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c9e1be850e00c3a51906168adb70519abe792b36..e157625539f140074467a26ea7a6862fe3650231 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,99 +1,63 @@ +# Restore from 4bac0e2 + patch frontend + backend models + auto-continue FROM node:20-alpine AS frontend-builder RUN apk add --no-cache git python3 -RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /source || true -COPY patch_restore_full_app.py /tmp/patch_restore_full_app.py -RUN python3 /tmp/patch_restore_full_app.py || true +RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /source + +# Patch frontend: add OpenRouter models COPY patch_frontend.py /tmp/patch_frontend.py -RUN python3 /tmp/patch_frontend.py || true -COPY patch_space_bunny.py /tmp/patch_space_bunny.py -RUN python3 /tmp/patch_space_bunny.py || true -COPY patch_frontend_uploads.py /tmp/patch_frontend_uploads.py -RUN python3 /tmp/patch_frontend_uploads.py || true -COPY patch_frontend_multi.py /tmp/patch_frontend_multi.py -RUN python3 /tmp/patch_frontend_multi.py || true -COPY patch_frontend_new_models.py /tmp/patch_frontend_new_models.py -RUN python3 /tmp/patch_frontend_new_models.py || true -COPY patch_frontend_apodex_ling_beatapi.py /tmp/patch_frontend_apodex_ling_beatapi.py -RUN python3 /tmp/patch_frontend_apodex_ling_beatapi.py || true -COPY patch_catch_up_dataset_fallback.py /tmp/patch_catch_up_dataset_fallback.py -RUN python3 /tmp/patch_catch_up_dataset_fallback.py || true -# Run auto_continue patch BEFORE our frontend fix, since it also patches useAgentChat.ts -COPY patch_auto_continue_v17.py /tmp/patch_auto_continue_v17.py -RUN python3 /tmp/patch_auto_continue_v17.py || true -# Run our frontend catch-up fix AFTER all other frontend patches -COPY patch_catch_up_fix_frontend.py /tmp/patch_catch_up_fix_frontend.py -RUN python3 /tmp/patch_catch_up_fix_frontend.py || true +RUN python3 /tmp/patch_frontend.py + +# Patch auto-continue feature for free models +COPY patch_use_agent_chat_full.py /tmp/patch_use_agent_chat_full.py +RUN python3 /tmp/patch_use_agent_chat_full.py + +COPY patch_chat_input.py /tmp/patch_chat_input.py +RUN python3 /tmp/patch_chat_input.py + +COPY patch_sse_transport.py /tmp/patch_sse_transport.py +RUN python3 /tmp/patch_sse_transport.py WORKDIR /source/frontend -RUN npm config set fetch-timeout 120000 && npm config set fetch-retries 3 && npm install && npm run build || echo "FRONTEND BUILD FAILED" -# Guarantee the dist dir exists so COPY --from below never fails, even if npm failed. -RUN mkdir -p /source/frontend/dist +RUN npm config set fetch-timeout 120000 && \ + npm config set fetch-retries 3 && \ + npm install && \ + npm run build +# Stage 2: Production FROM python:3.12-slim COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ RUN useradd -m -u 1000 user WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates tesseract-ocr tesseract-ocr-eng tesseract-ocr-vie libreoffice-writer libreoffice-calc libreoffice-impress && rm -rf /var/lib/apt/lists/* - -RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /tmp/source && cp /tmp/source/pyproject.toml /tmp/source/uv.lock ./ && cp -r /tmp/source/agent ./agent && cp -r /tmp/source/backend ./backend && (cp /tmp/source/backend/document_parser.py ./backend/document_parser.py 2>/dev/null || true) && cp -r /tmp/source/configs ./configs && cp -r /tmp/source/scripts ./scripts && rm -rf /tmp/source +RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates && rm -rf /var/lib/apt/lists/* -COPY scripts/build_kpis.py /tmp/build_kpis_patched.py -RUN cp /tmp/build_kpis_patched.py /app/scripts/build_kpis.py && python3 -c "import ast; ast.parse(open('/app/scripts/build_kpis.py').read())" || echo "KP FAILED" +RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /tmp/source && \ + cp /tmp/source/pyproject.toml /tmp/source/uv.lock ./ && \ + cp -r /tmp/source/agent ./agent && \ + cp -r /tmp/source/backend ./backend && \ + cp -r /tmp/source/configs ./configs && \ + rm -rf /tmp/source -COPY patch_restore_full_app.py /tmp/patch_restore_full_app.py -RUN python3 /tmp/patch_restore_full_app.py || true +RUN uv sync --no-dev --frozen -RUN uv sync --no-dev --frozen || echo "UV SYNC FAILED" +COPY --from=frontend-builder /source/frontend/dist ./static/ +COPY configs/frontend_agent_config.json ./configs/frontend_agent_config.json -COPY backend/session_logger.py /app/backend/session_logger.py -COPY backend/document_parser.py /app/backend/document_parser.py +# Patch backend: accept new model IDs + auto-continue COPY patch_models.py /tmp/patch_models.py -RUN python /tmp/patch_models.py || true -COPY patch_new_models.py /tmp/patch_new_models.py -RUN python /tmp/patch_new_models.py || true -COPY patch_apodex_ling_beatapi.py /tmp/patch_apodex_ling_beatapi.py -RUN python3 /tmp/patch_apodex_ling_beatapi.py || true -COPY patch_openrouter_fix.py /tmp/patch_openrouter_fix.py -RUN python3 /tmp/patch_openrouter_fix.py || true -COPY patch_beatapi_routing.py /tmp/patch_beatapi_routing.py -RUN python3 /tmp/patch_beatapi_routing.py || true -COPY patch_agent_backend.py /tmp/patch_agent_backend.py -RUN python3 /tmp/patch_agent_backend.py || true -COPY patch_document_upload.py /tmp/patch_document_upload.py -RUN python3 /tmp/patch_document_upload.py || true -COPY patch_multi_upload.py /tmp/patch_multi_upload.py -RUN python3 /tmp/patch_multi_upload.py || true -COPY patch_permanent_session_logging.py /tmp/patch_permanent_session_logging.py -RUN python3 /tmp/patch_permanent_session_logging.py || true -COPY patch_catch_up_fix_backend.py /tmp/patch_catch_up_fix_backend.py -RUN python3 /tmp/patch_catch_up_fix_backend.py || true - -# === PATCH: fix 404 on root and /api when frontend build is missing === -# Root cause: mkdir -p /app/static creates an empty dir; main.py mounts -# StaticFiles on the empty dir, shadowing ALL API routes with 404s. -COPY patch_fix_static_404.py /tmp/patch_fix_static_404.py -RUN python3 /tmp/patch_fix_static_404.py || echo "STATIC FIX PATCH WARN (BUILD CONTINUES)" - -# === PATCH: lossless long-context memory (fix context loss for long sessions) === -COPY patch_lossless_context.py /tmp/patch_lossless_context.py -RUN python /tmp/patch_lossless_context.py || echo "LOSSLESS CONTEXT PATCH WARN (BUILD CONTINUES)" -RUN python3 -c "import ast; [ast.parse(open(f).read()) for f in ['/app/agent/context_manager/manager.py','/app/agent/core/agent_loop.py','/app/agent/core/session.py','/app/agent/core/session_resume.py','/app/agent/config.py']]; print('ALL PATCHED FILES SYNTAX OK')" || echo "SYNTAX CHECK WARN" - -# Copy frontend dist from builder stage using COPY --from (multi-stage) -# The frontend-builder stage guarantees /source/frontend/dist exists (mkdir -p) -COPY --from=frontend-builder /source/frontend/dist /app/static -RUN echo "Static dir ready: $(ls /app/static 2>/dev/null | wc -l) files" - -RUN mkdir -p /app/configs -RUN cp configs/frontend_agent_config.json /app/configs/frontend_agent_config.json 2>/dev/null || echo "No frontend config" +RUN python /tmp/patch_models.py -RUN grep -rl "where we here now" /app/static/ 2>/dev/null | xargs sed -i "s/where we here now//g" 2>/dev/null || true +COPY patch_agent_loop.py /tmp/patch_agent_loop.py +RUN python /tmp/patch_agent_loop.py || echo "agent_loop patch not applied (may need adjustment)" RUN mkdir -p /app/session_logs && chown -R user:user /app USER user -ENV HOME=/home/user PYTHONUNBUFFERED=1 PYTHONPATH=/app PATH="/app/.venv/bin:$PATH" +ENV HOME=/home/user \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PATH="/app/.venv/bin:$PATH" EXPOSE 7860 WORKDIR /app/backend -CMD ["bash", "start.sh"] +# python:slim does not include bash by default; start.sh is POSIX-compatible. +CMD ["sh", "start.sh"] \ No newline at end of file diff --git a/README.md b/README.md index ff201f3f087047cda66c24d65a48aef9943a01ba..2292c8e75cb82c877ebc3233bc2e8379b6817f43 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ colorFrom: yellow colorTo: blue sdk: docker app_port: 7860 -short_description: ML Intern V2 (FREE & SAVE MODEL + PERMANENT SESSION LOGGING) +short_description: ML Intern V2 (FREE & SAVE MODEL) hf_oauth: true hf_oauth_expiration_minutes: 43200 hf_oauth_scopes: @@ -19,48 +19,14 @@ hf_oauth_scopes: - write-discussions tags: - ml-intern -- permanent-session-logging -- persistent-context --- -# Space Multiverse — ML Intern V2 +# Space Multiverse — ML Intern V2 (FREE & SAVE MODEL) -## 🔒 Permanent Session & User Context Logging + -**Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace.** - -- Mỗi message, tool call, event được lưu **ngay khi xảy ra** — không đợi heartbeat -- Không bao giờ mất dữ liệu, ngay cả khi Space restart/crash -- Lưu vào `bep40/ml-intern-sessions` (dataset chung) + `{user}/ml-intern-sessions` (cá nhân) -- Mỗi model (dù là model nào) đều được lưu session vĩnh viễn -- **Không có thông báo "where we here now?"** — giảm thiểu thông báo không cần thiết - -### Cách hoạt động - -1. Khi session được tạo → log ngay vào dataset với event_type=session_created -2. User gửi tin nhắn → log ngay role=user, event_type=message -3. Agent phản hồi → log ngay role=assistant, event_type=message -4. Tool call/result → log ngay event_type=tool_call/tool_result -5. Session kết thúc → log trajectory đầy đủ - -### Truy cập lịch sử - -- `GET /api/sessions-history` — lấy tất cả session của user -- `GET /api/session-history/{session_id}` — lấy full lịch sử một session -- `GET /api/dataset-info` — thông tin dataset lưu trữ - -Dataset: [bep40/ml-intern-sessions](https://huggingface.co/datasets/bep40/ml-intern-sessions) - -## Models - -- **Default**: `openai/tencent/hy3:free` -- OpenRouter models: Tencent HY3, Gemma 4 31B, Llama 3.3 70B, DeepSeek V4, Laguna S 2.1 -- BeatAPI: `beatapi/space-bunny-alpha` (provider riêng, cần BEATAPI_API_KEY) -- Cục bản chất, mọi model đều được lưu session vĩnh viễn - -## Quick Start - -1. Chọn model (dùng bất kỳ model nào — đều được lưu) -2. Chat bình thường -3. Session được lưu vĩnh viễn vào dataset -4. Sau Space restart, frontend tự động restore từ dataset +- Replaced GPT-5.5 model with **Laguna S 2.1:free** (path: `openai/poolside/laguna-s-2.1:free`) +- Added OpenRouter routing (catch-all `openai/` prefix → OpenRouter API) +- Default: `openai/tencent/hy3:free` +- Models: Tencent HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, **Laguna M.1:free**, **Laguna S 2.1:free**, Llama 3.1 8B +- Ollama: không thể dùng cho HF Space (local inference, không có cloud API free cho use case này) \ No newline at end of file diff --git a/backend/document_parser.py b/backend/document_parser.py deleted file mode 100644 index 6d9cc765e923fcdf0bc003ae7889bac946303f0c..0000000000000000000000000000000000000000 --- a/backend/document_parser.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Extract plain-text context from uploaded office/binary documents. - -The ML Intern agent runtime is a coding agent that mostly runs on text-only -LLM backends (many of the free OpenRouter models have no vision). So instead -of just handing the raw binary file to the model, we extract the meaningful -text out of it and present it as a compact, readable context block. - -Supported formats: - PDF (.pdf) - Images (.png, .jpg, .jpeg, .webp, .bmp, .tiff, .gif) - Excel (.xlsx, .xlsm, .xls) - Word (.docx) - PowerPoint (.pptx) - OpenDocument (.odt, .ods, .odp) (via LibreOffice) - Plain text (.txt, .md, .csv, .json, .jsonl, .rtf, .log) - -IMPORTANT: For OCR we rely on pytesseract + a system tesseract binary. -For legacy binary office formats (.xls, .doc, .rtf, .odt/.ods/.odp) we rely -on LibreOffice headless (`libreoffice --headless --convert-to txt`). If those -system binaries are not installed, extraction degrades gracefully: the file is -still uploaded and referenced, but with a warning that the raw content could -not be parsed. -""" - -from __future__ import annotations - -import io -import os -import re -import shutil -import subprocess -import tempfile -from typing import Optional - -# --- Optional heavy imports, guarded -------------------------------- -try: - from PIL import Image -except Exception: # pragma: no cover - Image = None - -try: - from pypdf import PdfReader -except Exception: # pragma: no cover - PdfReader = None - -try: - import docx # python-docx -except Exception: # pragma: no cover - docx = None - -try: - import openpyxl -except Exception: # pragma: no cover - openpyxl = None - -try: - import pptx # python-pptx -except Exception: # pragma: no cover - pptx = None - -MAX_CHARS = 60_000 # cap extracted text so the context block stays small -_PAGE_BREAK = "\n\n--- PAGE BREAK ---\n\n" - -# Map extension -> parser kind -IMAGE_EXTS = {"png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif"} -TEXT_EXTS = {"txt", "md", "csv", "json", "jsonl", "log", "rtf"} -_LO_TEXT_FORMATS = {".xls", ".doc", ".odt", ".ods", ".odp"} - - -def _snippet(text: str, limit: int = 120) -> str: - text = " ".join(text.split()) - return text[:limit] + ("…" if len(text) > limit else "") - - -# --- Individual extractors ------------------------------------------- - - -def _extract_pdf(data: bytes) -> str: - if PdfReader is None: - raise RuntimeError("pypdf not installed") - reader = PdfReader(io.BytesIO(data)) - pages = [] - for page in reader.pages: - try: - pages.append(page.extract_text() or "") - except Exception: - pages.append("") - return _PAGE_BREAK.join(pages) - - -def _extract_docx(data: bytes) -> str: - if docx is None: - raise RuntimeError("python-docx not installed") - document = docx.Document(io.BytesIO(data)) - parts = [] - - # Tables first (structure is usually important) - for table in document.tables: - for row in table.rows: - cells = [c.text.strip() for c in row.cells] - parts.append(" | ".join(cells)) - parts.append("") - - for para in document.paragraphs: - t = para.text.strip() - if t: - parts.append(t) - return "\n".join(parts) - - -def _extract_xlsx(data: bytes) -> str: - if openpyxl is None: - raise RuntimeError("openpyxl not installed") - wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) - parts = [] - for ws in wb.worksheets: - parts.append(f"### Sheet: {ws.title}") - for row in ws.iter_rows(values_only=True): - vals = ["" if v is None else str(v) for v in row] - line = " | ".join(vals).strip() - if line: - parts.append(line) - parts.append("") - return "\n".join(parts) - - -def _extract_pptx(data: bytes) -> str: - if pptx is None: - raise RuntimeError("python-pptx not installed") - prs = pptx.Presentation(io.BytesIO(data)) - parts = [] - for idx, slide in enumerate(prs.slides, start=1): - parts.append(f"### Slide {idx}") - for shape in slide.shapes: - if hasattr(shape, "text") and shape.text and shape.text.strip(): - parts.append(shape.text.strip()) - if shape.has_table if hasattr(shape, "has_table") else False: - for row in shape.table.rows: - parts.append(" | ".join(c.text.strip() for c in row.cells)) - parts.append("") - return "\n".join(parts) - - -def _extract_image(data: bytes) -> str: - """OCR an image. Returns combined text + a note about the image.""" - if Image is None: - raise RuntimeError("Pillow not installed") - tesseract = shutil.which("tesseract") - if not tesseract: - raise RuntimeError( - "OCR unavailable: tesseract binary not found on this runtime. " - "The image was still uploaded and attached by reference." - ) - try: - import pytesseract - except Exception as exc: # pragma: no cover - raise RuntimeError(f"OCR unavailable: pytesseract import failed: {exc}") - - image = Image.open(io.BytesIO(data)) - try: - text = pytesseract.image_to_string(image) - finally: - try: - image.close() - except Exception: - pass - return text - - -def _extract_rtf(data: bytes) -> str: - """RTF is too messy to hand-parse well; route through LibreOffice if present.""" - return _lo_convert_to_text(data, ".rtf") - - -def _extract_legacy_office(data: bytes, ext: str) -> str: - """Legacy binary office formats (.xls, .doc) via LibreOffice headless.""" - return _lo_convert_to_text(data, ext) - - -def _lo_convert_to_text(data: bytes, ext: str) -> str: - """Use LibreOffice headless to convert a document to plain text.""" - soffice = shutil.which("libreoffice") or shutil.which("soffice") - if not soffice: - raise RuntimeError( - "LibreOffice not installed on this runtime; cannot parse this " - "format here. The file was still uploaded and attached by reference." - ) - with tempfile.TemporaryDirectory() as tmp: - in_path = os.path.join(tmp, f"input{ext}") - out_dir = os.path.join(tmp, "out") - os.makedirs(out_dir, exist_ok=True) - with open(in_path, "wb") as f: - f.write(data) - result = subprocess.run( - [soffice, "--headless", "--convert-to", "txt:Text (encoded):UTF8", - "--outdir", out_dir, in_path], - capture_output=True, - timeout=120, - ) - if result.returncode != 0: - raise RuntimeError( - f"LibreOffice failed to convert this file (rc={result.returncode})." - ) - out_files = os.listdir(out_dir) - if not out_files: - raise RuntimeError("LibreOffice produced no output for this file.") - with open(os.path.join(out_dir, out_files[0]), "r", encoding="utf-8", errors="ignore") as f: - return f.read() - - -# --- Public dispatcher ------------------------------------------------- - - -def extract_document_text(filename: str, data: bytes) -> tuple[str, str]: - """Extract text from an uploaded document. - - Returns: - (extracted_text, warning) — `warning` is a non-empty string when the - extraction is a degraded fallback, otherwise empty. - """ - ext = os.path.splitext(filename or "")[1].lower().lstrip(".") - try: - if ext == "pdf": - text = _extract_pdf(data) - elif ext in IMAGE_EXTS: - text = _extract_image(data) - elif ext in {"xlsx", "xlsm"}: - text = _extract_xlsx(data) - elif ext == "xls": - text = _extract_legacy_office(data, ".xls") - elif ext == "docx": - text = _extract_docx(data) - elif ext == "doc": - text = _extract_legacy_office(data, ".doc") - elif ext == "pptx": - text = _extract_pptx(data) - elif ext in {"odt", "ods", "odp"}: - text = _extract_legacy_office(data, f".{ext}") - elif ext == "rtf": - text = _extract_rtf(data) - elif ext in TEXT_EXTS: - text = data.decode("utf-8", errors="replace") - else: - return "", f"Unsupported format '.{ext}' — file uploaded but no text extracted." - except RuntimeError as e: - return "", str(e) - except Exception as e: # pragma: no cover - return "", f"Failed to extract text from this file: {e}" - - cleaned = "\n".join(line.rstrip() for line in text.splitlines()) - cleaned = re.sub(r"\n{4,}", "\n\n\n", cleaned).strip() - - if ext in IMAGE_EXTS: - note = ( - f"[The image is {len(data):,} bytes. The text below is OCR output. " - "For layout/diagram questions, refer to the uploaded image file.]\n\n" - ) - cleaned = note + cleaned - - if len(cleaned) > MAX_CHARS: - cleaned = cleaned[:MAX_CHARS] + "\n\n... [TRUNCATED — file content exceeds context limit] ..." - - return cleaned, "" - - -# --- Dispatch helper for the upload note ------------------------------- - - -def format_uploaded_document_context( - *, - filename: str, - stored_filename: str, - repo_id: str, - path_in_repo: str, - hub_url: str, - size_bytes: int, - file_format: str, - extracted_text: str, - warning: str, -) -> str: - """Build the SYSTEM context note injected into the session for the AI.""" - lines = [ - "[SYSTEM: The user uploaded a document/file for this session.", - "", - "A text extraction was performed below so you can understand the file's", - "content directly. If the text is truncated, empty, or you need the", - "original bytes/format, the original file is preserved on the Hub:", - "", - f"- Repo ID: {repo_id}", - f"- Repo type: dataset", - f"- File in repo: {path_in_repo}", - f"- Original filename: {filename}", - f"- Stored filename: {stored_filename}", - f"- Format: {file_format}", - f"- Size: {size_bytes} bytes", - f"- Hub URL: {hub_url}", - "", - ] - if warning: - lines.extend( - [ - "WARNING (extraction):", - warning, - "", - "You can still try to load/read the original file from the Hub URL", - "above, but understand the plain-text extraction did not succeed.", - "", - ] - ) - lines.extend( - [ - "Extracted text content:", - "```", - extracted_text if extracted_text else "(no text could be extracted)", - "```", - "]", - ] - ) - return "\n".join(lines) diff --git a/backend/session_logger.py b/backend/session_logger.py deleted file mode 100644 index 6d4934551b304767743a7abc82754d492e11be3b..0000000000000000000000000000000000000000 --- a/backend/session_logger.py +++ /dev/null @@ -1,467 +0,0 @@ -""" -Permanent session & user context logger. - -Lưu vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace ngay khi -có message/event mới — fire-and-forget, robust, không bao giờ block agent. - -Lưu vào: - - ``bep40/ml-intern-sessions`` : dataset chung, lưu tất cả session - - ``{hf_user}/ml-intern-sessions``: dataset cá nhân mỗi user - -Mỗi message được lưu ngay lập tức qua subprocess (detached) để tránh blocking. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import os -import subprocess -import sys -import time -from datetime import datetime -from pathlib import Path -from typing import Optional - -from huggingface_hub import HfApi, hf_hub_download - -logger = logging.getLogger(__name__) - -PUBLIC_SESSIONS_DATASET = os.environ.get( - "ML_INTERN_SESSIONS_DATASET", "bep40/ml-intern-sessions" -) - -DEFAULT_LOG_DIR = Path(os.environ.get("ML_INTERN_SESSION_LOG_DIR", "/tmp/session_logs")) - - -def _now_iso() -> str: - return datetime.now().astimezone().isoformat() - - -def _get_hf_token() -> Optional[str]: - return ( - os.environ.get("HF_TOKEN") - or os.environ.get("_ML_INTERN_PERSONAL_TOKEN") - or os.environ.get("HF_SESSION_UPLOAD_TOKEN") - or os.environ.get("HF_ADMIN_TOKEN") - ) - - -def _dataset_card_readme() -> str: - return """--- -pretty_name: "ML Intern Session Context" -language: -- en -license: other -task_categories: -- text-generation -tags: -- ml-intern -- session-traces -- conversation -- agent-traces -configs: -- config_name: default - data_files: - - split: train - path: "sessions/**/*.jsonl" ---- - -# ML Intern Session Context - -**Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user.** - -Mỗi message, tool call, event được lưu ngay khi xảy ra để đảm bảo không bao giờ mất dữ liệu. - -## Schema - -| Column | Type | Mô tả | -|----------------|--------|------------------------------------------| -| session_id | string | UUID của session | -| user_id | string | ID người dùng | -| model_name | string | Model đang dùng | -| timestamp | string | ISO timestamp | -| role | string | user / assistant / tool / system / event | -| content | string | Nội dung message | -| event_type | string | Loại event | -| seq | int | Số thứ tự trong session | -| extra | string | JSON thông tin bổ sung | -""" - - -def _append_to_local_log( - session_id: str, - user_id: str, - model_name: str, - role: str, - content: str, - event_type: str, - seq: int, - extra: dict | None = None, - directory: str = str(DEFAULT_LOG_DIR), -) -> Optional[str]: - try: - log_dir = Path(directory) - log_dir.mkdir(parents=True, exist_ok=True) - row = { - "session_id": session_id, - "user_id": user_id, - "model_name": model_name, - "timestamp": _now_iso(), - "role": role, - "content": content, - "event_type": event_type, - "seq": seq, - "extra": json.dumps(extra) if extra else "{}", - } - date_str = datetime.now().strftime("%Y-%m-%d") - filename = f"session_{session_id}_{date_str}.jsonl" - filepath = log_dir / filename - with open(filepath, "a") as f: - f.write(json.dumps(row, default=str) + "\n") - return str(filepath) - except Exception as e: - logger.debug("Local log write failed: %s", e) - return None - - -# ─── Dataset reader: per-file scan (never loads 410MB into memory) ────────── - -_file_list_cache: list[str] | None = None -_file_list_cache_time: float = 0 - - -def _list_dataset_files() -> list[str]: - """Return all .jsonl file paths in the sessions dataset (cached, 60s TTL).""" - global _file_list_cache, _file_list_cache_time - now = time.time() - if _file_list_cache is not None and (now - _file_list_cache_time) < 60: - return _file_list_cache - try: - api = HfApi(token=_get_hf_token()) - files = api.list_repo_files(PUBLIC_SESSIONS_DATASET, repo_type="dataset") - _file_list_cache = [f for f in files if f.endswith(".jsonl")] - _file_list_cache_time = now - except Exception as e: - logger.warning("Failed to list dataset files: %s", e) - _file_list_cache = [] - _file_list_cache_time = now - return _file_list_cache - - -def _normalize_row(row: dict) -> dict | None: - """Normalize a row to the standard schema regardless of source format. - - The dataset contains two incompatible formats: - - *New* (session_logger.py): session_id, user_id, role, content, event_type, seq - - *Old* (Claude Code trace format): type, message, uuid, parentUuid, sessionId, timestamp - """ - if not isinstance(row, dict): - return None - - # Already in new format — has session_id + role - if "session_id" in row and "role" in row: - return row - - # Old Claude Code format: type + message - if "type" in row and "message" in row: - session_id = row.get("sessionId") or row.get("session_id") or "" - if not session_id: - return None - msg = row.get("message") - role = row.get("type", "unknown") - content_text = "" - model_name = "" - if isinstance(msg, dict): - role = msg.get("role", role) - content_val = msg.get("content", "") - model_name = msg.get("model", "") - if isinstance(content_val, str): - content_text = content_val - elif isinstance(content_val, list): - parts = [] - for block in content_val: - if isinstance(block, dict): - t = block.get("text") or block.get("content") - if isinstance(t, str): - parts.append(t) - elif isinstance(block, str): - parts.append(block) - content_text = " ".join(parts) - elif content_val is None: - content_text = "" - return { - "session_id": session_id, - "user_id": "", - "model_name": model_name, - "timestamp": row.get("timestamp", ""), - "role": role, - "content": content_text, - "event_type": row.get("type", "message"), - "seq": 0, - "extra": "{}", - } - - return None - - -def _read_jsonl_file(file_path: str) -> list[dict]: - """Download a single JSONL file from the dataset and parse its lines. - - Robust to: empty files, malformed JSON lines, old/new format mismatch. - """ - try: - local = hf_hub_download( - PUBLIC_SESSIONS_DATASET, - file_path, - repo_type="dataset", - token=_get_hf_token(), - ) - rows: list[dict] = [] - with open(local, encoding="utf-8", errors="replace") as fh: - for line in fh: - line = line.strip() - if not line: - continue - try: - row = json.loads(line) - normalized = _normalize_row(row) - if normalized is not None: - rows.append(normalized) - except json.JSONDecodeError: - continue - return rows - except Exception as e: - logger.debug("Failed to read %s: %s", file_path, e) - return [] - - -def load_session_history(session_id: str) -> list[dict]: - """Load all messages/events for a session from the dataset. - - Reads only the files whose name contains ``session_id`` instead of - loading the entire 400MB dataset into memory on every call. - """ - try: - all_files = _list_dataset_files() - matching = [ - f for f in all_files if session_id in os.path.basename(f) - ] - rows: list[dict] = [] - for f in matching: - for row in _read_jsonl_file(f): - if row.get("session_id") == session_id: - rows.append(row) - rows.sort(key=lambda r: (r.get("seq", 0), r.get("timestamp", ""))) - return rows - except Exception as e: - import traceback - logger.warning( - "Failed to load session history: %s\n%s", - e, - traceback.format_exc(), - ) - return [] - - -def get_user_sessions(user_id: str, limit: int = 50) -> list[dict]: - """Get all sessions for a user from the dataset. - - Uses per-file scanning exclusively (never calls load_dataset which can - fail when the dataset has incompatible/mixed schemas). Cached file list - (60s TTL) avoids repeated list_repo_files calls. - """ - sessions: dict[str, dict] = {} - for f in _list_dataset_files(): - for row in _read_jsonl_file(f): - if row.get("user_id") == user_id: - sid = row.get("session_id") - if sid and sid not in sessions: - sessions[sid] = { - "session_id": sid, - "user_id": user_id, - "model_name": row.get("model_name"), - "last_timestamp": row.get("timestamp"), - "message_count": 0, - } - sessions[sid]["message_count"] += 1 - - result = list(sessions.values()) - result.sort(key=lambda s: s.get("last_timestamp", ""), reverse=True) - return result[:limit] - - -# ─── Upload/subprocess functions (unchanged from original) ────────────────── - -def _spawn_uploader( - row_data: dict, - repo_id: str, - token: str, - private: bool = False, -) -> None: - """Spawn detached subprocess to upload a single row to HF dataset.""" - try: - script = f""" -import json, sys, time, os -from huggingface_hub import HfApi - -row_data = {json.dumps(row_data)} -repo_id = "{repo_id}" -token = "{token}" -private = {private} - -api = HfApi() -try: - api.create_repo(repo_id=repo_id, repo_type="dataset", private=private, token=token, exist_ok=True) -except: pass - -# Upload README -try: - api.upload_file(path_or_fileobj={_dataset_card_readme()!r}.encode(), path_in_repo="README.md", repo_id=repo_id, repo_type="dataset", token=token) -except: pass - -date_str = row_data.get("timestamp", "")[:10] -session_id = row_data.get("session_id", "unknown") -seq = row_data.get("seq", 0) -path_in_repo = f"sessions/{{date_str}}/{{session_id}}_{{seq}}.jsonl" -payload = json.dumps(row_data) - -for attempt in range(5): - try: - api.upload_file(path_or_fileobj=payload.encode(), path_in_repo=path_in_repo, repo_id=repo_id, repo_type="dataset", token=token, commit_message=f"Add session {{session_id[:8]}} seq {{seq}}") - break - except Exception as e: - if attempt < 4: - time.sleep(2 ** attempt) - else: - sys.exit(1) -""" - subprocess.Popen( - [sys.executable, "-c", script], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=os.environ.copy(), - start_new_session=True, - ) - except Exception as e: - logger.debug("Upload spawn failed: %s", e) - - -def log_message( - session_id: str, - user_id: str, - model_name: str, - role: str, - content: str, - event_type: str = "message", - seq: int = 0, - extra: dict | None = None, - hf_user: str | None = None, -) -> None: - """Log a single message/event permanently to dataset. - - Fire-and-forget — all I/O is offloaded to a detached subprocess. - """ - token = _get_hf_token() - - # Always write to local log first (backup) - _append_to_local_log( - session_id=session_id, - user_id=user_id, - model_name=model_name, - role=role, - content=content, - event_type=event_type, - seq=seq, - extra=extra, - ) - - if not token: - logger.debug("No HF token, skipping dataset upload") - return - - row_data = { - "session_id": session_id, - "user_id": user_id, - "model_name": model_name, - "timestamp": _now_iso(), - "role": role, - "content": content, - "event_type": event_type, - "seq": seq, - "extra": json.dumps(extra) if extra else "{}", - } - - # Upload to public/shared dataset - _spawn_uploader(row_data, PUBLIC_SESSIONS_DATASET, token, private=False) - - # Upload to personal dataset if available - if hf_user and hf_user != "dev": - _spawn_uploader(row_data, f"{hf_user}/ml-intern-sessions", token, private=True) - - -def log_trajectory( - trajectory: dict, - user_id: str, - hf_user: str | None = None, - hf_token: str | None = None, - private: bool = False, -) -> Optional[str]: - """Upload complete session trajectory to dataset (final flush).""" - session_id = trajectory.get("session_id", "unknown") - model_name = trajectory.get("model_name", "") - messages = trajectory.get("messages", []) - - log_dir = Path(str(DEFAULT_LOG_DIR)) - log_dir.mkdir(parents=True, exist_ok=True) - date_str = datetime.now().strftime("%Y-%m-%d") - filepath = log_dir / f"session_{session_id}_{date_str}.jsonl" - - with open(filepath, "w") as f: - for msg in messages: - row = { - "session_id": session_id, - "user_id": user_id, - "model_name": model_name, - "timestamp": msg.get("timestamp") or _now_iso(), - "role": msg.get("role", "unknown"), - "content": msg.get("content", ""), - "event_type": "message", - "seq": msg.get("seq", 0), - "extra": json.dumps({ - "tool_calls": msg.get("tool_calls"), - "tool_call_id": msg.get("tool_call_id"), - }) if any(k in msg for k in ("tool_calls", "tool_call_id")) else "{}", - } - f.write(json.dumps(row, default=str) + "\n") - - events = trajectory.get("events", []) - for event in events: - row = { - "session_id": session_id, - "user_id": user_id, - "model_name": model_name, - "timestamp": event.get("timestamp", _now_iso()), - "role": "event", - "content": json.dumps(event.get("data", {})), - "event_type": event.get("event_type", "unknown"), - "seq": event.get("seq", 0), - "extra": "{}", - } - f.write(json.dumps(row, default=str) + "\n") - - token = hf_token or _get_hf_token() - if token: - with open(filepath, "r") as f: - for line in f: - try: - row = json.loads(line.strip()) - _spawn_uploader(row, PUBLIC_SESSIONS_DATASET, token, private) - if hf_user and hf_user != "dev": - _spawn_uploader(row, f"{hf_user}/ml-intern-sessions", token, True) - except Exception: - continue - - return str(filepath) diff --git a/configs/frontend_agent_config.json b/configs/frontend_agent_config.json index b198b6b6f290a3477deffffd88f3f184fbbafd79..65603f466f6d88f70335619c6da7f1c8a6bbd2d3 100644 --- a/configs/frontend_agent_config.json +++ b/configs/frontend_agent_config.json @@ -7,13 +7,10 @@ "yolo_mode": false, "confirm_cpu_jobs": true, "auto_file_upload": true, - "auto_save_interval": 1, - "heartbeat_interval_s": 30, - "max_iterations": 300, "mcpServers": { "hf-mcp-server": { "transport": "http", "url": "https://openrouter.ai/mcp" } } -} +} \ No newline at end of file diff --git a/fix_space_error_report.json b/fix_space_error_report.json index a0f16c111ea55e69f6715097ed80ab7514ab4728..23fb73a0e4ec9ef98b13880761778c204d90a365 100644 --- a/fix_space_error_report.json +++ b/fix_space_error_report.json @@ -1,14 +1,9 @@ { - "status": "rebuilding_with_deepseek_flash_fix", + "status": "fixed_uploaded", "space": "bep40/ml-intern", "space_url": "https://huggingface.co/spaces/bep40/ml-intern", - "root_cause": "DeepSeek V4 Flash model IDs were routed incorrectly: (1) GLM_52_MODEL_ID was set to 'openai/deepseek/deepseek-v4-flash' which the OpenRouter routing patch sends to OpenRouter, but OpenRouter model IDs do not include the 'openai/' prefix -- causing a 404 model-not-found. (2) The routing prefix check startswith('deepseek/') never matches 'deepseek-ai/' model IDs, so 'deepseek-ai/DeepSeek-V4-Flash' without a provider tag relied on HF Router auto-routing which could hit a provider lacking structured output support.", - "fix": "Changed GLM_52_MODEL_ID from 'openai/deepseek/deepseek-v4-flash' to 'deepseek-ai/DeepSeek-V4-Flash:deepinfra' (HF Router path with explicit provider tag). Also fixed the OpenRouter routing patch to strip the 'openai/' prefix before sending the model name to OpenRouter's API. Added ':novita' tag to DeepSeek V4 Pro for deterministic HF Router routing.", - "files_changed": ["patch_models.py"], - "model_routing": { - "deepseek-ai/DeepSeek-V4-Flash": "HF Inference Router (3 providers: novita, fireworks-ai, deepinfra) - all support tools, 1M context", - "deepseek-ai/DeepSeek-V4-Flash-0731": "HF Inference Router (5 providers) - all support tools, 1M context", - "deepseek/deepseek-v4-flash-0731": "OpenRouter - available via openrouter/deepseek/deepseek-v4-flash-0731" - }, - "verification": "Space rebuild triggered. Monitor Space build logs. Test model selection for DeepSeek V4 Flash after rebuild completes." + "root_cause": "Dockerfile runtime stage used CMD [\"bash\", \"start.sh\"] on python:3.12-slim, which does not include bash by default. This causes container startup failure and Space error after a successful build.", + "fix": "Changed Dockerfile CMD to [\"sh\", \"start.sh\"] and added ca-certificates to apt packages. start.sh is POSIX-compatible for this use.", + "files_changed": ["Dockerfile", "README.md"], + "verification": "Repo files updated and rebuild triggered. Live runtime verification via HF Jobs/sandbox was blocked by namespace credit/duplication limits in this session; check Space build logs if HF still reports error." } \ No newline at end of file diff --git a/patch_agent_backend.py b/patch_agent_backend.py deleted file mode 100644 index fa0cfe0372f3d27887efd11422be5a7345e81cda..0000000000000000000000000000000000000000 --- a/patch_agent_backend.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Patch: agent_loop.py task detection + auto-approve for free models.""" - -import os -import re -import ast - -AGENT_LOOP = "/app/agent/core/agent_loop.py" -AGENT_FILE = "/app/backend/routes/agent.py" -SESSION_FILE = "/app/agent/core/session.py" - -# ===== PATCH 1: agent_loop.py - detect model stopped mid-task ===== -with open(AGENT_LOOP) as f: - c = f.read() - -# Add helper function before class Handlers -if "_unfinished_plan" not in c: - fn = ( - "\n\ndef _unfinished_plan(s):\n" - " p = getattr(s, 'current_plan', None) or []\n" - " return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n\n" - ) - c = c.replace("class Handlers:", fn + "class Handlers:") - print("OK: Added _unfinished_plan()") - -# Add detection: if model produced no tool calls but content still exists, -# and plan items are still pending/in_progress, send session_update event -if "ac_plan" not in c: - check = ( - "\n # Auto-continue: detect model stopped mid-task\n" - " if not llm_result.tool_calls_acc and llm_result.content:\n" - " unfinished = _unfinished_plan(session)\n" - " if unfinished:\n" - " await session.send_event(Event(event_type='session_update', data={\n" - " 'ac_plan': unfinished,\n" - " }))\n" - ) - c = c.replace(" # -- End of turn --", check + " # -- End of turn --") - print("OK: Added mid-task detection") - -try: - ast.parse(c) - with open(AGENT_LOOP, "w") as f: - f.write(c) - print("OK: agent_loop.py patched") -except SyntaxError as e: - print(f"FAIL: agent_loop.py syntax: {e}") - -# ===== PATCH 2: Enable auto_approval for ALL sessions by default ===== -# So free models don't block on user approval. -with open(SESSION_FILE) as f: - c = f.read() - -if "AUTO_APPROVE" not in c and "self.auto_approval" not in c: - auto_patch = ( - "\n # === PATCH: Auto-approve for free models ===\n" - " self.auto_approval = {\n" - ' "enabled": True,\n' - ' "cost_cap_usd": None,\n' - ' "estimated_spend_usd": 0.0,\n' - ' "remaining_usd": None,\n' - " }\n" - ) - - # Insert as the FIRST STATEMENT of __init__ body: find the closing - # " ):" that terminates the def __init__(...) parameter list and - # insert right after it. (Inserting into the parameter list corrupts - # the file with invalid syntax.) - init_match = re.search(r"def __init__\(.*?\):\n", c, re.DOTALL) - if init_match: - insert_at = init_match.end() - c = c[:insert_at] + auto_patch + c[insert_at:] - print("OK: Added auto_approve default") - else: - print("WARN: could not locate __init__ signature in session.py") - - try: - ast.parse(c) - with open(SESSION_FILE, "w") as f: - f.write(c) - print("OK: session.py patched & syntactically valid") - except SyntaxError as e: - print(f"FAIL: session.py syntax: {e}") - # Don't block on this patch -else: - print("OK: session.py already has auto_approval") - -print("DONE: Backend patches applied") \ No newline at end of file diff --git a/patch_apodex_ling_beatapi.py b/patch_apodex_ling_beatapi.py deleted file mode 100644 index 7781e37255be4a89e60237050c859840c50df5c6..0000000000000000000000000000000000000000 --- a/patch_apodex_ling_beatapi.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Patch backend: add apodex-1.1-mini:free, ling-3.1-flash (OpenRouter) + BeatAPI space-bunny-alpha. - -Two OpenRouter models (same `openai/` catch-all routing as the rest of the app): - - openai/apodex/apodex-1.1-mini:free - - openai/inclusionai/ling-3.1-flash - -One BEATAPI model routed to a *different* provider entirely: - - beatapi/space-bunny-alpha - -The BeatAPI id deliberately uses a `beatapi/` prefix (NOT `openai/`) so it can -never be captured by the OpenRouter catch-all branch in _resolve_llm_params. -See patch_beatapi_routing.py for the dedicated routing branch. - -This runs AFTER patch_models.py / patch_new_models.py, which already injected -the `openai/`-prefixed constants and the LING_3_0_FLASH_FREE_MODEL_ID anchor. -Idempotent: re-running is a no-op. -""" - -import ast -import re - -IDS_FILE = "/app/agent/core/model_ids.py" -AGENT_FILE = "/app/backend/routes/agent.py" - -# ---------- 1. model_ids.py ---------- -with open(IDS_FILE) as f: - ids = f.read() - -new_constants = ( - 'APODEX_1_1_MINI_FREE_MODEL_ID = "openai/apodex/apodex-1.1-mini:free"\n' - 'LING_3_1_FLASH_MODEL_ID = "openai/inclusionai/ling-3.1-flash"\n' - '# NOTE: BeatAPI model. NOT an OpenRouter id: `beatapi/` routes to\n' - '# https://api.beatapi.io/v1 and is authenticated with BEATAPI_API_KEY\n' - '# (see patch_beatapi_routing.py). Keep this prefix exclusive to BeatAPI.\n' - 'BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID = "beatapi/space-bunny-alpha"\n' -) - -missing = [ - name - for name in ( - "APODEX_1_1_MINI_FREE_MODEL_ID", - "LING_3_1_FLASH_MODEL_ID", - "BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID", - ) - if name not in ids -] - -if not missing: - print("OK: model_ids.py already has all 3 new constants (idempotent)") -else: - # Anchor: the LING_3_0_FLASH line injected by patch_new_models.py. - # Fall back to a generic OpenRouter constant line from patch_models.py. - anchors = [ - 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"', - 'NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"', - ] - inserted = False - for anchor in anchors: - if anchor in ids: - ids = ids.replace(anchor, anchor + "\n" + new_constants, 1) - print(f"OK: added constants to model_ids.py after {anchor.split(' =')[0]}") - inserted = True - break - if not inserted: - # Last resort: append right after the HOSTED_MODEL_IDS set closes, which - # is a stable upstream anchor present in every revision. - m = re.search(r"HOSTED_MODEL_IDS\s*=\s*\{[^}]*\}\n", ids) - if m: - ids = ids[: m.end()] + "\n" + new_constants + ids[m.end() :] - print("OK: added constants to model_ids.py after HOSTED_MODEL_IDS") - else: - print("FAIL: no anchor found in model_ids.py") - raise SystemExit(1) - - with open(IDS_FILE, "w") as f: - f.write(ids) - ast.parse(ids) - print("OK: model_ids.py written & syntax OK") - -# ---------- 2. agent.py : imports ---------- -with open(AGENT_FILE) as f: - ac = f.read() - -if "APODEX_1_1_MINI_FREE_MODEL_ID" not in ac: - # patch_models.py rewrote the import block to list every OpenRouter constant. - imp_anchor = " LING_3_0_FLASH_FREE_MODEL_ID,\n" - if imp_anchor in ac: - ac = ac.replace( - imp_anchor, - imp_anchor - + " APODEX_1_1_MINI_FREE_MODEL_ID,\n" - + " LING_3_1_FLASH_MODEL_ID,\n" - + " BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID,\n", - 1, - ) - print("OK: added 3 model imports to agent.py") - else: - print("FAIL: import anchor not found in agent.py") - raise SystemExit(1) - - # ---------- 3. agent.py : _available_models ---------- - line_anchor = ( - ' {"id": SPACE_BUNNY_ALPHA_MODEL_ID, "label": "stealth/space-bunny-alpha", "recommended": True},\n' - ) - new_lines = line_anchor + ( - ' # OpenRouter models (openai/ prefix -> OpenRouter)\n' - ' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n' - ' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n' - ' # BeatAPI model (beatapi/ prefix -> https://api.beatapi.io/v1)\n' - ' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n' - ) - - if line_anchor in ac: - ac = ac.replace(line_anchor, new_lines, 1) - print("OK: added 3 new models to _available_models()") - else: - # The _available_models body was fully rewritten by patch_models.py; match - # on the generic recommended-Ling line so this stays robust. - alt = ( - ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": ' - '"inclusionai/ling-3.0-flash:free", "recommended": True},\n' - ) - if alt in ac: - ac = ac.replace( - alt, - alt - + ' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n' - + ' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n' - + ' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n', - 1, - ) - print("OK: added 3 new models to _available_models() (fallback anchor)") - else: - print("FAIL: _available_models() anchor not found in agent.py") - raise SystemExit(1) - - with open(AGENT_FILE, "w") as f: - f.write(ac) - ast.parse(ac) - print("OK: agent.py written & syntax OK") - -else: - print("OK: agent.py already patched (idempotent)") - -print("DONE: apodex-1.1-mini:free, ling-3.1-flash, BeatAPI space-bunny-alpha registered") diff --git a/patch_auto_continue_v10.py b/patch_auto_continue_v10.py deleted file mode 100644 index 5a655d50b41a50d9eba7811e10fe8d3344a6734f..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v10.py +++ /dev/null @@ -1,202 +0,0 @@ -"""V10: Fixes scope issue with chat.sendMessage. - -Uses: ref to store plan, useEffect + chatActionsRef to send.""" -import os, re - -events_ts = "/source/frontend/src/types/events.ts" -sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts" -hook_ts = "/source/frontend/src/hooks/useAgentChat.ts" -input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx" -session_tsx = "/source/frontend/src/components/SessionChat.tsx" -agent_py = "/app/agent/core/agent_loop.py" - -def patch_events(): - with open(events_ts) as f: c = f.read() - if "'task_incomplete'" not in c: - c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'") - with open(events_ts, 'w') as f: f.write(c) - print("OK: events.ts") - -def patch_sse(): - with open(sse_ts) as f: c = f.read() - if "case 'task_incomplete'" not in c: - case_block = """ case 'task_incomplete': - (sideChannel as any).onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + case_block) - with open(sse_ts, 'w') as f: f.write(c) - print("OK: sse.ts") - -def patch_hook(): - with open(hook_ts) as f: - lines = f.readlines() - - # Find key insertion points by line number - result = [] - state_added = False - handler_added = False - effect_added = False - cancel_added = False - return_added = False - - for i, line in enumerate(lines): - # 1. Add useState import - if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line and "useState" not in line: - line = line.replace("useRef }", "useRef, useState }") - - # 2. Add state after callbacksRef - if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_added: - result.append(line) - result.append(' // Auto-continue for free models\n') - result.append(' const [_showAc, _setShowAc] = useState(false);\n') - result.append(' const _acRef = useRef>([]);\n') - state_added = True - continue - - # 3. Add handler in sideChannel's onSessionUpdate - if "onSessionUpdate: (data) => {" in line and not handler_added: - result.append(line) - result.append(' // Auto-continue: check for ac_plan\n') - result.append(' if ((data as any).ac_plan) {\n') - result.append(' _acRef.current = (data as any).ac_plan;\n') - result.append(' _setShowAc(true);\n') - result.append(' return;\n') - result.append(' }\n') - handler_added = True - continue - - # 4. Add cancel function + effect before return - stripped = line.strip() - if stripped == "return {" and not cancel_added: - result.append(' // Auto-continue cancel\n') - result.append(' const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n') - cancel_added = True - # Add effect that uses chatActionsRef to send message - result.append('\n') - result.append(' // Auto-continue effect: 10s timer, sends via chatActionsRef\n') - result.append(' useEffect(() => {\n') - result.append(' if (!_showAc) return;\n') - result.append(' const plan = _acRef.current;\n') - result.append(' const timer = setTimeout(() => {\n') - result.append(' const s = chatActionsRef.current.setMessages;\n') - result.append(' const m = chatActionsRef.current.messages;\n') - result.append(' if (s && plan.length > 0) {\n') - result.append(" const t = plan.map(i => `- ${i.content}`).join('\\\\n');\n") - result.append(" s([...m, { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${t}` }], content: '' }]);\n") - result.append(' }\n') - result.append(' _setShowAc(false);\n') - result.append(' }, 10000);\n') - result.append(' return () => clearTimeout(timer);\n') - result.append(' }, [_showAc]);\n') - effect_added = True - - # 5. Return values - if "refreshMessages," in line and not return_added: - result.append(line) - result.append(' _showAc,\n') - result.append(' _acCancel,\n') - return_added = True - continue - - result.append(line) - - with open(hook_ts, 'w') as f: f.writelines(result) - print(f"OK: useAgentChat.ts (added={state_added}/{handler_added}/{cancel_added}/{effect_added}/{return_added})") - -def patch_chat_input(): - with open(input_tsx) as f: c = f.read() - if "Button" not in c: - c = c.replace(" Tooltip,", " Tooltip,\n Button,") - - if "_showAc" not in c: - old_iface = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; -}""" - new_iface = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; - _showAc?: boolean; - _acCancel?: () => void; -}""" - c = c.replace(old_iface, new_iface) - old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {" - new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - c = c.replace(old_props, new_props) - - if "T\u1ea1m d\u1eebng" not in c: - btn = """ - {_showAc && _acCancel && ( - - - - )} -""" - if " list[dict[str, str]]: - p = getattr(s, "current_plan", None) or [] - return [it for it in p if it.get("status") in ("pending", "in_progress")] -""" - c = c.replace("class Handlers:", fn + "\n\nclass Handlers:") - - if "ac_plan" not in c: - check = """ - if not llm_result.tool_calls_acc and llm_result.content: - unfinished = _unfinished_plan(session) - if unfinished: - current_ac = getattr(session, "auto_approval", None) - await session.send_event(Event(event_type="session_update", data={ - "ac_plan": unfinished, - "auto_approval": current_ac, - })) -""" - marker = " # -- End of turn --" - if marker in c: - c = c.replace(marker, check + "\n" + marker) - - with open(agent_py, 'w') as f: f.write(c) - print("OK: agent_loop.py") - -if __name__ == "__main__": - patch_events(); patch_sse(); patch_hook(); patch_chat_input(); patch_session(); patch_agent() - print("\nDONE: V10") \ No newline at end of file diff --git a/patch_auto_continue_v11.py b/patch_auto_continue_v11.py deleted file mode 100644 index 3195dbaf0edf8ff7ef05d0e29817ad8267aff497..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v11.py +++ /dev/null @@ -1,185 +0,0 @@ -"""V11: Auto-continue feature - clean single-file patch. - -Backend: agent_loop.py detects model stopped mid-task, sends event. -Frontend: useAgentChat listens via onSessionUpdate, shows pause button. -All TS errors avoided via (as any) casts where needed. -""" -import os - -ROOT = "/source/frontend/src" -EVENTS = f"{ROOT}/types/events.ts" -SSE = f"{ROOT}/lib/sse-chat-transport.ts" -HOOK = f"{ROOT}/hooks/useAgentChat.ts" -INPUT = f"{ROOT}/components/Chat/ChatInput.tsx" -SESS = f"{ROOT}/components/SessionChat.tsx" -AGENT_LOOP = "/app/agent/core/agent_loop.py" - -def p(f, s): - print(f"OK: {f}" if s else f"FAIL: {f}") - -# --- PATCH 1: events.ts --- -with open(EVENTS) as f: c = f.read() -r1 = "'task_incomplete'" not in c -if r1: - c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'") - with open(EVENTS, 'w') as f: f.write(c) -p("events.ts", r1) - -# --- PATCH 2: sse-chat-transport.ts --- -with open(SSE) as f: c = f.read() -r2 = "case 'task_incomplete'" not in c -if r2: - block = """ case 'task_incomplete': - (sideChannel as any).onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + block) - with open(SSE, 'w') as f: f.write(c) -p("sse.ts", r2) - -# --- PATCH 3: useAgentChat.ts --- -with open(HOOK) as f: lines = f.readlines() -r3 = True -new_lines = [] -state_done = False -handler_done = False -efx_done = False -ret_done = False - -for line in lines: - if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line: - line = line.replace("useRef }", "useRef, useState }") - - if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_done: - new_lines.append(line) - new_lines.append(" // Auto-continue state\n") - new_lines.append(" const [_showAc, _setShowAc] = useState(false);\n") - new_lines.append(" const _acRef = useRef>([]);\n") - state_done = True - continue - - if "onSessionUpdate: (data) => {" in line and not handler_done: - new_lines.append(line) - new_lines.append(" // Auto-continue check\n") - new_lines.append(" if ((data as any).ac_plan) {\n") - new_lines.append(" _acRef.current = (data as any).ac_plan;\n") - new_lines.append(" _setShowAc(true);\n") - new_lines.append(" return;\n") - new_lines.append(" }\n") - handler_done = True - continue - - stripped = line.strip() - if stripped == "return {" and not efx_done: - new_lines.append(" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n") - new_lines.append(" useEffect(() => {\n") - new_lines.append(" if (!_showAc) return;\n") - new_lines.append(" const plan = _acRef.current;\n") - new_lines.append(" const timer = setTimeout(() => {\n") - new_lines.append(" const s = chatActionsRef.current.setMessages;\n") - new_lines.append(" const m = chatActionsRef.current.messages;\n") - new_lines.append(" if (s && plan.length > 0) {\n") - new_lines.append(" const t = plan.map(i => `- ${i.content}`).join('\\n');\n") - new_lines.append(" const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIEP TUC] Task chua hoan thanh:\\n${t}` }], content: '' };\n") - new_lines.append(" s([...m, nu]);\n") - new_lines.append(" }\n") - new_lines.append(" _setShowAc(false);\n") - new_lines.append(" }, 10000);\n") - new_lines.append(" return () => clearTimeout(timer);\n") - new_lines.append(" }, [_showAc]);\n") - efx_done = True - - if "refreshMessages," in line and not ret_done: - new_lines.append(line) - new_lines.append(" _showAc,\n") - new_lines.append(" _acCancel,\n") - ret_done = True - continue - - new_lines.append(line) - -if not all([state_done, handler_done, efx_done, ret_done]): - r3 = False - print(f" MISSING: state={state_done} handler={handler_done} effect={efx_done} ret={ret_done}") -else: - with open(HOOK, 'w') as f: f.writelines(new_lines) -p("useAgentChat.ts", r3) - -# --- PATCH 4: ChatInput.tsx --- -with open(INPUT) as f: c = f.read() -r4 = True -if "Button" not in c: - c = c.replace(" Tooltip,", " Tooltip,\n Button,") - -if "_showAc" not in c: - old = "placeholder?: string;" - new = "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;" - c = c.replace(old, new) - - old2 = "placeholder = 'Ask anything...' }: ChatInputProps) {" - new2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - c = c.replace(old2, new2) - - btn = """ - {_showAc && _acCancel && ( - - - - )} -""" - c = c.replace(") || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + block) - with open(SSE, 'w') as f: - f.write(c) -ok("sse.ts") - -# --- 3. useAgentChat.ts: add state, handler, effect, return --- -with open(HOOK) as f: - content = f.read() - -# Add useState to import -if "useState" not in content: - content = content.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - -# Add state after callbacksRef line -state_block = """\ - // Auto-continue state - const [_showAc, _setShowAc] = useState(false); - const _acRef = useRef>([]); -""" -if "_showAc" not in content: - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block - ) - -# Add handler inside onSessionUpdate -handler_code = """\ - // Auto-continue check - if ((data as any).ac_plan) { - _acRef.current = (data as any).ac_plan; - _setShowAc(true); - return; - } -""" -if "ac_plan" not in content: - content = content.replace( - "onSessionUpdate: (data) => {", - "onSessionUpdate: (data) => {\n" + handler_code - ) - -# Add cancel + effect BEFORE the two-line return marker -cancel_effect = """\ - const _acCancel = useCallback(() => { _setShowAc(false); }, []); - useEffect(() => { - if (!_showAc) return; - const plan = _acRef.current; - const timer = setTimeout(() => { - const s = chatActionsRef.current.setMessages; - const m = chatActionsRef.current.messages; - if (s && plan.length > 0) { - const t = plan.map((i: { content: string }) => `- ${i.content}`).join('\\n'); - const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIEP TUC] Task chua hoan thanh:\\n${t}` }], content: '' }; - s([...m, nu]); - } - _setShowAc(false); - }, 10000); - return () => clearTimeout(timer); - }, [_showAc]); - -""" -if "_acCancel" not in content: - # Match the EXACT two-line return pattern - content = content.replace( - " return {\n messages: chat.messages,", - cancel_effect + " return {\n messages: chat.messages," - ) - -# Add return values -if "_showAc," not in content: - content = content.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - -with open(HOOK, 'w') as f: - f.write(content) -ok("useAgentChat.ts") - -# --- 4. ChatInput.tsx: add Button + props + pause button --- -with open(INPUT) as f: - content = f.read() - -# Add Button to MUI imports - match the actual format -if "Button" not in content: - content = content.replace( - " Tooltip,", - " Tooltip,\n Button," - ) - -# Add props to interface -if "_showAc" not in content: - content = content.replace( - "placeholder?: string;", - "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;" - ) - -# Add to destructured function params -if "_showAc" not in content or "_acCancel" not in content: - content = content.replace( - "placeholder = 'Ask anything...' }: ChatInputProps) {", - "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - ) - -# Add pause button before JobsUpgradeDialog -if "Tam dung" not in content: - btn = """ - {_showAc && _acCancel && ( - - - - )} -""" - content = content.replace(") || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + block) - with open(SSE, 'w') as f: - f.write(c) -ok("sse.ts") - -# --- 3. useAgentChat.ts --- -with open(HOOK) as f: - content = f.read() - -if "useState" not in content: - content = content.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - -state_block = """\ - // Auto-continue state - const [_showAc, _setShowAc] = useState(false); - const _acRef = useRef>([]); -""" -if "_showAc" not in content: - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block - ) - -handler_code = """\ - // Auto-continue check - if ((data as any).ac_plan) { - _acRef.current = (data as any).ac_plan; - _setShowAc(true); - return; - } -""" -if "ac_plan" not in content: - content = content.replace( - "onSessionUpdate: (data) => {", - "onSessionUpdate: (data) => {\n" + handler_code - ) - -cancel_effect = """\ - const _acCancel = useCallback(() => { _setShowAc(false); }, []); - useEffect(() => { - if (!_showAc) return; - const plan = _acRef.current; - const timer = setTimeout(() => { - const s = chatActionsRef.current.setMessages; - const m = chatActionsRef.current.messages; - if (s && plan.length > 0) { - const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n'); - const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '""" + CONTINUE_TEXT + "\\\\n' + t }], content: '' };\n" -cancel_effect += """\ - s([...m, nu]); - } - _setShowAc(false); - }, 10000); - return () => clearTimeout(timer); - }, [_showAc]); - -""" -if "_acCancel" not in content: - content = content.replace( - " return {\n messages: chat.messages,", - cancel_effect + " return {\n messages: chat.messages," - ) - -if "_showAc," not in content: - content = content.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - -with open(HOOK, 'w') as f: - f.write(content) -ok("useAgentChat.ts") - -# --- 4. ChatInput.tsx --- -with open(INPUT) as f: - content = f.read() - -if "Button" not in content: - content = content.replace( - " Tooltip,", - " Tooltip,\n Button," - ) - -if "_showAc" not in content: - content = content.replace( - "placeholder?: string;", - "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;" - ) - content = content.replace( - "placeholder = 'Ask anything...' }: ChatInputProps) {", - "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - ) - -pause_marker = "TAM_DUNG" -if pause_marker not in content: - btn = """ - {_showAc && _acCancel && ( - - - - )} -""" - content = content.replace(") || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + block) - open(FILES["sse.ts"], 'w').write(c) -print("OK: sse.ts") - -# 3. hook.ts - useAgentChat.ts -c = open(FILES["hook.ts"]).read() - -# useState import -if "useState" not in c: - c = c.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - -# State after callbacksRef -if "_showAc" not in c: - state_block = ( - " // Auto-continue state\n" - " const [_showAc, _setShowAc] = useState(false);\n" - " const _acRef = useRef>([]);\n" - ) - c = c.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block - ) - -# Handler in onSessionUpdate -if "ac_plan" not in c: - handler = ( - " // Auto-continue check\n" - " if ((data as any).ac_plan) {\n" - " _acRef.current = (data as any).ac_plan;\n" - " _setShowAc(true);\n" - " return;\n" - " }\n" - ) - c = c.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + handler) - -# Cancel + effect before return -if "_acCancel" not in c: - cancel = " const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n" - effect = ( - " useEffect(() => {\n" - " if (!_showAc) return;\n" - " const plan = _acRef.current;\n" - " const timer = setTimeout(() => {\n" - " const s = chatActionsRef.current.setMessages;\n" - " const m = chatActionsRef.current.messages;\n" - " if (s && plan.length > 0) {\n" - " const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n" - " const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n" - " s([...m, nu]);\n" - " }\n" - " _setShowAc(false);\n" - " }, 10000);\n" - " return () => clearTimeout(timer);\n" - " }, [_showAc]);\n\n" - ) - c = c.replace( - " return {\n messages: chat.messages,", - cancel + effect + " return {\n messages: chat.messages," - ) - -# Return values -if "_showAc," not in c: - c = c.replace("refreshMessages,\n };", "refreshMessages,\n _showAc,\n _acCancel,\n };") - -open(FILES["hook.ts"], 'w').write(c) -print("OK: useAgentChat.ts") - -# 4. input.tsx - ChatInput.tsx -c = open(FILES["input.tsx"]).read() - -if "Button" not in c: - # Match the exact MUI import block - c = c.replace("import {\n Alert,\n Box,\n TextField,\n IconButton,\n CircularProgress,\n Typography,\n Menu,\n MenuItem,\n ListItemIcon,\n ListItemText,\n Chip,\n LinearProgress,\n Snackbar,\n Tooltip,\n} from '@mui/material';", - "import {\n Alert,\n Box,\n TextField,\n IconButton,\n CircularProgress,\n Typography,\n Menu,\n MenuItem,\n ListItemIcon,\n ListItemText,\n Chip,\n LinearProgress,\n Snackbar,\n Tooltip,\n Button,\n} from '@mui/material';") - -if "_showAc" not in c: - c = c.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;") - c = c.replace("placeholder = 'Ask anything...' }: ChatInputProps) {", - "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {") - -if "TAM_DUNG" not in c: - btn = ( - " {_showAc && _acCancel && (\n" - " \n" - " \n" - " \n" - " )}\n" - ) - c = c.replace(") || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + block) - with open(SSE, 'w') as f: f.write(c) -ok("sse.ts") - -# 3. useAgentChat.ts -with open(HOOK) as f: - lines = f.readlines() - -new_lines = [] -state_ok = hdl_ok = efx_ok = ret_ok = False - -for line in lines: - # Import useState - if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line: - line = line.replace("useRef }", "useRef, useState }") - - # State after callbacksRef - if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_ok: - new_lines.append(line) - new_lines.append(" const [_showAc, _setShowAc] = useState(false);\n") - new_lines.append(" const _acRef = useRef>([]);\n") - state_ok = True - continue - - # Handler - if "onSessionUpdate: (data) => {" in line and not hdl_ok: - new_lines.append(line) - new_lines.append(" if ((data as any).ac_plan) {\n") - new_lines.append(" _acRef.current = (data as any).ac_plan;\n") - new_lines.append(" _setShowAc(true);\n") - new_lines.append(" return;\n") - new_lines.append(" }\n") - hdl_ok = True - continue - - # Cancel + effect BEFORE 'return {' - stripped = line.strip() - if stripped == "return {" and not efx_ok: - new_lines.append(" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n") - new_lines.append(" useEffect(() => {\n") - new_lines.append(" if (!_showAc) return;\n") - new_lines.append(" const plan = _acRef.current;\n") - new_lines.append(" const timer = setTimeout(() => {\n") - new_lines.append(" const s = chatActionsRef.current.setMessages;\n") - new_lines.append(" const m = chatActionsRef.current.messages;\n") - new_lines.append(" if (s && plan.length > 0) {\n") - new_lines.append(" const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n") - new_lines.append(" const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n") - new_lines.append(" s([...m, nu]);\n") - new_lines.append(" }\n") - new_lines.append(" _setShowAc(false);\n") - new_lines.append(" }, 10000);\n") - new_lines.append(" return () => clearTimeout(timer);\n") - new_lines.append(" }, [_showAc]);\n\n") - efx_ok = True - - # Add return values after "refreshMessages," - if "refreshMessages," in line and not ret_ok: - new_lines.append(line) - new_lines.append(" _showAc,\n") - new_lines.append(" _acCancel,\n") - ret_ok = True - continue - - new_lines.append(line) - -with open(HOOK, 'w') as f: - f.writelines(new_lines) -ok(f"useAgentChat.ts (s={state_ok} h={hdl_ok} e={efx_ok} r={ret_ok})") - -# 4. ChatInput.tsx -with open(INPUT) as f: - content = f.read() - -# Check for exact Button in MUI import (not "IconButton" which contains "Button") -if " Button,\n" not in content and " Button\n" not in content: - content = content.replace( - " Tooltip,", - " Tooltip,\n Button," - ) - -# Add props to interface -if "_showAc" not in content: - content = content.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;") - content = content.replace("placeholder = 'Ask anything...' }: ChatInputProps) {", - "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {") - - # Add pause button before \n" - " \n" - " \n" - " )}\n" - ) - content = content.replace(") || [],\n );\n break;\n\n default:") - open(SSE, 'w').write(c) -ok("sse.ts") - -# --- useAgentChat.ts --- -with open(HOOK) as f: content = f.read() - -# useState import -if "useState" not in content: - content = content.replace("useRef }", "useRef, useState }") - -# State after callbacksRef (insert once) -if "_showAc" not in content: - s = (" // Auto-continue state\n" - " const [_showAc, _setShowAc] = useState(false);\n" - " const _acRef = useRef>([]);\n") - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };\n" + s - ) - -# Handler in onSessionUpdate -if "ac_plan" not in content: - h = (" // Auto-continue check\n" - " if ((data as any).ac_plan) {\n" - " _acRef.current = (data as any).ac_plan;\n" - " _setShowAc(true);\n" - " return;\n" - " }\n") - content = content.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + h) - -# CRITICAL: Only match the exact two-line return that has messages: chat.messages -# Only ONE place in the file has this exact pattern -marker = " return {\n messages: chat.messages," -if marker in content and "_acCancel" not in content: - cancel_effect = ( - " const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n" - " useEffect(() => {\n" - " if (!_showAc) return;\n" - " const plan = _acRef.current;\n" - " const timer = setTimeout(() => {\n" - " const s = chatActionsRef.current.setMessages;\n" - " const m = chatActionsRef.current.messages;\n" - " if (s && plan.length > 0) {\n" - " const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n" - " const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n" - " s([...m, nu]);\n" - " }\n" - " _setShowAc(false);\n" - " }, 10000);\n" - " return () => clearTimeout(timer);\n" - " }, [_showAc]);\n\n" - ) - content = content.replace(marker, cancel_effect + marker) - - # Add to return values - content = content.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - -with open(HOOK, 'w').write(content) -ok("useAgentChat.ts") - -# --- ChatInput.tsx --- -with open(INPUT) as f: content = f.read() - -# Button: check for standalone ", Button" or " Button" NOT "IconButton" -if ", Button,\n" not in content and ", Button\n" not in content: - content = content.replace( - " Tooltip,", - " Tooltip,\n Button," - ) - -if "_showAc" not in content: - content = content.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;") - content = content.replace("placeholder = 'Ask anything...' }: ChatInputProps) {", - "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {") - btn = (" {_showAc && _acCancel && (\n" - " \n" - " \n" - " \n" - " )}\n") - content = content.replace(") || [],\n" - " );\n" - " break;\n\n" - " default:" - ) - c = c.replace("\n default:", "\n" + block) - with open(SSE, 'w') as f: - f.write(c) -print("OK: sse.ts") - -# --- useAgentChat.ts --- -with open(HOOK) as f: - content = f.read() - -# Add useState import -if "useState" not in content: - content = content.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - -# Add state after callbacksRef -if "_showAc" not in content: - state_code = ( - " // Auto-continue state\n" - " const [_showAc, _setShowAc] = useState(false);\n" - " const _acRef = useRef>([]);\n" - ) - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_code - ) - -# Add handler in onSessionUpdate -if "ac_plan" not in content: - h = ( - " // Auto-continue check\n" - " if ((data as any).ac_plan) {\n" - " _acRef.current = (data as any).ac_plan;\n" - " _setShowAc(true);\n" - " return;\n" - " }\n" - ) - content = content.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + h) - -# ONLY match the unique two-line pattern: " return {\n messages: chat.messages," -UNIQUE_MARKER = " return {\n messages: chat.messages," -if UNIQUE_MARKER in content and "_acCancel" not in content: - insert = ( - " const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n" - " useEffect(() => {\n" - " if (!_showAc) return;\n" - " const plan = _acRef.current;\n" - " const timer = setTimeout(() => {\n" - " const s = chatActionsRef.current.setMessages;\n" - " const m = chatActionsRef.current.messages;\n" - " if (s && plan.length > 0) {\n" - " const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n" - " const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '[TIEP TUC] Task chua hoan thanh:\\\\n' + t }], content: '' };\n" - " s([...m, nu]);\n" - " }\n" - " _setShowAc(false);\n" - " }, 10000);\n" - " return () => clearTimeout(timer);\n" - " }, [_showAc]);\n\n" - ) - content = content.replace(UNIQUE_MARKER, insert + UNIQUE_MARKER) - - # Add to return - content = content.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - -with open(HOOK, 'w') as f: - f.write(content) -print("OK: useAgentChat.ts") - -# --- ChatInput.tsx --- -with open(INPUT) as f: - content = f.read() - -# Add Button (check exact substring, not "IconButton") -if " Button,\n" not in content and " Button\n" not in content: - content = content.replace( - " Tooltip,", - " Tooltip,\n Button," - ) - -if "_showAc" not in content: - content = content.replace( - "placeholder?: string;", - "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;" - ) - content = content.replace( - "placeholder = 'Ask anything...' }: ChatInputProps) {", - "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - ) - btn = ( - " {_showAc && _acCancel && (\n" - " \n" - " \n" - " \n" - " )}\n" - ) - content = content.replace(" | null>(null); - const [showAutoContinue, setShowAutoContinue] = useState(false); - const [taskIncompleteInfo, setTaskIncompleteInfo] = useState<{ - incompletePlan: Array<{ id: string; content: string; status: string }>; - } | null>(null); - const startAutoContinueRef = useRef<() => void>(() => {}); - const cancelAutoContinueRef = useRef<() => void>(() => {}); -''' - if "autoContinueTimerRef" not in content: - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state_code - ) - - # 3. Add onTaskIncomplete to SideChannelCallbacks interface - if "onTaskIncomplete:" not in content and "onInterrupted:" in content: - content = content.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # 4. Replace onTaskIncomplete handler to use refs - old_handler = "onTaskIncomplete: (incompletePlan) => {" - new_handler = "onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {" - if old_handler in content: - content = content.replace(old_handler, new_handler) - - # Fix the timer callback to use ref instead of direct call - old_timer = "autoContinueTimerRef.current = setTimeout(() => {\n startAutoContinue();\n }, 10000);" - new_timer = "autoContinueTimerRef.current = setTimeout(() => {\n startAutoContinueRef.current();\n }, 10000);" - if old_timer in content: - content = content.replace(old_timer, new_timer) - - # 5. Add auto-continue functions BEFORE return (but using refs pattern) - # Remove old startAutoContinue/cancelAutoContinue if they exist - old_funcs_start = "const startAutoContinue = useCallback" - if old_funcs_start in content: - # Find and remove the old function blocks - start_idx = content.find("const startAutoContinue = useCallback") - cancel_idx = content.find("const cancelAutoContinue = useCallback") - cleanup_idx = content.find("// Cleanup timer on unmount") - - # Remove from startAutoContinue to end of cleanup - if start_idx > 0 and cleanup_idx > start_idx: - # Find the end of cleanup effect - end_cleanup = content.find("\n", cleanup_idx) - # Find the closing of the useEffect - close_idx = content.find("}, []);", end_cleanup) - if close_idx > 0: - close_idx += len("}, []);") - content = content[:start_idx] + content[close_idx:] - - # 5b. Add new functions using ref pattern, before return - new_funcs = ''' - // -- Auto-continue for free models when task incomplete ----------------- - const startAutoContinue = useCallback(() => { - setShowAutoContinue(false); - setTaskIncompleteInfo(null); - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - autoContinueTimerRef.current = null; - } - const incompleteItems = taskIncompleteInfo?.incompletePlan || []; - const continuationText = incompleteItems.length > 0 - ? `Tiếp tục từ các task chưa hoàn thành:\\n${incompleteItems.map(i => `- ${i.content}`).join('\\n')}` - : 'Tiếp tục nhiệm vụ.'; - chat.sendMessage({ - text: `[TỰ ĐỘNG TIẾP TỤC] ${continuationText}`, - metadata: { createdAt: new Date().toISOString() }, - }); - }, [taskIncompleteInfo, chat]); - - const cancelAutoContinue = useCallback(() => { - setShowAutoContinue(false); - setTaskIncompleteInfo(null); - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - autoContinueTimerRef.current = null; - } - }, []); - - // Keep refs in sync - startAutoContinueRef.current = startAutoContinue; - cancelAutoContinueRef.current = cancelAutoContinue; - - // Cleanup timer on unmount - useEffect(() => { - return () => { - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - } - }; - }, []); -''' - - if "startAutoContinueRef.current" not in content: - content = content.replace( - '\n return {\n messages: chat.messages,', - new_funcs + '\n return {\n messages: chat.messages,' - ) - - # 6. Update return to include new values - if "showAutoContinue," not in content: - content = content.replace( - "refreshMessages,\n };", - "refreshMessages,\n showAutoContinue,\n taskIncompleteInfo,\n startAutoContinue,\n cancelAutoContinue,\n };" - ) - - with open(USE_AGENT_CHAT, "w") as f: - f.write(content) - print(f"OK: Patched {USE_AGENT_CHAT}") - -# ===================================================================== -# PATCH 3: ChatInput.tsx - Fix DatasetUploadResponse import -# ===================================================================== -CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx" - -def patch_chat_input(): - if not os.path.exists(CHAT_INPUT): - print(f"SKIP: {CHAT_INPUT} not found") - return - - with open(CHAT_INPUT, "r") as f: - content = f.read() - - # Fix DatasetUploadResponse import - try correct path - if "DatasetUploadResponse" in content: - # Check if it's imported from agent.ts - wrong_import = "import type { DatasetUploadResponse } from '@/types/agent';" - if wrong_import in content: - # Try to find the correct import - it's probably in the same file or from backend - # Replace with inline type or remove - content = content.replace(wrong_import, "") - # Add inline type - content = content.replace( - "import { apiFetch } from '@/utils/api';", - "import { apiFetch } from '@/utils/api';\n\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };" - ) - print("OK: Fixed DatasetUploadResponse import") - - # Add Button to MUI imports - if "Button" not in content: - content = content.replace( - "import { Box, IconButton, Stack, Tooltip } from '@mui/material';", - "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';" - ) - - # Check if interface already has our props (from previous patch) - if "showAutoContinue" not in content: - # Find interface and replace with complete version - interface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL) - if interface_match: - props_code = ''' -interface ChatInputProps { - sessionId: string; - initialModelPath: string | null | undefined; - onSend: (text: string) => Promise; - onStop: () => void; - onDatasetUploaded: () => Promise; - isProcessing: boolean; - disabled: boolean; - placeholder?: string; - showAutoContinue?: boolean; - taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null; - startAutoContinue?: () => void; - cancelAutoContinue?: () => void; -} -''' - content = content.replace(interface_match.group(0), props_code) - print("OK: Updated ChatInputProps interface") - - # Add auto-continue buttons - buttons_code = ''' - {/* Auto-continue controls for free models */} - {showAutoContinue && taskIncompleteInfo && ( - - - - - )} -''' - - if "Tự động tiếp tục" not in content: - content = content.replace( - " void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # Use @ts-ignore for the case since EventType union doesn't include 'task_incomplete' - # Or better: add the case with a type assertion - if "case 'task_incomplete'" in content: - # Already added, make sure it compiles - old_case = "case 'task_incomplete':\n sideChannel.onTaskIncomplete(" - new_case = "case 'task_incomplete' as const:\n sideChannel.onTaskIncomplete(" - content = content.replace(old_case, new_case) - - with open(SSE_TRANSPORT, "w") as f: - f.write(content) - print(f"OK: Patched {SSE_TRANSPORT}") - - -if __name__ == "__main__": - patch_events() - patch_use_agent_chat() - patch_chat_input() - patch_sse_transport() - print("\nDONE: All V2 patches applied") \ No newline at end of file diff --git a/patch_auto_continue_v3.py b/patch_auto_continue_v3.py deleted file mode 100644 index 146caef7b660e99063b03d1f57ce17f73d246f05..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v3.py +++ /dev/null @@ -1,229 +0,0 @@ -"""V3: FINAL - Fix remaining TS errors for auto-continue feature. - -Errors fixed: -1. TS6133: 'showAutoContinue'/'taskIncompleteInfo' never read → prefix with _ -2. TS2741: Property 'onTaskIncomplete' missing in sideChannel object → add implementation -""" - -import os -import re - -USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts" -EVENTS_FILE = "/source/frontend/src/types/events.ts" -CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx" -SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts" - -def patch_events(): - if not os.path.exists(EVENTS_FILE): - print(f"SKIP: {EVENTS_FILE} not found") - return - with open(EVENTS_FILE, "r") as f: - content = f.read() - if "'task_incomplete'" not in content: - content = content.replace( - " | 'plan_update';", - " | 'plan_update'\n | 'task_incomplete';" - ) - with open(EVENTS_FILE, "w") as f: - f.write(content) - print("OK: Added 'task_incomplete' to EventType") - -def patch_use_agent_chat(): - if not os.path.exists(USE_AGENT_CHAT): - print(f"SKIP: {USE_AGENT_CHAT} not found") - return - - with open(USE_AGENT_CHAT, "r") as f: - content = f.read() - - # 1. Add useState import - if "useState" not in content: - content = content.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # 2. Add state after callbacksRef - state_block = ''' - // Auto-continue state for free models when task incomplete - const autoContinueTimerRef = useRef | null>(null); - const [_showAutoContinue, _setShowAutoContinue] = useState(false); - const [_taskIncompleteInfo, _setTaskIncompleteInfo] = useState<{ - incompletePlan: Array<{ id: string; content: string; status: string }>; - } | null>(null); -''' - if "autoContinueTimerRef" not in content: - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block - ) - - # 3. Add onTaskIncomplete to interface - if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content: - content = content.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # 4. Add onTaskIncomplete handler in sideChannel (BEFORE onInterrupted) - handler_block = ''' onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => { - _setTaskIncompleteInfo({ incompletePlan }); - _setShowAutoContinue(true); - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - } - autoContinueTimerRef.current = setTimeout(() => { - _setShowAutoContinue(false); - _setTaskIncompleteInfo(null); - const plan = incompletePlan.map(i => `- ${i.content}`).join('\\n'); - chat.sendMessage({ - text: `[TỰ ĐỘNG TIẾP TỤC] Tiếp tục từ các task chưa hoàn thành:\\n${plan}`, - metadata: { createdAt: new Date().toISOString() }, - }); - }, 10000); - }, - onInterrupted: () => { /* no-op */ },''' - - if "onTaskIncomplete: (incompletePlan" not in content: - old_interrupted = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },' - if old_interrupted in content: - content = content.replace(old_interrupted, handler_block) - else: - # Try the simpler version - simple_interrupted = ' onInterrupted: () => { /* no-op */ },' - if simple_interrupted in content: - content = content.replace(simple_interrupted, handler_block) - - # 5. Add cleanup timer before return - cleanup = ''' - // Cleanup auto-continue timer - useEffect(() => { - return () => { - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - } - }; - }, []); -''' - if "autoContinueTimerRef.current" in content and "Cleanup timer" not in content: - content = content.replace( - '\n return {\n messages: chat.messages,', - cleanup + '\n return {\n messages: chat.messages,' - ) - - with open(USE_AGENT_CHAT, "w") as f: - f.write(content) - print(f"OK: Patched {USE_AGENT_CHAT}") - -def patch_chat_input(): - if not os.path.exists(CHAT_INPUT): - print(f"SKIP: {CHAT_INPUT} not found") - return - - with open(CHAT_INPUT, "r") as f: - content = f.read() - - # Fix DatasetUploadResponse - define inline type - if "DatasetUploadResponse" in content and "type DatasetUploadResponse" not in content: - wrong = "import type { DatasetUploadResponse } from '@/types/agent';" - if wrong in content: - content = content.replace(wrong, "") - # Add inline type after apiFetch import - content = content.replace( - "import { apiFetch } from '@/utils/api';", - "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };" - ) - - # Add Button import - if "Button" not in content: - content = content.replace( - "import { Box, IconButton, Stack, Tooltip } from '@mui/material';", - "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';" - ) - - # Fix interface - replace with complete version - if "_showAutoContinue" not in content: - interface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL) - if interface_match: - props_code = ''' -interface ChatInputProps { - sessionId: string; - initialModelPath: string | null | undefined; - onSend: (text: string) => Promise; - onStop: () => void; - onDatasetUploaded: () => Promise; - isProcessing: boolean; - disabled: boolean; - placeholder?: string; - _showAutoContinue?: boolean; - _taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null; - _onCancelAutoContinue?: () => void; -} -''' - content = content.replace(interface_match.group(0), props_code) - - # Add cancel button - buttons = ''' - {/* Auto-continue cancel button for free models */} - {_showAutoContinue && _taskIncompleteInfo && _onCancelAutoContinue && ( - - - - )} -''' - if "Tạm dừng" not in content: - content = content.replace( - " void;" in content: - content = content.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # Add case 'task_incomplete' - if "case 'task_incomplete'" not in content: - task_case = ''' case 'task_incomplete' as const: - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; -''' - content = content.replace( - "case 'turn_complete':", - task_case + "\n case 'turn_complete':" - ) - - with open(SSE_TRANSPORT, "w") as f: - f.write(content) - print(f"OK: Patched {SSE_TRANSPORT}") - -if __name__ == "__main__": - patch_events() - patch_use_agent_chat() - patch_chat_input() - patch_sse_transport() - print("\nDONE: V3 patches applied") \ No newline at end of file diff --git a/patch_auto_continue_v4.py b/patch_auto_continue_v4.py deleted file mode 100644 index d9aaa47dccd017f44378bd74d57c5384b1811f33..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v4.py +++ /dev/null @@ -1,264 +0,0 @@ -"""V4: FINAL WORKING - Fix all TS errors for auto-continue feature. - -Strategy: -- useAgentChat: return _showAutoContinue + _cancelAutoContinue (TS _ prefix suppresses "unused") -- ChatInput.tsx: receive props and render "Tạm dừng" button -- SessionChat.tsx: pass props from hook to ChatInput -- events.ts: add 'task_incomplete' to EventType union -- sse-chat-transport.ts: add onTaskIncomplete + case handler -- agent_loop.py: send task_incomplete event -""" - -import os -import re - -EVENTS = "/source/frontend/src/types/events.ts" -USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts" -SSE = "/source/frontend/src/lib/sse-chat-transport.ts" -CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx" -SESSION_CHAT = "/source/frontend/src/components/SessionChat.tsx" - - -def patch_events(): - with open(EVENTS, "r") as f: - c = f.read() - if "'task_incomplete'" not in c: - c = c.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';") - with open(EVENTS, "w") as f: f.write(c) - print("OK: events.ts") - - -def patch_sse(): - with open(SSE, "r") as f: - c = f.read() - - # Add onTaskIncomplete to interface - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # Add case handler - if "case 'task_incomplete'" not in c: - case = """ case 'task_incomplete' as const: - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; -""" - c = c.replace("case 'turn_complete':", case + "\n case 'turn_complete':") - - with open(SSE, "w") as f: f.write(c) - print("OK: sse-chat-transport.ts") - - -def patch_use_agent_chat(): - with open(USE_AGENT_CHAT, "r") as f: - c = f.read() - - # 1. Import useState - if "useState" not in c: - c = c.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # 2. State (TS _ prefix = unused ok) - state_block = ''' - // Auto-continue for free models - const _autoContinueTimer = useRef | null>(null); - const [_showAutoContinue, _setShowAutoContinue] = useState(false); -''' - if "_autoContinueTimer" not in c: - c = c.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block - ) - - # 3. onTaskIncomplete in interface - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # 4. Cancel ref - cancel_ref = ' const _cancelAutoContinue = useRef(() => {});\n' - if "_cancelAutoContinue" not in c: - # Insert after callbacksRef.current = { ... } area - c = c.replace( - " isActiveRef.current = isActive;", - " isActiveRef.current = isActive;\n" + cancel_ref - ) - - # 5. Handler in sideChannel - inline all logic - handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => { - _setShowAutoContinue(true); - if (_autoContinueTimer.current) clearTimeout(_autoContinueTimer.current); - _autoContinueTimer.current = setTimeout(() => { - _setShowAutoContinue(false); - const plan = incompletePlan.map(i => `- ${i.content}`).join('\\\\n'); - chat.sendMessage({ - text: `[TỰ ĐỘNG TIẾP TỤC] Tiếp tục từ các task chưa hoàn thành:\\\\n${plan}`, - metadata: { createdAt: new Date().toISOString() }, - }); - }, 10000); - }, - onInterrupted: () => { /* no-op */ },""" - - if "onTaskIncomplete: (incompletePlan" not in c: - old = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },' - if old in c: - c = c.replace(old, handler) - - # 6. Set cancel ref function + cleanup - cancel_func = """ - _cancelAutoContinue.current = () => { - _setShowAutoContinue(false); - if (_autoContinueTimer.current) { - clearTimeout(_autoContinueTimer.current); - _autoContinueTimer.current = null; - } - }; -""" - if "_cancelAutoContinue.current = ()" not in c: - # Insert before return - c = c.replace( - "\n return {\n messages: chat.messages,", - cancel_func + "\n return {\n messages: chat.messages," - ) - - # 7. Return values - if "_showAutoContinue," not in c: - c = c.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAutoContinue,\n _cancelAutoContinue,\n };" - ) - - with open(USE_AGENT_CHAT, "w") as f: f.write(c) - print("OK: useAgentChat.ts") - - -def patch_chat_input(): - with open(CHAT_INPUT, "r") as f: - c = f.read() - - # Fix DatasetUploadResponse - if "DatasetUploadResponse" in c and "inline" not in c: - wrong = "import type { DatasetUploadResponse } from '@/types/agent';" - if wrong in c: - c = c.replace(wrong, "") - c = c.replace( - "import { apiFetch } from '@/utils/api';", - "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };" - ) - - # Add Button import - if "Button" not in c: - c = c.replace( - "import { Box, IconButton, Stack, Tooltip } from '@mui/material';", - "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';" - ) - - # Add props to interface - if "_showAutoContinue" not in c: - match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', c, re.DOTALL) - if match: - new_iface = """interface ChatInputProps { - sessionId: string; - initialModelPath: string | null | undefined; - onSend: (text: string) => Promise; - onStop: () => void; - onDatasetUploaded: () => Promise; - isProcessing: boolean; - disabled: boolean; - placeholder?: string; - _showAutoContinue?: boolean; - _cancelAutoContinue?: React.RefObject<() => void>; -}""" - c = c.replace(match.group(0), new_iface) - - # Add pause button - btn = """ - {/* Pause auto-continue for free models */} - {_showAutoContinue && _cancelAutoContinue?.current && ( - - - - )} -""" - if "Tạm dừng" not in c: - c = c.replace("]*sessionId={sessionId}[^>]*initialModelPath={initialModelPath}[^>]*)(\s*/>)' - new_props = r'\1 _showAutoContinue={_showAutoContinue} _cancelAutoContinue={_cancelAutoContinue}\2' - c = re.sub(chat_input_pattern, new_props, c) - - with open(SESSION_CHAT, "w") as f: f.write(c) - print("OK: SessionChat.tsx") - - -def patch_agent_loop(): - agent_loop = "/app/agent/core/agent_loop.py" - if not os.path.exists(agent_loop): - print(f"SKIP: {agent_loop} not found (backend)") - return - - with open(agent_loop, "r") as f: - c = f.read() - - if "_check_task_incomplete" not in c: - func = ''' - -def _unfinished_plan_items(session: Session) -> list[dict[str, str]]: - plan = getattr(session, "current_plan", None) or [] - return [item for item in plan if item.get("status") in ("pending", "in_progress")] - -''' - c = c.replace("class Handlers:", func + "\n\nclass Handlers:") - - if "task_incomplete" not in c: - check = ''' - # === Auto-continue: if model stopped but plan incomplete === - if not llm_result.tool_calls_acc and llm_result.content: - unfinished = _unfinished_plan_items(session) - if unfinished: - await session.send_event( - Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}) - ) -''' - marker = " # -- End of turn --" - if marker in c: - c = c.replace(marker, check + "\n" + marker) - - with open(agent_loop, "w") as f: f.write(c) - print("OK: agent_loop.py") - - -if __name__ == "__main__": - patch_events() - patch_sse() - patch_use_agent_chat() - patch_chat_input() - patch_session_chat() - patch_agent_loop() - print("\nDONE: V4 - everything applied") \ No newline at end of file diff --git a/patch_auto_continue_v5.py b/patch_auto_continue_v5.py deleted file mode 100644 index 2f5b6e073a0ed84f6358a8e3f38b03210a72794a..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v5.py +++ /dev/null @@ -1,252 +0,0 @@ -"""V5: SIMPLEST approach - just show pause button with 10s auto-continue. - -Architecture: -1. Agent loop detects model stopped mid-task → sends 'task_incomplete' event -2. SSE transport receives it → calls sideChannel.onTaskIncomplete() -3. useAgentChat: onTaskIncomplete sets _showAutoContinue=true, sends continue msg -4. ChatInput shows "Tạm dừng" button if _showAutoContinue -""" - -import os, re - -EVENTS = "/source/frontend/src/types/events.ts" -SSE = "/source/frontend/src/lib/sse-chat-transport.ts" -USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts" -CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx" -AGENT_LOOP = "/app/agent/core/agent_loop.py" - - -def patch_events(): - with open(EVENTS, "r") as f: - c = f.read() - if "'task_incomplete'" not in c: - c = c.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';") - with open(EVENTS, "w") as f: - f.write(c) - print("OK: events.ts") - - -def patch_sse(): - with open(SSE, "r") as f: - c = f.read() - - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - if "case 'task_incomplete'" not in c: - case_block = """ case 'task_incomplete' as const: - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; -""" - c = c.replace("case 'turn_complete':", case_block + "\n case 'turn_complete':") - - with open(SSE, "w") as f: - f.write(c) - print("OK: sse.ts") - - -def patch_use_agent(): - with open(USE_AGENT_CHAT, "r") as f: - c = f.read() - - # 1. Import useState - if "useState" not in c: - c = c.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # 2. State (prefixed with _ = TS allows unused) - state = ''' - // Auto-continue state for free models - const _acTimer = useRef | null>(null); - const [_showAc, _setShowAc] = useState(false);''' - if "_acTimer" not in c: - c = c.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state - ) - - # 3. Add onTaskIncomplete to interface - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # 4. Add onTaskIncomplete handler - uses chatActionsRef ref instead of chat directly - handler = ''' onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => { - _setShowAc(true); - if (_acTimer.current) clearTimeout(_acTimer.current); - _acTimer.current = setTimeout(() => { - _setShowAc(false); - // Auto-continue after 10s via chatActionsRef - const chatRef = chatActionsRef.current; - const sendMsg = (chatRef as any)?.sendMessage; - if (sendMsg) { - const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n'); - sendMsg({ text: `[TIẾP TỤC] Tiếp tục task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } }); - } - }, 10000); - }, - onInterrupted: () => { /* no-op */ },''' - - if "onTaskIncomplete: (incompletePlan" not in c: - old = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },' - if old in c: - c = c.replace(old, handler) - else: - old2 = ' onInterrupted: () => { /* no-op */ },' - c = c.replace(old2, handler) - - # 5. Cancel ref + return values - if "_showAc" not in content: - # Insert cancel before return - cancel = ''' - // Expose auto-continue state - const _acCancel = useCallback(() => { - _setShowAc(false); - if (_acTimer.current) { clearTimeout(_acTimer.current); _acTimer.current = null; } - }, []); -''' - if "_acCancel" not in c: - c = c.replace('\n return {\n messages: chat.messages,', cancel + '\n return {\n messages: chat.messages,') - - if "_showAc," not in c: - c = c.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - - with open(USE_AGENT_CHAT, "w") as f: - f.write(c) - print("OK: useAgentChat.ts") - - -def patch_chat_input(): - with open(CHAT_INPUT, "r") as f: - c = f.read() - - # DatasetUploadResponse fix - if "DatasetUploadResponse" in c and "type DatasetUploadResponse" not in c: - wrong = "import type { DatasetUploadResponse } from '@/types/agent';" - if wrong in c: - c = c.replace(wrong, "") - c = c.replace( - "import { apiFetch } from '@/utils/api';", - "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };" - ) - - # Add Button - if "Button" not in c: - c = c.replace( - "import { Box, IconButton, Stack, Tooltip } from '@mui/material';", - "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';" - ) - - # Update interface - if "_showAc" not in c: - m = re.search(r'interface ChatInputProps\s*\{[^}]*\}', c, re.DOTALL) - if m: - iface = """interface ChatInputProps { - sessionId: string; - initialModelPath: string | null | undefined; - onSend: (text: string) => Promise; - onStop: () => void; - onDatasetUploaded: () => Promise; - isProcessing: boolean; - disabled: boolean; - placeholder?: string; - _showAc?: boolean; - _acCancel?: () => void; -}""" - c = c.replace(m.group(0), iface) - - # Add button - btn = """ - {/* Auto-continue pause button for free models */} - {_showAc && _acCancel && ( - - - - )} -""" - if "Tạm dừng" not in c: - c = c.replace(" list[dict[str, str]]: - plan = getattr(session, "current_plan", None) or [] - return [item for item in plan if item.get("status") in ("pending", "in_progress")] - -''' - c = c.replace("class Handlers:", fn + "\n\nclass Handlers:") - - if "task_incomplete" not in c: - check = ''' - # === Auto-continue detection === - if not llm_result.tool_calls_acc and llm_result.content: - unfinished = _unfinished_plan(session) - if unfinished: - await session.send_event( - Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}) - ) -''' - marker = " # -- End of turn --" - if marker in c: - c = c.replace(marker, check + "\n" + marker) - - with open(AGENT_LOOP, "w") as f: - f.write(c) - print("OK: agent_loop.py") - - -if __name__ == "__main__": - patch_events() - patch_sse() - patch_use_agent() - patch_chat_input() - patch_session_chat() - patch_agent_loop() - print("\nDONE: V5 - simple approach applied") \ No newline at end of file diff --git a/patch_auto_continue_v6.py b/patch_auto_continue_v6.py deleted file mode 100644 index fc8c52f4e41f128f6bbb8c8ef1dacc876612f7a9..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v6.py +++ /dev/null @@ -1,243 +0,0 @@ -"""V6: FIXED - Auto-continue with pause button for free models.""" - -import os -import re - -EVENTS_FILE = "/source/frontend/src/types/events.ts" -SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts" -HOOK_FILE = "/source/frontend/src/hooks/useAgentChat.ts" -CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx" -SESSION_CHAT = "/source/frontend/src/components/SessionChat.tsx" -AGENT_LOOP = "/app/agent/core/agent_loop.py" - - -def fix_events(): - with open(EVENTS_FILE, "r") as f: - content = f.read() - if "'task_incomplete'" not in content: - content = content.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';") - with open(EVENTS_FILE, "w") as f: - f.write(content) - print("OK: events.ts") - - -def fix_sse(): - with open(SSE_TRANSPORT, "r") as f: - content = f.read() - - # Add onTaskIncomplete to interface - if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content: - content = content.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # Add case handler - if "case 'task_incomplete'" not in content: - case_code = """ case 'task_incomplete' as const: - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; -""" - content = content.replace("case 'turn_complete':", case_code + "\n case 'turn_complete':") - - with open(SSE_TRANSPORT, "w") as f: - f.write(content) - print("OK: sse-chat-transport.ts") - - -def fix_hook(): - with open(HOOK_FILE, "r") as f: - content = f.read() - - # 1. Import useState - if "useState" not in content: - content = content.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # 2. Add state after callbacksRef block - state_block = """ - // Auto-continue for free models - const _acTimer = useRef | null>(null); - const [_showAc, _setShowAc] = useState(false); - const _acCancel = useCallback(() => { - _setShowAc(false); - if (_acTimer.current) { clearTimeout(_acTimer.current); _acTimer.current = null; } - }, []);""" - - if "_acTimer" not in content: - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block - ) - - # 3. Add onTaskIncomplete to interface - if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content: - content = content.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # 4. Add handler in sideChannel - uses chatActionsRef to avoid hoisting issues - handler_code = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => { - _setShowAc(true); - if (_acTimer.current) clearTimeout(_acTimer.current); - _acTimer.current = setTimeout(() => { - _setShowAc(false); - const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n'); - const msg = { text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } }; - const setMsgs = chatActionsRef.current.setMessages; - if (setMsgs) { - chatActionsRef.current.messages = [...chatActionsRef.current.messages, { id: 'ac-continue', role: 'user', parts: [{ type: 'text', text: msg.text }], content: msg.text }]; - } - }, 10000); - }, - onInterrupted: () => { /* no-op - handled by stop() */ },""" - - if "onTaskIncomplete: (incompletePlan" not in content: - old_handler = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },' - if old_handler in content: - content = content.replace(old_handler, handler_code) - else: - old_handler2 = ' onInterrupted: () => { /* no-op */ },' - content = content.replace(old_handler2, handler_code) - - # 5. Return values - if "_showAc" not in content: - # The _showAc and _acCancel need to be in the return - return_marker = "refreshMessages,\n };" - if return_marker in content: - content = content.replace( - return_marker, - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - - with open(HOOK_FILE, "w") as f: - f.write(content) - print("OK: useAgentChat.ts") - - -def fix_chat_input(): - with open(CHAT_INPUT, "r") as f: - content = f.read() - - # Fix DatasetUploadResponse - wrong_import = "import type { DatasetUploadResponse } from '@/types/agent';" - if wrong_import in content: - content = content.replace(wrong_import, "") - content = content.replace( - "import { apiFetch } from '@/utils/api';", - "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };" - ) - - # Add Button import - if "Button" not in content: - content = content.replace( - "import { Box, IconButton, Stack, Tooltip } from '@mui/material';", - "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';" - ) - - # Fix interface - if "_showAc" not in content: - iface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL) - if iface_match: - new_iface = """interface ChatInputProps { - sessionId: string; - initialModelPath: string | null | undefined; - onSend: (text: string) => Promise; - onStop: () => void; - onDatasetUploaded: () => Promise; - isProcessing: boolean; - disabled: boolean; - placeholder?: string; - _showAc?: boolean; - _acCancel?: () => void; -}""" - content = content.replace(iface_match.group(0), new_iface) - - # Add pause button - btn_code = """ - {/* Auto-continue pause for free models */} - {_showAc && _acCancel && ( - - - - )}""" - - if "T\u1ea1m d\u1eebng" not in content and "free models" not in content: - content = content.replace(" list[dict[str, str]]: - p = getattr(s, "current_plan", None) or [] - return [it for it in p if it.get("status") in ("pending", "in_progress")] - -''' - content = content.replace("class Handlers:", fn_block + "\n\nclass Handlers:") - - if "task_incomplete" not in content: - check_block = ''' - # Auto-continue detect - if not llm_result.tool_calls_acc and llm_result.content: - unfinished = _unfinished_plan(session) - if unfinished: - await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished})) -''' - marker = " # -- End of turn --" - if marker in content: - content = content.replace(marker, check_block + "\n" + marker) - - with open(AGENT_LOOP, "w") as f: - f.write(content) - print("OK: agent_loop.py") - - -if __name__ == "__main__": - fix_events() - fix_sse() - fix_hook() - fix_chat_input() - fix_session_chat() - fix_agent_loop() - print("\nDONE: V6 - all fixed") \ No newline at end of file diff --git a/patch_auto_continue_v7.py b/patch_auto_continue_v7.py deleted file mode 100644 index ad135a6ade573a2cedb1a94968a2b733d713f52c..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v7.py +++ /dev/null @@ -1,255 +0,0 @@ -"""V7: PRECISE patch - exact string matching with source code. - -Adds auto-continue pause button for free models. All patches use exact -string content from the original source files for reliable replacement. -""" - -import os, sys - -# ========== PATCH 1: events.ts - add 'task_incomplete' to EventType ========== -events_ts = "/source/frontend/src/types/events.ts" -def patch_events(): - with open(events_ts) as f: - c = f.read() - if "'task_incomplete'" not in c: - c = c.replace( - " | 'interrupted'", - " | 'interrupted'\n | 'task_incomplete'" - ) - with open(events_ts, 'w') as f: f.write(c) - print("OK: events.ts") - -# ========== PATCH 2: SSE transport - add onTaskIncomplete to interface + handler ========== -sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts" -def patch_sse(): - with open(sse_ts) as f: - c = f.read() - - # Add to interface - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # Add case handler before default - if "case 'task_incomplete'" not in c: - case_handler = """ case 'task_incomplete': - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + case_handler) - - with open(sse_ts, 'w') as f: f.write(c) - print("OK: sse-chat-transport.ts") - -# ========== PATCH 3: useAgentChat.ts - add state + handler + return ========== -hook_ts = "/source/frontend/src/hooks/useAgentChat.ts" -def patch_hook(): - with open(hook_ts) as f: - c = f.read() - - # 1. Add useState to imports - if "useState" not in c: - c = c.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # 2. Add state variables after callbacksRef - state_block = """ - // Auto-continue state for free models - const _acTimerRef = useRef | null>(null); - const [_showAc, _setShowAc] = useState(false);""" - if "_acTimerRef" not in c: - c = c.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block - ) - - # 3. Add onTaskIncomplete to interface - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # 4. Add onTaskIncomplete handler in sideChannel object - # This inserts BEFORE the existing onInterrupted handler - handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => { - _setShowAc(true); - if (_acTimerRef.current) clearTimeout(_acTimerRef.current); - _acTimerRef.current = setTimeout(() => { - _setShowAc(false); - const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n'); - const setMsgs = chatActionsRef.current.setMessages; - if (setMsgs) { - const msgs = chatActionsRef.current.messages; - setMsgs([...msgs, { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}` }], content: '' }]); - } - }, 10000); - }, - onInterrupted: () => { /* no-op */ },""" - - if "onTaskIncomplete: (incompletePlan" not in c: - old_interrupted = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },' - if old_interrupted in c: - c = c.replace(old_interrupted, handler) - else: - # Try simpler version - old2 = ' onInterrupted: () => { /* no-op */ },' - c = c.replace(old2, handler) - - # 5. Add _acCancel function + return values - cancel_func = """ - const _acCancel = useCallback(() => { - _setShowAc(false); - if (_acTimerRef.current) { clearTimeout(_acTimerRef.current); _acTimerRef.current = null; } - }, []);""" - if "_acCancel" not in c: - c = c.replace("\n return {\n messages: chat.messages,", cancel_func + "\n return {\n messages: chat.messages,") - - # 6. Return values - if "_showAc," not in c: - c = c.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - - with open(hook_ts, 'w') as f: f.write(c) - print("OK: useAgentChat.ts") - -# ========== PATCH 4: ChatInput.tsx - add pause button ========== -input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx" -def patch_chat_input(): - with open(input_tsx) as f: - c = f.read() - - # 1. Add Button import (it's missing from the original imports) - if "Button" not in c: - c = c.replace( - " Tooltip,", - " Tooltip,\n Button," - ) - - # 2. Add props to interface - if "_showAc" not in c: - old_interface_end = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; -}""" - new_interface = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; - _showAc?: boolean; - _acCancel?: () => void; -}""" - c = c.replace(old_interface_end, new_interface) - - # 3. Add destructured props in function signature - if "_showAc" not in c: - old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {" - new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - if old_props not in c: - old_props2 = "placeholder = 'Ask anything...' }: ChatInputProps) {" - new_props2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - c = c.replace(old_props2, new_props2) - else: - c = c.replace(old_props, new_props) - - # 4. Add pause button after the model badge area, before - if "T\u1ea1m d\u1eebng" not in c: - btn = """ - {/* Auto-continue pause for free models */} - {_showAc && _acCancel && ( - - - - )} -""" - if " list[dict[str, str]]: - p = getattr(s, "current_plan", None) or [] - return [it for it in p if it.get("status") in ("pending", "in_progress")] - -""" - c = c.replace("class Handlers:", fn + "\n\nclass Handlers:") - - # Add detection - if "task_incomplete" not in c: - check = """ - # Auto-continue detection - if not llm_result.tool_calls_acc and llm_result.content: - unfinished = _unfinished_plan(session) - if unfinished: - await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished})) -""" - marker = " # -- End of turn --" - if marker in c: - c = c.replace(marker, check + "\n" + marker) - - with open(agent_py, 'w') as f: f.write(c) - print("OK: agent_loop.py") - -if __name__ == "__main__": - patch_events() - patch_sse() - patch_hook() - patch_chat_input() - patch_session() - patch_agent() - print("\nDONE: V7 - precise patches applied") \ No newline at end of file diff --git a/patch_auto_continue_v8.py b/patch_auto_continue_v8.py deleted file mode 100644 index db27ecc1a6e0630f732fe19836d154fcc7c1d484..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v8.py +++ /dev/null @@ -1,239 +0,0 @@ -"""V8: Safe auto-continue via useEffect pattern (no hoisting issues).""" - -import os - -events_ts = "/source/frontend/src/types/events.ts" -sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts" -hook_ts = "/source/frontend/src/hooks/useAgentChat.ts" -input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx" -session_tsx = "/source/frontend/src/components/SessionChat.tsx" -agent_py = "/app/agent/core/agent_loop.py" - - -def patch_events(): - with open(events_ts) as f: - c = f.read() - if "'task_incomplete'" not in c: - c = c.replace( - " | 'interrupted'", - " | 'interrupted'\n | 'task_incomplete'" - ) - with open(events_ts, 'w') as f: f.write(c) - print("OK: events.ts") - - -def patch_sse(): - with open(sse_ts) as f: - c = f.read() - - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - if "case 'task_incomplete'" not in c: - case_handler = """ case 'task_incomplete': - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + case_handler) - - with open(sse_ts, 'w') as f: f.write(c) - print("OK: sse.ts") - - -def patch_hook(): - with open(hook_ts) as f: - c = f.read() - - # 1. Import useState - if "useState" not in c: - c = c.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # 2. State after callbacksRef - state = """ - // Auto-continue state for free models - const [_showAc, _setShowAc] = useState(false); - const _acPlanRef = useRef>([]);""" - if "_showAc" not in c: - c = c.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state - ) - - # 3. onTaskIncomplete in interface - if "onTaskIncomplete:" not in c: - c = c.replace( - " onInterrupted: () => void;", - " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - # 4. Handler in sideChannel (only set state + ref, no chat.sendMessage) - handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => { - _acPlanRef.current = incompletePlan; - _setShowAc(true); - }, - onInterrupted: () => { /* no-op */ },""" - - if "onTaskIncomplete: (incompletePlan" not in c: - old = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },' - if old in c: - c = c.replace(old, handler) - else: - c = c.replace(' onInterrupted: () => { /* no-op */ },', handler) - - # 5. useEffect to watch _showAc - runs timer, calls chat.sendMessage via chatActionsRef - effect = """ - // Auto-continue effect: when _showAc becomes true, start 10s timer - useEffect(() => { - if (!_showAc) return; - const timer = setTimeout(() => { - const plan = _acPlanRef.current; - const setMsgs = chatActionsRef.current.setMessages; - const msgs = chatActionsRef.current.messages; - if (setMsgs && plan.length > 0) { - const planStr = plan.map(i => `- ${i.content}`).join('\\\\n'); - const newMsg = { id: 'ac-' + Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}` }], content: '' }; - setMsgs([...msgs, newMsg]); - } - _setShowAc(false); - }, 10000); - return () => clearTimeout(timer); - }, [_showAc]); -""" - if "Auto-continue effect" not in c: - # Insert before return - c = c.replace("\n return {\n messages: chat.messages,", effect + "\n return {\n messages: chat.messages,") - - # 6. Cancel + return - cancel = """ - const _acCancel = useCallback(() => { _setShowAc(false); }, []);""" - if "_acCancel" not in c: - c = c.replace("\n return {\n messages: chat.messages,", cancel + "\n return {\n messages: chat.messages,") - if "_showAc," not in c: - c = c.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - - with open(hook_ts, 'w') as f: f.write(c) - print("OK: useAgentChat.ts") - - -def patch_chat_input(): - with open(input_tsx) as f: - c = f.read() - - if "Button" not in c: - c = c.replace(" Tooltip,", " Tooltip,\n Button,") - - if "_showAc" not in c: - old_iface = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; -}""" - new_iface = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; - _showAc?: boolean; - _acCancel?: () => void; -}""" - c = c.replace(old_iface, new_iface) - - old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {" - new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - if old_props not in c: - old_props2 = "placeholder = 'Ask anything...' }: ChatInputProps) {" - new_props2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - c = c.replace(old_props2, new_props2) - else: - c = c.replace(old_props, new_props) - - if "T\u1ea1m d\u1eebng" not in c: - btn = """ - {_showAc && _acCancel && ( - - - - )} -""" - if " list[dict[str, str]]: - p = getattr(s, "current_plan", None) or [] - return [it for it in p if it.get("status") in ("pending", "in_progress")] -""" - c = c.replace("class Handlers:", fn + "\n\nclass Handlers:") - - if "task_incomplete" not in c: - check = """ - # Auto-continue detection - if not llm_result.tool_calls_acc and llm_result.content: - unfinished = _unfinished_plan(session) - if unfinished: - await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished})) -""" - marker = " # -- End of turn --" - if marker in c: - c = c.replace(marker, check + "\n" + marker) - - with open(agent_py, 'w') as f: f.write(c) - print("OK: agent_loop.py") - - -if __name__ == "__main__": - patch_events() - patch_sse() - patch_hook() - patch_chat_input() - patch_session() - patch_agent() - print("\nDONE: V8 - safe useEffect pattern") \ No newline at end of file diff --git a/patch_auto_continue_v9.py b/patch_auto_continue_v9.py deleted file mode 100644 index de1da46135b1e6c25530b11dd5343d29caf1d926..0000000000000000000000000000000000000000 --- a/patch_auto_continue_v9.py +++ /dev/null @@ -1,227 +0,0 @@ -"""V9: SIMPLEST approach - no interface changes, no props chain. - -Uses CustomEvent to communicate between sideChannel and React state. -This avoids ALL TypeScript issues: -- No interface changes needed -- No hoisting issues -- No unused variable warnings -""" - -import os - -events_ts = "/source/frontend/src/types/events.ts" -sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts" -hook_ts = "/source/frontend/src/hooks/useAgentChat.ts" -input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx" -agent_py = "/app/agent/core/agent_loop.py" - - -def patch_events(): - with open(events_ts) as f: - c = f.read() - if "'task_incomplete'" not in c: - c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'") - with open(events_ts, 'w') as f: f.write(c) - print("OK: events.ts") - - -def patch_sse(): - with open(sse_ts) as f: - c = f.read() - - # Add case handler DIRECTLY without changing interface - # Use as any cast to bypass type checking - if "case 'task_incomplete'" not in c: - # Add handler before default, using (sideChannel as any) to avoid interface issue - case_block = """ case 'task_incomplete': - (sideChannel as any).onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; - - default:""" - c = c.replace("\n default:", "\n" + case_block) - - with open(sse_ts, 'w') as f: f.write(c) - print("OK: sse.ts") - - -def patch_hook(): - with open(hook_ts) as f: - c = f.read() - - # 1. Import useState - if "useState" not in c: - c = c.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # 2. Add state after callbacksRef - TS will complain "unused" but _ prefix helps - state_block = """ - // Auto-continue for free models - const [_showAc, _setShowAc] = useState(false);""" - if "_showAc" not in c: - c = c.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block - ) - - # 3. Add onTaskIncomplete to the sideChannel object via (sideChannel as any) - # We use the onSessionUpdate handler as injection point since it already exists - handler_block = """ onSessionUpdate: (data) => { - // Check for auto-continue trigger - if ((data as any).ac_plan) { - const plan = (data as any).ac_plan as Array<{ id: string; content: string; status: string }>; - _setShowAc(true); - setTimeout(() => { - _setShowAc(false); - if (plan.length > 0) { - const planStr = plan.map(i => `- ${i.content}`).join('\\\\n'); - chat.sendMessage({ text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } }); - } - }, 10000); - return; - } - const autoApproval = data.auto_approval;""" - - if "_setShowAc" in c and "ac_plan" not in c: - # Find existing onSessionUpdate - old = """ onSessionUpdate: (data) => { - const autoApproval = data.auto_approval;""" - if old in c: - c = c.replace(old, handler_block) - print("OK: injected onSessionUpdate handler") - - # 4. Cancel function + return values - cancel = """ - const _acCancel = useCallback(() => { _setShowAc(false); }, []);""" - if "_acCancel" not in c and "_showAc" in c: - c = c.replace("\n return {\n messages: chat.messages,", cancel + "\n return {\n messages: chat.messages,") - c = c.replace( - "refreshMessages,\n };", - "refreshMessages,\n _showAc,\n _acCancel,\n };" - ) - - with open(hook_ts, 'w') as f: f.write(c) - print("OK: useAgentChat.ts") - - -def patch_chat_input(): - with open(input_tsx) as f: - c = f.read() - - if "Button" not in c: - c = c.replace(" Tooltip,", " Tooltip,\n Button,") - - if "_showAc" not in c: - old_iface = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; -}""" - new_iface = """interface ChatInputProps { - sessionId?: string; - initialModelPath?: string | null; - onSend: (text: string) => void; - onStop?: () => void; - onDatasetUploaded?: () => Promise | boolean; - isProcessing?: boolean; - disabled?: boolean; - placeholder?: string; - _showAc?: boolean; - _acCancel?: () => void; -}""" - c = c.replace(old_iface, new_iface) - - # Add destructured props - old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {" - new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {" - c = c.replace(old_props, new_props) - - if "T\u1ea1m d\u1eebng" not in c: - btn = """ - {_showAc && _acCancel && ( - - - - )} -""" - if " list[dict[str, str]]: - p = getattr(s, "current_plan", None) or [] - return [it for it in p if it.get("status") in ("pending", "in_progress")] -""" - c = c.replace("class Handlers:", fn + "\n\nclass Handlers:") - - if "task_incomplete" not in c: - check = """ - # Auto-continue detection - if not llm_result.tool_calls_acc and llm_result.content: - unfinished = _unfinished_plan(session) - if unfinished: - # Use session_update event with ac_plan field to trigger auto-continue - await session.send_event(Event(event_type="session_update", data={ - "ac_plan": unfinished, - "auto_approval": getattr(session, "auto_approval", None), - })) -""" - marker = " # -- End of turn --" - if marker in c: - c = c.replace(marker, check + "\n" + marker) - else: - print("WARN: Could not find -- End of turn -- marker") - # Try alternative: find where final_response is set - marker2 = "final_response = llm_result.content or None" - if marker2 in c: - c = c.replace(marker2, marker2 + "\n" + check) - - with open(agent_py, 'w') as f: f.write(c) - print("OK: agent_loop.py") - - -if __name__ == "__main__": - patch_events() - patch_sse() - patch_hook() - patch_chat_input() - patch_session() - patch_agent() - print("\nDONE: V9 applied") - print("Strategy: Uses existing session_update event + (sideChannel as any) to avoid TS issues") \ No newline at end of file diff --git a/patch_beatapi_routing.py b/patch_beatapi_routing.py deleted file mode 100644 index 79e77ea35c82a61706dd5091c4ddb5744fe6ff1f..0000000000000000000000000000000000000000 --- a/patch_beatapi_routing.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Patch llm_params.py: add a DEDICATED BeatAPI provider branch. - -BEATAPI vs OpenRouter -- why this branch is separate and must stay separate -========================================================================= -Every other model in this app except the HF Router / local-ollama ones is an -*OpenRouter* model. They are all tagged with an `openai/` prefix, and -_resolve_llm_params has a single catch-all that: - - 1. strips the `openai/` prefix, - 2. points api_base at https://openrouter.ai/api/v1, - 3. authenticates with OPENROUTER_API_KEY. - -BeatAPI (https://api.beatapi.io) is a *different provider* that happens to -expose an OpenAI-compatible surface. It must therefore NOT reuse that branch: - - provider OpenRouter BeatAPI - ----------- ----------------------------- ------------------------------ - api_base https://openrouter.ai/api/v1 https://api.beatapi.io/v1 - key env OPENROUTER_API_KEY BEATAPI_API_KEY - model id vendor/model space-bunny-alpha (bare, no vendor prefix) - effort unsupported (omitted) low/medium/high/xhigh/max via extra_body - -BeatAPI additionally: - - does not understand the `openai/` prefix at all, so sending - "openai/space-bunny-alpha" or "openai/stealth/space-bunny-alpha" 404s; - - uses `space-bunny-alpha` as a *flat* model id, so we must strip our own - `beatapi/` routing prefix before the request leaves the app; - - rate-limits the free tier (1 req/min before first top-up, 10/min after, - 1000/day) and returns HTTP 429 with Retry-After. - -Therefore BeatAPI models use the `beatapi/` prefix -- a namespace nothing else -uses -- so this branch can never be shadowed by the `openai/` catch-all, and -the OpenRouter branch can never accidentally strip a BeatAPI model id. - -If BEATAPI_API_KEY is unset we raise a clear, actionable error instead of -silently sending an empty key (which BeatAPI answers with a bare 401 -missing_api_key). - -Runs AFTER patch_models.py and patch_openrouter_fix.py. Idempotent. -""" - -import ast - -FILE = "/app/agent/core/llm_params.py" - -BEATAPI_BASE_URL = "https://api.beatapi.io/v1" -BEATAPI_MODEL_PREFIX = "beatapi/" - - -with open(FILE) as f: - c = f.read() - -if "BeatApiKeyMissingError" in c: - print("OK: llm_params.py already contains BeatAPI routing (idempotent)") - raise SystemExit(0) - -if "\nimport os\n" not in c: - print("FAIL: expected `import os` in llm_params.py") - raise SystemExit(1) - -# --- Insert the BeatAPI block before _resolve_llm_params --- -anchor = "def _resolve_llm_params(\n" -if anchor not in c: - print("FAIL: could not find _resolve_llm_params definition") - raise SystemExit(1) - -beatapi_block = f'''BEATAPI_BASE_URL = "{BEATAPI_BASE_URL}" -BEATAPI_MODEL_PREFIX = "{BEATAPI_MODEL_PREFIX}" -# BeatAPI accepts low/medium/high/xhigh/max (HF Router only low/medium/high). -_BEATAPI_EFFORTS = {{"low", "medium", "high", "xhigh", "max"}} - - -class BeatApiKeyMissingError(ValueError): - """BEATAPI_API_KEY is not configured, so BeatAPI cannot be called. - - Raised before any network call so the UI shows an actionable message - ("set BEATAPI_API_KEY") instead of an opaque 401 missing_api_key. - """ - - -def _beatapi_base_model(model_name: str) -> str: - """Return the wire model id for a BeatAPI request. - - ``beatapi/space-bunny-alpha`` -> ``space-bunny-alpha`` - BeatAPI model ids are flat; they must never carry our routing prefix. - """ - return model_name[len(BEATAPI_MODEL_PREFIX):].strip() - - -def _resolve_beatapi_params( - model_name: str, - reasoning_effort: str | None = None, - strict: bool = False, -) -> dict: - """Build LiteLLM kwargs for a BeatAPI (``beatapi/``-prefixed) model. - - Mirrors the OpenRouter branch but targets api.beatapi.io, uses - BEATAPI_API_KEY, forces LiteLLM's OpenAI-compatible client, and forwards - BeatAPI's own reasoning_effort levels (it accepts xhigh and max, which the - HF Router does not). - """ - base_model = _beatapi_base_model(model_name) - if not base_model: - raise ValueError(f"Missing BeatAPI model id: {{model_name!r}}") - - api_key = os.environ.get("BEATAPI_API_KEY", "").strip() - if not api_key: - raise BeatApiKeyMissingError( - "BEATAPI_API_KEY is not set. BeatAPI models (beatapi/...) require a " - "BeatAPI key from https://beatapi.io (Dashboard -> API Keys); " - "set it as a Space secret named BEATAPI_API_KEY." - ) - - params: dict = {{ - "model": f"openai/{{base_model}}", - "api_base": BEATAPI_BASE_URL, - "api_key": api_key, - # Force LiteLLM's OpenAI-compatible client. Without this, LiteLLM would - # try to resolve a provider from the model string and raise - # "LLM Provider NOT provided" (same class of bug as the OpenRouter fix). - "custom_llm_provider": "openai", - }} - - if reasoning_effort: - level = reasoning_effort - if level not in _BEATAPI_EFFORTS: - if strict: - raise UnsupportedEffortError( - f"BeatAPI doesn't accept effort={{level!r}}" - ) - else: - # BeatAPI reads reasoning_effort from the request body directly. - params["extra_body"] = {{"reasoning_effort": level}} - return params - - -''' - -c = c.replace(anchor, beatapi_block + anchor, 1) - -# --- Add the dispatch inside _resolve_llm_params, BEFORE the openai/ catch-all --- -# The dispatch must sit after the local-model checks and before the OpenRouter -# branch so `beatapi/` can never be swallowed by `openai/`. -dispatch_anchor = " hf_model = normalized_model\n" -if dispatch_anchor not in c: - print("FAIL: could not find hf_model assignment in _resolve_llm_params") - raise SystemExit(1) - -dispatch = """ # --- BeatAPI provider (beatapi/ prefix) ------------------------------- - # NOTE: distinct from the OpenRouter branch below on api_base, API key, - # model-id shape and reasoning_effort levels. Keep this check above the - # openai/ catch-all so the two providers can never collide. - if normalized_model.startswith(BEATAPI_MODEL_PREFIX): - return _resolve_beatapi_params( - normalized_model, reasoning_effort, strict - ) - -""" - -c = c.replace(dispatch_anchor, dispatch + dispatch_anchor, 1) - -with open(FILE, "w") as f: - f.write(c) - -try: - ast.parse(c) - print("OK: llm_params.py patched & syntax valid") -except SyntaxError as e: - print(f"FAIL: llm_params.py syntax error: {e}") - raise - -# Sanity: the BeatAPI branch must appear before the openai/ catch-all. -beat_i = c.find("startswith(BEATAPI_MODEL_PREFIX)") -openai_i = c.find('startswith("openai/")') -if beat_i != -1 and openai_i != -1: - if beat_i < openai_i: - print("OK: BeatAPI dispatch precedes the openai/ catch-all") - else: - print("FAIL: BeatAPI dispatch is AFTER the openai/ catch-all") - raise SystemExit(1) - -print("DONE: BeatAPI routing branch added to llm_params.py") diff --git a/patch_catch_up_dataset_fallback.py b/patch_catch_up_dataset_fallback.py deleted file mode 100644 index c1b8920feca87d5e43406f2eb34fb625c45e8381..0000000000000000000000000000000000000000 --- a/patch_catch_up_dataset_fallback.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch frontend: Add dataset fallback to ExpiredBanner handleCatchUp. - -When localStorage caches (backend-messages, ui-messages) are empty -(user cleared cache, new browser, Space restarted), the catch-up flow -shows "Nothing to summarize from this chat.". This patch adds a fallback -that fetches messages from /api/session-history/{session_id} — which reads -from the permanent HF dataset (bep40/ml-intern-sessions). - -This ensures the "Catch me up" button always works as long as the -session was previously logged to the permanent dataset. -""" - -import os -import sys - -PATCHED_MARKER = "// === PATCH: catch-up dataset fallback ===" -TARGET_FILE = "/source/frontend/src/components/Chat/ExpiredBanner.tsx" - - -def patch_expired_banner(content: str) -> tuple[str, bool]: - changed = False - - if PATCHED_MARKER in content: - print(f" SKIP: already patched") - return content, False - - # Add a fallback to /api/session-history/{session_id} before the error. - # The old block checks if messages array is empty and bails with error. - old_block = ( - " if (!messages || messages.length === 0) {\n" - " setError('Nothing to summarize from this chat.');\n" - " setBusy(null);\n" - " return;\n" - " }\n" - ) - new_block = ( - PATCHED_MARKER + "\n" - " // Fallback: when localStorage caches are empty (Space restart,\n" - " // new browser, cache cleared), read from the permanent HF dataset\n" - " // via /api/session-history/{session_id}.\n" - " if (!messages || messages.length === 0) {\n" - " try {\n" - " const res = await apiFetch(`/api/session-history/${sessionId}`);\n" - " if (res.ok) {\n" - " const history = await res.json();\n" - " if (Array.isArray(history) && history.length > 0) {\n" - " messages = history.map((row: any) => ({\n" - " role: row.role || 'assistant',\n" - " content: row.content || '',\n" - " }));\n" - " }\n" - " }\n" - " } catch {\n" - " /* dataset fetch failed — continue to error below */\n" - " }\n" - " }\n" - " if (!messages || messages.length === 0) {\n" - " setError('Nothing to summarize from this chat.');\n" - " setBusy(null);\n" - " return;\n" - " }\n" - ) - if old_block in content: - content = content.replace(old_block, new_block, 1) - changed = True - print(" + Added dataset fallback to handleCatchUp") - else: - print(" SKIP: target block not found (pattern may have changed)") - - return content, changed - - -def main(): - if not os.path.exists(TARGET_FILE): - print(f"SKIP: {TARGET_FILE} not found — frontend source not available") - return - - with open(TARGET_FILE, "r", encoding="utf-8") as f: - content = f.read() - - if PATCHED_MARKER in content: - print(f"SKIP: {TARGET_FILE} already patched") - return - - content, changed = patch_expired_banner(content) - if changed: - with open(TARGET_FILE, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: {TARGET_FILE} patched with dataset fallback") - else: - print(f"SKIP: {TARGET_FILE} — no changes applied") - - -if __name__ == "__main__": - try: - main() - except Exception as e: - print(f"WARN: patch_catch_up_dataset_fallback.py error: {e}") - # Never fail the Docker build - sys.exit(0) diff --git a/patch_catch_up_fix_backend.py b/patch_catch_up_fix_backend.py deleted file mode 100644 index 9b90b2ece353433a7792835ad6e5ca9b9785f2ed..0000000000000000000000000000000000000000 --- a/patch_catch_up_fix_backend.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch: Fix catch-up backend robustness. - -Issues fixed: -1. /api/session-history/{session_id} returns ALL rows including 'event' type - rows (role='event'). These cause Message.model_validate to silently drop them. - Fix: Filter out non-message roles (event, system) from history endpoint. - -2. seed_from_summary raises if summarization fails (API error, rate limit, credits), - causing restore-summary to return 500 and the frontend to show "Couldn't catch up." - Fix: Fall back to raw messages if summarization fails. - -3. Event rows with role='event' fail Message.model_validate. - Fix: Skip event rows explicitly in seed_from_summary. -""" - -import ast -import os -import sys - -PATCHED_MARKER = "# === PATCH: catch-up fix (backend) ===" - - -def _safe_replace(content, old, new, count=1): - if old in content: - content = content.replace(old, new, count) - return content, True - return content, False - - -def patch_file(path, patch_fn): - if not os.path.exists(path): - print(f"SKIP: {path} not found") - return False - try: - with open(path, "r") as f: - content = f.read() - content, changed = patch_fn(content) - if not changed: - print(f"SKIP: {path} — no changes needed") - return False - try: - ast.parse(content) - except SyntaxError as e: - print(f"FAIL: {path} syntax: {e}") - return False - with open(path, "w") as f: - f.write(content) - print(f"OK: {path} patched (catch-up fix)") - return True - except Exception as e: - print(f"WARN: {path} patch error: {e}") - return False - - -# ─── Patch routes/agent.py: filter event rows from session-history ───────── -print("=== Patching routes/agent.py (session-history filter) ===") - -ROUTES_AGENT = "/app/backend/routes/agent.py" - -def _patch_routes(content): - changed = False - - if PATCHED_MARKER in content: - print(" SKIP: already patched") - return content, False - - # Patch the get_session_history endpoint to filter out event rows - old_endpoint = ( - '@router.get("/session-history/{session_id}")\n' - 'async def get_session_history(\n' - ' session_id: str, user: dict = Depends(get_current_user)\n' - ') -> list:\n' - ' """Get full message history for a session from the permanent dataset."""\n' - ' from backend.session_logger import load_session_history\n' - ' return load_session_history(session_id)' - ) - - new_endpoint = ( - '@router.get("/session-history/{session_id}")\n' - 'async def get_session_history(\n' - ' session_id: str, user: dict = Depends(get_current_user)\n' - ') -> list:\n' - ' """Get full message history for a session from the permanent dataset.\n' - '\n' - ' Returns only valid conversation messages (user/assistant/tool roles).\n' - " Event rows (role='event') are filtered out because they are internal\n" - ' tracing data that cannot be parsed as Message objects and would be\n' - ' silently dropped during catch-up, causing message loss.\n' - ' """\n' - ' from backend.session_logger import load_session_history\n' - ' rows = load_session_history(session_id)\n' - ' # Filter out event rows and any rows without valid roles\n' - ' valid_roles = {"user", "assistant", "tool"}\n' - ' return [r for r in rows if r.get("role") in valid_roles]' - ) - - content, ok = _safe_replace(content, old_endpoint, new_endpoint) - if ok: - print(" + Filtered event rows from session-history endpoint") - changed = True - else: - print(" SKIP: target block not found") - - # Add marker - content = content.rstrip() + "\n\n" + PATCHED_MARKER + "\n" - changed = True - - return content, changed - -patch_file(ROUTES_AGENT, _patch_routes) - -# ─── Patch session_manager.py: robust seed_from_summary ───────────────────── -print("\n=== Patching session_manager.py (seed_from_summary robustness) ===") - -SESSION_MANAGER = "/app/backend/session_manager.py" - -def _patch_seed_from_summary(content): - changed = False - - # Check if already patched - if PATCHED_MARKER in content: - print(" SKIP: already patched") - return content, False - - # Patch 1: Skip event rows explicitly in seed_from_summary - old_parse = ( - ' # Parse into Message objects, tolerating malformed entries.\n' - ' parsed: list[Message] = []\n' - ' for raw in messages:\n' - ' if raw.get("role") == "system":\n' - ' continue # the new session has its own system prompt\n' - ' try:\n' - ' parsed.append(Message.model_validate(raw))\n' - ' except Exception as e:\n' - ' logger.warning("Dropping malformed message during seed: %s", e)' - ) - - new_parse = ( - ' # Parse into Message objects, tolerating malformed entries.\n' - ' # === PATCH: skip event rows (not just system) ===\n' - ' parsed: list[Message] = []\n' - ' for raw in messages:\n' - ' role = raw.get("role", "")\n' - ' if role in ("system", "event"):\n' - ' continue # not conversation messages\n' - ' try:\n' - ' parsed.append(Message.model_validate(raw))\n' - ' except Exception as e:\n' - ' logger.warning("Dropping malformed message during seed: %s", e)' - ) - - content, ok = _safe_replace(content, old_parse, new_parse) - if ok: - print(" + Skip event rows explicitly in seed_from_summary") - changed = True - - # Patch 2: Fall back to raw messages if summarization fails - old_try = ( - ' try:\n' - ' summary, _ = await summarize_messages(\n' - ' parsed,\n' - ' model_name=session.config.model_name,\n' - ' hf_token=session.hf_token,\n' - ' max_tokens=4000,\n' - ' prompt=_RESTORE_PROMPT,\n' - ' tool_specs=tool_specs,\n' - ' session=session,\n' - ' kind="restore",\n' - ' )\n' - ' except Exception as e:\n' - ' logger.error("Summary call failed during seed: %s", e)\n' - ' raise\n' - '\n' - ' seed = Message(' - ) - - new_try = ( - ' # === PATCH: fall back to raw messages if summarization fails ===\n' - ' summary = None\n' - ' try:\n' - ' summary, _ = await summarize_messages(\n' - ' parsed,\n' - ' model_name=session.config.model_name,\n' - ' hf_token=session.hf_token,\n' - ' max_tokens=4000,\n' - ' prompt=_RESTORE_PROMPT,\n' - ' tool_specs=tool_specs,\n' - ' session=session,\n' - ' kind="restore",\n' - ' )\n' - ' except Exception as e:\n' - ' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\n' - ' # Fall back: inject raw messages directly into the new session context\n' - ' # This preserves the full conversation instead of losing it\n' - ' for msg in parsed:\n' - ' session.context_manager.items.append(msg)\n' - ' self._touch(agent_session)\n' - ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n' - ' return len(parsed)\n' - '\n' - ' seed = Message(' - ) - - content, ok = _safe_replace(content, old_try, new_try) - if ok: - print(" + Fall back to raw messages if summarization fails") - changed = True - - # Add marker - if changed: - content = content + "\n" + PATCHED_MARKER + "\n" - changed = True - - return content, changed - -patch_file(SESSION_MANAGER, _patch_seed_from_summary) - -print("\n=== Backend catch-up fixes applied ===") diff --git a/patch_catch_up_fix_frontend.py b/patch_catch_up_fix_frontend.py deleted file mode 100644 index be2be459d49419db4c31f9ad73574860569927eb..0000000000000000000000000000000000000000 --- a/patch_catch_up_fix_frontend.py +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch: Fix frontend catch-up message loss. - -Issue: After catch-up succeeds, the new session's backend hydration -fetches /api/session/{newId}/messages from the backend, which only -contains the summary message. It then calls setMessages() and -saveMessages(), OVERWRITING the old full messages that were moved to -the new ID via renameSession -> moveMessages. - -Fix: In each hydration code path, only overwrite frontend messages if -the backend returns MORE non-system messages than the frontend already has. -If the frontend already has more messages (from localStorage), keep them. - -Also: Fix the catch-up dataset fallback in ExpiredBanner to properly -filter event rows and handle malformed data. - -Runs during Docker build — never fails the build. -""" - -import ast -import os -import sys - -PATCHED_MARKER = "// PATCH: catch-up fix (frontend)" - - -def _safe_replace(content, old, new, count=1): - if old in content: - content = content.replace(old, new, count) - return content, True - return content, False - - -def _is_python_file(path): - return path.endswith(".py") - - -def patch_file(path, patch_fn): - if not os.path.exists(path): - print(f"SKIP: {path} not found") - return False - try: - with open(path, "r", encoding="utf-8") as f: - content = f.read() - content, changed = patch_fn(content) - if not changed: - print(f"SKIP: {path} — no changes needed") - return False - # Only validate Python syntax for .py files - if _is_python_file(path): - try: - ast.parse(content) - except SyntaxError as e: - print(f"FAIL: {path} syntax: {e}") - return False - with open(path, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: {path} patched (catch-up fix)") - return True - except Exception as e: - print(f"WARN: {path} patch error: {e}") - return False - - -# ─── Patch useAgentChat.ts ────────────────────────────────────────────────── -print("=== Patching useAgentChat.ts (prevent message overwrite) ===") - -USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts" - -def _patch_use_agent_chat(content): - changed = False - - if PATCHED_MARKER in content: - print(" SKIP: already patched") - return content, False - - # Patch 1: onRecoverMessages - # The pattern uses 10-space indentation (inside a nested callback) - old_pattern1 = ( - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && uiMsgs.length >= currentMessageCount) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - new_pattern1 = ( - " // === PATCH: catch-up fix (frontend) ===\n" - " // After catch-up, the backend only has a summary message but\n" - " // the frontend (via localStorage moveMessages) has the full\n" - " // old conversation. Don't overwrite if backend has fewer.\n" - " const _currentNonSystem = chatActionsRef.current.messages.filter(\n" - " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n" - " );\n" - " const _shouldOverwrite = uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0;\n" - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && _shouldOverwrite) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - content, ok = _safe_replace(content, old_pattern1, new_pattern1) - if ok: - print(" + Prevented backend overwrite in onRecoverMessages") - changed = True - else: - print(" INFO: onRecoverMessages pattern not found") - - # Patch 2: refreshMessages - # The pattern uses 6-space indentation and ends with 'return true;' - old_pattern2 = ( - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && uiMsgs.length > 0) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }\n" - " return true;" - ) - - new_pattern2 = ( - " // === PATCH: catch-up fix (frontend) ===\n" - " const _currentNonSystem2 = chatActionsRef.current.messages.filter(\n" - " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n" - " );\n" - " const _shouldOverwrite2 = uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0;\n" - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && _shouldOverwrite2) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }\n" - " return true;" - ) - - content, ok2 = _safe_replace(content, old_pattern2, new_pattern2) - if ok2: - print(" + Prevented backend overwrite in refreshMessages") - changed = True - else: - print(" INFO: refreshMessages pattern not found") - - # Patch 3: Main hydration useEffect (uses chat.setMessages) - # There are multiple code paths with different indentation levels: - # - 6-space indent: in some code paths - # - 10-space indent: main hydration useEffect - # - 14-space indent: hydrateMessages helper (appears twice) - # We patch each indentation level separately. - - old_pattern3a = ( - " if (uiMsgs.length > 0) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - new_pattern3a = ( - " // === PATCH: catch-up fix (frontend) ===\n" - " // Don't overwrite frontend messages with backend's fewer messages\n" - " const _currentMsgs = (chatActionsRef.current.messages || []).filter(\n" - " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n" - " );\n" - " const _shouldSet = uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0;\n" - " if (_shouldSet) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - # 10-space indentation variant - old_pattern3b = ( - " if (uiMsgs.length > 0) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - new_pattern3b = ( - " // === PATCH: catch-up fix (frontend) ===\n" - " const _currentMsgsB = (chatActionsRef.current.messages || []).filter(\n" - " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n" - " );\n" - " const _shouldSetB = uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0;\n" - " if (_shouldSetB) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - # 14-space indentation variant - old_pattern3c = ( - " if (uiMsgs.length > 0) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - new_pattern3c = ( - " // === PATCH: catch-up fix (frontend) ===\n" - " const _currentMsgsC = (chatActionsRef.current.messages || []).filter(\n" - " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n" - " );\n" - " const _shouldSetC = uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0;\n" - " if (_shouldSetC) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - - count = 0 - for old_p, new_p in [(old_pattern3a, new_pattern3a), (old_pattern3b, new_pattern3b), (old_pattern3c, new_pattern3c)]: - while True: - content, ok3 = _safe_replace(content, old_p, new_p) - if ok3: - count += 1 - changed = True - else: - break - if count > 0: - print(f" + Prevented backend overwrite in {count} hydration code paths") - - if changed: - content = content.replace( - "import { logger } from '@/utils/logger';", - "import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied", - 1 - ) - - return content, changed - -patch_file(USE_AGENT_CHAT, _patch_use_agent_chat) - -# ─── Patch ExpiredBanner.tsx: filter event rows in dataset fallback ───────── -print("\n=== Patching ExpiredBanner.tsx (dataset fallback filter) ===") - -EXPIRED_BANNER = "/source/frontend/src/components/Chat/ExpiredBanner.tsx" - -def _patch_expired_banner(content): - changed = False - - if PATCHED_MARKER in content: - print(" SKIP: already patched") - return content, False - - # Check if the dataset fallback is present (from patch_catch_up_dataset_fallback.py) - if "/api/session-history/" not in content: - print(" SKIP: no dataset fallback found in ExpiredBanner.tsx") - return content, False - - # Patch the dataset fallback mapping to filter event rows - old_mapping = ( - " messages = history.map((row: any) => ({\n" - " role: row.role || 'assistant',\n" - " content: row.content || '',\n" - " }));" - ) - - new_mapping = ( - " // === PATCH: catch-up fix (frontend) ===\n" - " // Filter out 'event' rows — they are internal tracing\n" - " // data that cannot be parsed as Message objects\n" - " const validRoles = new Set(['user', 'assistant', 'tool']);\n" - " messages = history\n" - " .filter((row: any) => validRoles.has(row.role))\n" - " .map((row: any) => ({\n" - " role: row.role,\n" - " content: row.content || '',\n" - " }));" - ) - - content, ok = _safe_replace(content, old_mapping, new_mapping) - if ok: - print(" + Filtered event rows in dataset fallback") - changed = True - else: - print(" INFO: dataset fallback mapping pattern not found") - - if changed: - content = content.replace( - "import { logger } from '@/utils/logger';", - "import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied", - 1 - ) - - return content, changed - -patch_file(EXPIRED_BANNER, _patch_expired_banner) - -print("\n=== Frontend catch-up fixes applied ===") diff --git a/patch_chat_input.py b/patch_chat_input.py index bd3be5cbb9a9e5b4f535007b83d451d7b1f1b286..785c5d5e92560dfff450a566d3301ec44852f120 100644 --- a/patch_chat_input.py +++ b/patch_chat_input.py @@ -1,4 +1,4 @@ -"""Patch ChatInput.tsx - Fixed import for DatasetUploadResponse.""" +"""Patch ChatInput.tsx for auto-continue buttons.""" CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx" @@ -18,24 +18,12 @@ def patch(): "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';" ) - # 2. Fix DatasetUploadResponse import - check existing imports + # 2. Add DatasetUploadResponse import if needed if "DatasetUploadResponse" not in content: - # Find existing import from types/agent - if "from '@/types/agent'" in content: - content = content.replace( - "from '@/types/agent'", - "from '@/types/agent'" - ) - # Add DatasetUploadResponse to existing import - content = content.replace( - "import type { ", - "import type { DatasetUploadResponse, " - ) - else: - content = content.replace( - "import { apiFetch } from '@/utils/api';", - "import { apiFetch } from '@/utils/api';\nimport type { DatasetUploadResponse } from '@/types/agent';" - ) + content = content.replace( + "import { apiFetch } from '@/utils/api';", + "import { apiFetch } from '@/utils/api';\nimport type { DatasetUploadResponse } from '@/types/agent';" + ) # 3. Add props to interface if "showAutoContinue" not in content: @@ -55,6 +43,7 @@ interface ChatInputProps { cancelAutoContinue?: () => void; } ''' + # Find and replace existing interface match = re.search(r'interface ChatInputProps[^}]*[}][^}]*[}]', content, re.DOTALL) if match: content = content.replace(match.group(0), props_code) @@ -92,6 +81,7 @@ interface ChatInputProps { ''' if "Tự động tiếp tục" not in content: + # Insert before tuple[str, bool]: - changed = False - - old_block = ( - "# Serve static files (frontend build) in production\n" - "static_path = Path(__file__).parent.parent / \"static\"\n" - "if static_path.exists():\n" - " app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n" - " logger.info(f\"Serving static files from {static_path}\")\n" - "else:\n" - " logger.info(\"No static directory found, running in API-only mode\")" - ) - - new_block = ( - "# Serve static files (frontend build) in production\n" - "static_path = Path(__file__).parent.parent / \"static\"\n" - f"{PATCHED_MARKER}\n" - "# Only mount if the directory exists AND has content — an empty\n" - "# directory would shadow all API routes with 404s.\n" - "if static_path.exists() and any(static_path.iterdir()):\n" - " app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n" - " logger.info(f\"Serving static files from {static_path}\")\n" - "else:\n" - " logger.info(\"No static files found, running in API-only mode\")" - ) - - if old_block in content: - content = content.replace(old_block, new_block, 1) - changed = True - print(" + Fixed static file mounting to check for non-empty directory") - else: - # Try a more flexible match - if "static_path.exists()" in content and PATCHED_MARKER not in content: - import re - # Replace the if condition - pattern = r'if static_path\.exists\(\):' - replacement = f'if static_path.exists() and any(static_path.iterdir()):\n {PATCHED_MARKER}' - content = re.sub(pattern, replacement, content, count=1) - changed = True - print(" + Fixed static file mounting (regex fallback)") - else: - print(f" SKIP: pattern already patched or not found") - - return content, changed - - -def main(): - if not os.path.exists(TARGET_FILE): - print(f"SKIP: {TARGET_FILE} not found") - return - - with open(TARGET_FILE, "r", encoding="utf-8") as f: - content = f.read() - - if PATCHED_MARKER in content: - print(f"SKIP: {TARGET_FILE} already patched") - return - - # Syntax-check by parsing - import ast - try: - ast.parse(content) - except SyntaxError as e: - print(f"WARN: {TARGET_FILE} has syntax errors before patching: {e}") - return - - content, changed = patch_main_py(content) - if changed: - try: - ast.parse(content) - with open(TARGET_FILE, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: {TARGET_FILE} patched") - except SyntaxError as e: - print(f"FAIL: patched {TARGET_FILE} has syntax error: {e}") - else: - print(f"SKIP: {TARGET_FILE} — no changes applied") - - -if __name__ == "__main__": - try: - main() - except Exception as e: - print(f"WARN: patch_fix_static_404.py error: {e}") - sys.exit(0) diff --git a/patch_frontend.py b/patch_frontend.py index 228eb22b7e0c4fbf6623a680c0217df65513136f..1b67c52170a2216e64d7be707d5b3c52fb1ed402 100644 --- a/patch_frontend.py +++ b/patch_frontend.py @@ -20,21 +20,13 @@ model_content = model_content.replace( ) model_content = model_content.replace( "export const GLM_52_MODEL_PATH = 'zai-org/GLM-5.2:novita';", - "export const GLM_52_MODEL_PATH = 'deepseek/deepseek-v4-flash';" + "export const GLM_52_MODEL_PATH = 'openai/deepseek/deepseek-v4-flash';" ) model_content = model_content.replace( "export const DEEPSEEK_V4_PRO_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro:novita';", "export const DEEPSEEK_V4_PRO_MODEL_PATH = 'nvidia/nemotron-3-super-120b-a12b:free';" ) -# Add DeepSeek V4 Flash latest model path -if "DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH" not in model_content: - model_content = model_content.replace( - "export const LAGUNA_S21_MODEL_PATH", - "export const DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH = '~deepseek/deepseek-v4-flash-latest';\n\nexport const LAGUNA_S21_MODEL_PATH" - ) - print("OK: Added DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH") - # === PATCH: Add Laguna S 2.1 model path === if "LAGUNA_S21_MODEL_PATH" not in model_content: model_content = model_content.replace( @@ -43,14 +35,6 @@ if "LAGUNA_S21_MODEL_PATH" not in model_content: ) print("OK: Added LAGUNA_S21_MODEL_PATH") -# === PATCH: Add Space Bunny Alpha model path === -if "SPACE_BUNNY_ALPHA_MODEL_PATH" not in model_content: - model_content = model_content.replace( - "export const LAGUNA_S21_MODEL_PATH", - "export const SPACE_BUNNY_ALPHA_MODEL_PATH = 'openai/stealth/space-bunny-alpha';\n\nexport const LAGUNA_S21_MODEL_PATH" - ) - print("OK: Added SPACE_BUNNY_ALPHA_MODEL_PATH") - with open(MODEL_FILE, "w") as f: f.write(model_content) print("OK: model.ts constants updated") @@ -98,11 +82,11 @@ if not match: print("FAIL: Could not find DEFAULT_MODEL_OPTIONS array!") sys.exit(1) -NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ +new_array = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ { - id: 'laguna-s21', - name: 'Laguna S 2.1:free', - modelPath: 'openai/poolside/laguna-s-2.1:free', + id: 'tencent-hy3', + name: 'Tencent HY3:free', + modelPath: 'openai/tencent/hy3:free', avatarUrl: 'https://huggingface.co/api/avatars/tencent', recommended: true, }, @@ -134,27 +118,6 @@ NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ avatarUrl: 'https://huggingface.co/api/avatars/poolside', recommended: true, }, - { - id: 'space-bunny-alpha', - name: 'stealth/space-bunny-alpha', - modelPath: 'openai/stealth/space-bunny-alpha', - avatarUrl: 'https://huggingface.co/api/avatars/stealth', - recommended: true, - }, - { - id: 'nex-n2-mini', - name: 'nex-agi/nex-n2-mini', - modelPath: 'openai/nex-agi/nex-n2-mini', - avatarUrl: 'https://huggingface.co/api/avatars/nex-agi', - recommended: true, - }, - { - id: 'ling-3.0-flash', - name: 'inclusionai/ling-3.0-flash:free', - modelPath: 'openai/inclusionai/ling-3.0-flash:free', - avatarUrl: 'https://huggingface.co/api/avatars/inclusionai', - recommended: true, - }, { id: 'llama-3.1-8b', name: 'Llama 3.1 8B', @@ -173,21 +136,14 @@ NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ modelPath: 'openai/google/gemini-2.0-flash-001', avatarUrl: 'https://huggingface.co/api/avatars/google', }, - { - id: 'deepseek-v4-flash-latest', - name: 'DeepSeek V4 Flash latest', - modelPath: '~deepseek/deepseek-v4-flash-latest', - avatarUrl: 'https://huggingface.co/api/avatars/deepseek', - recommended: true, - }, ];""" -content = content[:match.start()] + NEW_MODELS + content[match.end():] +content = content[:match.start()] + new_array + content[match.end():] # Update DEFAULT_MODEL_PATH dm_path = re.search(r'const DEFAULT_MODEL_PATH\s*=.*?;', content) if dm_path: - content = content.replace(dm_path.group(0), "const DEFAULT_MODEL_PATH = 'openai/poolside/laguna-s-2.1:free';") + content = content.replace(dm_path.group(0), "const DEFAULT_MODEL_PATH = 'openai/tencent/hy3:free';") print("OK: Replaced DEFAULT_MODEL_PATH") else: print("OK: DEFAULT_MODEL_PATH not found") @@ -195,5 +151,5 @@ else: with open(FILE, "w", encoding="utf-8") as f: f.write(content) -print("OK: Frontend patched - Laguna S 2.1:free DEFAULT") -print(" Models: HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, Laguna M.1:free, Laguna S 2.1:free, stealth/space-bunny-alpha, Nex N2 Mini, Ling 3.0 Flash:free, Llama 3.1 8B, DeepSeek V4 Flash latest") +print("OK: Frontend patched - Tencent HY3:free DEFAULT") +print(" Models: HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, Laguna M.1:free, Laguna S 2.1:free, Llama 3.1 8B") \ No newline at end of file diff --git a/patch_frontend_apodex_ling_beatapi.py b/patch_frontend_apodex_ling_beatapi.py deleted file mode 100644 index 3a4f3d8339cf3b98c1d2d9dc41219c8ca153e28d..0000000000000000000000000000000000000000 --- a/patch_frontend_apodex_ling_beatapi.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Patch frontend ChatInput.tsx: add apodex-1.1-mini:free, ling-3.1-flash and -the BeatAPI space-bunny-alpha entry to the model picker. - - - apodex/apodex-1.1-mini:free path: openai/apodex/apodex-1.1-mini:free - - inclusionai/ling-3.1-flash path: openai/inclusionai/ling-3.1-flash - - BeatAPI space-bunny-alpha path: beatapi/space-bunny-alpha - -The first two follow the same OpenRouter `openai/` convention as the existing -models (including stealth/space-bunny-alpha). The third is a BEATAPI model: -its path starts with `beatapi/`, NOT `openai/`, so the backend routes it to -https://api.beatapi.io/v1 with BEATAPI_API_KEY instead of OpenRouter. The name -is prefixed "BeatAPI" so the picker makes the provider difference visible. - -Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already -replaced DEFAULT_MODEL_OPTIONS). Idempotent. -""" - -import os -import re - -FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" - -NEW_ENTRIES = """ { - id: 'apodex-1.1-mini-free', - name: 'apodex/apodex-1.1-mini:free', - modelPath: 'openai/apodex/apodex-1.1-mini:free', - avatarUrl: 'https://huggingface.co/api/avatars/apodex', - recommended: true, - }, - { - id: 'ling-3.1-flash', - name: 'inclusionai/ling-3.1-flash', - modelPath: 'openai/inclusionai/ling-3.1-flash', - avatarUrl: 'https://huggingface.co/api/avatars/inclusionai', - recommended: true, - }, - { - id: 'beatapi-space-bunny-alpha', - name: 'BeatAPI space-bunny-alpha', - modelPath: 'beatapi/space-bunny-alpha', - avatarUrl: 'https://beatapi.io/favicon.ico', - recommended: true, - }, -""" - - -def patch(): - if not os.path.exists(FILE): - print(f"SKIP: {FILE} not found") - return - - with open(FILE, "r", encoding="utf-8") as f: - content = f.read() - - if ( - "apodex/apodex-1.1-mini:free" in content - and "beatapi/space-bunny-alpha" in content - ): - print("OK: new models already present in ChatInput.tsx") - return - - # Insert right before the stealth/space-bunny-alpha entry so the three new - # models sit next to the existing free OpenRouter models. - anchor = " {\n id: 'space-bunny-alpha',\n" - if anchor in content: - content = content.replace(anchor, NEW_ENTRIES + anchor, 1) - print("OK: added 3 models before the stealth/space-bunny-alpha entry") - else: - # Fallback: insert before deepseek-v4-flash-latest, the other stable anchor. - alt = " {\n id: 'deepseek-v4-flash-latest',\n" - if alt in content: - content = content.replace(alt, NEW_ENTRIES + alt, 1) - print("OK: added 3 models before deepseek-v4-flash-latest (fallback)") - else: - m = re.search( - r"const DEFAULT_MODEL_OPTIONS: ModelOption\[\] = \[.*?\];", - content, - re.DOTALL, - ) - if not m: - print("FAIL: could not find any model-list anchor") - return - head = m.group(0)[: m.group(0).rindex("[") + 1] - content = ( - content[: m.start()] - + head - + "\n" - + NEW_ENTRIES - + content[m.start() + len(head) :] - ) - print("OK: added 3 models at top of DEFAULT_MODEL_OPTIONS (fallback)") - - with open(FILE, "w", encoding="utf-8") as f: - f.write(content) - print("OK: ChatInput.tsx patched") - - # Verification: all three paths must be present. - for needle in ( - "openai/apodex/apodex-1.1-mini:free", - "openai/inclusionai/ling-3.1-flash", - "beatapi/space-bunny-alpha", - ): - if needle not in content: - print(f"FAIL: missing {needle}") - raise SystemExit(1) - print("OK: verified all 3 new model paths present") - - -if __name__ == "__main__": - patch() diff --git a/patch_frontend_multi.py b/patch_frontend_multi.py deleted file mode 100644 index a65050719ddc2e093e741e34080852e49565f17f..0000000000000000000000000000000000000000 --- a/patch_frontend_multi.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Patch ChatInput.tsx: support uploading multiple files at once. - -Changes: - 1. Add `multiple` to the so the user can select many - files/images in one pick. - 2. Rewrite handleDatasetFileChange to send ALL selected files in a single - multipart request (backend returns list[DatasetUploadResponse]). -""" - -import os - -FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" - -def patch(): - if not os.path.exists(FILE): - print(f"SKIP: {FILE} not found") - return - - with open(FILE, "r", encoding="utf-8") as f: - content = f.read() - - # ---- 1. Add `multiple` to the file input ---- - old_input = '' - new_input = '' - if old_input in content: - content = content.replace(old_input, new_input) - print("OK: added multiple to file input") - else: - print("WARN: file input block not matched exactly") - - # ---- 2. Rewrite handleDatasetFileChange ---- - old_handler_start = " const handleDatasetFileChange = useCallback(" - start = content.find(old_handler_start) - if start == -1: - print("FAIL: handleDatasetFileChange not found") - return - # End of the handler: the " );" right after the deps line. - deps = " [sessionId, onDatasetUploaded],\n );" - end = content.find(deps, start) - if end != -1: - end += len(deps) - else: - print("FAIL: could not find end of handler") - return - - new_handler = ''' const handleDatasetFileChange = useCallback( - async (event: React.ChangeEvent) => { - const files = Array.from(event.target.files ?? []); - event.target.value = ''; - if (!files.length) return; - - if (!sessionId) { - setDatasetUploadError('Start a session before uploading files.'); - return; - } - - // Validate each file before uploading the batch. - for (const file of files) { - const ext = file.name.split('.').pop()?.toLowerCase() || ''; - if (!DATASET_UPLOAD_EXTENSIONS.has(ext)) { - setDatasetUploadError('Không hỗ trợ định dạng này. Các định dạng được hỗ trợ: CSV, JSON, JSONL, PDF, ảnh (PNG/JPG/JPEG/WebP/BMP/TIFF/GIF), Excel (XLSX/XLSM/XLS), Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), và văn bản (TXT/MD/RTF/LOG).'); - return; - } - if (file.size > MAX_DATASET_UPLOAD_BYTES) { - setDatasetUploadError(`File must be 100 MB or smaller. ${file.name} is ${formatBytes(file.size)}.`); - return; - } - if (file.size === 0) { - setDatasetUploadError(`Uploaded file is empty: ${file.name}`); - return; - } - } - - // Send all files in one request; backend returns a list. - const formData = new FormData(); - for (const file of files) { - formData.append('file', file); - } - setIsUploadingDataset(true); - setDatasetUploadProgress(0); - setDatasetUploadError(null); - setDatasetUploadSuccess(null); - try { - const res = await apiUpload(`/api/session/${sessionId}/datasets`, formData, { - onProgress: ({ percent }) => { - setDatasetUploadProgress(percent !== null && percent < 100 ? percent : null); - }, - }); - if (!res.ok) { - setDatasetUploadError(await readApiErrorMessage(res, 'File upload failed.')); - return; - } - const payload = await res.json() as DatasetUploadResponse[]; - if (payload.length) { - setUploadedDatasets((previous) => [...payload, ...previous]); - const names = payload.map((u) => u.filename).join(', '); - setDatasetUploadSuccess(`Đã tải lên ${payload.length} file: ${names}`); - } - await onDatasetUploaded?.(); - } catch (error) { - setDatasetUploadError( - error instanceof Error ? error.message : 'File upload failed.' - ); - } finally { - setIsUploadingDataset(false); - setDatasetUploadProgress(null); - } - }, - [sessionId, onDatasetUploaded], - );''' - - content = content[:start] + new_handler + content[end:] - - with open(FILE, "w", encoding="utf-8") as f: - f.write(content) - print("OK: ChatInput.tsx multi-file upload patched") - - -if __name__ == "__main__": - patch() diff --git a/patch_frontend_new_models.py b/patch_frontend_new_models.py deleted file mode 100644 index 6f8122a825873a6ce39dc047d77fcf94e8404fc2..0000000000000000000000000000000000000000 --- a/patch_frontend_new_models.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Patch frontend ChatInput.tsx: add 3 new OpenRouter models to the model picker. - -New models (openai/ prefix routes to OpenRouter): - - UPSTAGE Solar Pro 4 (openai/upstage/solar-pro4) - - NVIDIA Nemotron 3.5 Lightning:free (openai/nvidia/nemotron-3.5-lightning:free) - - Liquid LFM 2.5 2.6B:free (openai/liquid/lfm-2.5-2.6b:free) -""" - -import os - -FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" - -def patch(): - if not os.path.exists(FILE): - print(f"SKIP: {FILE} not found") - return - - with open(FILE, "r", encoding="utf-8") as f: - content = f.read() - - if "upstage-solar-pro4" in content: - print("OK: models already present") - return - - # Unique anchor: the deepseek-v4-flash-latest entry inside DEFAULT_MODEL_OPTIONS. - # Insert the 3 new model objects immediately BEFORE that entry. - anchor = " {\n id: 'deepseek-v4-flash-latest',\n" - new_entries = ( - " {\n" - " id: 'upstage-solar-pro4',\n" - " name: 'UPSTAGE Solar Pro 4',\n" - " modelPath: 'openai/upstage/solar-pro4',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/upstage',\n" - " recommended: true,\n" - " },\n" - " {\n" - " id: 'nemotron-3.5-lightning',\n" - " name: 'NVIDIA Nemotron 3.5 Lightning:free',\n" - " modelPath: 'openai/nvidia/nemotron-3.5-lightning:free',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n" - " recommended: true,\n" - " },\n" - " {\n" - " id: 'liquid-lfm-2.5-2.6b',\n" - " name: 'Liquid LFM 2.5 2.6B:free',\n" - " modelPath: 'openai/liquid/lfm-2.5-2.6b:free',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/liquid',\n" - " recommended: true,\n" - " },\n" - ) - - if anchor in content: - content = content.replace(anchor, new_entries + anchor, 1) - print("OK: added 3 new models before deepseek-v4-flash-latest entry") - else: - # Fallback: find the deepseek id line by itself. - alt = " id: 'deepseek-v4-flash-latest',\n" - if alt in content: - content = content.replace(alt, new_entries + " {\n" + alt, 1) - print("OK: added 3 new models (fallback anchor)") - else: - print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS") - return - - with open(FILE, "w", encoding="utf-8") as f: - f.write(content) - print("OK: ChatInput.tsx new models patched") - - -if __name__ == "__main__": - patch() diff --git a/patch_frontend_permanent_sessions.py b/patch_frontend_permanent_sessions.py deleted file mode 100644 index e31de0101d0eb1cb2e91d84c956b560db122acb9..0000000000000000000000000000000000000000 --- a/patch_frontend_permanent_sessions.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Patch frontend: Disable 'where we here now?' notification. - -Chỉ tìm và xóa bất kỳ chuỗi 'where we here now' nào trong frontend source. -An toàn — không thêm code TypeScript mới để tránh lỗi build. -""" -import os -import sys - -FRONTEND_DIR = "/source/frontend/src" - -patch_count = 0 - -# Scan all .ts/.tsx files for 'where we here now' and remove the notification -if os.path.exists(FRONTEND_DIR): - for root, dirs, files in os.walk(FRONTEND_DIR): - for fname in files: - if fname.endswith((".ts", ".tsx")): - fpath = os.path.join(root, fname) - try: - with open(fpath, "r", encoding="utf-8") as f: - content = f.read() - if "where we here now" in content.lower(): - # Remove the entire line containing the notification text - lines = content.split("\n") - new_lines = [] - removed = False - for line in lines: - if "where we here now" in line.lower(): - removed = True - continue # Skip this line - new_lines.append(line) - if removed: - with open(fpath, "w", encoding="utf-8") as f: - f.write("\n".join(new_lines)) - patch_count += 1 - print(f" Removed 'where we here now?' from {fpath}") - except Exception as e: - print(f" WARN: Could not patch {fpath}: {e}") - -if patch_count == 0: - print(" No 'where we here now?' notifications found (already clean)") - -print(f"Done: patched {patch_count} frontend files") -sys.exit(0) # Always exit 0 — never fail build diff --git a/patch_frontend_uploads.py b/patch_frontend_uploads.py deleted file mode 100644 index 2bdf7f1d0f17d3e97f5dbaf50cb526537f5c028b..0000000000000000000000000000000000000000 --- a/patch_frontend_uploads.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Patch ChatInput.tsx: allow uploading PDF, images, Excel, Word, PPT, -OpenDocument and text files in addition to CSV/JSON/JSONL, and surface the -extracted-text fields returned by the backend. -""" - -import os - -FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" - -ACCEPT = ( - ".csv,.json,.jsonl,.pdf," - ".png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif," - ".xlsx,.xlsm,.xls," - ".docx,.doc," - ".pptx," - ".odt,.ods,.odp," - ".txt,.md,.rtf,.log" -) - -EXTENSIONS_SET = ( - "['csv', 'json', 'jsonl', 'pdf', " - "'png', 'jpg', 'jpeg', 'webp', 'bmp', 'tiff', 'tif', 'gif', " - "'xlsx', 'xlsm', 'xls', 'docx', 'doc', 'pptx', " - "'odt', 'ods', 'odp', 'txt', 'md', 'rtf', 'log']" -) - - -def patch(): - if not os.path.exists(FILE): - print(f"SKIP: {FILE} not found") - return - - with open(FILE, "r", encoding="utf-8") as f: - content = f.read() - - # 1. Widen the accept string - old_accept = "const DATASET_UPLOAD_ACCEPT = '.csv,.json,.jsonl';" - if old_accept in content: - content = content.replace(old_accept, f"const DATASET_UPLOAD_ACCEPT = `{ACCEPT}`;") - print("OK: widened DATASET_UPLOAD_ACCEPT") - - # 2. Widen the allowed extension set - old_ext = "const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']);" - if old_ext in content: - content = content.replace(old_ext, f"const DATASET_UPLOAD_EXTENSIONS = new Set({EXTENSIONS_SET});") - print("OK: widened DATASET_UPLOAD_EXTENSIONS") - - # 3. Update the error message - old_err = "'Only CSV, JSON, and JSONL dataset files are supported.'" - new_err = ( - "'Không hỗ trợ định dạng này. Các định dạng được hỗ trợ: CSV, JSON, JSONL, " - "PDF, ảnh (PNG/JPG/JPEG/WebP/BMP/TIFF/GIF), Excel (XLSX/XLSM/XLS), " - "Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), " - "và văn bản (TXT/MD/RTF/LOG).'" - ) - if old_err in content: - content = content.replace(old_err, new_err) - print("OK: updated unsupported-format error message") - - # 4. Widen the DatasetUploadResponse interface format + add extracted fields - old_fmt = " format: 'csv' | 'json' | 'jsonl';" - if old_fmt in content: - content = content.replace(old_fmt, " format: string;") - print("OK: widened interface format type") - - if "extracted_text?: string" not in content: - content = content.replace( - " load_dataset_snippet: string;\n", - " load_dataset_snippet: string;\n" - " extracted_text?: string;\n" - " extraction_warning?: string;\n", - ) - print("OK: added extracted_text/extraction_warning to interface") - - with open(FILE, "w", encoding="utf-8") as f: - f.write(content) - print("OK: ChatInput.tsx patched for multi-format uploads") - - -if __name__ == "__main__": - patch() diff --git a/patch_frontend_xing_nemotron_vl.py b/patch_frontend_xing_nemotron_vl.py deleted file mode 100644 index 6884323754f9baf8158d52a8524ee384496ad6e4..0000000000000000000000000000000000000000 --- a/patch_frontend_xing_nemotron_vl.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Patch frontend ChatInput.tsx: add xingchen-agi/xing4.0-29b-a4b:free and -nvidia/nemotron-3.5-super-vl:free with the same openai/ path convention as -Laguna S 2.1:free. - -New models (openai/ prefix -> OpenRouter): - - xingchen-agi/xing4.0-29b-a4b:free modelPath: openai/xingchen-agi/xing4.0-29b-a4b:free - - nvidia/nemotron-3.5-super-vl:free modelPath: openai/nvidia/nemotron-3.5-super-vl:free - -Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already -replaced DEFAULT_MODEL_OPTIONS). Idempotent. -""" - -import os - -FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" - - -def patch(): - if not os.path.exists(FILE): - print(f"SKIP: {FILE} not found") - return - - with open(FILE, "r", encoding="utf-8") as f: - content = f.read() - - if "xingchen-agi/xing4.0-29b-a4b" in content: - print("OK: models already present in ChatInput.tsx") - return - - # Insert right before the deepseek-v4-flash-latest entry — a stable anchor - # that exists after patch_frontend.py rewrites DEFAULT_MODEL_OPTIONS. - anchor = " {\n id: 'deepseek-v4-flash-latest',\n" - new_entries = ( - " {\n" - " id: 'xingchen-xing4-0-29b',\n" - " name: 'xingchen-agi/xing4.0-29b-a4b:free',\n" - " modelPath: 'openai/xingchen-agi/xing4.0-29b-a4b:free',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/xingchen-agi',\n" - " recommended: true,\n" - " },\n" - " {\n" - " id: 'nemotron-3.5-super-vl',\n" - " name: 'nvidia/nemotron-3.5-super-vl:free',\n" - " modelPath: 'openai/nvidia/nemotron-3.5-super-vl:free',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n" - " recommended: true,\n" - " },\n" - ) - - if anchor in content: - content = content.replace(anchor, new_entries + anchor, 1) - print("OK: added 2 models before deepseek-v4-flash-latest entry") - else: - print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS") - return - - with open(FILE, "w", encoding="utf-8") as f: - f.write(content) - print("OK: ChatInput.tsx patched") - - -if __name__ == "__main__": - patch() \ No newline at end of file diff --git a/patch_lossless_context.py b/patch_lossless_context.py deleted file mode 100644 index 860c990e5e31a42de0842338afb60bfe3b3901bc..0000000000000000000000000000000000000000 --- a/patch_lossless_context.py +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env python3 -"""Patch: lossless long-context memory — fix context loss in long sessions/jobs. - -Applied during Docker build. Idempotent. NEVER fails the build. - -Root causes fixed: - 1. compact() deleted the middle incl. ALL tool results (model continued a long - job from a terse paraphrase) - 2. CompactionFailedError TERMINATED the session mid-job (_compact_and_notify) - 3. restore_session_from_log reloaded history VERBATIM -> next compaction - nuked the tool trail or killed the session - 4. max_iterations=300 silently stopped long jobs - -Fixes: - A. compact(): snapshot removed middle (raw tool calls/results) into - cm.compaction_archive before rewriting items — the raw trail survives. - B. untouched_messages default 5 -> 24 (keep more of the recent working set). - C. cm.prune_to_fit(): drop oldest tool outputs (markers) until under - threshold. agent_loop _compact_and_notify calls it on CompactionFailedError - instead of terminating — degrade, never kill. - D. restore_session_from_log: normalize restored history to 70% headroom - (trim oldest tool outputs, keep system/first_user/tail). - E. config max_iterations default -1 (unlimited) + untouched_messages=24; - session.py reads config.untouched_messages. -""" - -import ast -import os - -MARKER = "# === PATCH: lossless long-context memory ===" - - -def _apply(path, patch_fn, label): - if not os.path.exists(path): - print(f"SKIP: {path} not found") - return False - try: - with open(path, "r") as f: - c = f.read() - if MARKER in c: - print(f"SKIP: {path} already patched") - return True - new_c, changed = patch_fn(c) - if not changed: - print(f"SKIP: {path} — no changes needed") - return False - try: - ast.parse(new_c) - except SyntaxError as e: - print(f"FAIL: {path} syntax: {e}") - return False - with open(path, "w") as f: - f.write(new_c) - print(f"OK: {path} {label}") - return True - except Exception as e: - print(f"WARN: {path} patch error: {e}") - return False - - -# ── 1. manager.py — lossless compaction + prune_to_fit ────────────────────── -MANAGER = "/app/agent/context_manager/manager.py" - - -def _patch_manager(c): - changed = False - - if "untouched_messages: int = 5" in c: - c = c.replace("untouched_messages: int = 5", "untouched_messages: int = 24") - print(" + untouched_messages default 5 -> 24") - changed = True - - archive_code = ( - " # === PATCH: lossless long-context memory ===\n" - " # Never destroy the working state: keep the full removed middle\n" - " # (tool calls + results + user prompts) in memory on the summary,\n" - " # so a follow-up question or a continued job can be answered from\n" - " # the raw trail, not from a paraphrase.\n" - " try:\n" - " snapshot = [\n" - " m.model_dump() if hasattr(m, \"model_dump\") else dict(m)\n" - " for m in messages_to_summarize\n" - " ]\n" - " if not hasattr(self, \"compaction_archive\"):\n" - " self.compaction_archive = []\n" - " self.compaction_archive.extend(snapshot)\n" - " # Cap the archive so we don't grow unbounded: keep last 400 msgs.\n" - " if len(self.compaction_archive) > 400:\n" - " self.compaction_archive = self.compaction_archive[-400:]\n" - " except Exception:\n" - " self.compaction_archive = getattr(self, \"compaction_archive\", [])\n" - ) - anchor = " self.items = head + [summarized_message] + recent_messages" - if anchor in c and "compaction_archive" not in c: - c = c.replace(anchor, archive_code + anchor, 1) - print(" + compact() snapshots removed middle to compaction_archive") - changed = True - - if "def prune_to_fit" not in c: - prune = ''' - - def prune_to_fit( - self, model_name: str, *, max_messages: int = 200 - ) -> bool: - """Drop oldest non-first-user tool outputs until under threshold. - - Degrade, don't kill. Returns True if context is now under threshold. - Preserves: system prompt, first user message, assistant reasoning, - and enough of the tail to continue. Drops the OLDEST tool outputs - (replaced with a short marker) so a long job can keep running. - """ - from litellm import token_counter - - def _usage(messages): - try: - return token_counter( - model=model_name, - messages=[m.model_dump() for m in messages], - ) - except Exception: - return ( - sum(len(getattr(m, "content", "") or "") for m in messages) // 4 - ) - - items = list(self.items) - # Find first user message index (skip system at 0). - first_user_idx = 1 - for i in range(1, len(items)): - if getattr(items[i], "role", None) == "user": - first_user_idx = i - break - # Trim tool messages from oldest to newest (skip system/first-user). - trimmed = 0 - for i in range(first_user_idx + 1, len(items)): - if _usage(items) <= self.compaction_threshold: - break - msg = items[i] - if getattr(msg, "role", None) != "tool": - continue - old = items[i] - items[i] = Message( - role="tool", - content=( - "[tool output pruned to keep the session running — " - f"original was {len(getattr(old, 'content', '') or '')} chars; " - "stored in compaction_archive]" - ), - tool_call_id=getattr(old, "tool_call_id", None), - name=getattr(old, "name", None), - ) - trimmed += 1 - self.items = items - self._recompute_usage(model_name) - return self.running_context_usage <= self.compaction_threshold -''' - c += prune - print(" + Added prune_to_fit() (degrade instead of kill)") - changed = True - - return c, changed - - -_apply(MANAGER, _patch_manager, "lossless compaction patched") - -# ── 2. agent_loop.py — never kill on compaction failure ───────────────────── -AGENT_LOOP = "/app/agent/core/agent_loop.py" - - -def _patch_agent_loop(c): - changed = False - - old_fail = ( - " await session.send_event(\n" - " Event(\n" - " event_type=\"session_terminated\",\n" - " data={\n" - " \"reason\": \"compaction_failed\",\n" - " \"context_usage\": cm.running_context_usage,\n" - " \"context_threshold\": cm.compaction_threshold,\n" - " \"error\": str(e)[:300],\n" - " \"user_message\": (\n" - " \"Your conversation has grown too large to continue. \"\n" - " \"The work you've done is saved — start a new session to keep going.\"\n" - " ),\n" - " },\n" - " )\n" - " )\n" - " # Stop the agent loop; the finally in _run_session will fire\n" - " # cleanup_sandbox + save_trajectory so the dataset captures\n" - " # everything that did happen.\n" - " session.is_running = False\n" - " return" - ) - new_fail = ( - " # === PATCH: lossless long-context memory ===\n" - " # Never kill the session on compaction failure — degrade instead:\n" - " # prune the oldest tool outputs to fit, then keep working.\n" - " try:\n" - " ok = cm.prune_to_fit(session.config.model_name)\n" - " if ok:\n" - " await session.send_event(\n" - " Event(\n" - " event_type=\"compacted\",\n" - " data={\n" - " \"reason\": \"pruned_to_fit\",\n" - " \"context_usage\": cm.running_context_usage,\n" - " \"context_threshold\": cm.compaction_threshold,\n" - " \"user_message\": (\n" - " \"Context was getting full, so the agent \"\n" - " \"trimmed some old tool outputs to keep working. \"\n" - " \"The full raw history stays in the session archive.\"\n" - " ),\n" - " },\n" - " )\n" - " )\n" - " return\n" - " except Exception as _prune_exc:\n" - " logger.error(\"prune_to_fit failed: %s\", _prune_exc)\n" - " await session.send_event(\n" - " Event(\n" - " event_type=\"session_terminated\",\n" - " data={\n" - " \"reason\": \"compaction_failed\",\n" - " \"context_usage\": cm.running_context_usage,\n" - " \"context_threshold\": cm.compaction_threshold,\n" - " \"error\": str(e)[:300],\n" - " \"user_message\": (\n" - " \"The conversation context is full and could not be \"\n" - " \"reduced further. The work done so far is saved — \"\n" - " \"start a new session to keep going.\"\n" - " ),\n" - " },\n" - " )\n" - " )\n" - " # Stop the agent loop; the finally in _run_session will fire\n" - " # cleanup_sandbox + save_trajectory so the dataset captures\n" - " # everything that did happen.\n" - " session.is_running = False\n" - " return" - ) - if old_fail in c and "prune_to_fit" not in c: - c = c.replace(old_fail, new_fail, 1) - print(" + _compact_and_notify degrades via prune_to_fit instead of kill") - changed = True - - return c, changed - - -_apply(AGENT_LOOP, _patch_agent_loop, "agent_loop degrades instead of killing") - -# ── 3. session.py — read config.untouched_messages ────────────────────────── -SESSION = "/app/agent/core/session.py" - - -def _patch_session(c): - changed = False - old = " compact_size=0.1,\n untouched_messages=5," - new = ( - " compact_size=0.1,\n" - " untouched_messages=getattr(config, 'untouched_messages', 24)," - ) - if old in c: - c = c.replace(old, new, 1) - print(" + session.py reads config.untouched_messages (default 24)") - changed = True - return c, changed - - -_apply(SESSION, _patch_session, "session.py untouched_messages override") - -# ── 4. config.py — max_iterations unlimited + untouched_messages=24 ───────── -CONFIG = "/app/agent/config.py" - - -def _patch_config(c): - changed = False - old = " max_iterations: int = 300 # Max LLM calls per agent turn (-1 = unlimited)" - new = ( - " max_iterations: int = -1 # Max LLM calls per agent turn (-1 = unlimited)\n" - " untouched_messages: int = 24 # PATCH: keep more recent tool trail before compaction" - ) - if old in c: - c = c.replace(old, new, 1) - print(" + config.py max_iterations default -1 (unlimited), untouched_messages=24") - changed = True - return c, changed - - -_apply(CONFIG, _patch_config, "config.py max_iterations unlimited") - -# ── 5. session_resume.py — normalize restored history to headroom ─────────── -RESUME = "/app/agent/core/session_resume.py" - - -def _patch_resume(c): - changed = False - anchor = " cm = session.context_manager\n" - insert = ( - " # === PATCH: lossless long-context memory ===\n" - " # Normalize restored history to fit model headroom instead of restoring\n" - " # everything verbatim and letting the first compaction nuke the tool\n" - " # trail or kill the session. Keep system + first user + tail verbatim;\n" - " # replace oversize tool outputs in the middle with markers (full payloads\n" - " # still exist on-disk in the original log).\n" - " try:\n" - " from litellm import token_counter as _tc\n" - " _budget = int(session.context_manager.model_max_tokens * 0.70)\n" - " try:\n" - " _use = _tc(\n" - " model=session.config.model_name,\n" - " messages=[m.model_dump() for m in restored_messages],\n" - " )\n" - " except Exception:\n" - " _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n" - " if _use > _budget:\n" - " # Trim the oldest tool outputs (keep system + first user + tail).\n" - " _first_user_idx = 0\n" - " for _i, _m in enumerate(restored_messages):\n" - " if getattr(_m, \"role\", None) == \"user\":\n" - " _first_user_idx = _i\n" - " break\n" - " _trimmed = 0\n" - " for _i in range(_first_user_idx + 1, len(restored_messages)):\n" - " if _use <= _budget:\n" - " break\n" - " _m = restored_messages[_i]\n" - " if getattr(_m, \"role\", None) != \"tool\":\n" - " continue\n" - " _sz = len(getattr(_m, \"content\", \"\") or \"\")\n" - " restored_messages[_i] = type(_m)(\n" - " role=\"tool\",\n" - " content=(\n" - " \"[tool output trimmed on restore to fit context — \"\n" - " f\"original {_sz} chars retained in the on-disk log]\"\n" - " ),\n" - " tool_call_id=getattr(_m, \"tool_call_id\", None),\n" - " name=getattr(_m, \"name\", None),\n" - " )\n" - " _trimmed += 1\n" - " try:\n" - " _use = _tc(\n" - " model=session.config.model_name,\n" - " messages=[m.model_dump() for m in restored_messages],\n" - " )\n" - " except Exception:\n" - " _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n" - " logger.warning(\n" - " \"Restored session %s was over budget (%d/%d); trimmed %d tool outputs\",\n" - " session.session_id if hasattr(session, \"session_id\") else \"?\",\n" - " _use, _budget, _trimmed,\n" - " )\n" - " except Exception as _exc:\n" - " logger.warning(\"Restore normalization skipped: %s\", _exc)\n" - "\n" - ) - if anchor in c and "Restore normalization skipped" not in c: - c = c.replace(anchor, anchor + insert, 1) - print(" + restore_session_from_log normalizes history to 70% headroom") - changed = True - return c, changed - - -_apply(RESUME, _patch_resume, "session_resume normalized restore") - -print("\n=== All long-context patches applied (build will not fail regardless) ===") \ No newline at end of file diff --git a/patch_models.py b/patch_models.py index f3718698c94feb706308302478a27387fa41e131..bca2fa240e2e04a20fd81a5817d34fbe8685ca38 100644 --- a/patch_models.py +++ b/patch_models.py @@ -1,18 +1,4 @@ -"""Patch backend: OpenRouter routing + DeepSeek V4 Flash 0731 + FIX DeepSeek routing. - -FIX: GLM_52_MODEL_ID was set to "openai/deepseek/deepseek-v4-flash" which routes -to OpenRouter via the openai/ prefix catch-all. However, OpenRouter's model ID -is "deepseek/deepseek-v4-flash" (without the openai/ prefix). When LiteLLM -sends the full "openai/deepseek/deepseek-v4-flash" as the model name to -OpenRouter's API, it gets a 404 because OpenRouter doesn't recognize the -"openai/" prefix. - -Solution: -1. GLM_52_MODEL_ID -> "deepseek-ai/DeepSeek-V4-Flash:deepinfra" (HF Router path, model exists there, cheapest provider) -2. OpenRouter routing strips the "openai/" prefix before sending to OpenRouter -3. Add :novita tag to DeepSeek V4 Pro for deterministic routing via HF Router -4. Add stealth/space-bunny-alpha using the same OpenRouter-style prefix. -""" +"""Patch backend: replace AVAILABLE_MODELS with OpenRouter models + routing.""" import ast import os @@ -20,180 +6,121 @@ import re AGENT_FILE = "/app/backend/routes/agent.py" LLM_PARAMS_FILE = "/app/agent/core/llm_params.py" -IDS_FILE = "/app/agent/core/model_ids.py" -with open(IDS_FILE) as f: +# === Step 1: Patch model_ids.py === +IDS_FILE = "/app/agent/core/model_ids.py" +with open(IDS_FILE, "r") as f: content = f.read() -# Remove NO_TOOLS_MODELS if present from previous patches -if "NO_TOOLS_MODELS" in content: - content = re.sub(r'NO_TOOLS_MODELS\s*=\s*\{[^}]*\}\n?', '', content) - print("OK: Removed old NO_TOOLS_MODELS") +old_models_block = 'CLAUDE_OPUS_48_MODEL_ID = "anthropic/claude-opus-4.8:fal-ai"\nGPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"\nKIMI_K27_CODE_MODEL_ID = "moonshotai/Kimi-K2.7-Code:novita"\nMINIMAX_M3_MODEL_ID = "MiniMaxAI/MiniMax-M3:novita"\nGLM_52_MODEL_ID = "zai-org/GLM-5.2:novita"\nDEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"' -# FIX: Set GLM_52_MODEL_ID to HF Router path with provider tag. -# "deepseek-ai/DeepSeek-V4-Flash:deepinfra" IS available on HF Router -# (5 providers for -0731 variant, 3 for standard). All support tools, 1M context. -# Using :deepinfra for better pricing ($0.09/0.18 per 1M tokens) -# KIMI_K27_CODE_MODEL_ID -> DeepSeek V4 Pro via HF Router with :novita tag -replacements = [ - ('KIMI_K27_CODE_MODEL_ID = "moonshotai/Kimi-K2.7-Code:novita"', - 'KIMI_K27_CODE_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"'), - ('MINIMAX_M3_MODEL_ID = "MiniMaxAI/MiniMax-M3:novita"', - 'MINIMAX_M3_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash:deepinfra"'), - # FIX: Was "openai/deepseek/deepseek-v4-flash" which routes to OpenRouter but - # OpenRouter model IDs don't include the "openai/" prefix, causing 404. - # Change to HF Router path with :deepinfra provider tag for cheaper pricing. - ('GLM_52_MODEL_ID = "zai-org/GLM-5.2:novita"', - 'GLM_52_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash:deepinfra"'), - ('DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"', - 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'), -] -for old, new in replacements: - content = content.replace(old, new) -print("OK: Model ID replacements applied") +new_models_block = 'GPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"\nKIMI_K27_CODE_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro"\nMINIMAX_M3_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash"\nGLM_52_MODEL_ID = "openai/deepseek/deepseek-v4-flash"\nDEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"\n\n# === PATCH: OpenRouter models ===\nGEMMA_4_31B_FREE_MODEL_ID = "openai/google/gemma-4-31b-it:free"\nTENCENT_HY3_FREE_MODEL_ID = "openai/tencent/hy3:free"\nLLAMA_3_3_70B_FREE_MODEL_ID = "openai/meta-llama/llama-3.3-70b-instruct:free"\nLLAMA_3_1_8B_MODEL_ID = "openai/meta-llama/llama-3.1-8b-instruct"\nLAGUNA_M1_FREE_MODEL_ID = "openai/poolside/laguna-m.1:free"\nLAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"' -# Add missing constants BEFORE the specific line DEEPSEEK_V4_PRO_MODEL_ID = "..." -if "TENCENT_HY3_FREE_MODEL_ID" not in content: - target_line = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"' - insert_block = '''TENCENT_HY3_FREE_MODEL_ID = "openai/tencent/hy3:free" -GEMMA_4_31B_FREE_MODEL_ID = "openai/google/gemma-4-31b-it:free" -LLAMA_3_3_70B_FREE_MODEL_ID = "openai/meta-llama/llama-3.3-70b-instruct:free" -LLAMA_3_1_8B_MODEL_ID = "openai/meta-llama/llama-3.1-8b-instruct" -LAGUNA_M1_FREE_MODEL_ID = "openai/poolside/laguna-m.1:free" -LAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free" -NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini" -LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free" -''' - # Only replace the first occurrence (the variable definition) - if target_line in content: - content = content.replace(target_line, insert_block + target_line) - print("OK: Added missing constants") - else: - print("WARN: Could not find target line") - -# Add Space Bunny independently so this remains idempotent when the base -# model_ids.py already contains the earlier OpenRouter constants. -if "SPACE_BUNNY_ALPHA_MODEL_ID" not in content: - anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"' - if anchor in content: - content = content.replace( - anchor, - anchor + '\nSPACE_BUNNY_ALPHA_MODEL_ID = "openai/stealth/space-bunny-alpha"\n', - 1, - ) - print("OK: Added SPACE_BUNNY_ALPHA_MODEL_ID") - else: - print("WARN: Could not find LING_3_0_FLASH_FREE_MODEL_ID anchor") +content = content.replace(old_models_block, new_models_block) -# Replace all references to ~deepseek deepseek models with 0731 version (quoted strings only) -content = content.replace('"~deepseek/deepseek-v4-flash-latest"', '"deepseek/deepseek-v4-flash-0731"') -content = content.replace('"~deepseek/deepseek-v4-flash-0731"', '"deepseek/deepseek-v4-flash-0731"') +old_hosted = "HOSTED_MODEL_IDS = {\n CLAUDE_OPUS_48_MODEL_ID,\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n GLM_52_MODEL_ID,\n DEEPSEEK_V4_PRO_MODEL_ID,\n}" +new_hosted = "HOSTED_MODEL_IDS = {\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n GLM_52_MODEL_ID,\n DEEPSEEK_V4_PRO_MODEL_ID,\n GEMMA_4_31B_FREE_MODEL_ID,\n TENCENT_HY3_FREE_MODEL_ID,\n LLAMA_3_3_70B_FREE_MODEL_ID,\n LLAMA_3_1_8B_MODEL_ID,\n LAGUNA_M1_FREE_MODEL_ID,\n LAGUNA_S21_FREE_MODEL_ID,\n}" +content = content.replace(old_hosted, new_hosted) with open(IDS_FILE, "w") as f: f.write(content) -print("OK: model_ids.py written") - -ast.parse(content) # validate syntax -print("OK: model_ids.py syntax OK") +print("OK: model_ids.py patched") +# === Step 2: Patch agent.py — replace _available_models() via regex === with open(AGENT_FILE) as f: content = f.read() +# Make DEFAULT_MODEL_ID and DEFAULT_GPT_MODEL_ID point to our models content = content.replace( "DEFAULT_MODEL_ID = GLM_52_MODEL_ID", - 'DEFAULT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID' + 'DEFAULT_MODEL_ID = "openai/tencent/hy3:free"' ) content = content.replace( "DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID", - "DEFAULT_GPT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID" + 'DEFAULT_GPT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID' ) # Update imports -old_import = ( - "from agent.core.model_ids import (\n" - " CLAUDE_OPUS_48_MODEL_ID,\n" - " DEEPSEEK_V4_PRO_MODEL_ID,\n" - " GLM_52_MODEL_ID,\n" - " GPT_55_MODEL_ID,\n" - " KIMI_K27_CODE_MODEL_ID,\n" - " MINIMAX_M3_MODEL_ID,\n" - " strip_huggingface_model_prefix,\n" - ")" -) -new_import = ( - "from agent.core.model_ids import (\n" - " DEEPSEEK_V4_PRO_MODEL_ID,\n" - " GLM_52_MODEL_ID,\n" - " GPT_55_MODEL_ID,\n" - " KIMI_K27_CODE_MODEL_ID,\n" - " MINIMAX_M3_MODEL_ID,\n" - " TENCENT_HY3_FREE_MODEL_ID,\n" - " GEMMA_4_31B_FREE_MODEL_ID,\n" - " LLAMA_3_3_70B_FREE_MODEL_ID,\n" - " LLAMA_3_1_8B_MODEL_ID,\n" - " LAGUNA_M1_FREE_MODEL_ID,\n" - " LAGUNA_S21_FREE_MODEL_ID,\n" - " NEX_N2_MINI_MODEL_ID,\n" - " LING_3_0_FLASH_FREE_MODEL_ID,\n" - " SPACE_BUNNY_ALPHA_MODEL_ID,\n" - " strip_huggingface_model_prefix,\n" - ")" -) +old_import = "from agent.core.model_ids import (\n CLAUDE_OPUS_48_MODEL_ID,\n DEEPSEEK_V4_PRO_MODEL_ID,\n GLM_52_MODEL_ID,\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n strip_huggingface_model_prefix,\n)" +new_import = "from agent.core.model_ids import (\n DEEPSEEK_V4_PRO_MODEL_ID,\n GLM_52_MODEL_ID,\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n GEMMA_4_31B_FREE_MODEL_ID,\n TENCENT_HY3_FREE_MODEL_ID,\n LLAMA_3_3_70B_FREE_MODEL_ID,\n LLAMA_3_1_8B_MODEL_ID,\n LAGUNA_M1_FREE_MODEL_ID,\n LAGUNA_S21_FREE_MODEL_ID,\n strip_huggingface_model_prefix,\n)" content = content.replace(old_import, new_import) -# Update _available_models to include DeepSeek V4 Flash 0731 +# Replace _available_models() using regex +func_pattern = re.compile( + r'def _available_models\(\) -> list\[dict\[str, Any\]\]:\s*\n' + r'\s+models\s*=\s*\[.*?\]\s*\n\s+return models', + re.DOTALL +) + new_func = '''def _available_models() -> list[dict[str, Any]]: models = [ - {"id": TENCENT_HY3_FREE_MODEL_ID, "label": "Tencent HY3:free", "recommended": True}, - {"id": GEMMA_4_31B_FREE_MODEL_ID, "label": "Gemma 4 31B:free", "recommended": True}, - {"id": LLAMA_3_3_70B_FREE_MODEL_ID, "label": "Llama 3.3 70B:free", "recommended": True}, - {"id": LAGUNA_M1_FREE_MODEL_ID, "label": "Laguna M.1:free", "recommended": True}, - {"id": DEFAULT_GPT_MODEL_ID, "label": "Laguna S 2.1:free", "recommended": True}, - {"id": SPACE_BUNNY_ALPHA_MODEL_ID, "label": "stealth/space-bunny-alpha", "recommended": True}, - {"id": NEX_N2_MINI_MODEL_ID, "label": "nex-agi/nex-n2-mini", "recommended": True}, - {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True}, - {"id": LLAMA_3_1_8B_MODEL_ID, "label": "Llama 3.1 8B"}, - {"id": KIMI_K27_CODE_MODEL_ID, "label": "DeepSeek V4 Pro"}, - {"id": MINIMAX_M3_MODEL_ID, "label": "DeepSeek V4 Flash"}, - {"id": DEFAULT_MODEL_ID, "label": "Laguna S 2.1:free"}, - {"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "Nemotron 3 Super 120B"}, - {"id": "deepseek/deepseek-v4-flash-0731", "label": "DeepSeek V4 Flash 0731", "recommended": True}, + { + "id": TENCENT_HY3_FREE_MODEL_ID, + "label": "Tencent HY3:free", + "recommended": True, + }, + { + "id": GEMMA_4_31B_FREE_MODEL_ID, + "label": "Gemma 4 31B:free", + "recommended": True, + }, + { + "id": LLAMA_3_3_70B_FREE_MODEL_ID, + "label": "Llama 3.3 70B:free", + "recommended": True, + }, + { + "id": LAGUNA_M1_FREE_MODEL_ID, + "label": "Laguna M.1:free", + "recommended": True, + }, + { + "id": DEFAULT_GPT_MODEL_ID, + "label": "Laguna S 2.1:free", + "recommended": True, + }, + { + "id": LLAMA_3_1_8B_MODEL_ID, + "label": "Llama 3.1 8B", + }, + { + "id": KIMI_K27_CODE_MODEL_ID, + "label": "DeepSeek V4 Pro", + }, + { + "id": MINIMAX_M3_MODEL_ID, + "label": "DeepSeek V4 Flash", + }, + { + "id": DEFAULT_MODEL_ID, + "label": "DeepSeek V4 Flash", + }, + { + "id": DEEPSEEK_V4_PRO_MODEL_ID, + "label": "Nemotron 3 Super 120B", + }, ] return models''' -func_match = re.search( - r"def _available_models\(\)\s*->\s*list\[dict\[str,\s*Any\]\]:.*?return models", - content, re.DOTALL -) +func_match = func_pattern.search(content) if func_match: content = content[:func_match.start()] + new_func + content[func_match.end():] - print("OK: Replaced _available_models() with DeepSeek V4 Flash 0731") + print("OK: Replaced _available_models() via regex") else: - old_func = ( - "def _available_models() -> list[dict[str, Any]]:\n" - " models = [\n" - ' {"id": CLAUDE_OPUS_48_MODEL_ID, "label": "Claude Opus 4.8"},\n' - ' {"id": DEFAULT_GPT_MODEL_ID, "label": "GPT-5.5"},\n' - ' {"id": KIMI_K27_CODE_MODEL_ID, "label": "Kimi K2.7 Code"},\n' - ' {"id": MINIMAX_M3_MODEL_ID, "label": "MiniMax M3"},\n' - ' {"id": DEFAULT_MODEL_ID, "label": "GLM 5.2", "recommended": True},\n' - ' {"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "DeepSeek V4 Pro"},\n' - " ]\n" - " return models" - ) - if old_func in content: - content = content.replace(old_func, new_func) - print("OK: String replace of _available_models()") + print("WARN: Regex failed, trying string replace...") + old_available = 'def _available_models() -> list[dict[str, Any]]:\n models = [\n {\n "id": CLAUDE_OPUS_48_MODEL_ID,\n "label": "Claude Opus 4.8",\n },\n {\n "id": DEFAULT_GPT_MODEL_ID,\n "label": "GPT-5.5",\n },\n {\n "id": KIMI_K27_CODE_MODEL_ID,\n "label": "Kimi K2.7 Code",\n },\n {\n "id": MINIMAX_M3_MODEL_ID,\n "label": "MiniMax M3",\n },\n {\n "id": DEFAULT_MODEL_ID,\n "label": "GLM 5.2",\n "recommended": True,\n },\n {\n "id": DEEPSEEK_V4_PRO_MODEL_ID,\n "label": "DeepSeek V4 Pro",\n },\n ]\n return models' + if old_available in content: + content = content.replace(old_available, new_func) + print("OK: String replace worked") else: - print("WARN: Could not find _available_models() pattern, searching...") - idx = content.find("def _available_models") - if idx >= 0: - print(f"Found at index {idx}, context: {content[idx:idx+400]}") + print("FAIL: Cannot find _available_models()!") + import sys + sys.exit(1) -content = content.replace( - '"openai/gpt-oss-120b:cerebras",', - '"huggingface/deepseek-ai/DeepSeek-V4-Pro",' -) +# Update title generation +old_title = '"openai/gpt-oss-120b:cerebras",' +new_title = '"huggingface/deepseek-ai/DeepSeek-V4-Pro",' +content = content.replace(old_title, new_title) try: ast.parse(content) @@ -204,40 +131,28 @@ except SyntaxError as e: with open(AGENT_FILE, "w") as f: f.write(content) +print("OK: agent.py patched") -# Patch llm_params for OpenRouter routing -# FIX: Strip the "openai/" prefix before sending model name to OpenRouter -# OpenRouter model IDs do not include the "openai/" prefix. -# e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free" +# === Step 3: Patch _resolve_llm_params for OpenRouter routing === with open(LLM_PARAMS_FILE) as f: llm_content = f.read() if "normalized_model.startswith" not in llm_content: api_key_find = "api_key = _resolve_hf_router_token(session_hf_token)" if api_key_find in llm_content: - line_end = llm_content.find("\n", llm_content.find(api_key_find) + len(api_key_find)) + 1 - routing_insert = ( - ' # Route deepseek/ models to OpenRouter\n' - ' if normalized_model.startswith("deepseek/"):\n' - ' return {\n' - ' "model": normalized_model,\n' - ' "api_base": "https://openrouter.ai/api/v1",\n' - ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n' - ' }\n\n' - ' # Route openai/-prefixed models to OpenRouter\n' - ' # FIX: Strip the "openai/" prefix - OpenRouter model IDs do not include it.\n' - ' # e.g. "openai/deepseek/deepseek-v4-flash" -> "deepseek/deepseek-v4-flash"\n' - ' # e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free"\n' - ' if normalized_model.startswith("openai/"):\n' - ' or_model = normalized_model[len("openai/"):]\n' - ' return {\n' - ' "model": or_model,\n' - ' "api_base": "https://openrouter.ai/api/v1",\n' - ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n' - ' }\n\n' - ) + line_end = llm_content.find('\n', llm_content.find(api_key_find) + len(api_key_find)) + 1 + routing_insert = ''' # === PATCH: Route ALL openai/-prefixed models to OpenRouter === + if normalized_model.startswith("openai/"): + return { + "model": normalized_model, + "api_base": "https://openrouter.ai/api/v1", + "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "", + } + # === END PATCH === + +''' llm_content = llm_content[:line_end] + routing_insert + llm_content[line_end:] - print("OK: Patched _resolve_llm_params with deepseek/ + fixed openai/ routing") + print("OK: Patched _resolve_llm_params (catch-all openai/ -> OR)") else: print("WARN: Could not find api_key line") else: @@ -253,4 +168,4 @@ except SyntaxError as e: with open(LLM_PARAMS_FILE, "w") as f: f.write(llm_content) -print("OK: ALL BACKEND PATCHES APPLIED (including DeepSeek V4 Flash routing fix)") +print("OK: ALL PATCHES APPLIED") \ No newline at end of file diff --git a/patch_multi_upload.py b/patch_multi_upload.py deleted file mode 100644 index 2ba60e51519b18f72d4cdfe675c9a5a287636953..0000000000000000000000000000000000000000 --- a/patch_multi_upload.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Patch: allow multiple file uploads in one request. - -Backend changes: - 1. Replace the single-file helper with a multi-file helper. - 2. Rewrite the upload route to accept up to 20 files and return a list of - DatasetUploadResponse, uploading each file to the session's Hub dataset - repo and injecting a context note for each. -""" - -import ast -import re -import os - -AGENT_FILE = "/app/backend/routes/agent.py" - -with open(AGENT_FILE) as f: - c = f.read() - -# ---- 1. Replace single-file helper with multi-file helper ---- -old_helper = '''def _dataset_upload_file_from_form(form: FormData) -> UploadFile: - uploaded_files = [ - (key, value) - for key, value in form.multi_items() - if isinstance(value, UploadFile) - ] - if len(uploaded_files) != 1: - raise HTTPException( - status_code=400, - detail="Upload exactly one dataset file.", - ) - field_name, upload = uploaded_files[0] - if field_name != "file": - raise HTTPException( - status_code=400, - detail="Missing 'file' upload field.", - ) - return upload -''' - -new_helper = '''def _dataset_upload_files_from_form(form: FormData) -> list[UploadFile]: - """Return all uploaded files from the multipart form (field 'file' or 'files').""" - uploaded_files = [ - value - for key, value in form.multi_items() - if isinstance(value, UploadFile) - ] - if not uploaded_files: - raise HTTPException( - status_code=400, - detail="No file uploads found in the request.", - ) - return uploaded_files -''' - -if old_helper in c: - c = c.replace(old_helper, new_helper) - print("OK: replaced helper with multi-file helper") -else: - print("WARN: single-file helper not found (check indentation)") - -# ---- 2. Rewrite the upload route to handle multiple files ---- -# Replace the entire function based on its decorator + route path. -start_marker = '@router.post("/session/{session_id}/datasets"' -end_marker = '@router.patch("/session/{session_id}/yolo")' -start = c.find(start_marker) -end = c.find(end_marker) -if start == -1 or end == -1: - print(f"FAIL: route markers not found (start={start}, end={end})") - raise SystemExit(1) - -new_route = '''@router.post("/session/{session_id}/datasets", response_model=list[DatasetUploadResponse]) -async def upload_session_dataset( - session_id: str, - request: Request, - user: dict = Depends(get_current_user), -) -> list[DatasetUploadResponse]: - """Upload one or more document/dataset files to a private Hub dataset for this session.""" - files: list[UploadFile] = [] - try: - _reject_oversize_dataset_upload(request) - agent_session = await _check_session_access(session_id, user, request) - if not agent_session or not agent_session.is_active: - raise HTTPException(status_code=404, detail="Session not found") - if agent_session.is_processing: - raise HTTPException( - status_code=409, - detail="Cannot upload files while the agent is processing.", - ) - if agent_session.session.pending_approval: - raise HTTPException( - status_code=409, - detail="Resolve pending approvals before uploading files.", - ) - - hf_token = ( - resolve_hf_request_token(request, include_env_fallback=False) - or _user_hf_token(user) - or resolve_hf_request_token(request) - ) - if not hf_token: - raise HTTPException( - status_code=401, - detail="A Hugging Face token is required to upload files.", - ) - - form = await request.form( - max_files=20, - max_fields=20, - max_part_size=MAX_DATASET_UPLOAD_BYTES, - ) - files = _dataset_upload_files_from_form(form) - if not files: - raise HTTPException(status_code=400, detail="No files to upload.") - - hf_username = user.get("username") or agent_session.hf_username - results: list[DatasetUploadResponse] = [] - for file in files: - uploaded = await push_dataset_upload_to_hub( - upload=file, - session_id=session_id, - hf_username=hf_username, - hf_token=hf_token, - ) - agent_session.session.context_manager.add_message( - Message(role="user", content=format_uploaded_document_context( - filename=uploaded.original_filename, - stored_filename=uploaded.filename, - repo_id=uploaded.repo_id, - path_in_repo=uploaded.path_in_repo, - hub_url=uploaded.hub_url, - size_bytes=uploaded.size_bytes, - file_format=uploaded.format, - extracted_text=uploaded.extracted_text, - warning=uploaded.extraction_warning, - )) - ) - results.append(DatasetUploadResponse(**uploaded.response_payload())) - logger.info( - "Uploaded file %s to %s for session %s", - uploaded.filename, - uploaded.repo_id, - session_id, - ) - - session_manager._touch(agent_session) - await session_manager.persist_session_snapshot(agent_session) - return results - except HTTPException: - raise - except HfHubHTTPError as e: - logger.warning( - "Hub rejected file upload for session %s: status=%s request_id=%s", - session_id, - getattr(e.response, "status_code", None), - getattr(e, "request_id", None), - ) - raise _dataset_upload_hub_http_exception(e) - except Exception: - logger.exception("File upload failed for session %s", session_id) - raise HTTPException( - status_code=502, - detail="File upload failed. Please try again.", - ) - finally: - for f in files: - await f.close() - - -''' - -c = c[:start] + new_route + c[end:] - -with open(AGENT_FILE, "w") as f: - f.write(c) - -try: - ast.parse(c) - print("OK: agent.py written & syntax valid") -except SyntaxError as e: - print(f"FAIL: agent.py syntax error: {e}") - raise - -print("DONE: multi-file upload backend patch complete") diff --git a/patch_new_models.py b/patch_new_models.py deleted file mode 100644 index 718be4a72652273f1dc06ff30141c457c5120c57..0000000000000000000000000000000000000000 --- a/patch_new_models.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Patch backend: add 3 new OpenRouter models (openai/ prefix routes to OpenRouter). - -New models: - - openai/upstage/solar-pro4 - - openai/nvidia/nemotron-3.5-lightning:free - - openai/liquid/lfm-2.5-2.6b:free - -The `openai/` prefix is already stripped in _resolve_llm_params and routed to -OpenRouter, so we only need to register the model IDs, imports, and the -available-models list. -""" - -import ast -import os - -IDS_FILE = "/app/agent/core/model_ids.py" -AGENT_FILE = "/app/backend/routes/agent.py" - -# ---------- 1. model_ids.py ---------- -with open(IDS_FILE) as f: - c = f.read() - -new_constants = '''UPSTAGE_SOLAR_PRO4_MODEL_ID = "openai/upstage/solar-pro4" -NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-lightning:free" -LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID = "openai/liquid/lfm-2.5-2.6b:free" -''' - -if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in c: - # Insert before DEEPSEEK_V4_PRO_MODEL_ID definition - target = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"' - if target in c: - c = c.replace(target, new_constants + target) - print("OK: added 3 new model constants to model_ids.py") - else: - # fallback: append at end of constants near TENCENT/HY3 block - anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"' - if anchor in c: - c = c.replace(anchor, anchor + "\n" + new_constants) - print("OK: added constants after LING block") - else: - print("FAIL: could not find insertion anchor in model_ids.py") -else: - print("OK: constants already present") - -with open(IDS_FILE, "w") as f: - f.write(c) -ast.parse(c) -print("OK: model_ids.py syntax OK") - -# ---------- 2. agent.py : imports + _available_models ---------- -with open(AGENT_FILE) as f: - ac = f.read() - -# Add imports -if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in ac: - old_imp = " NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n" - new_imp = ( - " NEX_N2_MINI_MODEL_ID,\n" - " LING_3_0_FLASH_FREE_MODEL_ID,\n" - " UPSTAGE_SOLAR_PRO4_MODEL_ID,\n" - " NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID,\n" - " LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID,\n" - ) - if old_imp in ac: - ac = ac.replace(old_imp, new_imp) - print("OK: added new model imports") - else: - print("WARN: import anchor not matched") - -# Add to _available_models list - insert after LING_3_0_FLASH line -old_line = ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n' -new_line = old_line + ( - ' {"id": UPSTAGE_SOLAR_PRO4_MODEL_ID, "label": "UPSTAGE Solar Pro 4", "recommended": True},\n' - ' {"id": NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID, "label": "NVIDIA Nemotron 3.5 Lightning:free", "recommended": True},\n' - ' {"id": LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID, "label": "Liquid LFM 2.5 2.6B:free", "recommended": True},\n' -) -if old_line in ac: - ac = ac.replace(old_line, new_line) - print("OK: added 3 new models to _available_models") -else: - print("WARN: _available_models LING line not matched (check exact text)") - -with open(AGENT_FILE, "w") as f: - f.write(ac) -ast.parse(ac) -print("OK: agent.py syntax OK") - -print("DONE: 3 new OpenRouter models added (backend)") diff --git a/patch_openrouter_fix.py b/patch_openrouter_fix.py deleted file mode 100644 index e96649fe915a8355cfe5202775cde6d3c0cf5448..0000000000000000000000000000000000000000 --- a/patch_openrouter_fix.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Patch backend llm_params.py: force OpenRouter as the LiteLLM provider. - -Root cause: the openai/-prefixed (and deepseek/) model routing returns only -``api_base`` + ``api_key``. LiteLLM resolves the provider from the model -string's org prefix, so for models whose org is NOT a recognized LiteLLM -provider (upstage/, nvidia/, liquid/, meta-llama/, google/, poolside/, -inclusionai/, ...) it raises "LLM Provider NOT provided" even though -``api_base`` points at OpenRouter. Only orgs that happen to match a real -LiteLLM provider (e.g. tencent/) worked by luck. - -Fix: add ``custom_llm_provider: "openrouter"`` to both OpenRouter routing -branches so LiteLLM always uses the OpenRouter OpenAI-compatible client. - -This runs AFTER patch_models.py (which may already have inserted the routing -blocks); it is idempotent. -""" - -import ast -import os - -FILE = "/app/agent/core/llm_params.py" - -with open(FILE) as f: - c = f.read() - -before = c - -# --- 1. openai/ branch (unique via or_model line) --- -openai_old = ( - ' or_model = normalized_model[len("openai/"):]\n' - ' return {\n' - ' "model": or_model,\n' - ' "api_base": "https://openrouter.ai/api/v1",\n' - ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n' - ' }\n' -) -openai_new = ( - ' or_model = normalized_model[len("openai/"):]\n' - ' return {\n' - ' "model": or_model,\n' - ' "api_base": "https://openrouter.ai/api/v1",\n' - ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n' - ' "custom_llm_provider": "openrouter",\n' - ' }\n' -) - -# --- 2. deepseek/ branch (unique via normalized_model on its own line) --- -deepseek_old = ( - ' if normalized_model.startswith("deepseek/"):\n' - ' return {\n' - ' "model": normalized_model,\n' - ' "api_base": "https://openrouter.ai/api/v1",\n' - ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n' - ' }\n' -) -deepseek_new = ( - ' if normalized_model.startswith("deepseek/"):\n' - ' return {\n' - ' "model": normalized_model,\n' - ' "api_base": "https://openrouter.ai/api/v1",\n' - ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n' - ' "custom_llm_provider": "openrouter",\n' - ' }\n' -) - -if 'custom_llm_provider' in c: - print("OK: llm_params.py already contains custom_llm_provider (idempotent)") -else: - n1 = c.count(openai_old) - n2 = c.count(deepseek_old) - if n1 == 1: - c = c.replace(openai_old, openai_new) - print("OK: openai/ branch -> custom_llm_provider='openrouter'") - else: - print(f"WARN: openai/ branch match count = {n1} (expected 1)") - if n2 == 1: - c = c.replace(deepseek_old, deepseek_new) - print("OK: deepseek/ branch -> custom_llm_provider='openrouter'") - else: - print(f"WARN: deepseek/ branch match count = {n2} (expected 1)") - - if c != before: - with open(FILE, "w") as f: - f.write(c) - try: - ast.parse(c) - print("OK: llm_params.py written & syntax valid") - except SyntaxError as e: - print(f"FAIL: llm_params.py syntax error: {e}") - raise - else: - print("WARN: no changes applied") diff --git a/patch_permanent_session_logging.py b/patch_permanent_session_logging.py deleted file mode 100644 index 890388c88798e71238c712fba6d9b2bd4289dda6..0000000000000000000000000000000000000000 --- a/patch_permanent_session_logging.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch: Permanent session & user context logging. - -Lưu vĩnh viễn ngữ cảnh từng phiên/user vào dataset HF ngay khi có message/event mới. -Tắt thông báo "where we here now?". Đảm bảo mọi model đều được lưu session. - -Chạy trong Docker build — an toàn với mọi trường hợp lỗi (never fails build). -""" - -import ast -import os -import sys - -PATCHED_MARKER = "# === PATCH: permanent session logging ===" - -def _safe_replace(content, old, new, count=1): - """Replace and verify count changed.""" - if old in content: - content = content.replace(old, new, count) - return content, True - return content, False - -def patch_file(path, patch_fn): - """Safely read, patch, and write a file. Returns True if patched.""" - if not os.path.exists(path): - print(f"SKIP: {path} not found") - return False - try: - with open(path, "r") as f: - content = f.read() - content, changed = patch_fn(content) - if not changed: - print(f"SKIP: {path} — no changes needed") - return False - # Validate syntax - try: - ast.parse(content) - except SyntaxError as e: - print(f"FAIL: {path} syntax: {e}") - return False - with open(path, "w") as f: - f.write(content) - print(f"OK: {path} patched") - return True - except Exception as e: - print(f"WARN: {path} patch error: {e}") - return False - - -# ─── Patch session_manager.py ──────────────────────────────────────────────── -print("=== Patching session_manager.py ===") - -SESSION_MANAGER = "/app/backend/session_manager.py" - -def _patch_session_manager(content): - changed = False - - # Add import - if "from backend.session_logger import" not in content: - content, ok = _safe_replace( - content, - "from agent.messaging.gateway import NotificationGateway", - "from agent.messaging.gateway import NotificationGateway\n" - "from backend.session_logger import log_message as _perm_log_message, log_trajectory as _perm_log_trajectory", - ) - if ok: - print(" + Added session_logger import") - changed = True - - # Patch create_session - if PATCHED_MARKER not in content: - # Add session creation logging after session_id = str(uuid.uuid4()) - old = " session_id = str(uuid.uuid4())\n" - new = ''' session_id = str(uuid.uuid4()) - - # === PATCH: permanent session logging === - # Log session creation to dataset IMMEDIATELY — every model gets session saved - try: - def _do_create_log(): - _perm_log_message( - session_id=session_id, - user_id=user_id or "dev", - model_name=model or "", - role="system", - content=f"Session created for {user_id or 'dev'} with model {model or 'default'}", - event_type="session_created", - seq=0, - extra={"surface": "frontend"}, - hf_user=hf_username, - ) - asyncio.get_event_loop().run_in_executor(None, _do_create_log) - except Exception as _e: - logger.debug("Session creation logging failed: %s", _e) -''' - content, ok = _safe_replace(content, old, new) - if ok: - print(" + Added session creation logging") - changed = True - - # Patch submit_user_input - old_submit = " agent_session.submission_queue.put_nowait(submission)\n self._touch(agent_session)" - new_submit = ''' agent_session.submission_queue.put_nowait(submission) - self._touch(agent_session) - - # === PATCH: log user message immediately === - try: - model_name = getattr(agent_session.session.config, 'model_name', '') - def _do_msg_log(): - _perm_log_message( - session_id=session_id, - user_id=user_id, - model_name=model_name, - role="user", - content=text, - event_type="message", - seq=len(agent_session.session.context_manager.items), - extra={"source": "submit_user_input"}, - hf_user=agent_session.hf_username or agent_session.user_id, - ) - asyncio.get_event_loop().run_in_executor(None, _do_msg_log) - except Exception as _e: - logger.debug("Message logging failed: %s", _e)''' - content, ok = _safe_replace(content, old_submit, new_submit) - if ok: - print(" + Added immediate message logging") - changed = True - - # Patch trajectory upload - old_refresh = " session.save_and_upload_detached(session.config.session_dataset_repo)" - new_refresh = ''' session.save_and_upload_detached(session.config.session_dataset_repo) - # === PATCH: upload full trajectory to permanent dataset === - try: - trajectory = session.get_trajectory() - hf_user = agent_session.hf_username or agent_session.user_id - def _do_traj_upload(): - _perm_log_trajectory(trajectory, session.user_id or "dev", hf_user, agent_session.hf_token, False) - asyncio.get_event_loop().run_in_executor(None, _do_traj_upload) - except Exception as _e: - logger.debug("Trajectory upload failed: %s", _e)''' - content, ok = _safe_replace(content, old_refresh, new_refresh) - if ok: - print(" + Added trajectory upload") - changed = True - - # Remove 'where we here now?' - if "where we here now" in content.lower(): - lines = content.split("\n") - content = "\n".join([l for l in lines if "where we here now" not in l.lower()]) - print(" + Removed 'where we here now?' notification") - changed = True - - return content, changed - -patch_file(SESSION_MANAGER, _patch_session_manager) - -# ─── Patch routes/agent.py ─────────────────────────────────────────────────── -print("\n=== Patching routes/agent.py ===") - -ROUTES_AGENT = "/app/backend/routes/agent.py" - -def _patch_routes(content): - changed = False - - # Add import - if "from backend.session_logger import" not in content: - content, ok = _safe_replace( - content, - "from session_manager import (\n", - "from backend.session_logger import log_message as _log_session, PUBLIC_SESSIONS_DATASET\n" - "from session_manager import (\n", - ) - if ok: - print(" + Added session_logger import") - changed = True - - # Add endpoints - if "/session-log/" not in content: - log_endpoint = ''' - -@router.post("/session-log/{session_id}") -async def log_session_event( - session_id: str, - body: dict, - user: dict = Depends(get_current_user), -) -> dict: - """Receive permanent session event logs from the frontend.""" - event_type = body.get("event_type", "frontend_event") - data = body.get("data", {}) - seq = body.get("seq", 0) - role = body.get("role", "frontend") - content_text = body.get("content", "") - extra = {k: v for k, v in body.items() if k not in ("event_type", "data", "timestamp", "seq", "role", "content")} - try: - def _do_log(): - _log_session( - session_id=session_id, - user_id=user.get("user_id") or user.get("username") or "dev", - model_name="", - role=role, - content=content_text or str(data)[:500], - event_type=event_type, - seq=seq, - extra=extra, - hf_user=user.get("username") or user.get("user_id"), - ) - asyncio.get_event_loop().run_in_executor(None, _do_log) - except Exception: - pass - return {"status": "logged", "session_id": session_id} - - -@router.get("/sessions-history") -async def get_sessions_history(user: dict = Depends(get_current_user)) -> list: - """Get ALL sessions for user from permanent dataset (survives restart).""" - from backend.session_logger import get_user_sessions - user_id = user.get("user_id") or user.get("username") or "dev" - return get_user_sessions(user_id, limit=100) - - -@router.get("/session-history/{session_id}") -async def get_session_history( - session_id: str, user: dict = Depends(get_current_user) -) -> list: - """Get full message history for a session from the permanent dataset.""" - from backend.session_logger import load_session_history - return load_session_history(session_id) - - -@router.get("/dataset-info") -async def get_dataset_info(user: dict = Depends(get_current_user)) -> dict: - """Info about the permanent sessions dataset.""" - return { - "dataset_repo": PUBLIC_SESSIONS_DATASET, - "dataset_url": f"https://huggingface.co/datasets/{PUBLIC_SESSIONS_DATASET}", - "storage": "permanent (HF Dataset, never lost)", - "note": "Sessions saved to dataset immediately on creation and on each message.", - } -''' - content = content.rstrip() + "\n" + log_endpoint - print(" + Added permanent logging endpoints") - changed = True - - # Remove 'where we here now?' - if "where we here now" in content.lower(): - lines = content.split("\n") - content = "\n".join([l for l in lines if "where we here now" not in l.lower()]) - print(" + Removed 'where we here now?' notification") - changed = True - - return content, changed - -patch_file(ROUTES_AGENT, _patch_routes) - -# ─── Patch session.py ──────────────────────────────────────────────────────── -print("\n=== Patching session.py ===") - -SESSION_FILE = "/app/agent/core/session.py" - -def _patch_session(content): - changed = False - - # Add import - if "from backend.session_logger import" not in content: - content, ok = _safe_replace( - content, - "import uuid\n", - "import uuid\n" - "# === PATCH: permanent session logging ===\n" - "try:\n" - " from backend.session_logger import log_message as _perm_log_message\n" - "except Exception:\n" - " def _perm_log_message(*a, **kw): pass\n", - ) - if ok: - print(" + Added session_logger import") - changed = True - - # Patch send_event to log permanently - if "# === PATCH: permanent event logging ===" not in content: - old_put = " await self.event_queue.put(event)\n await self._enqueue_auto_notification_requests(event)" - new_put = ''' await self.event_queue.put(event) - # === PATCH: permanent event logging === - try: - def _do_event_log(): - _perm_log_message( - session_id=self.session_id, - user_id=self.user_id or "dev", - model_name=self.config.model_name, - role="event", - content=str(event.data) if event.data else "", - event_type=event.event_type, - seq=event.seq, - extra={"session_id": self.session_id}, - hf_user=self.hf_username or self.user_id, - ) - asyncio.get_event_loop().run_in_executor(None, _do_event_log) - except Exception as _e: - logger.debug("Permanent event logging failed: %s", _e) - await self._enqueue_auto_notification_requests(event)''' - content, ok = _safe_replace(content, old_put, new_put) - if ok: - print(" + Added permanent event logging to send_event") - changed = True - - # Patch start_new_conversation to log - if "# === PATCH: log new conversation ===" not in content: - old_conv = " self.context_manager.items = [system_msg] if system_msg is not None else []" - new_conv = ''' self.context_manager.items = [system_msg] if system_msg is not None else [] - # === PATCH: log new conversation start === - try: - def _do_conv_log(): - _perm_log_message( - session_id=self.session_id, - user_id=self.user_id or "dev", - model_name=self.config.model_name, - role="system", - content=f"New conversation started (session: {self.session_id})", - event_type="new_conversation", - seq=0, - extra={}, - hf_user=self.hf_username or self.user_id, - ) - asyncio.get_event_loop().run_in_executor(None, _do_conv_log) - except Exception as _e: - logger.debug("New conversation logging failed: %s", _e)''' - content, ok = _safe_replace(content, old_conv, new_conv) - if ok: - print(" + Added new conversation logging") - changed = True - - return content, changed - -patch_file(SESSION_FILE, _patch_session) - -# ─── Patch frontend: disable 'where we here now?' ────────────────────────────── -print("\n=== Patching frontend for notification removal ===") -frontend_src = "/source/frontend/src" -if os.path.exists(frontend_src): - for root, dirs, files in os.walk(frontend_src): - for fname in files: - if fname.endswith((".ts", ".tsx")): - fpath = os.path.join(root, fname) - try: - with open(fpath, "r", encoding="utf-8") as f: - fcontent = f.read() - if "where we here now" in fcontent.lower(): - lines = fcontent.split("\n") - filtered = [l for l in lines if "where we here now" not in l.lower()] - with open(fpath, "w", encoding="utf-8") as f: - f.write("\n".join(filtered)) - print(f" + Removed 'where we here now?' from {fpath}") - except Exception: - pass -else: - print(" SKIP: frontend source not found") - -print("\n=== All patches applied (build will not fail regardless) ===") diff --git a/patch_restore_full_app.py b/patch_restore_full_app.py deleted file mode 100644 index e6cc3031f004a4ee969520307b14f657b0317aed..0000000000000000000000000000000000000000 --- a/patch_restore_full_app.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Restore the pre-retirement full app: App.tsx (frontend) + start.sh (backend). - -Upstream smolagents/ml-intern retired the hosted app: frontend App.tsx now -renders RetirementPage ("ML Intern has moved to HuggingChat") and backend/start.sh -now runs retired_main:app. That turns this Space into a static redirect page and -the backend never starts, so our model patches have no visible effect. - -This patch overwrites both files with the last pre-retirement versions that this -Space's Dockerfile was built on: - - frontend/src/App.tsx -> full AppLayout + useAuth app - - backend/start.sh -> uvicorn main:app (the real backend) - -Runs from either build stage: it patches whichever target exists and prints -SKIP for the other (idempotent, re-runnable). -""" - -import os - -FRONTEND_APP = r"""import { Box } from '@mui/material'; -import AppLayout from '@/components/Layout/AppLayout'; -import { useAuth } from '@/hooks/useAuth'; - -function App() { - // Non-blocking auth check -- fires in background, updates store when done. - // If auth fails later, apiFetch redirects to /auth/login. - useAuth(); - - return ( - - - - ); -} - -export default App; -""" - -BACKEND_START = r"""#!/bin/bash -# Entrypoint for HF Spaces dev mode compatibility. -# Dev mode spawns CMD multiple times simultaneously on restart. -# Only the first instance can bind port 7860 - the rest must exit -# with code 0 so the dev mode daemon doesn't mark the app as crashed. - -# Run uvicorn; if it fails due to port conflict, exit cleanly. -uvicorn main:app --host 0.0.0.0 --port 7860 -EXIT_CODE=$? - -if [ $EXIT_CODE -ne 0 ]; then - # Check if this was a port-in-use failure (another instance already running) - echo "uvicorn exited with code $EXIT_CODE, exiting gracefully." - exit 0 -fi -""" - - -def _write(path: str, content: str, executable: bool = False) -> bool: - if not os.path.exists(path): - return False - with open(path, "w", encoding="utf-8") as f: - f.write(content) - if executable: - os.chmod(path, 0o755) - return True - - -changed = False - -written = _write("/source/frontend/src/App.tsx", FRONTEND_APP) -if written: - print("OK: restored /source/frontend/src/App.tsx (full AppLayout app)") - changed = True -else: - print("SKIP: /source/frontend/src/App.tsx not present (backend stage?)") - -written = _write("/app/backend/start.sh", BACKEND_START, executable=True) -if written: - print("OK: restored /app/backend/start.sh (uvicorn main:app)") - changed = True -else: - print("SKIP: /app/backend/start.sh not present (frontend stage?)") - -if not changed: - raise SystemExit("FAIL: neither target file found - unexpected layout") -print("OK: full-app restore done") \ No newline at end of file diff --git a/patch_space_bunny.py b/patch_space_bunny.py deleted file mode 100644 index 933c4d6f0f148380584e82585c5e14315d0f29b0..0000000000000000000000000000000000000000 --- a/patch_space_bunny.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Patch frontend: add stealth/space-bunny-alpha beside Laguna S 2.1. - -The model uses the same OpenRouter-style `openai/` model path convention as -Laguna S 2.1 (`openai/poolside/laguna-s-2.1:free`). -""" - -import os - -FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" - - -def patch(): - if not os.path.exists(FILE): - print(f"SKIP: {FILE} not found") - return - - with open(FILE, "r", encoding="utf-8") as f: - content = f.read() - - if "space-bunny-alpha" in content: - print("OK: stealth/space-bunny-alpha already present") - return - - anchor = ( - " {\n" - " id: 'laguna-s21',\n" - " name: 'Laguna S 2.1:free',\n" - " modelPath: 'openai/poolside/laguna-s-2.1:free',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/poolside',\n" - " recommended: true,\n" - " },\n" - ) - new_entry = ( - " {\n" - " id: 'space-bunny-alpha',\n" - " name: 'stealth/space-bunny-alpha',\n" - " modelPath: 'openai/stealth/space-bunny-alpha',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/stealth',\n" - " recommended: true,\n" - " },\n" - ) - - if anchor in content: - content = content.replace(anchor, anchor + new_entry, 1) - print("OK: added stealth/space-bunny-alpha after Laguna S 2.1") - else: - print("FAIL: could not find Laguna S 2.1 model entry") - return - - with open(FILE, "w", encoding="utf-8") as f: - f.write(content) - print("OK: ChatInput.tsx patched") - - -if __name__ == "__main__": - patch() diff --git a/patch_xing_nemotron_vl.py b/patch_xing_nemotron_vl.py deleted file mode 100644 index 3616cc370f698d03093f5e3802786acb1386c3f2..0000000000000000000000000000000000000000 --- a/patch_xing_nemotron_vl.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Patch backend: add 2 new OpenRouter models (openai/ prefix routes to OpenRouter). - -New models (same `openai/` path convention as Laguna S 2.1:free): - - openai/xingchen-agi/xing4.0-29b-a4b:free - - openai/nvidia/nemotron-3.5-super-vl:free - -The `openai/` prefix is already stripped in _resolve_llm_params and routed to -OpenRouter, so we only need to register the model IDs, imports, and the -available-models list. - -Runs AFTER patch_models.py / patch_new_models.py / patch_apodex_ling_beatapi.py. -Idempotent. -""" - -import ast - -IDS_FILE = "/app/agent/core/model_ids.py" -AGENT_FILE = "/app/backend/routes/agent.py" - -# ---------- 1. model_ids.py ---------- -with open(IDS_FILE) as f: - c = f.read() - -new_constants = ( - 'XINGCHEN_XING4_0_29B_FREE_MODEL_ID = "openai/xingchen-agi/xing4.0-29b-a4b:free"\n' - 'NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-super-vl:free"\n' -) - -if "XINGCHEN_XING4_0_29B_FREE_MODEL_ID" not in c: - # Insert before DEEPSEEK_V4_PRO_MODEL_ID definition (same anchor as - # patch_new_models.py uses). - target = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"' - if target in c: - c = c.replace(target, new_constants + target) - print("OK: added 2 new model constants to model_ids.py") - else: - # Fallback anchor: the LING_3_0_FLASH line injected by patch_models.py. - anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"' - if anchor in c: - c = c.replace(anchor, anchor + "\n" + new_constants) - print("OK: added constants after LING block") - else: - print("FAIL: could not find insertion anchor in model_ids.py") -else: - print("OK: constants already present") - -with open(IDS_FILE, "w") as f: - f.write(c) -ast.parse(c) -print("OK: model_ids.py syntax OK") - -# ---------- 2. agent.py : imports + _available_models ---------- -with open(AGENT_FILE) as f: - ac = f.read() - -# Add imports after LING_3_0_FLASH_FREE_MODEL_ID, -if "XINGCHEN_XING4_0_29B_FREE_MODEL_ID" not in ac: - old_imp = " NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n" - new_imp = ( - " NEX_N2_MINI_MODEL_ID,\n" - " LING_3_0_FLASH_FREE_MODEL_ID,\n" - " XINGCHEN_XING4_0_29B_FREE_MODEL_ID,\n" - " NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID,\n" - ) - if old_imp in ac: - ac = ac.replace(old_imp, new_imp) - print("OK: added new model imports") - else: - print("WARN: import anchor not matched") - -# Add to _available_models list - insert after the LING_3_0_FLASH entry -old_line = ( - ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, ' - '"label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n' -) -new_line = old_line + ( - ' {"id": XINGCHEN_XING4_0_29B_FREE_MODEL_ID, ' - '"label": "xingchen-agi/xing4.0-29b-a4b:free", "recommended": True},\n' - ' {"id": NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID, ' - '"label": "nvidia/nemotron-3.5-super-vl:free", "recommended": True},\n' -) -if old_line in ac: - ac = ac.replace(old_line, new_line) - print("OK: added 2 new models to _available_models") -else: - print("WARN: _available_models LING line not matched (check exact text)") - -with open(AGENT_FILE, "w") as f: - f.write(ac) -ast.parse(ac) -print("OK: agent.py syntax OK") - -print("DONE: 2 new OpenRouter models added (backend)") \ No newline at end of file diff --git a/scripts/build_kpis.py b/scripts/build_kpis.py deleted file mode 100644 index 60e078927a8e581fdd169e98b6986a11674d0a4a..0000000000000000000000000000000000000000 --- a/scripts/build_kpis.py +++ /dev/null @@ -1,917 +0,0 @@ -#!/usr/bin/env python3 -"""Hourly KPI rollup for the session-trajectory dataset. - -================================================================================ - Data flow -================================================================================ - - ┌────────────────────┐ heartbeat ┌────────────────────────────────┐ - │ agent (CLI/web) │ ───────────────▶ │ hf-agent-sessions (dataset) │ - │ Session.send_event│ │ sessions/YYYY-MM-DD/.jsonl│ - └────────────────────┘ └───────────────┬────────────────┘ - │ cron @:05 each hour - ▼ - ┌──────────────────────────────────┐ - │ scripts/build_kpis.py │ - │ (GitHub Actions) │ - └───────────────┬──────────────────┘ - │ upload CSV - ▼ - ┌──────────────────────────────────┐ - │ hf-agent-kpis (dataset) │ - │ hourly/YYYY-MM-DD/HH.csv │ - └──────────────────────────────────┘ - -Each hourly run reads today's + yesterday's session folders (to cover sessions -that crossed midnight), filters events into the target hour window -``[hour, hour+1h)``, computes aggregates, and writes one CSV at -``hourly//.csv`` in the target dataset. Uploads are idempotent — -re-running the same hour overwrites. - -================================================================================ - Metrics (one row per hour) -================================================================================ - - sessions — distinct session_ids with ≥1 event in window - users — distinct user ids (when present on session rows) - turns — sum of user-message counts across active sessions - llm_calls — count of llm_call events - tokens_prompt / _completion / _cache_read / _cache_creation - cost_usd — sum of llm_call.cost_usd - cost_per_session_mean / _p50 / _p95 — per-session cost distribution - cache_hit_ratio — cache_read / (cache_read + prompt) - tool_calls_total / _succeeded / _failed — per-tool_output reliability counts - tool_success_rate — succeeded / total (kept for back-compat) - successful_sessions / errored_sessions / regenerated_sessions — outcome counts - failure_rate / regenerate_rate — kept for back-compat - time_to_first_action_s_p50 / _p95 — from session_start to first tool_call - thumbs_up / thumbs_down - hf_jobs_submitted / _succeeded / _blocked - sandboxes_created / _cpu / _gpu — sandbox_create events bucketed by hardware - pro_cta_clicks - gpu_hours_by_flavor_json — JSON-serialised {flavor: gpu-hours} - research_calls — total `research` tool_call events - sessions_with_research — sessions that called `research` ≥1 - research_calls_per_session_p50 / _p95 — among sessions that did any (zero-only sessions excluded) - distinct_tools_per_session_p50 / _p95 — among sessions with ≥1 named tool_call - tool_calls_per_session_p50 / _p95 — among sessions with ≥1 named tool_call - tool_calls_per_turn_p50 / _p95 — calls / turns, among sessions with turns>0 - tool_calls_by_name_json — JSON {tool: total_calls} (all tools seen) - sessions_using_tool_json — JSON {tool: distinct_sessions_using} - sessions_by_model_json — JSON {model_name: count} (router/local split) - -================================================================================ - Usage -================================================================================ - - # Run for the most recently completed hour (default — the cron path): - python scripts/build_kpis.py - - # Backfill last 24 hours: - python scripts/build_kpis.py --hours 24 - - # Explicit hour (UTC): - python scripts/build_kpis.py --datetime 2026-04-24T14 - -Env: - HF_TOKEN (or HF_KPI_WRITE_TOKEN) — write access to the target dataset. - -================================================================================ - Deploy -================================================================================ - -See ``.github/workflows/build-kpis.yml`` — runs every hour at :05. To provision: - - 1. Create the target dataset (once): - huggingface-cli repo create hf-agent-kpis --type dataset - 2. Put ``HF_KPI_WRITE_TOKEN`` (or ``HF_TOKEN``) into repo Actions secrets. - 3. Merge this file; the first scheduled run fires within the hour. -""" - -from __future__ import annotations - -import argparse -import io -import json -import logging -import os -import sys -import tempfile -from collections import defaultdict -from datetime import date, datetime, timedelta, timezone -from typing import Any, Iterable - -logger = logging.getLogger("build_kpis") - -# Rough gpu-hour pricing for hf_jobs flavor strings. Keep conservative; used -# only to compute gpu-hours (not dollars) — wall_time_s * flavor_gpu_count. -_FLAVOR_GPU_COUNT = { - "cpu-basic": 0, - "cpu-upgrade": 0, - "t4-small": 1, - "t4-medium": 1, - "l4x1": 1, - "l4x4": 4, - "l40sx1": 1, - "l40sx4": 4, - "l40sx8": 8, - "a10g-small": 1, - "a10g-large": 1, - "a10g-largex2": 2, - "a10g-largex4": 4, - "a100-large": 1, - "a100x2": 2, - "a100x4": 4, - "a100x8": 8, - "h100": 1, - "h100x8": 8, -} - - -def _percentile(values: list[float], p: float) -> float: - if not values: - return 0.0 - values = sorted(values) - k = (len(values) - 1) * p - f = int(k) - c = min(f + 1, len(values) - 1) - if f == c: - return float(values[f]) - return float(values[f] + (values[c] - values[f]) * (k - f)) - - -def _parse_ts(s: Any) -> datetime | None: - if not s or not isinstance(s, str): - return None - try: - dt = datetime.fromisoformat(s) - except Exception: - return None - # Normalise to aware UTC so comparisons work against window bounds. - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - - -def _iter_session_files(api, repo_id: str, day: date, token: str) -> Iterable[str]: - """Yield repo-relative paths for all sessions under ``sessions/YYYY-MM-DD/``.""" - prefix = f"sessions/{day.isoformat()}/" - try: - files = api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token) - except Exception as e: - logger.warning("list_repo_files(%s) failed: %s", repo_id, e) - return [] - return [f for f in files if f.startswith(prefix) and f.endswith(".jsonl")] - - -def _is_row_format(row: dict) -> bool: - """True if this row is the single-row 'row' format (has session_id).""" - return isinstance(row, dict) and "session_id" in row - - -def _is_cc_event(row: dict) -> bool: - """True if this row is a Claude-Code-format event line.""" - return ( - isinstance(row, dict) - and "type" in row - and "message" in row - and "uuid" in row - ) - - -def _cc_event_to_internal(ev: dict) -> dict: - """Convert one Claude-Code-format event into the internal session shape. - - The session_uploader writes one event per line (see - agent/core/session_uploader.py::to_claude_code_jsonl). build_kpis needs - ``messages`` and ``events`` — reconstruct them from the CC stream. - """ - etype = ev.get("type") - msg = ev.get("message") or {} - role = msg.get("role") - content = msg.get("content") - ts = ev.get("timestamp") - - if etype == "user" and role == "user": - blocks = content if isinstance(content, list) else None - tool_results = [] - text_parts = [] - if blocks: - for b in blocks: - if not isinstance(b, dict): - text_parts.append(str(b)) - continue - if b.get("type") == "tool_result": - tool_results.append( - { - "tool_call_id": b.get("tool_use_id") or "", - "content": b.get("content"), - "timestamp": ts, - } - ) - elif "text" in b: - text_parts.append(b.get("text") or "") - else: - text_parts.append(json.dumps(b, default=str)) - if tool_results: - return {"messages": [], "events": tool_results} - return {"messages": [{"role": "user", "content": "\n".join(text_parts), "timestamp": ts}], "events": []} - - if etype == "assistant" and role == "assistant": - text_parts = [] - tool_calls = [] - blocks = content if isinstance(content, list) else None - if blocks: - for b in blocks: - if not isinstance(b, dict): - text_parts.append(str(b)) - continue - if b.get("type") == "tool_use": - tool_calls.append( - { - "id": b.get("id") or "", - "type": "function", - "function": { - "name": b.get("name") or "", - "arguments": json.dumps(b.get("input") or {}, default=str), - }, - } - ) - elif "text" in b: - text_parts.append(b.get("text") or "") - return { - "messages": [ - { - "role": "assistant", - "content": "\n".join(text_parts), - "tool_calls": tool_calls, - "timestamp": ts, - } - ], - "events": [], - } - - return {"messages": [], "events": []} - - -def _download_session(repo_id: str, path: str, token: str) -> dict | None: - """Fetch one session file and decode it into the internal session shape. - - Supports two on-disk formats: - - * ``row`` — single-line JSONL row used by the org telemetry/KPI pipeline - (``session_id`` + ``messages``/``events`` as JSON strings). - * ``claude_code`` — one event per line in the Claude Code JSONL schema, - auto-detected by the HF Agent Trace Viewer - (https://huggingface.co/changelog/agent-trace-viewer). Each line is - converted into the internal ``messages``/``events`` shape. - - ``hf_hub_download`` caches; second run within the same process / runner - directory is near-free. - """ - from huggingface_hub import hf_hub_download - - try: - local = hf_hub_download( - repo_id=repo_id, - filename=path, - repo_type="dataset", - token=token, - ) - except Exception as e: - logger.warning("hf_hub_download(%s) failed: %s", path, e) - return None - try: - with open(local, "r") as f: - raw = f.read() - - if not raw.strip(): - return None - - # Peek at the first non-empty line to decide the format. - first_line = next( - (ln.strip() for ln in raw.splitlines() if ln.strip()), "" - ) - if not first_line: - return None - try: - first = json.loads(first_line) - except Exception as e: - logger.warning("parse(%s) failed: %s", path, e) - return None - - if _is_row_format(first): - row = first - for key in ("messages", "events", "tools"): - v = row.get(key) - if isinstance(v, str): - try: - row[key] = json.loads(v) - except Exception: - row[key] = [] - return row - - if not _is_cc_event(first): - logger.warning("parse(%s): unrecognised format, skipping", path) - return None - - # Claude-Code format: one event per line. Reconstruct the internal - # session shape by walking every line. - session_id = first.get("sessionId") or "" - session_start = first.get("timestamp") or "" - messages: list[dict] = [] - events: list[dict] = [] - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - try: - ev = json.loads(line) - except Exception: - continue - if not _is_cc_event(ev): - continue - converted = _cc_event_to_internal(ev) - messages.extend(converted.get("messages") or []) - events.extend(converted.get("events") or []) - - return { - "session_id": session_id, - "session_start_time": session_start, - "session_end_time": session_start, - "model_name": "", - "total_cost_usd": None, - "messages": messages, - "events": events, - "tools": [], - } - except Exception as e: - logger.warning("parse(%s) failed: %s", path, e) - return None - - -def _filter_session_to_window( - session: dict, - start: datetime, - end: datetime, -) -> dict | None: - """Return a copy of ``session`` whose events are only those in ``[start, end)``. - - ``None`` if no event falls in the window — the caller drops the session - from this hour's aggregate. - """ - events = session.get("events") or [] - in_window = [] - for ev in events: - ts = _parse_ts(ev.get("timestamp")) - if ts is None: - continue - if start <= ts < end: - in_window.append(ev) - if not in_window: - return None - return {**session, "events": in_window} - - -def _session_metrics(session: dict) -> dict: - """Reduce a single session trajectory to its KPI contributions. - - Assumes ``events`` are already filtered to the target window by the caller. - """ - # Pre-seed every numeric key so downstream aggregation can sum without - # having to special-case empty sessions. - out: dict = { - "sessions": 0, - "turns": 0, - "llm_calls": 0, - "tokens_prompt": 0, - "tokens_completion": 0, - "tokens_cache_read": 0, - "tokens_cache_creation": 0, - "cost_usd": 0.0, - "tool_calls_total": 0, - "tool_calls_success": 0, - "failures": 0, - "regenerate_sessions": 0, - "thumbs_up": 0, - "thumbs_down": 0, - "hf_jobs_submitted": 0, - "hf_jobs_succeeded": 0, - "hf_jobs_blocked": 0, - "pro_cta_clicks": 0, - "pro_conversions": 0, - "credits_topped_up": 0, - "sandboxes_created": 0, - "sandboxes_cpu": 0, - "sandboxes_gpu": 0, - "first_tool_s": -1, - } - events = session.get("events") or [] - messages = session.get("messages") or [] - - turn_count = sum(1 for m in messages if m.get("role") == "user") - out["turns"] = turn_count - out["sessions"] = 1 - - tool_success = 0 - tool_total = 0 - had_error = False - had_undo = False - first_tool_ts = None - session_start = session.get("session_start_time") - gpu_hours_by_flavor: dict[str, float] = defaultdict(float) - jobs_submitted = 0 - jobs_succeeded = 0 - thumbs_up = 0 - thumbs_down = 0 - sandboxes_created = 0 - sandboxes_cpu = 0 - sandboxes_gpu = 0 - jobs_blocked = 0 - pro_cta_clicks = 0 - pro_conversions = 0 - credits_topped_up = 0 - pro_cta_by_source: dict[str, int] = defaultdict(int) - # Per-tool counters from tool_call events. Counted off tool_call (which - # carries data["tool"]) rather than tool_output (which only carries - # success/output) so we can attribute calls to specific tools. - tool_calls_by_name: dict[str, int] = defaultdict(int) - total_named_tool_calls = 0 - - start_dt = _parse_ts(session_start) - - for ev in events: - et = ev.get("event_type") - data = ev.get("data") or {} - ts = _parse_ts(ev.get("timestamp")) - - if et == "llm_call": - out["llm_calls"] += 1 - out["tokens_prompt"] += int(data.get("prompt_tokens") or 0) - out["tokens_completion"] += int(data.get("completion_tokens") or 0) - out["tokens_cache_read"] += int(data.get("cache_read_tokens") or 0) - out["tokens_cache_creation"] += int(data.get("cache_creation_tokens") or 0) - out["cost_usd"] += float(data.get("cost_usd") or 0.0) - - elif et == "tool_output": - tool_total += 1 - if data.get("success"): - tool_success += 1 - if first_tool_ts is None and ts is not None and start_dt is not None: - first_tool_ts = (ts - start_dt).total_seconds() - - elif et == "tool_call": - name = data.get("tool") - if name: - tool_calls_by_name[name] += 1 - total_named_tool_calls += 1 - if first_tool_ts is None and ts is not None and start_dt is not None: - first_tool_ts = (ts - start_dt).total_seconds() - - elif et == "error": - had_error = True - - elif et == "undo_complete": - had_undo = True - - elif et == "feedback": - rating = data.get("rating") - if rating == "up": - thumbs_up += 1 - elif rating == "down": - thumbs_down += 1 - - elif et == "hf_job_submit": - jobs_submitted += 1 - - elif et == "hf_job_complete": - flavor = data.get("flavor") or "unknown" - status = (data.get("final_status") or "").lower() - wall = float(data.get("wall_time_s") or 0.0) - gpus = _FLAVOR_GPU_COUNT.get(flavor, 0) - gpu_hours_by_flavor[flavor] += wall * gpus / 3600.0 - if status in ("completed", "succeeded", "success"): - jobs_succeeded += 1 - - elif et == "jobs_access_blocked": - jobs_blocked += 1 - - elif et == "pro_cta_click": - pro_cta_clicks += 1 - source = str(data.get("source") or "unknown") - pro_cta_by_source[source] += 1 - - elif et == "pro_conversion": - pro_conversions += 1 - - elif et == "credits_topped_up": - credits_topped_up += 1 - - elif et == "sandbox_create": - sandboxes_created += 1 - hardware = (data.get("hardware") or "").lower() - # CPU flavors are explicitly named "cpu-*". Everything else - # (including unknown/missing hardware strings) lands in the GPU - # bucket, since the auto-create default is "cpu-basic" which is - # matched here — anything that isn't is almost always an explicit - # GPU choice. - if hardware.startswith("cpu-"): - sandboxes_cpu += 1 - else: - sandboxes_gpu += 1 - - out["tool_calls_total"] = tool_total - out["tool_calls_success"] = tool_success - out["failures"] = 1 if had_error else 0 - out["regenerate_sessions"] = 1 if had_undo else 0 - out["thumbs_up"] = thumbs_up - out["thumbs_down"] = thumbs_down - out["hf_jobs_submitted"] = jobs_submitted - out["hf_jobs_succeeded"] = jobs_succeeded - out["sandboxes_created"] = sandboxes_created - out["sandboxes_cpu"] = sandboxes_cpu - out["sandboxes_gpu"] = sandboxes_gpu - out["hf_jobs_blocked"] = jobs_blocked - out["pro_cta_clicks"] = pro_cta_clicks - out["pro_conversions"] = pro_conversions - out["credits_topped_up"] = credits_topped_up - out["first_tool_s"] = first_tool_ts if first_tool_ts is not None else -1 - out["_gpu_hours_by_flavor"] = dict(gpu_hours_by_flavor) - out["_pro_cta_by_source"] = dict(pro_cta_by_source) - out["_user"] = session.get("user_id") or session.get("session_id") - # Intra-session tool fields. Underscore-prefixed = consumed by _aggregate - # only, never written to CSV directly. - out["_tool_calls_by_name"] = dict(tool_calls_by_name) - out["_research_calls"] = tool_calls_by_name.get("research", 0) - out["_distinct_tools_used"] = len(tool_calls_by_name) - out["_total_named_tool_calls"] = total_named_tool_calls - out["_model_name"] = session.get("model_name") or "unknown" - return dict(out) - - -def _aggregate(per_session: list[dict]) -> dict: - """Collapse a bucket's worth of session rollups into the final KPI row.""" - ttfa_values = [ - s["first_tool_s"] for s in per_session if s.get("first_tool_s", -1) >= 0 - ] - gpu_hours: dict[str, float] = defaultdict(float) - for s in per_session: - for f, h in (s.get("_gpu_hours_by_flavor") or {}).items(): - gpu_hours[f] += h - - # Per-tool aggregates. ``sessions_using_tool`` counts each session at most - # once per tool, so the dashboard can show "how many sessions reached for - # research" alongside "how many research calls overall". - tool_calls_by_name: dict[str, int] = defaultdict(int) - sessions_using_tool: dict[str, int] = defaultdict(int) - sessions_by_model: dict[str, int] = defaultdict(int) - for s in per_session: - for name, count in (s.get("_tool_calls_by_name") or {}).items(): - tool_calls_by_name[name] += int(count) - sessions_using_tool[name] += 1 - sessions_by_model[s.get("_model_name") or "unknown"] += 1 - - # Percentile inputs. All "per session" percentiles exclude sessions that - # never reached for the relevant signal — otherwise quiet hours - # (status-check sessions, abandoned new conversations) drag every median - # to 0 and the chart tells you nothing. - research_calls_nz = [ - s.get("_research_calls", 0) - for s in per_session - if s.get("_research_calls", 0) > 0 - ] - distinct_tools_values = [ - s.get("_distinct_tools_used", 0) - for s in per_session - if s.get("_distinct_tools_used", 0) > 0 - ] - total_calls_values = [ - s.get("_total_named_tool_calls", 0) - for s in per_session - if s.get("_total_named_tool_calls", 0) > 0 - ] - # Per-turn intensity: turns>0 is the natural filter here (a session with - # 5 turns and 0 tools is a meaningful 0). Don't strip those. - calls_per_turn_values = [ - s.get("_total_named_tool_calls", 0) / s["turns"] - for s in per_session - if s.get("turns", 0) > 0 - ] - - total_sessions = sum(s["sessions"] for s in per_session) - total_turns = sum(s["turns"] for s in per_session) - tokens_prompt = sum(s["tokens_prompt"] for s in per_session) - tokens_cache_read = sum(s["tokens_cache_read"] for s in per_session) - tool_total = sum(s["tool_calls_total"] for s in per_session) - tool_success = sum(s["tool_calls_success"] for s in per_session) - failures = int(sum(s["failures"] for s in per_session)) - regenerates = int(sum(s["regenerate_sessions"] for s in per_session)) - research_calls_total = int(sum(s.get("_research_calls", 0) for s in per_session)) - sessions_with_research = sum( - 1 for s in per_session if s.get("_research_calls", 0) > 0 - ) - - # Per-session cost percentiles — chart "median session cost" alongside the - # mean so a few $700 outliers don't make you think every session is pricey. - session_costs = [float(s.get("cost_usd") or 0.0) for s in per_session] - cost_p50 = _percentile(session_costs, 0.5) - cost_p95 = _percentile(session_costs, 0.95) - - unique_users = {s.get("_user") for s in per_session if s.get("_user")} - - return { - "sessions": total_sessions, - "users": len(unique_users), - "turns": total_turns, - "llm_calls": int(sum(s["llm_calls"] for s in per_session)), - "tokens_prompt": int(tokens_prompt), - "tokens_completion": int(sum(s["tokens_completion"] for s in per_session)), - "tokens_cache_read": int(tokens_cache_read), - "tokens_cache_creation": int( - sum(s["tokens_cache_creation"] for s in per_session) - ), - "cost_usd": round(sum(s["cost_usd"] for s in per_session), 4), - # Per-session cost summaries. - "cost_per_session_mean": round( - sum(s["cost_usd"] for s in per_session) / total_sessions, 6 - ) - if total_sessions > 0 - else 0.0, - "cost_per_session_p50": round(cost_p50, 6), - "cost_per_session_p95": round(cost_p95, 6), - "cache_hit_ratio": round( - tokens_cache_read / (tokens_cache_read + tokens_prompt), 4 - ) - if (tokens_cache_read + tokens_prompt) > 0 - else 0.0, - # Raw reliability COUNTS (these are what the dashboard shows directly). - "tool_calls_total": int(tool_total), - "tool_calls_succeeded": int(tool_success), - "tool_calls_failed": int(tool_total - tool_success), - "errored_sessions": failures, - # Successful = "did not raise an error event". Mutually exclusive - # with errored_sessions; sums with errored_sessions to total sessions. - "successful_sessions": int(total_sessions - failures), - # Regenerated is an orthogonal dimension (the user retried) — a - # session can be both successful and regenerated, or both errored - # and regenerated. - "regenerated_sessions": regenerates, - # Rates kept for backwards compatibility with anything reading the - # KPI dataset directly. - "tool_success_rate": round(tool_success / tool_total, 4) - if tool_total > 0 - else 0.0, - "failure_rate": round(failures / total_sessions, 4) - if total_sessions > 0 - else 0.0, - "regenerate_rate": round(regenerates / total_sessions, 4) - if total_sessions > 0 - else 0.0, - "time_to_first_action_s_p50": round(_percentile(ttfa_values, 0.5), 2), - "time_to_first_action_s_p95": round(_percentile(ttfa_values, 0.95), 2), - "thumbs_up": int(sum(s["thumbs_up"] for s in per_session)), - "thumbs_down": int(sum(s["thumbs_down"] for s in per_session)), - "hf_jobs_submitted": int(sum(s["hf_jobs_submitted"] for s in per_session)), - "hf_jobs_succeeded": int(sum(s["hf_jobs_succeeded"] for s in per_session)), - "sandboxes_created": int( - sum(s.get("sandboxes_created", 0) for s in per_session) - ), - "sandboxes_cpu": int(sum(s.get("sandboxes_cpu", 0) for s in per_session)), - "sandboxes_gpu": int(sum(s.get("sandboxes_gpu", 0) for s in per_session)), - "hf_jobs_blocked": int(sum(s.get("hf_jobs_blocked", 0) for s in per_session)), - "pro_cta_clicks": int(sum(s.get("pro_cta_clicks", 0) for s in per_session)), - "pro_conversions": int(sum(s.get("pro_conversions", 0) for s in per_session)), - "credits_topped_up": int( - sum(s.get("credits_topped_up", 0) for s in per_session) - ), - "gpu_hours_by_flavor_json": json.dumps(dict(gpu_hours), sort_keys=True), - # Research KPIs — answer "is the agent reaching for research?". - "research_calls": research_calls_total, - "sessions_with_research": int(sessions_with_research), - "research_calls_per_session_p50": round(_percentile(research_calls_nz, 0.5), 2), - "research_calls_per_session_p95": round( - _percentile(research_calls_nz, 0.95), 2 - ), - # Intra-session breadth + intensity. p50 + p95 over per-session values. - "distinct_tools_per_session_p50": round( - _percentile(distinct_tools_values, 0.5), 2 - ), - "distinct_tools_per_session_p95": round( - _percentile(distinct_tools_values, 0.95), 2 - ), - "tool_calls_per_session_p50": round(_percentile(total_calls_values, 0.5), 2), - "tool_calls_per_session_p95": round(_percentile(total_calls_values, 0.95), 2), - "tool_calls_per_turn_p50": round(_percentile(calls_per_turn_values, 0.5), 2), - "tool_calls_per_turn_p95": round(_percentile(calls_per_turn_values, 0.95), 2), - # JSON columns let the dashboard add/remove tools without schema churn. - "tool_calls_by_name_json": json.dumps(dict(tool_calls_by_name), sort_keys=True), - "sessions_using_tool_json": json.dumps( - dict(sessions_using_tool), sort_keys=True - ), - # Surface split by selected model for dashboard drilldowns. - "sessions_by_model_json": json.dumps(dict(sessions_by_model), sort_keys=True), - } - - -# Back-compat alias: older tests call _aggregate_day. -_aggregate_day = _aggregate - - -def _csv_cell(v: Any) -> str: - s = str(v) - if "," in s or '"' in s or "\n" in s: - return '"' + s.replace('"', '""') + '"' - return s - - -def _write_csv( - api, - row: dict, - bucket_key: str, - path_in_repo: str, - target_repo: str, - token: str, -) -> None: - """Render ``row`` to CSV with a leading ``bucket`` column and upload. - - ``bucket_key`` is the hour string (ISO ``YYYY-MM-DDTHH``) or date string; - written as the ``bucket`` column so downstream consumers can union all - CSVs without date-parsing paths. ``api`` is the caller's ``HfApi`` - instance — reused so we don't spin up a fresh one per CSV. - """ - columns = list(row.keys()) - buf = io.StringIO() - buf.write(",".join(["bucket", *columns]) + "\n") - buf.write(",".join([bucket_key, *[_csv_cell(row[c]) for c in columns]]) + "\n") - - with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as tmp: - tmp.write(buf.getvalue()) - tmp_path = tmp.name - - try: - api.create_repo( - repo_id=target_repo, - repo_type="dataset", - exist_ok=True, - token=token, - ) - api.upload_file( - path_or_fileobj=tmp_path, - path_in_repo=path_in_repo, - repo_id=target_repo, - repo_type="dataset", - token=token, - commit_message=f"KPIs for {bucket_key}", - ) - finally: - try: - os.unlink(tmp_path) - except Exception: - pass - - -def run_for_hour( - api, - source_repo: str, - target_repo: str, - hour_dt: datetime, - token: str, -) -> dict: - """Roll up one UTC hour [hour_dt, hour_dt+1h). - - Reads today's + yesterday's session folders so sessions that crossed - midnight land in the right hourly bucket. - """ - if hour_dt.tzinfo is None: - hour_dt = hour_dt.replace(tzinfo=timezone.utc) - window_start = hour_dt.replace(minute=0, second=0, microsecond=0) - window_end = window_start + timedelta(hours=1) - - # Sessions partition by session_start_time date. A session that started - # at 23:50 yesterday can still emit events in today's first hours, so we - # look at both folders. - candidate_dates = {window_start.date(), (window_start - timedelta(days=1)).date()} - - per_session: list[dict] = [] - for d in sorted(candidate_dates): - for path in _iter_session_files(api, source_repo, d, token): - sess = _download_session(source_repo, path, token) - if not sess: - continue - windowed = _filter_session_to_window(sess, window_start, window_end) - if windowed is None: - continue - per_session.append(_session_metrics(windowed)) - - if not per_session: - logger.info("No sessions in window %s — skipping", window_start.isoformat()) - return {} - - row = _aggregate(per_session) - bucket_key = window_start.strftime("%Y-%m-%dT%H") - path_in_repo = ( - f"hourly/{window_start.strftime('%Y-%m-%d')}/{window_start.strftime('%H')}.csv" - ) - _write_csv(api, row, bucket_key, path_in_repo, target_repo, token) - logger.info( - "Wrote KPIs for %s (%d sessions): %s", - bucket_key, - per_session and len(per_session), - row, - ) - return row - - -# Back-compat for daily backfills — unchanged behaviour. -def run_for_day(api, source_repo: str, target_repo: str, day: date, token: str) -> dict: - paths = _iter_session_files(api, source_repo, day, token) - per_session: list[dict] = [] - for path in paths: - sess = _download_session(source_repo, path, token) - if not sess: - continue - per_session.append(_session_metrics(sess)) - if not per_session: - logger.info("No sessions found for %s — skipping", day) - return {} - row = _aggregate(per_session) - path_in_repo = f"daily/{day.isoformat()}.csv" - _write_csv(api, row, day.isoformat(), path_in_repo, target_repo, token) - return row - - -def _parse_hour_arg(s: str) -> datetime: - """Accept ``YYYY-MM-DDTHH`` or full ISO — always pinned to the start of the hour, UTC.""" - dt = datetime.fromisoformat(s) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.replace(minute=0, second=0, microsecond=0) - - -def main(argv: list[str] | None = None) -> int: - logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") - ap = argparse.ArgumentParser() - ap.add_argument("--source", default="smolagents/ml-intern-sessions") - ap.add_argument("--target", default="smolagents/ml-intern-kpis") - ap.add_argument( - "--hours", - type=int, - default=1, - help="Number of trailing hours to roll up (default: 1 = last completed hour).", - ) - ap.add_argument( - "--datetime", - type=str, - default=None, - help="Single hour, ISO ``YYYY-MM-DDTHH`` (UTC); overrides --hours.", - ) - ap.add_argument( - "--daily-backfill", - type=str, - default=None, - help="Escape hatch: aggregate a whole day at once (YYYY-MM-DD). " - "Writes to daily/.csv. Use for historical backfill only.", - ) - args = ap.parse_args(argv) - - token = ( - os.environ.get("HF_KPI_WRITE_TOKEN") - or os.environ.get("HF_SESSION_UPLOAD_TOKEN") - or os.environ.get("HF_TOKEN") - or os.environ.get("HF_ADMIN_TOKEN") - ) - if not token: - logger.error( - "No HF token found. Set one of: HF_KPI_WRITE_TOKEN, " - "HF_SESSION_UPLOAD_TOKEN, HF_TOKEN, HF_ADMIN_TOKEN." - ) - return 1 - - from huggingface_hub import HfApi - - api = HfApi() - - if args.daily_backfill: - run_for_day( - api, - args.source, - args.target, - date.fromisoformat(args.daily_backfill), - token, - ) - return 0 - - if args.datetime: - target_hours = [_parse_hour_arg(args.datetime)] - else: - now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) - # Roll up *completed* hours: start from the hour before ``now``. - target_hours = [now - timedelta(hours=i) for i in range(1, args.hours + 1)] - - for hour in target_hours: - run_for_hour(api, args.source, args.target, hour, token) - return 0 - - -if __name__ == "__main__": - sys.exit(main())