Auto-continue feature for free models

#2
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitignore +0 -2
  2. BUILD_TRIGGER +0 -1
  3. Dockerfile +40 -76
  4. README.md +8 -42
  5. backend/document_parser.py +0 -319
  6. backend/session_logger.py +0 -467
  7. configs/frontend_agent_config.json +1 -4
  8. fix_space_error_report.json +5 -10
  9. patch_agent_backend.py +0 -87
  10. patch_apodex_ling_beatapi.py +0 -145
  11. patch_auto_continue_v10.py +0 -202
  12. patch_auto_continue_v11.py +0 -185
  13. patch_auto_continue_v12.py +0 -209
  14. patch_auto_continue_v13.py +0 -197
  15. patch_auto_continue_v14.py +0 -166
  16. patch_auto_continue_v15.py +0 -167
  17. patch_auto_continue_v16.py +0 -142
  18. patch_auto_continue_v17.py +0 -187
  19. patch_auto_continue_v2.py +0 -307
  20. patch_auto_continue_v3.py +0 -229
  21. patch_auto_continue_v4.py +0 -264
  22. patch_auto_continue_v5.py +0 -252
  23. patch_auto_continue_v6.py +0 -243
  24. patch_auto_continue_v7.py +0 -255
  25. patch_auto_continue_v8.py +0 -239
  26. patch_auto_continue_v9.py +0 -227
  27. patch_beatapi_routing.py +0 -182
  28. patch_catch_up_dataset_fallback.py +0 -102
  29. patch_catch_up_fix_backend.py +0 -220
  30. patch_catch_up_fix_frontend.py +0 -289
  31. patch_chat_input.py +8 -18
  32. patch_document_upload.py +0 -225
  33. patch_fix_static_404.py +0 -105
  34. patch_frontend.py +9 -53
  35. patch_frontend_apodex_ling_beatapi.py +0 -110
  36. patch_frontend_multi.py +0 -120
  37. patch_frontend_new_models.py +0 -71
  38. patch_frontend_permanent_sessions.py +0 -45
  39. patch_frontend_uploads.py +0 -81
  40. patch_frontend_xing_nemotron_vl.py +0 -63
  41. patch_lossless_context.py +0 -365
  42. patch_models.py +98 -183
  43. patch_multi_upload.py +0 -183
  44. patch_new_models.py +0 -88
  45. patch_openrouter_fix.py +0 -92
  46. patch_permanent_session_logging.py +0 -358
  47. patch_restore_full_app.py +0 -84
  48. patch_space_bunny.py +0 -56
  49. patch_xing_nemotron_vl.py +0 -93
  50. scripts/build_kpis.py +0 -917
.gitignore DELETED
@@ -1,2 +0,0 @@
1
- __pycache__/
2
- *.pyc
 
 
 
BUILD_TRIGGER DELETED
@@ -1 +0,0 @@
1
- catch-up-message-loss-fix-v4-rebuild-xing-nemotron-vl
 
 
Dockerfile CHANGED
@@ -1,99 +1,63 @@
 
1
  FROM node:20-alpine AS frontend-builder
2
  RUN apk add --no-cache git python3
3
- RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /source || true
4
- COPY patch_restore_full_app.py /tmp/patch_restore_full_app.py
5
- RUN python3 /tmp/patch_restore_full_app.py || true
6
  COPY patch_frontend.py /tmp/patch_frontend.py
7
- RUN python3 /tmp/patch_frontend.py || true
8
- COPY patch_space_bunny.py /tmp/patch_space_bunny.py
9
- RUN python3 /tmp/patch_space_bunny.py || true
10
- COPY patch_frontend_uploads.py /tmp/patch_frontend_uploads.py
11
- RUN python3 /tmp/patch_frontend_uploads.py || true
12
- COPY patch_frontend_multi.py /tmp/patch_frontend_multi.py
13
- RUN python3 /tmp/patch_frontend_multi.py || true
14
- COPY patch_frontend_new_models.py /tmp/patch_frontend_new_models.py
15
- RUN python3 /tmp/patch_frontend_new_models.py || true
16
- COPY patch_frontend_apodex_ling_beatapi.py /tmp/patch_frontend_apodex_ling_beatapi.py
17
- RUN python3 /tmp/patch_frontend_apodex_ling_beatapi.py || true
18
- COPY patch_catch_up_dataset_fallback.py /tmp/patch_catch_up_dataset_fallback.py
19
- RUN python3 /tmp/patch_catch_up_dataset_fallback.py || true
20
- # Run auto_continue patch BEFORE our frontend fix, since it also patches useAgentChat.ts
21
- COPY patch_auto_continue_v17.py /tmp/patch_auto_continue_v17.py
22
- RUN python3 /tmp/patch_auto_continue_v17.py || true
23
- # Run our frontend catch-up fix AFTER all other frontend patches
24
- COPY patch_catch_up_fix_frontend.py /tmp/patch_catch_up_fix_frontend.py
25
- RUN python3 /tmp/patch_catch_up_fix_frontend.py || true
26
 
27
  WORKDIR /source/frontend
28
- RUN npm config set fetch-timeout 120000 && npm config set fetch-retries 3 && npm install && npm run build || echo "FRONTEND BUILD FAILED"
29
- # Guarantee the dist dir exists so COPY --from below never fails, even if npm failed.
30
- RUN mkdir -p /source/frontend/dist
 
31
 
 
32
  FROM python:3.12-slim
33
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
34
  RUN useradd -m -u 1000 user
35
  WORKDIR /app
36
- 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/*
37
-
38
- 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
39
 
40
- COPY scripts/build_kpis.py /tmp/build_kpis_patched.py
41
- 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"
 
 
 
 
42
 
43
- COPY patch_restore_full_app.py /tmp/patch_restore_full_app.py
44
- RUN python3 /tmp/patch_restore_full_app.py || true
45
 
46
- RUN uv sync --no-dev --frozen || echo "UV SYNC FAILED"
 
47
 
48
- COPY backend/session_logger.py /app/backend/session_logger.py
49
- COPY backend/document_parser.py /app/backend/document_parser.py
50
  COPY patch_models.py /tmp/patch_models.py
51
- RUN python /tmp/patch_models.py || true
52
- COPY patch_new_models.py /tmp/patch_new_models.py
53
- RUN python /tmp/patch_new_models.py || true
54
- COPY patch_apodex_ling_beatapi.py /tmp/patch_apodex_ling_beatapi.py
55
- RUN python3 /tmp/patch_apodex_ling_beatapi.py || true
56
- COPY patch_openrouter_fix.py /tmp/patch_openrouter_fix.py
57
- RUN python3 /tmp/patch_openrouter_fix.py || true
58
- COPY patch_beatapi_routing.py /tmp/patch_beatapi_routing.py
59
- RUN python3 /tmp/patch_beatapi_routing.py || true
60
- COPY patch_agent_backend.py /tmp/patch_agent_backend.py
61
- RUN python3 /tmp/patch_agent_backend.py || true
62
- COPY patch_document_upload.py /tmp/patch_document_upload.py
63
- RUN python3 /tmp/patch_document_upload.py || true
64
- COPY patch_multi_upload.py /tmp/patch_multi_upload.py
65
- RUN python3 /tmp/patch_multi_upload.py || true
66
- COPY patch_permanent_session_logging.py /tmp/patch_permanent_session_logging.py
67
- RUN python3 /tmp/patch_permanent_session_logging.py || true
68
- COPY patch_catch_up_fix_backend.py /tmp/patch_catch_up_fix_backend.py
69
- RUN python3 /tmp/patch_catch_up_fix_backend.py || true
70
-
71
- # === PATCH: fix 404 on root and /api when frontend build is missing ===
72
- # Root cause: mkdir -p /app/static creates an empty dir; main.py mounts
73
- # StaticFiles on the empty dir, shadowing ALL API routes with 404s.
74
- COPY patch_fix_static_404.py /tmp/patch_fix_static_404.py
75
- RUN python3 /tmp/patch_fix_static_404.py || echo "STATIC FIX PATCH WARN (BUILD CONTINUES)"
76
-
77
- # === PATCH: lossless long-context memory (fix context loss for long sessions) ===
78
- COPY patch_lossless_context.py /tmp/patch_lossless_context.py
79
- RUN python /tmp/patch_lossless_context.py || echo "LOSSLESS CONTEXT PATCH WARN (BUILD CONTINUES)"
80
- 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"
81
-
82
- # Copy frontend dist from builder stage using COPY --from (multi-stage)
83
- # The frontend-builder stage guarantees /source/frontend/dist exists (mkdir -p)
84
- COPY --from=frontend-builder /source/frontend/dist /app/static
85
- RUN echo "Static dir ready: $(ls /app/static 2>/dev/null | wc -l) files"
86
-
87
- RUN mkdir -p /app/configs
88
- RUN cp configs/frontend_agent_config.json /app/configs/frontend_agent_config.json 2>/dev/null || echo "No frontend config"
89
 
90
- 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
 
91
 
92
  RUN mkdir -p /app/session_logs && chown -R user:user /app
93
 
94
  USER user
95
- ENV HOME=/home/user PYTHONUNBUFFERED=1 PYTHONPATH=/app PATH="/app/.venv/bin:$PATH"
 
 
 
96
 
97
  EXPOSE 7860
98
  WORKDIR /app/backend
99
- CMD ["bash", "start.sh"]
 
 
1
+ # Restore from 4bac0e2 + patch frontend + backend models + auto-continue
2
  FROM node:20-alpine AS frontend-builder
3
  RUN apk add --no-cache git python3
4
+ RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /source
5
+
6
+ # Patch frontend: add OpenRouter models
7
  COPY patch_frontend.py /tmp/patch_frontend.py
8
+ RUN python3 /tmp/patch_frontend.py
9
+
10
+ # Patch auto-continue feature for free models
11
+ COPY patch_use_agent_chat_full.py /tmp/patch_use_agent_chat_full.py
12
+ RUN python3 /tmp/patch_use_agent_chat_full.py
13
+
14
+ COPY patch_chat_input.py /tmp/patch_chat_input.py
15
+ RUN python3 /tmp/patch_chat_input.py
16
+
17
+ COPY patch_sse_transport.py /tmp/patch_sse_transport.py
18
+ RUN python3 /tmp/patch_sse_transport.py
 
 
 
 
 
 
 
 
19
 
20
  WORKDIR /source/frontend
21
+ RUN npm config set fetch-timeout 120000 && \
22
+ npm config set fetch-retries 3 && \
23
+ npm install && \
24
+ npm run build
25
 
26
+ # Stage 2: Production
27
  FROM python:3.12-slim
28
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
29
  RUN useradd -m -u 1000 user
30
  WORKDIR /app
31
+ RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates && rm -rf /var/lib/apt/lists/*
 
 
32
 
33
+ RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /tmp/source && \
34
+ cp /tmp/source/pyproject.toml /tmp/source/uv.lock ./ && \
35
+ cp -r /tmp/source/agent ./agent && \
36
+ cp -r /tmp/source/backend ./backend && \
37
+ cp -r /tmp/source/configs ./configs && \
38
+ rm -rf /tmp/source
39
 
40
+ RUN uv sync --no-dev --frozen
 
41
 
42
+ COPY --from=frontend-builder /source/frontend/dist ./static/
43
+ COPY configs/frontend_agent_config.json ./configs/frontend_agent_config.json
44
 
45
+ # Patch backend: accept new model IDs + auto-continue
 
46
  COPY patch_models.py /tmp/patch_models.py
47
+ RUN python /tmp/patch_models.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ COPY patch_agent_loop.py /tmp/patch_agent_loop.py
50
+ RUN python /tmp/patch_agent_loop.py || echo "agent_loop patch not applied (may need adjustment)"
51
 
52
  RUN mkdir -p /app/session_logs && chown -R user:user /app
53
 
54
  USER user
55
+ ENV HOME=/home/user \
56
+ PYTHONUNBUFFERED=1 \
57
+ PYTHONPATH=/app \
58
+ PATH="/app/.venv/bin:$PATH"
59
 
60
  EXPOSE 7860
61
  WORKDIR /app/backend
62
+ # python:slim does not include bash by default; start.sh is POSIX-compatible.
63
+ CMD ["sh", "start.sh"]
README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: yellow
5
  colorTo: blue
6
  sdk: docker
7
  app_port: 7860
8
- short_description: ML Intern V2 (FREE & SAVE MODEL + PERMANENT SESSION LOGGING)
9
  hf_oauth: true
10
  hf_oauth_expiration_minutes: 43200
11
  hf_oauth_scopes:
@@ -19,48 +19,14 @@ hf_oauth_scopes:
19
  - write-discussions
20
  tags:
21
  - ml-intern
22
- - permanent-session-logging
23
- - persistent-context
24
  ---
25
 
26
- # Space Multiverse — ML Intern V2
27
 
28
- ## 🔒 Permanent Session & User Context Logging
29
 
30
- **Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace.**
31
-
32
- - Mỗi message, tool call, event được lưu **ngay khi xảy ra** — không đợi heartbeat
33
- - Không bao giờ mất dữ liệu, ngay cả khi Space restart/crash
34
- - Lưu vào `bep40/ml-intern-sessions` (dataset chung) + `{user}/ml-intern-sessions` (cá nhân)
35
- - Mỗi model (dù là model nào) đều được lưu session vĩnh viễn
36
- - **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
37
-
38
- ### Cách hoạt động
39
-
40
- 1. Khi session được tạo → log ngay vào dataset với event_type=session_created
41
- 2. User gửi tin nhắn → log ngay role=user, event_type=message
42
- 3. Agent phản hồi → log ngay role=assistant, event_type=message
43
- 4. Tool call/result → log ngay event_type=tool_call/tool_result
44
- 5. Session kết thúc → log trajectory đầy đủ
45
-
46
- ### Truy cập lịch sử
47
-
48
- - `GET /api/sessions-history` — lấy tất cả session của user
49
- - `GET /api/session-history/{session_id}` — lấy full lịch sử một session
50
- - `GET /api/dataset-info` — thông tin dataset lưu trữ
51
-
52
- Dataset: [bep40/ml-intern-sessions](https://huggingface.co/datasets/bep40/ml-intern-sessions)
53
-
54
- ## Models
55
-
56
- - **Default**: `openai/tencent/hy3:free`
57
- - OpenRouter models: Tencent HY3, Gemma 4 31B, Llama 3.3 70B, DeepSeek V4, Laguna S 2.1
58
- - BeatAPI: `beatapi/space-bunny-alpha` (provider riêng, cần BEATAPI_API_KEY)
59
- - Cục bản chất, mọi model đều được lưu session vĩnh viễn
60
-
61
- ## Quick Start
62
-
63
- 1. Chọn model (dùng bất kỳ model nào — đều được lưu)
64
- 2. Chat bình thường
65
- 3. Session được lưu vĩnh viễn vào dataset
66
- 4. Sau Space restart, frontend tự động restore từ dataset
 
5
  colorTo: blue
6
  sdk: docker
7
  app_port: 7860
8
+ short_description: ML Intern V2 (FREE & SAVE MODEL)
9
  hf_oauth: true
10
  hf_oauth_expiration_minutes: 43200
11
  hf_oauth_scopes:
 
19
  - write-discussions
20
  tags:
21
  - ml-intern
 
 
22
  ---
23
 
24
+ # Space Multiverse — ML Intern V2 (FREE & SAVE MODEL)
25
 
26
+ <!-- rebuild: add-laguna-s21-2026-07-22T03:00:00Z -->
27
 
28
+ - Replaced GPT-5.5 model with **Laguna S 2.1:free** (path: `openai/poolside/laguna-s-2.1:free`)
29
+ - Added OpenRouter routing (catch-all `openai/` prefix → OpenRouter API)
30
+ - Default: `openai/tencent/hy3:free`
31
+ - Models: Tencent HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, **Laguna M.1:free**, **Laguna S 2.1:free**, Llama 3.1 8B
32
+ - Ollama: không thể dùng cho HF Space (local inference, không có cloud API free cho use case này)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/document_parser.py DELETED
@@ -1,319 +0,0 @@
1
- """Extract plain-text context from uploaded office/binary documents.
2
-
3
- The ML Intern agent runtime is a coding agent that mostly runs on text-only
4
- LLM backends (many of the free OpenRouter models have no vision). So instead
5
- of just handing the raw binary file to the model, we extract the meaningful
6
- text out of it and present it as a compact, readable context block.
7
-
8
- Supported formats:
9
- PDF (.pdf)
10
- Images (.png, .jpg, .jpeg, .webp, .bmp, .tiff, .gif)
11
- Excel (.xlsx, .xlsm, .xls)
12
- Word (.docx)
13
- PowerPoint (.pptx)
14
- OpenDocument (.odt, .ods, .odp) (via LibreOffice)
15
- Plain text (.txt, .md, .csv, .json, .jsonl, .rtf, .log)
16
-
17
- IMPORTANT: For OCR we rely on pytesseract + a system tesseract binary.
18
- For legacy binary office formats (.xls, .doc, .rtf, .odt/.ods/.odp) we rely
19
- on LibreOffice headless (`libreoffice --headless --convert-to txt`). If those
20
- system binaries are not installed, extraction degrades gracefully: the file is
21
- still uploaded and referenced, but with a warning that the raw content could
22
- not be parsed.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- import io
28
- import os
29
- import re
30
- import shutil
31
- import subprocess
32
- import tempfile
33
- from typing import Optional
34
-
35
- # --- Optional heavy imports, guarded --------------------------------
36
- try:
37
- from PIL import Image
38
- except Exception: # pragma: no cover
39
- Image = None
40
-
41
- try:
42
- from pypdf import PdfReader
43
- except Exception: # pragma: no cover
44
- PdfReader = None
45
-
46
- try:
47
- import docx # python-docx
48
- except Exception: # pragma: no cover
49
- docx = None
50
-
51
- try:
52
- import openpyxl
53
- except Exception: # pragma: no cover
54
- openpyxl = None
55
-
56
- try:
57
- import pptx # python-pptx
58
- except Exception: # pragma: no cover
59
- pptx = None
60
-
61
- MAX_CHARS = 60_000 # cap extracted text so the context block stays small
62
- _PAGE_BREAK = "\n\n--- PAGE BREAK ---\n\n"
63
-
64
- # Map extension -> parser kind
65
- IMAGE_EXTS = {"png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif"}
66
- TEXT_EXTS = {"txt", "md", "csv", "json", "jsonl", "log", "rtf"}
67
- _LO_TEXT_FORMATS = {".xls", ".doc", ".odt", ".ods", ".odp"}
68
-
69
-
70
- def _snippet(text: str, limit: int = 120) -> str:
71
- text = " ".join(text.split())
72
- return text[:limit] + ("…" if len(text) > limit else "")
73
-
74
-
75
- # --- Individual extractors -------------------------------------------
76
-
77
-
78
- def _extract_pdf(data: bytes) -> str:
79
- if PdfReader is None:
80
- raise RuntimeError("pypdf not installed")
81
- reader = PdfReader(io.BytesIO(data))
82
- pages = []
83
- for page in reader.pages:
84
- try:
85
- pages.append(page.extract_text() or "")
86
- except Exception:
87
- pages.append("")
88
- return _PAGE_BREAK.join(pages)
89
-
90
-
91
- def _extract_docx(data: bytes) -> str:
92
- if docx is None:
93
- raise RuntimeError("python-docx not installed")
94
- document = docx.Document(io.BytesIO(data))
95
- parts = []
96
-
97
- # Tables first (structure is usually important)
98
- for table in document.tables:
99
- for row in table.rows:
100
- cells = [c.text.strip() for c in row.cells]
101
- parts.append(" | ".join(cells))
102
- parts.append("")
103
-
104
- for para in document.paragraphs:
105
- t = para.text.strip()
106
- if t:
107
- parts.append(t)
108
- return "\n".join(parts)
109
-
110
-
111
- def _extract_xlsx(data: bytes) -> str:
112
- if openpyxl is None:
113
- raise RuntimeError("openpyxl not installed")
114
- wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
115
- parts = []
116
- for ws in wb.worksheets:
117
- parts.append(f"### Sheet: {ws.title}")
118
- for row in ws.iter_rows(values_only=True):
119
- vals = ["" if v is None else str(v) for v in row]
120
- line = " | ".join(vals).strip()
121
- if line:
122
- parts.append(line)
123
- parts.append("")
124
- return "\n".join(parts)
125
-
126
-
127
- def _extract_pptx(data: bytes) -> str:
128
- if pptx is None:
129
- raise RuntimeError("python-pptx not installed")
130
- prs = pptx.Presentation(io.BytesIO(data))
131
- parts = []
132
- for idx, slide in enumerate(prs.slides, start=1):
133
- parts.append(f"### Slide {idx}")
134
- for shape in slide.shapes:
135
- if hasattr(shape, "text") and shape.text and shape.text.strip():
136
- parts.append(shape.text.strip())
137
- if shape.has_table if hasattr(shape, "has_table") else False:
138
- for row in shape.table.rows:
139
- parts.append(" | ".join(c.text.strip() for c in row.cells))
140
- parts.append("")
141
- return "\n".join(parts)
142
-
143
-
144
- def _extract_image(data: bytes) -> str:
145
- """OCR an image. Returns combined text + a note about the image."""
146
- if Image is None:
147
- raise RuntimeError("Pillow not installed")
148
- tesseract = shutil.which("tesseract")
149
- if not tesseract:
150
- raise RuntimeError(
151
- "OCR unavailable: tesseract binary not found on this runtime. "
152
- "The image was still uploaded and attached by reference."
153
- )
154
- try:
155
- import pytesseract
156
- except Exception as exc: # pragma: no cover
157
- raise RuntimeError(f"OCR unavailable: pytesseract import failed: {exc}")
158
-
159
- image = Image.open(io.BytesIO(data))
160
- try:
161
- text = pytesseract.image_to_string(image)
162
- finally:
163
- try:
164
- image.close()
165
- except Exception:
166
- pass
167
- return text
168
-
169
-
170
- def _extract_rtf(data: bytes) -> str:
171
- """RTF is too messy to hand-parse well; route through LibreOffice if present."""
172
- return _lo_convert_to_text(data, ".rtf")
173
-
174
-
175
- def _extract_legacy_office(data: bytes, ext: str) -> str:
176
- """Legacy binary office formats (.xls, .doc) via LibreOffice headless."""
177
- return _lo_convert_to_text(data, ext)
178
-
179
-
180
- def _lo_convert_to_text(data: bytes, ext: str) -> str:
181
- """Use LibreOffice headless to convert a document to plain text."""
182
- soffice = shutil.which("libreoffice") or shutil.which("soffice")
183
- if not soffice:
184
- raise RuntimeError(
185
- "LibreOffice not installed on this runtime; cannot parse this "
186
- "format here. The file was still uploaded and attached by reference."
187
- )
188
- with tempfile.TemporaryDirectory() as tmp:
189
- in_path = os.path.join(tmp, f"input{ext}")
190
- out_dir = os.path.join(tmp, "out")
191
- os.makedirs(out_dir, exist_ok=True)
192
- with open(in_path, "wb") as f:
193
- f.write(data)
194
- result = subprocess.run(
195
- [soffice, "--headless", "--convert-to", "txt:Text (encoded):UTF8",
196
- "--outdir", out_dir, in_path],
197
- capture_output=True,
198
- timeout=120,
199
- )
200
- if result.returncode != 0:
201
- raise RuntimeError(
202
- f"LibreOffice failed to convert this file (rc={result.returncode})."
203
- )
204
- out_files = os.listdir(out_dir)
205
- if not out_files:
206
- raise RuntimeError("LibreOffice produced no output for this file.")
207
- with open(os.path.join(out_dir, out_files[0]), "r", encoding="utf-8", errors="ignore") as f:
208
- return f.read()
209
-
210
-
211
- # --- Public dispatcher -------------------------------------------------
212
-
213
-
214
- def extract_document_text(filename: str, data: bytes) -> tuple[str, str]:
215
- """Extract text from an uploaded document.
216
-
217
- Returns:
218
- (extracted_text, warning) — `warning` is a non-empty string when the
219
- extraction is a degraded fallback, otherwise empty.
220
- """
221
- ext = os.path.splitext(filename or "")[1].lower().lstrip(".")
222
- try:
223
- if ext == "pdf":
224
- text = _extract_pdf(data)
225
- elif ext in IMAGE_EXTS:
226
- text = _extract_image(data)
227
- elif ext in {"xlsx", "xlsm"}:
228
- text = _extract_xlsx(data)
229
- elif ext == "xls":
230
- text = _extract_legacy_office(data, ".xls")
231
- elif ext == "docx":
232
- text = _extract_docx(data)
233
- elif ext == "doc":
234
- text = _extract_legacy_office(data, ".doc")
235
- elif ext == "pptx":
236
- text = _extract_pptx(data)
237
- elif ext in {"odt", "ods", "odp"}:
238
- text = _extract_legacy_office(data, f".{ext}")
239
- elif ext == "rtf":
240
- text = _extract_rtf(data)
241
- elif ext in TEXT_EXTS:
242
- text = data.decode("utf-8", errors="replace")
243
- else:
244
- return "", f"Unsupported format '.{ext}' — file uploaded but no text extracted."
245
- except RuntimeError as e:
246
- return "", str(e)
247
- except Exception as e: # pragma: no cover
248
- return "", f"Failed to extract text from this file: {e}"
249
-
250
- cleaned = "\n".join(line.rstrip() for line in text.splitlines())
251
- cleaned = re.sub(r"\n{4,}", "\n\n\n", cleaned).strip()
252
-
253
- if ext in IMAGE_EXTS:
254
- note = (
255
- f"[The image is {len(data):,} bytes. The text below is OCR output. "
256
- "For layout/diagram questions, refer to the uploaded image file.]\n\n"
257
- )
258
- cleaned = note + cleaned
259
-
260
- if len(cleaned) > MAX_CHARS:
261
- cleaned = cleaned[:MAX_CHARS] + "\n\n... [TRUNCATED — file content exceeds context limit] ..."
262
-
263
- return cleaned, ""
264
-
265
-
266
- # --- Dispatch helper for the upload note -------------------------------
267
-
268
-
269
- def format_uploaded_document_context(
270
- *,
271
- filename: str,
272
- stored_filename: str,
273
- repo_id: str,
274
- path_in_repo: str,
275
- hub_url: str,
276
- size_bytes: int,
277
- file_format: str,
278
- extracted_text: str,
279
- warning: str,
280
- ) -> str:
281
- """Build the SYSTEM context note injected into the session for the AI."""
282
- lines = [
283
- "[SYSTEM: The user uploaded a document/file for this session.",
284
- "",
285
- "A text extraction was performed below so you can understand the file's",
286
- "content directly. If the text is truncated, empty, or you need the",
287
- "original bytes/format, the original file is preserved on the Hub:",
288
- "",
289
- f"- Repo ID: {repo_id}",
290
- f"- Repo type: dataset",
291
- f"- File in repo: {path_in_repo}",
292
- f"- Original filename: {filename}",
293
- f"- Stored filename: {stored_filename}",
294
- f"- Format: {file_format}",
295
- f"- Size: {size_bytes} bytes",
296
- f"- Hub URL: {hub_url}",
297
- "",
298
- ]
299
- if warning:
300
- lines.extend(
301
- [
302
- "WARNING (extraction):",
303
- warning,
304
- "",
305
- "You can still try to load/read the original file from the Hub URL",
306
- "above, but understand the plain-text extraction did not succeed.",
307
- "",
308
- ]
309
- )
310
- lines.extend(
311
- [
312
- "Extracted text content:",
313
- "```",
314
- extracted_text if extracted_text else "(no text could be extracted)",
315
- "```",
316
- "]",
317
- ]
318
- )
319
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/session_logger.py DELETED
@@ -1,467 +0,0 @@
1
- """
2
- Permanent session & user context logger.
3
-
4
- Lưu vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace ngay khi
5
- có message/event mới — fire-and-forget, robust, không bao giờ block agent.
6
-
7
- Lưu vào:
8
- - ``bep40/ml-intern-sessions`` : dataset chung, lưu tất cả session
9
- - ``{hf_user}/ml-intern-sessions``: dataset cá nhân mỗi user
10
-
11
- Mỗi message được lưu ngay lập tức qua subprocess (detached) để tránh blocking.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import asyncio
17
- import json
18
- import logging
19
- import os
20
- import subprocess
21
- import sys
22
- import time
23
- from datetime import datetime
24
- from pathlib import Path
25
- from typing import Optional
26
-
27
- from huggingface_hub import HfApi, hf_hub_download
28
-
29
- logger = logging.getLogger(__name__)
30
-
31
- PUBLIC_SESSIONS_DATASET = os.environ.get(
32
- "ML_INTERN_SESSIONS_DATASET", "bep40/ml-intern-sessions"
33
- )
34
-
35
- DEFAULT_LOG_DIR = Path(os.environ.get("ML_INTERN_SESSION_LOG_DIR", "/tmp/session_logs"))
36
-
37
-
38
- def _now_iso() -> str:
39
- return datetime.now().astimezone().isoformat()
40
-
41
-
42
- def _get_hf_token() -> Optional[str]:
43
- return (
44
- os.environ.get("HF_TOKEN")
45
- or os.environ.get("_ML_INTERN_PERSONAL_TOKEN")
46
- or os.environ.get("HF_SESSION_UPLOAD_TOKEN")
47
- or os.environ.get("HF_ADMIN_TOKEN")
48
- )
49
-
50
-
51
- def _dataset_card_readme() -> str:
52
- return """---
53
- pretty_name: "ML Intern Session Context"
54
- language:
55
- - en
56
- license: other
57
- task_categories:
58
- - text-generation
59
- tags:
60
- - ml-intern
61
- - session-traces
62
- - conversation
63
- - agent-traces
64
- configs:
65
- - config_name: default
66
- data_files:
67
- - split: train
68
- path: "sessions/**/*.jsonl"
69
- ---
70
-
71
- # ML Intern Session Context
72
-
73
- **Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user.**
74
-
75
- 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.
76
-
77
- ## Schema
78
-
79
- | Column | Type | Mô tả |
80
- |----------------|--------|------------------------------------------|
81
- | session_id | string | UUID của session |
82
- | user_id | string | ID người dùng |
83
- | model_name | string | Model đang dùng |
84
- | timestamp | string | ISO timestamp |
85
- | role | string | user / assistant / tool / system / event |
86
- | content | string | Nội dung message |
87
- | event_type | string | Loại event |
88
- | seq | int | Số thứ tự trong session |
89
- | extra | string | JSON thông tin bổ sung |
90
- """
91
-
92
-
93
- def _append_to_local_log(
94
- session_id: str,
95
- user_id: str,
96
- model_name: str,
97
- role: str,
98
- content: str,
99
- event_type: str,
100
- seq: int,
101
- extra: dict | None = None,
102
- directory: str = str(DEFAULT_LOG_DIR),
103
- ) -> Optional[str]:
104
- try:
105
- log_dir = Path(directory)
106
- log_dir.mkdir(parents=True, exist_ok=True)
107
- row = {
108
- "session_id": session_id,
109
- "user_id": user_id,
110
- "model_name": model_name,
111
- "timestamp": _now_iso(),
112
- "role": role,
113
- "content": content,
114
- "event_type": event_type,
115
- "seq": seq,
116
- "extra": json.dumps(extra) if extra else "{}",
117
- }
118
- date_str = datetime.now().strftime("%Y-%m-%d")
119
- filename = f"session_{session_id}_{date_str}.jsonl"
120
- filepath = log_dir / filename
121
- with open(filepath, "a") as f:
122
- f.write(json.dumps(row, default=str) + "\n")
123
- return str(filepath)
124
- except Exception as e:
125
- logger.debug("Local log write failed: %s", e)
126
- return None
127
-
128
-
129
- # ─── Dataset reader: per-file scan (never loads 410MB into memory) ──────────
130
-
131
- _file_list_cache: list[str] | None = None
132
- _file_list_cache_time: float = 0
133
-
134
-
135
- def _list_dataset_files() -> list[str]:
136
- """Return all .jsonl file paths in the sessions dataset (cached, 60s TTL)."""
137
- global _file_list_cache, _file_list_cache_time
138
- now = time.time()
139
- if _file_list_cache is not None and (now - _file_list_cache_time) < 60:
140
- return _file_list_cache
141
- try:
142
- api = HfApi(token=_get_hf_token())
143
- files = api.list_repo_files(PUBLIC_SESSIONS_DATASET, repo_type="dataset")
144
- _file_list_cache = [f for f in files if f.endswith(".jsonl")]
145
- _file_list_cache_time = now
146
- except Exception as e:
147
- logger.warning("Failed to list dataset files: %s", e)
148
- _file_list_cache = []
149
- _file_list_cache_time = now
150
- return _file_list_cache
151
-
152
-
153
- def _normalize_row(row: dict) -> dict | None:
154
- """Normalize a row to the standard schema regardless of source format.
155
-
156
- The dataset contains two incompatible formats:
157
- - *New* (session_logger.py): session_id, user_id, role, content, event_type, seq
158
- - *Old* (Claude Code trace format): type, message, uuid, parentUuid, sessionId, timestamp
159
- """
160
- if not isinstance(row, dict):
161
- return None
162
-
163
- # Already in new format — has session_id + role
164
- if "session_id" in row and "role" in row:
165
- return row
166
-
167
- # Old Claude Code format: type + message
168
- if "type" in row and "message" in row:
169
- session_id = row.get("sessionId") or row.get("session_id") or ""
170
- if not session_id:
171
- return None
172
- msg = row.get("message")
173
- role = row.get("type", "unknown")
174
- content_text = ""
175
- model_name = ""
176
- if isinstance(msg, dict):
177
- role = msg.get("role", role)
178
- content_val = msg.get("content", "")
179
- model_name = msg.get("model", "")
180
- if isinstance(content_val, str):
181
- content_text = content_val
182
- elif isinstance(content_val, list):
183
- parts = []
184
- for block in content_val:
185
- if isinstance(block, dict):
186
- t = block.get("text") or block.get("content")
187
- if isinstance(t, str):
188
- parts.append(t)
189
- elif isinstance(block, str):
190
- parts.append(block)
191
- content_text = " ".join(parts)
192
- elif content_val is None:
193
- content_text = ""
194
- return {
195
- "session_id": session_id,
196
- "user_id": "",
197
- "model_name": model_name,
198
- "timestamp": row.get("timestamp", ""),
199
- "role": role,
200
- "content": content_text,
201
- "event_type": row.get("type", "message"),
202
- "seq": 0,
203
- "extra": "{}",
204
- }
205
-
206
- return None
207
-
208
-
209
- def _read_jsonl_file(file_path: str) -> list[dict]:
210
- """Download a single JSONL file from the dataset and parse its lines.
211
-
212
- Robust to: empty files, malformed JSON lines, old/new format mismatch.
213
- """
214
- try:
215
- local = hf_hub_download(
216
- PUBLIC_SESSIONS_DATASET,
217
- file_path,
218
- repo_type="dataset",
219
- token=_get_hf_token(),
220
- )
221
- rows: list[dict] = []
222
- with open(local, encoding="utf-8", errors="replace") as fh:
223
- for line in fh:
224
- line = line.strip()
225
- if not line:
226
- continue
227
- try:
228
- row = json.loads(line)
229
- normalized = _normalize_row(row)
230
- if normalized is not None:
231
- rows.append(normalized)
232
- except json.JSONDecodeError:
233
- continue
234
- return rows
235
- except Exception as e:
236
- logger.debug("Failed to read %s: %s", file_path, e)
237
- return []
238
-
239
-
240
- def load_session_history(session_id: str) -> list[dict]:
241
- """Load all messages/events for a session from the dataset.
242
-
243
- Reads only the files whose name contains ``session_id`` instead of
244
- loading the entire 400MB dataset into memory on every call.
245
- """
246
- try:
247
- all_files = _list_dataset_files()
248
- matching = [
249
- f for f in all_files if session_id in os.path.basename(f)
250
- ]
251
- rows: list[dict] = []
252
- for f in matching:
253
- for row in _read_jsonl_file(f):
254
- if row.get("session_id") == session_id:
255
- rows.append(row)
256
- rows.sort(key=lambda r: (r.get("seq", 0), r.get("timestamp", "")))
257
- return rows
258
- except Exception as e:
259
- import traceback
260
- logger.warning(
261
- "Failed to load session history: %s\n%s",
262
- e,
263
- traceback.format_exc(),
264
- )
265
- return []
266
-
267
-
268
- def get_user_sessions(user_id: str, limit: int = 50) -> list[dict]:
269
- """Get all sessions for a user from the dataset.
270
-
271
- Uses per-file scanning exclusively (never calls load_dataset which can
272
- fail when the dataset has incompatible/mixed schemas). Cached file list
273
- (60s TTL) avoids repeated list_repo_files calls.
274
- """
275
- sessions: dict[str, dict] = {}
276
- for f in _list_dataset_files():
277
- for row in _read_jsonl_file(f):
278
- if row.get("user_id") == user_id:
279
- sid = row.get("session_id")
280
- if sid and sid not in sessions:
281
- sessions[sid] = {
282
- "session_id": sid,
283
- "user_id": user_id,
284
- "model_name": row.get("model_name"),
285
- "last_timestamp": row.get("timestamp"),
286
- "message_count": 0,
287
- }
288
- sessions[sid]["message_count"] += 1
289
-
290
- result = list(sessions.values())
291
- result.sort(key=lambda s: s.get("last_timestamp", ""), reverse=True)
292
- return result[:limit]
293
-
294
-
295
- # ─── Upload/subprocess functions (unchanged from original) ──────────────────
296
-
297
- def _spawn_uploader(
298
- row_data: dict,
299
- repo_id: str,
300
- token: str,
301
- private: bool = False,
302
- ) -> None:
303
- """Spawn detached subprocess to upload a single row to HF dataset."""
304
- try:
305
- script = f"""
306
- import json, sys, time, os
307
- from huggingface_hub import HfApi
308
-
309
- row_data = {json.dumps(row_data)}
310
- repo_id = "{repo_id}"
311
- token = "{token}"
312
- private = {private}
313
-
314
- api = HfApi()
315
- try:
316
- api.create_repo(repo_id=repo_id, repo_type="dataset", private=private, token=token, exist_ok=True)
317
- except: pass
318
-
319
- # Upload README
320
- try:
321
- 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)
322
- except: pass
323
-
324
- date_str = row_data.get("timestamp", "")[:10]
325
- session_id = row_data.get("session_id", "unknown")
326
- seq = row_data.get("seq", 0)
327
- path_in_repo = f"sessions/{{date_str}}/{{session_id}}_{{seq}}.jsonl"
328
- payload = json.dumps(row_data)
329
-
330
- for attempt in range(5):
331
- try:
332
- 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}}")
333
- break
334
- except Exception as e:
335
- if attempt < 4:
336
- time.sleep(2 ** attempt)
337
- else:
338
- sys.exit(1)
339
- """
340
- subprocess.Popen(
341
- [sys.executable, "-c", script],
342
- stdin=subprocess.DEVNULL,
343
- stdout=subprocess.DEVNULL,
344
- stderr=subprocess.DEVNULL,
345
- env=os.environ.copy(),
346
- start_new_session=True,
347
- )
348
- except Exception as e:
349
- logger.debug("Upload spawn failed: %s", e)
350
-
351
-
352
- def log_message(
353
- session_id: str,
354
- user_id: str,
355
- model_name: str,
356
- role: str,
357
- content: str,
358
- event_type: str = "message",
359
- seq: int = 0,
360
- extra: dict | None = None,
361
- hf_user: str | None = None,
362
- ) -> None:
363
- """Log a single message/event permanently to dataset.
364
-
365
- Fire-and-forget — all I/O is offloaded to a detached subprocess.
366
- """
367
- token = _get_hf_token()
368
-
369
- # Always write to local log first (backup)
370
- _append_to_local_log(
371
- session_id=session_id,
372
- user_id=user_id,
373
- model_name=model_name,
374
- role=role,
375
- content=content,
376
- event_type=event_type,
377
- seq=seq,
378
- extra=extra,
379
- )
380
-
381
- if not token:
382
- logger.debug("No HF token, skipping dataset upload")
383
- return
384
-
385
- row_data = {
386
- "session_id": session_id,
387
- "user_id": user_id,
388
- "model_name": model_name,
389
- "timestamp": _now_iso(),
390
- "role": role,
391
- "content": content,
392
- "event_type": event_type,
393
- "seq": seq,
394
- "extra": json.dumps(extra) if extra else "{}",
395
- }
396
-
397
- # Upload to public/shared dataset
398
- _spawn_uploader(row_data, PUBLIC_SESSIONS_DATASET, token, private=False)
399
-
400
- # Upload to personal dataset if available
401
- if hf_user and hf_user != "dev":
402
- _spawn_uploader(row_data, f"{hf_user}/ml-intern-sessions", token, private=True)
403
-
404
-
405
- def log_trajectory(
406
- trajectory: dict,
407
- user_id: str,
408
- hf_user: str | None = None,
409
- hf_token: str | None = None,
410
- private: bool = False,
411
- ) -> Optional[str]:
412
- """Upload complete session trajectory to dataset (final flush)."""
413
- session_id = trajectory.get("session_id", "unknown")
414
- model_name = trajectory.get("model_name", "")
415
- messages = trajectory.get("messages", [])
416
-
417
- log_dir = Path(str(DEFAULT_LOG_DIR))
418
- log_dir.mkdir(parents=True, exist_ok=True)
419
- date_str = datetime.now().strftime("%Y-%m-%d")
420
- filepath = log_dir / f"session_{session_id}_{date_str}.jsonl"
421
-
422
- with open(filepath, "w") as f:
423
- for msg in messages:
424
- row = {
425
- "session_id": session_id,
426
- "user_id": user_id,
427
- "model_name": model_name,
428
- "timestamp": msg.get("timestamp") or _now_iso(),
429
- "role": msg.get("role", "unknown"),
430
- "content": msg.get("content", ""),
431
- "event_type": "message",
432
- "seq": msg.get("seq", 0),
433
- "extra": json.dumps({
434
- "tool_calls": msg.get("tool_calls"),
435
- "tool_call_id": msg.get("tool_call_id"),
436
- }) if any(k in msg for k in ("tool_calls", "tool_call_id")) else "{}",
437
- }
438
- f.write(json.dumps(row, default=str) + "\n")
439
-
440
- events = trajectory.get("events", [])
441
- for event in events:
442
- row = {
443
- "session_id": session_id,
444
- "user_id": user_id,
445
- "model_name": model_name,
446
- "timestamp": event.get("timestamp", _now_iso()),
447
- "role": "event",
448
- "content": json.dumps(event.get("data", {})),
449
- "event_type": event.get("event_type", "unknown"),
450
- "seq": event.get("seq", 0),
451
- "extra": "{}",
452
- }
453
- f.write(json.dumps(row, default=str) + "\n")
454
-
455
- token = hf_token or _get_hf_token()
456
- if token:
457
- with open(filepath, "r") as f:
458
- for line in f:
459
- try:
460
- row = json.loads(line.strip())
461
- _spawn_uploader(row, PUBLIC_SESSIONS_DATASET, token, private)
462
- if hf_user and hf_user != "dev":
463
- _spawn_uploader(row, f"{hf_user}/ml-intern-sessions", token, True)
464
- except Exception:
465
- continue
466
-
467
- return str(filepath)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
configs/frontend_agent_config.json CHANGED
@@ -7,13 +7,10 @@
7
  "yolo_mode": false,
8
  "confirm_cpu_jobs": true,
9
  "auto_file_upload": true,
10
- "auto_save_interval": 1,
11
- "heartbeat_interval_s": 30,
12
- "max_iterations": 300,
13
  "mcpServers": {
14
  "hf-mcp-server": {
15
  "transport": "http",
16
  "url": "https://openrouter.ai/mcp"
17
  }
18
  }
19
- }
 
7
  "yolo_mode": false,
8
  "confirm_cpu_jobs": true,
9
  "auto_file_upload": true,
 
 
 
10
  "mcpServers": {
11
  "hf-mcp-server": {
12
  "transport": "http",
13
  "url": "https://openrouter.ai/mcp"
14
  }
15
  }
16
+ }
fix_space_error_report.json CHANGED
@@ -1,14 +1,9 @@
1
  {
2
- "status": "rebuilding_with_deepseek_flash_fix",
3
  "space": "bep40/ml-intern",
4
  "space_url": "https://huggingface.co/spaces/bep40/ml-intern",
5
- "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.",
6
- "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.",
7
- "files_changed": ["patch_models.py"],
8
- "model_routing": {
9
- "deepseek-ai/DeepSeek-V4-Flash": "HF Inference Router (3 providers: novita, fireworks-ai, deepinfra) - all support tools, 1M context",
10
- "deepseek-ai/DeepSeek-V4-Flash-0731": "HF Inference Router (5 providers) - all support tools, 1M context",
11
- "deepseek/deepseek-v4-flash-0731": "OpenRouter - available via openrouter/deepseek/deepseek-v4-flash-0731"
12
- },
13
- "verification": "Space rebuild triggered. Monitor Space build logs. Test model selection for DeepSeek V4 Flash after rebuild completes."
14
  }
 
1
  {
2
+ "status": "fixed_uploaded",
3
  "space": "bep40/ml-intern",
4
  "space_url": "https://huggingface.co/spaces/bep40/ml-intern",
5
+ "root_cause": "Dockerfile runtime stage used CMD [\"bash\", \"start.sh\"] on python:3.12-slim, which does not include bash by default. This causes container startup failure and Space error after a successful build.",
6
+ "fix": "Changed Dockerfile CMD to [\"sh\", \"start.sh\"] and added ca-certificates to apt packages. start.sh is POSIX-compatible for this use.",
7
+ "files_changed": ["Dockerfile", "README.md"],
8
+ "verification": "Repo files updated and rebuild triggered. Live runtime verification via HF Jobs/sandbox was blocked by namespace credit/duplication limits in this session; check Space build logs if HF still reports error."
 
 
 
 
 
9
  }
patch_agent_backend.py DELETED
@@ -1,87 +0,0 @@
1
- """Patch: agent_loop.py task detection + auto-approve for free models."""
2
-
3
- import os
4
- import re
5
- import ast
6
-
7
- AGENT_LOOP = "/app/agent/core/agent_loop.py"
8
- AGENT_FILE = "/app/backend/routes/agent.py"
9
- SESSION_FILE = "/app/agent/core/session.py"
10
-
11
- # ===== PATCH 1: agent_loop.py - detect model stopped mid-task =====
12
- with open(AGENT_LOOP) as f:
13
- c = f.read()
14
-
15
- # Add helper function before class Handlers
16
- if "_unfinished_plan" not in c:
17
- fn = (
18
- "\n\ndef _unfinished_plan(s):\n"
19
- " p = getattr(s, 'current_plan', None) or []\n"
20
- " return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n\n"
21
- )
22
- c = c.replace("class Handlers:", fn + "class Handlers:")
23
- print("OK: Added _unfinished_plan()")
24
-
25
- # Add detection: if model produced no tool calls but content still exists,
26
- # and plan items are still pending/in_progress, send session_update event
27
- if "ac_plan" not in c:
28
- check = (
29
- "\n # Auto-continue: detect model stopped mid-task\n"
30
- " if not llm_result.tool_calls_acc and llm_result.content:\n"
31
- " unfinished = _unfinished_plan(session)\n"
32
- " if unfinished:\n"
33
- " await session.send_event(Event(event_type='session_update', data={\n"
34
- " 'ac_plan': unfinished,\n"
35
- " }))\n"
36
- )
37
- c = c.replace(" # -- End of turn --", check + " # -- End of turn --")
38
- print("OK: Added mid-task detection")
39
-
40
- try:
41
- ast.parse(c)
42
- with open(AGENT_LOOP, "w") as f:
43
- f.write(c)
44
- print("OK: agent_loop.py patched")
45
- except SyntaxError as e:
46
- print(f"FAIL: agent_loop.py syntax: {e}")
47
-
48
- # ===== PATCH 2: Enable auto_approval for ALL sessions by default =====
49
- # So free models don't block on user approval.
50
- with open(SESSION_FILE) as f:
51
- c = f.read()
52
-
53
- if "AUTO_APPROVE" not in c and "self.auto_approval" not in c:
54
- auto_patch = (
55
- "\n # === PATCH: Auto-approve for free models ===\n"
56
- " self.auto_approval = {\n"
57
- ' "enabled": True,\n'
58
- ' "cost_cap_usd": None,\n'
59
- ' "estimated_spend_usd": 0.0,\n'
60
- ' "remaining_usd": None,\n'
61
- " }\n"
62
- )
63
-
64
- # Insert as the FIRST STATEMENT of __init__ body: find the closing
65
- # " ):" that terminates the def __init__(...) parameter list and
66
- # insert right after it. (Inserting into the parameter list corrupts
67
- # the file with invalid syntax.)
68
- init_match = re.search(r"def __init__\(.*?\):\n", c, re.DOTALL)
69
- if init_match:
70
- insert_at = init_match.end()
71
- c = c[:insert_at] + auto_patch + c[insert_at:]
72
- print("OK: Added auto_approve default")
73
- else:
74
- print("WARN: could not locate __init__ signature in session.py")
75
-
76
- try:
77
- ast.parse(c)
78
- with open(SESSION_FILE, "w") as f:
79
- f.write(c)
80
- print("OK: session.py patched & syntactically valid")
81
- except SyntaxError as e:
82
- print(f"FAIL: session.py syntax: {e}")
83
- # Don't block on this patch
84
- else:
85
- print("OK: session.py already has auto_approval")
86
-
87
- print("DONE: Backend patches applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_apodex_ling_beatapi.py DELETED
@@ -1,145 +0,0 @@
1
- """Patch backend: add apodex-1.1-mini:free, ling-3.1-flash (OpenRouter) + BeatAPI space-bunny-alpha.
2
-
3
- Two OpenRouter models (same `openai/` catch-all routing as the rest of the app):
4
- - openai/apodex/apodex-1.1-mini:free
5
- - openai/inclusionai/ling-3.1-flash
6
-
7
- One BEATAPI model routed to a *different* provider entirely:
8
- - beatapi/space-bunny-alpha
9
-
10
- The BeatAPI id deliberately uses a `beatapi/` prefix (NOT `openai/`) so it can
11
- never be captured by the OpenRouter catch-all branch in _resolve_llm_params.
12
- See patch_beatapi_routing.py for the dedicated routing branch.
13
-
14
- This runs AFTER patch_models.py / patch_new_models.py, which already injected
15
- the `openai/`-prefixed constants and the LING_3_0_FLASH_FREE_MODEL_ID anchor.
16
- Idempotent: re-running is a no-op.
17
- """
18
-
19
- import ast
20
- import re
21
-
22
- IDS_FILE = "/app/agent/core/model_ids.py"
23
- AGENT_FILE = "/app/backend/routes/agent.py"
24
-
25
- # ---------- 1. model_ids.py ----------
26
- with open(IDS_FILE) as f:
27
- ids = f.read()
28
-
29
- new_constants = (
30
- 'APODEX_1_1_MINI_FREE_MODEL_ID = "openai/apodex/apodex-1.1-mini:free"\n'
31
- 'LING_3_1_FLASH_MODEL_ID = "openai/inclusionai/ling-3.1-flash"\n'
32
- '# NOTE: BeatAPI model. NOT an OpenRouter id: `beatapi/` routes to\n'
33
- '# https://api.beatapi.io/v1 and is authenticated with BEATAPI_API_KEY\n'
34
- '# (see patch_beatapi_routing.py). Keep this prefix exclusive to BeatAPI.\n'
35
- 'BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID = "beatapi/space-bunny-alpha"\n'
36
- )
37
-
38
- missing = [
39
- name
40
- for name in (
41
- "APODEX_1_1_MINI_FREE_MODEL_ID",
42
- "LING_3_1_FLASH_MODEL_ID",
43
- "BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID",
44
- )
45
- if name not in ids
46
- ]
47
-
48
- if not missing:
49
- print("OK: model_ids.py already has all 3 new constants (idempotent)")
50
- else:
51
- # Anchor: the LING_3_0_FLASH line injected by patch_new_models.py.
52
- # Fall back to a generic OpenRouter constant line from patch_models.py.
53
- anchors = [
54
- 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"',
55
- 'NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"',
56
- ]
57
- inserted = False
58
- for anchor in anchors:
59
- if anchor in ids:
60
- ids = ids.replace(anchor, anchor + "\n" + new_constants, 1)
61
- print(f"OK: added constants to model_ids.py after {anchor.split(' =')[0]}")
62
- inserted = True
63
- break
64
- if not inserted:
65
- # Last resort: append right after the HOSTED_MODEL_IDS set closes, which
66
- # is a stable upstream anchor present in every revision.
67
- m = re.search(r"HOSTED_MODEL_IDS\s*=\s*\{[^}]*\}\n", ids)
68
- if m:
69
- ids = ids[: m.end()] + "\n" + new_constants + ids[m.end() :]
70
- print("OK: added constants to model_ids.py after HOSTED_MODEL_IDS")
71
- else:
72
- print("FAIL: no anchor found in model_ids.py")
73
- raise SystemExit(1)
74
-
75
- with open(IDS_FILE, "w") as f:
76
- f.write(ids)
77
- ast.parse(ids)
78
- print("OK: model_ids.py written & syntax OK")
79
-
80
- # ---------- 2. agent.py : imports ----------
81
- with open(AGENT_FILE) as f:
82
- ac = f.read()
83
-
84
- if "APODEX_1_1_MINI_FREE_MODEL_ID" not in ac:
85
- # patch_models.py rewrote the import block to list every OpenRouter constant.
86
- imp_anchor = " LING_3_0_FLASH_FREE_MODEL_ID,\n"
87
- if imp_anchor in ac:
88
- ac = ac.replace(
89
- imp_anchor,
90
- imp_anchor
91
- + " APODEX_1_1_MINI_FREE_MODEL_ID,\n"
92
- + " LING_3_1_FLASH_MODEL_ID,\n"
93
- + " BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID,\n",
94
- 1,
95
- )
96
- print("OK: added 3 model imports to agent.py")
97
- else:
98
- print("FAIL: import anchor not found in agent.py")
99
- raise SystemExit(1)
100
-
101
- # ---------- 3. agent.py : _available_models ----------
102
- line_anchor = (
103
- ' {"id": SPACE_BUNNY_ALPHA_MODEL_ID, "label": "stealth/space-bunny-alpha", "recommended": True},\n'
104
- )
105
- new_lines = line_anchor + (
106
- ' # OpenRouter models (openai/ prefix -> OpenRouter)\n'
107
- ' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n'
108
- ' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n'
109
- ' # BeatAPI model (beatapi/ prefix -> https://api.beatapi.io/v1)\n'
110
- ' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n'
111
- )
112
-
113
- if line_anchor in ac:
114
- ac = ac.replace(line_anchor, new_lines, 1)
115
- print("OK: added 3 new models to _available_models()")
116
- else:
117
- # The _available_models body was fully rewritten by patch_models.py; match
118
- # on the generic recommended-Ling line so this stays robust.
119
- alt = (
120
- ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": '
121
- '"inclusionai/ling-3.0-flash:free", "recommended": True},\n'
122
- )
123
- if alt in ac:
124
- ac = ac.replace(
125
- alt,
126
- alt
127
- + ' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n'
128
- + ' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n'
129
- + ' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n',
130
- 1,
131
- )
132
- print("OK: added 3 new models to _available_models() (fallback anchor)")
133
- else:
134
- print("FAIL: _available_models() anchor not found in agent.py")
135
- raise SystemExit(1)
136
-
137
- with open(AGENT_FILE, "w") as f:
138
- f.write(ac)
139
- ast.parse(ac)
140
- print("OK: agent.py written & syntax OK")
141
-
142
- else:
143
- print("OK: agent.py already patched (idempotent)")
144
-
145
- print("DONE: apodex-1.1-mini:free, ling-3.1-flash, BeatAPI space-bunny-alpha registered")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v10.py DELETED
@@ -1,202 +0,0 @@
1
- """V10: Fixes scope issue with chat.sendMessage.
2
-
3
- Uses: ref to store plan, useEffect + chatActionsRef to send."""
4
- import os, re
5
-
6
- events_ts = "/source/frontend/src/types/events.ts"
7
- sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
8
- hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
9
- input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
10
- session_tsx = "/source/frontend/src/components/SessionChat.tsx"
11
- agent_py = "/app/agent/core/agent_loop.py"
12
-
13
- def patch_events():
14
- with open(events_ts) as f: c = f.read()
15
- if "'task_incomplete'" not in c:
16
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
17
- with open(events_ts, 'w') as f: f.write(c)
18
- print("OK: events.ts")
19
-
20
- def patch_sse():
21
- with open(sse_ts) as f: c = f.read()
22
- if "case 'task_incomplete'" not in c:
23
- case_block = """ case 'task_incomplete':
24
- (sideChannel as any).onTaskIncomplete(
25
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
26
- );
27
- break;
28
-
29
- default:"""
30
- c = c.replace("\n default:", "\n" + case_block)
31
- with open(sse_ts, 'w') as f: f.write(c)
32
- print("OK: sse.ts")
33
-
34
- def patch_hook():
35
- with open(hook_ts) as f:
36
- lines = f.readlines()
37
-
38
- # Find key insertion points by line number
39
- result = []
40
- state_added = False
41
- handler_added = False
42
- effect_added = False
43
- cancel_added = False
44
- return_added = False
45
-
46
- for i, line in enumerate(lines):
47
- # 1. Add useState import
48
- if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line and "useState" not in line:
49
- line = line.replace("useRef }", "useRef, useState }")
50
-
51
- # 2. Add state after callbacksRef
52
- if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_added:
53
- result.append(line)
54
- result.append(' // Auto-continue for free models\n')
55
- result.append(' const [_showAc, _setShowAc] = useState(false);\n')
56
- result.append(' const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n')
57
- state_added = True
58
- continue
59
-
60
- # 3. Add handler in sideChannel's onSessionUpdate
61
- if "onSessionUpdate: (data) => {" in line and not handler_added:
62
- result.append(line)
63
- result.append(' // Auto-continue: check for ac_plan\n')
64
- result.append(' if ((data as any).ac_plan) {\n')
65
- result.append(' _acRef.current = (data as any).ac_plan;\n')
66
- result.append(' _setShowAc(true);\n')
67
- result.append(' return;\n')
68
- result.append(' }\n')
69
- handler_added = True
70
- continue
71
-
72
- # 4. Add cancel function + effect before return
73
- stripped = line.strip()
74
- if stripped == "return {" and not cancel_added:
75
- result.append(' // Auto-continue cancel\n')
76
- result.append(' const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n')
77
- cancel_added = True
78
- # Add effect that uses chatActionsRef to send message
79
- result.append('\n')
80
- result.append(' // Auto-continue effect: 10s timer, sends via chatActionsRef\n')
81
- result.append(' useEffect(() => {\n')
82
- result.append(' if (!_showAc) return;\n')
83
- result.append(' const plan = _acRef.current;\n')
84
- result.append(' const timer = setTimeout(() => {\n')
85
- result.append(' const s = chatActionsRef.current.setMessages;\n')
86
- result.append(' const m = chatActionsRef.current.messages;\n')
87
- result.append(' if (s && plan.length > 0) {\n')
88
- result.append(" const t = plan.map(i => `- ${i.content}`).join('\\\\n');\n")
89
- 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")
90
- result.append(' }\n')
91
- result.append(' _setShowAc(false);\n')
92
- result.append(' }, 10000);\n')
93
- result.append(' return () => clearTimeout(timer);\n')
94
- result.append(' }, [_showAc]);\n')
95
- effect_added = True
96
-
97
- # 5. Return values
98
- if "refreshMessages," in line and not return_added:
99
- result.append(line)
100
- result.append(' _showAc,\n')
101
- result.append(' _acCancel,\n')
102
- return_added = True
103
- continue
104
-
105
- result.append(line)
106
-
107
- with open(hook_ts, 'w') as f: f.writelines(result)
108
- print(f"OK: useAgentChat.ts (added={state_added}/{handler_added}/{cancel_added}/{effect_added}/{return_added})")
109
-
110
- def patch_chat_input():
111
- with open(input_tsx) as f: c = f.read()
112
- if "Button" not in c:
113
- c = c.replace(" Tooltip,", " Tooltip,\n Button,")
114
-
115
- if "_showAc" not in c:
116
- old_iface = """interface ChatInputProps {
117
- sessionId?: string;
118
- initialModelPath?: string | null;
119
- onSend: (text: string) => void;
120
- onStop?: () => void;
121
- onDatasetUploaded?: () => Promise<boolean> | boolean;
122
- isProcessing?: boolean;
123
- disabled?: boolean;
124
- placeholder?: string;
125
- }"""
126
- new_iface = """interface ChatInputProps {
127
- sessionId?: string;
128
- initialModelPath?: string | null;
129
- onSend: (text: string) => void;
130
- onStop?: () => void;
131
- onDatasetUploaded?: () => Promise<boolean> | boolean;
132
- isProcessing?: boolean;
133
- disabled?: boolean;
134
- placeholder?: string;
135
- _showAc?: boolean;
136
- _acCancel?: () => void;
137
- }"""
138
- c = c.replace(old_iface, new_iface)
139
- old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
140
- new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
141
- c = c.replace(old_props, new_props)
142
-
143
- if "T\u1ea1m d\u1eebng" not in c:
144
- btn = """
145
- {_showAc && _acCancel && (
146
- <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
147
- <Button variant="outlined" size="small" onClick={_acCancel}
148
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
149
- T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
150
- </Button>
151
- </Box>
152
- )}
153
- """
154
- if "<JobsUpgradeDialog" in c:
155
- c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
156
-
157
- with open(input_tsx, 'w') as f: f.write(c)
158
- print("OK: ChatInput.tsx")
159
-
160
- def patch_session():
161
- if not os.path.exists(session_tsx):
162
- print(f"SKIP: {session_tsx}"); return
163
- with open(session_tsx) as f: c = f.read()
164
- if "_showAc" not in c:
165
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
166
- with open(session_tsx, 'w') as f: f.write(c)
167
- print("OK: SessionChat.tsx")
168
-
169
- def patch_agent():
170
- if not os.path.exists(agent_py):
171
- print(f"SKIP: {agent_py}"); return
172
- with open(agent_py) as f: c = f.read()
173
-
174
- if "_unfinished_plan" not in c:
175
- fn = """
176
- def _unfinished_plan(s: Session) -> list[dict[str, str]]:
177
- p = getattr(s, "current_plan", None) or []
178
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
179
- """
180
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
181
-
182
- if "ac_plan" not in c:
183
- check = """
184
- if not llm_result.tool_calls_acc and llm_result.content:
185
- unfinished = _unfinished_plan(session)
186
- if unfinished:
187
- current_ac = getattr(session, "auto_approval", None)
188
- await session.send_event(Event(event_type="session_update", data={
189
- "ac_plan": unfinished,
190
- "auto_approval": current_ac,
191
- }))
192
- """
193
- marker = " # -- End of turn --"
194
- if marker in c:
195
- c = c.replace(marker, check + "\n" + marker)
196
-
197
- with open(agent_py, 'w') as f: f.write(c)
198
- print("OK: agent_loop.py")
199
-
200
- if __name__ == "__main__":
201
- patch_events(); patch_sse(); patch_hook(); patch_chat_input(); patch_session(); patch_agent()
202
- print("\nDONE: V10")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v11.py DELETED
@@ -1,185 +0,0 @@
1
- """V11: Auto-continue feature - clean single-file patch.
2
-
3
- Backend: agent_loop.py detects model stopped mid-task, sends event.
4
- Frontend: useAgentChat listens via onSessionUpdate, shows pause button.
5
- All TS errors avoided via (as any) casts where needed.
6
- """
7
- import os
8
-
9
- ROOT = "/source/frontend/src"
10
- EVENTS = f"{ROOT}/types/events.ts"
11
- SSE = f"{ROOT}/lib/sse-chat-transport.ts"
12
- HOOK = f"{ROOT}/hooks/useAgentChat.ts"
13
- INPUT = f"{ROOT}/components/Chat/ChatInput.tsx"
14
- SESS = f"{ROOT}/components/SessionChat.tsx"
15
- AGENT_LOOP = "/app/agent/core/agent_loop.py"
16
-
17
- def p(f, s):
18
- print(f"OK: {f}" if s else f"FAIL: {f}")
19
-
20
- # --- PATCH 1: events.ts ---
21
- with open(EVENTS) as f: c = f.read()
22
- r1 = "'task_incomplete'" not in c
23
- if r1:
24
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
25
- with open(EVENTS, 'w') as f: f.write(c)
26
- p("events.ts", r1)
27
-
28
- # --- PATCH 2: sse-chat-transport.ts ---
29
- with open(SSE) as f: c = f.read()
30
- r2 = "case 'task_incomplete'" not in c
31
- if r2:
32
- block = """ case 'task_incomplete':
33
- (sideChannel as any).onTaskIncomplete(
34
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
35
- );
36
- break;
37
-
38
- default:"""
39
- c = c.replace("\n default:", "\n" + block)
40
- with open(SSE, 'w') as f: f.write(c)
41
- p("sse.ts", r2)
42
-
43
- # --- PATCH 3: useAgentChat.ts ---
44
- with open(HOOK) as f: lines = f.readlines()
45
- r3 = True
46
- new_lines = []
47
- state_done = False
48
- handler_done = False
49
- efx_done = False
50
- ret_done = False
51
-
52
- for line in lines:
53
- if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line:
54
- line = line.replace("useRef }", "useRef, useState }")
55
-
56
- if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_done:
57
- new_lines.append(line)
58
- new_lines.append(" // Auto-continue state\n")
59
- new_lines.append(" const [_showAc, _setShowAc] = useState(false);\n")
60
- new_lines.append(" const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n")
61
- state_done = True
62
- continue
63
-
64
- if "onSessionUpdate: (data) => {" in line and not handler_done:
65
- new_lines.append(line)
66
- new_lines.append(" // Auto-continue check\n")
67
- new_lines.append(" if ((data as any).ac_plan) {\n")
68
- new_lines.append(" _acRef.current = (data as any).ac_plan;\n")
69
- new_lines.append(" _setShowAc(true);\n")
70
- new_lines.append(" return;\n")
71
- new_lines.append(" }\n")
72
- handler_done = True
73
- continue
74
-
75
- stripped = line.strip()
76
- if stripped == "return {" and not efx_done:
77
- new_lines.append(" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n")
78
- new_lines.append(" useEffect(() => {\n")
79
- new_lines.append(" if (!_showAc) return;\n")
80
- new_lines.append(" const plan = _acRef.current;\n")
81
- new_lines.append(" const timer = setTimeout(() => {\n")
82
- new_lines.append(" const s = chatActionsRef.current.setMessages;\n")
83
- new_lines.append(" const m = chatActionsRef.current.messages;\n")
84
- new_lines.append(" if (s && plan.length > 0) {\n")
85
- new_lines.append(" const t = plan.map(i => `- ${i.content}`).join('\\n');\n")
86
- 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")
87
- new_lines.append(" s([...m, nu]);\n")
88
- new_lines.append(" }\n")
89
- new_lines.append(" _setShowAc(false);\n")
90
- new_lines.append(" }, 10000);\n")
91
- new_lines.append(" return () => clearTimeout(timer);\n")
92
- new_lines.append(" }, [_showAc]);\n")
93
- efx_done = True
94
-
95
- if "refreshMessages," in line and not ret_done:
96
- new_lines.append(line)
97
- new_lines.append(" _showAc,\n")
98
- new_lines.append(" _acCancel,\n")
99
- ret_done = True
100
- continue
101
-
102
- new_lines.append(line)
103
-
104
- if not all([state_done, handler_done, efx_done, ret_done]):
105
- r3 = False
106
- print(f" MISSING: state={state_done} handler={handler_done} effect={efx_done} ret={ret_done}")
107
- else:
108
- with open(HOOK, 'w') as f: f.writelines(new_lines)
109
- p("useAgentChat.ts", r3)
110
-
111
- # --- PATCH 4: ChatInput.tsx ---
112
- with open(INPUT) as f: c = f.read()
113
- r4 = True
114
- if "Button" not in c:
115
- c = c.replace(" Tooltip,", " Tooltip,\n Button,")
116
-
117
- if "_showAc" not in c:
118
- old = "placeholder?: string;"
119
- new = "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
120
- c = c.replace(old, new)
121
-
122
- old2 = "placeholder = 'Ask anything...' }: ChatInputProps) {"
123
- new2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
124
- c = c.replace(old2, new2)
125
-
126
- btn = """
127
- {_showAc && _acCancel && (
128
- <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
129
- <Button variant="outlined" size="small" onClick={_acCancel}
130
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
131
- Tam dung tu dong tiep tuc (10s)
132
- </Button>
133
- </Box>
134
- )}
135
- """
136
- c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
137
- else:
138
- r4 = False
139
-
140
- with open(INPUT, 'w') as f: f.write(c)
141
- p("ChatInput.tsx", r4)
142
-
143
- # --- PATCH 5: SessionChat.tsx ---
144
- if os.path.exists(SESS):
145
- with open(SESS) as f: c = f.read()
146
- r5 = "_showAc" not in c
147
- if r5:
148
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
149
- with open(SESS, 'w') as f: f.write(c)
150
- p("SessionChat.tsx", r5)
151
- else:
152
- print("SKIP: SessionChat.tsx")
153
-
154
- # --- PATCH 6: agent_loop.py ---
155
- if os.path.exists(AGENT_LOOP):
156
- with open(AGENT_LOOP) as f: c = f.read()
157
- r6 = True
158
- if "_unfinished_plan" not in c:
159
- fn = """
160
- def _unfinished_plan(s):
161
- p = getattr(s, "current_plan", None) or []
162
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
163
- """
164
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
165
-
166
- if "ac_plan" not in c:
167
- check = """
168
- if not llm_result.tool_calls_acc and llm_result.content:
169
- unfinished = _unfinished_plan(session)
170
- if unfinished:
171
- await session.send_event(Event(event_type="session_update", data={
172
- "ac_plan": unfinished,
173
- }))
174
- """
175
- marker = " # -- End of turn --"
176
- c = c.replace(marker, check + "\n" + marker)
177
- else:
178
- r6 = False
179
-
180
- with open(AGENT_LOOP, 'w') as f: f.write(c)
181
- p("agent_loop.py", r6)
182
- else:
183
- print("SKIP: agent_loop.py")
184
-
185
- print("\nDONE: V11")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v12.py DELETED
@@ -1,209 +0,0 @@
1
- """V12: Auto-continue feature - final fix.
2
-
3
- Key fixes for V11 bugs:
4
- 1. Button import: check exact MUI import format
5
- 2. useAgentChat: match two-line `return {\n messages: chat.messages,` instead of bare `return {`
6
- 3. Use \u unicode escapes for Vietnamese text
7
- """
8
-
9
- import os
10
-
11
- ROOT = "/source/frontend/src"
12
- EVENTS = f"{ROOT}/types/events.ts"
13
- SSE = f"{ROOT}/lib/sse-chat-transport.ts"
14
- HOOK = f"{ROOT}/hooks/useAgentChat.ts"
15
- INPUT = f"{ROOT}/components/Chat/ChatInput.tsx"
16
- SESS = f"{ROOT}/components/SessionChat.tsx"
17
- AGENT = "/app/agent/core/agent_loop.py"
18
-
19
- def ok(f):
20
- print(f"OK: {f}")
21
-
22
- # --- 1. events.ts: add task_incomplete ---
23
- with open(EVENTS) as f:
24
- c = f.read()
25
- if "'task_incomplete'" not in c:
26
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
27
- with open(EVENTS, 'w') as f:
28
- f.write(c)
29
- ok("events.ts")
30
-
31
- # --- 2. sse-chat-transport.ts: add case handler ---
32
- with open(SSE) as f:
33
- c = f.read()
34
- if "case 'task_incomplete'" not in c:
35
- block = """ case 'task_incomplete':
36
- (sideChannel as any).onTaskIncomplete(
37
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
38
- );
39
- break;
40
-
41
- default:"""
42
- c = c.replace("\n default:", "\n" + block)
43
- with open(SSE, 'w') as f:
44
- f.write(c)
45
- ok("sse.ts")
46
-
47
- # --- 3. useAgentChat.ts: add state, handler, effect, return ---
48
- with open(HOOK) as f:
49
- content = f.read()
50
-
51
- # Add useState to import
52
- if "useState" not in content:
53
- content = content.replace(
54
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
55
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
56
- )
57
-
58
- # Add state after callbacksRef line
59
- state_block = """\
60
- // Auto-continue state
61
- const [_showAc, _setShowAc] = useState(false);
62
- const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);
63
- """
64
- if "_showAc" not in content:
65
- content = content.replace(
66
- "callbacksRef.current = { onReady, onError, onSessionDead };",
67
- "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block
68
- )
69
-
70
- # Add handler inside onSessionUpdate
71
- handler_code = """\
72
- // Auto-continue check
73
- if ((data as any).ac_plan) {
74
- _acRef.current = (data as any).ac_plan;
75
- _setShowAc(true);
76
- return;
77
- }
78
- """
79
- if "ac_plan" not in content:
80
- content = content.replace(
81
- "onSessionUpdate: (data) => {",
82
- "onSessionUpdate: (data) => {\n" + handler_code
83
- )
84
-
85
- # Add cancel + effect BEFORE the two-line return marker
86
- cancel_effect = """\
87
- const _acCancel = useCallback(() => { _setShowAc(false); }, []);
88
- useEffect(() => {
89
- if (!_showAc) return;
90
- const plan = _acRef.current;
91
- const timer = setTimeout(() => {
92
- const s = chatActionsRef.current.setMessages;
93
- const m = chatActionsRef.current.messages;
94
- if (s && plan.length > 0) {
95
- const t = plan.map((i: { content: string }) => `- ${i.content}`).join('\\n');
96
- 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: '' };
97
- s([...m, nu]);
98
- }
99
- _setShowAc(false);
100
- }, 10000);
101
- return () => clearTimeout(timer);
102
- }, [_showAc]);
103
-
104
- """
105
- if "_acCancel" not in content:
106
- # Match the EXACT two-line return pattern
107
- content = content.replace(
108
- " return {\n messages: chat.messages,",
109
- cancel_effect + " return {\n messages: chat.messages,"
110
- )
111
-
112
- # Add return values
113
- if "_showAc," not in content:
114
- content = content.replace(
115
- "refreshMessages,\n };",
116
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
117
- )
118
-
119
- with open(HOOK, 'w') as f:
120
- f.write(content)
121
- ok("useAgentChat.ts")
122
-
123
- # --- 4. ChatInput.tsx: add Button + props + pause button ---
124
- with open(INPUT) as f:
125
- content = f.read()
126
-
127
- # Add Button to MUI imports - match the actual format
128
- if "Button" not in content:
129
- content = content.replace(
130
- " Tooltip,",
131
- " Tooltip,\n Button,"
132
- )
133
-
134
- # Add props to interface
135
- if "_showAc" not in content:
136
- content = content.replace(
137
- "placeholder?: string;",
138
- "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
139
- )
140
-
141
- # Add to destructured function params
142
- if "_showAc" not in content or "_acCancel" not in content:
143
- content = content.replace(
144
- "placeholder = 'Ask anything...' }: ChatInputProps) {",
145
- "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
146
- )
147
-
148
- # Add pause button before JobsUpgradeDialog
149
- if "Tam dung" not in content:
150
- btn = """
151
- {_showAc && _acCancel && (
152
- <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
153
- <Button variant="outlined" size="small" onClick={_acCancel}
154
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
155
- T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
156
- </Button>
157
- </Box>
158
- )}
159
- """
160
- content = content.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
161
-
162
- with open(INPUT, 'w') as f:
163
- f.write(content)
164
- ok("ChatInput.tsx")
165
-
166
- # --- 5. SessionChat.tsx: pass props ---
167
- if os.path.exists(SESS):
168
- with open(SESS) as f:
169
- c = f.read()
170
- if "_showAc" not in c:
171
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
172
- with open(SESS, 'w') as f:
173
- f.write(c)
174
- ok("SessionChat.tsx")
175
-
176
- # --- 6. agent_loop.py: detection ---
177
- if os.path.exists(AGENT):
178
- with open(AGENT) as f:
179
- c = f.read()
180
-
181
- if "_unfinished_plan" not in c:
182
- fn = """
183
-
184
- def _unfinished_plan(s):
185
- p = getattr(s, "current_plan", None) or []
186
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
187
- """
188
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
189
-
190
- if "ac_plan" not in c:
191
- check = """
192
- if not llm_result.tool_calls_acc and llm_result.content:
193
- unfinished = _unfinished_plan(session)
194
- if unfinished:
195
- await session.send_event(Event(event_type="session_update", data={
196
- "ac_plan": unfinished,
197
- }))
198
- """
199
- marker = " # -- End of turn --"
200
- if marker in c:
201
- c = c.replace(marker, check + marker)
202
-
203
- with open(AGENT, 'w') as f:
204
- f.write(c)
205
- ok("agent_loop.py")
206
- else:
207
- print("SKIP: agent_loop.py (backend)")
208
-
209
- print("\nDONE: V12")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v13.py DELETED
@@ -1,197 +0,0 @@
1
- """V13: Auto-continue feature - fixed SyntaxError, no raw \u in Python source"""
2
-
3
- import os
4
-
5
- ROOT = "/source/frontend/src"
6
- EVENTS = f"{ROOT}/types/events.ts"
7
- SSE = f"{ROOT}/lib/sse-chat-transport.ts"
8
- HOOK = f"{ROOT}/hooks/useAgentChat.ts"
9
- INPUT = f"{ROOT}/components/Chat/ChatInput.tsx"
10
- SESS = f"{ROOT}/components/SessionChat.tsx"
11
- AGENT = "/app/agent/core/agent_loop.py"
12
-
13
- # Vietnamese text as hex to avoid \u issues in Python source
14
- PAUSE_TEXT = "T\\u1ea1m d\\u1eebng t\\u1ef1 \\u0111\\u1ed9ng ti\\u1ebfp t\\u1ee5c (10s)"
15
- CONTINUE_TEXT = "[TIEP TUC] Task chua hoan thanh:"
16
-
17
- def ok(f):
18
- print(f"OK: {f}")
19
-
20
- # --- 1. events.ts ---
21
- with open(EVENTS) as f:
22
- c = f.read()
23
- if "'task_incomplete'" not in c:
24
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
25
- with open(EVENTS, 'w') as f:
26
- f.write(c)
27
- ok("events.ts")
28
-
29
- # --- 2. sse.ts ---
30
- with open(SSE) as f:
31
- c = f.read()
32
- if "case 'task_incomplete'" not in c:
33
- block = """ case 'task_incomplete':
34
- (sideChannel as any).onTaskIncomplete(
35
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
36
- );
37
- break;
38
-
39
- default:"""
40
- c = c.replace("\n default:", "\n" + block)
41
- with open(SSE, 'w') as f:
42
- f.write(c)
43
- ok("sse.ts")
44
-
45
- # --- 3. useAgentChat.ts ---
46
- with open(HOOK) as f:
47
- content = f.read()
48
-
49
- if "useState" not in content:
50
- content = content.replace(
51
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
52
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
53
- )
54
-
55
- state_block = """\
56
- // Auto-continue state
57
- const [_showAc, _setShowAc] = useState(false);
58
- const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);
59
- """
60
- if "_showAc" not in content:
61
- content = content.replace(
62
- "callbacksRef.current = { onReady, onError, onSessionDead };",
63
- "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block
64
- )
65
-
66
- handler_code = """\
67
- // Auto-continue check
68
- if ((data as any).ac_plan) {
69
- _acRef.current = (data as any).ac_plan;
70
- _setShowAc(true);
71
- return;
72
- }
73
- """
74
- if "ac_plan" not in content:
75
- content = content.replace(
76
- "onSessionUpdate: (data) => {",
77
- "onSessionUpdate: (data) => {\n" + handler_code
78
- )
79
-
80
- cancel_effect = """\
81
- const _acCancel = useCallback(() => { _setShowAc(false); }, []);
82
- useEffect(() => {
83
- if (!_showAc) return;
84
- const plan = _acRef.current;
85
- const timer = setTimeout(() => {
86
- const s = chatActionsRef.current.setMessages;
87
- const m = chatActionsRef.current.messages;
88
- if (s && plan.length > 0) {
89
- const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');
90
- const nu = { id: 'ac-'+Date.now(), role: 'user' as const, parts: [{ type: 'text' as const, text: '""" + CONTINUE_TEXT + "\\\\n' + t }], content: '' };\n"
91
- cancel_effect += """\
92
- s([...m, nu]);
93
- }
94
- _setShowAc(false);
95
- }, 10000);
96
- return () => clearTimeout(timer);
97
- }, [_showAc]);
98
-
99
- """
100
- if "_acCancel" not in content:
101
- content = content.replace(
102
- " return {\n messages: chat.messages,",
103
- cancel_effect + " return {\n messages: chat.messages,"
104
- )
105
-
106
- if "_showAc," not in content:
107
- content = content.replace(
108
- "refreshMessages,\n };",
109
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
110
- )
111
-
112
- with open(HOOK, 'w') as f:
113
- f.write(content)
114
- ok("useAgentChat.ts")
115
-
116
- # --- 4. ChatInput.tsx ---
117
- with open(INPUT) as f:
118
- content = f.read()
119
-
120
- if "Button" not in content:
121
- content = content.replace(
122
- " Tooltip,",
123
- " Tooltip,\n Button,"
124
- )
125
-
126
- if "_showAc" not in content:
127
- content = content.replace(
128
- "placeholder?: string;",
129
- "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
130
- )
131
- content = content.replace(
132
- "placeholder = 'Ask anything...' }: ChatInputProps) {",
133
- "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
134
- )
135
-
136
- pause_marker = "TAM_DUNG"
137
- if pause_marker not in content:
138
- btn = """
139
- {_showAc && _acCancel && (
140
- <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
141
- <Button variant="outlined" size="small" onClick={_acCancel}
142
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
143
- """ + PAUSE_TEXT + """
144
- </Button>
145
- </Box>
146
- )}
147
- """
148
- content = content.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
149
-
150
- with open(INPUT, 'w') as f:
151
- f.write(content)
152
- ok("ChatInput.tsx")
153
-
154
- # --- 5. SessionChat.tsx ---
155
- if os.path.exists(SESS):
156
- with open(SESS) as f:
157
- c = f.read()
158
- if "_showAc" not in c:
159
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
160
- with open(SESS, 'w') as f:
161
- f.write(c)
162
- ok("SessionChat.tsx")
163
-
164
- # --- 6. agent_loop.py ---
165
- if os.path.exists(AGENT):
166
- with open(AGENT) as f:
167
- c = f.read()
168
-
169
- if "_unfinished_plan" not in c:
170
- fn = """
171
-
172
- def _unfinished_plan(s):
173
- p = getattr(s, "current_plan", None) or []
174
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
175
- """
176
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
177
-
178
- if "ac_plan" not in c:
179
- check = """
180
- if not llm_result.tool_calls_acc and llm_result.content:
181
- unfinished = _unfinished_plan(session)
182
- if unfinished:
183
- await session.send_event(Event(event_type="session_update", data={
184
- "ac_plan": unfinished,
185
- }))
186
- """
187
- marker = " # -- End of turn --"
188
- if marker in c:
189
- c = c.replace(marker, check + marker)
190
-
191
- with open(AGENT, 'w') as f:
192
- f.write(c)
193
- ok("agent_loop.py")
194
- else:
195
- print("SKIP: agent_loop.py")
196
-
197
- print("\nDONE: V13")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v14.py DELETED
@@ -1,166 +0,0 @@
1
- """V14: Auto-continue. Use ASCII-only text for reliable build."""
2
-
3
- import os, sys
4
-
5
- ROOT = "/source/frontend/src"
6
-
7
- FILES = {
8
- "events.ts": ROOT + "/types/events.ts",
9
- "sse.ts": ROOT + "/lib/sse-chat-transport.ts",
10
- "hook.ts": ROOT + "/hooks/useAgentChat.ts",
11
- "input.tsx": ROOT + "/components/Chat/ChatInput.tsx",
12
- "sess.tsx": ROOT + "/components/SessionChat.tsx",
13
- "agent.py": "/app/agent/core/agent_loop.py",
14
- }
15
-
16
- # 1. events.ts
17
- c = open(FILES["events.ts"]).read()
18
- if "'task_incomplete'" not in c:
19
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
20
- open(FILES["events.ts"], 'w').write(c)
21
- print("OK: events.ts")
22
-
23
- # 2. sse.ts
24
- c = open(FILES["sse.ts"]).read()
25
- if "case 'task_incomplete'" not in c:
26
- block = """ case 'task_incomplete':
27
- (sideChannel as any).onTaskIncomplete(
28
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
29
- );
30
- break;
31
-
32
- default:"""
33
- c = c.replace("\n default:", "\n" + block)
34
- open(FILES["sse.ts"], 'w').write(c)
35
- print("OK: sse.ts")
36
-
37
- # 3. hook.ts - useAgentChat.ts
38
- c = open(FILES["hook.ts"]).read()
39
-
40
- # useState import
41
- if "useState" not in c:
42
- c = c.replace(
43
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
44
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
45
- )
46
-
47
- # State after callbacksRef
48
- if "_showAc" not in c:
49
- state_block = (
50
- " // Auto-continue state\n"
51
- " const [_showAc, _setShowAc] = useState(false);\n"
52
- " const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n"
53
- )
54
- c = c.replace(
55
- "callbacksRef.current = { onReady, onError, onSessionDead };",
56
- "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_block
57
- )
58
-
59
- # Handler in onSessionUpdate
60
- if "ac_plan" not in c:
61
- handler = (
62
- " // Auto-continue check\n"
63
- " if ((data as any).ac_plan) {\n"
64
- " _acRef.current = (data as any).ac_plan;\n"
65
- " _setShowAc(true);\n"
66
- " return;\n"
67
- " }\n"
68
- )
69
- c = c.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + handler)
70
-
71
- # Cancel + effect before return
72
- if "_acCancel" not in c:
73
- cancel = " const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n"
74
- effect = (
75
- " useEffect(() => {\n"
76
- " if (!_showAc) return;\n"
77
- " const plan = _acRef.current;\n"
78
- " const timer = setTimeout(() => {\n"
79
- " const s = chatActionsRef.current.setMessages;\n"
80
- " const m = chatActionsRef.current.messages;\n"
81
- " if (s && plan.length > 0) {\n"
82
- " const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n"
83
- " 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"
84
- " s([...m, nu]);\n"
85
- " }\n"
86
- " _setShowAc(false);\n"
87
- " }, 10000);\n"
88
- " return () => clearTimeout(timer);\n"
89
- " }, [_showAc]);\n\n"
90
- )
91
- c = c.replace(
92
- " return {\n messages: chat.messages,",
93
- cancel + effect + " return {\n messages: chat.messages,"
94
- )
95
-
96
- # Return values
97
- if "_showAc," not in c:
98
- c = c.replace("refreshMessages,\n };", "refreshMessages,\n _showAc,\n _acCancel,\n };")
99
-
100
- open(FILES["hook.ts"], 'w').write(c)
101
- print("OK: useAgentChat.ts")
102
-
103
- # 4. input.tsx - ChatInput.tsx
104
- c = open(FILES["input.tsx"]).read()
105
-
106
- if "Button" not in c:
107
- # Match the exact MUI import block
108
- 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';",
109
- "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';")
110
-
111
- if "_showAc" not in c:
112
- c = c.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;")
113
- c = c.replace("placeholder = 'Ask anything...' }: ChatInputProps) {",
114
- "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {")
115
-
116
- if "TAM_DUNG" not in c:
117
- btn = (
118
- " {_showAc && _acCancel && (\n"
119
- " <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
120
- " <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
121
- " sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
122
- " TAM_DUNG_TU_DONG_TIEP_TUC\n"
123
- " </Button>\n"
124
- " </Box>\n"
125
- " )}\n"
126
- )
127
- c = c.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
128
-
129
- open(FILES["input.tsx"], 'w').write(c)
130
- print("OK: ChatInput.tsx")
131
-
132
- # 5. sess.tsx - SessionChat.tsx
133
- if os.path.exists(FILES["sess.tsx"]):
134
- c = open(FILES["sess.tsx"]).read()
135
- if "_showAc" not in c:
136
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
137
- open(FILES["sess.tsx"], 'w').write(c)
138
- print("OK: SessionChat.tsx")
139
-
140
- # 6. agent.py
141
- if os.path.exists(FILES["agent.py"]):
142
- c = open(FILES["agent.py"]).read()
143
-
144
- if "_unfinished_plan" not in c:
145
- 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"
146
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
147
-
148
- if "ac_plan" not in c:
149
- check = (
150
- "\n if not llm_result.tool_calls_acc and llm_result.content:\n"
151
- " unfinished = _unfinished_plan(session)\n"
152
- " if unfinished:\n"
153
- " await session.send_event(Event(event_type='session_update', data={\n"
154
- " 'ac_plan': unfinished,\n"
155
- " }))\n"
156
- )
157
- marker = " # -- End of turn --"
158
- if marker in c:
159
- c = c.replace(marker, check + marker)
160
-
161
- open(FILES["agent.py"], 'w').write(c)
162
- print("OK: agent_loop.py")
163
- else:
164
- print("SKIP: agent_loop.py")
165
-
166
- print("\nDONE: V14")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v15.py DELETED
@@ -1,167 +0,0 @@
1
- """V15: Auto-continue - fix Button import detection + return matching."""
2
-
3
- import os
4
-
5
- ROOT = "/source/frontend/src"
6
- EVENTS = ROOT + "/types/events.ts"
7
- SSE = ROOT + "/lib/sse-chat-transport.ts"
8
- HOOK = ROOT + "/hooks/useAgentChat.ts"
9
- INPUT = ROOT + "/components/Chat/ChatInput.tsx"
10
- SESS = ROOT + "/components/SessionChat.tsx"
11
- AGENT = "/app/agent/core/agent_loop.py"
12
-
13
- def ok(f): print(f"OK: {f}")
14
-
15
- # 1. events.ts
16
- with open(EVENTS) as f: c = f.read()
17
- if "'task_incomplete'" not in c:
18
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
19
- with open(EVENTS, 'w') as f: f.write(c)
20
- ok("events.ts")
21
-
22
- # 2. sse.ts
23
- with open(SSE) as f: c = f.read()
24
- if "case 'task_incomplete'" not in c:
25
- block = """ case 'task_incomplete':
26
- (sideChannel as any).onTaskIncomplete(
27
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
28
- );
29
- break;
30
-
31
- default:"""
32
- c = c.replace("\n default:", "\n" + block)
33
- with open(SSE, 'w') as f: f.write(c)
34
- ok("sse.ts")
35
-
36
- # 3. useAgentChat.ts
37
- with open(HOOK) as f:
38
- lines = f.readlines()
39
-
40
- new_lines = []
41
- state_ok = hdl_ok = efx_ok = ret_ok = False
42
-
43
- for line in lines:
44
- # Import useState
45
- if "import { useCallback, useEffect, useMemo, useRef } from 'react';" in line:
46
- line = line.replace("useRef }", "useRef, useState }")
47
-
48
- # State after callbacksRef
49
- if "callbacksRef.current = { onReady, onError, onSessionDead };" in line and not state_ok:
50
- new_lines.append(line)
51
- new_lines.append(" const [_showAc, _setShowAc] = useState(false);\n")
52
- new_lines.append(" const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n")
53
- state_ok = True
54
- continue
55
-
56
- # Handler
57
- if "onSessionUpdate: (data) => {" in line and not hdl_ok:
58
- new_lines.append(line)
59
- new_lines.append(" if ((data as any).ac_plan) {\n")
60
- new_lines.append(" _acRef.current = (data as any).ac_plan;\n")
61
- new_lines.append(" _setShowAc(true);\n")
62
- new_lines.append(" return;\n")
63
- new_lines.append(" }\n")
64
- hdl_ok = True
65
- continue
66
-
67
- # Cancel + effect BEFORE 'return {'
68
- stripped = line.strip()
69
- if stripped == "return {" and not efx_ok:
70
- new_lines.append(" const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n")
71
- new_lines.append(" useEffect(() => {\n")
72
- new_lines.append(" if (!_showAc) return;\n")
73
- new_lines.append(" const plan = _acRef.current;\n")
74
- new_lines.append(" const timer = setTimeout(() => {\n")
75
- new_lines.append(" const s = chatActionsRef.current.setMessages;\n")
76
- new_lines.append(" const m = chatActionsRef.current.messages;\n")
77
- new_lines.append(" if (s && plan.length > 0) {\n")
78
- new_lines.append(" const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n")
79
- 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")
80
- new_lines.append(" s([...m, nu]);\n")
81
- new_lines.append(" }\n")
82
- new_lines.append(" _setShowAc(false);\n")
83
- new_lines.append(" }, 10000);\n")
84
- new_lines.append(" return () => clearTimeout(timer);\n")
85
- new_lines.append(" }, [_showAc]);\n\n")
86
- efx_ok = True
87
-
88
- # Add return values after "refreshMessages,"
89
- if "refreshMessages," in line and not ret_ok:
90
- new_lines.append(line)
91
- new_lines.append(" _showAc,\n")
92
- new_lines.append(" _acCancel,\n")
93
- ret_ok = True
94
- continue
95
-
96
- new_lines.append(line)
97
-
98
- with open(HOOK, 'w') as f:
99
- f.writelines(new_lines)
100
- ok(f"useAgentChat.ts (s={state_ok} h={hdl_ok} e={efx_ok} r={ret_ok})")
101
-
102
- # 4. ChatInput.tsx
103
- with open(INPUT) as f:
104
- content = f.read()
105
-
106
- # Check for exact Button in MUI import (not "IconButton" which contains "Button")
107
- if " Button,\n" not in content and " Button\n" not in content:
108
- content = content.replace(
109
- " Tooltip,",
110
- " Tooltip,\n Button,"
111
- )
112
-
113
- # Add props to interface
114
- if "_showAc" not in content:
115
- content = content.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;")
116
- content = content.replace("placeholder = 'Ask anything...' }: ChatInputProps) {",
117
- "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {")
118
-
119
- # Add pause button before <JobsUpgradeDialog
120
- btn = (
121
- " {_showAc && _acCancel && (\n"
122
- " <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
123
- " <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
124
- " sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
125
- " TAM_DUNG_TU_DONG_TIEP_TUC (10s)\n"
126
- " </Button>\n"
127
- " </Box>\n"
128
- " )}\n"
129
- )
130
- content = content.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
131
-
132
- with open(INPUT, 'w') as f:
133
- f.write(content)
134
- ok("ChatInput.tsx")
135
-
136
- # 5. SessionChat.tsx
137
- if os.path.exists(SESS):
138
- with open(SESS) as f: c = f.read()
139
- if "_showAc" not in c:
140
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
141
- with open(SESS, 'w') as f: f.write(c)
142
- ok("SessionChat.tsx")
143
-
144
- # 6. agent_loop.py
145
- if os.path.exists(AGENT):
146
- with open(AGENT) as f: c = f.read()
147
- if "_unfinished_plan" not in c:
148
- 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"
149
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
150
- if "ac_plan" not in c:
151
- check = (
152
- "\n if not llm_result.tool_calls_acc and llm_result.content:\n"
153
- " unfinished = _unfinished_plan(session)\n"
154
- " if unfinished:\n"
155
- " await session.send_event(Event(event_type='session_update', data={\n"
156
- " 'ac_plan': unfinished,\n"
157
- " }))\n"
158
- )
159
- marker = " # -- End of turn --"
160
- if marker in c:
161
- c = c.replace(marker, check + marker)
162
- with open(AGENT, 'w') as f: f.write(c)
163
- ok("agent_loop.py")
164
- else:
165
- print("SKIP: agent_loop.py")
166
-
167
- print("\nDONE: V15")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v16.py DELETED
@@ -1,142 +0,0 @@
1
- """V16: Auto-continue - match exact 'return {\n messages: chat.messages,' (only one in file)"""
2
-
3
- import os, sys
4
-
5
- F = lambda n: "/source/frontend/src/" + n
6
- EVENTS = F("types/events.ts")
7
- SSE = F("lib/sse-chat-transport.ts")
8
- HOOK = F("hooks/useAgentChat.ts")
9
- INPUT = F("components/Chat/ChatInput.tsx")
10
- SESS = F("components/SessionChat.tsx")
11
- AGENT = "/app/agent/core/agent_loop.py"
12
-
13
- def ok(f): print(f"OK: {f}")
14
-
15
- # --- events.ts ---
16
- with open(EVENTS) as f: c = f.read()
17
- if "'task_incomplete'" not in c:
18
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
19
- open(EVENTS, 'w').write(c)
20
- ok("events.ts")
21
-
22
- # --- sse.ts ---
23
- with open(SSE) as f: c = f.read()
24
- if "case 'task_incomplete'" not in c:
25
- c = c.replace("\n default:", "\n case 'task_incomplete':\n (sideChannel as any).onTaskIncomplete(\n (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],\n );\n break;\n\n default:")
26
- open(SSE, 'w').write(c)
27
- ok("sse.ts")
28
-
29
- # --- useAgentChat.ts ---
30
- with open(HOOK) as f: content = f.read()
31
-
32
- # useState import
33
- if "useState" not in content:
34
- content = content.replace("useRef }", "useRef, useState }")
35
-
36
- # State after callbacksRef (insert once)
37
- if "_showAc" not in content:
38
- s = (" // Auto-continue state\n"
39
- " const [_showAc, _setShowAc] = useState(false);\n"
40
- " const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n")
41
- content = content.replace(
42
- "callbacksRef.current = { onReady, onError, onSessionDead };",
43
- "callbacksRef.current = { onReady, onError, onSessionDead };\n" + s
44
- )
45
-
46
- # Handler in onSessionUpdate
47
- if "ac_plan" not in content:
48
- h = (" // Auto-continue check\n"
49
- " if ((data as any).ac_plan) {\n"
50
- " _acRef.current = (data as any).ac_plan;\n"
51
- " _setShowAc(true);\n"
52
- " return;\n"
53
- " }\n")
54
- content = content.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + h)
55
-
56
- # CRITICAL: Only match the exact two-line return that has messages: chat.messages
57
- # Only ONE place in the file has this exact pattern
58
- marker = " return {\n messages: chat.messages,"
59
- if marker in content and "_acCancel" not in content:
60
- cancel_effect = (
61
- " const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n"
62
- " useEffect(() => {\n"
63
- " if (!_showAc) return;\n"
64
- " const plan = _acRef.current;\n"
65
- " const timer = setTimeout(() => {\n"
66
- " const s = chatActionsRef.current.setMessages;\n"
67
- " const m = chatActionsRef.current.messages;\n"
68
- " if (s && plan.length > 0) {\n"
69
- " const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n"
70
- " 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"
71
- " s([...m, nu]);\n"
72
- " }\n"
73
- " _setShowAc(false);\n"
74
- " }, 10000);\n"
75
- " return () => clearTimeout(timer);\n"
76
- " }, [_showAc]);\n\n"
77
- )
78
- content = content.replace(marker, cancel_effect + marker)
79
-
80
- # Add to return values
81
- content = content.replace(
82
- "refreshMessages,\n };",
83
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
84
- )
85
-
86
- with open(HOOK, 'w').write(content)
87
- ok("useAgentChat.ts")
88
-
89
- # --- ChatInput.tsx ---
90
- with open(INPUT) as f: content = f.read()
91
-
92
- # Button: check for standalone ", Button" or " Button" NOT "IconButton"
93
- if ", Button,\n" not in content and ", Button\n" not in content:
94
- content = content.replace(
95
- " Tooltip,",
96
- " Tooltip,\n Button,"
97
- )
98
-
99
- if "_showAc" not in content:
100
- content = content.replace("placeholder?: string;", "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;")
101
- content = content.replace("placeholder = 'Ask anything...' }: ChatInputProps) {",
102
- "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {")
103
- btn = (" {_showAc && _acCancel && (\n"
104
- " <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
105
- " <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
106
- " sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
107
- " TAM_DUNG_TU_DONG_TIEP_TUC (10s)\n"
108
- " </Button>\n"
109
- " </Box>\n"
110
- " )}\n")
111
- content = content.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
112
-
113
- open(INPUT, 'w').write((content).read() if hasattr(content, 'read') else content)
114
- ok("ChatInput.tsx")
115
-
116
- # --- SessionChat.tsx ---
117
- if os.path.exists(SESS):
118
- with open(SESS) as f: c = f.read()
119
- if "_showAc" not in c:
120
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
121
- open(SESS, 'w').write(c)
122
- ok("SessionChat.tsx")
123
-
124
- # --- agent_loop.py ---
125
- if os.path.exists(AGENT):
126
- with open(AGENT) as f: c = f.read()
127
- if "_unfinished_plan" not in c:
128
- c = c.replace("class Handlers:", "\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\nclass Handlers:")
129
- if "ac_plan" not in c:
130
- check = ("\n if not llm_result.tool_calls_acc and llm_result.content:\n"
131
- " unfinished = _unfinished_plan(session)\n"
132
- " if unfinished:\n"
133
- " await session.send_event(Event(event_type='session_update', data={\n"
134
- " 'ac_plan': unfinished,\n"
135
- " }))\n")
136
- c = c.replace(" # -- End of turn --", check + " # -- End of turn --")
137
- open(AGENT, 'w').write(c)
138
- ok("agent_loop.py")
139
- else:
140
- print("SKIP: agent_loop.py")
141
-
142
- print("\nDONE: V16")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v17.py DELETED
@@ -1,187 +0,0 @@
1
- """V17: Auto-continue - perfect Python, exact marker matching"""
2
-
3
- import os
4
-
5
- EVENTS = "/source/frontend/src/types/events.ts"
6
- SSE = "/source/frontend/src/lib/sse-chat-transport.ts"
7
- HOOK = "/source/frontend/src/hooks/useAgentChat.ts"
8
- INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
9
- SESS = "/source/frontend/src/components/SessionChat.tsx"
10
- AGENT = "/app/agent/core/agent_loop.py"
11
-
12
- print("Patching auto-continue V17...")
13
-
14
- # --- events.ts ---
15
- with open(EVENTS) as f:
16
- c = f.read()
17
- if "'task_incomplete'" not in c:
18
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
19
- with open(EVENTS, 'w') as f:
20
- f.write(c)
21
- print("OK: events.ts")
22
-
23
- # --- sse.ts ---
24
- with open(SSE) as f:
25
- c = f.read()
26
- if "case 'task_incomplete'" not in c:
27
- block = (
28
- " case 'task_incomplete':\n"
29
- " (sideChannel as any).onTaskIncomplete(\n"
30
- " (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],\n"
31
- " );\n"
32
- " break;\n\n"
33
- " default:"
34
- )
35
- c = c.replace("\n default:", "\n" + block)
36
- with open(SSE, 'w') as f:
37
- f.write(c)
38
- print("OK: sse.ts")
39
-
40
- # --- useAgentChat.ts ---
41
- with open(HOOK) as f:
42
- content = f.read()
43
-
44
- # Add useState import
45
- if "useState" not in content:
46
- content = content.replace(
47
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
48
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
49
- )
50
-
51
- # Add state after callbacksRef
52
- if "_showAc" not in content:
53
- state_code = (
54
- " // Auto-continue state\n"
55
- " const [_showAc, _setShowAc] = useState(false);\n"
56
- " const _acRef = useRef<Array<{ id: string; content: string; status: string }>>([]);\n"
57
- )
58
- content = content.replace(
59
- "callbacksRef.current = { onReady, onError, onSessionDead };",
60
- "callbacksRef.current = { onReady, onError, onSessionDead };\n" + state_code
61
- )
62
-
63
- # Add handler in onSessionUpdate
64
- if "ac_plan" not in content:
65
- h = (
66
- " // Auto-continue check\n"
67
- " if ((data as any).ac_plan) {\n"
68
- " _acRef.current = (data as any).ac_plan;\n"
69
- " _setShowAc(true);\n"
70
- " return;\n"
71
- " }\n"
72
- )
73
- content = content.replace("onSessionUpdate: (data) => {", "onSessionUpdate: (data) => {\n" + h)
74
-
75
- # ONLY match the unique two-line pattern: " return {\n messages: chat.messages,"
76
- UNIQUE_MARKER = " return {\n messages: chat.messages,"
77
- if UNIQUE_MARKER in content and "_acCancel" not in content:
78
- insert = (
79
- " const _acCancel = useCallback(() => { _setShowAc(false); }, []);\n"
80
- " useEffect(() => {\n"
81
- " if (!_showAc) return;\n"
82
- " const plan = _acRef.current;\n"
83
- " const timer = setTimeout(() => {\n"
84
- " const s = chatActionsRef.current.setMessages;\n"
85
- " const m = chatActionsRef.current.messages;\n"
86
- " if (s && plan.length > 0) {\n"
87
- " const t = plan.map((i: { content: string }) => '- ' + i.content).join('\\\\n');\n"
88
- " 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"
89
- " s([...m, nu]);\n"
90
- " }\n"
91
- " _setShowAc(false);\n"
92
- " }, 10000);\n"
93
- " return () => clearTimeout(timer);\n"
94
- " }, [_showAc]);\n\n"
95
- )
96
- content = content.replace(UNIQUE_MARKER, insert + UNIQUE_MARKER)
97
-
98
- # Add to return
99
- content = content.replace(
100
- "refreshMessages,\n };",
101
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
102
- )
103
-
104
- with open(HOOK, 'w') as f:
105
- f.write(content)
106
- print("OK: useAgentChat.ts")
107
-
108
- # --- ChatInput.tsx ---
109
- with open(INPUT) as f:
110
- content = f.read()
111
-
112
- # Add Button (check exact substring, not "IconButton")
113
- if " Button,\n" not in content and " Button\n" not in content:
114
- content = content.replace(
115
- " Tooltip,",
116
- " Tooltip,\n Button,"
117
- )
118
-
119
- if "_showAc" not in content:
120
- content = content.replace(
121
- "placeholder?: string;",
122
- "placeholder?: string;\n _showAc?: boolean;\n _acCancel?: () => void;"
123
- )
124
- content = content.replace(
125
- "placeholder = 'Ask anything...' }: ChatInputProps) {",
126
- "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
127
- )
128
- btn = (
129
- " {_showAc && _acCancel && (\n"
130
- " <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>\n"
131
- " <Button variant=\"outlined\" size=\"small\" onClick={_acCancel}\n"
132
- " sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>\n"
133
- " TAM_DUNG_TU_DONG_TIEP_TUC (10s)\n"
134
- " </Button>\n"
135
- " </Box>\n"
136
- " )}\n"
137
- )
138
- content = content.replace("<JobsUpgradeDialog", btn + " <JobsUpgradeDialog")
139
-
140
- with open(INPUT, 'w') as f:
141
- f.write(content)
142
- print("OK: ChatInput.tsx")
143
-
144
- # --- SessionChat.tsx ---
145
- if os.path.exists(SESS):
146
- with open(SESS) as f:
147
- c = f.read()
148
- if "_showAc" not in c:
149
- c = c.replace(
150
- "<ChatInput ",
151
- "<ChatInput _showAc={_showAc} _acCancel={_acCancel} "
152
- )
153
- with open(SESS, 'w') as f:
154
- f.write(c)
155
- print("OK: SessionChat.tsx")
156
-
157
- # --- agent_loop.py ---
158
- if os.path.exists(AGENT):
159
- with open(AGENT) as f:
160
- c = f.read()
161
-
162
- if "_unfinished_plan" not in c:
163
- fn = (
164
- "\n\ndef _unfinished_plan(s):\n"
165
- " p = getattr(s, 'current_plan', None) or []\n"
166
- " return [it for it in p if it.get('status') in ('pending', 'in_progress')]\n\n"
167
- )
168
- c = c.replace("class Handlers:", fn + "class Handlers:")
169
-
170
- if "ac_plan" not in c:
171
- check = (
172
- "\n if not llm_result.tool_calls_acc and llm_result.content:\n"
173
- " unfinished = _unfinished_plan(session)\n"
174
- " if unfinished:\n"
175
- " await session.send_event(Event(event_type='session_update', data={\n"
176
- " 'ac_plan': unfinished,\n"
177
- " }))\n"
178
- )
179
- c = c.replace(" # -- End of turn --", check + " # -- End of turn --")
180
-
181
- with open(AGENT, 'w') as f:
182
- f.write(c)
183
- print("OK: agent_loop.py")
184
- else:
185
- print("SKIP: agent_loop.py")
186
-
187
- print("DONE: V17")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v2.py DELETED
@@ -1,307 +0,0 @@
1
- """V2: Fix all TypeScript errors for auto-continue feature.
2
-
3
- Errors fixed:
4
- 1. 'startAutoContinue' used before declaration → use refs
5
- 2. 'showAutoContinue'/'taskIncompleteInfo' never read → they're in return
6
- 3. 'incompletePlan' implicit any → add type
7
- 4. EventType missing 'task_incomplete' → add to events.ts
8
- 5. DatasetUploadResponse not found → fix import path
9
- 6. onTaskIncomplete not on SideChannelCallbacks → fix in SSE transport
10
- """
11
-
12
- import os
13
- import re
14
-
15
- # =====================================================================
16
- # PATCH 1: events.ts - Add 'task_incomplete' to EventType
17
- # =====================================================================
18
- EVENTS_FILE = "/source/frontend/src/types/events.ts"
19
-
20
- def patch_events():
21
- if not os.path.exists(EVENTS_FILE):
22
- print(f"SKIP: {EVENTS_FILE} not found")
23
- return
24
- with open(EVENTS_FILE, "r") as f:
25
- content = f.read()
26
- if "'task_incomplete'" not in content:
27
- content = content.replace(
28
- " | 'plan_update';",
29
- " | 'plan_update'\n | 'task_incomplete';"
30
- )
31
- with open(EVENTS_FILE, "w") as f:
32
- f.write(content)
33
- print("OK: Added 'task_incomplete' to EventType")
34
- else:
35
- print("OK: EventType already has 'task_incomplete'")
36
-
37
- # =====================================================================
38
- # PATCH 2: useAgentChat.ts - Fix hoisting + type issues
39
- # =====================================================================
40
- USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
41
-
42
- def patch_use_agent_chat():
43
- if not os.path.exists(USE_AGENT_CHAT):
44
- print(f"SKIP: {USE_AGENT_CHAT} not found")
45
- return
46
-
47
- with open(USE_AGENT_CHAT, "r") as f:
48
- content = f.read()
49
-
50
- # 1. Add useState to imports
51
- if "useState" not in content:
52
- content = content.replace(
53
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
54
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
55
- )
56
-
57
- # 2. Add state + refs after callbacksRef
58
- state_code = '''
59
- // Auto-continue state for free models when task incomplete
60
- const autoContinueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61
- const [showAutoContinue, setShowAutoContinue] = useState(false);
62
- const [taskIncompleteInfo, setTaskIncompleteInfo] = useState<{
63
- incompletePlan: Array<{ id: string; content: string; status: string }>;
64
- } | null>(null);
65
- const startAutoContinueRef = useRef<() => void>(() => {});
66
- const cancelAutoContinueRef = useRef<() => void>(() => {});
67
- '''
68
- if "autoContinueTimerRef" not in content:
69
- content = content.replace(
70
- "callbacksRef.current = { onReady, onError, onSessionDead };",
71
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state_code
72
- )
73
-
74
- # 3. Add onTaskIncomplete to SideChannelCallbacks interface
75
- if "onTaskIncomplete:" not in content and "onInterrupted:" in content:
76
- content = content.replace(
77
- " onInterrupted: () => void;",
78
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
79
- )
80
-
81
- # 4. Replace onTaskIncomplete handler to use refs
82
- old_handler = "onTaskIncomplete: (incompletePlan) => {"
83
- new_handler = "onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {"
84
- if old_handler in content:
85
- content = content.replace(old_handler, new_handler)
86
-
87
- # Fix the timer callback to use ref instead of direct call
88
- old_timer = "autoContinueTimerRef.current = setTimeout(() => {\n startAutoContinue();\n }, 10000);"
89
- new_timer = "autoContinueTimerRef.current = setTimeout(() => {\n startAutoContinueRef.current();\n }, 10000);"
90
- if old_timer in content:
91
- content = content.replace(old_timer, new_timer)
92
-
93
- # 5. Add auto-continue functions BEFORE return (but using refs pattern)
94
- # Remove old startAutoContinue/cancelAutoContinue if they exist
95
- old_funcs_start = "const startAutoContinue = useCallback"
96
- if old_funcs_start in content:
97
- # Find and remove the old function blocks
98
- start_idx = content.find("const startAutoContinue = useCallback")
99
- cancel_idx = content.find("const cancelAutoContinue = useCallback")
100
- cleanup_idx = content.find("// Cleanup timer on unmount")
101
-
102
- # Remove from startAutoContinue to end of cleanup
103
- if start_idx > 0 and cleanup_idx > start_idx:
104
- # Find the end of cleanup effect
105
- end_cleanup = content.find("\n", cleanup_idx)
106
- # Find the closing of the useEffect
107
- close_idx = content.find("}, []);", end_cleanup)
108
- if close_idx > 0:
109
- close_idx += len("}, []);")
110
- content = content[:start_idx] + content[close_idx:]
111
-
112
- # 5b. Add new functions using ref pattern, before return
113
- new_funcs = '''
114
- // -- Auto-continue for free models when task incomplete -----------------
115
- const startAutoContinue = useCallback(() => {
116
- setShowAutoContinue(false);
117
- setTaskIncompleteInfo(null);
118
- if (autoContinueTimerRef.current) {
119
- clearTimeout(autoContinueTimerRef.current);
120
- autoContinueTimerRef.current = null;
121
- }
122
- const incompleteItems = taskIncompleteInfo?.incompletePlan || [];
123
- const continuationText = incompleteItems.length > 0
124
- ? `Tiếp tục từ các task chưa hoàn thành:\\n${incompleteItems.map(i => `- ${i.content}`).join('\\n')}`
125
- : 'Tiếp tục nhiệm vụ.';
126
- chat.sendMessage({
127
- text: `[TỰ ĐỘNG TIẾP TỤC] ${continuationText}`,
128
- metadata: { createdAt: new Date().toISOString() },
129
- });
130
- }, [taskIncompleteInfo, chat]);
131
-
132
- const cancelAutoContinue = useCallback(() => {
133
- setShowAutoContinue(false);
134
- setTaskIncompleteInfo(null);
135
- if (autoContinueTimerRef.current) {
136
- clearTimeout(autoContinueTimerRef.current);
137
- autoContinueTimerRef.current = null;
138
- }
139
- }, []);
140
-
141
- // Keep refs in sync
142
- startAutoContinueRef.current = startAutoContinue;
143
- cancelAutoContinueRef.current = cancelAutoContinue;
144
-
145
- // Cleanup timer on unmount
146
- useEffect(() => {
147
- return () => {
148
- if (autoContinueTimerRef.current) {
149
- clearTimeout(autoContinueTimerRef.current);
150
- }
151
- };
152
- }, []);
153
- '''
154
-
155
- if "startAutoContinueRef.current" not in content:
156
- content = content.replace(
157
- '\n return {\n messages: chat.messages,',
158
- new_funcs + '\n return {\n messages: chat.messages,'
159
- )
160
-
161
- # 6. Update return to include new values
162
- if "showAutoContinue," not in content:
163
- content = content.replace(
164
- "refreshMessages,\n };",
165
- "refreshMessages,\n showAutoContinue,\n taskIncompleteInfo,\n startAutoContinue,\n cancelAutoContinue,\n };"
166
- )
167
-
168
- with open(USE_AGENT_CHAT, "w") as f:
169
- f.write(content)
170
- print(f"OK: Patched {USE_AGENT_CHAT}")
171
-
172
- # =====================================================================
173
- # PATCH 3: ChatInput.tsx - Fix DatasetUploadResponse import
174
- # =====================================================================
175
- CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
176
-
177
- def patch_chat_input():
178
- if not os.path.exists(CHAT_INPUT):
179
- print(f"SKIP: {CHAT_INPUT} not found")
180
- return
181
-
182
- with open(CHAT_INPUT, "r") as f:
183
- content = f.read()
184
-
185
- # Fix DatasetUploadResponse import - try correct path
186
- if "DatasetUploadResponse" in content:
187
- # Check if it's imported from agent.ts
188
- wrong_import = "import type { DatasetUploadResponse } from '@/types/agent';"
189
- if wrong_import in content:
190
- # Try to find the correct import - it's probably in the same file or from backend
191
- # Replace with inline type or remove
192
- content = content.replace(wrong_import, "")
193
- # Add inline type
194
- content = content.replace(
195
- "import { apiFetch } from '@/utils/api';",
196
- "import { apiFetch } from '@/utils/api';\n\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
197
- )
198
- print("OK: Fixed DatasetUploadResponse import")
199
-
200
- # Add Button to MUI imports
201
- if "Button" not in content:
202
- content = content.replace(
203
- "import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
204
- "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
205
- )
206
-
207
- # Check if interface already has our props (from previous patch)
208
- if "showAutoContinue" not in content:
209
- # Find interface and replace with complete version
210
- interface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL)
211
- if interface_match:
212
- props_code = '''
213
- interface ChatInputProps {
214
- sessionId: string;
215
- initialModelPath: string | null | undefined;
216
- onSend: (text: string) => Promise<void>;
217
- onStop: () => void;
218
- onDatasetUploaded: () => Promise<boolean>;
219
- isProcessing: boolean;
220
- disabled: boolean;
221
- placeholder?: string;
222
- showAutoContinue?: boolean;
223
- taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null;
224
- startAutoContinue?: () => void;
225
- cancelAutoContinue?: () => void;
226
- }
227
- '''
228
- content = content.replace(interface_match.group(0), props_code)
229
- print("OK: Updated ChatInputProps interface")
230
-
231
- # Add auto-continue buttons
232
- buttons_code = '''
233
- {/* Auto-continue controls for free models */}
234
- {showAutoContinue && taskIncompleteInfo && (
235
- <Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
236
- <Button
237
- variant="contained"
238
- color="primary"
239
- size="small"
240
- onClick={startAutoContinue}
241
- sx={{ textTransform: 'none', fontSize: '0.75rem' }}
242
- >
243
- Tự động tiếp tục (10s)
244
- </Button>
245
- <Button
246
- variant="outlined"
247
- color="secondary"
248
- size="small"
249
- onClick={cancelAutoContinue}
250
- sx={{ textTransform: 'none', fontSize: '0.75rem' }}
251
- >
252
- Tạm dừng
253
- </Button>
254
- </Stack>
255
- )}
256
- '''
257
-
258
- if "Tự động tiếp tục" not in content:
259
- content = content.replace(
260
- "<Box sx={{ flex: 1 ",
261
- buttons_code + " <Box sx={{ flex: 1 "
262
- )
263
- print("OK: Added auto-continue buttons")
264
-
265
- with open(CHAT_INPUT, "w") as f:
266
- f.write(content)
267
- print(f"OK: Patched {CHAT_INPUT}")
268
-
269
- # =====================================================================
270
- # PATCH 4: sse-chat-transport.ts - Fix onTaskIncomplete reference
271
- # =====================================================================
272
- SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
273
-
274
- def patch_sse_transport():
275
- if not os.path.exists(SSE_TRANSPORT):
276
- print(f"SKIP: {SSE_TRANSPORT} not found")
277
- return
278
-
279
- with open(SSE_TRANSPORT, "r") as f:
280
- content = f.read()
281
-
282
- # Add onTaskIncomplete to SideChannelCallbacks interface
283
- if "onTaskIncomplete:" not in content and "onInterrupted:" in content:
284
- content = content.replace(
285
- " onInterrupted: () => void;",
286
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
287
- )
288
-
289
- # Use @ts-ignore for the case since EventType union doesn't include 'task_incomplete'
290
- # Or better: add the case with a type assertion
291
- if "case 'task_incomplete'" in content:
292
- # Already added, make sure it compiles
293
- old_case = "case 'task_incomplete':\n sideChannel.onTaskIncomplete("
294
- new_case = "case 'task_incomplete' as const:\n sideChannel.onTaskIncomplete("
295
- content = content.replace(old_case, new_case)
296
-
297
- with open(SSE_TRANSPORT, "w") as f:
298
- f.write(content)
299
- print(f"OK: Patched {SSE_TRANSPORT}")
300
-
301
-
302
- if __name__ == "__main__":
303
- patch_events()
304
- patch_use_agent_chat()
305
- patch_chat_input()
306
- patch_sse_transport()
307
- print("\nDONE: All V2 patches applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v3.py DELETED
@@ -1,229 +0,0 @@
1
- """V3: FINAL - Fix remaining TS errors for auto-continue feature.
2
-
3
- Errors fixed:
4
- 1. TS6133: 'showAutoContinue'/'taskIncompleteInfo' never read → prefix with _
5
- 2. TS2741: Property 'onTaskIncomplete' missing in sideChannel object → add implementation
6
- """
7
-
8
- import os
9
- import re
10
-
11
- USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
12
- EVENTS_FILE = "/source/frontend/src/types/events.ts"
13
- CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
14
- SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
15
-
16
- def patch_events():
17
- if not os.path.exists(EVENTS_FILE):
18
- print(f"SKIP: {EVENTS_FILE} not found")
19
- return
20
- with open(EVENTS_FILE, "r") as f:
21
- content = f.read()
22
- if "'task_incomplete'" not in content:
23
- content = content.replace(
24
- " | 'plan_update';",
25
- " | 'plan_update'\n | 'task_incomplete';"
26
- )
27
- with open(EVENTS_FILE, "w") as f:
28
- f.write(content)
29
- print("OK: Added 'task_incomplete' to EventType")
30
-
31
- def patch_use_agent_chat():
32
- if not os.path.exists(USE_AGENT_CHAT):
33
- print(f"SKIP: {USE_AGENT_CHAT} not found")
34
- return
35
-
36
- with open(USE_AGENT_CHAT, "r") as f:
37
- content = f.read()
38
-
39
- # 1. Add useState import
40
- if "useState" not in content:
41
- content = content.replace(
42
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
43
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
44
- )
45
-
46
- # 2. Add state after callbacksRef
47
- state_block = '''
48
- // Auto-continue state for free models when task incomplete
49
- const autoContinueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
50
- const [_showAutoContinue, _setShowAutoContinue] = useState(false);
51
- const [_taskIncompleteInfo, _setTaskIncompleteInfo] = useState<{
52
- incompletePlan: Array<{ id: string; content: string; status: string }>;
53
- } | null>(null);
54
- '''
55
- if "autoContinueTimerRef" not in content:
56
- content = content.replace(
57
- "callbacksRef.current = { onReady, onError, onSessionDead };",
58
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
59
- )
60
-
61
- # 3. Add onTaskIncomplete to interface
62
- if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
63
- content = content.replace(
64
- " onInterrupted: () => void;",
65
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
66
- )
67
-
68
- # 4. Add onTaskIncomplete handler in sideChannel (BEFORE onInterrupted)
69
- handler_block = ''' onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
70
- _setTaskIncompleteInfo({ incompletePlan });
71
- _setShowAutoContinue(true);
72
- if (autoContinueTimerRef.current) {
73
- clearTimeout(autoContinueTimerRef.current);
74
- }
75
- autoContinueTimerRef.current = setTimeout(() => {
76
- _setShowAutoContinue(false);
77
- _setTaskIncompleteInfo(null);
78
- const plan = incompletePlan.map(i => `- ${i.content}`).join('\\n');
79
- chat.sendMessage({
80
- text: `[TỰ ĐỘNG TIẾP TỤC] Tiếp tục từ các task chưa hoàn thành:\\n${plan}`,
81
- metadata: { createdAt: new Date().toISOString() },
82
- });
83
- }, 10000);
84
- },
85
- onInterrupted: () => { /* no-op */ },'''
86
-
87
- if "onTaskIncomplete: (incompletePlan" not in content:
88
- old_interrupted = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },'
89
- if old_interrupted in content:
90
- content = content.replace(old_interrupted, handler_block)
91
- else:
92
- # Try the simpler version
93
- simple_interrupted = ' onInterrupted: () => { /* no-op */ },'
94
- if simple_interrupted in content:
95
- content = content.replace(simple_interrupted, handler_block)
96
-
97
- # 5. Add cleanup timer before return
98
- cleanup = '''
99
- // Cleanup auto-continue timer
100
- useEffect(() => {
101
- return () => {
102
- if (autoContinueTimerRef.current) {
103
- clearTimeout(autoContinueTimerRef.current);
104
- }
105
- };
106
- }, []);
107
- '''
108
- if "autoContinueTimerRef.current" in content and "Cleanup timer" not in content:
109
- content = content.replace(
110
- '\n return {\n messages: chat.messages,',
111
- cleanup + '\n return {\n messages: chat.messages,'
112
- )
113
-
114
- with open(USE_AGENT_CHAT, "w") as f:
115
- f.write(content)
116
- print(f"OK: Patched {USE_AGENT_CHAT}")
117
-
118
- def patch_chat_input():
119
- if not os.path.exists(CHAT_INPUT):
120
- print(f"SKIP: {CHAT_INPUT} not found")
121
- return
122
-
123
- with open(CHAT_INPUT, "r") as f:
124
- content = f.read()
125
-
126
- # Fix DatasetUploadResponse - define inline type
127
- if "DatasetUploadResponse" in content and "type DatasetUploadResponse" not in content:
128
- wrong = "import type { DatasetUploadResponse } from '@/types/agent';"
129
- if wrong in content:
130
- content = content.replace(wrong, "")
131
- # Add inline type after apiFetch import
132
- content = content.replace(
133
- "import { apiFetch } from '@/utils/api';",
134
- "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
135
- )
136
-
137
- # Add Button import
138
- if "Button" not in content:
139
- content = content.replace(
140
- "import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
141
- "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
142
- )
143
-
144
- # Fix interface - replace with complete version
145
- if "_showAutoContinue" not in content:
146
- interface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL)
147
- if interface_match:
148
- props_code = '''
149
- interface ChatInputProps {
150
- sessionId: string;
151
- initialModelPath: string | null | undefined;
152
- onSend: (text: string) => Promise<void>;
153
- onStop: () => void;
154
- onDatasetUploaded: () => Promise<boolean>;
155
- isProcessing: boolean;
156
- disabled: boolean;
157
- placeholder?: string;
158
- _showAutoContinue?: boolean;
159
- _taskIncompleteInfo?: { incompletePlan: Array<{ id: string; content: string; status: string }> } | null;
160
- _onCancelAutoContinue?: () => void;
161
- }
162
- '''
163
- content = content.replace(interface_match.group(0), props_code)
164
-
165
- # Add cancel button
166
- buttons = '''
167
- {/* Auto-continue cancel button for free models */}
168
- {_showAutoContinue && _taskIncompleteInfo && _onCancelAutoContinue && (
169
- <Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
170
- <Button
171
- variant="outlined"
172
- color="secondary"
173
- size="small"
174
- onClick={_onCancelAutoContinue}
175
- sx={{ textTransform: 'none', fontSize: '0.75rem' }}
176
- >
177
- Tạm dừng (để nhập nội dung khác)
178
- </Button>
179
- </Stack>
180
- )}
181
- '''
182
- if "Tạm dừng" not in content:
183
- content = content.replace(
184
- "<Box sx={{ flex: 1 ",
185
- buttons + "\n <Box sx={{ flex: 1 "
186
- )
187
-
188
- with open(CHAT_INPUT, "w") as f:
189
- f.write(content)
190
- print(f"OK: Patched {CHAT_INPUT}")
191
-
192
- def patch_sse_transport():
193
- if not os.path.exists(SSE_TRANSPORT):
194
- print(f"SKIP: {SSE_TRANSPORT} not found")
195
- return
196
-
197
- with open(SSE_TRANSPORT, "r") as f:
198
- content = f.read()
199
-
200
- # Add onTaskIncomplete to SideChannelCallbacks interface
201
- if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
202
- content = content.replace(
203
- " onInterrupted: () => void;",
204
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
205
- )
206
-
207
- # Add case 'task_incomplete'
208
- if "case 'task_incomplete'" not in content:
209
- task_case = ''' case 'task_incomplete' as const:
210
- sideChannel.onTaskIncomplete(
211
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
212
- );
213
- break;
214
- '''
215
- content = content.replace(
216
- "case 'turn_complete':",
217
- task_case + "\n case 'turn_complete':"
218
- )
219
-
220
- with open(SSE_TRANSPORT, "w") as f:
221
- f.write(content)
222
- print(f"OK: Patched {SSE_TRANSPORT}")
223
-
224
- if __name__ == "__main__":
225
- patch_events()
226
- patch_use_agent_chat()
227
- patch_chat_input()
228
- patch_sse_transport()
229
- print("\nDONE: V3 patches applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v4.py DELETED
@@ -1,264 +0,0 @@
1
- """V4: FINAL WORKING - Fix all TS errors for auto-continue feature.
2
-
3
- Strategy:
4
- - useAgentChat: return _showAutoContinue + _cancelAutoContinue (TS _ prefix suppresses "unused")
5
- - ChatInput.tsx: receive props and render "Tạm dừng" button
6
- - SessionChat.tsx: pass props from hook to ChatInput
7
- - events.ts: add 'task_incomplete' to EventType union
8
- - sse-chat-transport.ts: add onTaskIncomplete + case handler
9
- - agent_loop.py: send task_incomplete event
10
- """
11
-
12
- import os
13
- import re
14
-
15
- EVENTS = "/source/frontend/src/types/events.ts"
16
- USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
17
- SSE = "/source/frontend/src/lib/sse-chat-transport.ts"
18
- CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
19
- SESSION_CHAT = "/source/frontend/src/components/SessionChat.tsx"
20
-
21
-
22
- def patch_events():
23
- with open(EVENTS, "r") as f:
24
- c = f.read()
25
- if "'task_incomplete'" not in c:
26
- c = c.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';")
27
- with open(EVENTS, "w") as f: f.write(c)
28
- print("OK: events.ts")
29
-
30
-
31
- def patch_sse():
32
- with open(SSE, "r") as f:
33
- c = f.read()
34
-
35
- # Add onTaskIncomplete to interface
36
- if "onTaskIncomplete:" not in c:
37
- c = c.replace(
38
- " onInterrupted: () => void;",
39
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
40
- )
41
-
42
- # Add case handler
43
- if "case 'task_incomplete'" not in c:
44
- case = """ case 'task_incomplete' as const:
45
- sideChannel.onTaskIncomplete(
46
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
47
- );
48
- break;
49
- """
50
- c = c.replace("case 'turn_complete':", case + "\n case 'turn_complete':")
51
-
52
- with open(SSE, "w") as f: f.write(c)
53
- print("OK: sse-chat-transport.ts")
54
-
55
-
56
- def patch_use_agent_chat():
57
- with open(USE_AGENT_CHAT, "r") as f:
58
- c = f.read()
59
-
60
- # 1. Import useState
61
- if "useState" not in c:
62
- c = c.replace(
63
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
64
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
65
- )
66
-
67
- # 2. State (TS _ prefix = unused ok)
68
- state_block = '''
69
- // Auto-continue for free models
70
- const _autoContinueTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
71
- const [_showAutoContinue, _setShowAutoContinue] = useState(false);
72
- '''
73
- if "_autoContinueTimer" not in c:
74
- c = c.replace(
75
- "callbacksRef.current = { onReady, onError, onSessionDead };",
76
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
77
- )
78
-
79
- # 3. onTaskIncomplete in interface
80
- if "onTaskIncomplete:" not in c:
81
- c = c.replace(
82
- " onInterrupted: () => void;",
83
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
84
- )
85
-
86
- # 4. Cancel ref
87
- cancel_ref = ' const _cancelAutoContinue = useRef(() => {});\n'
88
- if "_cancelAutoContinue" not in c:
89
- # Insert after callbacksRef.current = { ... } area
90
- c = c.replace(
91
- " isActiveRef.current = isActive;",
92
- " isActiveRef.current = isActive;\n" + cancel_ref
93
- )
94
-
95
- # 5. Handler in sideChannel - inline all logic
96
- handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
97
- _setShowAutoContinue(true);
98
- if (_autoContinueTimer.current) clearTimeout(_autoContinueTimer.current);
99
- _autoContinueTimer.current = setTimeout(() => {
100
- _setShowAutoContinue(false);
101
- const plan = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
102
- chat.sendMessage({
103
- text: `[TỰ ĐỘNG TIẾP TỤC] Tiếp tục từ các task chưa hoàn thành:\\\\n${plan}`,
104
- metadata: { createdAt: new Date().toISOString() },
105
- });
106
- }, 10000);
107
- },
108
- onInterrupted: () => { /* no-op */ },"""
109
-
110
- if "onTaskIncomplete: (incompletePlan" not in c:
111
- old = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },'
112
- if old in c:
113
- c = c.replace(old, handler)
114
-
115
- # 6. Set cancel ref function + cleanup
116
- cancel_func = """
117
- _cancelAutoContinue.current = () => {
118
- _setShowAutoContinue(false);
119
- if (_autoContinueTimer.current) {
120
- clearTimeout(_autoContinueTimer.current);
121
- _autoContinueTimer.current = null;
122
- }
123
- };
124
- """
125
- if "_cancelAutoContinue.current = ()" not in c:
126
- # Insert before return
127
- c = c.replace(
128
- "\n return {\n messages: chat.messages,",
129
- cancel_func + "\n return {\n messages: chat.messages,"
130
- )
131
-
132
- # 7. Return values
133
- if "_showAutoContinue," not in c:
134
- c = c.replace(
135
- "refreshMessages,\n };",
136
- "refreshMessages,\n _showAutoContinue,\n _cancelAutoContinue,\n };"
137
- )
138
-
139
- with open(USE_AGENT_CHAT, "w") as f: f.write(c)
140
- print("OK: useAgentChat.ts")
141
-
142
-
143
- def patch_chat_input():
144
- with open(CHAT_INPUT, "r") as f:
145
- c = f.read()
146
-
147
- # Fix DatasetUploadResponse
148
- if "DatasetUploadResponse" in c and "inline" not in c:
149
- wrong = "import type { DatasetUploadResponse } from '@/types/agent';"
150
- if wrong in c:
151
- c = c.replace(wrong, "")
152
- c = c.replace(
153
- "import { apiFetch } from '@/utils/api';",
154
- "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
155
- )
156
-
157
- # Add Button import
158
- if "Button" not in c:
159
- c = c.replace(
160
- "import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
161
- "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
162
- )
163
-
164
- # Add props to interface
165
- if "_showAutoContinue" not in c:
166
- match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', c, re.DOTALL)
167
- if match:
168
- new_iface = """interface ChatInputProps {
169
- sessionId: string;
170
- initialModelPath: string | null | undefined;
171
- onSend: (text: string) => Promise<void>;
172
- onStop: () => void;
173
- onDatasetUploaded: () => Promise<boolean>;
174
- isProcessing: boolean;
175
- disabled: boolean;
176
- placeholder?: string;
177
- _showAutoContinue?: boolean;
178
- _cancelAutoContinue?: React.RefObject<() => void>;
179
- }"""
180
- c = c.replace(match.group(0), new_iface)
181
-
182
- # Add pause button
183
- btn = """
184
- {/* Pause auto-continue for free models */}
185
- {_showAutoContinue && _cancelAutoContinue?.current && (
186
- <Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
187
- <Button
188
- variant="outlined"
189
- color="secondary"
190
- size="small"
191
- onClick={_cancelAutoContinue.current}
192
- sx={{ textTransform: 'none', fontSize: '0.75rem' }}
193
- >
194
- Tạm dừng (để nhập nội dung khác)
195
- </Button>
196
- </Stack>
197
- )}
198
- """
199
- if "Tạm dừng" not in c:
200
- c = c.replace("<Box sx={{ flex: 1 ", btn + "\n <Box sx={{ flex: 1 ")
201
-
202
- with open(CHAT_INPUT, "w") as f: f.write(c)
203
- print("OK: ChatInput.tsx")
204
-
205
-
206
- def patch_session_chat():
207
- with open(SESSION_CHAT, "r") as f:
208
- c = f.read()
209
-
210
- # Find the ChatInput usage and add new props
211
- if "_showAutoContinue" not in c:
212
- chat_input_pattern = r'(<ChatInput[^>]*sessionId={sessionId}[^>]*initialModelPath={initialModelPath}[^>]*)(\s*/>)'
213
- new_props = r'\1 _showAutoContinue={_showAutoContinue} _cancelAutoContinue={_cancelAutoContinue}\2'
214
- c = re.sub(chat_input_pattern, new_props, c)
215
-
216
- with open(SESSION_CHAT, "w") as f: f.write(c)
217
- print("OK: SessionChat.tsx")
218
-
219
-
220
- def patch_agent_loop():
221
- agent_loop = "/app/agent/core/agent_loop.py"
222
- if not os.path.exists(agent_loop):
223
- print(f"SKIP: {agent_loop} not found (backend)")
224
- return
225
-
226
- with open(agent_loop, "r") as f:
227
- c = f.read()
228
-
229
- if "_check_task_incomplete" not in c:
230
- func = '''
231
-
232
- def _unfinished_plan_items(session: Session) -> list[dict[str, str]]:
233
- plan = getattr(session, "current_plan", None) or []
234
- return [item for item in plan if item.get("status") in ("pending", "in_progress")]
235
-
236
- '''
237
- c = c.replace("class Handlers:", func + "\n\nclass Handlers:")
238
-
239
- if "task_incomplete" not in c:
240
- check = '''
241
- # === Auto-continue: if model stopped but plan incomplete ===
242
- if not llm_result.tool_calls_acc and llm_result.content:
243
- unfinished = _unfinished_plan_items(session)
244
- if unfinished:
245
- await session.send_event(
246
- Event(event_type="task_incomplete", data={"incomplete_plan": unfinished})
247
- )
248
- '''
249
- marker = " # -- End of turn --"
250
- if marker in c:
251
- c = c.replace(marker, check + "\n" + marker)
252
-
253
- with open(agent_loop, "w") as f: f.write(c)
254
- print("OK: agent_loop.py")
255
-
256
-
257
- if __name__ == "__main__":
258
- patch_events()
259
- patch_sse()
260
- patch_use_agent_chat()
261
- patch_chat_input()
262
- patch_session_chat()
263
- patch_agent_loop()
264
- print("\nDONE: V4 - everything applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v5.py DELETED
@@ -1,252 +0,0 @@
1
- """V5: SIMPLEST approach - just show pause button with 10s auto-continue.
2
-
3
- Architecture:
4
- 1. Agent loop detects model stopped mid-task → sends 'task_incomplete' event
5
- 2. SSE transport receives it → calls sideChannel.onTaskIncomplete()
6
- 3. useAgentChat: onTaskIncomplete sets _showAutoContinue=true, sends continue msg
7
- 4. ChatInput shows "Tạm dừng" button if _showAutoContinue
8
- """
9
-
10
- import os, re
11
-
12
- EVENTS = "/source/frontend/src/types/events.ts"
13
- SSE = "/source/frontend/src/lib/sse-chat-transport.ts"
14
- USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
15
- CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
16
- AGENT_LOOP = "/app/agent/core/agent_loop.py"
17
-
18
-
19
- def patch_events():
20
- with open(EVENTS, "r") as f:
21
- c = f.read()
22
- if "'task_incomplete'" not in c:
23
- c = c.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';")
24
- with open(EVENTS, "w") as f:
25
- f.write(c)
26
- print("OK: events.ts")
27
-
28
-
29
- def patch_sse():
30
- with open(SSE, "r") as f:
31
- c = f.read()
32
-
33
- if "onTaskIncomplete:" not in c:
34
- c = c.replace(
35
- " onInterrupted: () => void;",
36
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
37
- )
38
-
39
- if "case 'task_incomplete'" not in c:
40
- case_block = """ case 'task_incomplete' as const:
41
- sideChannel.onTaskIncomplete(
42
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
43
- );
44
- break;
45
- """
46
- c = c.replace("case 'turn_complete':", case_block + "\n case 'turn_complete':")
47
-
48
- with open(SSE, "w") as f:
49
- f.write(c)
50
- print("OK: sse.ts")
51
-
52
-
53
- def patch_use_agent():
54
- with open(USE_AGENT_CHAT, "r") as f:
55
- c = f.read()
56
-
57
- # 1. Import useState
58
- if "useState" not in c:
59
- c = c.replace(
60
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
61
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
62
- )
63
-
64
- # 2. State (prefixed with _ = TS allows unused)
65
- state = '''
66
- // Auto-continue state for free models
67
- const _acTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
68
- const [_showAc, _setShowAc] = useState(false);'''
69
- if "_acTimer" not in c:
70
- c = c.replace(
71
- "callbacksRef.current = { onReady, onError, onSessionDead };",
72
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state
73
- )
74
-
75
- # 3. Add onTaskIncomplete to interface
76
- if "onTaskIncomplete:" not in c:
77
- c = c.replace(
78
- " onInterrupted: () => void;",
79
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
80
- )
81
-
82
- # 4. Add onTaskIncomplete handler - uses chatActionsRef ref instead of chat directly
83
- handler = ''' onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
84
- _setShowAc(true);
85
- if (_acTimer.current) clearTimeout(_acTimer.current);
86
- _acTimer.current = setTimeout(() => {
87
- _setShowAc(false);
88
- // Auto-continue after 10s via chatActionsRef
89
- const chatRef = chatActionsRef.current;
90
- const sendMsg = (chatRef as any)?.sendMessage;
91
- if (sendMsg) {
92
- const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
93
- sendMsg({ text: `[TIẾP TỤC] Tiếp tục task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } });
94
- }
95
- }, 10000);
96
- },
97
- onInterrupted: () => { /* no-op */ },'''
98
-
99
- if "onTaskIncomplete: (incompletePlan" not in c:
100
- old = ' onInterrupted: () => { /* no-op — handled by stop() caller */ },'
101
- if old in c:
102
- c = c.replace(old, handler)
103
- else:
104
- old2 = ' onInterrupted: () => { /* no-op */ },'
105
- c = c.replace(old2, handler)
106
-
107
- # 5. Cancel ref + return values
108
- if "_showAc" not in content:
109
- # Insert cancel before return
110
- cancel = '''
111
- // Expose auto-continue state
112
- const _acCancel = useCallback(() => {
113
- _setShowAc(false);
114
- if (_acTimer.current) { clearTimeout(_acTimer.current); _acTimer.current = null; }
115
- }, []);
116
- '''
117
- if "_acCancel" not in c:
118
- c = c.replace('\n return {\n messages: chat.messages,', cancel + '\n return {\n messages: chat.messages,')
119
-
120
- if "_showAc," not in c:
121
- c = c.replace(
122
- "refreshMessages,\n };",
123
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
124
- )
125
-
126
- with open(USE_AGENT_CHAT, "w") as f:
127
- f.write(c)
128
- print("OK: useAgentChat.ts")
129
-
130
-
131
- def patch_chat_input():
132
- with open(CHAT_INPUT, "r") as f:
133
- c = f.read()
134
-
135
- # DatasetUploadResponse fix
136
- if "DatasetUploadResponse" in c and "type DatasetUploadResponse" not in c:
137
- wrong = "import type { DatasetUploadResponse } from '@/types/agent';"
138
- if wrong in c:
139
- c = c.replace(wrong, "")
140
- c = c.replace(
141
- "import { apiFetch } from '@/utils/api';",
142
- "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
143
- )
144
-
145
- # Add Button
146
- if "Button" not in c:
147
- c = c.replace(
148
- "import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
149
- "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
150
- )
151
-
152
- # Update interface
153
- if "_showAc" not in c:
154
- m = re.search(r'interface ChatInputProps\s*\{[^}]*\}', c, re.DOTALL)
155
- if m:
156
- iface = """interface ChatInputProps {
157
- sessionId: string;
158
- initialModelPath: string | null | undefined;
159
- onSend: (text: string) => Promise<void>;
160
- onStop: () => void;
161
- onDatasetUploaded: () => Promise<boolean>;
162
- isProcessing: boolean;
163
- disabled: boolean;
164
- placeholder?: string;
165
- _showAc?: boolean;
166
- _acCancel?: () => void;
167
- }"""
168
- c = c.replace(m.group(0), iface)
169
-
170
- # Add button
171
- btn = """
172
- {/* Auto-continue pause button for free models */}
173
- {_showAc && _acCancel && (
174
- <Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
175
- <Button
176
- variant="outlined"
177
- color="secondary"
178
- size="small"
179
- onClick={_acCancel}
180
- sx={{ textTransform: 'none', fontSize: '0.7rem' }}
181
- >
182
- Tạm dừng tự động tiếp tục (10s)
183
- </Button>
184
- </Stack>
185
- )}
186
- """
187
- if "Tạm dừng" not in c:
188
- c = c.replace("<Box sx={{ flex: 1 ", btn + "\n <Box sx={{ flex: 1 ")
189
-
190
- with open(CHAT_INPUT, "w") as f:
191
- f.write(c)
192
- print("OK: ChatInput.tsx")
193
-
194
-
195
- def patch_session_chat():
196
- sess = "/source/frontend/src/components/SessionChat.tsx"
197
- if not os.path.exists(sess):
198
- print(f"SKIP: {sess}")
199
- return
200
- with open(sess, "r") as f:
201
- c = f.read()
202
- if "_showAc" not in c:
203
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
204
- with open(sess, "w") as f:
205
- f.write(c)
206
- print("OK: SessionChat.tsx")
207
-
208
-
209
- def patch_agent_loop():
210
- if not os.path.exists(AGENT_LOOP):
211
- print(f"SKIP: {AGENT_LOOP}")
212
- return
213
- with open(AGENT_LOOP, "r") as f:
214
- c = f.read()
215
-
216
- if "_unfinished_plan" not in c:
217
- fn = '''
218
-
219
- def _unfinished_plan(session: Session) -> list[dict[str, str]]:
220
- plan = getattr(session, "current_plan", None) or []
221
- return [item for item in plan if item.get("status") in ("pending", "in_progress")]
222
-
223
- '''
224
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
225
-
226
- if "task_incomplete" not in c:
227
- check = '''
228
- # === Auto-continue detection ===
229
- if not llm_result.tool_calls_acc and llm_result.content:
230
- unfinished = _unfinished_plan(session)
231
- if unfinished:
232
- await session.send_event(
233
- Event(event_type="task_incomplete", data={"incomplete_plan": unfinished})
234
- )
235
- '''
236
- marker = " # -- End of turn --"
237
- if marker in c:
238
- c = c.replace(marker, check + "\n" + marker)
239
-
240
- with open(AGENT_LOOP, "w") as f:
241
- f.write(c)
242
- print("OK: agent_loop.py")
243
-
244
-
245
- if __name__ == "__main__":
246
- patch_events()
247
- patch_sse()
248
- patch_use_agent()
249
- patch_chat_input()
250
- patch_session_chat()
251
- patch_agent_loop()
252
- print("\nDONE: V5 - simple approach applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v6.py DELETED
@@ -1,243 +0,0 @@
1
- """V6: FIXED - Auto-continue with pause button for free models."""
2
-
3
- import os
4
- import re
5
-
6
- EVENTS_FILE = "/source/frontend/src/types/events.ts"
7
- SSE_TRANSPORT = "/source/frontend/src/lib/sse-chat-transport.ts"
8
- HOOK_FILE = "/source/frontend/src/hooks/useAgentChat.ts"
9
- CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
10
- SESSION_CHAT = "/source/frontend/src/components/SessionChat.tsx"
11
- AGENT_LOOP = "/app/agent/core/agent_loop.py"
12
-
13
-
14
- def fix_events():
15
- with open(EVENTS_FILE, "r") as f:
16
- content = f.read()
17
- if "'task_incomplete'" not in content:
18
- content = content.replace(" | 'plan_update';", " | 'plan_update'\n | 'task_incomplete';")
19
- with open(EVENTS_FILE, "w") as f:
20
- f.write(content)
21
- print("OK: events.ts")
22
-
23
-
24
- def fix_sse():
25
- with open(SSE_TRANSPORT, "r") as f:
26
- content = f.read()
27
-
28
- # Add onTaskIncomplete to interface
29
- if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
30
- content = content.replace(
31
- " onInterrupted: () => void;",
32
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
33
- )
34
-
35
- # Add case handler
36
- if "case 'task_incomplete'" not in content:
37
- case_code = """ case 'task_incomplete' as const:
38
- sideChannel.onTaskIncomplete(
39
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
40
- );
41
- break;
42
- """
43
- content = content.replace("case 'turn_complete':", case_code + "\n case 'turn_complete':")
44
-
45
- with open(SSE_TRANSPORT, "w") as f:
46
- f.write(content)
47
- print("OK: sse-chat-transport.ts")
48
-
49
-
50
- def fix_hook():
51
- with open(HOOK_FILE, "r") as f:
52
- content = f.read()
53
-
54
- # 1. Import useState
55
- if "useState" not in content:
56
- content = content.replace(
57
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
58
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
59
- )
60
-
61
- # 2. Add state after callbacksRef block
62
- state_block = """
63
- // Auto-continue for free models
64
- const _acTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
65
- const [_showAc, _setShowAc] = useState(false);
66
- const _acCancel = useCallback(() => {
67
- _setShowAc(false);
68
- if (_acTimer.current) { clearTimeout(_acTimer.current); _acTimer.current = null; }
69
- }, []);"""
70
-
71
- if "_acTimer" not in content:
72
- content = content.replace(
73
- "callbacksRef.current = { onReady, onError, onSessionDead };",
74
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
75
- )
76
-
77
- # 3. Add onTaskIncomplete to interface
78
- if "onTaskIncomplete:" not in content and "onInterrupted: () => void;" in content:
79
- content = content.replace(
80
- " onInterrupted: () => void;",
81
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
82
- )
83
-
84
- # 4. Add handler in sideChannel - uses chatActionsRef to avoid hoisting issues
85
- handler_code = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
86
- _setShowAc(true);
87
- if (_acTimer.current) clearTimeout(_acTimer.current);
88
- _acTimer.current = setTimeout(() => {
89
- _setShowAc(false);
90
- const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
91
- const msg = { text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } };
92
- const setMsgs = chatActionsRef.current.setMessages;
93
- if (setMsgs) {
94
- chatActionsRef.current.messages = [...chatActionsRef.current.messages, { id: 'ac-continue', role: 'user', parts: [{ type: 'text', text: msg.text }], content: msg.text }];
95
- }
96
- }, 10000);
97
- },
98
- onInterrupted: () => { /* no-op - handled by stop() */ },"""
99
-
100
- if "onTaskIncomplete: (incompletePlan" not in content:
101
- old_handler = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },'
102
- if old_handler in content:
103
- content = content.replace(old_handler, handler_code)
104
- else:
105
- old_handler2 = ' onInterrupted: () => { /* no-op */ },'
106
- content = content.replace(old_handler2, handler_code)
107
-
108
- # 5. Return values
109
- if "_showAc" not in content:
110
- # The _showAc and _acCancel need to be in the return
111
- return_marker = "refreshMessages,\n };"
112
- if return_marker in content:
113
- content = content.replace(
114
- return_marker,
115
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
116
- )
117
-
118
- with open(HOOK_FILE, "w") as f:
119
- f.write(content)
120
- print("OK: useAgentChat.ts")
121
-
122
-
123
- def fix_chat_input():
124
- with open(CHAT_INPUT, "r") as f:
125
- content = f.read()
126
-
127
- # Fix DatasetUploadResponse
128
- wrong_import = "import type { DatasetUploadResponse } from '@/types/agent';"
129
- if wrong_import in content:
130
- content = content.replace(wrong_import, "")
131
- content = content.replace(
132
- "import { apiFetch } from '@/utils/api';",
133
- "import { apiFetch } from '@/utils/api';\ntype DatasetUploadResponse = { url: string; repo_id: string; filename: string; size: number; };"
134
- )
135
-
136
- # Add Button import
137
- if "Button" not in content:
138
- content = content.replace(
139
- "import { Box, IconButton, Stack, Tooltip } from '@mui/material';",
140
- "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
141
- )
142
-
143
- # Fix interface
144
- if "_showAc" not in content:
145
- iface_match = re.search(r'interface ChatInputProps\s*\{[^}]*\}', content, re.DOTALL)
146
- if iface_match:
147
- new_iface = """interface ChatInputProps {
148
- sessionId: string;
149
- initialModelPath: string | null | undefined;
150
- onSend: (text: string) => Promise<void>;
151
- onStop: () => void;
152
- onDatasetUploaded: () => Promise<boolean>;
153
- isProcessing: boolean;
154
- disabled: boolean;
155
- placeholder?: string;
156
- _showAc?: boolean;
157
- _acCancel?: () => void;
158
- }"""
159
- content = content.replace(iface_match.group(0), new_iface)
160
-
161
- # Add pause button
162
- btn_code = """
163
- {/* Auto-continue pause for free models */}
164
- {_showAc && _acCancel && (
165
- <Stack direction="row" spacing={1} sx={{ mb: 1, px: 1 }}>
166
- <Button
167
- variant="outlined"
168
- color="secondary"
169
- size="small"
170
- onClick={_acCancel}
171
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.8 }}
172
- >
173
- T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
174
- </Button>
175
- </Stack>
176
- )}"""
177
-
178
- if "T\u1ea1m d\u1eebng" not in content and "free models" not in content:
179
- content = content.replace("<Box sx={{ flex: 1 ", btn_code + "\n <Box sx={{ flex: 1 ")
180
-
181
- with open(CHAT_INPUT, "w") as f:
182
- f.write(content)
183
- print("OK: ChatInput.tsx")
184
-
185
-
186
- def fix_session_chat():
187
- with open(SESSION_CHAT, "r") as f:
188
- content = f.read()
189
-
190
- # Add props to ChatInput
191
- if "_showAc" not in content:
192
- content = content.replace(
193
- "<ChatInput ",
194
- "<ChatInput _showAc={_showAc} _acCancel={_acCancel} "
195
- )
196
-
197
- with open(SESSION_CHAT, "w") as f:
198
- f.write(content)
199
- print("OK: SessionChat.tsx")
200
-
201
-
202
- def fix_agent_loop():
203
- if not os.path.exists(AGENT_LOOP):
204
- print(f"SKIP: {AGENT_LOOP}")
205
- return
206
- with open(AGENT_LOOP, "r") as f:
207
- content = f.read()
208
-
209
- if "_unfinished_plan" not in content:
210
- fn_block = '''
211
-
212
- def _unfinished_plan(s: Session) -> list[dict[str, str]]:
213
- p = getattr(s, "current_plan", None) or []
214
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
215
-
216
- '''
217
- content = content.replace("class Handlers:", fn_block + "\n\nclass Handlers:")
218
-
219
- if "task_incomplete" not in content:
220
- check_block = '''
221
- # Auto-continue detect
222
- if not llm_result.tool_calls_acc and llm_result.content:
223
- unfinished = _unfinished_plan(session)
224
- if unfinished:
225
- await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}))
226
- '''
227
- marker = " # -- End of turn --"
228
- if marker in content:
229
- content = content.replace(marker, check_block + "\n" + marker)
230
-
231
- with open(AGENT_LOOP, "w") as f:
232
- f.write(content)
233
- print("OK: agent_loop.py")
234
-
235
-
236
- if __name__ == "__main__":
237
- fix_events()
238
- fix_sse()
239
- fix_hook()
240
- fix_chat_input()
241
- fix_session_chat()
242
- fix_agent_loop()
243
- print("\nDONE: V6 - all fixed")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v7.py DELETED
@@ -1,255 +0,0 @@
1
- """V7: PRECISE patch - exact string matching with source code.
2
-
3
- Adds auto-continue pause button for free models. All patches use exact
4
- string content from the original source files for reliable replacement.
5
- """
6
-
7
- import os, sys
8
-
9
- # ========== PATCH 1: events.ts - add 'task_incomplete' to EventType ==========
10
- events_ts = "/source/frontend/src/types/events.ts"
11
- def patch_events():
12
- with open(events_ts) as f:
13
- c = f.read()
14
- if "'task_incomplete'" not in c:
15
- c = c.replace(
16
- " | 'interrupted'",
17
- " | 'interrupted'\n | 'task_incomplete'"
18
- )
19
- with open(events_ts, 'w') as f: f.write(c)
20
- print("OK: events.ts")
21
-
22
- # ========== PATCH 2: SSE transport - add onTaskIncomplete to interface + handler ==========
23
- sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
24
- def patch_sse():
25
- with open(sse_ts) as f:
26
- c = f.read()
27
-
28
- # Add to interface
29
- if "onTaskIncomplete:" not in c:
30
- c = c.replace(
31
- " onInterrupted: () => void;",
32
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
33
- )
34
-
35
- # Add case handler before default
36
- if "case 'task_incomplete'" not in c:
37
- case_handler = """ case 'task_incomplete':
38
- sideChannel.onTaskIncomplete(
39
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
40
- );
41
- break;
42
-
43
- default:"""
44
- c = c.replace("\n default:", "\n" + case_handler)
45
-
46
- with open(sse_ts, 'w') as f: f.write(c)
47
- print("OK: sse-chat-transport.ts")
48
-
49
- # ========== PATCH 3: useAgentChat.ts - add state + handler + return ==========
50
- hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
51
- def patch_hook():
52
- with open(hook_ts) as f:
53
- c = f.read()
54
-
55
- # 1. Add useState to imports
56
- if "useState" not in c:
57
- c = c.replace(
58
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
59
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
60
- )
61
-
62
- # 2. Add state variables after callbacksRef
63
- state_block = """
64
- // Auto-continue state for free models
65
- const _acTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
66
- const [_showAc, _setShowAc] = useState(false);"""
67
- if "_acTimerRef" not in c:
68
- c = c.replace(
69
- "callbacksRef.current = { onReady, onError, onSessionDead };",
70
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
71
- )
72
-
73
- # 3. Add onTaskIncomplete to interface
74
- if "onTaskIncomplete:" not in c:
75
- c = c.replace(
76
- " onInterrupted: () => void;",
77
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
78
- )
79
-
80
- # 4. Add onTaskIncomplete handler in sideChannel object
81
- # This inserts BEFORE the existing onInterrupted handler
82
- handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
83
- _setShowAc(true);
84
- if (_acTimerRef.current) clearTimeout(_acTimerRef.current);
85
- _acTimerRef.current = setTimeout(() => {
86
- _setShowAc(false);
87
- const planStr = incompletePlan.map(i => `- ${i.content}`).join('\\\\n');
88
- const setMsgs = chatActionsRef.current.setMessages;
89
- if (setMsgs) {
90
- const msgs = chatActionsRef.current.messages;
91
- 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: '' }]);
92
- }
93
- }, 10000);
94
- },
95
- onInterrupted: () => { /* no-op */ },"""
96
-
97
- if "onTaskIncomplete: (incompletePlan" not in c:
98
- old_interrupted = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },'
99
- if old_interrupted in c:
100
- c = c.replace(old_interrupted, handler)
101
- else:
102
- # Try simpler version
103
- old2 = ' onInterrupted: () => { /* no-op */ },'
104
- c = c.replace(old2, handler)
105
-
106
- # 5. Add _acCancel function + return values
107
- cancel_func = """
108
- const _acCancel = useCallback(() => {
109
- _setShowAc(false);
110
- if (_acTimerRef.current) { clearTimeout(_acTimerRef.current); _acTimerRef.current = null; }
111
- }, []);"""
112
- if "_acCancel" not in c:
113
- c = c.replace("\n return {\n messages: chat.messages,", cancel_func + "\n return {\n messages: chat.messages,")
114
-
115
- # 6. Return values
116
- if "_showAc," not in c:
117
- c = c.replace(
118
- "refreshMessages,\n };",
119
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
120
- )
121
-
122
- with open(hook_ts, 'w') as f: f.write(c)
123
- print("OK: useAgentChat.ts")
124
-
125
- # ========== PATCH 4: ChatInput.tsx - add pause button ==========
126
- input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
127
- def patch_chat_input():
128
- with open(input_tsx) as f:
129
- c = f.read()
130
-
131
- # 1. Add Button import (it's missing from the original imports)
132
- if "Button" not in c:
133
- c = c.replace(
134
- " Tooltip,",
135
- " Tooltip,\n Button,"
136
- )
137
-
138
- # 2. Add props to interface
139
- if "_showAc" not in c:
140
- old_interface_end = """interface ChatInputProps {
141
- sessionId?: string;
142
- initialModelPath?: string | null;
143
- onSend: (text: string) => void;
144
- onStop?: () => void;
145
- onDatasetUploaded?: () => Promise<boolean> | boolean;
146
- isProcessing?: boolean;
147
- disabled?: boolean;
148
- placeholder?: string;
149
- }"""
150
- new_interface = """interface ChatInputProps {
151
- sessionId?: string;
152
- initialModelPath?: string | null;
153
- onSend: (text: string) => void;
154
- onStop?: () => void;
155
- onDatasetUploaded?: () => Promise<boolean> | boolean;
156
- isProcessing?: boolean;
157
- disabled?: boolean;
158
- placeholder?: string;
159
- _showAc?: boolean;
160
- _acCancel?: () => void;
161
- }"""
162
- c = c.replace(old_interface_end, new_interface)
163
-
164
- # 3. Add destructured props in function signature
165
- if "_showAc" not in c:
166
- old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
167
- new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
168
- if old_props not in c:
169
- old_props2 = "placeholder = 'Ask anything...' }: ChatInputProps) {"
170
- new_props2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
171
- c = c.replace(old_props2, new_props2)
172
- else:
173
- c = c.replace(old_props, new_props)
174
-
175
- # 4. Add pause button after the model badge area, before <JobsUpgradeDialog>
176
- if "T\u1ea1m d\u1eebng" not in c:
177
- btn = """
178
- {/* Auto-continue pause for free models */}
179
- {_showAc && _acCancel && (
180
- <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
181
- <Button
182
- variant="outlined"
183
- size="small"
184
- onClick={_acCancel}
185
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}
186
- >
187
- T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
188
- </Button>
189
- </Box>
190
- )}
191
- """
192
- if "<JobsUpgradeDialog" in c:
193
- c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
194
-
195
- with open(input_tsx, 'w') as f: f.write(c)
196
- print("OK: ChatInput.tsx")
197
-
198
- # ========== PATCH 5: SessionChat.tsx - pass props down ==========
199
- session_tsx = "/source/frontend/src/components/SessionChat.tsx"
200
- def patch_session():
201
- if not os.path.exists(session_tsx):
202
- print(f"SKIP: {session_tsx}")
203
- return
204
- with open(session_tsx) as f:
205
- c = f.read()
206
- if "_showAc" not in c:
207
- old = "<ChatInput "
208
- new = "<ChatInput _showAc={_showAc} _acCancel={_acCancel} "
209
- c = c.replace(old, new)
210
- with open(session_tsx, 'w') as f: f.write(c)
211
- print("OK: SessionChat.tsx")
212
-
213
- # ========== PATCH 6: agent_loop.py - detection ==========
214
- agent_py = "/app/agent/core/agent_loop.py"
215
- def patch_agent():
216
- if not os.path.exists(agent_py):
217
- print(f"SKIP: {agent_py}")
218
- return
219
- with open(agent_py) as f:
220
- c = f.read()
221
-
222
- # Add helper function
223
- if "_unfinished_plan" not in c:
224
- fn = """
225
- def _unfinished_plan(s: Session) -> list[dict[str, str]]:
226
- p = getattr(s, "current_plan", None) or []
227
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
228
-
229
- """
230
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
231
-
232
- # Add detection
233
- if "task_incomplete" not in c:
234
- check = """
235
- # Auto-continue detection
236
- if not llm_result.tool_calls_acc and llm_result.content:
237
- unfinished = _unfinished_plan(session)
238
- if unfinished:
239
- await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}))
240
- """
241
- marker = " # -- End of turn --"
242
- if marker in c:
243
- c = c.replace(marker, check + "\n" + marker)
244
-
245
- with open(agent_py, 'w') as f: f.write(c)
246
- print("OK: agent_loop.py")
247
-
248
- if __name__ == "__main__":
249
- patch_events()
250
- patch_sse()
251
- patch_hook()
252
- patch_chat_input()
253
- patch_session()
254
- patch_agent()
255
- print("\nDONE: V7 - precise patches applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v8.py DELETED
@@ -1,239 +0,0 @@
1
- """V8: Safe auto-continue via useEffect pattern (no hoisting issues)."""
2
-
3
- import os
4
-
5
- events_ts = "/source/frontend/src/types/events.ts"
6
- sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
7
- hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
8
- input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
9
- session_tsx = "/source/frontend/src/components/SessionChat.tsx"
10
- agent_py = "/app/agent/core/agent_loop.py"
11
-
12
-
13
- def patch_events():
14
- with open(events_ts) as f:
15
- c = f.read()
16
- if "'task_incomplete'" not in c:
17
- c = c.replace(
18
- " | 'interrupted'",
19
- " | 'interrupted'\n | 'task_incomplete'"
20
- )
21
- with open(events_ts, 'w') as f: f.write(c)
22
- print("OK: events.ts")
23
-
24
-
25
- def patch_sse():
26
- with open(sse_ts) as f:
27
- c = f.read()
28
-
29
- if "onTaskIncomplete:" not in c:
30
- c = c.replace(
31
- " onInterrupted: () => void;",
32
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
33
- )
34
-
35
- if "case 'task_incomplete'" not in c:
36
- case_handler = """ case 'task_incomplete':
37
- sideChannel.onTaskIncomplete(
38
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
39
- );
40
- break;
41
-
42
- default:"""
43
- c = c.replace("\n default:", "\n" + case_handler)
44
-
45
- with open(sse_ts, 'w') as f: f.write(c)
46
- print("OK: sse.ts")
47
-
48
-
49
- def patch_hook():
50
- with open(hook_ts) as f:
51
- c = f.read()
52
-
53
- # 1. Import useState
54
- if "useState" not in c:
55
- c = c.replace(
56
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
57
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
58
- )
59
-
60
- # 2. State after callbacksRef
61
- state = """
62
- // Auto-continue state for free models
63
- const [_showAc, _setShowAc] = useState(false);
64
- const _acPlanRef = useRef<Array<{ id: string; content: string; status: string }>>([]);"""
65
- if "_showAc" not in c:
66
- c = c.replace(
67
- "callbacksRef.current = { onReady, onError, onSessionDead };",
68
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state
69
- )
70
-
71
- # 3. onTaskIncomplete in interface
72
- if "onTaskIncomplete:" not in c:
73
- c = c.replace(
74
- " onInterrupted: () => void;",
75
- " onInterrupted: () => void;\n onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => void;"
76
- )
77
-
78
- # 4. Handler in sideChannel (only set state + ref, no chat.sendMessage)
79
- handler = """ onTaskIncomplete: (incompletePlan: Array<{ id: string; content: string; status: string }>) => {
80
- _acPlanRef.current = incompletePlan;
81
- _setShowAc(true);
82
- },
83
- onInterrupted: () => { /* no-op */ },"""
84
-
85
- if "onTaskIncomplete: (incompletePlan" not in c:
86
- old = ' onInterrupted: () => { /* no-op \u2014 handled by stop() caller */ },'
87
- if old in c:
88
- c = c.replace(old, handler)
89
- else:
90
- c = c.replace(' onInterrupted: () => { /* no-op */ },', handler)
91
-
92
- # 5. useEffect to watch _showAc - runs timer, calls chat.sendMessage via chatActionsRef
93
- effect = """
94
- // Auto-continue effect: when _showAc becomes true, start 10s timer
95
- useEffect(() => {
96
- if (!_showAc) return;
97
- const timer = setTimeout(() => {
98
- const plan = _acPlanRef.current;
99
- const setMsgs = chatActionsRef.current.setMessages;
100
- const msgs = chatActionsRef.current.messages;
101
- if (setMsgs && plan.length > 0) {
102
- const planStr = plan.map(i => `- ${i.content}`).join('\\\\n');
103
- 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: '' };
104
- setMsgs([...msgs, newMsg]);
105
- }
106
- _setShowAc(false);
107
- }, 10000);
108
- return () => clearTimeout(timer);
109
- }, [_showAc]);
110
- """
111
- if "Auto-continue effect" not in c:
112
- # Insert before return
113
- c = c.replace("\n return {\n messages: chat.messages,", effect + "\n return {\n messages: chat.messages,")
114
-
115
- # 6. Cancel + return
116
- cancel = """
117
- const _acCancel = useCallback(() => { _setShowAc(false); }, []);"""
118
- if "_acCancel" not in c:
119
- c = c.replace("\n return {\n messages: chat.messages,", cancel + "\n return {\n messages: chat.messages,")
120
- if "_showAc," not in c:
121
- c = c.replace(
122
- "refreshMessages,\n };",
123
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
124
- )
125
-
126
- with open(hook_ts, 'w') as f: f.write(c)
127
- print("OK: useAgentChat.ts")
128
-
129
-
130
- def patch_chat_input():
131
- with open(input_tsx) as f:
132
- c = f.read()
133
-
134
- if "Button" not in c:
135
- c = c.replace(" Tooltip,", " Tooltip,\n Button,")
136
-
137
- if "_showAc" not in c:
138
- old_iface = """interface ChatInputProps {
139
- sessionId?: string;
140
- initialModelPath?: string | null;
141
- onSend: (text: string) => void;
142
- onStop?: () => void;
143
- onDatasetUploaded?: () => Promise<boolean> | boolean;
144
- isProcessing?: boolean;
145
- disabled?: boolean;
146
- placeholder?: string;
147
- }"""
148
- new_iface = """interface ChatInputProps {
149
- sessionId?: string;
150
- initialModelPath?: string | null;
151
- onSend: (text: string) => void;
152
- onStop?: () => void;
153
- onDatasetUploaded?: () => Promise<boolean> | boolean;
154
- isProcessing?: boolean;
155
- disabled?: boolean;
156
- placeholder?: string;
157
- _showAc?: boolean;
158
- _acCancel?: () => void;
159
- }"""
160
- c = c.replace(old_iface, new_iface)
161
-
162
- old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
163
- new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
164
- if old_props not in c:
165
- old_props2 = "placeholder = 'Ask anything...' }: ChatInputProps) {"
166
- new_props2 = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
167
- c = c.replace(old_props2, new_props2)
168
- else:
169
- c = c.replace(old_props, new_props)
170
-
171
- if "T\u1ea1m d\u1eebng" not in c:
172
- btn = """
173
- {_showAc && _acCancel && (
174
- <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
175
- <Button variant="outlined" size="small" onClick={_acCancel}
176
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
177
- T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
178
- </Button>
179
- </Box>
180
- )}
181
- """
182
- if "<JobsUpgradeDialog" in c:
183
- c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
184
-
185
- with open(input_tsx, 'w') as f: f.write(c)
186
- print("OK: ChatInput.tsx")
187
-
188
-
189
- def patch_session():
190
- if not os.path.exists(session_tsx):
191
- print(f"SKIP: {session_tsx}")
192
- return
193
- with open(session_tsx) as f:
194
- c = f.read()
195
- if "_showAc" not in c:
196
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
197
- with open(session_tsx, 'w') as f: f.write(c)
198
- print("OK: SessionChat.tsx")
199
-
200
-
201
- def patch_agent():
202
- if not os.path.exists(agent_py):
203
- print(f"SKIP: {agent_py}")
204
- return
205
- with open(agent_py) as f:
206
- c = f.read()
207
-
208
- if "_unfinished_plan" not in c:
209
- fn = """
210
- def _unfinished_plan(s: Session) -> list[dict[str, str]]:
211
- p = getattr(s, "current_plan", None) or []
212
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
213
- """
214
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
215
-
216
- if "task_incomplete" not in c:
217
- check = """
218
- # Auto-continue detection
219
- if not llm_result.tool_calls_acc and llm_result.content:
220
- unfinished = _unfinished_plan(session)
221
- if unfinished:
222
- await session.send_event(Event(event_type="task_incomplete", data={"incomplete_plan": unfinished}))
223
- """
224
- marker = " # -- End of turn --"
225
- if marker in c:
226
- c = c.replace(marker, check + "\n" + marker)
227
-
228
- with open(agent_py, 'w') as f: f.write(c)
229
- print("OK: agent_loop.py")
230
-
231
-
232
- if __name__ == "__main__":
233
- patch_events()
234
- patch_sse()
235
- patch_hook()
236
- patch_chat_input()
237
- patch_session()
238
- patch_agent()
239
- print("\nDONE: V8 - safe useEffect pattern")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_auto_continue_v9.py DELETED
@@ -1,227 +0,0 @@
1
- """V9: SIMPLEST approach - no interface changes, no props chain.
2
-
3
- Uses CustomEvent to communicate between sideChannel and React state.
4
- This avoids ALL TypeScript issues:
5
- - No interface changes needed
6
- - No hoisting issues
7
- - No unused variable warnings
8
- """
9
-
10
- import os
11
-
12
- events_ts = "/source/frontend/src/types/events.ts"
13
- sse_ts = "/source/frontend/src/lib/sse-chat-transport.ts"
14
- hook_ts = "/source/frontend/src/hooks/useAgentChat.ts"
15
- input_tsx = "/source/frontend/src/components/Chat/ChatInput.tsx"
16
- agent_py = "/app/agent/core/agent_loop.py"
17
-
18
-
19
- def patch_events():
20
- with open(events_ts) as f:
21
- c = f.read()
22
- if "'task_incomplete'" not in c:
23
- c = c.replace(" | 'interrupted'", " | 'interrupted'\n | 'task_incomplete'")
24
- with open(events_ts, 'w') as f: f.write(c)
25
- print("OK: events.ts")
26
-
27
-
28
- def patch_sse():
29
- with open(sse_ts) as f:
30
- c = f.read()
31
-
32
- # Add case handler DIRECTLY without changing interface
33
- # Use as any cast to bypass type checking
34
- if "case 'task_incomplete'" not in c:
35
- # Add handler before default, using (sideChannel as any) to avoid interface issue
36
- case_block = """ case 'task_incomplete':
37
- (sideChannel as any).onTaskIncomplete(
38
- (event.data?.incomplete_plan as Array<{ id: string; content: string; status: string }>) || [],
39
- );
40
- break;
41
-
42
- default:"""
43
- c = c.replace("\n default:", "\n" + case_block)
44
-
45
- with open(sse_ts, 'w') as f: f.write(c)
46
- print("OK: sse.ts")
47
-
48
-
49
- def patch_hook():
50
- with open(hook_ts) as f:
51
- c = f.read()
52
-
53
- # 1. Import useState
54
- if "useState" not in c:
55
- c = c.replace(
56
- "import { useCallback, useEffect, useMemo, useRef } from 'react';",
57
- "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';"
58
- )
59
-
60
- # 2. Add state after callbacksRef - TS will complain "unused" but _ prefix helps
61
- state_block = """
62
- // Auto-continue for free models
63
- const [_showAc, _setShowAc] = useState(false);"""
64
- if "_showAc" not in c:
65
- c = c.replace(
66
- "callbacksRef.current = { onReady, onError, onSessionDead };",
67
- "callbacksRef.current = { onReady, onError, onSessionDead };" + state_block
68
- )
69
-
70
- # 3. Add onTaskIncomplete to the sideChannel object via (sideChannel as any)
71
- # We use the onSessionUpdate handler as injection point since it already exists
72
- handler_block = """ onSessionUpdate: (data) => {
73
- // Check for auto-continue trigger
74
- if ((data as any).ac_plan) {
75
- const plan = (data as any).ac_plan as Array<{ id: string; content: string; status: string }>;
76
- _setShowAc(true);
77
- setTimeout(() => {
78
- _setShowAc(false);
79
- if (plan.length > 0) {
80
- const planStr = plan.map(i => `- ${i.content}`).join('\\\\n');
81
- chat.sendMessage({ text: `[TIẾP TỤC] Task chưa hoàn thành:\\\\n${planStr}`, metadata: { createdAt: new Date().toISOString() } });
82
- }
83
- }, 10000);
84
- return;
85
- }
86
- const autoApproval = data.auto_approval;"""
87
-
88
- if "_setShowAc" in c and "ac_plan" not in c:
89
- # Find existing onSessionUpdate
90
- old = """ onSessionUpdate: (data) => {
91
- const autoApproval = data.auto_approval;"""
92
- if old in c:
93
- c = c.replace(old, handler_block)
94
- print("OK: injected onSessionUpdate handler")
95
-
96
- # 4. Cancel function + return values
97
- cancel = """
98
- const _acCancel = useCallback(() => { _setShowAc(false); }, []);"""
99
- if "_acCancel" not in c and "_showAc" in c:
100
- c = c.replace("\n return {\n messages: chat.messages,", cancel + "\n return {\n messages: chat.messages,")
101
- c = c.replace(
102
- "refreshMessages,\n };",
103
- "refreshMessages,\n _showAc,\n _acCancel,\n };"
104
- )
105
-
106
- with open(hook_ts, 'w') as f: f.write(c)
107
- print("OK: useAgentChat.ts")
108
-
109
-
110
- def patch_chat_input():
111
- with open(input_tsx) as f:
112
- c = f.read()
113
-
114
- if "Button" not in c:
115
- c = c.replace(" Tooltip,", " Tooltip,\n Button,")
116
-
117
- if "_showAc" not in c:
118
- old_iface = """interface ChatInputProps {
119
- sessionId?: string;
120
- initialModelPath?: string | null;
121
- onSend: (text: string) => void;
122
- onStop?: () => void;
123
- onDatasetUploaded?: () => Promise<boolean> | boolean;
124
- isProcessing?: boolean;
125
- disabled?: boolean;
126
- placeholder?: string;
127
- }"""
128
- new_iface = """interface ChatInputProps {
129
- sessionId?: string;
130
- initialModelPath?: string | null;
131
- onSend: (text: string) => void;
132
- onStop?: () => void;
133
- onDatasetUploaded?: () => Promise<boolean> | boolean;
134
- isProcessing?: boolean;
135
- disabled?: boolean;
136
- placeholder?: string;
137
- _showAc?: boolean;
138
- _acCancel?: () => void;
139
- }"""
140
- c = c.replace(old_iface, new_iface)
141
-
142
- # Add destructured props
143
- old_props = "placeholder = 'Ask anything...' }: ChatInputProps) {"
144
- new_props = "placeholder = 'Ask anything...', _showAc = false, _acCancel }: ChatInputProps) {"
145
- c = c.replace(old_props, new_props)
146
-
147
- if "T\u1ea1m d\u1eebng" not in c:
148
- btn = """
149
- {_showAc && _acCancel && (
150
- <Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
151
- <Button variant="outlined" size="small" onClick={_acCancel}
152
- sx={{ textTransform: 'none', fontSize: '0.7rem', opacity: 0.7, '&:hover': { opacity: 1 } }}>
153
- T\u1ea1m d\u1eebng t\u1ef1 \u0111\u1ed9ng ti\u1ebfp t\u1ee5c (10s)
154
- </Button>
155
- </Box>
156
- )}
157
- """
158
- if "<JobsUpgradeDialog" in c:
159
- c = c.replace("<JobsUpgradeDialog", btn + "\n <JobsUpgradeDialog")
160
-
161
- with open(input_tsx, 'w') as f: f.write(c)
162
- print("OK: ChatInput.tsx")
163
-
164
-
165
- def patch_session():
166
- session_tsx = "/source/frontend/src/components/SessionChat.tsx"
167
- if not os.path.exists(session_tsx):
168
- print(f"SKIP: {session_tsx}")
169
- return
170
- with open(session_tsx) as f:
171
- c = f.read()
172
- if "_showAc" not in c:
173
- c = c.replace("<ChatInput ", "<ChatInput _showAc={_showAc} _acCancel={_acCancel} ")
174
- with open(session_tsx, 'w') as f: f.write(c)
175
- print("OK: SessionChat.tsx")
176
-
177
-
178
- def patch_agent():
179
- if not os.path.exists(agent_py):
180
- print(f"SKIP: {agent_py}")
181
- return
182
- with open(agent_py) as f:
183
- c = f.read()
184
-
185
- if "_unfinished_plan" not in c:
186
- fn = """
187
- def _unfinished_plan(s: Session) -> list[dict[str, str]]:
188
- p = getattr(s, "current_plan", None) or []
189
- return [it for it in p if it.get("status") in ("pending", "in_progress")]
190
- """
191
- c = c.replace("class Handlers:", fn + "\n\nclass Handlers:")
192
-
193
- if "task_incomplete" not in c:
194
- check = """
195
- # Auto-continue detection
196
- if not llm_result.tool_calls_acc and llm_result.content:
197
- unfinished = _unfinished_plan(session)
198
- if unfinished:
199
- # Use session_update event with ac_plan field to trigger auto-continue
200
- await session.send_event(Event(event_type="session_update", data={
201
- "ac_plan": unfinished,
202
- "auto_approval": getattr(session, "auto_approval", None),
203
- }))
204
- """
205
- marker = " # -- End of turn --"
206
- if marker in c:
207
- c = c.replace(marker, check + "\n" + marker)
208
- else:
209
- print("WARN: Could not find -- End of turn -- marker")
210
- # Try alternative: find where final_response is set
211
- marker2 = "final_response = llm_result.content or None"
212
- if marker2 in c:
213
- c = c.replace(marker2, marker2 + "\n" + check)
214
-
215
- with open(agent_py, 'w') as f: f.write(c)
216
- print("OK: agent_loop.py")
217
-
218
-
219
- if __name__ == "__main__":
220
- patch_events()
221
- patch_sse()
222
- patch_hook()
223
- patch_chat_input()
224
- patch_session()
225
- patch_agent()
226
- print("\nDONE: V9 applied")
227
- print("Strategy: Uses existing session_update event + (sideChannel as any) to avoid TS issues")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_beatapi_routing.py DELETED
@@ -1,182 +0,0 @@
1
- """Patch llm_params.py: add a DEDICATED BeatAPI provider branch.
2
-
3
- BEATAPI vs OpenRouter -- why this branch is separate and must stay separate
4
- =========================================================================
5
- Every other model in this app except the HF Router / local-ollama ones is an
6
- *OpenRouter* model. They are all tagged with an `openai/` prefix, and
7
- _resolve_llm_params has a single catch-all that:
8
-
9
- 1. strips the `openai/` prefix,
10
- 2. points api_base at https://openrouter.ai/api/v1,
11
- 3. authenticates with OPENROUTER_API_KEY.
12
-
13
- BeatAPI (https://api.beatapi.io) is a *different provider* that happens to
14
- expose an OpenAI-compatible surface. It must therefore NOT reuse that branch:
15
-
16
- provider OpenRouter BeatAPI
17
- ----------- ----------------------------- ------------------------------
18
- api_base https://openrouter.ai/api/v1 https://api.beatapi.io/v1
19
- key env OPENROUTER_API_KEY BEATAPI_API_KEY
20
- model id vendor/model space-bunny-alpha (bare, no vendor prefix)
21
- effort unsupported (omitted) low/medium/high/xhigh/max via extra_body
22
-
23
- BeatAPI additionally:
24
- - does not understand the `openai/` prefix at all, so sending
25
- "openai/space-bunny-alpha" or "openai/stealth/space-bunny-alpha" 404s;
26
- - uses `space-bunny-alpha` as a *flat* model id, so we must strip our own
27
- `beatapi/` routing prefix before the request leaves the app;
28
- - rate-limits the free tier (1 req/min before first top-up, 10/min after,
29
- 1000/day) and returns HTTP 429 with Retry-After.
30
-
31
- Therefore BeatAPI models use the `beatapi/` prefix -- a namespace nothing else
32
- uses -- so this branch can never be shadowed by the `openai/` catch-all, and
33
- the OpenRouter branch can never accidentally strip a BeatAPI model id.
34
-
35
- If BEATAPI_API_KEY is unset we raise a clear, actionable error instead of
36
- silently sending an empty key (which BeatAPI answers with a bare 401
37
- missing_api_key).
38
-
39
- Runs AFTER patch_models.py and patch_openrouter_fix.py. Idempotent.
40
- """
41
-
42
- import ast
43
-
44
- FILE = "/app/agent/core/llm_params.py"
45
-
46
- BEATAPI_BASE_URL = "https://api.beatapi.io/v1"
47
- BEATAPI_MODEL_PREFIX = "beatapi/"
48
-
49
-
50
- with open(FILE) as f:
51
- c = f.read()
52
-
53
- if "BeatApiKeyMissingError" in c:
54
- print("OK: llm_params.py already contains BeatAPI routing (idempotent)")
55
- raise SystemExit(0)
56
-
57
- if "\nimport os\n" not in c:
58
- print("FAIL: expected `import os` in llm_params.py")
59
- raise SystemExit(1)
60
-
61
- # --- Insert the BeatAPI block before _resolve_llm_params ---
62
- anchor = "def _resolve_llm_params(\n"
63
- if anchor not in c:
64
- print("FAIL: could not find _resolve_llm_params definition")
65
- raise SystemExit(1)
66
-
67
- beatapi_block = f'''BEATAPI_BASE_URL = "{BEATAPI_BASE_URL}"
68
- BEATAPI_MODEL_PREFIX = "{BEATAPI_MODEL_PREFIX}"
69
- # BeatAPI accepts low/medium/high/xhigh/max (HF Router only low/medium/high).
70
- _BEATAPI_EFFORTS = {{"low", "medium", "high", "xhigh", "max"}}
71
-
72
-
73
- class BeatApiKeyMissingError(ValueError):
74
- """BEATAPI_API_KEY is not configured, so BeatAPI cannot be called.
75
-
76
- Raised before any network call so the UI shows an actionable message
77
- ("set BEATAPI_API_KEY") instead of an opaque 401 missing_api_key.
78
- """
79
-
80
-
81
- def _beatapi_base_model(model_name: str) -> str:
82
- """Return the wire model id for a BeatAPI request.
83
-
84
- ``beatapi/space-bunny-alpha`` -> ``space-bunny-alpha``
85
- BeatAPI model ids are flat; they must never carry our routing prefix.
86
- """
87
- return model_name[len(BEATAPI_MODEL_PREFIX):].strip()
88
-
89
-
90
- def _resolve_beatapi_params(
91
- model_name: str,
92
- reasoning_effort: str | None = None,
93
- strict: bool = False,
94
- ) -> dict:
95
- """Build LiteLLM kwargs for a BeatAPI (``beatapi/``-prefixed) model.
96
-
97
- Mirrors the OpenRouter branch but targets api.beatapi.io, uses
98
- BEATAPI_API_KEY, forces LiteLLM's OpenAI-compatible client, and forwards
99
- BeatAPI's own reasoning_effort levels (it accepts xhigh and max, which the
100
- HF Router does not).
101
- """
102
- base_model = _beatapi_base_model(model_name)
103
- if not base_model:
104
- raise ValueError(f"Missing BeatAPI model id: {{model_name!r}}")
105
-
106
- api_key = os.environ.get("BEATAPI_API_KEY", "").strip()
107
- if not api_key:
108
- raise BeatApiKeyMissingError(
109
- "BEATAPI_API_KEY is not set. BeatAPI models (beatapi/...) require a "
110
- "BeatAPI key from https://beatapi.io (Dashboard -> API Keys); "
111
- "set it as a Space secret named BEATAPI_API_KEY."
112
- )
113
-
114
- params: dict = {{
115
- "model": f"openai/{{base_model}}",
116
- "api_base": BEATAPI_BASE_URL,
117
- "api_key": api_key,
118
- # Force LiteLLM's OpenAI-compatible client. Without this, LiteLLM would
119
- # try to resolve a provider from the model string and raise
120
- # "LLM Provider NOT provided" (same class of bug as the OpenRouter fix).
121
- "custom_llm_provider": "openai",
122
- }}
123
-
124
- if reasoning_effort:
125
- level = reasoning_effort
126
- if level not in _BEATAPI_EFFORTS:
127
- if strict:
128
- raise UnsupportedEffortError(
129
- f"BeatAPI doesn't accept effort={{level!r}}"
130
- )
131
- else:
132
- # BeatAPI reads reasoning_effort from the request body directly.
133
- params["extra_body"] = {{"reasoning_effort": level}}
134
- return params
135
-
136
-
137
- '''
138
-
139
- c = c.replace(anchor, beatapi_block + anchor, 1)
140
-
141
- # --- Add the dispatch inside _resolve_llm_params, BEFORE the openai/ catch-all ---
142
- # The dispatch must sit after the local-model checks and before the OpenRouter
143
- # branch so `beatapi/` can never be swallowed by `openai/`.
144
- dispatch_anchor = " hf_model = normalized_model\n"
145
- if dispatch_anchor not in c:
146
- print("FAIL: could not find hf_model assignment in _resolve_llm_params")
147
- raise SystemExit(1)
148
-
149
- dispatch = """ # --- BeatAPI provider (beatapi/ prefix) -------------------------------
150
- # NOTE: distinct from the OpenRouter branch below on api_base, API key,
151
- # model-id shape and reasoning_effort levels. Keep this check above the
152
- # openai/ catch-all so the two providers can never collide.
153
- if normalized_model.startswith(BEATAPI_MODEL_PREFIX):
154
- return _resolve_beatapi_params(
155
- normalized_model, reasoning_effort, strict
156
- )
157
-
158
- """
159
-
160
- c = c.replace(dispatch_anchor, dispatch + dispatch_anchor, 1)
161
-
162
- with open(FILE, "w") as f:
163
- f.write(c)
164
-
165
- try:
166
- ast.parse(c)
167
- print("OK: llm_params.py patched & syntax valid")
168
- except SyntaxError as e:
169
- print(f"FAIL: llm_params.py syntax error: {e}")
170
- raise
171
-
172
- # Sanity: the BeatAPI branch must appear before the openai/ catch-all.
173
- beat_i = c.find("startswith(BEATAPI_MODEL_PREFIX)")
174
- openai_i = c.find('startswith("openai/")')
175
- if beat_i != -1 and openai_i != -1:
176
- if beat_i < openai_i:
177
- print("OK: BeatAPI dispatch precedes the openai/ catch-all")
178
- else:
179
- print("FAIL: BeatAPI dispatch is AFTER the openai/ catch-all")
180
- raise SystemExit(1)
181
-
182
- print("DONE: BeatAPI routing branch added to llm_params.py")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_catch_up_dataset_fallback.py DELETED
@@ -1,102 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch frontend: Add dataset fallback to ExpiredBanner handleCatchUp.
4
-
5
- When localStorage caches (backend-messages, ui-messages) are empty
6
- (user cleared cache, new browser, Space restarted), the catch-up flow
7
- shows "Nothing to summarize from this chat.". This patch adds a fallback
8
- that fetches messages from /api/session-history/{session_id} — which reads
9
- from the permanent HF dataset (bep40/ml-intern-sessions).
10
-
11
- This ensures the "Catch me up" button always works as long as the
12
- session was previously logged to the permanent dataset.
13
- """
14
-
15
- import os
16
- import sys
17
-
18
- PATCHED_MARKER = "// === PATCH: catch-up dataset fallback ==="
19
- TARGET_FILE = "/source/frontend/src/components/Chat/ExpiredBanner.tsx"
20
-
21
-
22
- def patch_expired_banner(content: str) -> tuple[str, bool]:
23
- changed = False
24
-
25
- if PATCHED_MARKER in content:
26
- print(f" SKIP: already patched")
27
- return content, False
28
-
29
- # Add a fallback to /api/session-history/{session_id} before the error.
30
- # The old block checks if messages array is empty and bails with error.
31
- old_block = (
32
- " if (!messages || messages.length === 0) {\n"
33
- " setError('Nothing to summarize from this chat.');\n"
34
- " setBusy(null);\n"
35
- " return;\n"
36
- " }\n"
37
- )
38
- new_block = (
39
- PATCHED_MARKER + "\n"
40
- " // Fallback: when localStorage caches are empty (Space restart,\n"
41
- " // new browser, cache cleared), read from the permanent HF dataset\n"
42
- " // via /api/session-history/{session_id}.\n"
43
- " if (!messages || messages.length === 0) {\n"
44
- " try {\n"
45
- " const res = await apiFetch(`/api/session-history/${sessionId}`);\n"
46
- " if (res.ok) {\n"
47
- " const history = await res.json();\n"
48
- " if (Array.isArray(history) && history.length > 0) {\n"
49
- " messages = history.map((row: any) => ({\n"
50
- " role: row.role || 'assistant',\n"
51
- " content: row.content || '',\n"
52
- " }));\n"
53
- " }\n"
54
- " }\n"
55
- " } catch {\n"
56
- " /* dataset fetch failed — continue to error below */\n"
57
- " }\n"
58
- " }\n"
59
- " if (!messages || messages.length === 0) {\n"
60
- " setError('Nothing to summarize from this chat.');\n"
61
- " setBusy(null);\n"
62
- " return;\n"
63
- " }\n"
64
- )
65
- if old_block in content:
66
- content = content.replace(old_block, new_block, 1)
67
- changed = True
68
- print(" + Added dataset fallback to handleCatchUp")
69
- else:
70
- print(" SKIP: target block not found (pattern may have changed)")
71
-
72
- return content, changed
73
-
74
-
75
- def main():
76
- if not os.path.exists(TARGET_FILE):
77
- print(f"SKIP: {TARGET_FILE} not found — frontend source not available")
78
- return
79
-
80
- with open(TARGET_FILE, "r", encoding="utf-8") as f:
81
- content = f.read()
82
-
83
- if PATCHED_MARKER in content:
84
- print(f"SKIP: {TARGET_FILE} already patched")
85
- return
86
-
87
- content, changed = patch_expired_banner(content)
88
- if changed:
89
- with open(TARGET_FILE, "w", encoding="utf-8") as f:
90
- f.write(content)
91
- print(f"OK: {TARGET_FILE} patched with dataset fallback")
92
- else:
93
- print(f"SKIP: {TARGET_FILE} — no changes applied")
94
-
95
-
96
- if __name__ == "__main__":
97
- try:
98
- main()
99
- except Exception as e:
100
- print(f"WARN: patch_catch_up_dataset_fallback.py error: {e}")
101
- # Never fail the Docker build
102
- sys.exit(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_catch_up_fix_backend.py DELETED
@@ -1,220 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch: Fix catch-up backend robustness.
4
-
5
- Issues fixed:
6
- 1. /api/session-history/{session_id} returns ALL rows including 'event' type
7
- rows (role='event'). These cause Message.model_validate to silently drop them.
8
- Fix: Filter out non-message roles (event, system) from history endpoint.
9
-
10
- 2. seed_from_summary raises if summarization fails (API error, rate limit, credits),
11
- causing restore-summary to return 500 and the frontend to show "Couldn't catch up."
12
- Fix: Fall back to raw messages if summarization fails.
13
-
14
- 3. Event rows with role='event' fail Message.model_validate.
15
- Fix: Skip event rows explicitly in seed_from_summary.
16
- """
17
-
18
- import ast
19
- import os
20
- import sys
21
-
22
- PATCHED_MARKER = "# === PATCH: catch-up fix (backend) ==="
23
-
24
-
25
- def _safe_replace(content, old, new, count=1):
26
- if old in content:
27
- content = content.replace(old, new, count)
28
- return content, True
29
- return content, False
30
-
31
-
32
- def patch_file(path, patch_fn):
33
- if not os.path.exists(path):
34
- print(f"SKIP: {path} not found")
35
- return False
36
- try:
37
- with open(path, "r") as f:
38
- content = f.read()
39
- content, changed = patch_fn(content)
40
- if not changed:
41
- print(f"SKIP: {path} — no changes needed")
42
- return False
43
- try:
44
- ast.parse(content)
45
- except SyntaxError as e:
46
- print(f"FAIL: {path} syntax: {e}")
47
- return False
48
- with open(path, "w") as f:
49
- f.write(content)
50
- print(f"OK: {path} patched (catch-up fix)")
51
- return True
52
- except Exception as e:
53
- print(f"WARN: {path} patch error: {e}")
54
- return False
55
-
56
-
57
- # ─── Patch routes/agent.py: filter event rows from session-history ─────────
58
- print("=== Patching routes/agent.py (session-history filter) ===")
59
-
60
- ROUTES_AGENT = "/app/backend/routes/agent.py"
61
-
62
- def _patch_routes(content):
63
- changed = False
64
-
65
- if PATCHED_MARKER in content:
66
- print(" SKIP: already patched")
67
- return content, False
68
-
69
- # Patch the get_session_history endpoint to filter out event rows
70
- old_endpoint = (
71
- '@router.get("/session-history/{session_id}")\n'
72
- 'async def get_session_history(\n'
73
- ' session_id: str, user: dict = Depends(get_current_user)\n'
74
- ') -> list:\n'
75
- ' """Get full message history for a session from the permanent dataset."""\n'
76
- ' from backend.session_logger import load_session_history\n'
77
- ' return load_session_history(session_id)'
78
- )
79
-
80
- new_endpoint = (
81
- '@router.get("/session-history/{session_id}")\n'
82
- 'async def get_session_history(\n'
83
- ' session_id: str, user: dict = Depends(get_current_user)\n'
84
- ') -> list:\n'
85
- ' """Get full message history for a session from the permanent dataset.\n'
86
- '\n'
87
- ' Returns only valid conversation messages (user/assistant/tool roles).\n'
88
- " Event rows (role='event') are filtered out because they are internal\n"
89
- ' tracing data that cannot be parsed as Message objects and would be\n'
90
- ' silently dropped during catch-up, causing message loss.\n'
91
- ' """\n'
92
- ' from backend.session_logger import load_session_history\n'
93
- ' rows = load_session_history(session_id)\n'
94
- ' # Filter out event rows and any rows without valid roles\n'
95
- ' valid_roles = {"user", "assistant", "tool"}\n'
96
- ' return [r for r in rows if r.get("role") in valid_roles]'
97
- )
98
-
99
- content, ok = _safe_replace(content, old_endpoint, new_endpoint)
100
- if ok:
101
- print(" + Filtered event rows from session-history endpoint")
102
- changed = True
103
- else:
104
- print(" SKIP: target block not found")
105
-
106
- # Add marker
107
- content = content.rstrip() + "\n\n" + PATCHED_MARKER + "\n"
108
- changed = True
109
-
110
- return content, changed
111
-
112
- patch_file(ROUTES_AGENT, _patch_routes)
113
-
114
- # ─── Patch session_manager.py: robust seed_from_summary ─────────────────────
115
- print("\n=== Patching session_manager.py (seed_from_summary robustness) ===")
116
-
117
- SESSION_MANAGER = "/app/backend/session_manager.py"
118
-
119
- def _patch_seed_from_summary(content):
120
- changed = False
121
-
122
- # Check if already patched
123
- if PATCHED_MARKER in content:
124
- print(" SKIP: already patched")
125
- return content, False
126
-
127
- # Patch 1: Skip event rows explicitly in seed_from_summary
128
- old_parse = (
129
- ' # Parse into Message objects, tolerating malformed entries.\n'
130
- ' parsed: list[Message] = []\n'
131
- ' for raw in messages:\n'
132
- ' if raw.get("role") == "system":\n'
133
- ' continue # the new session has its own system prompt\n'
134
- ' try:\n'
135
- ' parsed.append(Message.model_validate(raw))\n'
136
- ' except Exception as e:\n'
137
- ' logger.warning("Dropping malformed message during seed: %s", e)'
138
- )
139
-
140
- new_parse = (
141
- ' # Parse into Message objects, tolerating malformed entries.\n'
142
- ' # === PATCH: skip event rows (not just system) ===\n'
143
- ' parsed: list[Message] = []\n'
144
- ' for raw in messages:\n'
145
- ' role = raw.get("role", "")\n'
146
- ' if role in ("system", "event"):\n'
147
- ' continue # not conversation messages\n'
148
- ' try:\n'
149
- ' parsed.append(Message.model_validate(raw))\n'
150
- ' except Exception as e:\n'
151
- ' logger.warning("Dropping malformed message during seed: %s", e)'
152
- )
153
-
154
- content, ok = _safe_replace(content, old_parse, new_parse)
155
- if ok:
156
- print(" + Skip event rows explicitly in seed_from_summary")
157
- changed = True
158
-
159
- # Patch 2: Fall back to raw messages if summarization fails
160
- old_try = (
161
- ' try:\n'
162
- ' summary, _ = await summarize_messages(\n'
163
- ' parsed,\n'
164
- ' model_name=session.config.model_name,\n'
165
- ' hf_token=session.hf_token,\n'
166
- ' max_tokens=4000,\n'
167
- ' prompt=_RESTORE_PROMPT,\n'
168
- ' tool_specs=tool_specs,\n'
169
- ' session=session,\n'
170
- ' kind="restore",\n'
171
- ' )\n'
172
- ' except Exception as e:\n'
173
- ' logger.error("Summary call failed during seed: %s", e)\n'
174
- ' raise\n'
175
- '\n'
176
- ' seed = Message('
177
- )
178
-
179
- new_try = (
180
- ' # === PATCH: fall back to raw messages if summarization fails ===\n'
181
- ' summary = None\n'
182
- ' try:\n'
183
- ' summary, _ = await summarize_messages(\n'
184
- ' parsed,\n'
185
- ' model_name=session.config.model_name,\n'
186
- ' hf_token=session.hf_token,\n'
187
- ' max_tokens=4000,\n'
188
- ' prompt=_RESTORE_PROMPT,\n'
189
- ' tool_specs=tool_specs,\n'
190
- ' session=session,\n'
191
- ' kind="restore",\n'
192
- ' )\n'
193
- ' except Exception as e:\n'
194
- ' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\n'
195
- ' # Fall back: inject raw messages directly into the new session context\n'
196
- ' # This preserves the full conversation instead of losing it\n'
197
- ' for msg in parsed:\n'
198
- ' session.context_manager.items.append(msg)\n'
199
- ' self._touch(agent_session)\n'
200
- ' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n'
201
- ' return len(parsed)\n'
202
- '\n'
203
- ' seed = Message('
204
- )
205
-
206
- content, ok = _safe_replace(content, old_try, new_try)
207
- if ok:
208
- print(" + Fall back to raw messages if summarization fails")
209
- changed = True
210
-
211
- # Add marker
212
- if changed:
213
- content = content + "\n" + PATCHED_MARKER + "\n"
214
- changed = True
215
-
216
- return content, changed
217
-
218
- patch_file(SESSION_MANAGER, _patch_seed_from_summary)
219
-
220
- print("\n=== Backend catch-up fixes applied ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_catch_up_fix_frontend.py DELETED
@@ -1,289 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch: Fix frontend catch-up message loss.
4
-
5
- Issue: After catch-up succeeds, the new session's backend hydration
6
- fetches /api/session/{newId}/messages from the backend, which only
7
- contains the summary message. It then calls setMessages() and
8
- saveMessages(), OVERWRITING the old full messages that were moved to
9
- the new ID via renameSession -> moveMessages.
10
-
11
- Fix: In each hydration code path, only overwrite frontend messages if
12
- the backend returns MORE non-system messages than the frontend already has.
13
- If the frontend already has more messages (from localStorage), keep them.
14
-
15
- Also: Fix the catch-up dataset fallback in ExpiredBanner to properly
16
- filter event rows and handle malformed data.
17
-
18
- Runs during Docker build — never fails the build.
19
- """
20
-
21
- import ast
22
- import os
23
- import sys
24
-
25
- PATCHED_MARKER = "// PATCH: catch-up fix (frontend)"
26
-
27
-
28
- def _safe_replace(content, old, new, count=1):
29
- if old in content:
30
- content = content.replace(old, new, count)
31
- return content, True
32
- return content, False
33
-
34
-
35
- def _is_python_file(path):
36
- return path.endswith(".py")
37
-
38
-
39
- def patch_file(path, patch_fn):
40
- if not os.path.exists(path):
41
- print(f"SKIP: {path} not found")
42
- return False
43
- try:
44
- with open(path, "r", encoding="utf-8") as f:
45
- content = f.read()
46
- content, changed = patch_fn(content)
47
- if not changed:
48
- print(f"SKIP: {path} — no changes needed")
49
- return False
50
- # Only validate Python syntax for .py files
51
- if _is_python_file(path):
52
- try:
53
- ast.parse(content)
54
- except SyntaxError as e:
55
- print(f"FAIL: {path} syntax: {e}")
56
- return False
57
- with open(path, "w", encoding="utf-8") as f:
58
- f.write(content)
59
- print(f"OK: {path} patched (catch-up fix)")
60
- return True
61
- except Exception as e:
62
- print(f"WARN: {path} patch error: {e}")
63
- return False
64
-
65
-
66
- # ─── Patch useAgentChat.ts ──────────────────────────────────────────────────
67
- print("=== Patching useAgentChat.ts (prevent message overwrite) ===")
68
-
69
- USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
70
-
71
- def _patch_use_agent_chat(content):
72
- changed = False
73
-
74
- if PATCHED_MARKER in content:
75
- print(" SKIP: already patched")
76
- return content, False
77
-
78
- # Patch 1: onRecoverMessages
79
- # The pattern uses 10-space indentation (inside a nested callback)
80
- old_pattern1 = (
81
- " const setMsgs = chatActionsRef.current.setMessages;\n"
82
- " if (setMsgs && uiMsgs.length >= currentMessageCount) {\n"
83
- " setMsgs(uiMsgs);\n"
84
- " saveMessages(sessionId, uiMsgs);\n"
85
- " }"
86
- )
87
-
88
- new_pattern1 = (
89
- " // === PATCH: catch-up fix (frontend) ===\n"
90
- " // After catch-up, the backend only has a summary message but\n"
91
- " // the frontend (via localStorage moveMessages) has the full\n"
92
- " // old conversation. Don't overwrite if backend has fewer.\n"
93
- " const _currentNonSystem = chatActionsRef.current.messages.filter(\n"
94
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
95
- " );\n"
96
- " const _shouldOverwrite = uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0;\n"
97
- " const setMsgs = chatActionsRef.current.setMessages;\n"
98
- " if (setMsgs && _shouldOverwrite) {\n"
99
- " setMsgs(uiMsgs);\n"
100
- " saveMessages(sessionId, uiMsgs);\n"
101
- " }"
102
- )
103
-
104
- content, ok = _safe_replace(content, old_pattern1, new_pattern1)
105
- if ok:
106
- print(" + Prevented backend overwrite in onRecoverMessages")
107
- changed = True
108
- else:
109
- print(" INFO: onRecoverMessages pattern not found")
110
-
111
- # Patch 2: refreshMessages
112
- # The pattern uses 6-space indentation and ends with 'return true;'
113
- old_pattern2 = (
114
- " const setMsgs = chatActionsRef.current.setMessages;\n"
115
- " if (setMsgs && uiMsgs.length > 0) {\n"
116
- " setMsgs(uiMsgs);\n"
117
- " saveMessages(sessionId, uiMsgs);\n"
118
- " }\n"
119
- " return true;"
120
- )
121
-
122
- new_pattern2 = (
123
- " // === PATCH: catch-up fix (frontend) ===\n"
124
- " const _currentNonSystem2 = chatActionsRef.current.messages.filter(\n"
125
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
126
- " );\n"
127
- " const _shouldOverwrite2 = uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0;\n"
128
- " const setMsgs = chatActionsRef.current.setMessages;\n"
129
- " if (setMsgs && _shouldOverwrite2) {\n"
130
- " setMsgs(uiMsgs);\n"
131
- " saveMessages(sessionId, uiMsgs);\n"
132
- " }\n"
133
- " return true;"
134
- )
135
-
136
- content, ok2 = _safe_replace(content, old_pattern2, new_pattern2)
137
- if ok2:
138
- print(" + Prevented backend overwrite in refreshMessages")
139
- changed = True
140
- else:
141
- print(" INFO: refreshMessages pattern not found")
142
-
143
- # Patch 3: Main hydration useEffect (uses chat.setMessages)
144
- # There are multiple code paths with different indentation levels:
145
- # - 6-space indent: in some code paths
146
- # - 10-space indent: main hydration useEffect
147
- # - 14-space indent: hydrateMessages helper (appears twice)
148
- # We patch each indentation level separately.
149
-
150
- old_pattern3a = (
151
- " if (uiMsgs.length > 0) {\n"
152
- " chat.setMessages(uiMsgs);\n"
153
- " saveMessages(sessionId, uiMsgs);\n"
154
- " }"
155
- )
156
-
157
- new_pattern3a = (
158
- " // === PATCH: catch-up fix (frontend) ===\n"
159
- " // Don't overwrite frontend messages with backend's fewer messages\n"
160
- " const _currentMsgs = (chatActionsRef.current.messages || []).filter(\n"
161
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
162
- " );\n"
163
- " const _shouldSet = uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0;\n"
164
- " if (_shouldSet) {\n"
165
- " chat.setMessages(uiMsgs);\n"
166
- " saveMessages(sessionId, uiMsgs);\n"
167
- " }"
168
- )
169
-
170
- # 10-space indentation variant
171
- old_pattern3b = (
172
- " if (uiMsgs.length > 0) {\n"
173
- " chat.setMessages(uiMsgs);\n"
174
- " saveMessages(sessionId, uiMsgs);\n"
175
- " }"
176
- )
177
-
178
- new_pattern3b = (
179
- " // === PATCH: catch-up fix (frontend) ===\n"
180
- " const _currentMsgsB = (chatActionsRef.current.messages || []).filter(\n"
181
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
182
- " );\n"
183
- " const _shouldSetB = uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0;\n"
184
- " if (_shouldSetB) {\n"
185
- " chat.setMessages(uiMsgs);\n"
186
- " saveMessages(sessionId, uiMsgs);\n"
187
- " }"
188
- )
189
-
190
- # 14-space indentation variant
191
- old_pattern3c = (
192
- " if (uiMsgs.length > 0) {\n"
193
- " chat.setMessages(uiMsgs);\n"
194
- " saveMessages(sessionId, uiMsgs);\n"
195
- " }"
196
- )
197
-
198
- new_pattern3c = (
199
- " // === PATCH: catch-up fix (frontend) ===\n"
200
- " const _currentMsgsC = (chatActionsRef.current.messages || []).filter(\n"
201
- " (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
202
- " );\n"
203
- " const _shouldSetC = uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0;\n"
204
- " if (_shouldSetC) {\n"
205
- " chat.setMessages(uiMsgs);\n"
206
- " saveMessages(sessionId, uiMsgs);\n"
207
- " }"
208
- )
209
-
210
- count = 0
211
- for old_p, new_p in [(old_pattern3a, new_pattern3a), (old_pattern3b, new_pattern3b), (old_pattern3c, new_pattern3c)]:
212
- while True:
213
- content, ok3 = _safe_replace(content, old_p, new_p)
214
- if ok3:
215
- count += 1
216
- changed = True
217
- else:
218
- break
219
- if count > 0:
220
- print(f" + Prevented backend overwrite in {count} hydration code paths")
221
-
222
- if changed:
223
- content = content.replace(
224
- "import { logger } from '@/utils/logger';",
225
- "import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied",
226
- 1
227
- )
228
-
229
- return content, changed
230
-
231
- patch_file(USE_AGENT_CHAT, _patch_use_agent_chat)
232
-
233
- # ─── Patch ExpiredBanner.tsx: filter event rows in dataset fallback ─────────
234
- print("\n=== Patching ExpiredBanner.tsx (dataset fallback filter) ===")
235
-
236
- EXPIRED_BANNER = "/source/frontend/src/components/Chat/ExpiredBanner.tsx"
237
-
238
- def _patch_expired_banner(content):
239
- changed = False
240
-
241
- if PATCHED_MARKER in content:
242
- print(" SKIP: already patched")
243
- return content, False
244
-
245
- # Check if the dataset fallback is present (from patch_catch_up_dataset_fallback.py)
246
- if "/api/session-history/" not in content:
247
- print(" SKIP: no dataset fallback found in ExpiredBanner.tsx")
248
- return content, False
249
-
250
- # Patch the dataset fallback mapping to filter event rows
251
- old_mapping = (
252
- " messages = history.map((row: any) => ({\n"
253
- " role: row.role || 'assistant',\n"
254
- " content: row.content || '',\n"
255
- " }));"
256
- )
257
-
258
- new_mapping = (
259
- " // === PATCH: catch-up fix (frontend) ===\n"
260
- " // Filter out 'event' rows — they are internal tracing\n"
261
- " // data that cannot be parsed as Message objects\n"
262
- " const validRoles = new Set(['user', 'assistant', 'tool']);\n"
263
- " messages = history\n"
264
- " .filter((row: any) => validRoles.has(row.role))\n"
265
- " .map((row: any) => ({\n"
266
- " role: row.role,\n"
267
- " content: row.content || '',\n"
268
- " }));"
269
- )
270
-
271
- content, ok = _safe_replace(content, old_mapping, new_mapping)
272
- if ok:
273
- print(" + Filtered event rows in dataset fallback")
274
- changed = True
275
- else:
276
- print(" INFO: dataset fallback mapping pattern not found")
277
-
278
- if changed:
279
- content = content.replace(
280
- "import { logger } from '@/utils/logger';",
281
- "import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied",
282
- 1
283
- )
284
-
285
- return content, changed
286
-
287
- patch_file(EXPIRED_BANNER, _patch_expired_banner)
288
-
289
- print("\n=== Frontend catch-up fixes applied ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_chat_input.py CHANGED
@@ -1,4 +1,4 @@
1
- """Patch ChatInput.tsx - Fixed import for DatasetUploadResponse."""
2
 
3
  CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
4
 
@@ -18,24 +18,12 @@ def patch():
18
  "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
19
  )
20
 
21
- # 2. Fix DatasetUploadResponse import - check existing imports
22
  if "DatasetUploadResponse" not in content:
23
- # Find existing import from types/agent
24
- if "from '@/types/agent'" in content:
25
- content = content.replace(
26
- "from '@/types/agent'",
27
- "from '@/types/agent'"
28
- )
29
- # Add DatasetUploadResponse to existing import
30
- content = content.replace(
31
- "import type { ",
32
- "import type { DatasetUploadResponse, "
33
- )
34
- else:
35
- content = content.replace(
36
- "import { apiFetch } from '@/utils/api';",
37
- "import { apiFetch } from '@/utils/api';\nimport type { DatasetUploadResponse } from '@/types/agent';"
38
- )
39
 
40
  # 3. Add props to interface
41
  if "showAutoContinue" not in content:
@@ -55,6 +43,7 @@ interface ChatInputProps {
55
  cancelAutoContinue?: () => void;
56
  }
57
  '''
 
58
  match = re.search(r'interface ChatInputProps[^}]*[}][^}]*[}]', content, re.DOTALL)
59
  if match:
60
  content = content.replace(match.group(0), props_code)
@@ -92,6 +81,7 @@ interface ChatInputProps {
92
  '''
93
 
94
  if "Tự động tiếp tục" not in content:
 
95
  content = content.replace(
96
  "<Box sx={{ flex: 1 ",
97
  buttons_code + " <Box sx={{ flex: 1 "
 
1
+ """Patch ChatInput.tsx for auto-continue buttons."""
2
 
3
  CHAT_INPUT = "/source/frontend/src/components/Chat/ChatInput.tsx"
4
 
 
18
  "import { Box, IconButton, Stack, Tooltip, Button } from '@mui/material';"
19
  )
20
 
21
+ # 2. Add DatasetUploadResponse import if needed
22
  if "DatasetUploadResponse" not in content:
23
+ content = content.replace(
24
+ "import { apiFetch } from '@/utils/api';",
25
+ "import { apiFetch } from '@/utils/api';\nimport type { DatasetUploadResponse } from '@/types/agent';"
26
+ )
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  # 3. Add props to interface
29
  if "showAutoContinue" not in content:
 
43
  cancelAutoContinue?: () => void;
44
  }
45
  '''
46
+ # Find and replace existing interface
47
  match = re.search(r'interface ChatInputProps[^}]*[}][^}]*[}]', content, re.DOTALL)
48
  if match:
49
  content = content.replace(match.group(0), props_code)
 
81
  '''
82
 
83
  if "Tự động tiếp tục" not in content:
84
+ # Insert before <Box sx={{ flex: 1 }}
85
  content = content.replace(
86
  "<Box sx={{ flex: 1 ",
87
  buttons_code + " <Box sx={{ flex: 1 "
patch_document_upload.py DELETED
@@ -1,225 +0,0 @@
1
- """Patch: extend dataset uploads to support office/binary documents
2
- (PDF, images, Excel, Word, PPT, OpenDocument, text) and extract their text
3
- so the agent can understand the file content even on text-only LLM backends.
4
- """
5
-
6
- import ast
7
- import os
8
-
9
- FILE = "/app/backend/dataset_uploads.py"
10
-
11
- with open(FILE) as f:
12
- c = f.read()
13
-
14
- orig = c
15
-
16
- # ---- 1. Expand allowed extensions ----
17
- old_ext = 'ALLOWED_DATASET_EXTENSIONS = {"csv", "json", "jsonl"}'
18
- new_ext = (
19
- 'ALLOWED_DATASET_EXTENSIONS = {\n'
20
- ' "csv", "json", "jsonl",\n'
21
- ' "pdf",\n'
22
- ' "png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif",\n'
23
- ' "xlsx", "xlsm", "xls",\n'
24
- ' "docx", "doc",\n'
25
- ' "pptx",\n'
26
- ' "odt", "ods", "odp",\n'
27
- ' "txt", "md", "rtf", "log",\n'
28
- '}\n'
29
- 'BINARY_EXTRACTION_EXTS = {\n'
30
- ' "pdf",\n'
31
- ' "png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif",\n'
32
- ' "xlsx", "xlsm", "xls",\n'
33
- ' "docx", "doc",\n'
34
- ' "pptx",\n'
35
- ' "odt", "ods", "odp",\n'
36
- ' "rtf",\n'
37
- '}\n'
38
- )
39
- if old_ext in c:
40
- c = c.replace(old_ext, new_ext)
41
- print("OK: expanded ALLOWED_DATASET_EXTENSIONS")
42
-
43
- else:
44
- print("WARN: extension line not found")
45
-
46
- # ---- 1b. Update the legacy error message so no stale message remains ----
47
- old_msg = 'detail="Only .csv, .json, and .jsonl dataset files are supported."'
48
- new_msg = 'detail="Không hỗ trợ định dạng này. Các định dạng hỗ trợ: CSV, JSON, JSONL, PDF, ảnh, Excel (XLSX/XLSM/XLS), Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), và văn bản (TXT/MD/RTF/LOG)."'
49
- if old_msg in c:
50
- c = c.replace(old_msg, new_msg)
51
- print("OK: updated legacy error message")
52
- else:
53
- print("WARN: legacy dataset message not found (already updated?)")
54
-
55
-
56
- # ---- 2. Import the document parser ----
57
- if "document_parser" not in c:
58
- c = c.replace(
59
- "from huggingface_hub import HfApi\n",
60
- "from huggingface_hub import HfApi\n"
61
- "from document_parser import (\n"
62
- " extract_document_text,\n"
63
- " format_uploaded_document_context,\n"
64
- ")\n",
65
- )
66
- print("OK: imported document_parser")
67
- else:
68
- print("OK: document_parser already imported")
69
-
70
- # ---- 3. Extract text inside push_dataset_upload_to_hub ----
71
- # Find the return statement that constructs the DatasetUpload and inject
72
- # extraction before it.
73
- marker = " return DatasetUpload(\n"
74
- if marker in c and "extract_document_text" in c and "extracted_text" not in c:
75
- extraction_block = (
76
- " # Extract plain-text content so the agent can understand the file\n"
77
- " # even when the LLM backend has no native attachment support.\n"
78
- " extracted_text, extraction_warning = \"\", \"\"\n"
79
- " if dataset_format in BINARY_EXTRACTION_EXTS:\n"
80
- " try:\n"
81
- " await asyncio.to_thread(upload.file.seek, 0)\n"
82
- " raw_bytes = await asyncio.to_thread(upload.file.read)\n"
83
- " extracted_text, extraction_warning = await asyncio.to_thread(\n"
84
- " extract_document_text, safe_filename, raw_bytes\n"
85
- " )\n"
86
- " await asyncio.to_thread(upload.file.seek, 0)\n"
87
- " except Exception as exc: # never let extraction break the upload\n"
88
- " extraction_warning = f\"Could not extract text: {exc}\"\n"
89
- " extracted_text = \"\"\n"
90
- " await asyncio.to_thread(upload.file.seek, 0)\n"
91
- "\n"
92
- )
93
- c = c.replace(marker, extraction_block + marker)
94
- print("OK: inserted extraction block before DatasetUpload return")
95
- else:
96
- print("WARN: could not insert extraction block (marker or state issue)")
97
-
98
- # ---- 4. Thread the extracted fields into the DatasetUpload dataclass ----
99
- # Add fields to the dataclass
100
- if "extracted_text: str" not in c:
101
- c = c.replace(
102
- " load_dataset_snippet: str\n",
103
- " load_dataset_snippet: str\n"
104
- " extracted_text: str = \"\"\n"
105
- " extraction_warning: str = \"\"\n",
106
- )
107
- print("OK: added dataclass fields")
108
- else:
109
- print("OK: dataclass fields present")
110
-
111
- # Add to response_payload
112
- if '"extracted_text": self.extracted_text' not in c:
113
- c = c.replace(
114
- ' "load_dataset_snippet": self.load_dataset_snippet,\n',
115
- ' "load_dataset_snippet": self.load_dataset_snippet,\n'
116
- ' "extracted_text": self.extracted_text,\n'
117
- ' "extraction_warning": self.extraction_warning,\n',
118
- )
119
- print("OK: response_payload includes extracted_text")
120
- else:
121
- print("OK: response_payload present")
122
-
123
- # Add to the final constructor call
124
- if "extracted_text=extracted_text" not in c:
125
- c = c.replace(
126
- " load_dataset_snippet=snippet,\n"
127
- " )",
128
- " load_dataset_snippet=snippet,\n"
129
- " extracted_text=extracted_text,\n"
130
- " extraction_warning=extraction_warning,\n"
131
- " )",
132
- )
133
- print("OK: constructor passes extracted fields")
134
- else:
135
- print("OK: constructor present")
136
-
137
- try:
138
- ast.parse(c)
139
- with open(FILE, "w") as f:
140
- f.write(c)
141
- print("OK: dataset_uploads.py written & syntax valid")
142
- except SyntaxError as e:
143
- print(f"FAIL: dataset_uploads.py syntax error: {e}")
144
-
145
- # ---- Also patch models.py DatasetUploadResponse.format literal ----
146
- MODELS_FILE = "/app/backend/models.py"
147
- with open(MODELS_FILE) as f:
148
- mc = f.read()
149
-
150
- if 'Literal["csv", "json", "jsonl"]' in mc:
151
- mc = mc.replace(
152
- 'format: Literal["csv", "json", "jsonl"]',
153
- 'format: str',
154
- )
155
- # add new fields
156
- mc = mc.replace(
157
- " load_dataset_snippet: str\n",
158
- " load_dataset_snippet: str\n"
159
- " extracted_text: str = \"\"\n"
160
- " extraction_warning: str = \"\"\n",
161
- )
162
- with open(MODELS_FILE, "w") as f:
163
- f.write(mc)
164
- print("OK: models.py DatasetUploadResponse widened")
165
- else:
166
- print("OK: models.py already patched")
167
-
168
- # ---- Patch routes/agent.py to use the richer doc context note ----
169
- AGENT_FILE = "/app/backend/routes/agent.py"
170
- with open(AGENT_FILE) as f:
171
- ac = f.read()
172
-
173
- if "format_uploaded_document_context" not in ac:
174
- # Update import
175
- old_imp = " dataset_context_note,\n push_dataset_upload_to_hub,\n"
176
- new_imp = (
177
- " format_uploaded_document_context,\n"
178
- " push_dataset_upload_to_hub,\n"
179
- )
180
- if old_imp in ac:
181
- ac = ac.replace(old_imp, new_imp)
182
- print("OK: agent.py import updated")
183
- else:
184
- # Try removing the dataset_context_note import line entirely
185
- ac = ac.replace(" dataset_context_note,\n", "")
186
- ac = ac.replace(
187
- "from dataset_uploads import (\n push_dataset_upload_to_hub,\n)",
188
- "from dataset_uploads import (\n"
189
- " format_uploaded_document_context,\n"
190
- " push_dataset_upload_to_hub,\n)",
191
- )
192
- print("OK: agent.py import swapped (fallback)")
193
-
194
- # Update usage
195
- old_use = 'Message(role="user", content=dataset_context_note(uploaded))'
196
- new_use = (
197
- 'Message(role="user", content=format_uploaded_document_context(\n'
198
- " filename=uploaded.original_filename,\n"
199
- " stored_filename=uploaded.filename,\n"
200
- " repo_id=uploaded.repo_id,\n"
201
- " path_in_repo=uploaded.path_in_repo,\n"
202
- " hub_url=uploaded.hub_url,\n"
203
- " size_bytes=uploaded.size_bytes,\n"
204
- " file_format=uploaded.format,\n"
205
- " extracted_text=uploaded.extracted_text,\n"
206
- " warning=uploaded.extraction_warning,\n"
207
- " ))"
208
- )
209
- if old_use in ac:
210
- ac = ac.replace(old_use, new_use)
211
- print("OK: agent.py usage updated")
212
- else:
213
- print("WARN: agent.py usage line not found")
214
-
215
- try:
216
- ast.parse(ac)
217
- with open(AGENT_FILE, "w") as f:
218
- f.write(ac)
219
- print("OK: agent.py written & syntax valid")
220
- except SyntaxError as e:
221
- print(f"FAIL: agent.py syntax error: {e}")
222
- else:
223
- print("OK: agent.py already patched")
224
-
225
- print("DONE: document upload backend patch complete")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_fix_static_404.py DELETED
@@ -1,105 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch: Fix 404 on root and /api when frontend build is missing.
4
-
5
- Root cause: The Dockerfile creates /app/static with `mkdir -p` but the
6
- frontend build may fail, leaving the directory empty. main.py checks
7
- `if static_path.exists()` which returns True for an empty directory,
8
- causing StaticFiles to mount at "/" and shadow ALL API routes with 404s.
9
-
10
- Fix: Patch main.py to check that the static directory is non-empty
11
- before mounting StaticFiles.
12
- """
13
-
14
- import os
15
- import sys
16
-
17
- TARGET_FILE = "/app/backend/main.py"
18
- PATCHED_MARKER = "# === PATCH: fix static 404 on empty dir ==="
19
-
20
-
21
- def patch_main_py(content: str) -> tuple[str, bool]:
22
- changed = False
23
-
24
- old_block = (
25
- "# Serve static files (frontend build) in production\n"
26
- "static_path = Path(__file__).parent.parent / \"static\"\n"
27
- "if static_path.exists():\n"
28
- " app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n"
29
- " logger.info(f\"Serving static files from {static_path}\")\n"
30
- "else:\n"
31
- " logger.info(\"No static directory found, running in API-only mode\")"
32
- )
33
-
34
- new_block = (
35
- "# Serve static files (frontend build) in production\n"
36
- "static_path = Path(__file__).parent.parent / \"static\"\n"
37
- f"{PATCHED_MARKER}\n"
38
- "# Only mount if the directory exists AND has content — an empty\n"
39
- "# directory would shadow all API routes with 404s.\n"
40
- "if static_path.exists() and any(static_path.iterdir()):\n"
41
- " app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n"
42
- " logger.info(f\"Serving static files from {static_path}\")\n"
43
- "else:\n"
44
- " logger.info(\"No static files found, running in API-only mode\")"
45
- )
46
-
47
- if old_block in content:
48
- content = content.replace(old_block, new_block, 1)
49
- changed = True
50
- print(" + Fixed static file mounting to check for non-empty directory")
51
- else:
52
- # Try a more flexible match
53
- if "static_path.exists()" in content and PATCHED_MARKER not in content:
54
- import re
55
- # Replace the if condition
56
- pattern = r'if static_path\.exists\(\):'
57
- replacement = f'if static_path.exists() and any(static_path.iterdir()):\n {PATCHED_MARKER}'
58
- content = re.sub(pattern, replacement, content, count=1)
59
- changed = True
60
- print(" + Fixed static file mounting (regex fallback)")
61
- else:
62
- print(f" SKIP: pattern already patched or not found")
63
-
64
- return content, changed
65
-
66
-
67
- def main():
68
- if not os.path.exists(TARGET_FILE):
69
- print(f"SKIP: {TARGET_FILE} not found")
70
- return
71
-
72
- with open(TARGET_FILE, "r", encoding="utf-8") as f:
73
- content = f.read()
74
-
75
- if PATCHED_MARKER in content:
76
- print(f"SKIP: {TARGET_FILE} already patched")
77
- return
78
-
79
- # Syntax-check by parsing
80
- import ast
81
- try:
82
- ast.parse(content)
83
- except SyntaxError as e:
84
- print(f"WARN: {TARGET_FILE} has syntax errors before patching: {e}")
85
- return
86
-
87
- content, changed = patch_main_py(content)
88
- if changed:
89
- try:
90
- ast.parse(content)
91
- with open(TARGET_FILE, "w", encoding="utf-8") as f:
92
- f.write(content)
93
- print(f"OK: {TARGET_FILE} patched")
94
- except SyntaxError as e:
95
- print(f"FAIL: patched {TARGET_FILE} has syntax error: {e}")
96
- else:
97
- print(f"SKIP: {TARGET_FILE} — no changes applied")
98
-
99
-
100
- if __name__ == "__main__":
101
- try:
102
- main()
103
- except Exception as e:
104
- print(f"WARN: patch_fix_static_404.py error: {e}")
105
- sys.exit(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_frontend.py CHANGED
@@ -20,21 +20,13 @@ model_content = model_content.replace(
20
  )
21
  model_content = model_content.replace(
22
  "export const GLM_52_MODEL_PATH = 'zai-org/GLM-5.2:novita';",
23
- "export const GLM_52_MODEL_PATH = 'deepseek/deepseek-v4-flash';"
24
  )
25
  model_content = model_content.replace(
26
  "export const DEEPSEEK_V4_PRO_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro:novita';",
27
  "export const DEEPSEEK_V4_PRO_MODEL_PATH = 'nvidia/nemotron-3-super-120b-a12b:free';"
28
  )
29
 
30
- # Add DeepSeek V4 Flash latest model path
31
- if "DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH" not in model_content:
32
- model_content = model_content.replace(
33
- "export const LAGUNA_S21_MODEL_PATH",
34
- "export const DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH = '~deepseek/deepseek-v4-flash-latest';\n\nexport const LAGUNA_S21_MODEL_PATH"
35
- )
36
- print("OK: Added DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH")
37
-
38
  # === PATCH: Add Laguna S 2.1 model path ===
39
  if "LAGUNA_S21_MODEL_PATH" not in model_content:
40
  model_content = model_content.replace(
@@ -43,14 +35,6 @@ if "LAGUNA_S21_MODEL_PATH" not in model_content:
43
  )
44
  print("OK: Added LAGUNA_S21_MODEL_PATH")
45
 
46
- # === PATCH: Add Space Bunny Alpha model path ===
47
- if "SPACE_BUNNY_ALPHA_MODEL_PATH" not in model_content:
48
- model_content = model_content.replace(
49
- "export const LAGUNA_S21_MODEL_PATH",
50
- "export const SPACE_BUNNY_ALPHA_MODEL_PATH = 'openai/stealth/space-bunny-alpha';\n\nexport const LAGUNA_S21_MODEL_PATH"
51
- )
52
- print("OK: Added SPACE_BUNNY_ALPHA_MODEL_PATH")
53
-
54
  with open(MODEL_FILE, "w") as f:
55
  f.write(model_content)
56
  print("OK: model.ts constants updated")
@@ -98,11 +82,11 @@ if not match:
98
  print("FAIL: Could not find DEFAULT_MODEL_OPTIONS array!")
99
  sys.exit(1)
100
 
101
- NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
102
  {
103
- id: 'laguna-s21',
104
- name: 'Laguna S 2.1:free',
105
- modelPath: 'openai/poolside/laguna-s-2.1:free',
106
  avatarUrl: 'https://huggingface.co/api/avatars/tencent',
107
  recommended: true,
108
  },
@@ -134,27 +118,6 @@ NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
134
  avatarUrl: 'https://huggingface.co/api/avatars/poolside',
135
  recommended: true,
136
  },
137
- {
138
- id: 'space-bunny-alpha',
139
- name: 'stealth/space-bunny-alpha',
140
- modelPath: 'openai/stealth/space-bunny-alpha',
141
- avatarUrl: 'https://huggingface.co/api/avatars/stealth',
142
- recommended: true,
143
- },
144
- {
145
- id: 'nex-n2-mini',
146
- name: 'nex-agi/nex-n2-mini',
147
- modelPath: 'openai/nex-agi/nex-n2-mini',
148
- avatarUrl: 'https://huggingface.co/api/avatars/nex-agi',
149
- recommended: true,
150
- },
151
- {
152
- id: 'ling-3.0-flash',
153
- name: 'inclusionai/ling-3.0-flash:free',
154
- modelPath: 'openai/inclusionai/ling-3.0-flash:free',
155
- avatarUrl: 'https://huggingface.co/api/avatars/inclusionai',
156
- recommended: true,
157
- },
158
  {
159
  id: 'llama-3.1-8b',
160
  name: 'Llama 3.1 8B',
@@ -173,21 +136,14 @@ NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
173
  modelPath: 'openai/google/gemini-2.0-flash-001',
174
  avatarUrl: 'https://huggingface.co/api/avatars/google',
175
  },
176
- {
177
- id: 'deepseek-v4-flash-latest',
178
- name: 'DeepSeek V4 Flash latest',
179
- modelPath: '~deepseek/deepseek-v4-flash-latest',
180
- avatarUrl: 'https://huggingface.co/api/avatars/deepseek',
181
- recommended: true,
182
- },
183
  ];"""
184
 
185
- content = content[:match.start()] + NEW_MODELS + content[match.end():]
186
 
187
  # Update DEFAULT_MODEL_PATH
188
  dm_path = re.search(r'const DEFAULT_MODEL_PATH\s*=.*?;', content)
189
  if dm_path:
190
- content = content.replace(dm_path.group(0), "const DEFAULT_MODEL_PATH = 'openai/poolside/laguna-s-2.1:free';")
191
  print("OK: Replaced DEFAULT_MODEL_PATH")
192
  else:
193
  print("OK: DEFAULT_MODEL_PATH not found")
@@ -195,5 +151,5 @@ else:
195
  with open(FILE, "w", encoding="utf-8") as f:
196
  f.write(content)
197
 
198
- print("OK: Frontend patched - Laguna S 2.1:free DEFAULT")
199
- 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")
 
20
  )
21
  model_content = model_content.replace(
22
  "export const GLM_52_MODEL_PATH = 'zai-org/GLM-5.2:novita';",
23
+ "export const GLM_52_MODEL_PATH = 'openai/deepseek/deepseek-v4-flash';"
24
  )
25
  model_content = model_content.replace(
26
  "export const DEEPSEEK_V4_PRO_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro:novita';",
27
  "export const DEEPSEEK_V4_PRO_MODEL_PATH = 'nvidia/nemotron-3-super-120b-a12b:free';"
28
  )
29
 
 
 
 
 
 
 
 
 
30
  # === PATCH: Add Laguna S 2.1 model path ===
31
  if "LAGUNA_S21_MODEL_PATH" not in model_content:
32
  model_content = model_content.replace(
 
35
  )
36
  print("OK: Added LAGUNA_S21_MODEL_PATH")
37
 
 
 
 
 
 
 
 
 
38
  with open(MODEL_FILE, "w") as f:
39
  f.write(model_content)
40
  print("OK: model.ts constants updated")
 
82
  print("FAIL: Could not find DEFAULT_MODEL_OPTIONS array!")
83
  sys.exit(1)
84
 
85
+ new_array = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
86
  {
87
+ id: 'tencent-hy3',
88
+ name: 'Tencent HY3:free',
89
+ modelPath: 'openai/tencent/hy3:free',
90
  avatarUrl: 'https://huggingface.co/api/avatars/tencent',
91
  recommended: true,
92
  },
 
118
  avatarUrl: 'https://huggingface.co/api/avatars/poolside',
119
  recommended: true,
120
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  {
122
  id: 'llama-3.1-8b',
123
  name: 'Llama 3.1 8B',
 
136
  modelPath: 'openai/google/gemini-2.0-flash-001',
137
  avatarUrl: 'https://huggingface.co/api/avatars/google',
138
  },
 
 
 
 
 
 
 
139
  ];"""
140
 
141
+ content = content[:match.start()] + new_array + content[match.end():]
142
 
143
  # Update DEFAULT_MODEL_PATH
144
  dm_path = re.search(r'const DEFAULT_MODEL_PATH\s*=.*?;', content)
145
  if dm_path:
146
+ content = content.replace(dm_path.group(0), "const DEFAULT_MODEL_PATH = 'openai/tencent/hy3:free';")
147
  print("OK: Replaced DEFAULT_MODEL_PATH")
148
  else:
149
  print("OK: DEFAULT_MODEL_PATH not found")
 
151
  with open(FILE, "w", encoding="utf-8") as f:
152
  f.write(content)
153
 
154
+ print("OK: Frontend patched - Tencent HY3:free DEFAULT")
155
+ print(" Models: HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, Laguna M.1:free, Laguna S 2.1:free, Llama 3.1 8B")
patch_frontend_apodex_ling_beatapi.py DELETED
@@ -1,110 +0,0 @@
1
- """Patch frontend ChatInput.tsx: add apodex-1.1-mini:free, ling-3.1-flash and
2
- the BeatAPI space-bunny-alpha entry to the model picker.
3
-
4
- - apodex/apodex-1.1-mini:free path: openai/apodex/apodex-1.1-mini:free
5
- - inclusionai/ling-3.1-flash path: openai/inclusionai/ling-3.1-flash
6
- - BeatAPI space-bunny-alpha path: beatapi/space-bunny-alpha
7
-
8
- The first two follow the same OpenRouter `openai/` convention as the existing
9
- models (including stealth/space-bunny-alpha). The third is a BEATAPI model:
10
- its path starts with `beatapi/`, NOT `openai/`, so the backend routes it to
11
- https://api.beatapi.io/v1 with BEATAPI_API_KEY instead of OpenRouter. The name
12
- is prefixed "BeatAPI" so the picker makes the provider difference visible.
13
-
14
- Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already
15
- replaced DEFAULT_MODEL_OPTIONS). Idempotent.
16
- """
17
-
18
- import os
19
- import re
20
-
21
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
22
-
23
- NEW_ENTRIES = """ {
24
- id: 'apodex-1.1-mini-free',
25
- name: 'apodex/apodex-1.1-mini:free',
26
- modelPath: 'openai/apodex/apodex-1.1-mini:free',
27
- avatarUrl: 'https://huggingface.co/api/avatars/apodex',
28
- recommended: true,
29
- },
30
- {
31
- id: 'ling-3.1-flash',
32
- name: 'inclusionai/ling-3.1-flash',
33
- modelPath: 'openai/inclusionai/ling-3.1-flash',
34
- avatarUrl: 'https://huggingface.co/api/avatars/inclusionai',
35
- recommended: true,
36
- },
37
- {
38
- id: 'beatapi-space-bunny-alpha',
39
- name: 'BeatAPI space-bunny-alpha',
40
- modelPath: 'beatapi/space-bunny-alpha',
41
- avatarUrl: 'https://beatapi.io/favicon.ico',
42
- recommended: true,
43
- },
44
- """
45
-
46
-
47
- def patch():
48
- if not os.path.exists(FILE):
49
- print(f"SKIP: {FILE} not found")
50
- return
51
-
52
- with open(FILE, "r", encoding="utf-8") as f:
53
- content = f.read()
54
-
55
- if (
56
- "apodex/apodex-1.1-mini:free" in content
57
- and "beatapi/space-bunny-alpha" in content
58
- ):
59
- print("OK: new models already present in ChatInput.tsx")
60
- return
61
-
62
- # Insert right before the stealth/space-bunny-alpha entry so the three new
63
- # models sit next to the existing free OpenRouter models.
64
- anchor = " {\n id: 'space-bunny-alpha',\n"
65
- if anchor in content:
66
- content = content.replace(anchor, NEW_ENTRIES + anchor, 1)
67
- print("OK: added 3 models before the stealth/space-bunny-alpha entry")
68
- else:
69
- # Fallback: insert before deepseek-v4-flash-latest, the other stable anchor.
70
- alt = " {\n id: 'deepseek-v4-flash-latest',\n"
71
- if alt in content:
72
- content = content.replace(alt, NEW_ENTRIES + alt, 1)
73
- print("OK: added 3 models before deepseek-v4-flash-latest (fallback)")
74
- else:
75
- m = re.search(
76
- r"const DEFAULT_MODEL_OPTIONS: ModelOption\[\] = \[.*?\];",
77
- content,
78
- re.DOTALL,
79
- )
80
- if not m:
81
- print("FAIL: could not find any model-list anchor")
82
- return
83
- head = m.group(0)[: m.group(0).rindex("[") + 1]
84
- content = (
85
- content[: m.start()]
86
- + head
87
- + "\n"
88
- + NEW_ENTRIES
89
- + content[m.start() + len(head) :]
90
- )
91
- print("OK: added 3 models at top of DEFAULT_MODEL_OPTIONS (fallback)")
92
-
93
- with open(FILE, "w", encoding="utf-8") as f:
94
- f.write(content)
95
- print("OK: ChatInput.tsx patched")
96
-
97
- # Verification: all three paths must be present.
98
- for needle in (
99
- "openai/apodex/apodex-1.1-mini:free",
100
- "openai/inclusionai/ling-3.1-flash",
101
- "beatapi/space-bunny-alpha",
102
- ):
103
- if needle not in content:
104
- print(f"FAIL: missing {needle}")
105
- raise SystemExit(1)
106
- print("OK: verified all 3 new model paths present")
107
-
108
-
109
- if __name__ == "__main__":
110
- patch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_frontend_multi.py DELETED
@@ -1,120 +0,0 @@
1
- """Patch ChatInput.tsx: support uploading multiple files at once.
2
-
3
- Changes:
4
- 1. Add `multiple` to the <input type="file"> so the user can select many
5
- files/images in one pick.
6
- 2. Rewrite handleDatasetFileChange to send ALL selected files in a single
7
- multipart request (backend returns list[DatasetUploadResponse]).
8
- """
9
-
10
- import os
11
-
12
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
13
-
14
- def patch():
15
- if not os.path.exists(FILE):
16
- print(f"SKIP: {FILE} not found")
17
- return
18
-
19
- with open(FILE, "r", encoding="utf-8") as f:
20
- content = f.read()
21
-
22
- # ---- 1. Add `multiple` to the file input ----
23
- old_input = '<input\n ref={fileInputRef}\n type="file"\n accept={DATASET_UPLOAD_ACCEPT}\n onChange={handleDatasetFileChange}\n style={{ display: \'none\' }}\n />'
24
- new_input = '<input\n ref={fileInputRef}\n type="file"\n accept={DATASET_UPLOAD_ACCEPT}\n multiple\n onChange={handleDatasetFileChange}\n style={{ display: \'none\' }}\n />'
25
- if old_input in content:
26
- content = content.replace(old_input, new_input)
27
- print("OK: added multiple to file input")
28
- else:
29
- print("WARN: file input block not matched exactly")
30
-
31
- # ---- 2. Rewrite handleDatasetFileChange ----
32
- old_handler_start = " const handleDatasetFileChange = useCallback("
33
- start = content.find(old_handler_start)
34
- if start == -1:
35
- print("FAIL: handleDatasetFileChange not found")
36
- return
37
- # End of the handler: the " );" right after the deps line.
38
- deps = " [sessionId, onDatasetUploaded],\n );"
39
- end = content.find(deps, start)
40
- if end != -1:
41
- end += len(deps)
42
- else:
43
- print("FAIL: could not find end of handler")
44
- return
45
-
46
- new_handler = ''' const handleDatasetFileChange = useCallback(
47
- async (event: React.ChangeEvent<HTMLInputElement>) => {
48
- const files = Array.from(event.target.files ?? []);
49
- event.target.value = '';
50
- if (!files.length) return;
51
-
52
- if (!sessionId) {
53
- setDatasetUploadError('Start a session before uploading files.');
54
- return;
55
- }
56
-
57
- // Validate each file before uploading the batch.
58
- for (const file of files) {
59
- const ext = file.name.split('.').pop()?.toLowerCase() || '';
60
- if (!DATASET_UPLOAD_EXTENSIONS.has(ext)) {
61
- 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).');
62
- return;
63
- }
64
- if (file.size > MAX_DATASET_UPLOAD_BYTES) {
65
- setDatasetUploadError(`File must be 100 MB or smaller. ${file.name} is ${formatBytes(file.size)}.`);
66
- return;
67
- }
68
- if (file.size === 0) {
69
- setDatasetUploadError(`Uploaded file is empty: ${file.name}`);
70
- return;
71
- }
72
- }
73
-
74
- // Send all files in one request; backend returns a list.
75
- const formData = new FormData();
76
- for (const file of files) {
77
- formData.append('file', file);
78
- }
79
- setIsUploadingDataset(true);
80
- setDatasetUploadProgress(0);
81
- setDatasetUploadError(null);
82
- setDatasetUploadSuccess(null);
83
- try {
84
- const res = await apiUpload(`/api/session/${sessionId}/datasets`, formData, {
85
- onProgress: ({ percent }) => {
86
- setDatasetUploadProgress(percent !== null && percent < 100 ? percent : null);
87
- },
88
- });
89
- if (!res.ok) {
90
- setDatasetUploadError(await readApiErrorMessage(res, 'File upload failed.'));
91
- return;
92
- }
93
- const payload = await res.json() as DatasetUploadResponse[];
94
- if (payload.length) {
95
- setUploadedDatasets((previous) => [...payload, ...previous]);
96
- const names = payload.map((u) => u.filename).join(', ');
97
- setDatasetUploadSuccess(`Đã tải lên ${payload.length} file: ${names}`);
98
- }
99
- await onDatasetUploaded?.();
100
- } catch (error) {
101
- setDatasetUploadError(
102
- error instanceof Error ? error.message : 'File upload failed.'
103
- );
104
- } finally {
105
- setIsUploadingDataset(false);
106
- setDatasetUploadProgress(null);
107
- }
108
- },
109
- [sessionId, onDatasetUploaded],
110
- );'''
111
-
112
- content = content[:start] + new_handler + content[end:]
113
-
114
- with open(FILE, "w", encoding="utf-8") as f:
115
- f.write(content)
116
- print("OK: ChatInput.tsx multi-file upload patched")
117
-
118
-
119
- if __name__ == "__main__":
120
- patch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_frontend_new_models.py DELETED
@@ -1,71 +0,0 @@
1
- """Patch frontend ChatInput.tsx: add 3 new OpenRouter models to the model picker.
2
-
3
- New models (openai/ prefix routes to OpenRouter):
4
- - UPSTAGE Solar Pro 4 (openai/upstage/solar-pro4)
5
- - NVIDIA Nemotron 3.5 Lightning:free (openai/nvidia/nemotron-3.5-lightning:free)
6
- - Liquid LFM 2.5 2.6B:free (openai/liquid/lfm-2.5-2.6b:free)
7
- """
8
-
9
- import os
10
-
11
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
12
-
13
- def patch():
14
- if not os.path.exists(FILE):
15
- print(f"SKIP: {FILE} not found")
16
- return
17
-
18
- with open(FILE, "r", encoding="utf-8") as f:
19
- content = f.read()
20
-
21
- if "upstage-solar-pro4" in content:
22
- print("OK: models already present")
23
- return
24
-
25
- # Unique anchor: the deepseek-v4-flash-latest entry inside DEFAULT_MODEL_OPTIONS.
26
- # Insert the 3 new model objects immediately BEFORE that entry.
27
- anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
28
- new_entries = (
29
- " {\n"
30
- " id: 'upstage-solar-pro4',\n"
31
- " name: 'UPSTAGE Solar Pro 4',\n"
32
- " modelPath: 'openai/upstage/solar-pro4',\n"
33
- " avatarUrl: 'https://huggingface.co/api/avatars/upstage',\n"
34
- " recommended: true,\n"
35
- " },\n"
36
- " {\n"
37
- " id: 'nemotron-3.5-lightning',\n"
38
- " name: 'NVIDIA Nemotron 3.5 Lightning:free',\n"
39
- " modelPath: 'openai/nvidia/nemotron-3.5-lightning:free',\n"
40
- " avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n"
41
- " recommended: true,\n"
42
- " },\n"
43
- " {\n"
44
- " id: 'liquid-lfm-2.5-2.6b',\n"
45
- " name: 'Liquid LFM 2.5 2.6B:free',\n"
46
- " modelPath: 'openai/liquid/lfm-2.5-2.6b:free',\n"
47
- " avatarUrl: 'https://huggingface.co/api/avatars/liquid',\n"
48
- " recommended: true,\n"
49
- " },\n"
50
- )
51
-
52
- if anchor in content:
53
- content = content.replace(anchor, new_entries + anchor, 1)
54
- print("OK: added 3 new models before deepseek-v4-flash-latest entry")
55
- else:
56
- # Fallback: find the deepseek id line by itself.
57
- alt = " id: 'deepseek-v4-flash-latest',\n"
58
- if alt in content:
59
- content = content.replace(alt, new_entries + " {\n" + alt, 1)
60
- print("OK: added 3 new models (fallback anchor)")
61
- else:
62
- print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS")
63
- return
64
-
65
- with open(FILE, "w", encoding="utf-8") as f:
66
- f.write(content)
67
- print("OK: ChatInput.tsx new models patched")
68
-
69
-
70
- if __name__ == "__main__":
71
- patch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_frontend_permanent_sessions.py DELETED
@@ -1,45 +0,0 @@
1
- """
2
- Patch frontend: Disable 'where we here now?' notification.
3
-
4
- Chỉ tìm và xóa bất kỳ chuỗi 'where we here now' nào trong frontend source.
5
- An toàn — không thêm code TypeScript mới để tránh lỗi build.
6
- """
7
- import os
8
- import sys
9
-
10
- FRONTEND_DIR = "/source/frontend/src"
11
-
12
- patch_count = 0
13
-
14
- # Scan all .ts/.tsx files for 'where we here now' and remove the notification
15
- if os.path.exists(FRONTEND_DIR):
16
- for root, dirs, files in os.walk(FRONTEND_DIR):
17
- for fname in files:
18
- if fname.endswith((".ts", ".tsx")):
19
- fpath = os.path.join(root, fname)
20
- try:
21
- with open(fpath, "r", encoding="utf-8") as f:
22
- content = f.read()
23
- if "where we here now" in content.lower():
24
- # Remove the entire line containing the notification text
25
- lines = content.split("\n")
26
- new_lines = []
27
- removed = False
28
- for line in lines:
29
- if "where we here now" in line.lower():
30
- removed = True
31
- continue # Skip this line
32
- new_lines.append(line)
33
- if removed:
34
- with open(fpath, "w", encoding="utf-8") as f:
35
- f.write("\n".join(new_lines))
36
- patch_count += 1
37
- print(f" Removed 'where we here now?' from {fpath}")
38
- except Exception as e:
39
- print(f" WARN: Could not patch {fpath}: {e}")
40
-
41
- if patch_count == 0:
42
- print(" No 'where we here now?' notifications found (already clean)")
43
-
44
- print(f"Done: patched {patch_count} frontend files")
45
- sys.exit(0) # Always exit 0 — never fail build
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_frontend_uploads.py DELETED
@@ -1,81 +0,0 @@
1
- """Patch ChatInput.tsx: allow uploading PDF, images, Excel, Word, PPT,
2
- OpenDocument and text files in addition to CSV/JSON/JSONL, and surface the
3
- extracted-text fields returned by the backend.
4
- """
5
-
6
- import os
7
-
8
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
9
-
10
- ACCEPT = (
11
- ".csv,.json,.jsonl,.pdf,"
12
- ".png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,"
13
- ".xlsx,.xlsm,.xls,"
14
- ".docx,.doc,"
15
- ".pptx,"
16
- ".odt,.ods,.odp,"
17
- ".txt,.md,.rtf,.log"
18
- )
19
-
20
- EXTENSIONS_SET = (
21
- "['csv', 'json', 'jsonl', 'pdf', "
22
- "'png', 'jpg', 'jpeg', 'webp', 'bmp', 'tiff', 'tif', 'gif', "
23
- "'xlsx', 'xlsm', 'xls', 'docx', 'doc', 'pptx', "
24
- "'odt', 'ods', 'odp', 'txt', 'md', 'rtf', 'log']"
25
- )
26
-
27
-
28
- def patch():
29
- if not os.path.exists(FILE):
30
- print(f"SKIP: {FILE} not found")
31
- return
32
-
33
- with open(FILE, "r", encoding="utf-8") as f:
34
- content = f.read()
35
-
36
- # 1. Widen the accept string
37
- old_accept = "const DATASET_UPLOAD_ACCEPT = '.csv,.json,.jsonl';"
38
- if old_accept in content:
39
- content = content.replace(old_accept, f"const DATASET_UPLOAD_ACCEPT = `{ACCEPT}`;")
40
- print("OK: widened DATASET_UPLOAD_ACCEPT")
41
-
42
- # 2. Widen the allowed extension set
43
- old_ext = "const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']);"
44
- if old_ext in content:
45
- content = content.replace(old_ext, f"const DATASET_UPLOAD_EXTENSIONS = new Set({EXTENSIONS_SET});")
46
- print("OK: widened DATASET_UPLOAD_EXTENSIONS")
47
-
48
- # 3. Update the error message
49
- old_err = "'Only CSV, JSON, and JSONL dataset files are supported.'"
50
- new_err = (
51
- "'Không hỗ trợ định dạng này. Các định dạng được hỗ trợ: CSV, JSON, JSONL, "
52
- "PDF, ảnh (PNG/JPG/JPEG/WebP/BMP/TIFF/GIF), Excel (XLSX/XLSM/XLS), "
53
- "Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), "
54
- "và văn bản (TXT/MD/RTF/LOG).'"
55
- )
56
- if old_err in content:
57
- content = content.replace(old_err, new_err)
58
- print("OK: updated unsupported-format error message")
59
-
60
- # 4. Widen the DatasetUploadResponse interface format + add extracted fields
61
- old_fmt = " format: 'csv' | 'json' | 'jsonl';"
62
- if old_fmt in content:
63
- content = content.replace(old_fmt, " format: string;")
64
- print("OK: widened interface format type")
65
-
66
- if "extracted_text?: string" not in content:
67
- content = content.replace(
68
- " load_dataset_snippet: string;\n",
69
- " load_dataset_snippet: string;\n"
70
- " extracted_text?: string;\n"
71
- " extraction_warning?: string;\n",
72
- )
73
- print("OK: added extracted_text/extraction_warning to interface")
74
-
75
- with open(FILE, "w", encoding="utf-8") as f:
76
- f.write(content)
77
- print("OK: ChatInput.tsx patched for multi-format uploads")
78
-
79
-
80
- if __name__ == "__main__":
81
- patch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_frontend_xing_nemotron_vl.py DELETED
@@ -1,63 +0,0 @@
1
- """Patch frontend ChatInput.tsx: add xingchen-agi/xing4.0-29b-a4b:free and
2
- nvidia/nemotron-3.5-super-vl:free with the same openai/ path convention as
3
- Laguna S 2.1:free.
4
-
5
- New models (openai/ prefix -> OpenRouter):
6
- - xingchen-agi/xing4.0-29b-a4b:free modelPath: openai/xingchen-agi/xing4.0-29b-a4b:free
7
- - nvidia/nemotron-3.5-super-vl:free modelPath: openai/nvidia/nemotron-3.5-super-vl:free
8
-
9
- Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already
10
- replaced DEFAULT_MODEL_OPTIONS). Idempotent.
11
- """
12
-
13
- import os
14
-
15
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
16
-
17
-
18
- def patch():
19
- if not os.path.exists(FILE):
20
- print(f"SKIP: {FILE} not found")
21
- return
22
-
23
- with open(FILE, "r", encoding="utf-8") as f:
24
- content = f.read()
25
-
26
- if "xingchen-agi/xing4.0-29b-a4b" in content:
27
- print("OK: models already present in ChatInput.tsx")
28
- return
29
-
30
- # Insert right before the deepseek-v4-flash-latest entry — a stable anchor
31
- # that exists after patch_frontend.py rewrites DEFAULT_MODEL_OPTIONS.
32
- anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
33
- new_entries = (
34
- " {\n"
35
- " id: 'xingchen-xing4-0-29b',\n"
36
- " name: 'xingchen-agi/xing4.0-29b-a4b:free',\n"
37
- " modelPath: 'openai/xingchen-agi/xing4.0-29b-a4b:free',\n"
38
- " avatarUrl: 'https://huggingface.co/api/avatars/xingchen-agi',\n"
39
- " recommended: true,\n"
40
- " },\n"
41
- " {\n"
42
- " id: 'nemotron-3.5-super-vl',\n"
43
- " name: 'nvidia/nemotron-3.5-super-vl:free',\n"
44
- " modelPath: 'openai/nvidia/nemotron-3.5-super-vl:free',\n"
45
- " avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n"
46
- " recommended: true,\n"
47
- " },\n"
48
- )
49
-
50
- if anchor in content:
51
- content = content.replace(anchor, new_entries + anchor, 1)
52
- print("OK: added 2 models before deepseek-v4-flash-latest entry")
53
- else:
54
- print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS")
55
- return
56
-
57
- with open(FILE, "w", encoding="utf-8") as f:
58
- f.write(content)
59
- print("OK: ChatInput.tsx patched")
60
-
61
-
62
- if __name__ == "__main__":
63
- patch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_lossless_context.py DELETED
@@ -1,365 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Patch: lossless long-context memory — fix context loss in long sessions/jobs.
3
-
4
- Applied during Docker build. Idempotent. NEVER fails the build.
5
-
6
- Root causes fixed:
7
- 1. compact() deleted the middle incl. ALL tool results (model continued a long
8
- job from a terse paraphrase)
9
- 2. CompactionFailedError TERMINATED the session mid-job (_compact_and_notify)
10
- 3. restore_session_from_log reloaded history VERBATIM -> next compaction
11
- nuked the tool trail or killed the session
12
- 4. max_iterations=300 silently stopped long jobs
13
-
14
- Fixes:
15
- A. compact(): snapshot removed middle (raw tool calls/results) into
16
- cm.compaction_archive before rewriting items — the raw trail survives.
17
- B. untouched_messages default 5 -> 24 (keep more of the recent working set).
18
- C. cm.prune_to_fit(): drop oldest tool outputs (markers) until under
19
- threshold. agent_loop _compact_and_notify calls it on CompactionFailedError
20
- instead of terminating — degrade, never kill.
21
- D. restore_session_from_log: normalize restored history to 70% headroom
22
- (trim oldest tool outputs, keep system/first_user/tail).
23
- E. config max_iterations default -1 (unlimited) + untouched_messages=24;
24
- session.py reads config.untouched_messages.
25
- """
26
-
27
- import ast
28
- import os
29
-
30
- MARKER = "# === PATCH: lossless long-context memory ==="
31
-
32
-
33
- def _apply(path, patch_fn, label):
34
- if not os.path.exists(path):
35
- print(f"SKIP: {path} not found")
36
- return False
37
- try:
38
- with open(path, "r") as f:
39
- c = f.read()
40
- if MARKER in c:
41
- print(f"SKIP: {path} already patched")
42
- return True
43
- new_c, changed = patch_fn(c)
44
- if not changed:
45
- print(f"SKIP: {path} — no changes needed")
46
- return False
47
- try:
48
- ast.parse(new_c)
49
- except SyntaxError as e:
50
- print(f"FAIL: {path} syntax: {e}")
51
- return False
52
- with open(path, "w") as f:
53
- f.write(new_c)
54
- print(f"OK: {path} {label}")
55
- return True
56
- except Exception as e:
57
- print(f"WARN: {path} patch error: {e}")
58
- return False
59
-
60
-
61
- # ── 1. manager.py — lossless compaction + prune_to_fit ──────────────────────
62
- MANAGER = "/app/agent/context_manager/manager.py"
63
-
64
-
65
- def _patch_manager(c):
66
- changed = False
67
-
68
- if "untouched_messages: int = 5" in c:
69
- c = c.replace("untouched_messages: int = 5", "untouched_messages: int = 24")
70
- print(" + untouched_messages default 5 -> 24")
71
- changed = True
72
-
73
- archive_code = (
74
- " # === PATCH: lossless long-context memory ===\n"
75
- " # Never destroy the working state: keep the full removed middle\n"
76
- " # (tool calls + results + user prompts) in memory on the summary,\n"
77
- " # so a follow-up question or a continued job can be answered from\n"
78
- " # the raw trail, not from a paraphrase.\n"
79
- " try:\n"
80
- " snapshot = [\n"
81
- " m.model_dump() if hasattr(m, \"model_dump\") else dict(m)\n"
82
- " for m in messages_to_summarize\n"
83
- " ]\n"
84
- " if not hasattr(self, \"compaction_archive\"):\n"
85
- " self.compaction_archive = []\n"
86
- " self.compaction_archive.extend(snapshot)\n"
87
- " # Cap the archive so we don't grow unbounded: keep last 400 msgs.\n"
88
- " if len(self.compaction_archive) > 400:\n"
89
- " self.compaction_archive = self.compaction_archive[-400:]\n"
90
- " except Exception:\n"
91
- " self.compaction_archive = getattr(self, \"compaction_archive\", [])\n"
92
- )
93
- anchor = " self.items = head + [summarized_message] + recent_messages"
94
- if anchor in c and "compaction_archive" not in c:
95
- c = c.replace(anchor, archive_code + anchor, 1)
96
- print(" + compact() snapshots removed middle to compaction_archive")
97
- changed = True
98
-
99
- if "def prune_to_fit" not in c:
100
- prune = '''
101
-
102
- def prune_to_fit(
103
- self, model_name: str, *, max_messages: int = 200
104
- ) -> bool:
105
- """Drop oldest non-first-user tool outputs until under threshold.
106
-
107
- Degrade, don't kill. Returns True if context is now under threshold.
108
- Preserves: system prompt, first user message, assistant reasoning,
109
- and enough of the tail to continue. Drops the OLDEST tool outputs
110
- (replaced with a short marker) so a long job can keep running.
111
- """
112
- from litellm import token_counter
113
-
114
- def _usage(messages):
115
- try:
116
- return token_counter(
117
- model=model_name,
118
- messages=[m.model_dump() for m in messages],
119
- )
120
- except Exception:
121
- return (
122
- sum(len(getattr(m, "content", "") or "") for m in messages) // 4
123
- )
124
-
125
- items = list(self.items)
126
- # Find first user message index (skip system at 0).
127
- first_user_idx = 1
128
- for i in range(1, len(items)):
129
- if getattr(items[i], "role", None) == "user":
130
- first_user_idx = i
131
- break
132
- # Trim tool messages from oldest to newest (skip system/first-user).
133
- trimmed = 0
134
- for i in range(first_user_idx + 1, len(items)):
135
- if _usage(items) <= self.compaction_threshold:
136
- break
137
- msg = items[i]
138
- if getattr(msg, "role", None) != "tool":
139
- continue
140
- old = items[i]
141
- items[i] = Message(
142
- role="tool",
143
- content=(
144
- "[tool output pruned to keep the session running — "
145
- f"original was {len(getattr(old, 'content', '') or '')} chars; "
146
- "stored in compaction_archive]"
147
- ),
148
- tool_call_id=getattr(old, "tool_call_id", None),
149
- name=getattr(old, "name", None),
150
- )
151
- trimmed += 1
152
- self.items = items
153
- self._recompute_usage(model_name)
154
- return self.running_context_usage <= self.compaction_threshold
155
- '''
156
- c += prune
157
- print(" + Added prune_to_fit() (degrade instead of kill)")
158
- changed = True
159
-
160
- return c, changed
161
-
162
-
163
- _apply(MANAGER, _patch_manager, "lossless compaction patched")
164
-
165
- # ── 2. agent_loop.py — never kill on compaction failure ─────────────────────
166
- AGENT_LOOP = "/app/agent/core/agent_loop.py"
167
-
168
-
169
- def _patch_agent_loop(c):
170
- changed = False
171
-
172
- old_fail = (
173
- " await session.send_event(\n"
174
- " Event(\n"
175
- " event_type=\"session_terminated\",\n"
176
- " data={\n"
177
- " \"reason\": \"compaction_failed\",\n"
178
- " \"context_usage\": cm.running_context_usage,\n"
179
- " \"context_threshold\": cm.compaction_threshold,\n"
180
- " \"error\": str(e)[:300],\n"
181
- " \"user_message\": (\n"
182
- " \"Your conversation has grown too large to continue. \"\n"
183
- " \"The work you've done is saved — start a new session to keep going.\"\n"
184
- " ),\n"
185
- " },\n"
186
- " )\n"
187
- " )\n"
188
- " # Stop the agent loop; the finally in _run_session will fire\n"
189
- " # cleanup_sandbox + save_trajectory so the dataset captures\n"
190
- " # everything that did happen.\n"
191
- " session.is_running = False\n"
192
- " return"
193
- )
194
- new_fail = (
195
- " # === PATCH: lossless long-context memory ===\n"
196
- " # Never kill the session on compaction failure — degrade instead:\n"
197
- " # prune the oldest tool outputs to fit, then keep working.\n"
198
- " try:\n"
199
- " ok = cm.prune_to_fit(session.config.model_name)\n"
200
- " if ok:\n"
201
- " await session.send_event(\n"
202
- " Event(\n"
203
- " event_type=\"compacted\",\n"
204
- " data={\n"
205
- " \"reason\": \"pruned_to_fit\",\n"
206
- " \"context_usage\": cm.running_context_usage,\n"
207
- " \"context_threshold\": cm.compaction_threshold,\n"
208
- " \"user_message\": (\n"
209
- " \"Context was getting full, so the agent \"\n"
210
- " \"trimmed some old tool outputs to keep working. \"\n"
211
- " \"The full raw history stays in the session archive.\"\n"
212
- " ),\n"
213
- " },\n"
214
- " )\n"
215
- " )\n"
216
- " return\n"
217
- " except Exception as _prune_exc:\n"
218
- " logger.error(\"prune_to_fit failed: %s\", _prune_exc)\n"
219
- " await session.send_event(\n"
220
- " Event(\n"
221
- " event_type=\"session_terminated\",\n"
222
- " data={\n"
223
- " \"reason\": \"compaction_failed\",\n"
224
- " \"context_usage\": cm.running_context_usage,\n"
225
- " \"context_threshold\": cm.compaction_threshold,\n"
226
- " \"error\": str(e)[:300],\n"
227
- " \"user_message\": (\n"
228
- " \"The conversation context is full and could not be \"\n"
229
- " \"reduced further. The work done so far is saved — \"\n"
230
- " \"start a new session to keep going.\"\n"
231
- " ),\n"
232
- " },\n"
233
- " )\n"
234
- " )\n"
235
- " # Stop the agent loop; the finally in _run_session will fire\n"
236
- " # cleanup_sandbox + save_trajectory so the dataset captures\n"
237
- " # everything that did happen.\n"
238
- " session.is_running = False\n"
239
- " return"
240
- )
241
- if old_fail in c and "prune_to_fit" not in c:
242
- c = c.replace(old_fail, new_fail, 1)
243
- print(" + _compact_and_notify degrades via prune_to_fit instead of kill")
244
- changed = True
245
-
246
- return c, changed
247
-
248
-
249
- _apply(AGENT_LOOP, _patch_agent_loop, "agent_loop degrades instead of killing")
250
-
251
- # ── 3. session.py — read config.untouched_messages ──────────────────────────
252
- SESSION = "/app/agent/core/session.py"
253
-
254
-
255
- def _patch_session(c):
256
- changed = False
257
- old = " compact_size=0.1,\n untouched_messages=5,"
258
- new = (
259
- " compact_size=0.1,\n"
260
- " untouched_messages=getattr(config, 'untouched_messages', 24),"
261
- )
262
- if old in c:
263
- c = c.replace(old, new, 1)
264
- print(" + session.py reads config.untouched_messages (default 24)")
265
- changed = True
266
- return c, changed
267
-
268
-
269
- _apply(SESSION, _patch_session, "session.py untouched_messages override")
270
-
271
- # ── 4. config.py — max_iterations unlimited + untouched_messages=24 ─────────
272
- CONFIG = "/app/agent/config.py"
273
-
274
-
275
- def _patch_config(c):
276
- changed = False
277
- old = " max_iterations: int = 300 # Max LLM calls per agent turn (-1 = unlimited)"
278
- new = (
279
- " max_iterations: int = -1 # Max LLM calls per agent turn (-1 = unlimited)\n"
280
- " untouched_messages: int = 24 # PATCH: keep more recent tool trail before compaction"
281
- )
282
- if old in c:
283
- c = c.replace(old, new, 1)
284
- print(" + config.py max_iterations default -1 (unlimited), untouched_messages=24")
285
- changed = True
286
- return c, changed
287
-
288
-
289
- _apply(CONFIG, _patch_config, "config.py max_iterations unlimited")
290
-
291
- # ── 5. session_resume.py — normalize restored history to headroom ───────────
292
- RESUME = "/app/agent/core/session_resume.py"
293
-
294
-
295
- def _patch_resume(c):
296
- changed = False
297
- anchor = " cm = session.context_manager\n"
298
- insert = (
299
- " # === PATCH: lossless long-context memory ===\n"
300
- " # Normalize restored history to fit model headroom instead of restoring\n"
301
- " # everything verbatim and letting the first compaction nuke the tool\n"
302
- " # trail or kill the session. Keep system + first user + tail verbatim;\n"
303
- " # replace oversize tool outputs in the middle with markers (full payloads\n"
304
- " # still exist on-disk in the original log).\n"
305
- " try:\n"
306
- " from litellm import token_counter as _tc\n"
307
- " _budget = int(session.context_manager.model_max_tokens * 0.70)\n"
308
- " try:\n"
309
- " _use = _tc(\n"
310
- " model=session.config.model_name,\n"
311
- " messages=[m.model_dump() for m in restored_messages],\n"
312
- " )\n"
313
- " except Exception:\n"
314
- " _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n"
315
- " if _use > _budget:\n"
316
- " # Trim the oldest tool outputs (keep system + first user + tail).\n"
317
- " _first_user_idx = 0\n"
318
- " for _i, _m in enumerate(restored_messages):\n"
319
- " if getattr(_m, \"role\", None) == \"user\":\n"
320
- " _first_user_idx = _i\n"
321
- " break\n"
322
- " _trimmed = 0\n"
323
- " for _i in range(_first_user_idx + 1, len(restored_messages)):\n"
324
- " if _use <= _budget:\n"
325
- " break\n"
326
- " _m = restored_messages[_i]\n"
327
- " if getattr(_m, \"role\", None) != \"tool\":\n"
328
- " continue\n"
329
- " _sz = len(getattr(_m, \"content\", \"\") or \"\")\n"
330
- " restored_messages[_i] = type(_m)(\n"
331
- " role=\"tool\",\n"
332
- " content=(\n"
333
- " \"[tool output trimmed on restore to fit context — \"\n"
334
- " f\"original {_sz} chars retained in the on-disk log]\"\n"
335
- " ),\n"
336
- " tool_call_id=getattr(_m, \"tool_call_id\", None),\n"
337
- " name=getattr(_m, \"name\", None),\n"
338
- " )\n"
339
- " _trimmed += 1\n"
340
- " try:\n"
341
- " _use = _tc(\n"
342
- " model=session.config.model_name,\n"
343
- " messages=[m.model_dump() for m in restored_messages],\n"
344
- " )\n"
345
- " except Exception:\n"
346
- " _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n"
347
- " logger.warning(\n"
348
- " \"Restored session %s was over budget (%d/%d); trimmed %d tool outputs\",\n"
349
- " session.session_id if hasattr(session, \"session_id\") else \"?\",\n"
350
- " _use, _budget, _trimmed,\n"
351
- " )\n"
352
- " except Exception as _exc:\n"
353
- " logger.warning(\"Restore normalization skipped: %s\", _exc)\n"
354
- "\n"
355
- )
356
- if anchor in c and "Restore normalization skipped" not in c:
357
- c = c.replace(anchor, anchor + insert, 1)
358
- print(" + restore_session_from_log normalizes history to 70% headroom")
359
- changed = True
360
- return c, changed
361
-
362
-
363
- _apply(RESUME, _patch_resume, "session_resume normalized restore")
364
-
365
- print("\n=== All long-context patches applied (build will not fail regardless) ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_models.py CHANGED
@@ -1,18 +1,4 @@
1
- """Patch backend: OpenRouter routing + DeepSeek V4 Flash 0731 + FIX DeepSeek routing.
2
-
3
- FIX: GLM_52_MODEL_ID was set to "openai/deepseek/deepseek-v4-flash" which routes
4
- to OpenRouter via the openai/ prefix catch-all. However, OpenRouter's model ID
5
- is "deepseek/deepseek-v4-flash" (without the openai/ prefix). When LiteLLM
6
- sends the full "openai/deepseek/deepseek-v4-flash" as the model name to
7
- OpenRouter's API, it gets a 404 because OpenRouter doesn't recognize the
8
- "openai/" prefix.
9
-
10
- Solution:
11
- 1. GLM_52_MODEL_ID -> "deepseek-ai/DeepSeek-V4-Flash:deepinfra" (HF Router path, model exists there, cheapest provider)
12
- 2. OpenRouter routing strips the "openai/" prefix before sending to OpenRouter
13
- 3. Add :novita tag to DeepSeek V4 Pro for deterministic routing via HF Router
14
- 4. Add stealth/space-bunny-alpha using the same OpenRouter-style prefix.
15
- """
16
 
17
  import ast
18
  import os
@@ -20,180 +6,121 @@ import re
20
 
21
  AGENT_FILE = "/app/backend/routes/agent.py"
22
  LLM_PARAMS_FILE = "/app/agent/core/llm_params.py"
23
- IDS_FILE = "/app/agent/core/model_ids.py"
24
 
25
- with open(IDS_FILE) as f:
 
 
26
  content = f.read()
27
 
28
- # Remove NO_TOOLS_MODELS if present from previous patches
29
- if "NO_TOOLS_MODELS" in content:
30
- content = re.sub(r'NO_TOOLS_MODELS\s*=\s*\{[^}]*\}\n?', '', content)
31
- print("OK: Removed old NO_TOOLS_MODELS")
32
 
33
- # FIX: Set GLM_52_MODEL_ID to HF Router path with provider tag.
34
- # "deepseek-ai/DeepSeek-V4-Flash:deepinfra" IS available on HF Router
35
- # (5 providers for -0731 variant, 3 for standard). All support tools, 1M context.
36
- # Using :deepinfra for better pricing ($0.09/0.18 per 1M tokens)
37
- # KIMI_K27_CODE_MODEL_ID -> DeepSeek V4 Pro via HF Router with :novita tag
38
- replacements = [
39
- ('KIMI_K27_CODE_MODEL_ID = "moonshotai/Kimi-K2.7-Code:novita"',
40
- 'KIMI_K27_CODE_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"'),
41
- ('MINIMAX_M3_MODEL_ID = "MiniMaxAI/MiniMax-M3:novita"',
42
- 'MINIMAX_M3_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash:deepinfra"'),
43
- # FIX: Was "openai/deepseek/deepseek-v4-flash" which routes to OpenRouter but
44
- # OpenRouter model IDs don't include the "openai/" prefix, causing 404.
45
- # Change to HF Router path with :deepinfra provider tag for cheaper pricing.
46
- ('GLM_52_MODEL_ID = "zai-org/GLM-5.2:novita"',
47
- 'GLM_52_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash:deepinfra"'),
48
- ('DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"',
49
- 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'),
50
- ]
51
- for old, new in replacements:
52
- content = content.replace(old, new)
53
- print("OK: Model ID replacements applied")
54
 
55
- # Add missing constants BEFORE the specific line DEEPSEEK_V4_PRO_MODEL_ID = "..."
56
- if "TENCENT_HY3_FREE_MODEL_ID" not in content:
57
- target_line = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
58
- insert_block = '''TENCENT_HY3_FREE_MODEL_ID = "openai/tencent/hy3:free"
59
- GEMMA_4_31B_FREE_MODEL_ID = "openai/google/gemma-4-31b-it:free"
60
- LLAMA_3_3_70B_FREE_MODEL_ID = "openai/meta-llama/llama-3.3-70b-instruct:free"
61
- LLAMA_3_1_8B_MODEL_ID = "openai/meta-llama/llama-3.1-8b-instruct"
62
- LAGUNA_M1_FREE_MODEL_ID = "openai/poolside/laguna-m.1:free"
63
- LAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"
64
- NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"
65
- LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"
66
- '''
67
- # Only replace the first occurrence (the variable definition)
68
- if target_line in content:
69
- content = content.replace(target_line, insert_block + target_line)
70
- print("OK: Added missing constants")
71
- else:
72
- print("WARN: Could not find target line")
73
-
74
- # Add Space Bunny independently so this remains idempotent when the base
75
- # model_ids.py already contains the earlier OpenRouter constants.
76
- if "SPACE_BUNNY_ALPHA_MODEL_ID" not in content:
77
- anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
78
- if anchor in content:
79
- content = content.replace(
80
- anchor,
81
- anchor + '\nSPACE_BUNNY_ALPHA_MODEL_ID = "openai/stealth/space-bunny-alpha"\n',
82
- 1,
83
- )
84
- print("OK: Added SPACE_BUNNY_ALPHA_MODEL_ID")
85
- else:
86
- print("WARN: Could not find LING_3_0_FLASH_FREE_MODEL_ID anchor")
87
 
88
- # Replace all references to ~deepseek deepseek models with 0731 version (quoted strings only)
89
- content = content.replace('"~deepseek/deepseek-v4-flash-latest"', '"deepseek/deepseek-v4-flash-0731"')
90
- content = content.replace('"~deepseek/deepseek-v4-flash-0731"', '"deepseek/deepseek-v4-flash-0731"')
91
 
92
  with open(IDS_FILE, "w") as f:
93
  f.write(content)
94
- print("OK: model_ids.py written")
95
-
96
- ast.parse(content) # validate syntax
97
- print("OK: model_ids.py syntax OK")
98
 
 
99
  with open(AGENT_FILE) as f:
100
  content = f.read()
101
 
 
102
  content = content.replace(
103
  "DEFAULT_MODEL_ID = GLM_52_MODEL_ID",
104
- 'DEFAULT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID'
105
  )
106
  content = content.replace(
107
  "DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID",
108
- "DEFAULT_GPT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID"
109
  )
110
 
111
  # Update imports
112
- old_import = (
113
- "from agent.core.model_ids import (\n"
114
- " CLAUDE_OPUS_48_MODEL_ID,\n"
115
- " DEEPSEEK_V4_PRO_MODEL_ID,\n"
116
- " GLM_52_MODEL_ID,\n"
117
- " GPT_55_MODEL_ID,\n"
118
- " KIMI_K27_CODE_MODEL_ID,\n"
119
- " MINIMAX_M3_MODEL_ID,\n"
120
- " strip_huggingface_model_prefix,\n"
121
- ")"
122
- )
123
- new_import = (
124
- "from agent.core.model_ids import (\n"
125
- " DEEPSEEK_V4_PRO_MODEL_ID,\n"
126
- " GLM_52_MODEL_ID,\n"
127
- " GPT_55_MODEL_ID,\n"
128
- " KIMI_K27_CODE_MODEL_ID,\n"
129
- " MINIMAX_M3_MODEL_ID,\n"
130
- " TENCENT_HY3_FREE_MODEL_ID,\n"
131
- " GEMMA_4_31B_FREE_MODEL_ID,\n"
132
- " LLAMA_3_3_70B_FREE_MODEL_ID,\n"
133
- " LLAMA_3_1_8B_MODEL_ID,\n"
134
- " LAGUNA_M1_FREE_MODEL_ID,\n"
135
- " LAGUNA_S21_FREE_MODEL_ID,\n"
136
- " NEX_N2_MINI_MODEL_ID,\n"
137
- " LING_3_0_FLASH_FREE_MODEL_ID,\n"
138
- " SPACE_BUNNY_ALPHA_MODEL_ID,\n"
139
- " strip_huggingface_model_prefix,\n"
140
- ")"
141
- )
142
  content = content.replace(old_import, new_import)
143
 
144
- # Update _available_models to include DeepSeek V4 Flash 0731
 
 
 
 
 
 
145
  new_func = '''def _available_models() -> list[dict[str, Any]]:
146
  models = [
147
- {"id": TENCENT_HY3_FREE_MODEL_ID, "label": "Tencent HY3:free", "recommended": True},
148
- {"id": GEMMA_4_31B_FREE_MODEL_ID, "label": "Gemma 4 31B:free", "recommended": True},
149
- {"id": LLAMA_3_3_70B_FREE_MODEL_ID, "label": "Llama 3.3 70B:free", "recommended": True},
150
- {"id": LAGUNA_M1_FREE_MODEL_ID, "label": "Laguna M.1:free", "recommended": True},
151
- {"id": DEFAULT_GPT_MODEL_ID, "label": "Laguna S 2.1:free", "recommended": True},
152
- {"id": SPACE_BUNNY_ALPHA_MODEL_ID, "label": "stealth/space-bunny-alpha", "recommended": True},
153
- {"id": NEX_N2_MINI_MODEL_ID, "label": "nex-agi/nex-n2-mini", "recommended": True},
154
- {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},
155
- {"id": LLAMA_3_1_8B_MODEL_ID, "label": "Llama 3.1 8B"},
156
- {"id": KIMI_K27_CODE_MODEL_ID, "label": "DeepSeek V4 Pro"},
157
- {"id": MINIMAX_M3_MODEL_ID, "label": "DeepSeek V4 Flash"},
158
- {"id": DEFAULT_MODEL_ID, "label": "Laguna S 2.1:free"},
159
- {"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "Nemotron 3 Super 120B"},
160
- {"id": "deepseek/deepseek-v4-flash-0731", "label": "DeepSeek V4 Flash 0731", "recommended": True},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  ]
162
  return models'''
163
 
164
- func_match = re.search(
165
- r"def _available_models\(\)\s*->\s*list\[dict\[str,\s*Any\]\]:.*?return models",
166
- content, re.DOTALL
167
- )
168
  if func_match:
169
  content = content[:func_match.start()] + new_func + content[func_match.end():]
170
- print("OK: Replaced _available_models() with DeepSeek V4 Flash 0731")
171
  else:
172
- old_func = (
173
- "def _available_models() -> list[dict[str, Any]]:\n"
174
- " models = [\n"
175
- ' {"id": CLAUDE_OPUS_48_MODEL_ID, "label": "Claude Opus 4.8"},\n'
176
- ' {"id": DEFAULT_GPT_MODEL_ID, "label": "GPT-5.5"},\n'
177
- ' {"id": KIMI_K27_CODE_MODEL_ID, "label": "Kimi K2.7 Code"},\n'
178
- ' {"id": MINIMAX_M3_MODEL_ID, "label": "MiniMax M3"},\n'
179
- ' {"id": DEFAULT_MODEL_ID, "label": "GLM 5.2", "recommended": True},\n'
180
- ' {"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "DeepSeek V4 Pro"},\n'
181
- " ]\n"
182
- " return models"
183
- )
184
- if old_func in content:
185
- content = content.replace(old_func, new_func)
186
- print("OK: String replace of _available_models()")
187
  else:
188
- print("WARN: Could not find _available_models() pattern, searching...")
189
- idx = content.find("def _available_models")
190
- if idx >= 0:
191
- print(f"Found at index {idx}, context: {content[idx:idx+400]}")
192
 
193
- content = content.replace(
194
- '"openai/gpt-oss-120b:cerebras",',
195
- '"huggingface/deepseek-ai/DeepSeek-V4-Pro",'
196
- )
197
 
198
  try:
199
  ast.parse(content)
@@ -204,40 +131,28 @@ except SyntaxError as e:
204
 
205
  with open(AGENT_FILE, "w") as f:
206
  f.write(content)
 
207
 
208
- # Patch llm_params for OpenRouter routing
209
- # FIX: Strip the "openai/" prefix before sending model name to OpenRouter
210
- # OpenRouter model IDs do not include the "openai/" prefix.
211
- # e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free"
212
  with open(LLM_PARAMS_FILE) as f:
213
  llm_content = f.read()
214
 
215
  if "normalized_model.startswith" not in llm_content:
216
  api_key_find = "api_key = _resolve_hf_router_token(session_hf_token)"
217
  if api_key_find in llm_content:
218
- line_end = llm_content.find("\n", llm_content.find(api_key_find) + len(api_key_find)) + 1
219
- routing_insert = (
220
- ' # Route deepseek/ models to OpenRouter\n'
221
- ' if normalized_model.startswith("deepseek/"):\n'
222
- ' return {\n'
223
- ' "model": normalized_model,\n'
224
- ' "api_base": "https://openrouter.ai/api/v1",\n'
225
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
226
- ' }\n\n'
227
- ' # Route openai/-prefixed models to OpenRouter\n'
228
- ' # FIX: Strip the "openai/" prefix - OpenRouter model IDs do not include it.\n'
229
- ' # e.g. "openai/deepseek/deepseek-v4-flash" -> "deepseek/deepseek-v4-flash"\n'
230
- ' # e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free"\n'
231
- ' if normalized_model.startswith("openai/"):\n'
232
- ' or_model = normalized_model[len("openai/"):]\n'
233
- ' return {\n'
234
- ' "model": or_model,\n'
235
- ' "api_base": "https://openrouter.ai/api/v1",\n'
236
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
237
- ' }\n\n'
238
- )
239
  llm_content = llm_content[:line_end] + routing_insert + llm_content[line_end:]
240
- print("OK: Patched _resolve_llm_params with deepseek/ + fixed openai/ routing")
241
  else:
242
  print("WARN: Could not find api_key line")
243
  else:
@@ -253,4 +168,4 @@ except SyntaxError as e:
253
  with open(LLM_PARAMS_FILE, "w") as f:
254
  f.write(llm_content)
255
 
256
- print("OK: ALL BACKEND PATCHES APPLIED (including DeepSeek V4 Flash routing fix)")
 
1
+ """Patch backend: replace AVAILABLE_MODELS with OpenRouter models + routing."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import ast
4
  import os
 
6
 
7
  AGENT_FILE = "/app/backend/routes/agent.py"
8
  LLM_PARAMS_FILE = "/app/agent/core/llm_params.py"
 
9
 
10
+ # === Step 1: Patch model_ids.py ===
11
+ IDS_FILE = "/app/agent/core/model_ids.py"
12
+ with open(IDS_FILE, "r") as f:
13
  content = f.read()
14
 
15
+ old_models_block = 'CLAUDE_OPUS_48_MODEL_ID = "anthropic/claude-opus-4.8:fal-ai"\nGPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"\nKIMI_K27_CODE_MODEL_ID = "moonshotai/Kimi-K2.7-Code:novita"\nMINIMAX_M3_MODEL_ID = "MiniMaxAI/MiniMax-M3:novita"\nGLM_52_MODEL_ID = "zai-org/GLM-5.2:novita"\nDEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"'
 
 
 
16
 
17
+ new_models_block = 'GPT_55_MODEL_ID = "openai/gpt-5.5:fal-ai"\nKIMI_K27_CODE_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro"\nMINIMAX_M3_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash"\nGLM_52_MODEL_ID = "openai/deepseek/deepseek-v4-flash"\nDEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"\n\n# === PATCH: OpenRouter models ===\nGEMMA_4_31B_FREE_MODEL_ID = "openai/google/gemma-4-31b-it:free"\nTENCENT_HY3_FREE_MODEL_ID = "openai/tencent/hy3:free"\nLLAMA_3_3_70B_FREE_MODEL_ID = "openai/meta-llama/llama-3.3-70b-instruct:free"\nLLAMA_3_1_8B_MODEL_ID = "openai/meta-llama/llama-3.1-8b-instruct"\nLAGUNA_M1_FREE_MODEL_ID = "openai/poolside/laguna-m.1:free"\nLAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ content = content.replace(old_models_block, new_models_block)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ old_hosted = "HOSTED_MODEL_IDS = {\n CLAUDE_OPUS_48_MODEL_ID,\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n GLM_52_MODEL_ID,\n DEEPSEEK_V4_PRO_MODEL_ID,\n}"
22
+ new_hosted = "HOSTED_MODEL_IDS = {\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n GLM_52_MODEL_ID,\n DEEPSEEK_V4_PRO_MODEL_ID,\n GEMMA_4_31B_FREE_MODEL_ID,\n TENCENT_HY3_FREE_MODEL_ID,\n LLAMA_3_3_70B_FREE_MODEL_ID,\n LLAMA_3_1_8B_MODEL_ID,\n LAGUNA_M1_FREE_MODEL_ID,\n LAGUNA_S21_FREE_MODEL_ID,\n}"
23
+ content = content.replace(old_hosted, new_hosted)
24
 
25
  with open(IDS_FILE, "w") as f:
26
  f.write(content)
27
+ print("OK: model_ids.py patched")
 
 
 
28
 
29
+ # === Step 2: Patch agent.py — replace _available_models() via regex ===
30
  with open(AGENT_FILE) as f:
31
  content = f.read()
32
 
33
+ # Make DEFAULT_MODEL_ID and DEFAULT_GPT_MODEL_ID point to our models
34
  content = content.replace(
35
  "DEFAULT_MODEL_ID = GLM_52_MODEL_ID",
36
+ 'DEFAULT_MODEL_ID = "openai/tencent/hy3:free"'
37
  )
38
  content = content.replace(
39
  "DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID",
40
+ 'DEFAULT_GPT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID'
41
  )
42
 
43
  # Update imports
44
+ 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)"
45
+ new_import = "from agent.core.model_ids import (\n DEEPSEEK_V4_PRO_MODEL_ID,\n GLM_52_MODEL_ID,\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n GEMMA_4_31B_FREE_MODEL_ID,\n TENCENT_HY3_FREE_MODEL_ID,\n LLAMA_3_3_70B_FREE_MODEL_ID,\n LLAMA_3_1_8B_MODEL_ID,\n LAGUNA_M1_FREE_MODEL_ID,\n LAGUNA_S21_FREE_MODEL_ID,\n strip_huggingface_model_prefix,\n)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  content = content.replace(old_import, new_import)
47
 
48
+ # Replace _available_models() using regex
49
+ func_pattern = re.compile(
50
+ r'def _available_models\(\) -> list\[dict\[str, Any\]\]:\s*\n'
51
+ r'\s+models\s*=\s*\[.*?\]\s*\n\s+return models',
52
+ re.DOTALL
53
+ )
54
+
55
  new_func = '''def _available_models() -> list[dict[str, Any]]:
56
  models = [
57
+ {
58
+ "id": TENCENT_HY3_FREE_MODEL_ID,
59
+ "label": "Tencent HY3:free",
60
+ "recommended": True,
61
+ },
62
+ {
63
+ "id": GEMMA_4_31B_FREE_MODEL_ID,
64
+ "label": "Gemma 4 31B:free",
65
+ "recommended": True,
66
+ },
67
+ {
68
+ "id": LLAMA_3_3_70B_FREE_MODEL_ID,
69
+ "label": "Llama 3.3 70B:free",
70
+ "recommended": True,
71
+ },
72
+ {
73
+ "id": LAGUNA_M1_FREE_MODEL_ID,
74
+ "label": "Laguna M.1:free",
75
+ "recommended": True,
76
+ },
77
+ {
78
+ "id": DEFAULT_GPT_MODEL_ID,
79
+ "label": "Laguna S 2.1:free",
80
+ "recommended": True,
81
+ },
82
+ {
83
+ "id": LLAMA_3_1_8B_MODEL_ID,
84
+ "label": "Llama 3.1 8B",
85
+ },
86
+ {
87
+ "id": KIMI_K27_CODE_MODEL_ID,
88
+ "label": "DeepSeek V4 Pro",
89
+ },
90
+ {
91
+ "id": MINIMAX_M3_MODEL_ID,
92
+ "label": "DeepSeek V4 Flash",
93
+ },
94
+ {
95
+ "id": DEFAULT_MODEL_ID,
96
+ "label": "DeepSeek V4 Flash",
97
+ },
98
+ {
99
+ "id": DEEPSEEK_V4_PRO_MODEL_ID,
100
+ "label": "Nemotron 3 Super 120B",
101
+ },
102
  ]
103
  return models'''
104
 
105
+ func_match = func_pattern.search(content)
 
 
 
106
  if func_match:
107
  content = content[:func_match.start()] + new_func + content[func_match.end():]
108
+ print("OK: Replaced _available_models() via regex")
109
  else:
110
+ print("WARN: Regex failed, trying string replace...")
111
+ old_available = 'def _available_models() -> list[dict[str, Any]]:\n models = [\n {\n "id": CLAUDE_OPUS_48_MODEL_ID,\n "label": "Claude Opus 4.8",\n },\n {\n "id": DEFAULT_GPT_MODEL_ID,\n "label": "GPT-5.5",\n },\n {\n "id": KIMI_K27_CODE_MODEL_ID,\n "label": "Kimi K2.7 Code",\n },\n {\n "id": MINIMAX_M3_MODEL_ID,\n "label": "MiniMax M3",\n },\n {\n "id": DEFAULT_MODEL_ID,\n "label": "GLM 5.2",\n "recommended": True,\n },\n {\n "id": DEEPSEEK_V4_PRO_MODEL_ID,\n "label": "DeepSeek V4 Pro",\n },\n ]\n return models'
112
+ if old_available in content:
113
+ content = content.replace(old_available, new_func)
114
+ print("OK: String replace worked")
 
 
 
 
 
 
 
 
 
 
115
  else:
116
+ print("FAIL: Cannot find _available_models()!")
117
+ import sys
118
+ sys.exit(1)
 
119
 
120
+ # Update title generation
121
+ old_title = '"openai/gpt-oss-120b:cerebras",'
122
+ new_title = '"huggingface/deepseek-ai/DeepSeek-V4-Pro",'
123
+ content = content.replace(old_title, new_title)
124
 
125
  try:
126
  ast.parse(content)
 
131
 
132
  with open(AGENT_FILE, "w") as f:
133
  f.write(content)
134
+ print("OK: agent.py patched")
135
 
136
+ # === Step 3: Patch _resolve_llm_params for OpenRouter routing ===
 
 
 
137
  with open(LLM_PARAMS_FILE) as f:
138
  llm_content = f.read()
139
 
140
  if "normalized_model.startswith" not in llm_content:
141
  api_key_find = "api_key = _resolve_hf_router_token(session_hf_token)"
142
  if api_key_find in llm_content:
143
+ line_end = llm_content.find('\n', llm_content.find(api_key_find) + len(api_key_find)) + 1
144
+ routing_insert = ''' # === PATCH: Route ALL openai/-prefixed models to OpenRouter ===
145
+ if normalized_model.startswith("openai/"):
146
+ return {
147
+ "model": normalized_model,
148
+ "api_base": "https://openrouter.ai/api/v1",
149
+ "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",
150
+ }
151
+ # === END PATCH ===
152
+
153
+ '''
 
 
 
 
 
 
 
 
 
 
154
  llm_content = llm_content[:line_end] + routing_insert + llm_content[line_end:]
155
+ print("OK: Patched _resolve_llm_params (catch-all openai/ -> OR)")
156
  else:
157
  print("WARN: Could not find api_key line")
158
  else:
 
168
  with open(LLM_PARAMS_FILE, "w") as f:
169
  f.write(llm_content)
170
 
171
+ print("OK: ALL PATCHES APPLIED")
patch_multi_upload.py DELETED
@@ -1,183 +0,0 @@
1
- """Patch: allow multiple file uploads in one request.
2
-
3
- Backend changes:
4
- 1. Replace the single-file helper with a multi-file helper.
5
- 2. Rewrite the upload route to accept up to 20 files and return a list of
6
- DatasetUploadResponse, uploading each file to the session's Hub dataset
7
- repo and injecting a context note for each.
8
- """
9
-
10
- import ast
11
- import re
12
- import os
13
-
14
- AGENT_FILE = "/app/backend/routes/agent.py"
15
-
16
- with open(AGENT_FILE) as f:
17
- c = f.read()
18
-
19
- # ---- 1. Replace single-file helper with multi-file helper ----
20
- old_helper = '''def _dataset_upload_file_from_form(form: FormData) -> UploadFile:
21
- uploaded_files = [
22
- (key, value)
23
- for key, value in form.multi_items()
24
- if isinstance(value, UploadFile)
25
- ]
26
- if len(uploaded_files) != 1:
27
- raise HTTPException(
28
- status_code=400,
29
- detail="Upload exactly one dataset file.",
30
- )
31
- field_name, upload = uploaded_files[0]
32
- if field_name != "file":
33
- raise HTTPException(
34
- status_code=400,
35
- detail="Missing 'file' upload field.",
36
- )
37
- return upload
38
- '''
39
-
40
- new_helper = '''def _dataset_upload_files_from_form(form: FormData) -> list[UploadFile]:
41
- """Return all uploaded files from the multipart form (field 'file' or 'files')."""
42
- uploaded_files = [
43
- value
44
- for key, value in form.multi_items()
45
- if isinstance(value, UploadFile)
46
- ]
47
- if not uploaded_files:
48
- raise HTTPException(
49
- status_code=400,
50
- detail="No file uploads found in the request.",
51
- )
52
- return uploaded_files
53
- '''
54
-
55
- if old_helper in c:
56
- c = c.replace(old_helper, new_helper)
57
- print("OK: replaced helper with multi-file helper")
58
- else:
59
- print("WARN: single-file helper not found (check indentation)")
60
-
61
- # ---- 2. Rewrite the upload route to handle multiple files ----
62
- # Replace the entire function based on its decorator + route path.
63
- start_marker = '@router.post("/session/{session_id}/datasets"'
64
- end_marker = '@router.patch("/session/{session_id}/yolo")'
65
- start = c.find(start_marker)
66
- end = c.find(end_marker)
67
- if start == -1 or end == -1:
68
- print(f"FAIL: route markers not found (start={start}, end={end})")
69
- raise SystemExit(1)
70
-
71
- new_route = '''@router.post("/session/{session_id}/datasets", response_model=list[DatasetUploadResponse])
72
- async def upload_session_dataset(
73
- session_id: str,
74
- request: Request,
75
- user: dict = Depends(get_current_user),
76
- ) -> list[DatasetUploadResponse]:
77
- """Upload one or more document/dataset files to a private Hub dataset for this session."""
78
- files: list[UploadFile] = []
79
- try:
80
- _reject_oversize_dataset_upload(request)
81
- agent_session = await _check_session_access(session_id, user, request)
82
- if not agent_session or not agent_session.is_active:
83
- raise HTTPException(status_code=404, detail="Session not found")
84
- if agent_session.is_processing:
85
- raise HTTPException(
86
- status_code=409,
87
- detail="Cannot upload files while the agent is processing.",
88
- )
89
- if agent_session.session.pending_approval:
90
- raise HTTPException(
91
- status_code=409,
92
- detail="Resolve pending approvals before uploading files.",
93
- )
94
-
95
- hf_token = (
96
- resolve_hf_request_token(request, include_env_fallback=False)
97
- or _user_hf_token(user)
98
- or resolve_hf_request_token(request)
99
- )
100
- if not hf_token:
101
- raise HTTPException(
102
- status_code=401,
103
- detail="A Hugging Face token is required to upload files.",
104
- )
105
-
106
- form = await request.form(
107
- max_files=20,
108
- max_fields=20,
109
- max_part_size=MAX_DATASET_UPLOAD_BYTES,
110
- )
111
- files = _dataset_upload_files_from_form(form)
112
- if not files:
113
- raise HTTPException(status_code=400, detail="No files to upload.")
114
-
115
- hf_username = user.get("username") or agent_session.hf_username
116
- results: list[DatasetUploadResponse] = []
117
- for file in files:
118
- uploaded = await push_dataset_upload_to_hub(
119
- upload=file,
120
- session_id=session_id,
121
- hf_username=hf_username,
122
- hf_token=hf_token,
123
- )
124
- agent_session.session.context_manager.add_message(
125
- Message(role="user", content=format_uploaded_document_context(
126
- filename=uploaded.original_filename,
127
- stored_filename=uploaded.filename,
128
- repo_id=uploaded.repo_id,
129
- path_in_repo=uploaded.path_in_repo,
130
- hub_url=uploaded.hub_url,
131
- size_bytes=uploaded.size_bytes,
132
- file_format=uploaded.format,
133
- extracted_text=uploaded.extracted_text,
134
- warning=uploaded.extraction_warning,
135
- ))
136
- )
137
- results.append(DatasetUploadResponse(**uploaded.response_payload()))
138
- logger.info(
139
- "Uploaded file %s to %s for session %s",
140
- uploaded.filename,
141
- uploaded.repo_id,
142
- session_id,
143
- )
144
-
145
- session_manager._touch(agent_session)
146
- await session_manager.persist_session_snapshot(agent_session)
147
- return results
148
- except HTTPException:
149
- raise
150
- except HfHubHTTPError as e:
151
- logger.warning(
152
- "Hub rejected file upload for session %s: status=%s request_id=%s",
153
- session_id,
154
- getattr(e.response, "status_code", None),
155
- getattr(e, "request_id", None),
156
- )
157
- raise _dataset_upload_hub_http_exception(e)
158
- except Exception:
159
- logger.exception("File upload failed for session %s", session_id)
160
- raise HTTPException(
161
- status_code=502,
162
- detail="File upload failed. Please try again.",
163
- )
164
- finally:
165
- for f in files:
166
- await f.close()
167
-
168
-
169
- '''
170
-
171
- c = c[:start] + new_route + c[end:]
172
-
173
- with open(AGENT_FILE, "w") as f:
174
- f.write(c)
175
-
176
- try:
177
- ast.parse(c)
178
- print("OK: agent.py written & syntax valid")
179
- except SyntaxError as e:
180
- print(f"FAIL: agent.py syntax error: {e}")
181
- raise
182
-
183
- print("DONE: multi-file upload backend patch complete")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_new_models.py DELETED
@@ -1,88 +0,0 @@
1
- """Patch backend: add 3 new OpenRouter models (openai/ prefix routes to OpenRouter).
2
-
3
- New models:
4
- - openai/upstage/solar-pro4
5
- - openai/nvidia/nemotron-3.5-lightning:free
6
- - openai/liquid/lfm-2.5-2.6b:free
7
-
8
- The `openai/` prefix is already stripped in _resolve_llm_params and routed to
9
- OpenRouter, so we only need to register the model IDs, imports, and the
10
- available-models list.
11
- """
12
-
13
- import ast
14
- import os
15
-
16
- IDS_FILE = "/app/agent/core/model_ids.py"
17
- AGENT_FILE = "/app/backend/routes/agent.py"
18
-
19
- # ---------- 1. model_ids.py ----------
20
- with open(IDS_FILE) as f:
21
- c = f.read()
22
-
23
- new_constants = '''UPSTAGE_SOLAR_PRO4_MODEL_ID = "openai/upstage/solar-pro4"
24
- NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-lightning:free"
25
- LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID = "openai/liquid/lfm-2.5-2.6b:free"
26
- '''
27
-
28
- if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in c:
29
- # Insert before DEEPSEEK_V4_PRO_MODEL_ID definition
30
- target = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
31
- if target in c:
32
- c = c.replace(target, new_constants + target)
33
- print("OK: added 3 new model constants to model_ids.py")
34
- else:
35
- # fallback: append at end of constants near TENCENT/HY3 block
36
- anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
37
- if anchor in c:
38
- c = c.replace(anchor, anchor + "\n" + new_constants)
39
- print("OK: added constants after LING block")
40
- else:
41
- print("FAIL: could not find insertion anchor in model_ids.py")
42
- else:
43
- print("OK: constants already present")
44
-
45
- with open(IDS_FILE, "w") as f:
46
- f.write(c)
47
- ast.parse(c)
48
- print("OK: model_ids.py syntax OK")
49
-
50
- # ---------- 2. agent.py : imports + _available_models ----------
51
- with open(AGENT_FILE) as f:
52
- ac = f.read()
53
-
54
- # Add imports
55
- if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in ac:
56
- old_imp = " NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n"
57
- new_imp = (
58
- " NEX_N2_MINI_MODEL_ID,\n"
59
- " LING_3_0_FLASH_FREE_MODEL_ID,\n"
60
- " UPSTAGE_SOLAR_PRO4_MODEL_ID,\n"
61
- " NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID,\n"
62
- " LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID,\n"
63
- )
64
- if old_imp in ac:
65
- ac = ac.replace(old_imp, new_imp)
66
- print("OK: added new model imports")
67
- else:
68
- print("WARN: import anchor not matched")
69
-
70
- # Add to _available_models list - insert after LING_3_0_FLASH line
71
- old_line = ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n'
72
- new_line = old_line + (
73
- ' {"id": UPSTAGE_SOLAR_PRO4_MODEL_ID, "label": "UPSTAGE Solar Pro 4", "recommended": True},\n'
74
- ' {"id": NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID, "label": "NVIDIA Nemotron 3.5 Lightning:free", "recommended": True},\n'
75
- ' {"id": LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID, "label": "Liquid LFM 2.5 2.6B:free", "recommended": True},\n'
76
- )
77
- if old_line in ac:
78
- ac = ac.replace(old_line, new_line)
79
- print("OK: added 3 new models to _available_models")
80
- else:
81
- print("WARN: _available_models LING line not matched (check exact text)")
82
-
83
- with open(AGENT_FILE, "w") as f:
84
- f.write(ac)
85
- ast.parse(ac)
86
- print("OK: agent.py syntax OK")
87
-
88
- print("DONE: 3 new OpenRouter models added (backend)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_openrouter_fix.py DELETED
@@ -1,92 +0,0 @@
1
- """Patch backend llm_params.py: force OpenRouter as the LiteLLM provider.
2
-
3
- Root cause: the openai/-prefixed (and deepseek/) model routing returns only
4
- ``api_base`` + ``api_key``. LiteLLM resolves the provider from the model
5
- string's org prefix, so for models whose org is NOT a recognized LiteLLM
6
- provider (upstage/, nvidia/, liquid/, meta-llama/, google/, poolside/,
7
- inclusionai/, ...) it raises "LLM Provider NOT provided" even though
8
- ``api_base`` points at OpenRouter. Only orgs that happen to match a real
9
- LiteLLM provider (e.g. tencent/) worked by luck.
10
-
11
- Fix: add ``custom_llm_provider: "openrouter"`` to both OpenRouter routing
12
- branches so LiteLLM always uses the OpenRouter OpenAI-compatible client.
13
-
14
- This runs AFTER patch_models.py (which may already have inserted the routing
15
- blocks); it is idempotent.
16
- """
17
-
18
- import ast
19
- import os
20
-
21
- FILE = "/app/agent/core/llm_params.py"
22
-
23
- with open(FILE) as f:
24
- c = f.read()
25
-
26
- before = c
27
-
28
- # --- 1. openai/ branch (unique via or_model line) ---
29
- openai_old = (
30
- ' or_model = normalized_model[len("openai/"):]\n'
31
- ' return {\n'
32
- ' "model": or_model,\n'
33
- ' "api_base": "https://openrouter.ai/api/v1",\n'
34
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
35
- ' }\n'
36
- )
37
- openai_new = (
38
- ' or_model = normalized_model[len("openai/"):]\n'
39
- ' return {\n'
40
- ' "model": or_model,\n'
41
- ' "api_base": "https://openrouter.ai/api/v1",\n'
42
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
43
- ' "custom_llm_provider": "openrouter",\n'
44
- ' }\n'
45
- )
46
-
47
- # --- 2. deepseek/ branch (unique via normalized_model on its own line) ---
48
- deepseek_old = (
49
- ' if normalized_model.startswith("deepseek/"):\n'
50
- ' return {\n'
51
- ' "model": normalized_model,\n'
52
- ' "api_base": "https://openrouter.ai/api/v1",\n'
53
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
54
- ' }\n'
55
- )
56
- deepseek_new = (
57
- ' if normalized_model.startswith("deepseek/"):\n'
58
- ' return {\n'
59
- ' "model": normalized_model,\n'
60
- ' "api_base": "https://openrouter.ai/api/v1",\n'
61
- ' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
62
- ' "custom_llm_provider": "openrouter",\n'
63
- ' }\n'
64
- )
65
-
66
- if 'custom_llm_provider' in c:
67
- print("OK: llm_params.py already contains custom_llm_provider (idempotent)")
68
- else:
69
- n1 = c.count(openai_old)
70
- n2 = c.count(deepseek_old)
71
- if n1 == 1:
72
- c = c.replace(openai_old, openai_new)
73
- print("OK: openai/ branch -> custom_llm_provider='openrouter'")
74
- else:
75
- print(f"WARN: openai/ branch match count = {n1} (expected 1)")
76
- if n2 == 1:
77
- c = c.replace(deepseek_old, deepseek_new)
78
- print("OK: deepseek/ branch -> custom_llm_provider='openrouter'")
79
- else:
80
- print(f"WARN: deepseek/ branch match count = {n2} (expected 1)")
81
-
82
- if c != before:
83
- with open(FILE, "w") as f:
84
- f.write(c)
85
- try:
86
- ast.parse(c)
87
- print("OK: llm_params.py written & syntax valid")
88
- except SyntaxError as e:
89
- print(f"FAIL: llm_params.py syntax error: {e}")
90
- raise
91
- else:
92
- print("WARN: no changes applied")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_permanent_session_logging.py DELETED
@@ -1,358 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Patch: Permanent session & user context logging.
4
-
5
- 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.
6
- Tắt thông báo "where we here now?". Đảm bảo mọi model đều được lưu session.
7
-
8
- Chạy trong Docker build — an toàn với mọi trường hợp lỗi (never fails build).
9
- """
10
-
11
- import ast
12
- import os
13
- import sys
14
-
15
- PATCHED_MARKER = "# === PATCH: permanent session logging ==="
16
-
17
- def _safe_replace(content, old, new, count=1):
18
- """Replace and verify count changed."""
19
- if old in content:
20
- content = content.replace(old, new, count)
21
- return content, True
22
- return content, False
23
-
24
- def patch_file(path, patch_fn):
25
- """Safely read, patch, and write a file. Returns True if patched."""
26
- if not os.path.exists(path):
27
- print(f"SKIP: {path} not found")
28
- return False
29
- try:
30
- with open(path, "r") as f:
31
- content = f.read()
32
- content, changed = patch_fn(content)
33
- if not changed:
34
- print(f"SKIP: {path} — no changes needed")
35
- return False
36
- # Validate syntax
37
- try:
38
- ast.parse(content)
39
- except SyntaxError as e:
40
- print(f"FAIL: {path} syntax: {e}")
41
- return False
42
- with open(path, "w") as f:
43
- f.write(content)
44
- print(f"OK: {path} patched")
45
- return True
46
- except Exception as e:
47
- print(f"WARN: {path} patch error: {e}")
48
- return False
49
-
50
-
51
- # ─── Patch session_manager.py ────────────────────────────────────────────────
52
- print("=== Patching session_manager.py ===")
53
-
54
- SESSION_MANAGER = "/app/backend/session_manager.py"
55
-
56
- def _patch_session_manager(content):
57
- changed = False
58
-
59
- # Add import
60
- if "from backend.session_logger import" not in content:
61
- content, ok = _safe_replace(
62
- content,
63
- "from agent.messaging.gateway import NotificationGateway",
64
- "from agent.messaging.gateway import NotificationGateway\n"
65
- "from backend.session_logger import log_message as _perm_log_message, log_trajectory as _perm_log_trajectory",
66
- )
67
- if ok:
68
- print(" + Added session_logger import")
69
- changed = True
70
-
71
- # Patch create_session
72
- if PATCHED_MARKER not in content:
73
- # Add session creation logging after session_id = str(uuid.uuid4())
74
- old = " session_id = str(uuid.uuid4())\n"
75
- new = ''' session_id = str(uuid.uuid4())
76
-
77
- # === PATCH: permanent session logging ===
78
- # Log session creation to dataset IMMEDIATELY — every model gets session saved
79
- try:
80
- def _do_create_log():
81
- _perm_log_message(
82
- session_id=session_id,
83
- user_id=user_id or "dev",
84
- model_name=model or "",
85
- role="system",
86
- content=f"Session created for {user_id or 'dev'} with model {model or 'default'}",
87
- event_type="session_created",
88
- seq=0,
89
- extra={"surface": "frontend"},
90
- hf_user=hf_username,
91
- )
92
- asyncio.get_event_loop().run_in_executor(None, _do_create_log)
93
- except Exception as _e:
94
- logger.debug("Session creation logging failed: %s", _e)
95
- '''
96
- content, ok = _safe_replace(content, old, new)
97
- if ok:
98
- print(" + Added session creation logging")
99
- changed = True
100
-
101
- # Patch submit_user_input
102
- old_submit = " agent_session.submission_queue.put_nowait(submission)\n self._touch(agent_session)"
103
- new_submit = ''' agent_session.submission_queue.put_nowait(submission)
104
- self._touch(agent_session)
105
-
106
- # === PATCH: log user message immediately ===
107
- try:
108
- model_name = getattr(agent_session.session.config, 'model_name', '')
109
- def _do_msg_log():
110
- _perm_log_message(
111
- session_id=session_id,
112
- user_id=user_id,
113
- model_name=model_name,
114
- role="user",
115
- content=text,
116
- event_type="message",
117
- seq=len(agent_session.session.context_manager.items),
118
- extra={"source": "submit_user_input"},
119
- hf_user=agent_session.hf_username or agent_session.user_id,
120
- )
121
- asyncio.get_event_loop().run_in_executor(None, _do_msg_log)
122
- except Exception as _e:
123
- logger.debug("Message logging failed: %s", _e)'''
124
- content, ok = _safe_replace(content, old_submit, new_submit)
125
- if ok:
126
- print(" + Added immediate message logging")
127
- changed = True
128
-
129
- # Patch trajectory upload
130
- old_refresh = " session.save_and_upload_detached(session.config.session_dataset_repo)"
131
- new_refresh = ''' session.save_and_upload_detached(session.config.session_dataset_repo)
132
- # === PATCH: upload full trajectory to permanent dataset ===
133
- try:
134
- trajectory = session.get_trajectory()
135
- hf_user = agent_session.hf_username or agent_session.user_id
136
- def _do_traj_upload():
137
- _perm_log_trajectory(trajectory, session.user_id or "dev", hf_user, agent_session.hf_token, False)
138
- asyncio.get_event_loop().run_in_executor(None, _do_traj_upload)
139
- except Exception as _e:
140
- logger.debug("Trajectory upload failed: %s", _e)'''
141
- content, ok = _safe_replace(content, old_refresh, new_refresh)
142
- if ok:
143
- print(" + Added trajectory upload")
144
- changed = True
145
-
146
- # Remove 'where we here now?'
147
- if "where we here now" in content.lower():
148
- lines = content.split("\n")
149
- content = "\n".join([l for l in lines if "where we here now" not in l.lower()])
150
- print(" + Removed 'where we here now?' notification")
151
- changed = True
152
-
153
- return content, changed
154
-
155
- patch_file(SESSION_MANAGER, _patch_session_manager)
156
-
157
- # ─── Patch routes/agent.py ───────────────────────────────────────────────────
158
- print("\n=== Patching routes/agent.py ===")
159
-
160
- ROUTES_AGENT = "/app/backend/routes/agent.py"
161
-
162
- def _patch_routes(content):
163
- changed = False
164
-
165
- # Add import
166
- if "from backend.session_logger import" not in content:
167
- content, ok = _safe_replace(
168
- content,
169
- "from session_manager import (\n",
170
- "from backend.session_logger import log_message as _log_session, PUBLIC_SESSIONS_DATASET\n"
171
- "from session_manager import (\n",
172
- )
173
- if ok:
174
- print(" + Added session_logger import")
175
- changed = True
176
-
177
- # Add endpoints
178
- if "/session-log/" not in content:
179
- log_endpoint = '''
180
-
181
- @router.post("/session-log/{session_id}")
182
- async def log_session_event(
183
- session_id: str,
184
- body: dict,
185
- user: dict = Depends(get_current_user),
186
- ) -> dict:
187
- """Receive permanent session event logs from the frontend."""
188
- event_type = body.get("event_type", "frontend_event")
189
- data = body.get("data", {})
190
- seq = body.get("seq", 0)
191
- role = body.get("role", "frontend")
192
- content_text = body.get("content", "")
193
- extra = {k: v for k, v in body.items() if k not in ("event_type", "data", "timestamp", "seq", "role", "content")}
194
- try:
195
- def _do_log():
196
- _log_session(
197
- session_id=session_id,
198
- user_id=user.get("user_id") or user.get("username") or "dev",
199
- model_name="",
200
- role=role,
201
- content=content_text or str(data)[:500],
202
- event_type=event_type,
203
- seq=seq,
204
- extra=extra,
205
- hf_user=user.get("username") or user.get("user_id"),
206
- )
207
- asyncio.get_event_loop().run_in_executor(None, _do_log)
208
- except Exception:
209
- pass
210
- return {"status": "logged", "session_id": session_id}
211
-
212
-
213
- @router.get("/sessions-history")
214
- async def get_sessions_history(user: dict = Depends(get_current_user)) -> list:
215
- """Get ALL sessions for user from permanent dataset (survives restart)."""
216
- from backend.session_logger import get_user_sessions
217
- user_id = user.get("user_id") or user.get("username") or "dev"
218
- return get_user_sessions(user_id, limit=100)
219
-
220
-
221
- @router.get("/session-history/{session_id}")
222
- async def get_session_history(
223
- session_id: str, user: dict = Depends(get_current_user)
224
- ) -> list:
225
- """Get full message history for a session from the permanent dataset."""
226
- from backend.session_logger import load_session_history
227
- return load_session_history(session_id)
228
-
229
-
230
- @router.get("/dataset-info")
231
- async def get_dataset_info(user: dict = Depends(get_current_user)) -> dict:
232
- """Info about the permanent sessions dataset."""
233
- return {
234
- "dataset_repo": PUBLIC_SESSIONS_DATASET,
235
- "dataset_url": f"https://huggingface.co/datasets/{PUBLIC_SESSIONS_DATASET}",
236
- "storage": "permanent (HF Dataset, never lost)",
237
- "note": "Sessions saved to dataset immediately on creation and on each message.",
238
- }
239
- '''
240
- content = content.rstrip() + "\n" + log_endpoint
241
- print(" + Added permanent logging endpoints")
242
- changed = True
243
-
244
- # Remove 'where we here now?'
245
- if "where we here now" in content.lower():
246
- lines = content.split("\n")
247
- content = "\n".join([l for l in lines if "where we here now" not in l.lower()])
248
- print(" + Removed 'where we here now?' notification")
249
- changed = True
250
-
251
- return content, changed
252
-
253
- patch_file(ROUTES_AGENT, _patch_routes)
254
-
255
- # ─── Patch session.py ────────────────────────────────────────────────────────
256
- print("\n=== Patching session.py ===")
257
-
258
- SESSION_FILE = "/app/agent/core/session.py"
259
-
260
- def _patch_session(content):
261
- changed = False
262
-
263
- # Add import
264
- if "from backend.session_logger import" not in content:
265
- content, ok = _safe_replace(
266
- content,
267
- "import uuid\n",
268
- "import uuid\n"
269
- "# === PATCH: permanent session logging ===\n"
270
- "try:\n"
271
- " from backend.session_logger import log_message as _perm_log_message\n"
272
- "except Exception:\n"
273
- " def _perm_log_message(*a, **kw): pass\n",
274
- )
275
- if ok:
276
- print(" + Added session_logger import")
277
- changed = True
278
-
279
- # Patch send_event to log permanently
280
- if "# === PATCH: permanent event logging ===" not in content:
281
- old_put = " await self.event_queue.put(event)\n await self._enqueue_auto_notification_requests(event)"
282
- new_put = ''' await self.event_queue.put(event)
283
- # === PATCH: permanent event logging ===
284
- try:
285
- def _do_event_log():
286
- _perm_log_message(
287
- session_id=self.session_id,
288
- user_id=self.user_id or "dev",
289
- model_name=self.config.model_name,
290
- role="event",
291
- content=str(event.data) if event.data else "",
292
- event_type=event.event_type,
293
- seq=event.seq,
294
- extra={"session_id": self.session_id},
295
- hf_user=self.hf_username or self.user_id,
296
- )
297
- asyncio.get_event_loop().run_in_executor(None, _do_event_log)
298
- except Exception as _e:
299
- logger.debug("Permanent event logging failed: %s", _e)
300
- await self._enqueue_auto_notification_requests(event)'''
301
- content, ok = _safe_replace(content, old_put, new_put)
302
- if ok:
303
- print(" + Added permanent event logging to send_event")
304
- changed = True
305
-
306
- # Patch start_new_conversation to log
307
- if "# === PATCH: log new conversation ===" not in content:
308
- old_conv = " self.context_manager.items = [system_msg] if system_msg is not None else []"
309
- new_conv = ''' self.context_manager.items = [system_msg] if system_msg is not None else []
310
- # === PATCH: log new conversation start ===
311
- try:
312
- def _do_conv_log():
313
- _perm_log_message(
314
- session_id=self.session_id,
315
- user_id=self.user_id or "dev",
316
- model_name=self.config.model_name,
317
- role="system",
318
- content=f"New conversation started (session: {self.session_id})",
319
- event_type="new_conversation",
320
- seq=0,
321
- extra={},
322
- hf_user=self.hf_username or self.user_id,
323
- )
324
- asyncio.get_event_loop().run_in_executor(None, _do_conv_log)
325
- except Exception as _e:
326
- logger.debug("New conversation logging failed: %s", _e)'''
327
- content, ok = _safe_replace(content, old_conv, new_conv)
328
- if ok:
329
- print(" + Added new conversation logging")
330
- changed = True
331
-
332
- return content, changed
333
-
334
- patch_file(SESSION_FILE, _patch_session)
335
-
336
- # ─── Patch frontend: disable 'where we here now?' ──────────────────────────────
337
- print("\n=== Patching frontend for notification removal ===")
338
- frontend_src = "/source/frontend/src"
339
- if os.path.exists(frontend_src):
340
- for root, dirs, files in os.walk(frontend_src):
341
- for fname in files:
342
- if fname.endswith((".ts", ".tsx")):
343
- fpath = os.path.join(root, fname)
344
- try:
345
- with open(fpath, "r", encoding="utf-8") as f:
346
- fcontent = f.read()
347
- if "where we here now" in fcontent.lower():
348
- lines = fcontent.split("\n")
349
- filtered = [l for l in lines if "where we here now" not in l.lower()]
350
- with open(fpath, "w", encoding="utf-8") as f:
351
- f.write("\n".join(filtered))
352
- print(f" + Removed 'where we here now?' from {fpath}")
353
- except Exception:
354
- pass
355
- else:
356
- print(" SKIP: frontend source not found")
357
-
358
- print("\n=== All patches applied (build will not fail regardless) ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_restore_full_app.py DELETED
@@ -1,84 +0,0 @@
1
- """Restore the pre-retirement full app: App.tsx (frontend) + start.sh (backend).
2
-
3
- Upstream smolagents/ml-intern retired the hosted app: frontend App.tsx now
4
- renders RetirementPage ("ML Intern has moved to HuggingChat") and backend/start.sh
5
- now runs retired_main:app. That turns this Space into a static redirect page and
6
- the backend never starts, so our model patches have no visible effect.
7
-
8
- This patch overwrites both files with the last pre-retirement versions that this
9
- Space's Dockerfile was built on:
10
- - frontend/src/App.tsx -> full AppLayout + useAuth app
11
- - backend/start.sh -> uvicorn main:app (the real backend)
12
-
13
- Runs from either build stage: it patches whichever target exists and prints
14
- SKIP for the other (idempotent, re-runnable).
15
- """
16
-
17
- import os
18
-
19
- FRONTEND_APP = r"""import { Box } from '@mui/material';
20
- import AppLayout from '@/components/Layout/AppLayout';
21
- import { useAuth } from '@/hooks/useAuth';
22
-
23
- function App() {
24
- // Non-blocking auth check -- fires in background, updates store when done.
25
- // If auth fails later, apiFetch redirects to /auth/login.
26
- useAuth();
27
-
28
- return (
29
- <Box sx={{ height: '100vh', display: 'flex' }}>
30
- <AppLayout />
31
- </Box>
32
- );
33
- }
34
-
35
- export default App;
36
- """
37
-
38
- BACKEND_START = r"""#!/bin/bash
39
- # Entrypoint for HF Spaces dev mode compatibility.
40
- # Dev mode spawns CMD multiple times simultaneously on restart.
41
- # Only the first instance can bind port 7860 - the rest must exit
42
- # with code 0 so the dev mode daemon doesn't mark the app as crashed.
43
-
44
- # Run uvicorn; if it fails due to port conflict, exit cleanly.
45
- uvicorn main:app --host 0.0.0.0 --port 7860
46
- EXIT_CODE=$?
47
-
48
- if [ $EXIT_CODE -ne 0 ]; then
49
- # Check if this was a port-in-use failure (another instance already running)
50
- echo "uvicorn exited with code $EXIT_CODE, exiting gracefully."
51
- exit 0
52
- fi
53
- """
54
-
55
-
56
- def _write(path: str, content: str, executable: bool = False) -> bool:
57
- if not os.path.exists(path):
58
- return False
59
- with open(path, "w", encoding="utf-8") as f:
60
- f.write(content)
61
- if executable:
62
- os.chmod(path, 0o755)
63
- return True
64
-
65
-
66
- changed = False
67
-
68
- written = _write("/source/frontend/src/App.tsx", FRONTEND_APP)
69
- if written:
70
- print("OK: restored /source/frontend/src/App.tsx (full AppLayout app)")
71
- changed = True
72
- else:
73
- print("SKIP: /source/frontend/src/App.tsx not present (backend stage?)")
74
-
75
- written = _write("/app/backend/start.sh", BACKEND_START, executable=True)
76
- if written:
77
- print("OK: restored /app/backend/start.sh (uvicorn main:app)")
78
- changed = True
79
- else:
80
- print("SKIP: /app/backend/start.sh not present (frontend stage?)")
81
-
82
- if not changed:
83
- raise SystemExit("FAIL: neither target file found - unexpected layout")
84
- print("OK: full-app restore done")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_space_bunny.py DELETED
@@ -1,56 +0,0 @@
1
- """Patch frontend: add stealth/space-bunny-alpha beside Laguna S 2.1.
2
-
3
- The model uses the same OpenRouter-style `openai/` model path convention as
4
- Laguna S 2.1 (`openai/poolside/laguna-s-2.1:free`).
5
- """
6
-
7
- import os
8
-
9
- FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
10
-
11
-
12
- def patch():
13
- if not os.path.exists(FILE):
14
- print(f"SKIP: {FILE} not found")
15
- return
16
-
17
- with open(FILE, "r", encoding="utf-8") as f:
18
- content = f.read()
19
-
20
- if "space-bunny-alpha" in content:
21
- print("OK: stealth/space-bunny-alpha already present")
22
- return
23
-
24
- anchor = (
25
- " {\n"
26
- " id: 'laguna-s21',\n"
27
- " name: 'Laguna S 2.1:free',\n"
28
- " modelPath: 'openai/poolside/laguna-s-2.1:free',\n"
29
- " avatarUrl: 'https://huggingface.co/api/avatars/poolside',\n"
30
- " recommended: true,\n"
31
- " },\n"
32
- )
33
- new_entry = (
34
- " {\n"
35
- " id: 'space-bunny-alpha',\n"
36
- " name: 'stealth/space-bunny-alpha',\n"
37
- " modelPath: 'openai/stealth/space-bunny-alpha',\n"
38
- " avatarUrl: 'https://huggingface.co/api/avatars/stealth',\n"
39
- " recommended: true,\n"
40
- " },\n"
41
- )
42
-
43
- if anchor in content:
44
- content = content.replace(anchor, anchor + new_entry, 1)
45
- print("OK: added stealth/space-bunny-alpha after Laguna S 2.1")
46
- else:
47
- print("FAIL: could not find Laguna S 2.1 model entry")
48
- return
49
-
50
- with open(FILE, "w", encoding="utf-8") as f:
51
- f.write(content)
52
- print("OK: ChatInput.tsx patched")
53
-
54
-
55
- if __name__ == "__main__":
56
- patch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_xing_nemotron_vl.py DELETED
@@ -1,93 +0,0 @@
1
- """Patch backend: add 2 new OpenRouter models (openai/ prefix routes to OpenRouter).
2
-
3
- New models (same `openai/` path convention as Laguna S 2.1:free):
4
- - openai/xingchen-agi/xing4.0-29b-a4b:free
5
- - openai/nvidia/nemotron-3.5-super-vl:free
6
-
7
- The `openai/` prefix is already stripped in _resolve_llm_params and routed to
8
- OpenRouter, so we only need to register the model IDs, imports, and the
9
- available-models list.
10
-
11
- Runs AFTER patch_models.py / patch_new_models.py / patch_apodex_ling_beatapi.py.
12
- Idempotent.
13
- """
14
-
15
- import ast
16
-
17
- IDS_FILE = "/app/agent/core/model_ids.py"
18
- AGENT_FILE = "/app/backend/routes/agent.py"
19
-
20
- # ---------- 1. model_ids.py ----------
21
- with open(IDS_FILE) as f:
22
- c = f.read()
23
-
24
- new_constants = (
25
- 'XINGCHEN_XING4_0_29B_FREE_MODEL_ID = "openai/xingchen-agi/xing4.0-29b-a4b:free"\n'
26
- 'NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-super-vl:free"\n'
27
- )
28
-
29
- if "XINGCHEN_XING4_0_29B_FREE_MODEL_ID" not in c:
30
- # Insert before DEEPSEEK_V4_PRO_MODEL_ID definition (same anchor as
31
- # patch_new_models.py uses).
32
- target = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
33
- if target in c:
34
- c = c.replace(target, new_constants + target)
35
- print("OK: added 2 new model constants to model_ids.py")
36
- else:
37
- # Fallback anchor: the LING_3_0_FLASH line injected by patch_models.py.
38
- anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
39
- if anchor in c:
40
- c = c.replace(anchor, anchor + "\n" + new_constants)
41
- print("OK: added constants after LING block")
42
- else:
43
- print("FAIL: could not find insertion anchor in model_ids.py")
44
- else:
45
- print("OK: constants already present")
46
-
47
- with open(IDS_FILE, "w") as f:
48
- f.write(c)
49
- ast.parse(c)
50
- print("OK: model_ids.py syntax OK")
51
-
52
- # ---------- 2. agent.py : imports + _available_models ----------
53
- with open(AGENT_FILE) as f:
54
- ac = f.read()
55
-
56
- # Add imports after LING_3_0_FLASH_FREE_MODEL_ID,
57
- if "XINGCHEN_XING4_0_29B_FREE_MODEL_ID" not in ac:
58
- old_imp = " NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n"
59
- new_imp = (
60
- " NEX_N2_MINI_MODEL_ID,\n"
61
- " LING_3_0_FLASH_FREE_MODEL_ID,\n"
62
- " XINGCHEN_XING4_0_29B_FREE_MODEL_ID,\n"
63
- " NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID,\n"
64
- )
65
- if old_imp in ac:
66
- ac = ac.replace(old_imp, new_imp)
67
- print("OK: added new model imports")
68
- else:
69
- print("WARN: import anchor not matched")
70
-
71
- # Add to _available_models list - insert after the LING_3_0_FLASH entry
72
- old_line = (
73
- ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, '
74
- '"label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n'
75
- )
76
- new_line = old_line + (
77
- ' {"id": XINGCHEN_XING4_0_29B_FREE_MODEL_ID, '
78
- '"label": "xingchen-agi/xing4.0-29b-a4b:free", "recommended": True},\n'
79
- ' {"id": NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID, '
80
- '"label": "nvidia/nemotron-3.5-super-vl:free", "recommended": True},\n'
81
- )
82
- if old_line in ac:
83
- ac = ac.replace(old_line, new_line)
84
- print("OK: added 2 new models to _available_models")
85
- else:
86
- print("WARN: _available_models LING line not matched (check exact text)")
87
-
88
- with open(AGENT_FILE, "w") as f:
89
- f.write(ac)
90
- ast.parse(ac)
91
- print("OK: agent.py syntax OK")
92
-
93
- print("DONE: 2 new OpenRouter models added (backend)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/build_kpis.py DELETED
@@ -1,917 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Hourly KPI rollup for the session-trajectory dataset.
3
-
4
- ================================================================================
5
- Data flow
6
- ================================================================================
7
-
8
- ┌────────────────────┐ heartbeat ┌────────────────────────────────┐
9
- │ agent (CLI/web) │ ───────────────▶ │ hf-agent-sessions (dataset) │
10
- │ Session.send_event│ │ sessions/YYYY-MM-DD/<id>.jsonl│
11
- └────────────────────┘ └───────────────┬────────────────┘
12
- │ cron @:05 each hour
13
- ▼
14
- ┌──────────────────────────────────┐
15
- │ scripts/build_kpis.py │
16
- │ (GitHub Actions) │
17
- └───────────────┬──────────────────┘
18
- │ upload CSV
19
- ▼
20
- ┌──────────────────────────────────┐
21
- │ hf-agent-kpis (dataset) │
22
- │ hourly/YYYY-MM-DD/HH.csv │
23
- └──────────────────────────────────┘
24
-
25
- Each hourly run reads today's + yesterday's session folders (to cover sessions
26
- that crossed midnight), filters events into the target hour window
27
- ``[hour, hour+1h)``, computes aggregates, and writes one CSV at
28
- ``hourly/<date>/<HH>.csv`` in the target dataset. Uploads are idempotent —
29
- re-running the same hour overwrites.
30
-
31
- ================================================================================
32
- Metrics (one row per hour)
33
- ================================================================================
34
-
35
- sessions — distinct session_ids with ≥1 event in window
36
- users — distinct user ids (when present on session rows)
37
- turns — sum of user-message counts across active sessions
38
- llm_calls — count of llm_call events
39
- tokens_prompt / _completion / _cache_read / _cache_creation
40
- cost_usd — sum of llm_call.cost_usd
41
- cost_per_session_mean / _p50 / _p95 — per-session cost distribution
42
- cache_hit_ratio — cache_read / (cache_read + prompt)
43
- tool_calls_total / _succeeded / _failed — per-tool_output reliability counts
44
- tool_success_rate — succeeded / total (kept for back-compat)
45
- successful_sessions / errored_sessions / regenerated_sessions — outcome counts
46
- failure_rate / regenerate_rate — kept for back-compat
47
- time_to_first_action_s_p50 / _p95 — from session_start to first tool_call
48
- thumbs_up / thumbs_down
49
- hf_jobs_submitted / _succeeded / _blocked
50
- sandboxes_created / _cpu / _gpu — sandbox_create events bucketed by hardware
51
- pro_cta_clicks
52
- gpu_hours_by_flavor_json — JSON-serialised {flavor: gpu-hours}
53
- research_calls — total `research` tool_call events
54
- sessions_with_research — sessions that called `research` ≥1
55
- research_calls_per_session_p50 / _p95 — among sessions that did any (zero-only sessions excluded)
56
- distinct_tools_per_session_p50 / _p95 — among sessions with ≥1 named tool_call
57
- tool_calls_per_session_p50 / _p95 — among sessions with ≥1 named tool_call
58
- tool_calls_per_turn_p50 / _p95 — calls / turns, among sessions with turns>0
59
- tool_calls_by_name_json — JSON {tool: total_calls} (all tools seen)
60
- sessions_using_tool_json — JSON {tool: distinct_sessions_using}
61
- sessions_by_model_json — JSON {model_name: count} (router/local split)
62
-
63
- ================================================================================
64
- Usage
65
- ================================================================================
66
-
67
- # Run for the most recently completed hour (default — the cron path):
68
- python scripts/build_kpis.py
69
-
70
- # Backfill last 24 hours:
71
- python scripts/build_kpis.py --hours 24
72
-
73
- # Explicit hour (UTC):
74
- python scripts/build_kpis.py --datetime 2026-04-24T14
75
-
76
- Env:
77
- HF_TOKEN (or HF_KPI_WRITE_TOKEN) — write access to the target dataset.
78
-
79
- ================================================================================
80
- Deploy
81
- ================================================================================
82
-
83
- See ``.github/workflows/build-kpis.yml`` — runs every hour at :05. To provision:
84
-
85
- 1. Create the target dataset (once):
86
- huggingface-cli repo create hf-agent-kpis --type dataset
87
- 2. Put ``HF_KPI_WRITE_TOKEN`` (or ``HF_TOKEN``) into repo Actions secrets.
88
- 3. Merge this file; the first scheduled run fires within the hour.
89
- """
90
-
91
- from __future__ import annotations
92
-
93
- import argparse
94
- import io
95
- import json
96
- import logging
97
- import os
98
- import sys
99
- import tempfile
100
- from collections import defaultdict
101
- from datetime import date, datetime, timedelta, timezone
102
- from typing import Any, Iterable
103
-
104
- logger = logging.getLogger("build_kpis")
105
-
106
- # Rough gpu-hour pricing for hf_jobs flavor strings. Keep conservative; used
107
- # only to compute gpu-hours (not dollars) — wall_time_s * flavor_gpu_count.
108
- _FLAVOR_GPU_COUNT = {
109
- "cpu-basic": 0,
110
- "cpu-upgrade": 0,
111
- "t4-small": 1,
112
- "t4-medium": 1,
113
- "l4x1": 1,
114
- "l4x4": 4,
115
- "l40sx1": 1,
116
- "l40sx4": 4,
117
- "l40sx8": 8,
118
- "a10g-small": 1,
119
- "a10g-large": 1,
120
- "a10g-largex2": 2,
121
- "a10g-largex4": 4,
122
- "a100-large": 1,
123
- "a100x2": 2,
124
- "a100x4": 4,
125
- "a100x8": 8,
126
- "h100": 1,
127
- "h100x8": 8,
128
- }
129
-
130
-
131
- def _percentile(values: list[float], p: float) -> float:
132
- if not values:
133
- return 0.0
134
- values = sorted(values)
135
- k = (len(values) - 1) * p
136
- f = int(k)
137
- c = min(f + 1, len(values) - 1)
138
- if f == c:
139
- return float(values[f])
140
- return float(values[f] + (values[c] - values[f]) * (k - f))
141
-
142
-
143
- def _parse_ts(s: Any) -> datetime | None:
144
- if not s or not isinstance(s, str):
145
- return None
146
- try:
147
- dt = datetime.fromisoformat(s)
148
- except Exception:
149
- return None
150
- # Normalise to aware UTC so comparisons work against window bounds.
151
- if dt.tzinfo is None:
152
- dt = dt.replace(tzinfo=timezone.utc)
153
- return dt
154
-
155
-
156
- def _iter_session_files(api, repo_id: str, day: date, token: str) -> Iterable[str]:
157
- """Yield repo-relative paths for all sessions under ``sessions/YYYY-MM-DD/``."""
158
- prefix = f"sessions/{day.isoformat()}/"
159
- try:
160
- files = api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token)
161
- except Exception as e:
162
- logger.warning("list_repo_files(%s) failed: %s", repo_id, e)
163
- return []
164
- return [f for f in files if f.startswith(prefix) and f.endswith(".jsonl")]
165
-
166
-
167
- def _is_row_format(row: dict) -> bool:
168
- """True if this row is the single-row 'row' format (has session_id)."""
169
- return isinstance(row, dict) and "session_id" in row
170
-
171
-
172
- def _is_cc_event(row: dict) -> bool:
173
- """True if this row is a Claude-Code-format event line."""
174
- return (
175
- isinstance(row, dict)
176
- and "type" in row
177
- and "message" in row
178
- and "uuid" in row
179
- )
180
-
181
-
182
- def _cc_event_to_internal(ev: dict) -> dict:
183
- """Convert one Claude-Code-format event into the internal session shape.
184
-
185
- The session_uploader writes one event per line (see
186
- agent/core/session_uploader.py::to_claude_code_jsonl). build_kpis needs
187
- ``messages`` and ``events`` — reconstruct them from the CC stream.
188
- """
189
- etype = ev.get("type")
190
- msg = ev.get("message") or {}
191
- role = msg.get("role")
192
- content = msg.get("content")
193
- ts = ev.get("timestamp")
194
-
195
- if etype == "user" and role == "user":
196
- blocks = content if isinstance(content, list) else None
197
- tool_results = []
198
- text_parts = []
199
- if blocks:
200
- for b in blocks:
201
- if not isinstance(b, dict):
202
- text_parts.append(str(b))
203
- continue
204
- if b.get("type") == "tool_result":
205
- tool_results.append(
206
- {
207
- "tool_call_id": b.get("tool_use_id") or "",
208
- "content": b.get("content"),
209
- "timestamp": ts,
210
- }
211
- )
212
- elif "text" in b:
213
- text_parts.append(b.get("text") or "")
214
- else:
215
- text_parts.append(json.dumps(b, default=str))
216
- if tool_results:
217
- return {"messages": [], "events": tool_results}
218
- return {"messages": [{"role": "user", "content": "\n".join(text_parts), "timestamp": ts}], "events": []}
219
-
220
- if etype == "assistant" and role == "assistant":
221
- text_parts = []
222
- tool_calls = []
223
- blocks = content if isinstance(content, list) else None
224
- if blocks:
225
- for b in blocks:
226
- if not isinstance(b, dict):
227
- text_parts.append(str(b))
228
- continue
229
- if b.get("type") == "tool_use":
230
- tool_calls.append(
231
- {
232
- "id": b.get("id") or "",
233
- "type": "function",
234
- "function": {
235
- "name": b.get("name") or "",
236
- "arguments": json.dumps(b.get("input") or {}, default=str),
237
- },
238
- }
239
- )
240
- elif "text" in b:
241
- text_parts.append(b.get("text") or "")
242
- return {
243
- "messages": [
244
- {
245
- "role": "assistant",
246
- "content": "\n".join(text_parts),
247
- "tool_calls": tool_calls,
248
- "timestamp": ts,
249
- }
250
- ],
251
- "events": [],
252
- }
253
-
254
- return {"messages": [], "events": []}
255
-
256
-
257
- def _download_session(repo_id: str, path: str, token: str) -> dict | None:
258
- """Fetch one session file and decode it into the internal session shape.
259
-
260
- Supports two on-disk formats:
261
-
262
- * ``row`` — single-line JSONL row used by the org telemetry/KPI pipeline
263
- (``session_id`` + ``messages``/``events`` as JSON strings).
264
- * ``claude_code`` — one event per line in the Claude Code JSONL schema,
265
- auto-detected by the HF Agent Trace Viewer
266
- (https://huggingface.co/changelog/agent-trace-viewer). Each line is
267
- converted into the internal ``messages``/``events`` shape.
268
-
269
- ``hf_hub_download`` caches; second run within the same process / runner
270
- directory is near-free.
271
- """
272
- from huggingface_hub import hf_hub_download
273
-
274
- try:
275
- local = hf_hub_download(
276
- repo_id=repo_id,
277
- filename=path,
278
- repo_type="dataset",
279
- token=token,
280
- )
281
- except Exception as e:
282
- logger.warning("hf_hub_download(%s) failed: %s", path, e)
283
- return None
284
- try:
285
- with open(local, "r") as f:
286
- raw = f.read()
287
-
288
- if not raw.strip():
289
- return None
290
-
291
- # Peek at the first non-empty line to decide the format.
292
- first_line = next(
293
- (ln.strip() for ln in raw.splitlines() if ln.strip()), ""
294
- )
295
- if not first_line:
296
- return None
297
- try:
298
- first = json.loads(first_line)
299
- except Exception as e:
300
- logger.warning("parse(%s) failed: %s", path, e)
301
- return None
302
-
303
- if _is_row_format(first):
304
- row = first
305
- for key in ("messages", "events", "tools"):
306
- v = row.get(key)
307
- if isinstance(v, str):
308
- try:
309
- row[key] = json.loads(v)
310
- except Exception:
311
- row[key] = []
312
- return row
313
-
314
- if not _is_cc_event(first):
315
- logger.warning("parse(%s): unrecognised format, skipping", path)
316
- return None
317
-
318
- # Claude-Code format: one event per line. Reconstruct the internal
319
- # session shape by walking every line.
320
- session_id = first.get("sessionId") or ""
321
- session_start = first.get("timestamp") or ""
322
- messages: list[dict] = []
323
- events: list[dict] = []
324
- for line in raw.splitlines():
325
- line = line.strip()
326
- if not line:
327
- continue
328
- try:
329
- ev = json.loads(line)
330
- except Exception:
331
- continue
332
- if not _is_cc_event(ev):
333
- continue
334
- converted = _cc_event_to_internal(ev)
335
- messages.extend(converted.get("messages") or [])
336
- events.extend(converted.get("events") or [])
337
-
338
- return {
339
- "session_id": session_id,
340
- "session_start_time": session_start,
341
- "session_end_time": session_start,
342
- "model_name": "",
343
- "total_cost_usd": None,
344
- "messages": messages,
345
- "events": events,
346
- "tools": [],
347
- }
348
- except Exception as e:
349
- logger.warning("parse(%s) failed: %s", path, e)
350
- return None
351
-
352
-
353
- def _filter_session_to_window(
354
- session: dict,
355
- start: datetime,
356
- end: datetime,
357
- ) -> dict | None:
358
- """Return a copy of ``session`` whose events are only those in ``[start, end)``.
359
-
360
- ``None`` if no event falls in the window — the caller drops the session
361
- from this hour's aggregate.
362
- """
363
- events = session.get("events") or []
364
- in_window = []
365
- for ev in events:
366
- ts = _parse_ts(ev.get("timestamp"))
367
- if ts is None:
368
- continue
369
- if start <= ts < end:
370
- in_window.append(ev)
371
- if not in_window:
372
- return None
373
- return {**session, "events": in_window}
374
-
375
-
376
- def _session_metrics(session: dict) -> dict:
377
- """Reduce a single session trajectory to its KPI contributions.
378
-
379
- Assumes ``events`` are already filtered to the target window by the caller.
380
- """
381
- # Pre-seed every numeric key so downstream aggregation can sum without
382
- # having to special-case empty sessions.
383
- out: dict = {
384
- "sessions": 0,
385
- "turns": 0,
386
- "llm_calls": 0,
387
- "tokens_prompt": 0,
388
- "tokens_completion": 0,
389
- "tokens_cache_read": 0,
390
- "tokens_cache_creation": 0,
391
- "cost_usd": 0.0,
392
- "tool_calls_total": 0,
393
- "tool_calls_success": 0,
394
- "failures": 0,
395
- "regenerate_sessions": 0,
396
- "thumbs_up": 0,
397
- "thumbs_down": 0,
398
- "hf_jobs_submitted": 0,
399
- "hf_jobs_succeeded": 0,
400
- "hf_jobs_blocked": 0,
401
- "pro_cta_clicks": 0,
402
- "pro_conversions": 0,
403
- "credits_topped_up": 0,
404
- "sandboxes_created": 0,
405
- "sandboxes_cpu": 0,
406
- "sandboxes_gpu": 0,
407
- "first_tool_s": -1,
408
- }
409
- events = session.get("events") or []
410
- messages = session.get("messages") or []
411
-
412
- turn_count = sum(1 for m in messages if m.get("role") == "user")
413
- out["turns"] = turn_count
414
- out["sessions"] = 1
415
-
416
- tool_success = 0
417
- tool_total = 0
418
- had_error = False
419
- had_undo = False
420
- first_tool_ts = None
421
- session_start = session.get("session_start_time")
422
- gpu_hours_by_flavor: dict[str, float] = defaultdict(float)
423
- jobs_submitted = 0
424
- jobs_succeeded = 0
425
- thumbs_up = 0
426
- thumbs_down = 0
427
- sandboxes_created = 0
428
- sandboxes_cpu = 0
429
- sandboxes_gpu = 0
430
- jobs_blocked = 0
431
- pro_cta_clicks = 0
432
- pro_conversions = 0
433
- credits_topped_up = 0
434
- pro_cta_by_source: dict[str, int] = defaultdict(int)
435
- # Per-tool counters from tool_call events. Counted off tool_call (which
436
- # carries data["tool"]) rather than tool_output (which only carries
437
- # success/output) so we can attribute calls to specific tools.
438
- tool_calls_by_name: dict[str, int] = defaultdict(int)
439
- total_named_tool_calls = 0
440
-
441
- start_dt = _parse_ts(session_start)
442
-
443
- for ev in events:
444
- et = ev.get("event_type")
445
- data = ev.get("data") or {}
446
- ts = _parse_ts(ev.get("timestamp"))
447
-
448
- if et == "llm_call":
449
- out["llm_calls"] += 1
450
- out["tokens_prompt"] += int(data.get("prompt_tokens") or 0)
451
- out["tokens_completion"] += int(data.get("completion_tokens") or 0)
452
- out["tokens_cache_read"] += int(data.get("cache_read_tokens") or 0)
453
- out["tokens_cache_creation"] += int(data.get("cache_creation_tokens") or 0)
454
- out["cost_usd"] += float(data.get("cost_usd") or 0.0)
455
-
456
- elif et == "tool_output":
457
- tool_total += 1
458
- if data.get("success"):
459
- tool_success += 1
460
- if first_tool_ts is None and ts is not None and start_dt is not None:
461
- first_tool_ts = (ts - start_dt).total_seconds()
462
-
463
- elif et == "tool_call":
464
- name = data.get("tool")
465
- if name:
466
- tool_calls_by_name[name] += 1
467
- total_named_tool_calls += 1
468
- if first_tool_ts is None and ts is not None and start_dt is not None:
469
- first_tool_ts = (ts - start_dt).total_seconds()
470
-
471
- elif et == "error":
472
- had_error = True
473
-
474
- elif et == "undo_complete":
475
- had_undo = True
476
-
477
- elif et == "feedback":
478
- rating = data.get("rating")
479
- if rating == "up":
480
- thumbs_up += 1
481
- elif rating == "down":
482
- thumbs_down += 1
483
-
484
- elif et == "hf_job_submit":
485
- jobs_submitted += 1
486
-
487
- elif et == "hf_job_complete":
488
- flavor = data.get("flavor") or "unknown"
489
- status = (data.get("final_status") or "").lower()
490
- wall = float(data.get("wall_time_s") or 0.0)
491
- gpus = _FLAVOR_GPU_COUNT.get(flavor, 0)
492
- gpu_hours_by_flavor[flavor] += wall * gpus / 3600.0
493
- if status in ("completed", "succeeded", "success"):
494
- jobs_succeeded += 1
495
-
496
- elif et == "jobs_access_blocked":
497
- jobs_blocked += 1
498
-
499
- elif et == "pro_cta_click":
500
- pro_cta_clicks += 1
501
- source = str(data.get("source") or "unknown")
502
- pro_cta_by_source[source] += 1
503
-
504
- elif et == "pro_conversion":
505
- pro_conversions += 1
506
-
507
- elif et == "credits_topped_up":
508
- credits_topped_up += 1
509
-
510
- elif et == "sandbox_create":
511
- sandboxes_created += 1
512
- hardware = (data.get("hardware") or "").lower()
513
- # CPU flavors are explicitly named "cpu-*". Everything else
514
- # (including unknown/missing hardware strings) lands in the GPU
515
- # bucket, since the auto-create default is "cpu-basic" which is
516
- # matched here — anything that isn't is almost always an explicit
517
- # GPU choice.
518
- if hardware.startswith("cpu-"):
519
- sandboxes_cpu += 1
520
- else:
521
- sandboxes_gpu += 1
522
-
523
- out["tool_calls_total"] = tool_total
524
- out["tool_calls_success"] = tool_success
525
- out["failures"] = 1 if had_error else 0
526
- out["regenerate_sessions"] = 1 if had_undo else 0
527
- out["thumbs_up"] = thumbs_up
528
- out["thumbs_down"] = thumbs_down
529
- out["hf_jobs_submitted"] = jobs_submitted
530
- out["hf_jobs_succeeded"] = jobs_succeeded
531
- out["sandboxes_created"] = sandboxes_created
532
- out["sandboxes_cpu"] = sandboxes_cpu
533
- out["sandboxes_gpu"] = sandboxes_gpu
534
- out["hf_jobs_blocked"] = jobs_blocked
535
- out["pro_cta_clicks"] = pro_cta_clicks
536
- out["pro_conversions"] = pro_conversions
537
- out["credits_topped_up"] = credits_topped_up
538
- out["first_tool_s"] = first_tool_ts if first_tool_ts is not None else -1
539
- out["_gpu_hours_by_flavor"] = dict(gpu_hours_by_flavor)
540
- out["_pro_cta_by_source"] = dict(pro_cta_by_source)
541
- out["_user"] = session.get("user_id") or session.get("session_id")
542
- # Intra-session tool fields. Underscore-prefixed = consumed by _aggregate
543
- # only, never written to CSV directly.
544
- out["_tool_calls_by_name"] = dict(tool_calls_by_name)
545
- out["_research_calls"] = tool_calls_by_name.get("research", 0)
546
- out["_distinct_tools_used"] = len(tool_calls_by_name)
547
- out["_total_named_tool_calls"] = total_named_tool_calls
548
- out["_model_name"] = session.get("model_name") or "unknown"
549
- return dict(out)
550
-
551
-
552
- def _aggregate(per_session: list[dict]) -> dict:
553
- """Collapse a bucket's worth of session rollups into the final KPI row."""
554
- ttfa_values = [
555
- s["first_tool_s"] for s in per_session if s.get("first_tool_s", -1) >= 0
556
- ]
557
- gpu_hours: dict[str, float] = defaultdict(float)
558
- for s in per_session:
559
- for f, h in (s.get("_gpu_hours_by_flavor") or {}).items():
560
- gpu_hours[f] += h
561
-
562
- # Per-tool aggregates. ``sessions_using_tool`` counts each session at most
563
- # once per tool, so the dashboard can show "how many sessions reached for
564
- # research" alongside "how many research calls overall".
565
- tool_calls_by_name: dict[str, int] = defaultdict(int)
566
- sessions_using_tool: dict[str, int] = defaultdict(int)
567
- sessions_by_model: dict[str, int] = defaultdict(int)
568
- for s in per_session:
569
- for name, count in (s.get("_tool_calls_by_name") or {}).items():
570
- tool_calls_by_name[name] += int(count)
571
- sessions_using_tool[name] += 1
572
- sessions_by_model[s.get("_model_name") or "unknown"] += 1
573
-
574
- # Percentile inputs. All "per session" percentiles exclude sessions that
575
- # never reached for the relevant signal — otherwise quiet hours
576
- # (status-check sessions, abandoned new conversations) drag every median
577
- # to 0 and the chart tells you nothing.
578
- research_calls_nz = [
579
- s.get("_research_calls", 0)
580
- for s in per_session
581
- if s.get("_research_calls", 0) > 0
582
- ]
583
- distinct_tools_values = [
584
- s.get("_distinct_tools_used", 0)
585
- for s in per_session
586
- if s.get("_distinct_tools_used", 0) > 0
587
- ]
588
- total_calls_values = [
589
- s.get("_total_named_tool_calls", 0)
590
- for s in per_session
591
- if s.get("_total_named_tool_calls", 0) > 0
592
- ]
593
- # Per-turn intensity: turns>0 is the natural filter here (a session with
594
- # 5 turns and 0 tools is a meaningful 0). Don't strip those.
595
- calls_per_turn_values = [
596
- s.get("_total_named_tool_calls", 0) / s["turns"]
597
- for s in per_session
598
- if s.get("turns", 0) > 0
599
- ]
600
-
601
- total_sessions = sum(s["sessions"] for s in per_session)
602
- total_turns = sum(s["turns"] for s in per_session)
603
- tokens_prompt = sum(s["tokens_prompt"] for s in per_session)
604
- tokens_cache_read = sum(s["tokens_cache_read"] for s in per_session)
605
- tool_total = sum(s["tool_calls_total"] for s in per_session)
606
- tool_success = sum(s["tool_calls_success"] for s in per_session)
607
- failures = int(sum(s["failures"] for s in per_session))
608
- regenerates = int(sum(s["regenerate_sessions"] for s in per_session))
609
- research_calls_total = int(sum(s.get("_research_calls", 0) for s in per_session))
610
- sessions_with_research = sum(
611
- 1 for s in per_session if s.get("_research_calls", 0) > 0
612
- )
613
-
614
- # Per-session cost percentiles — chart "median session cost" alongside the
615
- # mean so a few $700 outliers don't make you think every session is pricey.
616
- session_costs = [float(s.get("cost_usd") or 0.0) for s in per_session]
617
- cost_p50 = _percentile(session_costs, 0.5)
618
- cost_p95 = _percentile(session_costs, 0.95)
619
-
620
- unique_users = {s.get("_user") for s in per_session if s.get("_user")}
621
-
622
- return {
623
- "sessions": total_sessions,
624
- "users": len(unique_users),
625
- "turns": total_turns,
626
- "llm_calls": int(sum(s["llm_calls"] for s in per_session)),
627
- "tokens_prompt": int(tokens_prompt),
628
- "tokens_completion": int(sum(s["tokens_completion"] for s in per_session)),
629
- "tokens_cache_read": int(tokens_cache_read),
630
- "tokens_cache_creation": int(
631
- sum(s["tokens_cache_creation"] for s in per_session)
632
- ),
633
- "cost_usd": round(sum(s["cost_usd"] for s in per_session), 4),
634
- # Per-session cost summaries.
635
- "cost_per_session_mean": round(
636
- sum(s["cost_usd"] for s in per_session) / total_sessions, 6
637
- )
638
- if total_sessions > 0
639
- else 0.0,
640
- "cost_per_session_p50": round(cost_p50, 6),
641
- "cost_per_session_p95": round(cost_p95, 6),
642
- "cache_hit_ratio": round(
643
- tokens_cache_read / (tokens_cache_read + tokens_prompt), 4
644
- )
645
- if (tokens_cache_read + tokens_prompt) > 0
646
- else 0.0,
647
- # Raw reliability COUNTS (these are what the dashboard shows directly).
648
- "tool_calls_total": int(tool_total),
649
- "tool_calls_succeeded": int(tool_success),
650
- "tool_calls_failed": int(tool_total - tool_success),
651
- "errored_sessions": failures,
652
- # Successful = "did not raise an error event". Mutually exclusive
653
- # with errored_sessions; sums with errored_sessions to total sessions.
654
- "successful_sessions": int(total_sessions - failures),
655
- # Regenerated is an orthogonal dimension (the user retried) — a
656
- # session can be both successful and regenerated, or both errored
657
- # and regenerated.
658
- "regenerated_sessions": regenerates,
659
- # Rates kept for backwards compatibility with anything reading the
660
- # KPI dataset directly.
661
- "tool_success_rate": round(tool_success / tool_total, 4)
662
- if tool_total > 0
663
- else 0.0,
664
- "failure_rate": round(failures / total_sessions, 4)
665
- if total_sessions > 0
666
- else 0.0,
667
- "regenerate_rate": round(regenerates / total_sessions, 4)
668
- if total_sessions > 0
669
- else 0.0,
670
- "time_to_first_action_s_p50": round(_percentile(ttfa_values, 0.5), 2),
671
- "time_to_first_action_s_p95": round(_percentile(ttfa_values, 0.95), 2),
672
- "thumbs_up": int(sum(s["thumbs_up"] for s in per_session)),
673
- "thumbs_down": int(sum(s["thumbs_down"] for s in per_session)),
674
- "hf_jobs_submitted": int(sum(s["hf_jobs_submitted"] for s in per_session)),
675
- "hf_jobs_succeeded": int(sum(s["hf_jobs_succeeded"] for s in per_session)),
676
- "sandboxes_created": int(
677
- sum(s.get("sandboxes_created", 0) for s in per_session)
678
- ),
679
- "sandboxes_cpu": int(sum(s.get("sandboxes_cpu", 0) for s in per_session)),
680
- "sandboxes_gpu": int(sum(s.get("sandboxes_gpu", 0) for s in per_session)),
681
- "hf_jobs_blocked": int(sum(s.get("hf_jobs_blocked", 0) for s in per_session)),
682
- "pro_cta_clicks": int(sum(s.get("pro_cta_clicks", 0) for s in per_session)),
683
- "pro_conversions": int(sum(s.get("pro_conversions", 0) for s in per_session)),
684
- "credits_topped_up": int(
685
- sum(s.get("credits_topped_up", 0) for s in per_session)
686
- ),
687
- "gpu_hours_by_flavor_json": json.dumps(dict(gpu_hours), sort_keys=True),
688
- # Research KPIs — answer "is the agent reaching for research?".
689
- "research_calls": research_calls_total,
690
- "sessions_with_research": int(sessions_with_research),
691
- "research_calls_per_session_p50": round(_percentile(research_calls_nz, 0.5), 2),
692
- "research_calls_per_session_p95": round(
693
- _percentile(research_calls_nz, 0.95), 2
694
- ),
695
- # Intra-session breadth + intensity. p50 + p95 over per-session values.
696
- "distinct_tools_per_session_p50": round(
697
- _percentile(distinct_tools_values, 0.5), 2
698
- ),
699
- "distinct_tools_per_session_p95": round(
700
- _percentile(distinct_tools_values, 0.95), 2
701
- ),
702
- "tool_calls_per_session_p50": round(_percentile(total_calls_values, 0.5), 2),
703
- "tool_calls_per_session_p95": round(_percentile(total_calls_values, 0.95), 2),
704
- "tool_calls_per_turn_p50": round(_percentile(calls_per_turn_values, 0.5), 2),
705
- "tool_calls_per_turn_p95": round(_percentile(calls_per_turn_values, 0.95), 2),
706
- # JSON columns let the dashboard add/remove tools without schema churn.
707
- "tool_calls_by_name_json": json.dumps(dict(tool_calls_by_name), sort_keys=True),
708
- "sessions_using_tool_json": json.dumps(
709
- dict(sessions_using_tool), sort_keys=True
710
- ),
711
- # Surface split by selected model for dashboard drilldowns.
712
- "sessions_by_model_json": json.dumps(dict(sessions_by_model), sort_keys=True),
713
- }
714
-
715
-
716
- # Back-compat alias: older tests call _aggregate_day.
717
- _aggregate_day = _aggregate
718
-
719
-
720
- def _csv_cell(v: Any) -> str:
721
- s = str(v)
722
- if "," in s or '"' in s or "\n" in s:
723
- return '"' + s.replace('"', '""') + '"'
724
- return s
725
-
726
-
727
- def _write_csv(
728
- api,
729
- row: dict,
730
- bucket_key: str,
731
- path_in_repo: str,
732
- target_repo: str,
733
- token: str,
734
- ) -> None:
735
- """Render ``row`` to CSV with a leading ``bucket`` column and upload.
736
-
737
- ``bucket_key`` is the hour string (ISO ``YYYY-MM-DDTHH``) or date string;
738
- written as the ``bucket`` column so downstream consumers can union all
739
- CSVs without date-parsing paths. ``api`` is the caller's ``HfApi``
740
- instance — reused so we don't spin up a fresh one per CSV.
741
- """
742
- columns = list(row.keys())
743
- buf = io.StringIO()
744
- buf.write(",".join(["bucket", *columns]) + "\n")
745
- buf.write(",".join([bucket_key, *[_csv_cell(row[c]) for c in columns]]) + "\n")
746
-
747
- with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as tmp:
748
- tmp.write(buf.getvalue())
749
- tmp_path = tmp.name
750
-
751
- try:
752
- api.create_repo(
753
- repo_id=target_repo,
754
- repo_type="dataset",
755
- exist_ok=True,
756
- token=token,
757
- )
758
- api.upload_file(
759
- path_or_fileobj=tmp_path,
760
- path_in_repo=path_in_repo,
761
- repo_id=target_repo,
762
- repo_type="dataset",
763
- token=token,
764
- commit_message=f"KPIs for {bucket_key}",
765
- )
766
- finally:
767
- try:
768
- os.unlink(tmp_path)
769
- except Exception:
770
- pass
771
-
772
-
773
- def run_for_hour(
774
- api,
775
- source_repo: str,
776
- target_repo: str,
777
- hour_dt: datetime,
778
- token: str,
779
- ) -> dict:
780
- """Roll up one UTC hour [hour_dt, hour_dt+1h).
781
-
782
- Reads today's + yesterday's session folders so sessions that crossed
783
- midnight land in the right hourly bucket.
784
- """
785
- if hour_dt.tzinfo is None:
786
- hour_dt = hour_dt.replace(tzinfo=timezone.utc)
787
- window_start = hour_dt.replace(minute=0, second=0, microsecond=0)
788
- window_end = window_start + timedelta(hours=1)
789
-
790
- # Sessions partition by session_start_time date. A session that started
791
- # at 23:50 yesterday can still emit events in today's first hours, so we
792
- # look at both folders.
793
- candidate_dates = {window_start.date(), (window_start - timedelta(days=1)).date()}
794
-
795
- per_session: list[dict] = []
796
- for d in sorted(candidate_dates):
797
- for path in _iter_session_files(api, source_repo, d, token):
798
- sess = _download_session(source_repo, path, token)
799
- if not sess:
800
- continue
801
- windowed = _filter_session_to_window(sess, window_start, window_end)
802
- if windowed is None:
803
- continue
804
- per_session.append(_session_metrics(windowed))
805
-
806
- if not per_session:
807
- logger.info("No sessions in window %s — skipping", window_start.isoformat())
808
- return {}
809
-
810
- row = _aggregate(per_session)
811
- bucket_key = window_start.strftime("%Y-%m-%dT%H")
812
- path_in_repo = (
813
- f"hourly/{window_start.strftime('%Y-%m-%d')}/{window_start.strftime('%H')}.csv"
814
- )
815
- _write_csv(api, row, bucket_key, path_in_repo, target_repo, token)
816
- logger.info(
817
- "Wrote KPIs for %s (%d sessions): %s",
818
- bucket_key,
819
- per_session and len(per_session),
820
- row,
821
- )
822
- return row
823
-
824
-
825
- # Back-compat for daily backfills — unchanged behaviour.
826
- def run_for_day(api, source_repo: str, target_repo: str, day: date, token: str) -> dict:
827
- paths = _iter_session_files(api, source_repo, day, token)
828
- per_session: list[dict] = []
829
- for path in paths:
830
- sess = _download_session(source_repo, path, token)
831
- if not sess:
832
- continue
833
- per_session.append(_session_metrics(sess))
834
- if not per_session:
835
- logger.info("No sessions found for %s — skipping", day)
836
- return {}
837
- row = _aggregate(per_session)
838
- path_in_repo = f"daily/{day.isoformat()}.csv"
839
- _write_csv(api, row, day.isoformat(), path_in_repo, target_repo, token)
840
- return row
841
-
842
-
843
- def _parse_hour_arg(s: str) -> datetime:
844
- """Accept ``YYYY-MM-DDTHH`` or full ISO — always pinned to the start of the hour, UTC."""
845
- dt = datetime.fromisoformat(s)
846
- if dt.tzinfo is None:
847
- dt = dt.replace(tzinfo=timezone.utc)
848
- return dt.replace(minute=0, second=0, microsecond=0)
849
-
850
-
851
- def main(argv: list[str] | None = None) -> int:
852
- logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
853
- ap = argparse.ArgumentParser()
854
- ap.add_argument("--source", default="smolagents/ml-intern-sessions")
855
- ap.add_argument("--target", default="smolagents/ml-intern-kpis")
856
- ap.add_argument(
857
- "--hours",
858
- type=int,
859
- default=1,
860
- help="Number of trailing hours to roll up (default: 1 = last completed hour).",
861
- )
862
- ap.add_argument(
863
- "--datetime",
864
- type=str,
865
- default=None,
866
- help="Single hour, ISO ``YYYY-MM-DDTHH`` (UTC); overrides --hours.",
867
- )
868
- ap.add_argument(
869
- "--daily-backfill",
870
- type=str,
871
- default=None,
872
- help="Escape hatch: aggregate a whole day at once (YYYY-MM-DD). "
873
- "Writes to daily/<date>.csv. Use for historical backfill only.",
874
- )
875
- args = ap.parse_args(argv)
876
-
877
- token = (
878
- os.environ.get("HF_KPI_WRITE_TOKEN")
879
- or os.environ.get("HF_SESSION_UPLOAD_TOKEN")
880
- or os.environ.get("HF_TOKEN")
881
- or os.environ.get("HF_ADMIN_TOKEN")
882
- )
883
- if not token:
884
- logger.error(
885
- "No HF token found. Set one of: HF_KPI_WRITE_TOKEN, "
886
- "HF_SESSION_UPLOAD_TOKEN, HF_TOKEN, HF_ADMIN_TOKEN."
887
- )
888
- return 1
889
-
890
- from huggingface_hub import HfApi
891
-
892
- api = HfApi()
893
-
894
- if args.daily_backfill:
895
- run_for_day(
896
- api,
897
- args.source,
898
- args.target,
899
- date.fromisoformat(args.daily_backfill),
900
- token,
901
- )
902
- return 0
903
-
904
- if args.datetime:
905
- target_hours = [_parse_hour_arg(args.datetime)]
906
- else:
907
- now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
908
- # Roll up *completed* hours: start from the hour before ``now``.
909
- target_hours = [now - timedelta(hours=i) for i in range(1, args.hours + 1)]
910
-
911
- for hour in target_hours:
912
- run_for_hour(api, args.source, args.target, hour, token)
913
- return 0
914
-
915
-
916
- if __name__ == "__main__":
917
- sys.exit(main())