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/AUTO_CONTINUE_FEATURE.md b/AUTO_CONTINUE_FEATURE.md deleted file mode 100644 index adecb7598b822797cb47942bafb311d12578ef99..0000000000000000000000000000000000000000 --- a/AUTO_CONTINUE_FEATURE.md +++ /dev/null @@ -1,183 +0,0 @@ -# Auto-Continue Feature for Free Models - -## Tổng quan -Khi model miễn phí dùng OpenRouter phản hồi bị dừng nhưng nhiệm vụ chưa hoàn thành, hệ thống sẽ: -1. Phát hiện task incomplete dựa vào trạng thái plan -2. Hiển thị 2 nút: "Tự động tiếp tục (10s)" và "Tạm dừng" - -## Các file cần sửa - -### 1. frontend/src/hooks/useAgentChat.ts - -```typescript -// Thêm vào import -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -// Trong useAgentChat function, sau callbacksRef -const autoContinueTimerRef = useRef | null>(null); -const [showAutoContinue, setShowAutoContinue] = useState(false); -const [taskIncompleteInfo, setTaskIncompleteInfo] = useState<{ - incompletePlan: Array<{ id: string; content: string; status: string }>; -} | null>(null); - -// Thêm vào SideChannelCallbacks interface -export interface SideChannelCallbacks { - // ... existing callbacks ... - onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void; -} - -// Thêm vào sideChannel trong useMemo -onTaskIncomplete: (incompletePlan) => { - setTaskIncompleteInfo({ incompletePlan }); - setShowAutoContinue(true); - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - } - autoContinueTimerRef.current = setTimeout(() => { - startAutoContinue(); - }, 10000); -}, - -// Thêm hàm mới -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: ${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; - } -}, []); - -// Thêm vào return -return { - messages: chat.messages, - sendMessage: chat.sendMessage, - stop, - status: chat.status, - undoLastTurn, - editAndRegenerate, - approveTools, - refreshMessages, - showAutoContinue, - taskIncompleteInfo, - startAutoContinue, - cancelAutoContinue, -}; -``` - -### 2. frontend/src/components/Chat/ChatInput.tsx - -```typescript -// Thêm import -import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material'; - -// Thêm props -interface ChatInputProps { - // ... existing props - showAutoContinue?: boolean; - taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null; - startAutoContinue?: () => void; - cancelAutoContinue?: () => void; -} - -// Thêm UI buttons (trước hoặc sau input area) -{showAutoContinue && taskIncompleteInfo && ( - - - - -)} -``` - -### 3. frontend/src/lib/sse-chat-transport.ts - -```typescript -// Thêm vào case trong createEventToChunkStream -case 'task_incomplete': - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; -``` - -### 4. agent/core/agent_loop.py - -```python -# Trong run_agent, sau khi xử lý LLM response -def _check_task_incomplete(session: Session, llm_result: LLMResult) -> bool: - """Kiểm tra nếu model dừng nhưng task chưa hoàn thành.""" - if llm_result.tool_calls_acc: - return False # Có tool calls thì chưa incomplete - - plan = getattr(session, "current_plan", None) or [] - unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")] - - return len(unfinished) > 0 - -# Gọi sau khi nhận kết quả LLM: -if _check_task_incomplete(session, llm_result): - unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")] - await session.send_event( - Event( - event_type="task_incomplete", - data={ - "incomplete_plan": unfinished, - "message": "Model stopped streaming but task is incomplete." - } - ) - ) -``` - -## Logic hoạt động - -1. Khi agent loop phát hiện model trả lời không có tool calls nhưng plan còn incomplete -2. Backend gửi event `task_incomplete` với danh sách task chưa hoàn thành -3. Frontend nhận event qua `onTaskIncomplete` callback -4. Hiển thị 2 nút: - - **Tự động tiếp tục (10s)**: Sau 10 giây sẽ tự động gửi tin nhắn tiếp tục - - **Tạm dừng**: Hủy timer, cho phép user nhập nội dung mới - -## Áp dụng patch - -```bash -# Tải source -git clone https://huggingface.co/spaces/smolagents/ml-intern /tmp/source - -# Sửa các file trên -# Sau đó rebuild frontend -cd /tmp/source/frontend -npm install && npm run build -``` \ No newline at end of file diff --git a/BUILD_TRIGGER b/BUILD_TRIGGER deleted file mode 100644 index 5b52fc9f825e7d7997f32173b23d1b1f0aed0156..0000000000000000000000000000000000000000 --- a/BUILD_TRIGGER +++ /dev/null @@ -1 +0,0 @@ -rebuild \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5bb922fea990dc6dc4a73dab162c64c3102cea7e..fdd1caafcdb953553ae231a67b08646b3c2f90ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,131 +1,60 @@ +# Stage 1: Build frontend 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 -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_frontend_xing_nemotron_vl.py /tmp/patch_frontend_xing_nemotron_vl.py -RUN python3 /tmp/patch_frontend_xing_nemotron_vl.py || true -COPY patch_flint_free_frontend.py /tmp/patch_flint_free_frontend.py -RUN python3 /tmp/patch_flint_free_frontend.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 -# v2: comprehensive catch-up fix + shareable URLs (frontend) -COPY patch_catch_up_fix_v2.py /tmp/patch_catch_up_fix_v2.py -RUN python3 /tmp/patch_catch_up_fix_v2.py || true -# v3: FIX THE BUILD — v2 left dead imports (_catchupDone, ShareIcon) and an -# untyped param that fail `npm run build` -> empty /app/static -> NO frontend. -# This MUST run after v2 so it can complete v2's half-applied patches. -COPY patch_catch_up_fix_v3.py /tmp/patch_catch_up_fix_v3.py -RUN python3 /tmp/patch_catch_up_fix_v3.py || true - -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 +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm install +COPY frontend/ ./ +RUN 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 - -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" -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 || echo "UV SYNC FAILED" +# Install uv directly from official image +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -COPY backend/session_logger.py /app/backend/session_logger.py -COPY backend/document_parser.py /app/backend/document_parser.py -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_xing_nemotron_vl.py /tmp/patch_xing_nemotron_vl.py -RUN python3 /tmp/patch_xing_nemotron_vl.py || true -COPY patch_openrouter_fix.py /tmp/patch_openrouter_fix.py -RUN python3 /tmp/patch_openrouter_fix.py || true -COPY patch_flint_free.py /tmp/patch_flint_free.py -RUN python3 /tmp/patch_flint_free.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 -# v2: comprehensive catch-up fix + shareable URLs (backend — runs after all other backend patches) -COPY patch_catch_up_fix_v2.py /tmp/patch_catch_up_fix_v2.py -RUN python3 /tmp/patch_catch_up_fix_v2.py || true -# v3 backend: fix the event-row filter in session_logger.py that v2 broke -# (wrong indent -> SyntaxError -> filter never applied -> catch-up errors). -COPY patch_catch_up_fix_v3.py /tmp/patch_catch_up_fix_v3.py -RUN python3 /tmp/patch_catch_up_fix_v3.py || true +# Create user with UID 1000 (required for HF Spaces) +RUN useradd -m -u 1000 user -# === 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)" +WORKDIR /app -# === 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" +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + bash \ + && rm -rf /var/lib/apt/lists/* -# === PATCH: restore full session context from HF dataset === -# Fixes: (1) "Catch me up" banner appears after every Space restart because -# NoopSessionStore never restores sessions; (2) context lost after catch-up -# because seed_from_summary only used a lossy summary. This patch makes the -# backend reload full conversations from the permanent sessions dataset. -COPY patch_catch_up_context_restore.py /tmp/patch_catch_up_context_restore.py -RUN python3 /tmp/patch_catch_up_context_restore.py || echo "CONTEXT RESTORE PATCH WARN (BUILD CONTINUES)" -RUN python3 -c "import ast; ast.parse(open('/app/backend/session_manager.py').read()); print('session_manager.py SYNTAX OK')" || echo "SESSION MANAGER SYNTAX CHECK WARN" +# Copy dependency files +COPY pyproject.toml uv.lock ./ -# 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" +# Install dependencies into /app/.venv +# Use --frozen to ensure exact versions from uv.lock +RUN uv sync --no-dev --frozen -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" +# Copy application code +COPY agent/ ./agent/ +COPY backend/ ./backend/ +COPY configs/ ./configs/ -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 built frontend +COPY --from=frontend-builder /app/frontend/dist ./static/ -RUN mkdir -p /app/session_logs && chown -R user:user /app +# Create directories and set ownership +RUN mkdir -p /app/session_logs && \ + chown -R user:user /app +# Switch to non-root user USER user -ENV HOME=/home/user PYTHONUNBUFFERED=1 PYTHONPATH=/app PATH="/app/.venv/bin:$PATH" +# Set environment +ENV HOME=/home/user \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PATH="/app/.venv/bin:$PATH" + +# Expose port EXPOSE 7860 + +# Run the application from backend directory WORKDIR /app/backend -CMD ["bash", "start.sh"] +CMD ["bash", "start.sh"] \ No newline at end of file diff --git a/README.md b/README.md index ff201f3f087047cda66c24d65a48aef9943a01ba..f5e703bd6ba563241225e260714248c69bfedf45 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,340 @@ --- -title: Space Multiverse +title: ML Intern emoji: 🤖 colorFrom: yellow colorTo: blue sdk: docker app_port: 7860 -short_description: ML Intern V2 (FREE & SAVE MODEL + PERMANENT SESSION LOGGING) hf_oauth: true hf_oauth_expiration_minutes: 43200 hf_oauth_scopes: -- read-repos -- write-repos -- contribute-repos -- manage-repos -- write-collections -- inference-api -- jobs -- write-discussions -tags: -- ml-intern -- permanent-session-logging -- persistent-context + - read-repos + - write-repos + - contribute-repos + - manage-repos + - write-collections + - inference-api + - jobs + - write-discussions --- -# Space Multiverse — ML Intern V2 +

+ smolagents logo +

-## 🔒 Permanent Session & User Context Logging +# ML Intern -**Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace.** +An ML intern that autonomously researches, writes, and ships good quality ML related code using the Hugging Face ecosystem — with deep access to docs, papers, datasets, and cloud compute. -- 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 +## Quick Start -### Cách hoạt động +### Installation -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 đủ +```bash +git clone git@github.com:huggingface/ml-intern.git +cd ml-intern +uv sync +uv tool install -e . +``` -### Truy cập lịch sử +#### That's it. Now `ml-intern` works from any directory: -- `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ữ +```bash +ml-intern +``` -Dataset: [bep40/ml-intern-sessions](https://huggingface.co/datasets/bep40/ml-intern-sessions) +Create a `.env` file in the project root (or export these in your shell): -## Models +```bash +ANTHROPIC_API_KEY= # if using anthropic models +OPENAI_API_KEY= # if using openai models +HF_TOKEN= +GITHUB_TOKEN= +``` +If no `HF_TOKEN` is set, the CLI will prompt you to paste one on first launch. To get a GITHUB_TOKEN follow the tutorial [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token). -- **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 +### Usage -## Quick Start +**Interactive mode** (start a chat session): + +```bash +ml-intern +``` + +**Headless mode** (single prompt, auto-approve): + +```bash +ml-intern "fine-tune llama on my dataset" +``` + +**Options:** + +```bash +ml-intern --model anthropic/claude-opus-4-6 "your prompt" +ml-intern --model openai/gpt-5.5 "your prompt" +ml-intern --max-iterations 100 "your prompt" +ml-intern --no-stream "your prompt" +``` + +## Sharing Traces + +Every session is auto-uploaded to your **own private Hugging Face dataset** +in [Claude Code JSONL format](https://huggingface.co/changelog/agent-trace-viewer), +which the HF Agent Trace Viewer auto-detects so you can browse turns, tool +calls, and model responses directly on the Hub. + +By default the dataset is named `{your-hf-username}/ml-intern-sessions` and is +**created private**. You can flip it to public from inside the CLI: + +```bash +/share-traces # show current visibility + dataset URL +/share-traces public # publish (anyone can view) +/share-traces private # lock it back down +``` + +You can also flip visibility from the dataset page on huggingface.co — the +agent honours whatever you set there for subsequent uploads. + +To opt out entirely, set in your CLI config (e.g. `configs/cli_agent_config.json` +or `~/.config/ml-intern/cli_agent_config.json`): + +```json +{ "share_traces": false } +``` + +To override the destination repo, set: + +```json +{ "personal_trace_repo_template": "{hf_user}/my-custom-traces" } +``` + +The shared `smolagents/ml-intern-sessions` dataset is unrelated and only +receives anonymized telemetry rows used by the backend KPI scheduler. + +## Supported Gateways + +ML Intern currently supports one-way notification gateways from CLI sessions. +These gateways send out-of-band status updates; they do not accept inbound chat +messages. + +### Slack + +Slack notifications use the Slack Web API to post messages when the agent needs +approval, hits an error, or completes a turn. Create a Slack app with a bot token +that has `chat:write`, invite the bot to the target channel, then set: + +```bash +SLACK_BOT_TOKEN=xoxb-... +SLACK_CHANNEL_ID=C... +``` + +The CLI automatically creates a `slack.default` destination when both variables +are present. Optional environment variables for the env-only default: + +```bash +ML_INTERN_SLACK_NOTIFICATIONS=false +ML_INTERN_SLACK_DESTINATION=slack.ops +ML_INTERN_SLACK_AUTO_EVENTS=approval_required,error,turn_complete +ML_INTERN_SLACK_ALLOW_AGENT_TOOL=true +ML_INTERN_SLACK_ALLOW_AUTO_EVENTS=true +``` + +For a persistent user-level config, put overrides in +`~/.config/ml-intern/cli_agent_config.json` or point `ML_INTERN_CLI_CONFIG` at a +JSON file: + +```json +{ + "messaging": { + "enabled": true, + "auto_event_types": ["approval_required", "error", "turn_complete"], + "destinations": { + "slack.ops": { + "provider": "slack", + "token": "${SLACK_BOT_TOKEN}", + "channel": "${SLACK_CHANNEL_ID}", + "allow_agent_tool": true, + "allow_auto_events": true + } + } + } +} +``` + +## Architecture + +### Component Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User/CLI │ +└────────────┬─────────────────────────────────────┬──────────┘ + │ Operations │ Events + ↓ (user_input, exec_approval, ↑ + submission_queue interrupt, compact, ...) event_queue + │ │ + ↓ │ +┌────────────────────────────────────────────────────┐ │ +│ submission_loop (agent_loop.py) │ │ +│ ┌──────────────────────────────────────────────┐ │ │ +│ │ 1. Receive Operation from queue │ │ │ +│ │ 2. Route to handler (run_agent/compact/...) │ │ │ +│ └──────────────────────────────────────────────┘ │ │ │ +│ ↓ │ │ +│ ┌──────────────────────────────────────────────┐ │ │ +│ │ Handlers.run_agent() │ ├──┤ +│ │ │ │ │ +│ │ ┌────────────────────────────────────────┐ │ │ │ +│ │ │ Agentic Loop (max 300 iterations) │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ┌──────────────────────────────────┐ │ │ │ │ +│ │ │ │ Session │ │ │ │ │ +│ │ │ │ ┌────────────────────────────┐ │ │ │ │ │ +│ │ │ │ │ ContextManager │ │ │ │ │ │ +│ │ │ │ │ • Message history │ │ │ │ │ │ +│ │ │ │ │ (litellm.Message[]) │ │ │ │ │ │ +│ │ │ │ │ • Auto-compaction (170k) │ │ │ │ │ │ +│ │ │ │ │ • Session upload to HF │ │ │ │ │ │ +│ │ │ │ └────────────────────────────┘ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ +│ │ │ │ ┌────────────────────────────┐ │ │ │ │ │ +│ │ │ │ │ ToolRouter │ │ │ │ │ │ +│ │ │ │ │ ├─ HF docs & research │ │ │ │ │ │ +│ │ │ │ │ ├─ HF repos, datasets, │ │ │ │ │ │ +│ │ │ │ │ │ jobs, papers │ │ │ │ │ │ +│ │ │ │ │ ├─ GitHub code search │ │ │ │ │ │ +│ │ │ │ │ ├─ Sandbox & local tools │ │ │ │ │ │ +│ │ │ │ │ ├─ Planning │ │ │ │ │ │ +│ │ │ │ │ └─ MCP server tools │ │ │ │ │ │ +│ │ │ │ └────────────────────────────┘ │ │ │ │ │ +│ │ │ └──────────────────────────────────┘ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ┌──────────────────────────────────┐ │ │ │ │ +│ │ │ │ Doom Loop Detector │ │ │ │ │ +│ │ │ │ • Detects repeated tool patterns │ │ │ │ │ +│ │ │ │ • Injects corrective prompts │ │ │ │ │ +│ │ │ └──────────────────────────────────┘ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ Loop: │ │ │ │ +│ │ │ 1. LLM call (litellm.acompletion) │ │ │ │ +│ │ │ ↓ │ │ │ │ +│ │ │ 2. Parse tool_calls[] │ │ │ │ +│ │ │ ↓ │ │ │ │ +│ │ │ 3. Approval check │ │ │ │ +│ │ │ (jobs, sandbox, destructive ops) │ │ │ │ +│ │ │ ↓ │ │ │ │ +│ │ │ 4. Execute via ToolRouter │ │ │ │ +│ │ │ ↓ │ │ │ │ +│ │ │ 5. Add results to ContextManager │ │ │ │ +│ │ │ ↓ │ │ │ │ +│ │ │ 6. Repeat if tool_calls exist │ │ │ │ +│ │ └────────────────────────────────────────┘ │ │ │ +│ └──────────────────────────────────────────────┘ │ │ +└────────────────────────────────────────────────────┴──┘ +``` + +### Agentic Loop Flow + +``` +User Message + ↓ +[Add to ContextManager] + ↓ + ╔═══════════════════════════════════════════╗ + ║ Iteration Loop (max 300) ║ + ║ ║ + ║ Get messages + tool specs ║ + ║ ↓ ║ + ║ litellm.acompletion() ║ + ║ ↓ ║ + ║ Has tool_calls? ──No──> Done ║ + ║ │ ║ + ║ Yes ║ + ║ ↓ ║ + ║ Add assistant msg (with tool_calls) ║ + ║ ↓ ║ + ║ Doom loop check ║ + ║ ↓ ║ + ║ For each tool_call: ║ + ║ • Needs approval? ──Yes──> Wait for ║ + ║ │ user confirm ║ + ║ No ║ + ║ ↓ ║ + ║ • ToolRouter.execute_tool() ║ + ║ • Add result to ContextManager ║ + ║ ↓ ║ + ║ Continue loop ─────────────────┐ ║ + ║ ↑ │ ║ + ║ └───────────────────────┘ ║ + ╚═══════════════════════════════════════════╝ +``` + +## Events + +The agent emits the following events via `event_queue`: + +- `processing` - Starting to process user input +- `ready` - Agent is ready for input +- `assistant_chunk` - Streaming token chunk +- `assistant_message` - Complete LLM response text +- `assistant_stream_end` - Token stream finished +- `tool_call` - Tool being called with arguments +- `tool_output` - Tool execution result +- `tool_log` - Informational tool log message +- `tool_state_change` - Tool execution state transition +- `approval_required` - Requesting user approval for sensitive operations +- `turn_complete` - Agent finished processing +- `error` - Error occurred during processing +- `interrupted` - Agent was interrupted +- `compacted` - Context was compacted +- `undo_complete` - Undo operation completed +- `shutdown` - Agent shutting down + +## Development + +### Adding Built-in Tools + +Edit `agent/core/tools.py`: + +```python +def create_builtin_tools() -> list[ToolSpec]: + return [ + ToolSpec( + name="your_tool", + description="What your tool does", + parameters={ + "type": "object", + "properties": { + "param": {"type": "string", "description": "Parameter description"} + }, + "required": ["param"] + }, + handler=your_async_handler + ), + # ... existing tools + ] +``` + +### Adding MCP Servers + +Edit `configs/cli_agent_config.json` for CLI defaults, or +`configs/frontend_agent_config.json` for web-session defaults: + +```json +{ + "model_name": "anthropic/claude-sonnet-4-5-20250929", + "mcpServers": { + "your-server-name": { + "transport": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer ${YOUR_TOKEN}" + } + } + } +} +``` + +Note: Environment variables like `${YOUR_TOKEN}` are auto-substituted from `.env`. -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 +``` \ 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/__init__.py b/configs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/configs/cli_agent_config.json b/configs/cli_agent_config.json new file mode 100644 index 0000000000000000000000000000000000000000..75f241d9dcdca5f709d66b8bb2d370b1a9269078 --- /dev/null +++ b/configs/cli_agent_config.json @@ -0,0 +1,22 @@ +{ + "model_name": "zai-org/GLM-5.2:novita", + "save_sessions": true, + "session_dataset_repo": "smolagents/ml-intern-sessions", + "share_traces": true, + "personal_trace_repo_template": "{hf_user}/ml-intern-sessions", + "yolo_mode": false, + "confirm_cpu_jobs": true, + "auto_file_upload": true, + "tool_runtime": "local", + "messaging": { + "enabled": false, + "auto_event_types": ["approval_required", "error", "turn_complete"], + "destinations": {} + }, + "mcpServers": { + "hf-mcp-server": { + "transport": "http", + "url": "https://huggingface.co/mcp?login" + } + } +} \ No newline at end of file diff --git a/configs/frontend_agent_config.json b/configs/frontend_agent_config.json index b198b6b6f290a3477deffffd88f3f184fbbafd79..dbad8f9c0091166658608f40cb7ec7fbe9bc9c7e 100644 --- a/configs/frontend_agent_config.json +++ b/configs/frontend_agent_config.json @@ -1,19 +1,16 @@ { - "model_name": "openai/tencent/hy3:free", - "api_base": "https://openrouter.ai/api/v1", + "model_name": "${ML_INTERN_DEFAULT_MODEL_ID:-zai-org/GLM-5.2:novita}", "save_sessions": true, - "personal_trace_repo_template": "{hf_user}/ml-intern-sessions", + "session_dataset_repo": "smolagents/ml-intern-sessions", "share_traces": true, + "personal_trace_repo_template": "{hf_user}/ml-intern-sessions", "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" + "url": "https://huggingface.co/mcp?login" } } -} +} \ No newline at end of file diff --git a/fix_space_error_report.json b/fix_space_error_report.json deleted file mode 100644 index a0f16c111ea55e69f6715097ed80ab7514ab4728..0000000000000000000000000000000000000000 --- a/fix_space_error_report.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "status": "rebuilding_with_deepseek_flash_fix", - "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." -} \ 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_agent_loop.py b/patch_agent_loop.py deleted file mode 100644 index 27baa8804213fe36d920df3712135dfac1474565..0000000000000000000000000000000000000000 --- a/patch_agent_loop.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Patch agent_loop.py - detect task incomplete and send event.""" - -AGENT_LOOP = "/app/agent/core/agent_loop.py" - -def patch(): - import os - if not os.path.exists(AGENT_LOOP): - print(f"SKIP: {AGENT_LOOP} not found") - return - - with open(AGENT_LOOP, "r", encoding="utf-8") as f: - content = f.read() - - # Add _check_task_incomplete function before class Handlers - check_func = ''' - -def _check_task_incomplete(session: Session, llm_result: LLMResult) -> bool: - """Check if model stopped streaming but task is incomplete.""" - if llm_result.tool_calls_acc: - return False # Has tool calls, task continues via tool execution - - plan = getattr(session, "current_plan", None) or [] - unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")] - - return len(unfinished) > 0 - - -def _unfinished_plan_items(session: Session) -> list[dict[str, str]]: - """Helper to get unfinished plan items.""" - plan = getattr(session, "current_plan", None) or [] - return [item for item in plan if item.get("status") in ("pending", "in_progress")] - -''' - - if "_check_task_incomplete" not in content: - content = content.replace( - "class Handlers:", - check_func + "\n\nclass Handlers:" - ) - - # Add the check after LLM response processing - check_call = ''' - # === Check for incomplete task after LLM response === - 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, - "message": "Model phản hồi đã dừng nhưng nhiệm vụ chưa hoàn thành." - } - ) - ) -''' - - if "task_incomplete" not in content: - # Find a good insertion point - after tool calls processing - insert_marker = " # -- End of turn --" - if insert_marker in content: - content = content.replace(insert_marker, check_call + "\n" + insert_marker) - else: - # Try another location - result_marker = "final_response = llm_result.content or None" - if result_marker in content: - content = content.replace(result_marker, result_marker + "\n" + check_call) - - with open(AGENT_LOOP, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: Patched {AGENT_LOOP}") - -if __name__ == "__main__": - patch() \ 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.py b/patch_auto_continue.py deleted file mode 100644 index cfd0fe536d0afba58069cebb891e3632ba547f64..0000000000000000000000000000000000000000 --- a/patch_auto_continue.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Patch script: Add auto-continue feature for free models in ml-intern. - -Apply this script during Docker build to enable: -1. Detection when streaming ends but task incomplete -2. "Tự động tiếp tục (10s)" button - auto continues after 10s -3. "Tạm dừng" button - lets user send different content - -Usage: Run this during Docker build after cloning source. -""" - -import os -import re - -# Paths - adjust if needed -USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts" -SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts" -AGENT_LOOP = "/app/agent/core/agent_loop.py" - -# ============================================================================= -# Patch 1: useAgentChat.ts -# ============================================================================= -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", encoding="utf-8") as f: - content = f.read() - - # Add useState to imports if missing - if "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" not in content: - content = content.replace( - "import { useCallback, useEffect, useMemo, useRef } from 'react';", - "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';" - ) - - # Add auto-continue state after callbacksRef - state_code = ''' - // 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 and "callbacksRef.current = { onReady, onError, onSessionDead }" in content: - content = content.replace( - "callbacksRef.current = { onReady, onError, onSessionDead };", - "callbacksRef.current = { onReady, onError, onSessionDead };" + state_code - ) - - # Add onTaskIncomplete to SideChannelCallbacks interface - if "onTaskIncomplete" not in content: - content = content.replace( - "onInterrupted: () => void;", - "onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;" - ) - - with open(USE_AGENT_CHAT, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: Patched {USE_AGENT_CHAT}") - -# ============================================================================= -# Patch 2: sse-chat-transport.ts -# ============================================================================= -def patch_sse_transport(): - if not os.path.exists(SSE_TRANSPORT): - print(f"SKIP: {SSE_TRANSPORT} not found") - return - - with open(SSE_TRANSPORT, "r", encoding="utf-8") as f: - content = f.read() - - # Add task_incomplete case - if "case 'task_incomplete'" not in content: - task_incomplete_case = ''' case 'task_incomplete': - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; -''' - # Insert before turn_complete case - content = content.replace( - "case 'turn_complete':", - task_incomplete_case + "\n case 'turn_complete':" - ) - - with open(SSE_TRANSPORT, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: Patched {SSE_TRANSPORT}") - -# ============================================================================= -# Patch 3: agent_loop.py -# ============================================================================= -def patch_agent_loop(): - if not os.path.exists(AGENT_LOOP): - print(f"SKIP: {AGENT_LOOP} not found") - return - - with open(AGENT_LOOP, "r", encoding="utf-8") as f: - content = f.read() - - # Add check function if not exists - check_func = ''' -def _check_task_incomplete(session: Session, llm_result: LLMResult) -> bool: - """Check if model stopped but task is incomplete (has unfinished plan items).""" - if llm_result.tool_calls_acc: - return False # Has tool calls, not incomplete - plan = getattr(session, "current_plan", None) or [] - unfinished = [item for item in plan if item.get("status") in ("pending", "in_progress")] - return len(unfinished) > 0 -''' - if "_check_task_incomplete" not in content: - # Insert after LLMResult dataclass - content = content.replace( - "class Handlers:", - check_func + "\n\nclass Handlers:" - ) - - with open(AGENT_LOOP, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: Patched {AGENT_LOOP}") - - -if __name__ == "__main__": - patch_use_agent_chat() - patch_sse_transport() - patch_agent_loop() - print("\nDONE: Auto-continue patches applied") \ No newline at end of file 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_context_restore.py b/patch_catch_up_context_restore.py deleted file mode 100644 index 79cec2a342e39dac8f5c45ee98e6855b0534f020..0000000000000000000000000000000000000000 --- a/patch_catch_up_context_restore.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch: Restore full session context from the permanent HF dataset. - -Fixes two related bugs in bep40/ml-intern (ML Intern V2 Space): - -1. "Catch me up" banner appears constantly after a Space restart. - The backend only loads sessions from get_session_store() (Mongo/Noop). - On HF Spaces there is no MONGODB_URI, so NoopSessionStore.load_session() - always returns None -> every previously-open session is treated as dead - -> the frontend shows "Where were we? / Catch me up" for ALL old - sessions, even though the full conversation is safely stored in the - bep40/ml-intern-sessions dataset. - -2. After clicking "Catch me up", the model forgets prior context. - seed_from_summary() creates a brand-new session and seeds it with only a - lossy LLM summary (or raw messages that then get pruned). The model - never sees the full prior conversation, so it genuinely does not - remember. - -Fix (both in backend/session_manager.py): -- ensure_session_loaded(): when the persistence store has no session, - fall back to the permanent HF dataset (backend.session_logger. - load_session_history) and re-materialize the session with the FULL - conversation, so old sessions come back alive with complete context. -- seed_from_summary(): if the dataset has the full history for the old - session, seed the new session with the full conversation instead of a - lossy summary (falling back to the summary path when unavailable). - -Runs during Docker build; never fails the build. -""" - -import ast -import os - -PATCHED_MARKER = "# === PATCH: dataset-context-restore ===" - - -def _read(path: str) -> str: - with open(path, encoding="utf-8") as f: - return f.read() - - -def _write(path: str, content: str) -> None: - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def _replace(content: str, old: str, new: str) -> tuple[str, bool]: - if old in content: - return content.replace(old, new, 1), True - return content, False - - -SESSION_MANAGER = "/app/backend/session_manager.py" - -if os.path.exists(SESSION_MANAGER): - content = _read(SESSION_MANAGER) - changed = False - - if PATCHED_MARKER in content: - print("SKIP: session_manager.py already patched") - else: - # ---- 1) Dataset fallback in ensure_session_loaded ----------------- - old_load = ( - " store = self._store()\n" - " loaded = await store.load_session(session_id)\n" - " if not loaded:\n" - " return None\n" - ) - new_load = ( - " store = self._store()\n" - " loaded = await store.load_session(session_id)\n" - " if not loaded:\n" - " # === PATCH: dataset-context-restore ===\n" - " # The persistence store has no record for this session\n" - " # (typical on HF Spaces: no MONGODB_URI -> NoopSessionStore).\n" - " # Before declaring the session dead, try the permanent HF\n" - " # dataset so old sessions survive a Space restart with\n" - " # their FULL context intact.\n" - " try:\n" - " from backend.session_logger import load_session_history\n" - " ds_rows = load_session_history(session_id)\n" - " ds_messages = [\n" - " r for r in ds_rows\n" - " if r.get(\"role\") in (\"user\", \"assistant\", \"tool\")\n" - " ]\n" - " if ds_messages:\n" - " loaded = {\n" - " \"metadata\": {\n" - " \"user_id\": user_id,\n" - " \"model\": next(\n" - " (r.get(\"model_name\") for r in ds_rows if r.get(\"model_name\")),\n" - " self.config.model_name,\n" - " ),\n" - " },\n" - " \"messages\": ds_messages,\n" - " }\n" - " logger.info(\n" - " \"Restored session %s from HF dataset (%d messages)\",\n" - " session_id, len(ds_messages),\n" - " )\n" - " except Exception as e:\n" - " logger.debug(\"Dataset restore fallback failed for %s: %s\", session_id, e)\n" - " if not loaded:\n" - " return None\n" - ) - content, ok = _replace(content, old_load, new_load) - if ok: - print("+ ensure_session_loaded falls back to HF dataset") - changed = True - else: - print("INFO: store.load fallback anchor not found") - - # ---- 2) seed_from_summary: prefer full dataset history ------------ - old_seed = ( - " seed = Message(\n" - " role=\"user\",\n" - " content=(\n" - " \"[SYSTEM: Your prior memory of this conversation \u2014 written \"\n" - " \"in your own voice right before restart. Continue from here.]\\n\\n\"\n" - " + (summary or \"(no summary returned)\")\n" - " ),\n" - " )\n" - ) - new_seed = ( - " # === PATCH: dataset-context-restore ===\n" - " # If the permanent dataset has the full history for this\n" - " # session, seed the new session with the ACTUAL conversation\n" - " # instead of a lossy summary so the model really remembers.\n" - " seeded_from_dataset = 0\n" - " try:\n" - " from backend.session_logger import load_session_history\n" - " ds_rows = load_session_history(session_id)\n" - " full_msgs = [\n" - " r for r in ds_rows\n" - " if r.get(\"role\") in (\"user\", \"assistant\", \"tool\")\n" - " ]\n" - " if full_msgs:\n" - " for raw in full_msgs:\n" - " try:\n" - " session.context_manager.items.append(Message.model_validate(raw))\n" - " seeded_from_dataset += 1\n" - " except Exception as e:\n" - " logger.warning(\"Dropping dataset msg during seed: %s\", e)\n" - " except Exception as e:\n" - " logger.debug(\"Dataset seed fallback failed: %s\", e)\n" - "\n" - " if seeded_from_dataset:\n" - " self._touch(agent_session)\n" - " await self.persist_session_snapshot(agent_session, runtime_state=\"idle\")\n" - " return seeded_from_dataset\n" - "\n" - " seed = Message(\n" - " role=\"user\",\n" - " content=(\n" - " \"[SYSTEM: Your prior memory of this conversation \u2014 written \"\n" - " \"in your own voice right before restart. Continue from here.]\\n\\n\"\n" - " + (summary or \"(no summary returned)\")\n" - " ),\n" - " )\n" - ) - content, ok = _replace(content, old_seed, new_seed) - if ok: - print("+ seed_from_summary restores full dataset history") - changed = True - else: - print("INFO: seed block anchor not found") - - if changed: - try: - ast.parse(content) - _write(SESSION_MANAGER, content) - print("OK: session_manager.py patched (dataset context restore)") - except SyntaxError as e: - print("FAIL: session_manager.py syntax error:", e) - else: - print("SKIP: no changes applied") - -# ---- Verify importability of patched modules ----------------------------- -try: - import ast as _ast - for f in ["/app/backend/session_manager.py", "/app/backend/session_logger.py", "/app/backend/routes/agent.py"]: - if os.path.exists(f): - _ast.parse(open(f, encoding="utf-8").read()) - print("SYNTAX OK:", f) -except Exception as e: - print("VERIFY ERR:", e) 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_catch_up_fix_v2.py b/patch_catch_up_fix_v2.py deleted file mode 100644 index e832dee59ec65df3fb9d1ebdc5f716f4978deb91..0000000000000000000000000000000000000000 --- a/patch_catch_up_fix_v2.py +++ /dev/null @@ -1,758 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch v2: Comprehensive catch-up + shareable URL fix for bep40/ml-intern. - -Addresses three problems: - 1. "Catch me up" throws errors frequently - 2. Context is lost after catch-up (context preserved in session but not restored) - 3. Per-session shareable URLs - -Runs during Docker build — never fails the build. -""" - -import ast -import os -import re -import sys - -# ─── Backend paths (exist in the Python/stage-2 build) ─────────────────────── - -BACKEND_SESSION_MANAGER = "/app/backend/session_manager.py" -BACKEND_SESSION_LOGGER = "/app/backend/session_logger.py" -BACKEND_ROUTES_AGENT = "/app/backend/routes/agent.py" - -MARKER = "# === PATCH: catch-up-v2 ===" - - -def _read(path): - with open(path, "r", encoding="utf-8") as f: - return f.read() - - -def _write(path, content): - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def _safe_replace(content, old, new): - if old in content: - return content.replace(old, new, 1), True - return content, False - - -# ─── Backend session_manager.py ───────────────────────────────────────────── - -print("=== Patching session_manager.py ===") - -if os.path.exists(BACKEND_SESSION_MANAGER): - content = _read(BACKEND_SESSION_MANAGER) - changed = False - - # 1. seed_from_summary: fall back to raw messages on summary failure - if 'logger.error("Summary call failed during seed: %s", e)' in content and "Fall back to raw messages" not in content: - old = ( - ' except Exception as e:\n' - ' logger.error("Summary call failed during seed: %s", e)\n' - ' raise\n' - ) - new = ( - ' except Exception as e:\n' - ' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\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' - ) - if old in content: - content, ok = _safe_replace(content, old, new) - if ok: - print(" + seed_from_summary falls back to raw messages") - changed = True - else: - # Try regex match for the pattern - pattern = re.compile( - r' logger\.error\("Summary call failed during seed: %s", e\)\n raise\n' - ) - match = pattern.search(content) - if match: - content = pattern.sub( - ' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\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', - content, count=1 - ) - print(" + seed_from_summary fallback (regex match)") - changed = True - - # 2. seed_from_summary: skip event rows - if "skip event rows" not in content.lower(): - old_parse = ( - ' 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' - ) - new_parse = ( - ' 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' - ) - if old_parse in content: - content, ok = _safe_replace(content, old_parse, new_parse) - if ok: - print(" + seed_from_summary skips event rows") - changed = True - - # 3. Add record_catchup_done method - if "def record_catchup_done" not in content: - insert_after = " return len(parsed)\n\n @staticmethod" - if insert_after in content: - new_method = ( - " return len(parsed)\n\n" - " async def record_catchup_done(self, session_id: str) -> None:\n" - " \"\"\"Record that a session was successfully catch-up restored.\"\"\"\n" - " store = self._store()\n" - " if not getattr(store, 'enabled', False):\n" - " return\n" - " try:\n" - " await store.update_session_fields(\n" - " session_id,\n" - " catchup_done=True,\n" - " )\n" - " except Exception as e:\n" - " logger.debug('record_catchup_done failed: %s', e)\n\n" - " @staticmethod" - ) - content, ok = _safe_replace(content, insert_after, new_method) - if ok: - print(" + Added record_catchup_done method") - changed = True - - # 4. Record catchup_done after seed - old_seed_done = ( - ' session.context_manager.items.append(seed)\n' - ' self._touch(agent_session)\n' - ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n' - ' return len(parsed)\n' - ) - new_seed_done = ( - ' session.context_manager.items.append(seed)\n' - ' self._touch(agent_session)\n' - ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n' - ' # === PATCH: catch-up-v2 ===\n' - ' try:\n' - ' await self.record_catchup_done(session_id)\n' - ' except Exception as e:\n' - ' logger.debug("record_catchup_done failed: %s", e)\n' - ' return len(parsed)\n' - ) - if old_seed_done in content: - content, ok = _safe_replace(content, old_seed_done, new_seed_done) - if ok: - print(" + seed_from_summary records catchup_done") - changed = True - - if changed: - if MARKER not in content: - content = content + "\n" + MARKER + "\n" - try: - ast.parse(content) - _write(BACKEND_SESSION_MANAGER, content) - print("OK: session_manager.py patched") - except SyntaxError as e: - print(f"FAIL: session_manager.py syntax: {e}") -else: - print("SKIP: session_manager.py not found") - - -# ─── Backend session_logger.py ─────────────────────────────────────────────── - -print("\n=== Patching session_logger.py ===") - -if os.path.exists(BACKEND_SESSION_LOGGER): - content = _read(BACKEND_SESSION_LOGGER) - changed = False - - # load_session_history: filter event rows at source - sort_line = ' rows.sort(key=lambda r: (r.get("seq", 0), r.get("timestamp", "")))' - if sort_line in content and 'valid_roles' not in content.split(sort_line, 1)[1][:300]: - idx = content.find(sort_line) - end_idx = content.find("\n", idx) + 1 - insert_code = ( - ' # === PATCH: catch-up-v2 ===\n' - ' # Filter out event/system rows so summarization never sees\n' - ' # internal tracing data that cant be parsed as Message objects.\n' - ' valid_roles = {"user", "assistant", "tool"}\n' - ' rows = [r for r in rows if r.get("role") in valid_roles]\n' - ) - content = content[:end_idx] + insert_code + content[end_idx:] - print(" + load_session_history filters event rows") - changed = True - - if changed: - try: - ast.parse(content) - _write(BACKEND_SESSION_LOGGER, content) - print("OK: session_logger.py patched") - except SyntaxError as e: - print(f"FAIL: session_logger.py syntax: {e}") -else: - print("SKIP: session_logger.py not found") - - -# ─── Backend routes/agent.py ───────────────────────────────────────────────── - -print("\n=== Patching routes/agent.py ===") - -if os.path.exists(BACKEND_ROUTES_AGENT): - content = _read(BACKEND_ROUTES_AGENT) - changed = False - - # Ensure session-history endpoint filters events - old_history = ( - ' from backend.session_logger import load_session_history\n' - ' return load_session_history(session_id)' - ) - new_history = ( - ' from backend.session_logger import load_session_history\n' - ' rows = load_session_history(session_id)\n' - ' valid_roles = {"user", "assistant", "tool"}\n' - ' return [r for r in rows if r.get("role") in valid_roles]' - ) - if old_history in content: - content, ok = _safe_replace(content, old_history, new_history) - if ok: - print(" + session-history endpoint filters event rows") - changed = True - - # Add catchup-done endpoint - if "/session/{session_id}/catchup-done" not in content: - catchup_endpoint = ''' - -@router.post("/session/{session_id}/catchup-done") -async def mark_catchup_done( - session_id: str, user: dict = Depends(get_current_user) -) -> dict: - """Mark that a session was successfully catch-up restored. - - The frontend calls this after a successful catch-up so the backend - can track which sessions were restored from summary vs. fresh. - """ - await _check_session_access(session_id, user, preload_sandbox=False) - try: - await session_manager.record_catchup_done(session_id) - except Exception as e: - logger.warning("record_catchup_done failed for %s: %s", session_id, e) - return {"status": "ok", "session_id": session_id} -''' - content = content.rstrip() + "\n" + catchup_endpoint - print(" + Added /session/{id}/catchup-done endpoint") - changed = True - - if changed: - try: - ast.parse(content) - _write(BACKEND_ROUTES_AGENT, content) - print("OK: routes/agent.py patched") - except SyntaxError as e: - print(f"FAIL: routes/agent.py syntax: {e}") -else: - print("SKIP: routes/agent.py not found") - - -# ─── Frontend patches ──────────────────────────────────────────────────────── - -# These files are only present in the frontend-builder stage. - -FRONTEND_DIR = "/source/frontend/src" -FE_MARKER = "// === PATCH: catch-up-v2 ===" -URL_MARKER = "// === PATCH: shareable-url-v2 ===" - - -def patch_frontend_file(relpath, patch_fn, label): - path = os.path.join(FRONTEND_DIR, relpath) - if not os.path.exists(path): - print(f"SKIP: {path} not found") - return - with open(path, "r", encoding="utf-8") as f: - content = f.read() - content, changed = patch_fn(content, path) - if not changed: - print(f"SKIP: {path} — no changes needed ({label})") - return - with open(path, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: {path} patched ({label})") - - -# ─── useAgentChat.ts ───────────────────────────────────────────────────────── - -print("\n=== Patching useAgentChat.ts ===") - - -def patch_useAgentChat(content, _path): - changed = False - - if FE_MARKER in content: - print(" SKIP: already patched") - return content, False - - # 1. Add catch-up guard + URL sync helpers after chatActionsRef - ref_def = " const chatActionsRef = useRef<" - if ref_def in content: - idx = content.find(ref_def) - end_idx = content.find("});", idx) - if end_idx > 0: - insert_after = content.find("\n", end_idx) + 1 - helper = ''' - // === PATCH: catch-up-v2 (frontend) === - // After catch-up (renameSession), the backend only has the summary message - // but the frontend has the full conversation in localStorage. These guards - // prevent the backend's sparse data from overwriting the frontend's rich - // conversation. The ml-intern-catchup-done flag is set after renameSession - // and cleared after the first message is sent. - const _catchupDone = (() => { - try { - return localStorage.getItem(`ml-intern-catchup-done:${sessionId}`) === 'true'; - } catch { - return false; - } - })(); - const _clearCatchupDone = useCallback(() => { - try { - localStorage.removeItem(`ml-intern-catchup-done:${sessionId}`); - } catch {} - }, [sessionId]); -''' - content = content[:insert_after] + helper + content[insert_after:] - print(" + Added catchup guard helper") - changed = True - - # 2. Guard onRecoverMessages - old_recover = ( - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && uiMsgs.length >= currentMessageCount) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - new_recover = ( - " const setMsgs = chatActionsRef.current.setMessages;\n" - " // === PATCH: catch-up-v2 (frontend) ===\n" - " const _feCount = chatActionsRef.current.messages.length;\n" - " if (setMsgs && (!_catchupDone || uiMsgs.length >= _feCount) && uiMsgs.length >= currentMessageCount) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - content, ok = _safe_replace(content, old_recover, new_recover) - if ok: - print(" + Guarded onRecoverMessages") - changed = True - - # 3. Guard refreshMessages - old_refresh = ( - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && uiMsgs.length > 0) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }\n" - " return true;" - ) - new_refresh = ( - " const setMsgs = chatActionsRef.current.setMessages;\n" - " // === PATCH: catch-up-v2 (frontend) ===\n" - " const _feCountR = chatActionsRef.current.messages.length;\n" - " if (setMsgs && (!_catchupDone || uiMsgs.length > _feCountR) && uiMsgs.length > 0) {\n" - " setMsgs(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }\n" - " return true;" - ) - content, ok = _safe_replace(content, old_refresh, new_refresh) - if ok: - print(" + Guarded refreshMessages") - changed = True - - # 4. Guard all chat.setMessages(uiMsgs) in hydration paths (10-space indent) - old_hydrate = ( - " if (uiMsgs.length > 0) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - new_hydrate = ( - " // === PATCH: catch-up-v2 (frontend) ===\n" - " const _feCountH = chatActionsRef.current.messages.length;\n" - " if (uiMsgs.length > 0 && (!_catchupDone || uiMsgs.length > _feCountH)) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - count = 0 - while old_hydrate in content: - content, ok = _safe_replace(content, old_hydrate, new_hydrate) - if ok: - count += 1 - changed = True - else: - break - if count > 0: - print(f" + Guarded {count} hydration setMessages paths") - - # 5. Wrap sendMessage to clear catchup flag + update URL - old_send = " sendMessage: chat.sendMessage,\n stop,\n status: chat.status,\n" - if old_send in content: - new_send = ( - " sendMessage,\n" - " stop,\n" - " status: chat.status,\n" - ) - # Actually, we need to insert the wrapper BEFORE the return and change sendMessage in it - # Find the return block - return_block = " return {\n messages: chat.messages,\n sendMessage: chat.sendMessage,\n stop,\n status: chat.status,\n undoLastTurn,\n" - if return_block in content: - wrapper_block = ( - " const _sendMessageWrapped = useCallback((msg) => {\n" - " // === PATCH: catch-up-v2 (frontend) ===\n" - " _clearCatchupDone();\n" - " // === PATCH: shareable-url-v2 ===\n" - " const params = new URLSearchParams(window.location.search);\n" - " if (params.get('chat') !== sessionId) {\n" - " params.set('chat', sessionId);\n" - " const qs = params.toString();\n" - " window.history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);\n" - " }\n" - " return chat.sendMessage(msg);\n" - " }, [chat, _clearCatchupDone, sessionId]);\n" - "\n" - " return {\n messages: chat.messages,\n sendMessage: _sendMessageWrapped,\n stop,\n status: chat.status,\n undoLastTurn,\n" - ) - content, ok = _safe_replace(content, return_block, wrapper_block) - if ok: - print(" + Wrapped sendMessage (clear flag + URL sync)") - changed = True - else: - # Try the simpler pattern - simple_return = " return {\n messages: chat.messages,\n sendMessage: chat.sendMessage,\n" - if simple_return in content: - wrapper_block = ( - " const _sendMessageWrapped = useCallback((msg) => {\n" - " // === PATCH: catch-up-v2 (frontend) ===\n" - " _clearCatchupDone();\n" - " // === PATCH: shareable-url-v2 ===\n" - " const params = new URLSearchParams(window.location.search);\n" - " if (params.get('chat') !== sessionId) {\n" - " params.set('chat', sessionId);\n" - " const qs = params.toString();\n" - " window.history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);\n" - " }\n" - " return chat.sendMessage(msg);\n" - " }, [chat, _clearCatchupDone, sessionId]);\n" - "\n" - " return {\n messages: chat.messages,\n sendMessage: _sendMessageWrapped,\n" - ) - content, ok = _safe_replace(content, simple_return, wrapper_block) - if ok: - print(" + Wrapped sendMessage (simple pattern)") - changed = True - - if changed and "import { logger } from '@/utils/logger';" in content: - content = content.replace( - "import { logger } from '@/utils/logger';", - "import { logger } from '@/utils/logger';\n" + FE_MARKER + " applied", - 1 - ) - - return content, changed - - -# ─── ExpiredBanner.tsx ────────────────────────────────────────────────────── - -print("\n=== Patching ExpiredBanner.tsx ===") - - -def patch_expired_banner(content, _path): - changed = False - - if FE_MARKER in content: - print(" SKIP: already patched") - return content, False - - old_success = ( - " useAgentStore.getState().clearSessionState(sessionId);\n" - " renameSession(sessionId, newId);\n" - " if (data.model) updateSessionModel(newId, data.model);" - ) - new_success = ( - " useAgentStore.getState().clearSessionState(sessionId);\n" - " renameSession(sessionId, newId);\n" - " if (data.model) updateSessionModel(newId, data.model);\n" - " // === PATCH: catch-up-v2 (frontend) ===\n" - " // Mark session as catch-up done so hydration paths don't\n" - " // overwrite the full localStorage conversation with the\n" - " // backend's sparse summary-only data.\n" - " try {\n" - " localStorage.setItem(`ml-intern-catchup-done:${newId}`, 'true');\n" - " } catch {}\n" - " // Also notify the backend\n" - " try { await apiFetch(`/api/session/${newId}/catchup-done`, { method: 'POST' }); } catch {}" - ) - content, ok = _safe_replace(content, old_success, new_success) - if ok: - print(" + Set catchup-done flag after successful catch-up") - changed = True - else: - print(" INFO: catch-up success pattern not found") - - if changed and "import { logger } from '@/utils/logger';" in content: - content = content.replace( - "import { logger } from '@/utils/logger';", - "import { logger } from '@/utils/logger';\n" + FE_MARKER + " applied", - 1 - ) - - return content, changed - - -# ─── AppLayout.tsx ─────────────────────────────────────────────────────────── - -print("\n=== Patching AppLayout.tsx ===") - - -def patch_app_layout(content, _path): - changed = False - - if URL_MARKER in content: - print(" SKIP: already patched") - return content, False - - # 1. Add ShareIcon import - if "import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';" in content: - content = content.replace( - "import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';", - "import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';\n" - "import ShareIcon from '@mui/icons-material/Share';\n" - "// === PATCH: shareable-url-v2 ===", - 1 - ) - print(" + Added ShareIcon import") - changed = True - - # 2. Add URL-based session selection on mount - old_health = " // -- LLM health check on mount -----------------------------------------" - new_code = """ // === PATCH: shareable-url-v2 === - // On mount, read ?chat={sessionId} from URL and auto-select that session - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const chatParam = params.get('chat'); - if (chatParam) { - const { sessions: currentSessions, switchSession } = useSessionStore.getState(); - if (currentSessions.find((s) => s.id === chatParam)) { - switchSession(chatParam); - } - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Update URL whenever the active session changes - useEffect(() => { - if (activeSessionId) { - const params = new URLSearchParams(window.location.search); - if (params.get('chat') !== activeSessionId) { - params.set('chat', activeSessionId); - const qs = params.toString(); - window.history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname); - } - } - }, [activeSessionId]); - - // -- LLM health check on mount -----------------------------------------""" - content, ok = _safe_replace(content, old_health, new_code) - if ok: - print(" + URL-based session selection + URL sync on active change") - changed = True - - # 3. Add Share button to the header - old_avatar = ( - " {user?.picture ? (\n" - " \n" - " ) : user?.username ? (" - ) - new_avatar = ( - " {activeSessionId && (\n" - " \n" - " {\n" - " const url = window.location.href;\n" - " if (navigator.clipboard?.writeText) {\n" - " await navigator.clipboard.writeText(url);\n" - " } else {\n" - " prompt('Copy this link:', url);\n" - " }\n" - " }}\n" - " sx={{ color: 'text.secondary', ml: 0.5 }}\n" - " >\n" - " \n" - " \n" - " \n" - " )}\n" - " {user?.picture ? (\n" - " \n" - " ) : user?.username ? (" - ) - content, ok = _safe_replace(content, old_avatar, new_avatar) - if ok: - print(" + Added Share button to header") - changed = True - - return content, changed - - -# ─── SessionSidebar.tsx ────────────────────────────────────────────────────── - -print("\n=== Patching SessionSidebar.tsx ===") - - -def patch_session_sidebar(content, _path): - changed = False - - if URL_MARKER in content: - print(" SKIP: already patched") - return content, False - - # 1. URL update after handleNewSession - old_create = ( - " createSession(data.session_id, data.model);\n" - " void useUsageStore.getState().fetchUsage(data.session_id);\n" - " setPlan([]);\n" - " clearPanel();\n" - " onClose?.();" - ) - new_create = ( - " createSession(data.session_id, data.model);\n" - " void useUsageStore.getState().fetchUsage(data.session_id);\n" - " setPlan([]);\n" - " clearPanel();\n" - " // === PATCH: shareable-url-v2 ===\n" - " const _params = new URLSearchParams(window.location.search);\n" - " _params.set('chat', data.session_id);\n" - " const _qs = _params.toString();\n" - " window.history.replaceState(null, '', _qs ? `${window.location.pathname}?${_qs}` : window.location.pathname);\n" - " onClose?.();" - ) - content, ok = _safe_replace(content, old_create, new_create) - if ok: - print(" + URL update on new session") - changed = True - - # 2. URL update on second new session (delete-last case) - old_create2 = ( - " createSession(data.session_id, data.model);\n" - " void useUsageStore.getState().fetchUsage(data.session_id);\n" - " setPlan([]);\n" - " clearPanel();" - ) - new_create2 = ( - " createSession(data.session_id, data.model);\n" - " void useUsageStore.getState().fetchUsage(data.session_id);\n" - " setPlan([]);\n" - " clearPanel();\n" - " // === PATCH: shareable-url-v2 ===\n" - " const _params2 = new URLSearchParams(window.location.search);\n" - " _params2.set('chat', data.session_id);\n" - " const _qs2 = _params2.toString();\n" - " window.history.replaceState(null, '', _qs2 ? `${window.location.pathname}?${_qs2}` : window.location.pathname);" - ) - content, ok = _safe_replace(content, old_create2, new_create2) - if ok: - print(" + URL update on new session (delete-last case)") - changed = True - - # 3. URL update on session select - old_select = ( - " const shouldActivateSession = sessionId !== activeSessionId;\n" - " switchSession(sessionId);" - ) - new_select = ( - " const shouldActivateSession = sessionId !== activeSessionId;\n" - " switchSession(sessionId);\n" - " // === PATCH: shareable-url-v2 ===\n" - " const _p = new URLSearchParams(window.location.search);\n" - " if (_p.get('chat') !== sessionId) {\n" - " _p.set('chat', sessionId);\n" - " const _q = _p.toString();\n" - " window.history.replaceState(null, '', _q ? `${window.location.pathname}?${_q}` : window.location.pathname);\n" - " }" - ) - content, ok = _safe_replace(content, old_select, new_select) - if ok: - print(" + URL update on session select") - changed = True - - return content, changed - - -# ─── WelcomeScreen.tsx ─────────────────────────────────────────────────────── - -print("\n=== Patching WelcomeScreen.tsx ===") - -WS_PATH = os.path.join(FRONTEND_DIR, "components/WelcomeScreen/WelcomeScreen.tsx") -if os.path.exists(WS_PATH): - with open(WS_PATH, "r", encoding="utf-8") as f: - ws_content = f.read() - if URL_MARKER not in ws_content: - old_ws = ( - " createSession(data.session_id, data.model);\n" - " void useUsageStore.getState().fetchUsage(data.session_id);\n" - " setPlan([]);\n" - " clearPanel();\n" - " } catch" - ) - new_ws = ( - " createSession(data.session_id, data.model);\n" - " void useUsageStore.getState().fetchUsage(data.session_id);\n" - " setPlan([]);\n" - " clearPanel();\n" - " // === PATCH: shareable-url-v2 ===\n" - " const _params = new URLSearchParams(window.location.search);\n" - " _params.set('chat', data.session_id);\n" - " const _qs = _params.toString();\n" - " window.history.replaceState(null, '', _qs ? `${window.location.pathname}?${_qs}` : window.location.pathname);\n" - " } catch" - ) - if old_ws in ws_content: - ws_content = ws_content.replace(old_ws, new_ws, 1) - with open(WS_PATH, "w", encoding="utf-8") as f: - f.write(ws_content) - print(" + URL update on welcome screen session creation") - else: - print(" INFO: welcome screen pattern not found") - else: - print(" SKIP: already patched") -else: - print("SKIP: WelcomeScreen.tsx not found") - - -# ─── Execute frontend patches ───────────────────────────────────────────────── - -patch_frontend_file("hooks/useAgentChat.ts", patch_useAgentChat, "catch-up guard + URL sync") -patch_frontend_file("components/Chat/ExpiredBanner.tsx", patch_expired_banner, "catchup-done flag") -patch_frontend_file("components/Layout/AppLayout.tsx", patch_app_layout, "URL session selection + share button") -patch_frontend_file("components/SessionSidebar/SessionSidebar.tsx", patch_session_sidebar, "URL update on session actions") - -print("\n=== All v2 patches applied ===") diff --git a/patch_catch_up_fix_v3.py b/patch_catch_up_fix_v3.py deleted file mode 100644 index 72be99a03ae5e01c060600a79d5f7244cd3c29c9..0000000000000000000000000000000000000000 --- a/patch_catch_up_fix_v3.py +++ /dev/null @@ -1,675 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch v3: Fix the frontend build + complete catch-up & shareable-URL fixes. - -Build failure root cause (v2): - v2's patch_catch_up_fix_v2.py introduced THREE TypeScript build errors - because its inner patterns were already modified by earlier patches - (patch_catch_up_fix_frontend.py ran first and changed the same code): - - src/components/Layout/AppLayout.tsx(20,1): error TS6133: - 'ShareIcon' is declared but its value is never read. <- share button patch FAILED to match - src/hooks/useAgentChat.ts(572,9): error TS6133: - '_catchupDone' is declared but its value is never read. <- guard patches FAILED to match - src/hooks/useAgentChat.ts(1174,44): error TS7006: - Parameter 'msg' implicitly has an 'any' type. <- wrapper typed poorly - - Because npm run build fails, /app/static ends up EMPTY and the Space - serves NO frontend. All user-visible symptoms (catch-up errors, context - loss, no share URL) trace back to this. - -v3 fixes (runs AFTER all other patches, idempotent): - 1. useAgentChat.ts - a. Wire _catchupDone into the existing v1 guards so the flag is used - (kills TS6133) AND the "backend sparse summary overwrites full - local conversation" race is actually prevented. - b. Type _sendMessageWrapped's msg parameter using the same type as - chat.sendMessage (kills TS7006). - 2. AppLayout.tsx - a. Render the missing Share button in the header (kills TS6133 for - ShareIcon). The button copies a PUBLIC share URL (?share=). - b. If the URL has ?share=, render a read-only ShareView - instead of the chat (auth-free, works for anyone with the link). - 3. ShareView.tsx (new file): read-only transcript viewer. - 4. Backend routes/agent.py: add public GET /api/public/session/{id} - (no auth) that serves sanitized conversation rows from the permanent - public sessions dataset. - 5. Backend session_logger.py: fix v2's indent bug (insert filter - correctly so no SyntaxError) — event rows filtered at source. - -Never fails the build (all writes guarded by markers / ast.parse, and the -Dockerfile already appends `|| true`). -""" - -import ast -import os - - -def _read(path): - with open(path, "r", encoding="utf-8") as f: - return f.read() - - -def _write(path, content): - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def _replace(content, old, new): - if old in content: - return content.replace(old, new, 1), True - return content, False - - -MARKER3 = "// === PATCH: catch-up-v3 ===" -MARKER3_PY = "# === PATCH: catch-up-v3 ===" - - -def patch_frontend_file(relpath, patch_fn, label): - path = os.path.join(FRONTEND_DIR, relpath) - if not os.path.exists(path): - print(f"SKIP: {path} not found ({label})") - return - content = _read(path) - content, changed = patch_fn(content, path) - if not changed: - print(f"SKIP: {path} — no changes needed ({label})") - return - _write(path, content) - print(f"OK: {path} patched ({label})") - - -# ─── Frontend files ───────────────────────────────────────────────────────── - -FRONTEND_DIR = "/source/frontend/src" - - -def patch_use_agent_chat(content, _path): - """Wire _catchupDone into guards + type the sendMessage wrapper.""" - changed = False - if MARKER3 in content: - print(" SKIP: already patched") - return content, False - - # (1a) onRecoverMessages guard (v1 already present): - old_recover = ( - " const _shouldOverwrite = uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0;\n" - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && _shouldOverwrite) {" - ) - new_recover = ( - " // === PATCH: catch-up-v3 ===\n" - " // After catch-up the backend only has the summary message;\n" - " // never let it clobber the full local conversation.\n" - " const _shouldOverwrite = _catchupDone\n" - " ? false\n" - " : (uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0);\n" - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && _shouldOverwrite) {" - ) - content, ok = _replace(content, old_recover, new_recover) - if ok: - print(" + onRecoverMessages guard honors _catchupDone") - changed = True - else: - print(" INFO: onRecoverMessages guard pattern not found (may already be v3)") - - # refreshMessages guard (v1 already present): - old_refresh = ( - " const _shouldOverwrite2 = uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0;\n" - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && _shouldOverwrite2) {" - ) - new_refresh = ( - " // === PATCH: catch-up-v3 ===\n" - " const _shouldOverwrite2 = _catchupDone\n" - " ? false\n" - " : (uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0);\n" - " const setMsgs = chatActionsRef.current.setMessages;\n" - " if (setMsgs && _shouldOverwrite2) {" - ) - content, ok = _replace(content, old_refresh, new_refresh) - if ok: - print(" + refreshMessages guard honors _catchupDone") - changed = True - else: - print(" INFO: refreshMessages guard pattern not found (may already be v3)") - - # (1b) Type the sendMessage wrapper param using chat.sendMessage's type. - old_wrapper = " const _sendMessageWrapped = useCallback((msg) => {" - new_wrapper = ( - " // === PATCH: catch-up-v3 ===\n" - " type _ChatSendArgs = Parameters[0];\n" - " const _sendMessageWrapped = useCallback((msg: _ChatSendArgs) => {" - ) - content, ok = _replace(content, old_wrapper, new_wrapper) - if ok: - print(" + Typed _sendMessageWrapped msg parameter") - changed = True - else: - print(" INFO: wrapper pattern not found (may already be v3)") - - # (1c) Strengthen the remaining v1 count-based hydration guards - # (_shouldSetB 10-space, _shouldSetC 14-space x2, _shouldSet 6-space). - guard_rules = [ - ( - " const _shouldSetB = uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0;\n" - " if (_shouldSetB) {", - " // === PATCH: catch-up-v3 ===\n" - " const _shouldSetB = _catchupDone\n" - " ? false\n" - " : (uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0);\n" - " if (_shouldSetB) {", - ), - ( - " const _shouldSetC = uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0;\n" - " if (_shouldSetC) {", - " // === PATCH: catch-up-v3 ===\n" - " const _shouldSetC = _catchupDone\n" - " ? false\n" - " : (uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0);\n" - " if (_shouldSetC) {", - ), - ( - " const _shouldSet = uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0;\n" - " if (_shouldSet) {", - " // === PATCH: catch-up-v3 ===\n" - " const _shouldSet = _catchupDone\n" - " ? false\n" - " : (uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0);\n" - " if (_shouldSet) {", - ), - ] - for old_g, new_g in guard_rules: - n = content.count(old_g) - if n: - content = content.replace(old_g, new_g) - print(f" + _catchupDone wired into hydration guard ({n} site(s))") - changed = True - - # (1d) Extra safety: guard any remaining bare setMessages path in the - # event-stream consume / visibility handlers that v1 did not touch. - old_consume = ( - " if (uiMsgs.length > 0) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - new_consume = ( - " // === PATCH: catch-up-v3 ===\n" - " const _c3a = (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" - " if (uiMsgs.length > 0 && (!_catchupDone || uiMsgs.length > _c3a.length)) {\n" - " chat.setMessages(uiMsgs);\n" - " saveMessages(sessionId, uiMsgs);\n" - " }" - ) - count = 0 - while old_consume in content: - content, ok = _replace(content, old_consume, new_consume) - if ok: - count += 1 - changed = True - else: - break - if count: - print(f" + Guarded {count} event-stream/visibility setMessages paths") - - return content, changed - - -def patch_app_layout(content, _path): - """Share button (public ?share= URL) + read-only ShareView mode.""" - changed = False - if MARKER3 in content: - print(" SKIP: already patched") - return content, False - - # (2a) Import ShareView (new component in the same folder). - if "import ShareView" not in content: - anchor = "import ShareIcon from '@mui/icons-material/Share';" - if anchor in content: - content = content.replace( - anchor, - anchor + "\nimport ShareView from '@/components/ShareView';\n" + MARKER3, - 1, - ) - changed = True - print(" + Imported ShareView") - else: - print(" INFO: ShareIcon import not found for ShareView import") - - # (2b) Share button before theme toggle. Builds a PUBLIC share URL - # (?share=) instead of the owner-only ?chat= URL. - old_theme_btn = ( - " \n" - " {themeMode === 'dark' ? : }\n" - " " - ) - new_theme_btn = ( - " \n" - " {themeMode === 'dark' ? : }\n" - " \n" - "\n" - " {activeSessionId && (\n" - " \n" - " {\n" - " e.stopPropagation();\n" - " const _p = new URLSearchParams(window.location.search);\n" - " const _sid = _p.get('chat') || activeSessionId || '';\n" - " const _sp = new URLSearchParams();\n" - " if (_sid) _sp.set('share', _sid);\n" - " const _qs = _sp.toString();\n" - " const url = `${window.location.origin}${window.location.pathname}${_qs ? `?${_qs}` : ''}`;\n" - " if (navigator.clipboard?.writeText) {\n" - " navigator.clipboard.writeText(url).catch(() => {\n" - " prompt('Copy this link:', url);\n" - " });\n" - " } else {\n" - " prompt('Copy this link:', url);\n" - " }\n" - " }}\n" - " sx={{\n" - " color: 'text.secondary',\n" - " '&:hover': { color: 'primary.main' },\n" - " }}\n" - " >\n" - " \n" - " \n" - " \n" - " )}" - ) - content, ok = _replace(content, old_theme_btn, new_theme_btn) - if ok: - changed = True - print(" + Added Share button before theme toggle (public ?share= URL)") - else: - print(" INFO: theme toggle pattern not found (may already be v3)") - - # Ensure Tooltip is imported (used by the Share button). - if "Tooltip," not in content and "import Tooltip" not in content: - if "import {\n Avatar," in content: - content = content.replace( - "import {\n Avatar,", - "import {\n Tooltip,\n Avatar,", - 1, - ) - changed = True - print(" + Added Tooltip import") - elif "Avatar," in content: - content = content.replace(" Avatar,", " Tooltip,\n Avatar,", 1) - changed = True - print(" + Added Tooltip import (fallback)") - else: - print(" INFO: could not add Tooltip import (Avatar import not found)") - - # (2c) Read-only share mode: if ?share= is present, render ShareView. - # Must come AFTER all hooks (it is — right before the WelcomeScreen return). - old_welcome = " // -- Welcome screen: no sessions at all ---------------------------------\n" - new_welcome = ( - " // === PATCH: catch-up-v3 ===\n" - " // Public read-only share mode (?share=). Works without\n" - " // login for anyone who has the link; data comes from the public\n" - " // sessions dataset via GET /api/public/session/{id}.\n" - " const _shareParam = new URLSearchParams(window.location.search).get('share');\n" - " if (_shareParam) {\n" - " return ;\n" - " }\n" - "\n" - " // -- Welcome screen: no sessions at all ---------------------------------\n" - ) - content, ok = _replace(content, old_welcome, new_welcome) - if ok: - changed = True - print(" + ?share= renders read-only ShareView") - else: - print(" INFO: welcome-screen anchor not found for share mode") - - return content, changed - - -def patch_share_view(path): - """Create the read-only ShareView component (new file).""" - if os.path.exists(path): - print(" SKIP: ShareView.tsx already exists") - return - content = ( - "/**\n" - " * Read-only public session viewer (no auth required).\n" - " *\n" - " * Rendered by AppLayout when the URL contains ?share=.\n" - " * Fetches sanitized conversation rows from the public endpoint\n" - " * GET /api/public/session/{id}, which reads the permanent public\n" - " * sessions dataset. Anyone with the link can view the transcript.\n" - " */\n" - "// === PATCH: catch-up-v3 ===\n" - "import { useEffect, useState } from 'react';\n" - "import {\n" - " Alert,\n" - " Box,\n" - " Button,\n" - " CircularProgress,\n" - " Paper,\n" - " Typography,\n" - "} from '@mui/material';\n" - "\n" - "interface ShareRow {\n" - " role: string;\n" - " content: string;\n" - " timestamp?: string;\n" - "}\n" - "\n" - "interface ShareViewProps {\n" - " sessionId: string;\n" - "}\n" - "\n" - "export default function ShareView({ sessionId }: ShareViewProps) {\n" - " const [rows, setRows] = useState([]);\n" - " const [loading, setLoading] = useState(true);\n" - " const [error, setError] = useState(null);\n" - "\n" - " useEffect(() => {\n" - " let cancelled = false;\n" - " (async () => {\n" - " try {\n" - " const res = await fetch(\n" - " `/api/public/session/${encodeURIComponent(sessionId)}`,\n" - " { credentials: 'include' },\n" - " );\n" - " if (res.status === 404) {\n" - " if (!cancelled) setError('Session not found.');\n" - " } else if (!res.ok) {\n" - " if (!cancelled) setError('Failed to load this session.');\n" - " } else {\n" - " const data = await res.json();\n" - " if (!cancelled && Array.isArray(data)) setRows(data);\n" - " }\n" - " } catch {\n" - " if (!cancelled) setError('Network error while loading this session.');\n" - " } finally {\n" - " if (!cancelled) setLoading(false);\n" - " }\n" - " })();\n" - " return () => {\n" - " cancelled = true;\n" - " };\n" - " }, [sessionId]);\n" - "\n" - " return (\n" - " \n" - " \n" - " \n" - " \n" - " Shared session\n" - " \n" - " \n" - " \n" - "\n" - " {loading && }\n" - " {error && (\n" - " \n" - " {error}\n" - " \n" - " )}\n" - " {!loading && !error && rows.length === 0 && (\n" - " \n" - " No messages in this session.\n" - " \n" - " )}\n" - " {rows.map((row, i) => (\n" - " \n" - " \n" - " {row.role}\n" - " \n" - " \n" - " {row.content}\n" - " \n" - " \n" - " ))}\n" - " \n" - " \n" - " );\n" - "}\n" - ) - _write(path, content) - print(" + Created ShareView.tsx") - - -def patch_session_sidebar(content, _path): - changed = False - if MARKER3 in content: - return content, False - return content, changed - - -def patch_expired_banner(content, _path): - changed = False - if MARKER3 in content: - return content, False - return content, changed - - -def patch_welcome_screen(content, _path): - changed = False - if MARKER3 in content: - return content, False - return content, changed - - -# ─── Backend routes/agent.py: public share endpoint ─────────────────────── - -BACKEND_ROUTES_AGENT = "/app/backend/routes/agent.py" -BACKEND_SESSION_LOGGER = "/app/backend/session_logger.py" - - -def patch_routes_agent(content): - """Add public GET /api/public/session/{id} (no auth).""" - changed = False - if "public/session/" in content: - print(" SKIP: public endpoint already present") - return content, False - - endpoint = ''' - -@router.get("/public/session/{session_id}") -async def public_session_transcript(session_id: str) -> list[dict]: - """Public read-only transcript for a session (no auth required). - - Reads sanitized conversation rows from the permanent public sessions - dataset (bep40/ml-intern-sessions). Returns only user/assistant/tool - messages — never internal event/system rows. Useful for sharing a - session transcript with anyone via a link. - """ - from backend.session_logger import load_session_history - - rows = load_session_history(session_id) - valid_roles = {"user", "assistant", "tool"} - messages = [r for r in rows if r.get("role") in valid_roles] - # Cap payload size: last 300 messages, each content truncated to 20k chars. - truncated = [] - for r in messages[-300:]: - content = r.get("content") or "" - if isinstance(content, str) and len(content) > 20000: - content = content[:20000] + "\\n...[truncated]" - truncated.append( - { - "role": r.get("role", "assistant"), - "content": content, - "timestamp": r.get("timestamp"), - } - ) - return truncated -''' - content = content.rstrip() + "\n" + endpoint - print(" + Added public /api/public/session/{id} endpoint") - changed = True - return content, changed - - -def patch_session_logger(content): - """Fix v2's broken insertion that caused a SyntaxError. - - v2 inserted the filter between `rows.sort(...)` and `return rows` at the - WRONG indentation (4 spaces instead of 8), producing: - expected 'except' or 'finally' block - The whole patch was skipped, so event rows were never filtered. - - v3 inserts the filter at the correct 8-space indent inside the try block. - """ - changed = False - if MARKER3_PY in content: - print(" SKIP: already patched") - return content, False - - # Remove the broken v2 insertion if it ever made it in. - broken = ( - " # === PATCH: catch-up-v2 ===\n" - " # Filter out event/system rows so summarization never sees\n" - " # internal tracing data that cant be parsed as Message objects.\n" - " valid_roles = {\"user\", \"assistant\", \"tool\"}\n" - " rows = [r for r in rows if r.get(\"role\") in valid_roles]\n" - ) - if broken in content: - content = content.replace(broken, "", 1) - print(" + Removed broken v2 insertion") - changed = True - - if "def _filter_valid_roles" not in content: - old_sort = ( - " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n" - " return rows\n" - ) - new_sort = ( - " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n" - " # === PATCH: catch-up-v3 ===\n" - " # Summarization and Message.model_validate() cannot parse\n" - " # internal tracing rows (role='event'); filter them here so\n" - " # every consumer gets clean conversation data.\n" - " valid_roles = {\"user\", \"assistant\", \"tool\"}\n" - " return [r for r in rows if r.get(\"role\") in valid_roles]\n" - ) - content, ok = _replace(content, old_sort, new_sort) - if ok: - print(" + Filter event rows at source (load_session_history)") - changed = True - else: - # Try the already-v2-patched variant (sort only, return separate) - old_sort2 = ( - " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n" - ) - new_sort2 = ( - " rows.sort(key=lambda r: (r.get(\"seq\", 0), r.get(\"timestamp\", \"\")))\n" - " # === PATCH: catch-up-v3 ===\n" - " valid_roles = {\"user\", \"assistant\", \"tool\"}\n" - " rows = [r for r in rows if r.get(\"role\") in valid_roles]\n" - ) - content, ok = _replace(content, old_sort2, new_sort2) - if ok: - print(" + Filter event rows at source (sort-then-filter variant)") - changed = True - - return content, changed - - -# ─── Execute ──────────────────────────────────────────────────────────────── - -print("=== v3: Frontend ===") -patch_frontend_file( - "hooks/useAgentChat.ts", - patch_use_agent_chat, - "wire _catchupDone + type wrapper", -) -patch_frontend_file( - "components/Layout/AppLayout.tsx", - patch_app_layout, - "share button + share mode", -) -patch_share_view(os.path.join(FRONTEND_DIR, "components/ShareView.tsx")) -patch_frontend_file( - "components/SessionSidebar/SessionSidebar.tsx", - patch_session_sidebar, - "idempotency marker", -) -patch_frontend_file( - "components/Chat/ExpiredBanner.tsx", - patch_expired_banner, - "idempotency marker", -) -patch_frontend_file( - "components/WelcomeScreen/WelcomeScreen.tsx", - patch_welcome_screen, - "idempotency marker", -) - -print("\n=== v3: Backend ===") -if os.path.exists(BACKEND_ROUTES_AGENT): - content = _read(BACKEND_ROUTES_AGENT) - content, changed = patch_routes_agent(content) - if changed: - try: - ast.parse(content) - _write(BACKEND_ROUTES_AGENT, content) - print("OK: routes/agent.py patched") - except SyntaxError as e: - print(f"FAIL: routes/agent.py syntax: {e}") - else: - print("SKIP: routes/agent.py — no changes needed") -else: - print("SKIP: routes/agent.py not found") - -if os.path.exists(BACKEND_SESSION_LOGGER): - content = _read(BACKEND_SESSION_LOGGER) - content, changed = patch_session_logger(content) - if changed: - try: - ast.parse(content) - _write(BACKEND_SESSION_LOGGER, content) - print("OK: session_logger.py patched") - except SyntaxError as e: - print(f"FAIL: session_logger.py syntax: {e}") - else: - print("SKIP: session_logger.py — no changes needed") -else: - print("SKIP: session_logger.py not found") - -print("\n=== v3 done ===") diff --git a/patch_chat_input.py b/patch_chat_input.py deleted file mode 100644 index bd3be5cbb9a9e5b4f535007b83d451d7b1f1b286..0000000000000000000000000000000000000000 --- a/patch_chat_input.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Patch ChatInput.tsx - Fixed import for DatasetUploadResponse.""" - -CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx" - -def patch(): - import os, re - if not os.path.exists(CHAT_INPUT): - print(f"SKIP: {CHAT_INPUT} not found") - return - - with open(CHAT_INPUT, "r", encoding="utf-8") as f: - content = f.read() - - # 1. Add Button to MUI imports - if "Button" not in content and "@mui/material" in content: - content = content.replace( - "import { Box, IconButton, Stack, Tooltip } from '@mui/material';", - "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';" - ) - - # 2. Fix DatasetUploadResponse import - check existing imports - 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';" - ) - - # 3. Add props to interface - if "showAutoContinue" not in content: - 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; -} -''' - match = re.search(r'interface ChatInputProps[^}]*[}][^}]*[}]', content, re.DOTALL) - if match: - content = content.replace(match.group(0), props_code) - else: - content = content.replace( - "export default function ChatInput", - props_code + "\nexport default function ChatInput" - ) - - # 4. Add auto-continue buttons before the flex box - buttons_code = ''' - {/* Auto-continue controls for free models */} - {showAutoContinue && taskIncompleteInfo && ( - - - - - )} -''' - - if "Tự động tiếp tục" not in content: - content = content.replace( - " 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_flint_free.py b/patch_flint_free.py deleted file mode 100644 index b237c5ce93c490a5739a79c50bd32fa5bc5ab148..0000000000000000000000000000000000000000 --- a/patch_flint_free.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Patch backend + frontend: add recursive/flint-v1.0:free (OpenRouter). - -Uses the same `openai/` path convention as Laguna S 2.1:free: - - backend constant: RECURSIVE_FLINT_V1_FREE_MODEL_ID = "openai/recursive/flint-v1.0:free" - - registered in _available_models() backend list - - frontend: new ModelOption in ChatInput.tsx DEFAULT_MODEL_OPTIONS - -IMPORTANT: recursive/flint-v1.0:free has `reasoning: { mandatory: true }` on -OpenRouter. Without sending reasoning, the Recursive provider returns -400 "Inference request failed". We patch the `openai/` routing branch in -llm_params.py to inject `reasoning: {"effort": "low"}` into the params for -flint models so the provider accepts the request. - -Runs AFTER patch_models.py / patch_new_models.py / patch_xing_nemotron_vl.py -and patch_openrouter_fix.py (which adds the openai/ routing block). -Idempotent. -""" - -import ast -import os - -IDS_FILE = "/app/agent/core/model_ids.py" -AGENT_FILE = "/app/backend/routes/agent.py" -LLM_PARAMS_FILE = "/app/agent/core/llm_params.py" -FRONTEND_FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" - -# ---------- 1. model_ids.py ---------- -with open(IDS_FILE) as f: - c = f.read() - -new_constant = 'RECURSIVE_FLINT_V1_FREE_MODEL_ID = "openai/recursive/flint-v1.0:free"\n' - -if "RECURSIVE_FLINT_V1_FREE_MODEL_ID" not in c: - # Anchor: the NEMOTRON_3_5_SUPER_VL line injected by patch_xing_nemotron_vl.py. - # Fall back to LAGUNA_S21 line from patch_models.py. - anchors = [ - 'NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-super-vl:free"', - 'LAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"', - ] - inserted = False - for anchor in anchors: - if anchor in c: - c = c.replace(anchor, anchor + "\n" + new_constant, 1) - print(f"OK: added constant to model_ids.py after {anchor.split(' =')[0]}") - inserted = True - break - if not inserted: - print("FAIL: no anchor found in model_ids.py for constant insertion") - raise SystemExit(1) - - with open(IDS_FILE, "w") as f: - f.write(c) - ast.parse(c) - print("OK: model_ids.py written & syntax OK") -else: - print("OK: model_ids.py already has RECURSIVE_FLINT_V1_FREE_MODEL_ID (idempotent)") - -# ---------- 2. agent.py : imports ---------- -with open(AGENT_FILE) as f: - ac = f.read() - -if "RECURSIVE_FLINT_V1_FREE_MODEL_ID" not in ac: - # patch_xing_nemotron_vl.py injected NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID into - # the import block after LING_3_0_FLASH_FREE_MODEL_ID. - imp_anchor = " LING_3_0_FLASH_FREE_MODEL_ID,\n" - if imp_anchor in ac: - ac = ac.replace( - imp_anchor, - imp_anchor - + " NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID,\n" - + " RECURSIVE_FLINT_V1_FREE_MODEL_ID,\n", - 1, - ) - print("OK: added RECURSIVE_FLINT_V1 import to agent.py") - else: - print("FAIL: import anchor not found in agent.py") - raise SystemExit(1) - - # ---------- 3. agent.py : _available_models ---------- - # Insert after the NEMOTRON_3_5_SUPER_VL entry in _available_models. - line_anchor = ( - ' {"id": NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID, ' - '"label": "nvidia/nemotron-3.5-super-vl:free", "recommended": True},\n' - ) - new_line = line_anchor + ( - ' {"id": RECURSIVE_FLINT_V1_FREE_MODEL_ID, ' - '"label": "recursive/flint-v1.0:free", "recommended": True},\n' - ) - - if line_anchor in ac: - ac = ac.replace(line_anchor, new_line, 1) - print("OK: added recursive/flint-v1.0:free to _available_models()") - else: - print("WARN: NEMOTRON VL line anchor not matched, trying fallback...") - alt_anchor = ( - ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, ' - '"label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n' - ) - if alt_anchor in ac: - ac = ac.replace( - alt_anchor, - alt_anchor + new_line, - 1, - ) - print("OK: added recursive/flint-v1.0:free to _available_models() (fallback)") - else: - print("WARN: fallback anchor also not matched - model may already be present or patches changed") - - 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 has RECURSIVE_FLINT_V1_FREE_MODEL_ID (idempotent)") - -# ---------- 4. llm_params.py : reasoning support for recursive/flint-v1.0 ---------- -# Flint has reasoning: { mandatory: true } on OpenRouter. Without sending reasoning, -# the Recursive provider returns 400 "Inference request failed". -# We modify the openai/ routing block to include reasoning for flint models. -# The reasoning parameter is passed as a top-level LiteLLM kwarg so that when -# using custom_llm_provider: "openrouter", LiteLLM includes it in the request -# body sent to https://openrouter.ai/api/v1/chat/completions. -with open(LLM_PARAMS_FILE) as f: - lp = f.read() - -if "flint" not in lp.lower(): - # Version with comments (from patch_models.py comments) - openai_block_v0 = ( - ' # 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' - ) - openai_block_v0_new = ( - ' # 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' - ' params = {\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' - ' # Flint requires reasoning to be explicitly enabled (mandatory on OpenRouter)\n' - ' if "flint" in or_model.lower():\n' - ' params["reasoning"] = {"effort": "low"}\n' - ' return params\n' - ) - - # Version without comments (from patch_openrouter_fix.py, the final state) - openai_block_v2 = ( - ' 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' - ' "custom_llm_provider": "openrouter",\n' - ' }\n' - ) - openai_block_v2_new = ( - ' if normalized_model.startswith("openai/"):\n' - ' or_model = normalized_model[len("openai/"):]\n' - ' params = {\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' - ' # Flint requires reasoning to be explicitly enabled (mandatory on OpenRouter)\n' - ' if "flint" in or_model.lower():\n' - ' params["reasoning"] = {"effort": "low"}\n' - ' return params\n' - ) - - # Plain version without custom_llm_provider (from patch_models.py, before openrouter_fix) - openai_block_v1 = ( - ' 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' - ) - openai_block_v1_new = ( - ' if normalized_model.startswith("openai/"):\n' - ' or_model = normalized_model[len("openai/"):]\n' - ' params = {\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' - ' # Flint requires reasoning to be explicitly enabled (mandatory on OpenRouter)\n' - ' if "flint" in or_model.lower():\n' - ' params["reasoning"] = {"effort": "low"}\n' - ' return params\n' - ) - - if openai_block_v0 in lp: - lp = lp.replace(openai_block_v0, openai_block_v0_new) - print("OK: patched llm_params.py openai/ block (v0 with comments) for flint reasoning") - elif openai_block_v2 in lp: - lp = lp.replace(openai_block_v2, openai_block_v2_new) - print("OK: patched llm_params.py openai/ block (v2 custom_llm_provider) for flint reasoning") - elif openai_block_v1 in lp: - lp = lp.replace(openai_block_v1, openai_block_v1_new) - print("OK: patched llm_params.py openai/ block (v1) for flint reasoning") - else: - print("WARN: could not find openai/ routing block in llm_params.py") - print(" - Searching for 'openai/' patterns...") - for i, line in enumerate(lp.split('\n')): - if 'startswith("openai/")' in line: - print(f" Found at line {i+1}: {line.strip()}") - lines = lp.split('\n') - for j in range(max(0,i-2), min(len(lines), i+8)): - print(f" {j+1}: {lines[j]}") - print("---") - print(" - Flint model will be registered but may fail with 400 'Inference request failed'") - print(" - The model has reasoning:{mandatory:true} on OpenRouter") - - with open(LLM_PARAMS_FILE, "w") as f: - f.write(lp) - ast.parse(lp) - print("OK: llm_params.py written & syntax OK") -else: - print("OK: llm_params.py already patched for flint (idempotent)") - -# ---------- 5. Frontend ChatInput.tsx ---------- -if os.path.exists(FRONTEND_FILE): - with open(FRONTEND_FILE, "r", encoding="utf-8") as f: - fc = f.read() - - if "flint-v1.0" in fc: - print("OK: recursive/flint-v1.0:free already present in ChatInput.tsx (idempotent)") - else: - # Anchor: the deepseek-v4-flash-latest entry at the end of DEFAULT_MODEL_OPTIONS. - anchor = " {\n id: 'deepseek-v4-flash-latest',\n" - new_entry = ( - " {\n" - " id: 'flint-v1.0-free',\n" - " name: 'recursive/flint-v1.0:free',\n" - " modelPath: 'openai/recursive/flint-v1.0:free',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/recursive',\n" - " recommended: true,\n" - " },\n" - ) - - if anchor in fc: - fc = fc.replace(anchor, new_entry + anchor, 1) - with open(FRONTEND_FILE, "w", encoding="utf-8") as f: - f.write(fc) - print("OK: added recursive/flint-v1.0:free to ChatInput.tsx DEFAULT_MODEL_OPTIONS") - else: - print("WARN: deepseek anchor not found in ChatInput.tsx - frontend entry not added") -else: - print(f"WARN: {FRONTEND_FILE} not found (frontend patch skipped)") - -print("DONE: recursive/flint-v1.0:free added with Laguna S 2.1:free path convention + reasoning fix") diff --git a/patch_flint_free_frontend.py b/patch_flint_free_frontend.py deleted file mode 100644 index cb8af190882bc83fca13a9dffb22e6eea10541ec..0000000000000000000000000000000000000000 --- a/patch_flint_free_frontend.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Patch frontend ChatInput.tsx: add recursive/flint-v1.0:free (OpenRouter). - -Uses the same `openai/` modelPath convention as Laguna S 2.1:free. -The frontend modelPath is sent to the backend, which strips the `openai/` -prefix and routes to OpenRouter via the existing catch-all in _resolve_llm_params. - -Runs AFTER patch_frontend_xing_nemotron_vl.py (which already rewrote -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 "flint-v1.0" in content: - print("OK: recursive/flint-v1.0:free already present in ChatInput.tsx (idempotent)") - return - - # Anchor: the deepseek-v4-flash-latest entry at the end of DEFAULT_MODEL_OPTIONS. - anchor = " {\n id: 'deepseek-v4-flash-latest',\n" - new_entry = ( - " {\n" - " id: 'flint-v1.0-free',\n" - " name: 'recursive/flint-v1.0:free',\n" - " modelPath: 'openai/recursive/flint-v1.0:free',\n" - " avatarUrl: 'https://huggingface.co/api/avatars/recursive',\n" - " recommended: true,\n" - " },\n" - ) - - if anchor in content: - content = content.replace(anchor, new_entry + anchor, 1) - with open(FILE, "w", encoding="utf-8") as f: - f.write(content) - print("OK: added recursive/flint-v1.0:free to ChatInput.tsx DEFAULT_MODEL_OPTIONS") - else: - print("FAIL: deepseek anchor not found in ChatInput.tsx") - return - - -if __name__ == "__main__": - patch() diff --git a/patch_frontend.py b/patch_frontend.py deleted file mode 100644 index 228eb22b7e0c4fbf6623a680c0217df65513136f..0000000000000000000000000000000000000000 --- a/patch_frontend.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Patch frontend: Replace DEFAULT_MODEL_OPTIONS with OpenRouter models.""" - -import re -import sys - -FILE = "/source/frontend/src/components/Chat/ChatInput.tsx" -MODEL_FILE = "/source/frontend/src/utils/model.ts" - -# === Step 1: Patch model.ts === -with open(MODEL_FILE, "r") as f: - model_content = f.read() - -model_content = model_content.replace( - "export const KIMI_K27_CODE_MODEL_PATH = 'moonshotai/Kimi-K2.7-Code:novita';", - "export const KIMI_K27_CODE_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro';" -) -model_content = model_content.replace( - "export const MINIMAX_M3_MODEL_PATH = 'MiniMaxAI/MiniMax-M3:novita';", - "export const MINIMAX_M3_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Flash';" -) -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';" -) -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( - "export const LAGUNA_M1_MODEL_PATH", - "export const LAGUNA_S21_MODEL_PATH = 'openai/poolside/laguna-s-2.1:free';\n\nexport const LAGUNA_M1_MODEL_PATH" - ) - 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") - -# === Step 2: Patch ChatInput.tsx === -with open(FILE, "r", encoding="utf-8") as f: - content = f.read() - -# Fix imports -import_pattern = re.compile( - r'import\s*\{[^}]+\}\s*from\s*[\'"]@/utils/model[\'"];' -) -match = import_pattern.search(content) -if match: - content = content.replace(match.group(0), "import { isClaudePath } from '@/utils/model';") - print("OK: Replaced imports") - -# Remove Owl Alpha references -content = content.replace("'Owl Alpha'", "'Claude Opus 4.8'") -content = content.replace('"Owl Alpha"', '"Claude Opus 4.8"') - -# Row 3: Kimi → DeepSeek V4 Pro -content = content.replace("'Kimi K2.7 Code'", "'DeepSeek V4 Pro'") -content = content.replace('"Kimi K2.7 Code"', '"DeepSeek V4 Pro"') - -# Row 4: MiniMax → DeepSeek V4 Flash -content = content.replace("'MiniMax M3'", "'DeepSeek V4 Flash'") -content = content.replace('"MiniMax M3"', '"DeepSeek V4 Flash"') - -# Row 5: GLM → DeepSeek V4 Flash -content = content.replace("'GLM 5.2'", "'DeepSeek V4 Flash'") -content = content.replace('"GLM 5.2"', '"DeepSeek V4 Flash"') - -# Row 6: DeepSeek V4 Pro → Nemotron -content = content.replace( - " {\n id: 'deepseek-v4-pro',\n name: 'DeepSeek V4 Pro',\n modelPath: DEEPSEEK_V4_PRO_MODEL_PATH,\n avatarUrl: getHfAvatarUrl('deepseek-ai/DeepSeek-V4-Pro'),\n },", - " {\n id: 'nemotron-120b',\n name: 'nvidia/nemotron-3-super-120b-a12b:free',\n modelPath: DEEPSEEK_V4_PRO_MODEL_PATH,\n avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n }," -) - -# Replace entire DEFAULT_MODEL_OPTIONS array -pattern = r'const DEFAULT_MODEL_OPTIONS: ModelOption\[\] = \[.*?\];' -match = re.search(pattern, content, re.DOTALL) - -if not match: - print("FAIL: Could not find DEFAULT_MODEL_OPTIONS array!") - sys.exit(1) - -NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ - { - id: 'laguna-s21', - name: 'Laguna S 2.1:free', - modelPath: 'openai/poolside/laguna-s-2.1:free', - avatarUrl: 'https://huggingface.co/api/avatars/tencent', - recommended: true, - }, - { - id: 'gemma-4-31b', - name: 'Gemma 4 31B:free', - modelPath: 'openai/google/gemma-4-31b-it:free', - avatarUrl: 'https://huggingface.co/api/avatars/google', - recommended: true, - }, - { - id: 'llama-3.3-70b', - name: 'Llama 3.3 70B:free', - modelPath: 'openai/meta-llama/llama-3.3-70b-instruct:free', - avatarUrl: 'https://huggingface.co/api/avatars/meta-llama', - recommended: true, - }, - { - id: 'laguna-m1', - name: 'Laguna M.1:free', - modelPath: 'openai/poolside/laguna-m.1:free', - avatarUrl: 'https://huggingface.co/api/avatars/poolside', - recommended: true, - }, - { - id: 'laguna-s21', - name: 'Laguna S 2.1:free', - modelPath: 'openai/poolside/laguna-s-2.1:free', - 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', - modelPath: 'openai/meta-llama/llama-3.1-8b-instruct', - avatarUrl: 'https://huggingface.co/api/avatars/meta-llama', - }, - { - id: 'qwen3-coder-next', - name: 'Qwen3 Coder Next', - modelPath: 'Qwen/Qwen3-Coder-Next', - avatarUrl: 'https://huggingface.co/api/avatars/Qwen', - }, - { - id: 'gemini-2.0-flash', - name: 'Gemini 2.0 Flash', - 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():] - -# 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';") - print("OK: Replaced DEFAULT_MODEL_PATH") -else: - print("OK: DEFAULT_MODEL_PATH not found") - -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") 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 deleted file mode 100644 index f3718698c94feb706308302478a27387fa41e131..0000000000000000000000000000000000000000 --- a/patch_models.py +++ /dev/null @@ -1,256 +0,0 @@ -"""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. -""" - -import ast -import os -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: - 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") - -# 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") - -# 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") - -# 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"') - -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") - -with open(AGENT_FILE) as f: - content = f.read() - -content = content.replace( - "DEFAULT_MODEL_ID = GLM_52_MODEL_ID", - 'DEFAULT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID' -) -content = content.replace( - "DEFAULT_GPT_MODEL_ID = GPT_55_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" - ")" -) -content = content.replace(old_import, new_import) - -# Update _available_models to include DeepSeek V4 Flash 0731 -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}, - ] - return models''' - -func_match = re.search( - r"def _available_models\(\)\s*->\s*list\[dict\[str,\s*Any\]\]:.*?return models", - content, re.DOTALL -) -if func_match: - content = content[:func_match.start()] + new_func + content[func_match.end():] - print("OK: Replaced _available_models() with DeepSeek V4 Flash 0731") -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()") - 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]}") - -content = content.replace( - '"openai/gpt-oss-120b:cerebras",', - '"huggingface/deepseek-ai/DeepSeek-V4-Pro",' -) - -try: - ast.parse(content) - print("OK: agent.py syntax OK") -except SyntaxError as e: - print(f"FAIL: agent.py syntax error: {e}") - raise - -with open(AGENT_FILE, "w") as f: - f.write(content) - -# 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" -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' - ) - llm_content = llm_content[:line_end] + routing_insert + llm_content[line_end:] - print("OK: Patched _resolve_llm_params with deepseek/ + fixed openai/ routing") - else: - print("WARN: Could not find api_key line") -else: - print("OK: llm_params.py already patched") - -try: - ast.parse(llm_content) - print("OK: llm_params.py syntax OK") -except SyntaxError as e: - print(f"FAIL: llm_params.py syntax error: {e}") - raise - -with open(LLM_PARAMS_FILE, "w") as f: - f.write(llm_content) - -print("OK: ALL BACKEND PATCHES APPLIED (including DeepSeek V4 Flash routing fix)") 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_from_dataset.py b/patch_restore_from_dataset.py deleted file mode 100644 index b233c705b546239e2741d0314016cfcc5c43d227..0000000000000000000000000000000000000000 --- a/patch_restore_from_dataset.py +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch: Add "Restore from dataset" button to the expired-session banner. - -Adds a one-click way to recover a dead session from the permanent HF -dataset with its FULL conversation, instead of only a lossy summary. - -Backend: -- session_manager.seed_from_dataset(session_id): load full history from - the permanent dataset (backend.session_logger.load_session_history) and - seed the session's context manager with every valid message. Returns the - number of messages seeded (0 if the dataset has nothing). -- routes/agent.py: POST /api/session/restore-from-dataset — creates a new - session, seeds it from the dataset, returns the new session_id. - -Frontend: -- ExpiredBanner.tsx: adds a "Restore from dataset" button that calls the - new endpoint and renames the session (same flow as Catch me up). - -Runs during Docker build; never fails the build. -""" - -import ast -import os - -PATCHED_MARKER = "# === PATCH: restore-from-dataset ===" - - -def _read(path): - with open(path, encoding="utf-8") as f: - return f.read() - - -def _write(path, content): - with open(path, "w", encoding="utf-8") as f: - f.write(content) - - -def _replace(content, old, new): - if old in content: - return content.replace(old, new, 1), True - return content, False - - -# ---- Backend: session_manager.seed_from_dataset ------------------------- -SM = "/app/backend/session_manager.py" -if os.path.exists(SM): - content = _read(SM) - changed = False - if PATCHED_MARKER in content: - print("SKIP: session_manager.py already patched") - else: - anchor = " async def record_catchup_done(self, session_id: str) -> None:" - if anchor in content: - new_method = ( - " # === PATCH: restore-from-dataset ===\n" - " async def seed_from_dataset(self, session_id: str) -> int:\n" - " \"\"\"Rehydrate a session from the permanent HF dataset.\n" - "\n" - " Loads the FULL conversation history for ``session_id`` from\n" - " the permanent sessions dataset and seeds the new session's\n" - " context manager with every valid message, so the model\n" - " genuinely remembers the prior conversation (lossless).\n" - "\n" - " Returns the number of messages seeded (0 if none found).\n" - " \"\"\"\n" - " from litellm import Message\n" - " from backend.session_logger import load_session_history\n" - "\n" - " agent_session = self.sessions.get(session_id)\n" - " if not agent_session:\n" - " raise ValueError(f\"Session {session_id} not found\")\n" - "\n" - " try:\n" - " ds_rows = load_session_history(session_id)\n" - " except Exception as e:\n" - " logger.error(\"load_session_history failed for %s: %s\", session_id, e)\n" - " ds_rows = []\n" - "\n" - " full_msgs = [\n" - " r for r in ds_rows\n" - " if r.get(\"role\") in (\"user\", \"assistant\", \"tool\")\n" - " ]\n" - " if not full_msgs:\n" - " logger.info(\"No dataset messages for session %s\", session_id)\n" - " return 0\n" - "\n" - " session = agent_session.session\n" - " seeded = 0\n" - " for raw in full_msgs:\n" - " try:\n" - " session.context_manager.items.append(Message.model_validate(raw))\n" - " seeded += 1\n" - " except Exception as e:\n" - " logger.warning(\"Dropping dataset msg during seed: %s\", e)\n" - "\n" - " self._touch(agent_session)\n" - " await self.persist_session_snapshot(agent_session, runtime_state=\"idle\")\n" - " try:\n" - " await self.record_catchup_done(session_id)\n" - " except Exception:\n" - " pass\n" - " return seeded\n" - "\n" - " async def record_catchup_done(self, session_id: str) -> None:" - ) - content, ok = _replace(content, anchor, new_method) - if ok: - print("+ session_manager.seed_from_dataset added") - changed = True - else: - print("INFO: record_catchup_done anchor not found") - if changed: - try: - ast.parse(content) - _write(SM, content) - print("OK: session_manager.py patched (seed_from_dataset)") - except SyntaxError as e: - print("FAIL: session_manager.py syntax", e) - -# ---- Backend: routes/agent.py endpoint ------------------------------------ -RA = "/app/backend/routes/agent.py" -if os.path.exists(RA): - content = _read(RA) - changed = False - if PATCHED_MARKER in content: - print("SKIP: routes/agent.py already patched") - else: - anchor_start = ( - " logger.info(\n" - ' f"Seeded session {session_id} for {user.get(\'username\', \'unknown\')} "\n' - ' f"(summary of {summarized} messages)"\n' - " )\n" - " return SessionResponse(\n" - " session_id=session_id,\n" - " ready=True,\n" - " model=model,\n" - " )\n" - ) - endpoint = ( - "\n\n@router.post(\"/session/restore-from-dataset\", response_model=SessionResponse)\n" - "async def restore_session_from_dataset(\n" - " request: Request, body: dict, user: dict = Depends(get_current_user)\n" - ") -> SessionResponse:\n" - " \"\"\"Create a new session seeded with the FULL conversation history read\n" - " from the permanent HF dataset for the given ``session_id``.\n" - "\n" - " Unlike restore-summary, this does not run an LLM summarization — it\n" - " loads the actual stored messages, so the model genuinely remembers the\n" - " prior conversation. Body: {\"session_id\": \"...\"}.\n" - " \"\"\"\n" - " old_id = body.get(\"session_id\")\n" - " if not old_id or not isinstance(old_id, str):\n" - " raise HTTPException(status_code=400, detail=\"Missing 'session_id' string\")\n" - "\n" - " hf_token = resolve_hf_request_token(request)\n" - " model = body.get(\"model\")\n" - " _validate_model_id(model)\n" - " model = _model_override_for_new_session(model)\n" - "\n" - " try:\n" - " session_id = await session_manager.create_session(\n" - " user_id=user[\"user_id\"],\n" - " hf_username=user.get(\"username\"),\n" - " hf_token=hf_token,\n" - " user_plan=user.get(\"plan\"),\n" - " model=model,\n" - " is_pro=user.get(\"plan\") == \"pro\",\n" - " )\n" - " except SessionCapacityError as e:\n" - " raise HTTPException(status_code=503, detail=str(e))\n" - "\n" - " await _reset_usage_window(session_id)\n" - " await _check_session_access(\n" - " session_id,\n" - " user,\n" - " request,\n" - " preload_sandbox=False,\n" - " )\n" - " try:\n" - " seeded = await session_manager.seed_from_dataset(session_id)\n" - " except Exception as e:\n" - " logger.exception(\"seed_from_dataset failed\")\n" - " raise HTTPException(status_code=500, detail=f\"Restore failed: {e}\")\n" - "\n" - " logger.info(\n" - " f\"Restored session {session_id} from dataset for \"\n" - " f\"{user.get('username', 'unknown')} ({seeded} messages)\"\n" - " )\n" - " return SessionResponse(\n" - " session_id=session_id,\n" - " ready=True,\n" - " model=model,\n" - " )\n" - ) - if anchor_start in content: - content = content.replace(anchor_start, anchor_start + endpoint, 1) - print("+ POST /session/restore-from-dataset endpoint added") - changed = True - else: - print("INFO: exact anchor not found, trying loose anchor") - loose = " return SessionResponse(\n session_id=session_id,\n ready=True,\n model=model,\n )\n" - if loose in content: - idx_last = content.rfind(loose) - content = content[: idx_last + len(loose)] + endpoint + content[idx_last + len(loose):] - print("+ endpoint inserted after last SessionResponse return") - changed = True - if changed: - try: - ast.parse(content) - _write(RA, content) - print("OK: routes/agent.py patched (restore-from-dataset)") - except SyntaxError as e: - print("FAIL: routes/agent.py syntax", e) - -# ---- Frontend: ExpiredBanner.tsx button ------------------------------------ -EB = "/source/frontend/src/components/Chat/ExpiredBanner.tsx" -if os.path.exists(EB): - content = _read(EB) - changed = False - if PATCHED_MARKER in content: - print("SKIP: ExpiredBanner.tsx already patched") - else: - # 1. busy union type - old_busy = " const [busy, setBusy] = useState<'catch-up' | 'start-over' | null>(null);" - new_busy = " const [busy, setBusy] = useState<'catch-up' | 'restore-dataset' | 'start-over' | null>(null);" - content, ok = _replace(content, old_busy, new_busy) - if ok: - print("+ busy type widened") - changed = True - - # 2. handleRestoreFromDataset callback before handleStartOver - anchor2 = " const handleStartOver = useCallback(() => {" - if anchor2 in content: - cb = ( - " // === PATCH: restore-from-dataset ===\n" - " const handleRestoreFromDataset = useCallback(async () => {\n" - " setBusy('restore-dataset');\n" - " setError(null);\n" - " try {\n" - " const res = await apiFetch('/api/session/restore-from-dataset', {\n" - " method: 'POST',\n" - " body: JSON.stringify({ session_id: sessionId }),\n" - " });\n" - " if (!res.ok) throw new Error(`restore-from-dataset failed: ${res.status}`);\n" - " const data = await res.json();\n" - " const newId = data.session_id as string | undefined;\n" - " if (!newId) throw new Error('no session_id in response');\n" - "\n" - " useAgentStore.getState().clearSessionState(sessionId);\n" - " renameSession(sessionId, newId);\n" - " if (data.model) updateSessionModel(newId, data.model);\n" - " // Same catchup-done bookkeeping as Catch me up.\n" - " try {\n" - " localStorage.setItem(`ml-intern-catchup-done:${newId}`, 'true');\n" - " } catch {}\n" - " try { await apiFetch(`/api/session/${newId}/catchup-done`, { method: 'POST' }); } catch {}\n" - " } catch (e) {\n" - " logger.warn('Restore-from-dataset failed:', e);\n" - " setError(\"Couldn't restore from dataset — try Catch me up or start over.\");\n" - " setBusy(null);\n" - " }\n" - " }, [sessionId, renameSession, updateSessionModel]);\n" - "\n" - " const handleStartOver = useCallback(() => {" - ) - content, ok = _replace(content, anchor2, cb) - if ok: - print("+ handleRestoreFromDataset callback added") - changed = True - - # 3. Button before Start fresh - anchor3 = ( - " : null}\n" - " sx={{ textTransform: 'none' }}\n" - " >\n" - " {busy === 'restore-dataset' ? 'Restoring…' : 'Restore from dataset'}\n" - " \n" - " 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_sse_transport.py b/patch_sse_transport.py deleted file mode 100644 index 34e9c3ce02fca5c580b6f2ac737e9688cfd24550..0000000000000000000000000000000000000000 --- a/patch_sse_transport.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Patch sse-chat-transport.ts - handle task_incomplete event.""" - -SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts" - -def patch(): - import os - if not os.path.exists(SSE_TRANSPORT): - print(f"SKIP: {SSE_TRANSPORT} not found") - return - - with open(SSE_TRANSPORT, "r", encoding="utf-8") as f: - content = f.read() - - # Add task_incomplete case in createEventToChunkStream - if "case 'task_incomplete'" not in content: - task_incomplete_case = ''' case 'task_incomplete': - sideChannel.onTaskIncomplete( - (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [], - ); - break; -''' - content = content.replace( - "case 'turn_complete':", - task_incomplete_case + "\n case 'turn_complete':" - ) - - with open(SSE_TRANSPORT, "w", encoding="utf-8") as f: - f.write(content) - print(f"OK: Patched {SSE_TRANSPORT}") - -if __name__ == "__main__": - patch() \ No newline at end of file diff --git a/patch_use_agent_chat_full.py b/patch_use_agent_chat_full.py deleted file mode 100644 index e64f9bc9b7eef4a57d429e8abc7f16c6b9a0bc26..0000000000000000000000000000000000000000 --- a/patch_use_agent_chat_full.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Patch useAgentChat.ts - auto-continue feature.""" - -USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts" - -def patch(): - import os - if not os.path.exists(USE_AGENT_CHAT): - print(f"SKIP: {USE_AGENT_CHAT} not found") - return - - with open(USE_AGENT_CHAT, "r", encoding="utf-8") as f: - content = f.read() - - # 1. Add useState to imports - 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 declarations after callbacksRef.current = { ... } - state_decl = ''' - // 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_decl - ) - - # 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. Add onTaskIncomplete handler in sideChannel - sidechannel_handler = ''' onTaskIncomplete: (incompletePlan) => { - setTaskIncompleteInfo({ incompletePlan }); - setShowAutoContinue(true); - // Auto-start 10s timer for free models - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - } - autoContinueTimerRef.current = setTimeout(() => { - startAutoContinue(); - }, 10000); - },''' - - if "onTaskIncomplete: (incompletePlan)" not in content: - content = content.replace( - " onInterrupted: () => { /* no-op — handled by stop() caller */ },", - sidechannel_handler + "\n onInterrupted: () => { /* no-op — handled by stop() caller */ }," - ) - - # 5. Add auto-continue functions before return - auto_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; - } - // Build continuation message from incomplete plan - 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; - } - }, []); - - // Cleanup timer on unmount - useEffect(() => { - return () => { - if (autoContinueTimerRef.current) { - clearTimeout(autoContinueTimerRef.current); - } - }; - }, []); -''' - - if "startAutoContinue" not in content: - content = content.replace( - '\n return {\n messages: chat.messages,', - auto_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", encoding="utf-8") as f: - f.write(content) - print(f"OK: Complete patch applied to {USE_AGENT_CHAT}") - -if __name__ == "__main__": - patch() \ No newline at end of file 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())