diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml index 6def56f769..c1297b84cc 100644 --- a/.github/workflows/studio-inference-smoke.yml +++ b/.github/workflows/studio-inference-smoke.yml @@ -296,6 +296,8 @@ jobs: - name: Upload logs # Always upload so green runs are still reviewable. if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: openai-anthropic-log @@ -771,6 +773,8 @@ jobs: - name: Upload logs # Always upload so green runs are still reviewable. if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: tool-calling-log @@ -1043,6 +1047,8 @@ jobs: - name: Upload logs # Always upload so green runs are still reviewable. if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: json-images-log diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml index 1096b1abb4..e08ac6ca68 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -289,6 +289,8 @@ jobs: - name: Upload logs # Always upload so green runs are still reviewable. if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: openai-anthropic-log @@ -649,6 +651,8 @@ jobs: - name: Upload logs # Always upload so green runs are still reviewable. if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: tool-calling-log @@ -1025,6 +1029,8 @@ jobs: - name: Upload logs # Always upload so green runs are still reviewable. if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: json-images-log diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index 6253b9d213..bbe6b9e33a 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -65,6 +65,18 @@ jobs: with: persist-credentials: false + # Fast GPU-free gate: parse setup.ps1 and run the Resolve-CudaToolkit unit + # test (deferred Windows CUDA Toolkit check) before the heavy GGUF smoke. + - name: setup.ps1 unit test (Resolve-CudaToolkit) + shell: pwsh + run: | + $errs = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path studio/setup.ps1).Path, [ref]$null, [ref]$errs) + if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 } + Write-Host "setup.ps1 parsed with no errors" + pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' @@ -360,6 +372,9 @@ jobs: - name: Collect llama-server logs if: always() + # A transient Windows DLL-init crash (0xC0000142) in this diagnostic + # copy must not fail an otherwise-green job. + continue-on-error: true shell: bash # Copy llama-server's own stdout/stderr (teed by Studio under # ~/.unsloth/studio/logs/llama-server/) into the workspace so @@ -373,6 +388,8 @@ jobs: - name: Upload logs if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-openai-anthropic-log @@ -788,6 +805,9 @@ jobs: - name: Collect llama-server logs if: always() + # A transient Windows DLL-init crash (0xC0000142) in this diagnostic + # copy must not fail an otherwise-green job. + continue-on-error: true shell: bash # Copy llama-server's own stdout/stderr (teed by Studio under # ~/.unsloth/studio/logs/llama-server/) into the workspace so @@ -801,6 +821,8 @@ jobs: - name: Upload logs if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-tool-calling-log @@ -1186,6 +1208,9 @@ jobs: - name: Collect llama-server logs if: always() + # A transient Windows DLL-init crash (0xC0000142) in this diagnostic + # copy must not fail an otherwise-green job. + continue-on-error: true shell: bash # Copy llama-server's own stdout/stderr (teed by Studio under # ~/.unsloth/studio/logs/llama-server/) into the workspace so @@ -1199,6 +1224,8 @@ jobs: - name: Upload logs if: always() + # Diagnostic only: a transient artifact-service drop must not fail a green job. + continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-json-images-log diff --git a/README.md b/README.md index 948d84a789..562c35ff1a 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,13 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**. ```bash curl -fsSL https://unsloth.ai/install.sh | sh ``` +Use the same command to update. + #### Windows: ```powershell irm https://unsloth.ai/install.ps1 | iex ``` +Use the same command to update. #### Launch ```bash @@ -83,9 +86,6 @@ unsloth studio -p 8888 ``` For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally. -#### Update -To update, use the same install commands above or use `unsloth studio update`. - #### Docker Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run: ```bash @@ -171,7 +171,9 @@ unsloth studio -p 8888 ``` Then to update : ```bash -unsloth studio update +cd unsloth && git pull +./install.sh --local +unsloth studio -p 8888 ``` #### Developer installs: Windows PowerShell: @@ -184,7 +186,9 @@ unsloth studio -p 8888 ``` Then to update : ```bash -unsloth studio update +cd unsloth && git pull +./install.sh --local +unsloth studio -p 8888 ``` #### Nightly: MacOS, Linux, WSL: diff --git a/install.ps1 b/install.ps1 index cab66f5ae1..47c72bcdc1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1566,7 +1566,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -1580,7 +1580,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1627,7 +1627,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } @@ -1639,7 +1639,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -1667,7 +1667,7 @@ shell.Run cmd, 0, False Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index f0af60e2d6..15755c589f 100755 --- a/install.sh +++ b/install.sh @@ -1530,17 +1530,51 @@ if [ -x "$VENV_DIR/bin/python" ]; then : > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true fi -# Guard against Python 3.13.8 torch import bug on Apple Silicon -# (skip when the user explicitly chose a version via --python) +# Guard against two independent Apple Silicon venv problems, in order: +# 1. uv may create the venv from a cached x86_64 (Rosetta) Python when a +# same-version x86_64 build is already cached (often because uv itself +# is an x86_64 build). That venv reports x86_64 to wheel resolvers, and +# PyTorch ships no macOS wheels on the CPU index for any architecture, +# so the torch install can never resolve. Recreate it with an +# arch-explicit arm64 CPython. +# 2. Python 3.13.8 has a known torch import bug. +# The two are independent: a venv may be x86_64 and, once recreated, still +# land on 3.13.8. So we re-inspect the interpreter between the checks instead +# of chaining them with elif, guaranteeing both invariants hold on whatever +# venv we end up with. Skip both when the user explicitly chose an interpreter +# via --python. if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then - _PY_VER=$("$VENV_DIR/bin/python" -c \ - "import sys; print('{}.{}.{}'.format(*sys.version_info[:3]))" 2>/dev/null || echo "") + _inspect_venv() { + "$VENV_DIR/bin/python" -c \ + "import platform, sys; print(platform.machine(), '{}.{}.{}'.format(*sys.version_info[:3]))" \ + 2>/dev/null || echo " " + } + _info=$(_inspect_venv) + _VENV_ARCH=${_info%% *} + _PY_VER=${_info##* } + + if [ "$_VENV_ARCH" = "x86_64" ]; then + echo " WARNING: venv was created with an x86_64 (Rosetta) Python on Apple Silicon." + echo " Recreating venv with native arm64 Python ${PYTHON_VERSION}..." + rm -rf "$VENV_DIR" + run_install_cmd "recreate venv (arm64)" uv venv "$VENV_DIR" \ + --python "cpython-${PYTHON_VERSION}-macos-aarch64-none" + if [ -x "$VENV_DIR/bin/python" ]; then + : > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true + fi + # Re-inspect: the recreated arm64 venv may still be 3.13.8. + _info=$(_inspect_venv) + _VENV_ARCH=${_info%% *} + _PY_VER=${_info##* } + fi + if [ "$_PY_VER" = "3.13.8" ]; then echo " WARNING: Python 3.13.8 has a known torch import bug." echo " Recreating venv with Python 3.12..." rm -rf "$VENV_DIR" PYTHON_VERSION="3.12" - run_install_cmd "recreate venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION" + run_install_cmd "recreate venv" uv venv "$VENV_DIR" \ + --python "cpython-${PYTHON_VERSION}-macos-aarch64-none" if [ -x "$VENV_DIR/bin/python" ]; then : > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true fi @@ -2049,7 +2083,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.8" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2062,7 +2096,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.8" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2266,7 +2300,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.5.8" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo # Same pydantic-with-deps trick as the migrated branch. run_install_cmd "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2284,7 +2318,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2316,7 +2350,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." diff --git a/pyproject.toml b/pyproject.toml index aef88d90f5..acc65f12cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ triton = [ ] huggingfacenotorch = [ - "unsloth_zoo>=2026.5.4", + "unsloth_zoo>=2026.5.5", "wheel>=0.42.0", "packaging", "numpy", @@ -90,7 +90,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.5.4", + "unsloth_zoo>=2026.5.5", "torchvision", "unsloth[triton]", ] @@ -580,7 +580,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.5.4", + "unsloth_zoo>=2026.5.5", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index bc792c3b99..cdb0fdebff 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -218,6 +218,8 @@ class AnthropicStreamEmitter: def __init__(self) -> None: self.block_index: int = 0 self._text_block_open: bool = False + self._open_tool_call_id: Optional[str] = None + self._open_tool_args_sent: bool = False self._prev_text: str = "" self._usage: dict = {} @@ -263,8 +265,10 @@ class AnthropicStreamEmitter: def finish(self, stop_reason: str = "end_turn") -> list[str]: """Close any open block and emit message_delta + message_stop.""" events = [] - if self._text_block_open: + if self._text_block_open or self._open_tool_call_id is not None: events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False events.append( build_anthropic_sse_event( "message_delta", @@ -310,12 +314,26 @@ class AnthropicStreamEmitter: return events def _handle_tool_start(self, event: dict) -> list[str]: + tool_call_id = event.get("tool_call_id", "") + args = event.get("arguments", {}) + if tool_call_id and self._open_tool_call_id == tool_call_id: + return self._tool_arguments_delta(args) + events = [] - # Close current text block if open + # Close current text block if open. if self._text_block_open: events.append(self._close_block()) - # Open a tool_use block + # Defensive: if a replacement/different tool_start arrives while a + # tool_use block is open, close the stale block before starting another. + elif self._open_tool_call_id is not None: + events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False + + # Open a tool_use block. self.block_index += 1 + self._open_tool_call_id = tool_call_id + self._open_tool_args_sent = False events.append( build_anthropic_sse_event( "content_block_start", @@ -324,35 +342,43 @@ class AnthropicStreamEmitter: "index": self.block_index, "content_block": { "type": "tool_use", - "id": event.get("tool_call_id", ""), + "id": tool_call_id, "name": event.get("tool_name", ""), "input": {}, }, }, ) ) - # Emit the arguments as input_json_delta - args = event.get("arguments", {}) - if args: - events.append( - build_anthropic_sse_event( - "content_block_delta", - { - "type": "content_block_delta", - "index": self.block_index, - "delta": { - "type": "input_json_delta", - "partial_json": json.dumps(args), - }, - }, - ) - ) + events.extend(self._tool_arguments_delta(args)) return events + def _tool_arguments_delta(self, args: dict) -> list[str]: + if not args: + return [] + if self._open_tool_args_sent: + return [] + self._open_tool_args_sent = True + return [ + build_anthropic_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": self.block_index, + "delta": { + "type": "input_json_delta", + "partial_json": json.dumps(args), + }, + }, + ) + ] + def _handle_tool_end(self, event: dict) -> list[str]: events = [] - # Close the tool_use block - events.append(self._close_block()) + # Close the tool_use block. + if self._open_tool_call_id is not None or self._text_block_open: + events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False # Emit custom tool_result event (non-standard, ignored by SDKs) events.append( build_anthropic_sse_event( diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index cce95fc34c..7bcf02dc35 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -52,6 +52,7 @@ from utils.subprocess_compat import ( windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs, ) from core.inference.tool_call_parser import ( + RENDER_HTML_REPEAT_NUDGE, parse_tool_calls_from_text as _shared_parse_tool_calls_from_text, ) @@ -4616,6 +4617,7 @@ class LlamaCppBackend: # a transient failure are allowed (only block when the previous # identical call succeeded). _tool_call_history: list[tuple[str, bool]] = [] # (key, failed) + _render_html_succeeded = False # ── Re-prompt on plan-without-action ───────────────── # When the model describes what it intends to do (forward-looking @@ -4690,6 +4692,7 @@ class LlamaCppBackend: _iter_timings = None _stream_done = False _last_emitted = "" + provisional_render_html_tool_call_ids = set() stream_timeout = httpx.Timeout( connect = 10, @@ -4799,6 +4802,33 @@ class LlamaCppBackend: tool_calls_acc[idx]["function"][ "arguments" ] += func["arguments"] + current_name = tool_calls_acc[idx][ + "function" + ].get("name", "") + fallback_id = f"call_{idx}" + current_id = tool_calls_acc[idx].get( + "id", fallback_id + ) + already_started = ( + current_id + in provisional_render_html_tool_call_ids + ) + has_real_id = current_id != fallback_id + if ( + current_name == "render_html" + and not _render_html_succeeded + and not already_started + and has_real_id + ): + provisional_render_html_tool_call_ids.add( + current_id + ) + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": current_id, + "arguments": {}, + } continue # ── Reasoning tokens ── @@ -4980,13 +5010,25 @@ class LlamaCppBackend: "content": _stripped, } ) + available_tool_names = [ + tool.get("function", {}).get("name") + for tool in tools + if isinstance(tool, dict) + and isinstance(tool.get("function"), dict) + ] + available_tool_names = [ + name for name in available_tool_names if name + ] + tool_hint = ( + " or ".join(available_tool_names) or "an available tool" + ) conversation.append( { "role": "user", "content": ( "STOP. Do NOT write code or explain. " "You MUST call a tool NOW. " - "Call web_search or python immediately." + f"Call {tool_hint} immediately." ), } ) @@ -5158,7 +5200,12 @@ class LlamaCppBackend: arguments = json.loads(raw_args) except (json.JSONDecodeError, ValueError): if auto_heal_tool_calls: - arguments = {"query": raw_args} + heal_key = { + "python": "code", + "terminal": "command", + "render_html": "code", + }.get(tool_name, "query") + arguments = {heal_key: raw_args} else: arguments = {"raw": raw_args} else: @@ -5195,14 +5242,18 @@ class LlamaCppBackend: ) else: status_text = f"Calling: {tool_name}" - yield {"type": "status", "text": status_text} + _repeat_render_html = ( + tool_name == "render_html" and _render_html_succeeded + ) + if not _repeat_render_html: + yield {"type": "status", "text": status_text} - yield { - "type": "tool_start", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "arguments": arguments, - } + yield { + "type": "tool_start", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "arguments": arguments, + } # ── Duplicate call detection ────────────── # str(dict) is stable here: arguments always comes from @@ -5210,7 +5261,9 @@ class LlamaCppBackend: # so insertion order is deterministic (Python 3.7+). _tc_key = tool_name + str(arguments) _prev = _tool_call_history[-1] if _tool_call_history else None - if _prev and _prev[0] == _tc_key and not _prev[1]: + if _repeat_render_html: + result = RENDER_HTML_REPEAT_NUDGE + elif _prev and _prev[0] == _tc_key and not _prev[1]: result = ( "You already made this exact call. " "Do not repeat the same tool call. " @@ -5248,12 +5301,13 @@ class LlamaCppBackend: session_id = session_id, ) - yield { - "type": "tool_end", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "result": result, - } + if not _repeat_render_html: + yield { + "type": "tool_end", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "result": result, + } # Nudge model to try a different approach on errors _error_prefixes = ( @@ -5269,6 +5323,8 @@ class LlamaCppBackend: _is_error = isinstance(result, str) and result.lstrip().startswith( _error_prefixes ) + if tool_name == "render_html" and not _is_error: + _render_html_succeeded = True _tool_call_history.append((_tc_key, _is_error)) # Strip image sentinel before feeding result to the LLM # (the full result with sentinel is still yielded via diff --git a/studio/backend/core/inference/safetensors_agentic.py b/studio/backend/core/inference/safetensors_agentic.py index 73bb3d090a..94e9e303ab 100644 --- a/studio/backend/core/inference/safetensors_agentic.py +++ b/studio/backend/core/inference/safetensors_agentic.py @@ -18,6 +18,7 @@ cumulative text and dispatches them via ``core.inference.tools``. """ import json +import re import threading from typing import Callable, Generator, Optional from urllib.parse import urlparse @@ -27,6 +28,7 @@ from loggers import get_logger from core.inference.tool_call_parser import ( BUDGET_EXHAUSTED_NUDGE, DUPLICATE_CALL_NUDGE, + RENDER_HTML_REPEAT_NUDGE, TOOL_ERROR_NUDGE, TOOL_ERROR_PREFIXES, TOOL_XML_SIGNALS, @@ -66,7 +68,34 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str: return f"Calling: {tool_name}" -_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"} +_CANONICAL_HEAL_ARG = { + "python": "code", + "terminal": "command", + "render_html": "code", +} + + +_FUNCTION_SIGNAL_RE = re.compile(r"") +_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"') + + +def _detect_render_html_tool_start(content: str) -> bool: + """Return True when the first drained tool call is clearly render_html.""" + function_match = _FUNCTION_SIGNAL_RE.search(content) + tool_call_index = content.find("") + if not function_match and tool_call_index < 0: + return False + + if function_match and ( + tool_call_index < 0 or function_match.start() < tool_call_index + ): + return function_match.group(1) == "render_html" + + if tool_call_index >= 0: + name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:]) + return bool(name_match and name_match.group(1) == "render_html") + + return False def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict: @@ -135,6 +164,7 @@ def run_safetensors_tool_loop( """ conversation = list(messages) tool_call_history: list[tuple[str, bool]] = [] + render_html_succeeded = False final_attempt_done = False allowed_tool_names = { (tool.get("function") or {}).get("name") @@ -161,6 +191,8 @@ def run_safetensors_tool_loop( content_accum = "" cumulative_display = "" last_emitted = "" + provisional_render_html_started = False + provisional_render_html_id = f"call_{next_call_id}" gen = single_turn(conversation) prev_cumulative = "" @@ -179,6 +211,18 @@ def run_safetensors_tool_loop( content_accum += delta if detect_state == _state_draining: + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } continue if detect_state == _state_streaming: @@ -196,6 +240,18 @@ def run_safetensors_tool_loop( yield {"type": "content", "text": cleaned_before} cumulative_display = candidate detect_state = _state_draining + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } continue cumulative_display = candidate cleaned = strip_tool_markup(cumulative_display) @@ -222,6 +278,18 @@ def run_safetensors_tool_loop( if is_match: detect_state = _state_draining + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS: continue else: @@ -282,6 +350,13 @@ def run_safetensors_tool_loop( # literal "" prose is preserved. if content_accum: yield {"type": "content", "text": content_accum} + if provisional_render_html_started: + yield { + "type": "tool_end", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "result": "Error: render_html tool call could not be parsed.", + } yield {"type": "status", "text": ""} return content_text = strip_tool_markup(content_accum, final = True) @@ -308,16 +383,20 @@ def run_safetensors_tool_loop( tool_name = tool_name, ) - yield {"type": "status", "text": _status_for_tool(tool_name, arguments)} - yield { - "type": "tool_start", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "arguments": arguments, - } + repeat_render_html = tool_name == "render_html" and render_html_succeeded + if not repeat_render_html: + yield {"type": "status", "text": _status_for_tool(tool_name, arguments)} + yield { + "type": "tool_start", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "arguments": arguments, + } tc_key = tool_name + str(arguments) - if allowed_tool_names and tool_name not in allowed_tool_names: + if repeat_render_html: + result = RENDER_HTML_REPEAT_NUDGE + elif allowed_tool_names and tool_name not in allowed_tool_names: result = ( f"Error: tool '{tool_name}' is not enabled for this " "request. Use one of the enabled tools or provide a " @@ -345,16 +424,19 @@ def run_safetensors_tool_loop( logger.exception("Tool %s raised: %s", tool_name, exc) result = f"Error: tool raised an exception: {exc}" - yield { - "type": "tool_end", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "result": result, - } + if not repeat_render_html: + yield { + "type": "tool_end", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "result": result, + } is_error = isinstance(result, str) and result.lstrip().startswith( TOOL_ERROR_PREFIXES ) + if tool_name == "render_html" and not is_error: + render_html_succeeded = True tool_call_history.append((tc_key, is_error)) # Strip frontend image sentinel from the model's view. diff --git a/studio/backend/core/inference/tool_call_parser.py b/studio/backend/core/inference/tool_call_parser.py index 2f94990623..dacbc19ac0 100644 --- a/studio/backend/core/inference/tool_call_parser.py +++ b/studio/backend/core/inference/tool_call_parser.py @@ -49,6 +49,12 @@ DUPLICATE_CALL_NUDGE = ( "provide your final answer now." ) +RENDER_HTML_REPEAT_NUDGE = ( + "Error: render_html was already called for this response. Do not call " + "render_html again in this response unless the user asks for changes. " + "Provide the final answer now." +) + TOOL_ERROR_NUDGE = ( "\n\nThe tool call encountered an issue. Please try a different " "approach or rephrase your request." @@ -70,6 +76,20 @@ _TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$") # `issue-number`, `repo-name`); using `\w+` here dropped those keys. _TC_PARAM_START_RE = re.compile(r"\s*") _TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$") +_PARAM_CLOSE_TAG = "" +_FUNC_CLOSE_TAG = "" + + +def _inside_open_parameter(content: str, pos: int) -> bool: + """Return True when ``pos`` falls inside an unclosed parameter value.""" + last_param_start = -1 + for match in _TC_PARAM_START_RE.finditer(content, 0, pos): + last_param_start = match.start() + if last_param_start < 0: + return False + last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos) + last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos) + return last_param_start > max(last_param_close, last_func_close) def strip_tool_markup(text: str, *, final: bool = False) -> str: @@ -151,7 +171,11 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict # optional; don't use as body boundary because code # values can contain that literal. if not tool_calls: - func_starts = list(_TC_FUNC_START_RE.finditer(content)) + func_starts = [ + fm + for fm in _TC_FUNC_START_RE.finditer(content) + if not _inside_open_parameter(content, fm.start()) + ] for idx, fm in enumerate(func_starts): func_name = fm.group(1) body_start = fm.end() diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index baf1236456..0f5dbfa237 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -421,6 +421,35 @@ _workdirs: dict[str, str] = {} # Non-matching session_ids collapse to ``_invalid`` to block cross-session escapes. _SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z") +_PROJECT_SESSION_PREFIX = "project-" + + +def _get_project_workdir(session_id: str) -> str | None: + if not session_id.startswith(_PROJECT_SESSION_PREFIX): + return None + project_id = session_id[len(_PROJECT_SESSION_PREFIX) :] + if not project_id or not _SESSION_ID_RE.match(project_id): + return None + try: + from storage.studio_db import ensure_chat_project_workspace + + project = ensure_chat_project_workspace(project_id) + except Exception: + logger.warning( + "Failed to resolve project sandbox for %s", session_id, exc_info = True + ) + return None + if not project: + return None + root_path = project.get("rootPath") + sandbox_path = project.get("sandboxPath") + if not root_path or not sandbox_path: + return None + root_real = os.path.realpath(root_path) + sandbox_real = os.path.realpath(sandbox_path) + if sandbox_real != root_real and not sandbox_real.startswith(root_real + os.sep): + return None + return sandbox_real def _get_workdir(session_id: str | None = None) -> str: @@ -430,7 +459,14 @@ def _get_workdir(session_id: str | None = None) -> str: if key not in _workdirs or not os.path.isdir(_workdirs[key]): home = os.path.expanduser("~") sandbox_root = os.path.join(home, "studio_sandbox") - if session_id and _SESSION_ID_RE.match(session_id): + project_workdir = ( + _get_project_workdir(session_id) + if session_id and _SESSION_ID_RE.match(session_id) + else None + ) + if project_workdir: + workdir = project_workdir + elif session_id and _SESSION_ID_RE.match(session_id): workdir = os.path.join(sandbox_root, session_id) if not os.path.realpath(workdir).startswith( os.path.realpath(sandbox_root) + os.sep @@ -453,6 +489,10 @@ def _get_workdir(session_id: str | None = None) -> str: return _workdirs[key] +def get_sandbox_workdir(session_id: str | None = None) -> str: + return _get_workdir(session_id) + + WEB_SEARCH_TOOL = { "type": "function", "function": { @@ -514,7 +554,35 @@ TERMINAL_TOOL = { }, } -ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL] +RENDER_HTML_TOOL = { + "type": "function", + "function": { + "name": "render_html", + "description": ( + "Render a self-contained HTML/CSS/JavaScript artifact for the user. " + "Call this at most once per assistant response unless the user " + "explicitly asks for changes in that response. Future user requests " + "for new artifacts may call render_html once. Put the entire document " + "in code, including any CSS in