diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 0e3f898864da..29bc9cd41148 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -86,7 +86,7 @@ jobs: - platform: windows architecture: x86_64 target-suffix: pc-windows-msvc - build-on: windows-latest + build-on: windows-2022 variant: cuda steps: @@ -299,7 +299,7 @@ jobs: echo "ARTIFACT_ZIP=target/${TARGET}/release/goose-${TARGET}${VARIANT_SUFFIX}.zip" >> $GITHUB_ENV - name: Upload CLI artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: goose-${{ matrix.architecture }}-${{ matrix.target-suffix }}${{ matrix.variant != 'standard' && matrix.variant != 'musl' && format('-{0}', matrix.variant) || '' }} path: | diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index e539259d7233..65a5fe094594 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -169,7 +169,7 @@ jobs: df -h - name: Upload Desktop artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-darwin-x64 path: ui/desktop/out/ApeMind Agent-darwin-x64/ApeMind Agent_intel_mac.zip diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index d3fc27908720..a00ced73732d 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -104,6 +104,11 @@ jobs: sudo apt-get install -y libasound2 fi + - name: Configure RPM packaging + run: | + echo '%_build_id_links none' > ~/.rpmmacros + test "$(rpm --eval '%{_build_id_links}')" = "none" + - name: Setup Flatpak Runtimes run: | flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo @@ -181,6 +186,14 @@ jobs: done fi + for package in out/make/rpm/x64/*.rpm; do + package_contents="$(rpm -qlp "$package")" + if grep -qE '^/usr/lib/\.build-id(/|$)' <<< "$package_contents"; then + echo "::error file=$package::RPM contains build-id links" + exit 1 + fi + done + echo "Build completed. Checking output..." ls -la out/ find out/ -name "*.deb" -o -name "*.rpm" -o -name "*.flatpak" | head -10 @@ -198,7 +211,7 @@ jobs: - name: Upload .deb package if: matrix.variant == 'standard' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-deb path: ui/desktop/out/make/deb/x64/*.deb @@ -206,7 +219,7 @@ jobs: - name: Upload .deb package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-vulkan-deb path: ui/desktop/out/make/deb/x64/*-vulkan.deb @@ -214,7 +227,7 @@ jobs: - name: Upload .rpm package if: matrix.variant == 'standard' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-rpm path: ui/desktop/out/make/rpm/x64/*.rpm @@ -222,7 +235,7 @@ jobs: - name: Upload .rpm package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-vulkan-rpm path: ui/desktop/out/make/rpm/x64/*-vulkan.rpm @@ -230,7 +243,7 @@ jobs: - name: Upload .flatpak package if: matrix.variant == 'standard' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-flatpak path: ui/desktop/out/make/flatpak/x86_64/*.flatpak @@ -238,7 +251,7 @@ jobs: - name: Upload .flatpak package (Vulkan) if: matrix.variant == 'vulkan' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-linux-x64-vulkan-flatpak path: ui/desktop/out/make/flatpak/x86_64/*-vulkan.flatpak diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index c888b1613c6b..e72f90c99a1d 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -44,7 +44,7 @@ permissions: jobs: build-desktop-windows: name: Build Desktop (Windows) - runs-on: windows-latest + runs-on: ${{ inputs.windows_variant == 'cuda' && 'windows-2022' || 'windows-latest' }} steps: - name: Checkout repository @@ -53,7 +53,7 @@ jobs: ref: ${{ inputs.ref != '' && inputs.ref || '' }} - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24.10.0 @@ -195,7 +195,7 @@ jobs: ls -la ./dist-windows/resources/bin/ - name: Upload unsigned distribution - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-unsigned${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: ui/desktop/dist-windows/ @@ -255,7 +255,7 @@ jobs: 7z a -tzip "${ZIP_NAME}.zip" dist-windows/ - name: Upload signed Windows build - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }}.zip @@ -284,7 +284,7 @@ jobs: 7z a -tzip "${ZIP_NAME}.zip" dist-windows/ - name: Upload Windows build - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }} path: Goose-win32-x64${{ inputs.windows_variant == 'cuda' && '-cuda' || '' }}.zip diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 4fede5115464..eb34702d61d9 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -202,7 +202,7 @@ jobs: - name: Upload Desktop artifact id: upload-app-bundle - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Goose-darwin-arm64 path: ui/desktop/out/ApeMind Agent-darwin-arm64/ApeMind Agent.zip diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index d8f8727bffa9..f3a6ef03c418 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -61,7 +61,7 @@ jobs: needs: [build-cli] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: download_cli.sh path: download_cli.sh diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml index 014ec55efa24..d2817b003a14 100644 --- a/.github/workflows/code-review.yml +++ b/.github/workflows/code-review.yml @@ -64,7 +64,7 @@ jobs: OIDC_TOKEN: ${{ steps.oidc.outputs.token }} - name: Upload review inputs - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: code-review-inputs path: /tmp/code-review/ @@ -119,7 +119,7 @@ jobs: - name: Upload review output if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: code-review-output path: | diff --git a/.github/workflows/create-release-branch.yaml b/.github/workflows/create-release-branch.yaml index 75b4c4a54166..de6a331279da 100644 --- a/.github/workflows/create-release-branch.yaml +++ b/.github/workflows/create-release-branch.yaml @@ -24,7 +24,7 @@ jobs: fetch-depth: 0 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Extract version and base branch env: diff --git a/.github/workflows/create-version-bump-pr.yaml b/.github/workflows/create-version-bump-pr.yaml index 7707a138fb8a..1d3110d07abc 100644 --- a/.github/workflows/create-version-bump-pr.yaml +++ b/.github/workflows/create-version-bump-pr.yaml @@ -25,64 +25,77 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - fetch-depth: 0 - - - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - - - name: install dependencies - run: | - sudo apt update -y - sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev - - - name: Compute version and create bump branch - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} - TETRATE_API_KEY: ${{ secrets.TETRATE_API_KEY }} - run: | - VERSION=$(just get-next-minor-version) - echo "version=$VERSION" >> $GITHUB_ENV - echo "Version: $VERSION (minor bump on main)" - - git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - - BRANCH_NAME="version-bump/$VERSION" - git switch -c "$BRANCH_NAME" - - just prepare-release $VERSION - - echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV - - - name: Push bump branch - run: | - git push origin "${{ env.branch_name }}" - - - name: Create Pull Request - run: | - PR_BODY=$(cat <<'EOF' - Bumps version to **${{ env.version }}**. - - **Please follow these steps:** - - 1. Close and reopen this PR to trigger CI checks. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). - 2. Review and resolve any merge conflicts. - 3. Approve and merge this PR. - 4. The `release/${{ env.version }}` PR will be created automatically. - EOF - ) - PR_URL=$(gh pr create \ - -B main \ - -H "${{ env.branch_name }}" \ - --title "chore(release): bump version to ${{ env.version }} (minor)" \ - --body "$PR_BODY") - echo "pr_url=$PR_URL" >> $GITHUB_ENV - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: install dependencies + run: | + sudo apt update -y + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev + + - name: Compute version and create bump branch + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + TETRATE_API_KEY: ${{ secrets.TETRATE_API_KEY }} + run: | + VERSION=$(just get-next-minor-version) + echo "version=$VERSION" >> $GITHUB_ENV + echo "Version: $VERSION (minor bump on main)" + + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + BRANCH_NAME="version-bump/$VERSION" + git switch -C "$BRANCH_NAME" + + just prepare-release $VERSION + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV + + - name: Push bump branch + run: | + git push --force-with-lease origin "${{ env.branch_name }}" + + - name: Create Pull Request if needed + run: | + EXISTING_PR_URL=$(gh pr list \ + --base main \ + --head "${{ env.branch_name }}" \ + --state open \ + --json url \ + --jq '.[0].url // empty') + + if [ -n "$EXISTING_PR_URL" ]; then + echo "Existing PR found: $EXISTING_PR_URL" + echo "pr_url=$EXISTING_PR_URL" >> $GITHUB_ENV + exit 0 + fi + + PR_BODY=$(cat <<'EOF' + Bumps version to **${{ env.version }}**. + + **Please follow these steps:** + + 1. Close and reopen this PR to trigger CI checks. Reason: workflows don't run on PRs opened by `GITHUB_TOKEN` ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)). + 2. Review and resolve any merge conflicts. + 3. Approve and merge this PR. + 4. The `release/${{ env.version }}` PR will be created automatically. + EOF + ) + PR_URL=$(gh pr create \ + -B main \ + -H "${{ env.branch_name }}" \ + --title "chore(release): bump version to ${{ env.version }} (minor)" \ + --body "$PR_BODY") + echo "pr_url=$PR_URL" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-docs-and-extensions.yml b/.github/workflows/deploy-docs-and-extensions.yml index b93b5e7f7017..e1550dc84c5f 100644 --- a/.github/workflows/deploy-docs-and-extensions.yml +++ b/.github/workflows/deploy-docs-and-extensions.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 @@ -58,10 +58,10 @@ jobs: uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: documentation/build - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index 3df35a340e7a..628b7bdd5d5a 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -73,7 +73,7 @@ jobs: python-version: '3.11' - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' @@ -191,7 +191,7 @@ jobs: - name: Upload automation outputs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cli-docs-update-${{ steps.versions.outputs.old_version }}-to-${{ steps.versions.outputs.new_version }} path: | diff --git a/.github/workflows/docs-update-recipe-ref.yml b/.github/workflows/docs-update-recipe-ref.yml index a8c09cb0e80c..4d4791f8d893 100644 --- a/.github/workflows/docs-update-recipe-ref.yml +++ b/.github/workflows/docs-update-recipe-ref.yml @@ -61,7 +61,7 @@ jobs: sudo apt-get install -y jq ripgrep - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' @@ -178,7 +178,7 @@ jobs: - name: Upload automation outputs if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: recipe-docs-update-${{ steps.versions.outputs.old_version }}-to-${{ steps.versions.outputs.new_version }} path: | diff --git a/.github/workflows/minor-release.yaml b/.github/workflows/minor-release.yaml index 539dc3354313..e4e32d63bb1c 100644 --- a/.github/workflows/minor-release.yaml +++ b/.github/workflows/minor-release.yaml @@ -6,12 +6,61 @@ permissions: on: schedule: - - cron: '0 0 * * 2' + - cron: "0 0 * * 2" workflow_dispatch: + push: + branches: + - main jobs: + check-version-bump-pr: + if: github.repository == 'aaif-goose/goose' && github.event_name == 'push' + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.check.outputs.should_run }} + steps: + - name: Check for conflicted version bump PR + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for attempt in 1 2 3 4 5 6; do + CONFLICTING_PR_URLS=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --state open \ + --search 'head:version-bump/' \ + --json mergeStateStatus,url \ + --jq '.[] | select(.mergeStateStatus == "DIRTY") | .url') + + UNKNOWN_COUNT=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --state open \ + --search 'head:version-bump/' \ + --json mergeStateStatus \ + --jq '[.[] | select(.mergeStateStatus == "UNKNOWN")] | length') + + if [ -n "$CONFLICTING_PR_URLS" ] || [ "$UNKNOWN_COUNT" = "0" ]; then + break + fi + + echo "Waiting for GitHub to compute PR mergeability (attempt $attempt)..." + sleep 10 + done + + if [ -n "$CONFLICTING_PR_URLS" ]; then + echo "Found conflicted version bump PR(s):" + echo "$CONFLICTING_PR_URLS" + echo "should_run=true" >> "$GITHUB_OUTPUT" + else + echo "No conflicted version bump PR found." + echo "should_run=false" >> "$GITHUB_OUTPUT" + fi + release: - if: github.repository == 'aaif-goose/goose' + needs: check-version-bump-pr + if: always() && github.repository == 'aaif-goose/goose' && (github.event_name != 'push' || needs.check-version-bump-pr.outputs.should_run == 'true') uses: ./.github/workflows/create-version-bump-pr.yaml secrets: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/patch-release.yaml b/.github/workflows/patch-release.yaml index 8b0b375cb265..87170f8f35e6 100644 --- a/.github/workflows/patch-release.yaml +++ b/.github/workflows/patch-release.yaml @@ -25,7 +25,7 @@ jobs: fetch-depth: 0 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: install dependencies run: | diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 4eb030f4fe37..45eb08c4f6ed 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -70,14 +70,14 @@ jobs: cargo build --bin goose --bin goosed - name: Upload goose binary - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: goose-binary path: target/debug/goose retention-days: 1 - name: Upload goosed binary - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: goosed-binary path: target/debug/goosed @@ -88,52 +88,33 @@ jobs: runs-on: ubuntu-latest needs: build-binary steps: - - name: Check Anthropic secret - id: check-secrets - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - if [ -n "$ANTHROPIC_API_KEY" ]; then - echo "has_anthropic=true" >> "$GITHUB_OUTPUT" - else - echo "has_anthropic=false" >> "$GITHUB_OUTPUT" - echo "Skipping smoke tests because ANTHROPIC_API_KEY is not configured for this repository." - fi - - name: Checkout Code - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} - name: Download Binary - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: goose-binary path: target/debug - name: Make Binary Executable - if: steps.check-secrets.outputs.has_anthropic == 'true' run: chmod +x target/debug/goose - name: Set up Node.js - if: steps.check-secrets.outputs.has_anthropic == 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '22' - name: Install agentic providers - if: steps.check-secrets.outputs.has_anthropic == 'true' run: npm install -g @anthropic-ai/claude-code @zed-industries/claude-agent-acp @zed-industries/codex-acp - name: Install Node.js Dependencies - if: steps.check-secrets.outputs.has_anthropic == 'true' run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile working-directory: ui/desktop - name: Run Smoke Tests (Normal Mode) - if: steps.check-secrets.outputs.has_anthropic == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -156,17 +137,14 @@ jobs: working-directory: ui/desktop - name: Set up Python - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/setup-python@v6 with: python-version: '3.12' - name: Install uv - if: steps.check-secrets.outputs.has_anthropic == 'true' - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Run MCP Tests - if: steps.check-secrets.outputs.has_anthropic == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -183,7 +161,6 @@ jobs: bash scripts/test_mcp.sh - name: Run Subrecipe Tests - if: steps.check-secrets.outputs.has_anthropic == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GOOSE_PROVIDER: anthropic @@ -242,47 +219,29 @@ jobs: runs-on: ubuntu-latest needs: build-binary steps: - - name: Check Anthropic secret - id: check-secrets - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - if [ -n "$ANTHROPIC_API_KEY" ]; then - echo "has_anthropic=true" >> "$GITHUB_OUTPUT" - else - echo "has_anthropic=false" >> "$GITHUB_OUTPUT" - echo "Skipping compaction tests because ANTHROPIC_API_KEY is not configured for this repository." - fi - - name: Checkout Code - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} - name: Download Binary - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: goose-binary path: target/debug - name: Make Binary Executable - if: steps.check-secrets.outputs.has_anthropic == 'true' run: chmod +x target/debug/goose - name: Set up Python - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/setup-python@v6 with: python-version: '3.12' - name: Install uv - if: steps.check-secrets.outputs.has_anthropic == 'true' - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Run Compaction Tests - if: steps.check-secrets.outputs.has_anthropic == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GOOSE_PROVIDER: anthropic @@ -300,42 +259,25 @@ jobs: runs-on: ubuntu-latest needs: build-binary steps: - - name: Check Anthropic secret - id: check-secrets - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - if [ -n "$ANTHROPIC_API_KEY" ]; then - echo "has_anthropic=true" >> "$GITHUB_OUTPUT" - else - echo "has_anthropic=false" >> "$GITHUB_OUTPUT" - echo "Skipping goose server HTTP integration tests because ANTHROPIC_API_KEY is not configured for this repository." - fi - - name: Checkout Code - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.inputs.branch || github.ref }} - name: Download Binary - if: steps.check-secrets.outputs.has_anthropic == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: goosed-binary path: target/debug - name: Make Binary Executable - if: steps.check-secrets.outputs.has_anthropic == 'true' run: chmod +x target/debug/goosed - name: Install Node.js Dependencies - if: steps.check-secrets.outputs.has_anthropic == 'true' run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile working-directory: ui/desktop - name: Run Integration Tests - if: steps.check-secrets.outputs.has_anthropic == 'true' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GOOSED_BINARY: ../../target/debug/goosed diff --git a/.github/workflows/pr-website-preview.yml b/.github/workflows/pr-website-preview.yml index 97d001b91e82..f1fe1088559a 100644 --- a/.github/workflows/pr-website-preview.yml +++ b/.github/workflows/pr-website-preview.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js for docs build - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 cache: npm @@ -51,7 +51,7 @@ jobs: run: ./scripts/verify-build.sh - name: Setup Node.js for Wrangler - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index a9bac2037221..a4b99f2c4531 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.10.0' registry-url: 'https://registry.npmjs.org' @@ -108,7 +108,7 @@ jobs: pnpm run check:compat - name: Upload built packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: npm-packages path: ui/ @@ -157,7 +157,7 @@ jobs: run: find ui/goose-binary -name 'goose' -type f -exec chmod +x {} + - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.10.0' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/rebuild-skills-marketplace.yml b/.github/workflows/rebuild-skills-marketplace.yml index db0ad4c27cf2..d7bebf48c2df 100644 --- a/.github/workflows/rebuild-skills-marketplace.yml +++ b/.github/workflows/rebuild-skills-marketplace.yml @@ -48,7 +48,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 @@ -78,10 +78,10 @@ jobs: uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: documentation/build - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index ab5ccecca9fa..f155b6e675c5 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -243,7 +243,7 @@ jobs: - name: Upload scan artifacts if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: security-scan path: ${{ runner.temp }}/security-scan/** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0528a682f32..5f23d741047e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: needs: [build-cli] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: download_cli.sh path: download_cli.sh diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b0b4b1de3f4e..14c55f23b610 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif diff --git a/.github/workflows/update-health-dashboard.yml b/.github/workflows/update-health-dashboard.yml index 5dfd73d3508b..36f06dbe1028 100644 --- a/.github/workflows/update-health-dashboard.yml +++ b/.github/workflows/update-health-dashboard.yml @@ -223,7 +223,7 @@ jobs: console.log(`Successfully updated discussion #${discussionNumber}`); - name: 'Upload metrics artifact' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: health-metrics path: health-metrics.json diff --git a/.github/workflows/update-release-pr.yaml b/.github/workflows/update-release-pr.yaml index 987243f70462..3167fc558c7d 100644 --- a/.github/workflows/update-release-pr.yaml +++ b/.github/workflows/update-release-pr.yaml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Extract version from branch name env: diff --git a/AGENTS.md b/AGENTS.md index 286ecce432ce..e0fda6d5e075 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,11 +38,6 @@ just run-ui # start desktop cd ui/desktop && pnpm test # test UI ``` -### Git -```bash -git commit -s # required for DCO sign-off -``` - ## Structure ``` crates/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e91675951579..49e3dcd56ea9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -173,6 +173,17 @@ The app opens a window and displays first-time setup. After completing setup, go Make GUI changes in `ui/desktop`. +#### Troubleshooting: blank screen on `just run-ui` + +If the app opens to a blank window (logs show `Cannot read properties of null (reading 'useRef')`), your `node_modules` is out of date and is loading two copies of React. Delete it and reinstall: + +``` +rm -rf ui/desktop/node_modules +cd ui && pnpm install +``` + +See #8757. + ### Regenerating the OpenAPI schema The file `ui/desktop/openapi.json` is automatically generated during the build. @@ -350,16 +361,6 @@ This project follows the [Conventional Commits](https://www.conventionalcommits. [hermit]: https://cashapp.github.io/hermit/ [just]: https://github.com/casey/just?tab=readme-ov-file#installation -## Developer Certificate of Origin - -This project requires a [Developer Certificate of Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin) sign-offs on all commits. This is a statement indicating that you are allowed to make the contribution and that the project has the right to distribute it under its license. When you are ready to commit, use the `--signoff` or `-s` flag to attach the sign-off to your commit. - -``` -git commit --signoff ... -# OR -git commit -s ... -``` - ## Other Ways to Contribute There are numerous ways to be an open source contributor and contribute to goose. We're here to help you on your way! Here are some suggestions to get started. If you have any questions or need help, feel free to reach out to us on [Discord](https://discord.gg/goose-oss). diff --git a/Cargo.lock b/Cargo.lock index f6fa1507540c..fd207810565b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,7 +33,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] @@ -94,7 +94,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", "serde", @@ -206,6 +206,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "ar_archive_writer" version = "0.5.1" @@ -270,9 +279,9 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "askama" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" dependencies = [ "askama_derive", "itoa", @@ -283,9 +292,9 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" dependencies = [ "askama_parser", "basic-toml", @@ -300,9 +309,9 @@ dependencies = [ [[package]] name = "askama_parser" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" dependencies = [ "memchr", "serde", @@ -501,7 +510,7 @@ dependencies = [ "bytes", "fastrand", "hex", - "http 1.4.1", + "http 1.4.2", "sha1", "time", "tokio", @@ -563,7 +572,7 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -573,9 +582,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.132.0" +version = "1.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a2940faeb61f4f579a434bc3a546e9ab49a89596e94527d329281ef55fd44d" +checksum = "76314880945928a4ee3956e92af451ef29ef04b734d008bb94b11158a60a1034" dependencies = [ "arc-swap", "aws-credential-types", @@ -593,7 +602,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body-util", "regex-lite", "tracing", @@ -620,7 +629,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] @@ -645,7 +654,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] @@ -670,7 +679,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] @@ -696,16 +705,16 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -717,7 +726,7 @@ dependencies = [ "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "percent-encoding", "sha2 0.11.0", "time", @@ -759,7 +768,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -813,7 +822,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -834,7 +843,7 @@ dependencies = [ "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -860,21 +869,21 @@ checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.1", + "http 1.4.2", ] [[package]] name = "aws-smithy-types" -version = "1.4.9" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f93074121a1be41317b9aa607143ae17900631f7f59a99f2b905d519d6783b" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.1", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -925,7 +934,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -957,7 +966,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -988,7 +997,7 @@ dependencies = [ "bytes", "either", "fs-err 3.3.0", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "hyper", "hyper-util", @@ -1016,7 +1025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if", + "cfg-if 1.0.4", "libc", "miniz_oxide", "object", @@ -1090,7 +1099,7 @@ dependencies = [ "bincode", "bytesize", "clircle", - "console", + "console 0.16.3", "content_inspector", "encoding_rs", "flate2", @@ -1144,6 +1153,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.117", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -1160,7 +1189,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -1249,7 +1278,7 @@ dependencies = [ "biome_deserialize", "biome_diagnostics", "biome_rowan", - "cfg-if", + "cfg-if 1.0.4", "countme", "drop_bomb", "indexmap 1.9.3", @@ -1284,7 +1313,7 @@ dependencies = [ "biome_json_syntax", "biome_rowan", "biome_text_size", - "cfg-if", + "cfg-if 1.0.4", "smallvec", "tracing", "unicode-width 0.1.14", @@ -1304,7 +1333,7 @@ dependencies = [ "biome_parser", "biome_rowan", "bitflags 2.11.1", - "cfg-if", + "cfg-if 1.0.4", "drop_bomb", "indexmap 1.9.3", "rustc-hash 1.1.0", @@ -1521,7 +1550,7 @@ dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq", "cpufeatures 0.3.0", ] @@ -1568,6 +1597,31 @@ dependencies = [ "objc2", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -1932,7 +1986,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 1.3.0", ] [[package]] @@ -1955,6 +2009,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1973,7 +2033,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", "cpufeatures 0.2.17", ] @@ -1984,7 +2044,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -2004,9 +2064,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -2123,8 +2183,8 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd" dependencies = [ - "console", - "indicatif", + "console 0.16.3", + "indicatif 0.18.4", "once_cell", "strsim", "textwrap", @@ -2146,7 +2206,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d9334f725b46fb9bed8580b9b47a932587e044fadb344ed7fa98774b067ac1a" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows 0.56.0", ] @@ -2172,8 +2232,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" dependencies = [ "crmf", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2184,8 +2244,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ "const-oid 0.9.6", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2201,6 +2261,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -2228,7 +2297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" dependencies = [ "castaway", - "cfg-if", + "cfg-if 1.0.4", "itoa", "ryu", "static_assertions", @@ -2241,7 +2310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", - "cfg-if", + "cfg-if 1.0.4", "itoa", "rustversion", "ryu", @@ -2290,6 +2359,19 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.16.3" @@ -2314,6 +2396,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -2419,6 +2516,15 @@ dependencies = [ "libm", ] +[[package]] +name = "countio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" +dependencies = [ + "futures-io", +] + [[package]] name = "countme" version = "3.0.1" @@ -2464,7 +2570,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -2474,8 +2580,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" dependencies = [ "cms", - "der 0.7.10", - "spki 0.7.3", + "der", + "spki", "x509-cert", ] @@ -2571,6 +2677,27 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.9" @@ -2581,6 +2708,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor 0.1.1", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctutils" version = "0.4.2" @@ -2635,7 +2778,7 @@ version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", @@ -2666,6 +2809,16 @@ dependencies = [ "darling_macro 0.20.11", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" @@ -2690,6 +2843,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.23.0" @@ -2714,6 +2881,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" @@ -2740,7 +2918,7 @@ version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "hashbrown 0.14.5", "lock_api", "once_cell", @@ -2867,7 +3045,7 @@ dependencies = [ "deno_error", "deno_media_type", "deno_path_util", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "log", "once_cell", @@ -3172,16 +3350,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "const-oid 0.10.2", - "zeroize", -] - [[package]] name = "der-parser" version = "10.0.0" @@ -3344,34 +3512,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -3382,7 +3529,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -3477,6 +3624,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + [[package]] name = "dtor" version = "1.0.5" @@ -3486,6 +3642,12 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -3529,12 +3691,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", - "spki 0.7.3", + "spki", ] [[package]] @@ -3552,7 +3714,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", + "pkcs8", "signature", ] @@ -3593,7 +3755,7 @@ dependencies = [ "group", "hkdf", "pem-rfc7468", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -3621,7 +3783,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -3701,6 +3863,9 @@ name = "esaxx-rs" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] [[package]] name = "etcetera" @@ -3708,7 +3873,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "home", "windows-sys 0.48.0", ] @@ -3719,7 +3884,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-sys 0.61.2", ] @@ -3820,7 +3985,7 @@ version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", ] @@ -4170,6 +4335,15 @@ dependencies = [ "slab", ] +[[package]] +name = "gearhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" +dependencies = [ + "cfg-if 0.1.10", +] + [[package]] name = "gemm" version = "0.18.2" @@ -4435,10 +4609,10 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -4448,7 +4622,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi 5.3.0", @@ -4462,12 +4636,14 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -4496,6 +4672,26 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "glob" version = "0.3.3" @@ -4540,7 +4736,7 @@ dependencies = [ [[package]] name = "goose" -version = "1.37.0" +version = "1.38.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -4562,10 +4758,10 @@ dependencies = [ "candle-transformers", "chrono", "clap", - "ctor", - "dirs 5.0.1", + "ctor 0.2.9", + "dirs", "dotenvy", - "dtor", + "dtor 1.0.5", "encoding_rs", "env-lock", "etcetera 0.11.0", @@ -4578,7 +4774,8 @@ dependencies = [ "goose-providers", "goose-sdk-types", "goose-test-support", - "http 1.4.1", + "hf-hub 1.0.0-rc.1", + "http 1.4.2", "http-body-util", "icu_calendar", "icu_locale", @@ -4594,8 +4791,11 @@ dependencies = [ "libc", "llama-cpp-2", "llama-cpp-sys-2", - "lru", + "lru 0.18.0", "minijinja", + "mlx-lm", + "mlx-lm-utils", + "mlx-rs", "mockall", "nanoid", "nostr", @@ -4609,9 +4809,6 @@ dependencies = [ "opentelemetry_sdk 0.32.1", "pastey", "pctx_code_mode", - "pem", - "pkcs1", - "pkcs8 0.11.0", "process-wrap", "pulldown-cmark", "rand 0.8.6", @@ -4622,7 +4819,6 @@ dependencies = [ "rubato", "rustls", "schemars 1.2.1", - "sec1", "serde", "serde_json", "serde_urlencoded", @@ -4639,7 +4835,7 @@ dependencies = [ "sys-info", "tempfile", "test-case", - "thiserror 1.0.69", + "thiserror 2.0.18", "tiktoken-rs", "tokenizers", "tokio", @@ -4670,7 +4866,7 @@ dependencies = [ "uuid", "v_htmlescape", "webbrowser", - "which 8.0.2", + "which 8.0.3", "winapi", "wiremock", "zip 8.6.0", @@ -4678,7 +4874,7 @@ dependencies = [ [[package]] name = "goose-acp-macros" -version = "1.37.0" +version = "1.38.0" dependencies = [ "quote", "syn 2.0.117", @@ -4686,7 +4882,7 @@ dependencies = [ [[package]] name = "goose-cli" -version = "1.37.0" +version = "1.38.0" dependencies = [ "anstream", "anyhow", @@ -4702,7 +4898,7 @@ dependencies = [ "clap_mangen", "cliclack", "comfy-table", - "console", + "console 0.16.3", "dotenvy", "env-lock", "etcetera 0.11.0", @@ -4710,7 +4906,7 @@ dependencies = [ "goose", "goose-mcp", "goose-providers", - "indicatif", + "indicatif 0.18.4", "open", "rand 0.8.6", "regex", @@ -4721,7 +4917,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", - "shlex", + "shlex 2.0.1", "sigstore-verify", "strum 0.28.0", "tar", @@ -4740,7 +4936,7 @@ dependencies = [ [[package]] name = "goose-mcp" -version = "1.37.0" +version = "1.38.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4770,33 +4966,42 @@ dependencies = [ [[package]] name = "goose-providers" -version = "1.37.0" +version = "1.38.0" dependencies = [ "anyhow", "async-stream", + "async-trait", "base64 0.22.1", "chrono", + "env-lock", "futures", "once_cell", + "pem", + "pkcs1", + "pkcs8", + "rand 0.8.6", "regex", "reqwest 0.13.4", "rmcp", + "sec1", "serde", "serde_json", + "strum 0.28.0", "tempfile", "test-case", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", "unicode-normalization", + "url", "utoipa 4.2.3", "uuid", ] [[package]] name = "goose-sdk" -version = "1.37.0" +version = "1.38.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -4809,7 +5014,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" -version = "1.37.0" +version = "1.38.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -4820,7 +5025,7 @@ dependencies = [ [[package]] name = "goose-server" -version = "1.37.0" +version = "1.38.0" dependencies = [ "anyhow", "aws-lc-rs", @@ -4837,7 +5042,7 @@ dependencies = [ "goose-mcp", "goose-providers", "hex", - "http 1.4.1", + "http 1.4.2", "openssl", "pem", "rand 0.8.6", @@ -4850,7 +5055,7 @@ dependencies = [ "serde_path_to_error", "serde_yaml", "socket2", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-tungstenite 0.29.0", @@ -4867,7 +5072,7 @@ dependencies = [ [[package]] name = "goose-test" -version = "1.37.0" +version = "1.38.0" dependencies = [ "clap", "serde_json", @@ -4875,7 +5080,7 @@ dependencies = [ [[package]] name = "goose-test-support" -version = "1.37.0" +version = "1.38.0" dependencies = [ "axum", "env-lock", @@ -4916,7 +5121,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -4931,7 +5136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "crunchy", "num-traits", "rand 0.9.4", @@ -5010,6 +5215,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" + [[package]] name = "heck" version = "0.5.0" @@ -5038,28 +5249,95 @@ dependencies = [ ] [[package]] -name = "hipstr" -version = "0.6.0" +name = "hf-hub" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97971ffc85d4c98de12e2608e992a43f5294ebb625fdb045b27c731b64c4c6d6" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ + "dirs", + "http 1.4.2", + "indicatif 0.17.11", + "libc", + "log", + "rand 0.9.4", "serde", - "serde_bytes", - "sptr", + "serde_json", + "thiserror 2.0.18", + "ureq", + "windows-sys 0.60.2", ] [[package]] -name = "hkdf" -version = "0.12.4" +name = "hf-hub" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" dependencies = [ - "hmac 0.12.1", + "base64 0.22.1", + "bon", + "bytes", + "futures", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "hf-xet" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.2", + "more-asserts", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "xet-client", + "xet-core-structures", + "xet-data", + "xet-runtime", +] + +[[package]] +name = "hipstr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97971ffc85d4c98de12e2608e992a43f5294ebb625fdb045b27c731b64c4c6d6" +dependencies = [ + "serde", + "serde_bytes", + "sptr", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ @@ -5126,9 +5404,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -5152,7 +5430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -5163,7 +5441,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] @@ -5180,6 +5458,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hybrid-array" version = "0.4.12" @@ -5200,7 +5484,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", @@ -5217,7 +5501,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "hyper", "hyper-util", "rustls", @@ -5266,7 +5550,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "hyper", "ipnet", @@ -5473,9 +5757,9 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -5587,13 +5871,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] + [[package]] name = "indicatif" version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ - "console", + "console 0.16.3", "portable-atomic", "unicode-width 0.2.2", "unit-prefix", @@ -5621,9 +5918,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "once_cell", "similar", @@ -5636,7 +5933,7 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "wasm-bindgen", "web-sys", @@ -5762,7 +6059,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-macros", "jni-sys", @@ -5830,7 +6127,7 @@ version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "futures-util", "once_cell", "wasm-bindgen", @@ -5923,6 +6220,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "konst" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" +dependencies = [ + "const_panic", + "konst_proc_macros", + "typewit", +] + +[[package]] +name = "konst_proc_macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" + [[package]] name = "lazy-regex" version = "3.6.0" @@ -5995,7 +6309,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link", ] @@ -6005,7 +6319,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link", ] @@ -6098,7 +6412,7 @@ version = "0.1.146" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd" dependencies = [ - "bindgen", + "bindgen 0.72.1", "cc", "cmake", "find_cuda_helper", @@ -6123,9 +6437,9 @@ checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lopdf" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fdcbab5b237a03984f83b1394dc534e0b1960675c7f3ec4d04dccc9032b56d" +checksum = "67513274c50a2b51e5f75d9e682fcf4ab064a8a9c9ae2c3c59309084882bb24d" dependencies = [ "aes", "bitflags 2.11.1", @@ -6139,7 +6453,6 @@ dependencies = [ "log", "md-5", "nom 8.0.0", - "nom_locate", "rand 0.10.1", "rangemap", "sha2 0.10.9", @@ -6155,12 +6468,36 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "mach-sys" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48460c2e82a3a0de197152fdf8d2c2d5e43adc501501553e439bf2156e6f87c7" +dependencies = [ + "fastrand", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -6207,7 +6544,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "digest 0.10.7", ] @@ -6283,6 +6620,16 @@ dependencies = [ "serde", ] +[[package]] +name = "minijinja-contrib" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99df5123c54391e2a228014c1dbbd85a3dab08a25e776c810526f2f47542b3de" +dependencies = [ + "minijinja", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6306,17 +6653,110 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] +[[package]] +name = "mlx-internal-macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7c4444d624bf6b93db5cc22ebff4fdfa13593fd56154fe33b1f302a557c2c6" +dependencies = [ + "darling 0.21.3", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mlx-lm" +version = "0.0.1" +source = "git+https://github.com/jh-block/mlx-lm#4cb8572045f55f170eb6e41005a3f8a730673c42" +dependencies = [ + "anyhow", + "clap", + "idna_adapter", + "minijinja", + "mlx-lm-utils", + "mlx-macros", + "mlx-rs", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokenizers", +] + +[[package]] +name = "mlx-lm-utils" +version = "0.0.1" +source = "git+https://github.com/jh-block/mlx-lm#4cb8572045f55f170eb6e41005a3f8a730673c42" +dependencies = [ + "minijinja", + "minijinja-contrib", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokenizers", +] + +[[package]] +name = "mlx-macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819ee8b4434690572b6feb9c3ef0b6e90137e4190b340cf00150703b410aaf9" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mlx-rs" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a0f592c5839b0237b3072530b1d3c503923579a2009ee0761df3edd1b1f27b" +dependencies = [ + "bytemuck", + "dyn-clone", + "half", + "itertools 0.14.0", + "libc", + "mach-sys", + "mlx-internal-macros", + "mlx-macros", + "mlx-sys", + "num-complex", + "num-traits", + "num_enum", + "parking_lot", + "paste", + "safetensors 0.6.2", + "smallvec", + "strum 0.27.2", + "thiserror 2.0.18", +] + +[[package]] +name = "mlx-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e3bc3880111918b2d5018f845d48fd995f9901f16efc81d1fcfd2f4210b8219" +dependencies = [ + "bindgen 0.70.1", + "cc", + "cmake", +] + [[package]] name = "mockall" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "downcast", "fragile", "mockall_derive", @@ -6330,7 +6770,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "proc-macro2", "quote", "syn 2.0.117", @@ -6364,6 +6804,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + [[package]] name = "moxcms" version = "0.8.1" @@ -6434,7 +6880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.1", - "cfg-if", + "cfg-if 1.0.4", "cfg_aliases", "libc", ] @@ -6489,17 +6935,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "nom_locate" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" -dependencies = [ - "bytecount", - "memchr", - "nom 8.0.0", -] - [[package]] name = "nostr" version = "0.44.3" @@ -6530,7 +6965,7 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" dependencies = [ - "lru", + "lru 0.16.4", "nostr", "tokio", ] @@ -6554,7 +6989,7 @@ dependencies = [ "async-wsocket", "atomic-destructor", "hex", - "lru", + "lru 0.16.4", "negentropy", "nostr", "nostr-database", @@ -6577,6 +7012,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -6727,6 +7171,34 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "oauth2" version = "5.0.0" @@ -6736,7 +7208,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.1", + "http 1.4.2", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -6820,6 +7292,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -6845,6 +7327,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -6875,6 +7366,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + [[package]] name = "onig" version = "6.5.3" @@ -6921,7 +7418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.11.1", - "cfg-if", + "cfg-if 1.0.4", "foreign-types 0.3.2", "libc", "openssl-macros", @@ -7015,7 +7512,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.31.0", "reqwest 0.12.28", ] @@ -7028,7 +7525,7 @@ checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.32.0", "reqwest 0.13.4", ] @@ -7039,7 +7536,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.31.0", "opentelemetry-http 0.31.0", "opentelemetry-proto 0.31.0", @@ -7058,7 +7555,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "http 1.4.1", + "http 1.4.2", "opentelemetry 0.32.0", "opentelemetry-http 0.32.0", "opentelemetry-proto 0.32.0", @@ -7141,6 +7638,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + [[package]] name = "outref" version = "0.5.2" @@ -7202,7 +7708,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall 0.5.18", "smallvec", @@ -7334,7 +7840,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "camino", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "keyring", "opentelemetry-otlp 0.31.1", @@ -7343,7 +7849,7 @@ dependencies = [ "rmcp", "serde", "serde_json", - "shlex", + "shlex 1.3.0", "thiserror 2.0.18", "tokio", "tonic", @@ -7582,9 +8088,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -7593,18 +8099,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.0", - "spki 0.8.0", + "der", + "spki", ] [[package]] @@ -7765,6 +8261,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -7879,7 +8384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" dependencies = [ "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "libm", "num-complex", "reborrow", @@ -7893,7 +8398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" dependencies = [ "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "libm", "num-complex", "paste", @@ -8195,6 +8700,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -8213,17 +8727,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -8271,9 +8774,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -8300,9 +8803,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -8315,7 +8818,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -8362,7 +8865,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -8398,6 +8901,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.2", + "reqwest 0.13.4", + "thiserror 2.0.18", + "tower-service", +] + [[package]] name = "resb" version = "0.1.2" @@ -8434,7 +8951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted 0.9.0", @@ -8452,7 +8969,7 @@ dependencies = [ "bytes", "chrono", "futures", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -8509,10 +9026,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "signature", - "spki 0.7.3", + "spki", "subtle", "zeroize", ] @@ -8596,6 +9113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -8678,7 +9196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" dependencies = [ "bitflags 2.11.1", - "cfg-if", + "cfg-if 1.0.4", "clipboard-win", "home", "libc", @@ -8702,7 +9220,13 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" name = "ryu-js" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + +[[package]] +name = "safe-transmute" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" [[package]] name = "safetensors" @@ -8714,6 +9238,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "safetensors" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "safetensors" version = "0.7.0" @@ -8866,9 +9400,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der 0.7.10", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "subtle", "zeroize", ] @@ -9041,6 +9575,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -9149,7 +9694,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -9160,9 +9705,10 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", + "sha2-asm", ] [[package]] @@ -9171,11 +9717,20 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures 0.3.0", "digest 0.11.3", ] +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -9197,7 +9752,9 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs 6.0.0", + "bstr", + "dirs", + "os_str_bytes", ] [[package]] @@ -9206,6 +9763,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -9253,14 +9816,14 @@ dependencies = [ "aws-lc-rs", "base64 0.22.1", "const-oid 0.9.6", - "der 0.7.10", + "der", "digest 0.10.7", "pem", "rand_core 0.9.5", "sha2 0.10.9", "signature", "sigstore-types", - "spki 0.7.3", + "spki", "thiserror 2.0.18", "tracing", "x509-cert", @@ -9326,7 +9889,7 @@ dependencies = [ "cmpv2", "cms", "const-oid 0.9.6", - "der 0.7.10", + "der", "hex", "jiff", "rand 0.9.4", @@ -9481,7 +10044,7 @@ checksum = "566dc85be03a09c384f77a122188d9af000e1f1bd23551b346a9b555838da7e1" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.1", + "http 1.4.2", "parking_lot", "reqwest 0.13.4", ] @@ -9496,6 +10059,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "sourcemap" version = "9.3.2" @@ -9530,17 +10104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der 0.8.0", + "der", ] [[package]] @@ -9690,7 +10254,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -9728,7 +10292,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -9782,7 +10346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "libc", "psm", "windows-sys 0.61.2", @@ -9794,6 +10358,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "std_prelude" version = "0.2.12" @@ -10556,6 +11130,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -10654,7 +11242,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "memchr", "mio", @@ -10669,7 +11257,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "windows-sys 0.61.2", ] @@ -10695,7 +11283,7 @@ version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "proc-macro2", "quote", "syn 2.0.117", @@ -10791,7 +11379,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -10937,6 +11525,8 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", + "hf-hub 0.4.3", + "indicatif 0.18.4", "itertools 0.14.0", "log", "macro_rules_attribute", @@ -11022,6 +11612,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-retry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" +dependencies = [ + "pin-project-lite", + "rand 0.10.1", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -11104,15 +11705,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -11159,6 +11751,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -11185,7 +11789,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -11244,7 +11848,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -11326,6 +11930,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-opentelemetry" version = "0.33.0" @@ -11364,10 +11979,12 @@ dependencies = [ "serde", "serde_json", "sharded-slab", + "smallvec", "thread_local", "time", "tracing", "tracing-core", + "tracing-log", "tracing-serde", ] @@ -11511,7 +12128,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -11530,7 +12147,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.2", "httparse", "log", "native-tls", @@ -11559,6 +12176,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + [[package]] name = "ucd-trie" version = "0.1.7" @@ -11734,9 +12357,9 @@ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] name = "uniffi" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" +checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" dependencies = [ "anyhow", "camino", @@ -11750,9 +12373,9 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" +checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" dependencies = [ "anyhow", "askama", @@ -11767,7 +12390,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml 0.5.11", + "toml 0.9.12+spec-1.1.0", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -11776,9 +12399,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" +checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" dependencies = [ "anyhow", "bytes", @@ -11788,9 +12411,9 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" +checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" dependencies = [ "anyhow", "indexmap 2.14.0", @@ -11801,9 +12424,9 @@ dependencies = [ [[package]] name = "uniffi_macros" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" +checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" dependencies = [ "camino", "fs-err 2.11.0", @@ -11812,27 +12435,27 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "toml 0.5.11", + "toml 0.9.12+spec-1.1.0", "uniffi_meta", ] [[package]] name = "uniffi_meta" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" +checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" dependencies = [ "anyhow", - "siphasher 0.3.11", + "siphasher 1.0.3", "uniffi_internal_macros", "uniffi_pipeline", ] [[package]] name = "uniffi_pipeline" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" +checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" dependencies = [ "anyhow", "heck", @@ -11843,9 +12466,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.29.5" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" +checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" dependencies = [ "anyhow", "textwrap", @@ -11887,6 +12510,25 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -11974,9 +12616,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -12007,7 +12649,7 @@ version = "145.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da" dependencies = [ - "bindgen", + "bindgen 0.72.1", "bitflags 2.11.1", "fslock", "gzip-header", @@ -12082,6 +12724,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -12106,13 +12757,22 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -12322,9 +12982,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] @@ -12336,7 +12996,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite 1.0.2", + "web-sys", ] [[package]] @@ -12799,7 +13472,7 @@ version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-sys 0.61.2", ] @@ -12819,7 +13492,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http 1.4.1", + "http 1.4.2", "http-body-util", "hyper", "hyper-util", @@ -12965,10 +13638,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", - "der 0.7.10", + "der", "sha1", "signature", - "spki 0.7.3", + "spki", "tls_codec", ] @@ -12998,7 +13671,154 @@ checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" dependencies = [ "cmpv2", "cms", - "der 0.7.10", + "der", +] + +[[package]] +name = "xet-client" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "clap", + "crc32fast", + "futures", + "http 1.4.2", + "hyper", + "lazy_static", + "more-asserts", + "rand 0.10.1", + "redb", + "reqwest 0.13.4", + "reqwest-middleware", + "serde", + "serde_json", + "serde_repr", + "statrs", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "web-time", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-core-structures" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +dependencies = [ + "async-trait", + "base64 0.22.1", + "blake3", + "bytemuck", + "bytes", + "clap", + "countio", + "csv", + "futures", + "futures-util", + "getrandom 0.4.2", + "heapify", + "itertools 0.14.0", + "lazy_static", + "lz4_flex", + "more-asserts", + "rand 0.10.1", + "regex", + "safe-transmute", + "serde", + "static_assertions", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "xet-runtime", +] + +[[package]] +name = "xet-data" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "clap", + "gearhash", + "http 1.4.2", + "itertools 0.14.0", + "lazy_static", + "more-asserts", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", + "walkdir", + "xet-client", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-runtime" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "colored", + "const-str", + "ctor 0.6.3", + "dirs", + "futures", + "git-version", + "humantime", + "konst", + "lazy_static", + "libc", + "more-asserts", + "oneshot", + "pin-project", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "shellexpand", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "whoami 2.1.2", + "winapi", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d21c8a3a38f2..d99948f88557 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.37.0" +version = "1.38.0" rust-version = "1.91.1" authors = ["AAIF "] license = "Apache-2.0" @@ -34,7 +34,7 @@ candle-core = { version = "0.10", default-features = false } candle-nn = { version = "0.10", default-features = false } chrono = { version = "0.4.44", default-features = false, features = ["serde", "std"] } clap = { version = "4.1.14", default-features = false, features = ["derive", "std", "help", "suggestions", "usage", "color", "error-context"] } -dirs = { version = "5", default-features = false } +dirs = { version = "6", default-features = false } dotenvy = { version = "0.15.7", default-features = false } env-lock = { version = "1", default-features = false } etcetera = { version = "0.11", default-features = false } @@ -45,7 +45,7 @@ ignore = { version = "0.4.12", default-features = false } include_dir = { version = "0.7", default-features = false } indoc = { version = "2", default-features = false } keyring = { version = "3.6.3", default-features = false, features = ["vendored"] } -lru = { version = "0.16", default-features = false } +lru = { version = "0.18", default-features = false } once_cell = { version = "1.21.3", default-features = false, features = ["std"] } rand = { version = "0.8.5", default-features = false, features = ["std"] } regex = { version = "1.12.3", default-features = false, features = ["std"] } @@ -58,7 +58,7 @@ serde_yaml = { version = "0.9.32", default-features = false } shellexpand = { version = "3", default-features = false, features = ["base-0", "tilde"] } strum = { version = "0.28.0", default-features = false, features = ["derive", "std"] } tempfile = { version = "3.10.1", default-features = false } -thiserror = { version = "1.0.49", default-features = false } +thiserror = { version = "2.0.18", default-features = false } tokio = { version = "1.48", default-features = false } tokio-stream = { version = "0.1.16", default-features = false } tokio-util = { version = "0.7.12", default-features = false } @@ -83,7 +83,7 @@ url = { version = "2.5.4", default-features = false, features = ["std"] } opentelemetry = { version = "0.32", default-features = false } opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics"] } opentelemetry-http = { version = "0.32", default-features = false, features = ["internal-logs", "reqwest"] } -opentelemetry-otlp = { version = "0.32", default-features = false, features = ["http-proto", "internal-logs", "logs", "metrics", "reqwest-client", "trace"] } +opentelemetry-otlp = { version = "0.32", default-features = false, features = ["http-proto", "internal-logs", "logs", "metrics", "reqwest-blocking-client", "trace"] } opentelemetry-appender-tracing = { version = "0.32", default-features = false, features = ["experimental_span_attributes"] } opentelemetry-stdout = { version = "0.32", default-features = false, features = ["trace", "metrics", "logs"] } tracing-opentelemetry = { version = "0.33", default-features = false, features = ["metrics"] } @@ -113,3 +113,12 @@ icu_locale = { version = "=2.1.1", default-features = false } [patch.crates-io] v8 = { path = "vendor/v8" } cudaforge = { git = "https://github.com/jbg/cudaforge", rev = "e7c1967340e40673db98dc9e17da0f04834a456f" } + +# Dependencies don't need debug info. You rarely step-debug into third-party +# crates, but their DWARF dominates `target/debug` — roughly 50-70% of each +# dependency rlib in this workspace is debug info. Dropping it for dependencies +# only shrinks the debug build substantially and speeds linking. The "*" spec +# matches all dependencies but NOT workspace members, so first-party crates keep +# full debug info and stay fully debuggable; release builds are unaffected. +[profile.dev.package."*"] +debug = false diff --git a/I18N.md b/I18N.md index 274ed4fd7202..c07792efc858 100644 --- a/I18N.md +++ b/I18N.md @@ -100,11 +100,15 @@ Compiled files go to `src/i18n/compiled/` (gitignored). The locale is resolved at startup in the following order: -1. **`GOOSE_LOCALE`** — explicit override (set on the `window` object or via env) -2. **`navigator.language`** — the browser/OS locale -3. **`"en"`** — fallback default +1. **Desktop language setting** — selected in Settings, unless set to System Default +2. **`GOOSE_LOCALE`** — explicit override from env/app config when the desktop setting is + System Default +3. **`navigator.language`** — the browser/OS locale +4. **`"en"`** — fallback default -The resolved locale is used for both text translations and all Intl formatting (dates, numbers, relative times). +The resolved locale is used for both text translations and all Intl formatting (dates, numbers, +relative times). Changing the desktop language setting reloads the renderer so startup-only locale +helpers pick up the new value. ## Date and number formatting @@ -134,7 +138,8 @@ This ensures date/number formatting uses the same locale as the rest of the UI. 1. Copy `src/i18n/messages/en.json` to a new file, e.g., `src/i18n/messages/ja.json`. 2. Translate the `defaultMessage` values. Keep ICU syntax intact (e.g., `{count, plural, ...}`). 3. Add the locale code to `SUPPORTED_LOCALES` in `src/i18n/index.ts`. -4. Optionally run `pnpm i18n:compile` to pre-compile. +4. Add the locale to the language selector in `src/components/settings/app/AppSettingsSection.tsx`. +5. Optionally run `pnpm i18n:compile` to pre-compile. No other code changes are needed — `loadMessages()` dynamically imports the correct catalog at runtime. diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 2f33e21b1148..cbd1b79196dd 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -44,7 +44,7 @@ rustyline = { version = "18", default-features = false, features = ["custom-bind tracing = { workspace = true } chrono = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json", "time"] } -shlex = { version = "1.3", default-features = false, features = ["std"] } +shlex = { version = "2.0", default-features = false, features = ["std"] } async-trait = { workspace = true } base64 = { workspace = true } regex = { workspace = true } @@ -88,6 +88,7 @@ local-inference = ["goose/local-inference"] aws-providers = ["goose/aws-providers"] cuda = ["goose/cuda", "local-inference"] vulkan = ["goose/vulkan", "local-inference"] +mlx = ["goose/mlx", "local-inference"] telemetry = ["goose/telemetry"] nostr = ["goose/nostr"] otel = ["goose/otel"] @@ -102,12 +103,14 @@ rustls-tls = [ "sigstore-verify?/rustls", "goose/rustls-tls", "goose-mcp/rustls-tls", + "goose-providers/rustls-tls", ] native-tls = [ "reqwest/native-tls", "sigstore-verify?/native-tls", "goose/native-tls", "goose-mcp/native-tls", + "goose-providers/native-tls", ] [dev-dependencies] diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 7ef8d9134994..c99ddf4543ab 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -360,6 +360,13 @@ pub struct RunBehavior { )] pub resume: bool, + /// Print generation statistics after completion + #[arg( + long = "stats", + help = "Print generation statistics after the run completes" + )] + pub stats: bool, + /// Scheduled job ID (used internally for scheduled executions) #[arg( long = "scheduled-job-id", @@ -1128,7 +1135,6 @@ enum Command { #[arg(long = "severity", value_name = "LEVEL", default_value = "medium")] severity: String, }, - #[command( name = "validate-extensions", about = "Validate a bundled-extensions.json file", @@ -1143,8 +1149,8 @@ enum Command { #[cfg(feature = "local-inference")] #[derive(Subcommand)] enum LocalModelsCommand { - /// Search HuggingFace for GGUF models - #[command(about = "Search HuggingFace for GGUF models")] + /// Search HuggingFace for local models + #[command(about = "Search HuggingFace for local GGUF and MLX models")] Search { /// Search query query: String, @@ -1155,9 +1161,9 @@ enum LocalModelsCommand { }, /// Download a model from HuggingFace - #[command(about = "Download a GGUF model (e.g. bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M)")] + #[command(about = "Download a local model from a search result")] Download { - /// Model spec in user/repo:quantization format + /// Model spec/download id, e.g. user/repo:Q4_K_M or user/repo spec: String, }, @@ -1528,6 +1534,7 @@ async fn handle_interactive_session( quiet: false, output_format: "text".to_string(), container: session_opts.container.map(Container::new), + stats: false, }) .await; @@ -1740,6 +1747,7 @@ async fn handle_run_command( quiet: output_opts.quiet, output_format: output_opts.output_format, container: session_opts.container.map(Container::new), + stats: run_behavior.stats, }) .await; @@ -1847,20 +1855,38 @@ async fn handle_term_subcommand(command: TermCommand) -> Result<()> { } } +#[cfg(feature = "local-inference")] +fn print_download_progress(manager: &goose::download_manager::DownloadManager) { + let Some(progress) = manager + .list_progress() + .into_iter() + .find(|progress| progress.status == goose::download_manager::DownloadStatus::Downloading) + else { + return; + }; + + print!( + "\r {:.1}% ({:.0}MB / {:.0}MB)", + progress.progress_percent, + progress.bytes_downloaded as f64 / (1024.0 * 1024.0), + progress.total_bytes as f64 / (1024.0 * 1024.0), + ); + use std::io::Write; + std::io::stdout().flush().ok(); +} + #[cfg(feature = "local-inference")] async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> { use goose::providers::local_inference::hf_models; - use goose::providers::local_inference::local_model_registry::{ - get_registry, mmproj_local_path, model_id_from_repo, LocalModelEntry, - }; + use goose::providers::local_inference::local_model_registry::get_registry; match command { LocalModelsCommand::Search { query, limit } => { println!("Searching HuggingFace for '{}'...", query); - let results = hf_models::search_gguf_models(&query, limit).await?; + let results = hf_models::search_local_models(&query, limit).await?; if results.is_empty() { - println!("No GGUF models found."); + println!("No compatible local models found."); return Ok(()); } @@ -1869,131 +1895,68 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> "\n{} (by {}) — {} downloads", model.model_name, model.author, model.downloads ); - for file in &model.gguf_files { - let size = if file.size_bytes > 0 { + for variant in &model.variants { + let size = if variant.size_bytes > 0 { format!( "{:.1}GB", - file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + variant.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) ) } else { "unknown".to_string() }; - println!(" {} — {}", file.quantization, size); + let support = if variant.supported { + String::new() + } else { + format!( + " ({})", + variant + .unsupported_reason + .as_deref() + .unwrap_or("unsupported on this platform") + ) + }; + println!( + " [{}] {} — {} — {}{}", + variant.format, variant.label, size, variant.description, support + ); + if variant.supported { + println!( + " Download: goose local-models download '{}'", + variant.download_id + ); + } } - println!( - " Download: goose local-models download {}:", - model.repo_id - ); } } LocalModelsCommand::Download { spec } => { println!("Resolving {}...", spec); - let (repo_id, resolved) = hf_models::resolve_model_spec_full(&spec).await?; - if resolved.files.len() > 1 { - anyhow::bail!( - "Model '{}' is sharded ({} files) — download it from the desktop UI", - spec, - resolved.files.len() - ); - } - let mmproj = resolved.mmproj; - let file = resolved.files.into_iter().next().unwrap(); - let model_id = model_id_from_repo(&repo_id, &file.quantization); - let local_path = - goose::config::paths::Paths::in_data_dir("models").join(&file.filename); - let mmproj_path = mmproj - .as_ref() - .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); - let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); - let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); - let mut download_files = vec![(file.download_url.clone(), local_path.clone())]; - if let Some(mmproj) = mmproj { - download_files.push((mmproj.download_url, mmproj_path.clone().unwrap())); - } + let manager = goose::download_manager::get_download_manager(); + let resolve_task = hf_models::resolve_local_model_spec(&spec); + tokio::pin!(resolve_task); + let resolved = loop { + tokio::select! { + result = &mut resolve_task => break result?, + _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => { + print_download_progress(manager); + } + } + }; + let model_id = resolved.model_id(); + let total_size = resolved.total_size(); println!( - "Downloading {} ({})...", + "\nDownloaded {} ({}). Registering...", model_id, - if file.size_bytes > 0 { - format!( - "{:.1}GB", - file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) - ) + if total_size > 0 { + format!("{:.1}GB", total_size as f64 / (1024.0 * 1024.0 * 1024.0)) } else { "unknown size".to_string() } ); - // Register - let entry = LocalModelEntry { - id: model_id.clone(), - repo_id: repo_id.clone(), - filename: file.filename.clone(), - quantization: file.quantization.clone(), - local_path: local_path.clone(), - source_url: file.download_url.clone(), - settings: Default::default(), - size_bytes: file.size_bytes, - mmproj_path, - mmproj_source_url, - mmproj_size_bytes, - mmproj_checked: true, - shard_files: vec![], - }; - - { - let mut registry = get_registry() - .lock() - .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; - registry.add_model(entry)?; - } + let model_id = hf_models::register_resolved_model(resolved, &spec)?; - // Download - let manager = goose::download_manager::get_download_manager(); - let hf_token = goose::providers::huggingface_auth::resolve_token_async() - .await - .ok() - .flatten(); - manager - .download_model_sharded_with_bearer_token( - format!("{}-model", model_id), - download_files, - file.size_bytes + mmproj_size_bytes, - hf_token, - None, - ) - .await?; - - // Poll progress - loop { - if let Some(progress) = manager.get_progress(&format!("{}-model", model_id)) { - match progress.status { - goose::download_manager::DownloadStatus::Downloading => { - print!( - "\r {:.1}% ({:.0}MB / {:.0}MB)", - progress.progress_percent, - progress.bytes_downloaded as f64 / (1024.0 * 1024.0), - progress.total_bytes as f64 / (1024.0 * 1024.0), - ); - use std::io::Write; - std::io::stdout().flush().ok(); - } - goose::download_manager::DownloadStatus::Completed => { - println!("\nDownloaded: {}", model_id); - break; - } - goose::download_manager::DownloadStatus::Failed => { - let err = progress.error.unwrap_or_default(); - anyhow::bail!("Download failed: {}", err); - } - goose::download_manager::DownloadStatus::Cancelled => { - println!("\nDownload cancelled."); - break; - } - } - } - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } + println!("Registered: {}", model_id); } LocalModelsCommand::List => { let registry = get_registry() @@ -2006,12 +1969,16 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> return Ok(()); } - println!("{:<50} {:<10} Downloaded", "ID", "Quant"); - println!("{}", "-".repeat(70)); + println!( + "{:<50} {:<10} {:<12} Downloaded", + "ID", "Backend", "Variant" + ); + println!("{}", "-".repeat(88)); for m in models { println!( - "{:<50} {:<10} {}", + "{:<50} {:<10} {:<12} {}", m.id, + m.backend_id.as_deref().unwrap_or("llamacpp"), m.quantization, if m.is_downloaded() { "✓" } else { "✗" } ); @@ -2022,11 +1989,8 @@ async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; - if let Some(entry) = registry.get_model(&id) { - if entry.local_path.exists() { - std::fs::remove_file(&entry.local_path)?; - } - registry.remove_model(&id)?; + if registry.get_model(&id).is_some() { + registry.delete_model(&id)?; println!("Deleted model: {}", id); } else { println!("Model not found: {}", id); @@ -2071,6 +2035,7 @@ async fn handle_default_session() -> Result<()> { quiet: false, output_format: "text".to_string(), container: None, + stats: false, }) .await; session.interactive(None).await @@ -2289,4 +2254,105 @@ mod tests { let help = String::from_utf8(buffer).expect("utf8"); assert!(help.contains("nu")); } + + #[test] + fn skills_command_accepts_list_subcommand() { + let cli = Cli::try_parse_from(["goose", "skills", "list"]).expect("parse failed"); + + match cli.command { + Some(Command::Skills { + command: SkillsCommand::List, + }) => {} + _ => panic!("expected skills list command"), + } + } + + #[test] + fn review_command_accepts_options() { + let cli = Cli::try_parse_from([ + "goose", + "review", + "origin/main...HEAD", + "--prompt", + "REVIEW.md", + "--model", + "test-model", + "--provider", + "openai", + "--override-model", + "check-model", + "--turn-limit", + "4", + "--dry-run", + "--quiet", + "--no-orchestrate", + "--instructions", + "focus on correctness", + "--files", + "src/lib.rs", + "--check-filter", + "security", + "--check-scope", + ".agents", + "--checks-only", + "--summary-only", + "--severity", + "low", + ]) + .expect("parse failed"); + + match cli.command { + Some(Command::Review { + range, + prompt, + model, + provider, + override_model, + turn_limit, + dry_run, + quiet, + no_orchestrate, + instructions, + files, + check_filter, + check_scope, + checks_only, + summary_only, + severity, + }) => { + assert_eq!(range.as_deref(), Some("origin/main...HEAD")); + assert_eq!(prompt.as_deref(), Some(std::path::Path::new("REVIEW.md"))); + assert_eq!(model.as_deref(), Some("test-model")); + assert_eq!(provider.as_deref(), Some("openai")); + assert_eq!(override_model.as_deref(), Some("check-model")); + assert_eq!(turn_limit, Some(4)); + assert!(dry_run); + assert!(quiet); + assert!(no_orchestrate); + assert_eq!(instructions.as_deref(), Some("focus on correctness")); + assert_eq!(files, vec!["src/lib.rs"]); + assert_eq!(check_filter, vec!["security"]); + assert_eq!( + check_scope.as_deref(), + Some(std::path::Path::new(".agents")) + ); + assert!(checks_only); + assert!(summary_only); + assert_eq!(severity, "low"); + } + _ => panic!("expected review command"), + } + } + + #[cfg(feature = "tui")] + #[test] + fn tui_command_accepts_trailing_args() { + let cli = + Cli::try_parse_from(["goose", "tui", "--", "--theme", "dark"]).expect("parse failed"); + + match cli.command { + Some(Command::Tui { args }) => assert_eq!(args, vec!["--theme", "dark"]), + _ => panic!("expected tui command"), + } + } } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index ce6cb63acadc..bdcf1ac57bcc 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -19,13 +19,13 @@ use goose::config::{ configure_tetrate, Config, ConfigError, ExperimentManager, ExtensionEntry, GooseMode, PermissionManager, }; -use goose::model::ModelConfig; #[cfg(feature = "telemetry")] use goose::posthog::{get_telemetry_choice, TELEMETRY_ENABLED_KEY}; use goose::providers::base::ConfigKey; use goose::providers::provider_test::test_provider_configuration; use goose::providers::{create, providers, retry_operation, RetryConfig}; use goose::session::SessionType; +use goose_providers::thinking::ThinkingEffort; use serde_json::Value; use std::collections::HashMap; use std::io::IsTerminal; @@ -339,7 +339,7 @@ async fn handle_oauth_configuration(provider_name: &str, key_name: &str) -> anyh )); // Create a temporary provider instance to handle OAuth - let temp_model = ModelConfig::new("temp")?.with_canonical_limits(provider_name); + let temp_model = goose::model_config::model_config_from_user_config(provider_name, "temp")?; match create(provider_name, temp_model, Vec::new()).await { Ok(provider) => match provider.configure_oauth().await { Ok(_) => { @@ -736,8 +736,10 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let spin = spinner(); spin.start("Attempting to fetch supported models..."); - let temp_model_config = - ModelConfig::new(&provider_meta.default_model)?.with_canonical_limits(provider_name); + let temp_model_config = goose::model_config::model_config_from_user_config( + provider_name, + &provider_meta.default_model, + )?; let temp_provider = create(provider_name, temp_model_config, Vec::new()).await?; let models_res = retry_operation(&RetryConfig::default(), || async { temp_provider.fetch_recommended_models().await @@ -765,20 +767,22 @@ pub async fn configure_provider_dialog() -> anyhow::Result { { let supports_thinking = match temp_provider.fetch_model_info(&model).await { Ok(model_info) => model_info.reasoning, - Err(_) => goose::model::ModelConfig::new(&model) + Err(_) => goose_providers::model::ModelConfig::new(&model) .map(|c| c.is_reasoning_model()) .unwrap_or(false), }; if supports_thinking { - let effort: &str = cliclack::select("Select thinking effort:") + let effort: ThinkingEffort = cliclack::select("Select thinking effort:") .item("off", "Off - No extended thinking", "") .item("low", "Low - Better latency, lighter reasoning", "") .item("medium", "Medium - Moderate thinking", "") .item("high", "High - Deep reasoning", "") .item("max", "Max - No constraints on thinking depth", "") .initial_value("off") - .interact()?; + .interact()? + .parse() + .map_err(|_| anyhow::anyhow!("invalid thinking effort"))?; config.set_goose_thinking_effort(effort)?; } } @@ -1080,6 +1084,7 @@ fn configure_stdio_extension() -> anyhow::Result<()> { env_keys, description, timeout: Some(timeout), + cwd: None, bundled: None, available_tools: Vec::new(), }, @@ -1551,7 +1556,7 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { let model: String = config .get_goose_model() .expect("No model configured. Please set model first"); - let model_config = ModelConfig::new(&model)?.with_canonical_limits(&provider_name); + let model_config = goose::model_config::model_config_from_user_config(&provider_name, &model)?; let agent = Agent::new(); @@ -1771,14 +1776,15 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> { // Test configuration - get the model that was configured println!("\nTesting configuration..."); let configured_model: String = config.get_goose_model()?; - let model_config = match goose::model::ModelConfig::new(&configured_model) { - Ok(config) => config.with_canonical_limits("openrouter"), - Err(e) => { - eprintln!("⚠️ Invalid model configuration: {}", e); - eprintln!("Your settings have been saved. Please check your model configuration."); - return Ok(()); - } - }; + let model_config = + match goose::model_config::model_config_from_user_config("openrouter", &configured_model) { + Ok(config) => config, + Err(e) => { + eprintln!("⚠️ Invalid model configuration: {}", e); + eprintln!("Your settings have been saved. Please check your model configuration."); + return Ok(()); + } + }; match create("openrouter", model_config, Vec::new()).await { Ok(provider) => { @@ -1852,14 +1858,15 @@ pub async fn handle_tetrate_auth() -> anyhow::Result<()> { // Test configuration println!("\nTesting configuration..."); let configured_model: String = config.get_goose_model()?; - let model_config = match goose::model::ModelConfig::new(&configured_model) { - Ok(config) => config.with_canonical_limits("tetrate"), - Err(e) => { - eprintln!("⚠️ Invalid model configuration: {}", e); - eprintln!("Your settings have been saved. Please check your model configuration."); - return Ok(()); - } - }; + let model_config = + match goose::model_config::model_config_from_user_config("tetrate", &configured_model) { + Ok(config) => config, + Err(e) => { + eprintln!("⚠️ Invalid model configuration: {}", e); + eprintln!("Your settings have been saved. Please check your model configuration."); + return Ok(()); + } + }; match create("tetrate", model_config, Vec::new()).await { Ok(provider) => { diff --git a/crates/goose-cli/src/commands/info.rs b/crates/goose-cli/src/commands/info.rs index 821e7acbaf18..447d1a8ed88a 100644 --- a/crates/goose-cli/src/commands/info.rs +++ b/crates/goose-cli/src/commands/info.rs @@ -73,9 +73,8 @@ async fn check_provider( } }; - let model_config = goose::model::ModelConfig::new(&model) - .map_err(|e| ProviderCheckError::InvalidModel(e.to_string()))? - .with_canonical_limits(&provider); + let model_config = goose::model_config::model_config_from_user_config(&provider, &model) + .map_err(|e| ProviderCheckError::InvalidModel(e.to_string()))?; let provider_client = goose::providers::create(&provider, model_config, Vec::new()) .await diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 8596eeb782dc..17ecdfcb6e98 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -345,9 +345,7 @@ pub async fn handle_term_info() -> Result<()> { .ok() .and_then(|model_name| { config.get_goose_provider().ok().and_then(|provider_name| { - goose::model::ModelConfig::new(&model_name) - .ok() - .map(|c| c.with_canonical_limits(&provider_name)) + goose::model_config::model_config_from_user_config(&provider_name, &model_name).ok() }) }) .map(|mc| mc.context_limit()) diff --git a/crates/goose-cli/src/logging.rs b/crates/goose-cli/src/logging.rs index 68d435c3c240..a7f8af7c9512 100644 --- a/crates/goose-cli/src/logging.rs +++ b/crates/goose-cli/src/logging.rs @@ -1,53 +1,31 @@ use anyhow::Result; -use std::sync::Once; +use goose::providers::utils::init_goose_request_log; +use std::sync::OnceLock; // Used to ensure we only set up tracing once -static INIT: Once = Once::new(); +static INIT: OnceLock> = OnceLock::new(); /// Sets up the logging infrastructure for the CLI. /// Logs go to a JSON file only (no console output). -pub fn setup_logging(name: Option<&str>) -> Result<()> { - setup_logging_internal(name, false) -} - -fn setup_logging_internal(name: Option<&str>, force: bool) -> Result<()> { - let mut result = Ok(()); - - let mut setup = || { - result = (|| { - use tracing_subscriber::util::SubscriberInitExt; - - let config = goose::logging::LoggingConfig { - component: "cli", - name, - extra_directives: &["goose_cli=info"], - console: false, - json: true, - }; - let subscriber = goose::logging::build_logging_subscriber(&config)?; - - if force { - let _guard = subscriber.set_default(); - tracing::warn!("Test log entry from setup"); - tracing::info!("Another test log entry from setup"); - std::thread::sleep(std::time::Duration::from_millis(100)); - Ok(()) - } else { - subscriber - .try_init() - .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {}", e))?; - Ok(()) - } - })(); - }; +pub fn setup_logging(name: Option<&str>) -> &'static Result<()> { + INIT.get_or_init(|| { + use tracing_subscriber::util::SubscriberInitExt; - if force { - setup(); - } else { - INIT.call_once(setup); - } + init_goose_request_log()?; + let config = goose::logging::LoggingConfig { + component: "cli", + name, + extra_directives: &["goose_cli=info"], + console: false, + json: true, + }; + let subscriber = goose::logging::build_logging_subscriber(&config)?; - result + subscriber + .try_init() + .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {}", e))?; + Ok(()) + }) } #[cfg(test)] diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 3a9a17f8eef0..29a102ee3197 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -178,6 +178,7 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["SLACK_TOKEN".to_string()], timeout: None, + cwd: None, description: "slack-mcp".to_string(), bundled: None, available_tools: Vec::new(), @@ -275,6 +276,7 @@ mod tests { envs: Envs::new(HashMap::new()), env_keys: vec!["API_KEY".to_string()], // Same original key, different extension timeout: None, + cwd: None, description: "service-b".to_string(), bundled: None, available_tools: Vec::new(), diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index eae04307b366..9619f3bdc720 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -9,7 +9,6 @@ use anyhow::Result; use goose::agents::{Agent, AgentConfig, GoosePlatform}; use goose::config::permission::PermissionManager; use goose::config::GooseMode; -use goose::model::ModelConfig; use goose::providers::{create, testprovider::TestProvider}; use goose::session::session_manager::SessionType; use goose::session::SessionManager; @@ -188,7 +187,7 @@ where let inner_provider = create( &factory_name, - ModelConfig::new(config.model_name)?.with_canonical_limits(&factory_name), + goose::model_config::model_config_from_user_config(&factory_name, config.model_name)?, Vec::new(), ) .await?; @@ -262,6 +261,7 @@ where None, None, "text".to_string(), + false, ) .await; diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index a0139520816d..5639a01fce99 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -6,6 +6,7 @@ use console::style; use goose::agents::{Agent, Container, ExtensionError}; use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, ExtensionConfig, GooseMode}; +use goose::model_config::model_config_from_user_config; use goose::providers::create; use goose::recipe::Recipe; use goose::session::session_manager::SessionType; @@ -116,6 +117,8 @@ pub struct SessionBuilderConfig { pub output_format: String, /// Docker container to run stdio extensions inside pub container: Option, + /// Print generation statistics after headless runs. + pub stats: bool, } /// Manual implementation of Default to ensure proper initialization of output_format @@ -143,6 +146,7 @@ impl Default for SessionBuilderConfig { quiet: false, output_format: "text".to_string(), container: None, + stats: false, } } } @@ -226,14 +230,14 @@ async fn load_extensions( struct ResolvedProviderConfig { provider_name: String, model_name: String, - model_config: goose::model::ModelConfig, + model_config: goose_providers::model::ModelConfig, } fn resolve_provider_and_model( session_config: &SessionBuilderConfig, config: &Config, saved_provider: Option, - saved_model_config: Option, + saved_model_config: Option, ) -> ResolvedProviderConfig { let recipe_settings = session_config .recipe @@ -274,14 +278,16 @@ fn resolve_provider_and_model( } config } else { - let temperature = recipe_settings.and_then(|s| s.temperature); - goose::model::ModelConfig::new(&model_name) - .unwrap_or_else(|e| { - output::render_error(&format!("Failed to create model configuration: {}", e)); - process::exit(1); - }) - .with_canonical_limits(&provider_name) - .with_temperature(temperature) + let mut config = + goose::model_config::model_config_from_user_config(&provider_name, &model_name) + .unwrap_or_else(|e| { + output::render_error(&format!("Failed to create model configuration: {}", e)); + process::exit(1); + }); + if let Some(temp) = recipe_settings.and_then(|s| s.temperature) { + config = config.with_temperature(Some(temp)); + } + config }; ResolvedProviderConfig { @@ -408,6 +414,7 @@ async fn collect_extension_configs( recipe: Option<&Recipe>, session_id: &str, ) -> Result, ExtensionError> { + let recipe_extensions = recipe.and_then(|r| r.extensions.as_deref()); let configured_extensions: Vec = if session_config.resume { EnabledExtensionsState::for_session( &agent.config.session_manager, @@ -418,7 +425,7 @@ async fn collect_extension_configs( } else if session_config.no_profile { Vec::new() } else { - resolve_extensions_for_new_session(recipe.and_then(|r| r.extensions.as_deref()), None) + resolve_extensions_for_new_session(recipe_extensions, None) }; let cli_flag_extensions = parse_cli_flag_extensions( @@ -428,6 +435,12 @@ async fn collect_extension_configs( ); let mut all: Vec = configured_extensions; + if !session_config.no_profile && !session_config.resume && recipe_extensions.is_none() { + let project_root = std::env::current_dir().ok(); + all.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( + project_root.as_deref(), + )); + } all.extend(cli_flag_extensions.into_iter().map(|(_, cfg)| cfg)); Ok(all) @@ -531,14 +544,69 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { } }; - let new_provider = match create( + let (new_provider, effective_provider_name, effective_model_name) = match create( &resolved.provider_name, - resolved.model_config, + resolved.model_config.clone(), extensions_for_provider.clone(), ) .await { - Ok(provider) => provider, + Ok(provider) => ( + provider, + resolved.provider_name.clone(), + resolved.model_name.clone(), + ), + Err(e) + if session_config.resume + && session_config.provider.is_none() + && is_provider_unavailable_error(&e) => + { + let fallback_provider = config.get_goose_provider().unwrap_or_else(|_| { + output::render_error("No provider configured. Run 'goose configure' first."); + process::exit(1); + }); + let fallback_model = config.get_goose_model().unwrap_or_else(|_| { + output::render_error("No model configured. Run 'goose configure' first."); + process::exit(1); + }); + eprintln!( + "{}", + style(format!( + "Warning: Could not create the session's original provider '{}' ({}). \ + Falling back to the default provider '{}'.", + resolved.provider_name, e, fallback_provider + )) + .yellow() + ); + let fallback_model_config = + model_config_from_user_config(fallback_provider.as_str(), &fallback_model) + .unwrap_or_else(|e| { + output::render_error(&format!( + "Failed to create model configuration: {}", + e + )); + process::exit(1); + }); + match create( + &fallback_provider, + fallback_model_config, + extensions_for_provider.clone(), + ) + .await + { + Ok(provider) => (provider, fallback_provider, fallback_model), + Err(e2) => { + output::render_error(&format!( + "Error {}.\n\ + Please check your system keychain and run 'goose configure' again.\n\ + If your system is unable to use the keyring, please try setting secret key(s) via environment variables.\n\ + For more info, see: https://goose-docs.ai/docs/troubleshooting/#keychainkeyring-errors", + e2 + )); + process::exit(1); + } + } + } Err(e) => { output::render_error(&format!( "Error {}.\n\ @@ -550,7 +618,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { process::exit(1); } }; - tracing::info!("🤖 Using model: {}", resolved.model_name); + tracing::info!("🤖 Using model: {}", effective_model_name); agent .update_provider(new_provider, &session_id) @@ -605,6 +673,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { edit_mode, recipe.and_then(|r| r.retry.clone()), session_config.output_format.clone(), + session_config.stats, ) .await; @@ -613,14 +682,21 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { if !session_config.quiet { output::display_session_info( session_config.resume, - &resolved.provider_name, - &resolved.model_name, + &effective_provider_name, + &effective_model_name, &Some(session_id), ); } session } +fn is_provider_unavailable_error(e: &anyhow::Error) -> bool { + let msg = e.to_string(); + msg.contains("is not set") + || msg.contains("not configured") + || msg.contains("Configuration value not found") +} + #[cfg(test)] mod tests { use super::*; @@ -651,6 +727,7 @@ mod tests { quiet: false, output_format: "text".to_string(), container: None, + stats: false, }; assert_eq!(config.extensions.len(), 1); diff --git a/crates/goose-cli/src/session/elicitation.rs b/crates/goose-cli/src/session/elicitation.rs index ef4930d76ed6..012295ff968e 100644 --- a/crates/goose-cli/src/session/elicitation.rs +++ b/crates/goose-cli/src/session/elicitation.rs @@ -1,12 +1,15 @@ use console::style; +use rmcp::model::ElicitationAction; use serde_json::Value; use std::collections::HashMap; use std::io::{self, BufRead, IsTerminal, Write}; -pub fn collect_elicitation_input( - message: &str, - schema: &Value, -) -> io::Result>> { +pub struct ElicitationInput { + pub action: ElicitationAction, + pub user_data: HashMap, +} + +pub fn collect_elicitation_input(message: &str, schema: &Value) -> io::Result { if !message.is_empty() { println!("\n{}", style(message).cyan()); } @@ -24,9 +27,18 @@ pub fn collect_elicitation_input( "Approve?" }; return match cliclack::confirm(prompt).initial_value(true).interact() { - Ok(true) => Ok(Some(HashMap::new())), - Ok(false) => Ok(None), - Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(None), + Ok(true) => Ok(ElicitationInput { + action: ElicitationAction::Accept, + user_data: HashMap::new(), + }), + Ok(false) => Ok(ElicitationInput { + action: ElicitationAction::Decline, + user_data: HashMap::new(), + }), + Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(ElicitationInput { + action: ElicitationAction::Cancel, + user_data: HashMap::new(), + }), Err(e) => Err(e), }; } @@ -65,7 +77,12 @@ pub fn collect_elicitation_input( Ok(v) => { data.insert(name.clone(), Value::Bool(v)); } - Err(e) if e.kind() == io::ErrorKind::Interrupted => return Ok(None), + Err(e) if e.kind() == io::ErrorKind::Interrupted => { + return Ok(ElicitationInput { + action: ElicitationAction::Cancel, + user_data: HashMap::new(), + }); + } Err(e) => return Err(e), } continue; @@ -93,7 +110,10 @@ pub fn collect_elicitation_input( // Handle Ctrl+C / EOF for cancellation if input.is_none() { - return Ok(None); + return Ok(ElicitationInput { + action: ElicitationAction::Cancel, + user_data: HashMap::new(), + }); } let input = input.unwrap(); @@ -114,12 +134,18 @@ pub fn collect_elicitation_input( "{}", style(format!("Required field '{}' is missing", name)).red() ); - return Ok(None); + return Ok(ElicitationInput { + action: ElicitationAction::Decline, + user_data: HashMap::new(), + }); } } println!(); - Ok(Some(data)) + Ok(ElicitationInput { + action: ElicitationAction::Accept, + user_data: data, + }) } fn read_line() -> io::Result> { diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index 9f6b509a8ae6..f4969e6803b7 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -353,7 +353,7 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin message )); } - ActionRequiredData::ElicitationResponse { id, user_data } => { + ActionRequiredData::ElicitationResponse { id, user_data, .. } => { md.push_str(&format!( "**Action Required** (elicitation_response): {}\n```json\n{}\n```\n\n", id, diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 67c06704d4d4..b3027901b132 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -13,6 +13,7 @@ use crate::session::task_execution_display::{ format_task_execution_notification, TASK_EXECUTION_NOTIFICATION_TYPE, }; use goose::conversation::Conversation; +use std::env; use std::io::Write; use std::str::FromStr; use tokio::signal::ctrl_c; @@ -27,6 +28,7 @@ use goose::permission::permission_confirmation::PrincipalType; use goose::permission::Permission; use goose::permission::PermissionConfirmation; use goose::providers::base::Provider; +use goose::providers::base::ProviderUsage; use goose::utils::safe_truncate; use anyhow::{Context, Result}; @@ -37,8 +39,8 @@ use goose::agents::{Agent, SessionConfig, COMPACT_TRIGGERS}; use goose::config::extensions::name_to_key; use goose::config::{Config, GooseMode}; use input::InputResult; -use rmcp::model::PromptMessage; use rmcp::model::ServerNotification; +use rmcp::model::{ElicitationAction, PromptMessage}; use rmcp::model::{ErrorCode, ErrorData}; use strum::VariantNames; @@ -56,6 +58,8 @@ use tokio; use tokio_util::sync::CancellationToken; use tracing::warn; +const GOOSE_PLANNER_CONTEXT_LIMIT: &str = "GOOSE_PLANNER_CONTEXT_LIMIT"; + #[derive(Serialize, Deserialize, Debug)] struct JsonOutput { messages: Vec, @@ -172,6 +176,7 @@ pub struct CliSession { edit_mode: Option, retry_config: Option, output_format: String, + stats: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -250,6 +255,7 @@ impl CliSession { edit_mode: Option, retry_config: Option, output_format: String, + stats: bool, ) -> Self { let messages = agent .config @@ -271,6 +277,7 @@ impl CliSession { edit_mode, retry_config, output_format, + stats, } } @@ -312,6 +319,7 @@ impl CliSession { env_keys: Vec::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: Vec::new(), }) @@ -837,8 +845,11 @@ impl CliSession { let new_model_config = build_switched_model_config(¤t_provider_name, model_name, ¤t_model_config)?; + let configured_effort = Config::global().get_goose_thinking_effort(); + let new_effort = new_model_config.thinking_effort().or(configured_effort); + let current_effort = current_model_config.thinking_effort().or(configured_effort); if new_model_config.model_name == current_model_config.model_name - && new_model_config.thinking_effort() == current_model_config.thinking_effort() + && new_effort == current_effort { output::goose_mode_message(&format!( "Session already using model '{}' for provider '{}'", @@ -1176,6 +1187,9 @@ impl CliSession { let mut markdown_buffer = streaming_buffer::MarkdownBuffer::new(); let mut prompted_credits_urls: HashSet = HashSet::new(); let mut thinking_header_shown = false; + let run_started = Instant::now(); + let mut first_token_at: Option = None; + let mut last_usage: Option = None; use futures::StreamExt; loop { @@ -1183,6 +1197,9 @@ impl CliSession { result = stream.next() => { match result { Some(Ok(AgentEvent::Message(message))) => { + if first_token_at.is_none() && message_has_text(&message) { + first_token_at = Some(Instant::now()); + } if let Some((id, security_prompt)) = find_tool_confirmation(&message) { let permission = if interactive { prompt_tool_confirmation(&security_prompt)? @@ -1248,25 +1265,37 @@ impl CliSession { let _ = progress_bars.hide(); match elicitation::collect_elicitation_input(&elicitation_message, &schema) { - Ok(Some(user_data)) => { - let user_data_value = serde_json::to_value(user_data) + Ok(input) => { + match &input.action { + ElicitationAction::Decline => { + output::render_text("Information request declined.", Some(Color::Yellow), true); + } + ElicitationAction::Cancel => { + output::render_text("Information request cancelled.", Some(Color::Yellow), true); + } + ElicitationAction::Accept => {} + } + + let should_cancel = input.action == ElicitationAction::Cancel; + let action = input.action; + let user_data_value = serde_json::to_value(input.user_data) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); let response_message = Message::user() .with_content(MessageContent::action_required_elicitation_response( elicitation_id, user_data_value, + action, )) .with_visibility(false, true); self.messages.push(response_message.clone()); // Elicitation responses return an empty stream - the response // unblocks the waiting tool call via ActionRequiredManager let _ = self.agent.reply(response_message, session_config.clone(), Some(cancel_token.clone())).await?; - } - Ok(None) => { - output::render_text("Information request cancelled.", Some(Color::Yellow), true); - cancel_token_clone.cancel(); - drop(stream); - break; + if should_cancel { + cancel_token_clone.cancel(); + drop(stream); + break; + } } Err(e) => { output::render_error(&format!("Failed to collect input: {}", e)); @@ -1294,6 +1323,9 @@ impl CliSession { } } } + Some(Ok(AgentEvent::Usage(usage))) => { + last_usage = Some(usage); + } Some(Ok(AgentEvent::McpNotification((extension_id, notification)))) => { handle_mcp_notification( &extension_id, @@ -1390,6 +1422,9 @@ impl CliSession { }); } else { println!(); + if self.stats { + print_run_stats(run_started, first_token_at, last_usage.as_ref()); + } } Ok(()) @@ -1709,6 +1744,61 @@ impl CliSession { } } +fn message_has_text(message: &Message) -> bool { + message.content.iter().any( + |content| matches!(content, MessageContent::Text(text) if !text.text.trim().is_empty()), + ) +} + +fn print_run_stats( + run_started: Instant, + first_token_at: Option, + usage: Option<&ProviderUsage>, +) { + let elapsed = run_started.elapsed(); + let output_tokens = usage + .and_then(|usage| usage.usage.output_tokens) + .and_then(|tokens| usize::try_from(tokens).ok()) + .or_else(|| usage.and_then(|usage| usage.stats.as_ref()?.output_tokens)); + let tokens_per_second = output_tokens.map(|tokens| { + if elapsed.as_secs_f64() > 0.0 { + tokens as f64 / elapsed.as_secs_f64() + } else { + 0.0 + } + }); + + eprintln!("\nStats:"); + match first_token_at { + Some(first) => eprintln!( + " Time to first token: {:.2}s", + first.duration_since(run_started).as_secs_f64() + ), + None => eprintln!(" Time to first token: unavailable"), + } + match tokens_per_second { + Some(rate) => eprintln!(" Tokens/sec: {:.2}", rate), + None => eprintln!(" Tokens/sec: unavailable"), + } + if let Some(tokens) = output_tokens { + eprintln!(" Output tokens: {tokens}"); + } + + if let Some(draft) = usage + .and_then(|usage| usage.stats.as_ref()) + .and_then(|stats| stats.draft.as_ref()) + { + eprintln!(" Draft accept rate: {:.1}%", draft.accept_rate * 100.0); + eprintln!( + " Draft tokens: {} accepted: {} target verified: {} rounds: {}", + draft.draft_tokens, draft.accepted_tokens, draft.target_tokens, draft.rounds + ); + if let Some(model) = &draft.model { + eprintln!(" Draft model: {model}"); + } + } +} + fn maybe_open_credits_top_up_url( message: &Message, interactive: bool, @@ -2116,7 +2206,6 @@ fn handle_agent_error(e: &anyhow::Error, is_stream_json_mode: bool) { } async fn get_reasoner() -> Result, anyhow::Error> { - use goose::model::ModelConfig; use goose::providers::create; let config = Config::global(); @@ -2141,8 +2230,19 @@ async fn get_reasoner() -> Result, anyhow::Error> { .expect("No model configured. Run 'goose configure' first") }; + let planner_context_limit = match env::var(GOOSE_PLANNER_CONTEXT_LIMIT) + .ok() + .map(|v| v.parse::()) + { + Some(Ok(n)) if n >= 4096 => Some(n), + Some(Ok(_)) => anyhow::bail!("{} must be at least 4096", GOOSE_PLANNER_CONTEXT_LIMIT), + Some(Err(e)) => anyhow::bail!("{}: {}", GOOSE_PLANNER_CONTEXT_LIMIT, e), + None => None, + }; + let model_config = - ModelConfig::new_with_context_env(model, &provider, Some("GOOSE_PLANNER_CONTEXT_LIMIT"))?; + goose::model_config::model_config_from_user_config(&provider, model.as_str())? + .with_context_limit(planner_context_limit); let extensions = goose::config::extensions::get_enabled_extensions_with_config(config); let reasoner = create(&provider, model_config, extensions).await?; @@ -2165,12 +2265,11 @@ fn format_elapsed_time(duration: std::time::Duration) -> String { fn build_switched_model_config( provider_name: &str, model_name: &str, - current_model_config: &goose::model::ModelConfig, -) -> Result { - goose::model::ModelConfig::new(model_name) + current_model_config: &goose_providers::model::ModelConfig, +) -> Result { + goose::model_config::model_config_from_user_config(provider_name, model_name) .map(|config| { config - .with_canonical_limits(provider_name) .with_temperature(current_model_config.temperature) .with_toolshim(current_model_config.toolshim) .with_toolshim_model(current_model_config.toolshim_model.clone()) @@ -2263,6 +2362,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2278,6 +2378,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2293,6 +2394,7 @@ mod tests { env_keys: vec![], description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: vec![], } @@ -2317,7 +2419,7 @@ mod tests { ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), ]); - let current_model_config = goose::model::ModelConfig { + let current_model_config = goose_providers::model::ModelConfig { model_name: "gpt-4o".to_string(), context_limit: Some(128_000), temperature: Some(0.25), @@ -2334,7 +2436,7 @@ mod tests { let switched = build_switched_model_config("openai", "gpt-5.4", ¤t_model_config).unwrap(); - let expected = goose::model::ModelConfig::new_or_fail("gpt-5.4") + let expected = goose_providers::model::ModelConfig::new_or_fail("gpt-5.4") .with_canonical_limits("openai") .with_temperature(Some(0.25)) .with_toolshim(true) @@ -2361,8 +2463,8 @@ mod tests { ("GOOSE_THINKING_EFFORT", None::<&str>), ]); - let current = - goose::model::ModelConfig::new_or_fail("gpt-5.4-high").with_canonical_limits("openai"); + let current = goose_providers::model::ModelConfig::new_or_fail("gpt-5.4-high") + .with_canonical_limits("openai"); assert_eq!(current.model_name, "gpt-5.4"); assert_eq!( current.thinking_effort(), diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 610e9986b68c..45ac8577f207 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -35,7 +35,7 @@ etcetera = { workspace = true } tempfile = { workspace = true } include_dir = { workspace = true } once_cell = { workspace = true } -lopdf = { version = "0.40", default-features = false } +lopdf = { version = "0.41", default-features = false } docx-rs = { version = "0.4.18", default-features = false, features = ["image"] } image = { version = "0.24.4", default-features = false, features = ["bmp", "dds", "dxt", "farbfeld", "gif", "hdr", "ico", "jpeg", "jpeg_rayon", "openexr", "png", "pnm", "tga", "tiff", "webp"] } umya-spreadsheet = { version = "2", default-features = false } diff --git a/crates/goose-providers/Cargo.toml b/crates/goose-providers/Cargo.toml index 267d2b91c8b2..fa626714acf2 100644 --- a/crates/goose-providers/Cargo.toml +++ b/crates/goose-providers/Cargo.toml @@ -11,6 +11,21 @@ description.workspace = true [lints] workspace = true +[features] +default = [] +rustls-tls = [ + "reqwest/rustls", + "rmcp/reqwest", +] +native-tls = [ + "dep:pem", + "dep:pkcs1", + "dep:pkcs8", + "dep:sec1", + "reqwest/native-tls", + "rmcp/reqwest-native-tls", +] + [dependencies] anyhow = { workspace = true } async-stream = { workspace = true } @@ -28,9 +43,19 @@ tracing = { workspace = true } unicode-normalization = { version = "0.1.22", default-features = false, features = ["std"] } utoipa = { workspace = true, features = ["chrono"] } uuid = { workspace = true, features = ["v4", "std"] } +async-trait = { workspace = true } +strum = { workspace = true } +tokio = { workspace = true } +rand = { workspace = true } +url = { workspace = true } +pem = { version = "3.0.2", default-features = false, features = ["std"], optional = true } +pkcs1 = { version = "0.7.5", default-features = false, features = ["pkcs8", "std"], optional = true } +pkcs8 = { version = "0.10.2", default-features = false, features = ["alloc", "std"], optional = true } +sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8", "std"], optional = true } [dev-dependencies] test-case = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true } +env-lock = { workspace = true } diff --git a/crates/goose/src/providers/api_client.rs b/crates/goose-providers/src/api_client.rs similarity index 88% rename from crates/goose/src/providers/api_client.rs rename to crates/goose-providers/src/api_client.rs index f6bc62d47083..2493e6ada7c4 100644 --- a/crates/goose/src/providers/api_client.rs +++ b/crates/goose-providers/src/api_client.rs @@ -1,5 +1,3 @@ -use crate::providers::base::DEFAULT_PROVIDER_TIMEOUT_SECS; -use crate::session_context::SESSION_ID_HEADER; use anyhow::Result; use async_trait::async_trait; use reqwest::{ @@ -15,6 +13,9 @@ use std::fs::read_to_string; use std::path::PathBuf; use std::time::Duration; +const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600; +const SESSION_ID_HEADER: &str = "agent-session-id"; + pub struct ApiClient { client: Client, host: String, @@ -28,12 +29,7 @@ pub struct ApiClient { pub enum AuthMethod { NoAuth, BearerToken(String), - ApiKey { - header_name: String, - key: String, - }, - #[allow(dead_code)] - OAuth(OAuthConfig), + ApiKey { header_name: String, key: String }, Custom(Box), } @@ -57,48 +53,6 @@ impl TlsConfig { } } - pub fn from_config() -> Result> { - let config = crate::config::Config::global(); - let mut tls_config = TlsConfig::new(); - let mut has_tls_config = false; - - let client_cert_path = config.get_param::("GOOSE_CLIENT_CERT_PATH").ok(); - let client_key_path = config.get_param::("GOOSE_CLIENT_KEY_PATH").ok(); - - // Validate that both cert and key are provided if either is provided - match (client_cert_path, client_key_path) { - (Some(cert_path), Some(key_path)) => { - tls_config = tls_config.with_client_cert_and_key( - std::path::PathBuf::from(cert_path), - std::path::PathBuf::from(key_path), - ); - has_tls_config = true; - } - (Some(_), None) => { - return Err(anyhow::anyhow!( - "Client certificate provided (GOOSE_CLIENT_CERT_PATH) but no private key (GOOSE_CLIENT_KEY_PATH)" - )); - } - (None, Some(_)) => { - return Err(anyhow::anyhow!( - "Client private key provided (GOOSE_CLIENT_KEY_PATH) but no certificate (GOOSE_CLIENT_CERT_PATH)" - )); - } - (None, None) => {} - } - - if let Ok(ca_cert_path) = config.get_param::("GOOSE_CA_CERT_PATH") { - tls_config = tls_config.with_ca_cert(std::path::PathBuf::from(ca_cert_path)); - has_tls_config = true; - } - - if has_tls_config { - Ok(Some(tls_config)) - } else { - Ok(None) - } - } - pub fn with_client_cert_and_key(mut self, cert_path: PathBuf, key_path: PathBuf) -> Self { self.client_identity = Some(TlsCertKeyPair { cert_path, @@ -234,13 +188,6 @@ fn convert_key_to_pkcs8_pem(key_pem_str: &str) -> Result { } } -pub struct OAuthConfig { - pub host: String, - pub client_id: String, - pub redirect_url: String, - pub scopes: Vec, -} - #[async_trait] pub trait AuthProvider: Send + Sync { async fn get_auth_header(&self) -> Result<(String, String)>; @@ -261,7 +208,6 @@ impl fmt::Debug for AuthMethod { .field("header_name", header_name) .field("key", &"[hidden]") .finish(), - AuthMethod::OAuth(_) => f.debug_tuple("OAuth").field(&"[config]").finish(), AuthMethod::Custom(_) => f.debug_tuple("Custom").field(&"[provider]").finish(), } } @@ -283,19 +229,27 @@ pub struct ApiRequestBuilder<'a> { } impl ApiClient { - pub fn new(host: String, auth: AuthMethod) -> Result { - Self::with_timeout( + pub fn new_with_tls( + host: String, + auth: AuthMethod, + tls_config: Option, + ) -> Result { + Self::with_timeout_and_tls( host, auth, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), + tls_config, ) } - pub fn with_timeout(host: String, auth: AuthMethod, timeout: Duration) -> Result { + pub fn with_timeout_and_tls( + host: String, + auth: AuthMethod, + timeout: Duration, + tls_config: Option, + ) -> Result { let mut client_builder = Client::builder().timeout(timeout); - // Configure TLS if needed - let tls_config = TlsConfig::from_config()?; if let Some(ref config) = tls_config { client_builder = Self::configure_tls(client_builder, config)?; } @@ -313,6 +267,10 @@ impl ApiClient { }) } + pub fn host(&self) -> &str { + &self.host + } + fn rebuild_client(&mut self) -> Result<()> { let mut client_builder = Client::builder() .timeout(self.timeout) @@ -441,16 +399,6 @@ impl ApiClient { Ok(url) } - - async fn get_oauth_token(&self, config: &OAuthConfig) -> Result { - super::oauth::get_oauth_token_async( - &config.host, - &config.client_id, - &config.redirect_url, - &config.scopes, - ) - .await - } } impl<'a> ApiRequestBuilder<'a> { @@ -514,10 +462,6 @@ impl<'a> ApiRequestBuilder<'a> { request.header("Authorization", format!("Bearer {}", token)) } AuthMethod::ApiKey { header_name, key } => request.header(header_name.as_str(), key), - AuthMethod::OAuth(config) => { - let token = self.client.get_oauth_token(config).await?; - request.header("Authorization", format!("Bearer {}", token)) - } AuthMethod::Custom(provider) => { let (header_name, header_value) = provider.get_auth_header().await?; request.header(header_name, header_value) @@ -676,9 +620,10 @@ mod tests { ) { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(async { - let client = ApiClient::new( + let client = ApiClient::new_with_tls( "http://localhost:8080".to_string(), AuthMethod::BearerToken("test-token".to_string()), + None, ) .unwrap(); diff --git a/crates/goose-providers/src/base.rs b/crates/goose-providers/src/base.rs index e063ddebc9e4..5dfe73bd3d9b 100644 --- a/crates/goose-providers/src/base.rs +++ b/crates/goose-providers/src/base.rs @@ -1,17 +1,553 @@ -use std::future::Future; +use async_trait::async_trait; +use futures::Stream; +use rmcp::model::Tool; +use serde::{Deserialize, Serialize}; +use std::pin::Pin; +use utoipa::ToSchema; -pub struct Error; +use crate::{ + canonical::{map_to_canonical_model, CanonicalModelRegistry}, + conversation::{ + message::{Message, MessageContent}, + token_usage::{ProviderUsage, Usage}, + }, + errors::ProviderError, + goose_mode::GooseMode, + model::ModelConfig, + permission::PermissionConfirmation, + retry::RetryConfig, +}; -pub struct Model { +/// Information about a model's capabilities +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +pub struct ModelInfo { + /// The name of the model pub name: String, + /// The underlying model resolved from provider metadata, when the configured model is an alias or endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_model: Option, + /// The maximum context length this model supports + pub context_limit: usize, + /// Cost per token for input in USD (optional) + pub input_token_cost: Option, + /// Cost per token for output in USD (optional) + pub output_token_cost: Option, + /// Currency for the costs (default: "$") + pub currency: Option, + /// Whether this model supports cache control + pub supports_cache_control: Option, + /// Whether this model supports reasoning/thinking controls + #[serde(default)] + pub reasoning: bool, } -pub struct StreamingRequest { - pub model: Model, +impl ModelInfo { + /// Create a new ModelInfo with just name and context limit + pub fn new(name: impl Into, context_limit: usize) -> Self { + Self { + name: name.into(), + resolved_model: None, + context_limit, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + } + } + + /// Create a new ModelInfo with cost information (per token) + pub fn with_cost( + name: impl Into, + context_limit: usize, + input_cost: f64, + output_cost: f64, + ) -> Self { + Self { + name: name.into(), + resolved_model: None, + context_limit, + input_token_cost: Some(input_cost), + output_token_cost: Some(output_cost), + currency: Some("$".to_string()), + supports_cache_control: None, + reasoning: false, + } + } +} + +/// A message stream yields partial text content but complete tool calls, all within the Message object +/// So a message with text will contain potentially just a word of a longer response, but tool calls +/// messages will only be yielded once concatenated. +pub type MessageStream = Pin< + Box, Option), ProviderError>> + Send>, +>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PermissionRouting { + ActionRequired, + Noop, +} + +pub fn model_info_for_provider_model(provider_name: &str, model_name: &str) -> ModelInfo { + let registry = CanonicalModelRegistry::bundled().ok(); + let canonical = registry.as_ref().and_then(|registry| { + let canonical_id = map_to_canonical_model(provider_name, model_name, registry)?; + let (provider, model) = canonical_id.split_once('/')?; + registry.get(provider, model) + }); + + let reasoning = canonical + .as_ref() + .and_then(|model| model.reasoning) + .unwrap_or_else(|| ModelConfig::new_or_fail(model_name).is_reasoning_model()); + + ModelInfo { + name: model_name.to_string(), + resolved_model: None, + context_limit: ModelConfig::new_or_fail(model_name) + .with_canonical_limits(provider_name) + .context_limit(), + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning, + } } -pub struct StreamingResponse; +/// Collect all chunks from a MessageStream into a single Message and ProviderUsage +pub async fn collect_stream( + mut stream: MessageStream, +) -> Result<(Message, ProviderUsage), ProviderError> { + use futures::StreamExt; + + let mut final_message: Option = None; + let mut final_usage: Option = None; + + while let Some(result) = stream.next().await { + let (msg_opt, usage_opt) = result?; + + if let Some(msg) = msg_opt { + final_message = Some(match final_message { + Some(mut prev) => { + for new_content in msg.content { + match (&mut prev.content.last_mut(), &new_content) { + // Coalesce consecutive text blocks + ( + Some(MessageContent::Text(last_text)), + MessageContent::Text(new_text), + ) => { + last_text.text.push_str(&new_text.text); + } + _ => { + prev.content.push(new_content); + } + } + } + prev + } + None => msg, + }); + } + + if let Some(usage) = usage_opt { + final_usage = Some(usage); + } + } + + match final_message { + Some(msg) => { + let usage = final_usage + .unwrap_or_else(|| ProviderUsage::new("unknown".to_string(), Usage::default())); + Ok((msg, usage)) + } + None => Err(ProviderError::ExecutionError( + "Stream yielded no message".to_string(), + )), + } +} + +pub fn stream_from_single_message(message: Message, usage: ProviderUsage) -> MessageStream { + let stream = futures::stream::once(async move { Ok((Some(message), Some(usage))) }); + Box::pin(stream) +} + +/// Base trait for AI providers (OpenAI, Anthropic, etc) +#[async_trait] +pub trait Provider: Send + Sync { + /// Get the name of this provider instance + fn get_name(&self) -> &str; + + /// Primary streaming method that all providers must implement. + /// + /// Note: Do not add `#[instrument]` here — the call sites (`complete` and + /// `stream_response_from_provider`) create the telemetry span so that + /// `session.id` is set once rather than in every provider. + async fn stream( + &self, + model_config: &ModelConfig, + session_id: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result; + + /// Complete with a specific model config. + #[tracing::instrument( + skip(self, model_config, session_id, system, messages, tools), + fields(session.id = %session_id, gen_ai.request.model = %model_config.model_name) + )] + async fn complete( + &self, + model_config: &ModelConfig, + session_id: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let stream = self + .stream(model_config, session_id, system, messages, tools) + .await?; + collect_stream(stream).await + } + + /// Try fast model first, fall back to regular model on failure. + async fn complete_fast( + &self, + session_id: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let model_config = self.get_model_config(); + let fast_config = model_config.use_fast_model(); + + let result = self + .complete(&fast_config, session_id, system, messages, tools) + .await; + + match result { + Ok(response) => Ok(response), + Err(e) => { + if fast_config.model_name != model_config.model_name { + tracing::warn!( + "Fast model {} failed with error: {}. Falling back to regular model {}", + fast_config.model_name, + e, + model_config.model_name + ); + self.complete(&model_config, session_id, system, messages, tools) + .await + } else { + Err(e) + } + } + } + } + + /// Get the model config from the provider + fn get_model_config(&self) -> ModelConfig; + + fn retry_config(&self) -> RetryConfig { + RetryConfig::default() + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + Ok(vec![]) + } + + async fn fetch_supported_model_info(&self) -> Result, ProviderError> { + Ok(self + .fetch_supported_models() + .await? + .iter() + .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) + .collect()) + } + + async fn fetch_model_info(&self, model_name: &str) -> Result { + Ok(model_info_for_provider_model(self.get_name(), model_name)) + } + + fn skip_canonical_filtering(&self) -> bool { + false + } + + /// Fetch inventory models filtered by canonical registry and usability. + async fn fetch_recommended_models(&self) -> Result, ProviderError> { + let all_models = self.fetch_supported_models().await?; + + if self.skip_canonical_filtering() { + return Ok(all_models); + } + + let registry = CanonicalModelRegistry::bundled().map_err(|e| { + ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) + })?; + + let provider_name = self.get_name(); + + // Get all text-capable models with their release dates + let mut models_with_dates: Vec<(String, Option)> = all_models + .iter() + .filter_map(|model| { + let canonical_id = map_to_canonical_model(provider_name, model, registry)?; + + let (provider, model_name) = canonical_id.split_once('/')?; + let canonical_model = registry.get(provider, model_name)?; + + if !canonical_model + .modalities + .input + .contains(&crate::canonical::Modality::Text) + { + return None; + } + + if !canonical_model.tool_call && !self.get_model_config().toolshim { + return None; + } + + let release_date = canonical_model.release_date.clone(); + + Some((model.clone(), release_date)) + }) + .collect(); + + // Sort by release date (most recent first), then alphabetically for models without dates + models_with_dates.sort_by(|a, b| match (&a.1, &b.1) { + (Some(date_a), Some(date_b)) => date_b.cmp(date_a), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.0.cmp(&b.0), + }); + + let inventory_models: Vec = models_with_dates + .into_iter() + .map(|(name, _)| name) + .collect(); + + if inventory_models.is_empty() { + Ok(all_models) + } else { + Ok(inventory_models) + } + } + + async fn fetch_recommended_model_info(&self) -> Result, ProviderError> { + Ok(self + .fetch_recommended_models() + .await? + .iter() + .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) + .collect()) + } + + async fn map_to_canonical_model( + &self, + provider_model: &str, + ) -> Result, ProviderError> { + let registry = CanonicalModelRegistry::bundled().map_err(|e| { + ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) + })?; + + Ok(map_to_canonical_model( + self.get_name(), + provider_model, + registry, + )) + } + + /// Whether the provider manages its own conversation context (e.g. CLI + /// wrappers like Claude Code or Gemini CLI). When true, goose-side + /// context management such as tool-pair summarization is skipped because + /// the provider's internal state is the source of truth. + fn manages_own_context(&self) -> bool { + false + } + + async fn supports_cache_control(&self) -> bool { + false + } + + /// Configure OAuth authentication for this provider + /// + /// This method is called when a provider has configuration keys marked with oauth_flow = true. + /// Providers that support OAuth should override this method to implement their specific OAuth flow. + /// + /// # Returns + /// * `Ok(())` if OAuth configuration succeeds and credentials are saved + /// * `Err(ProviderError)` if OAuth fails or is not supported by this provider + /// + /// # Default Implementation + /// The default implementation returns an error indicating OAuth is not supported. + async fn configure_oauth(&self) -> Result<(), ProviderError> { + Err(ProviderError::ExecutionError( + "OAuth configuration not supported by this provider".to_string(), + )) + } + + async fn refresh_credentials(&self) -> Result<(), ProviderError> { + Err(ProviderError::NotImplemented( + "credential refresh not supported by this provider".to_string(), + )) + } + + async fn update_mode(&self, _session_id: &str, _mode: GooseMode) -> Result<(), ProviderError> { + Ok(()) + } + + fn permission_routing(&self) -> PermissionRouting { + PermissionRouting::Noop + } + + async fn handle_permission_confirmation( + &self, + _request_id: &str, + _confirmation: &PermissionConfirmation, + ) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + fn content_from_str(s: String) -> MessageContent { + if let Some(img_data) = s.strip_prefix("*img:") { + MessageContent::image(format!("http://example.com/{}", img_data), "image/png") + } else if let Some(tool_name) = s.strip_prefix("*tool:") { + let tool_call = Ok( + rmcp::model::CallToolRequestParams::new(tool_name.to_string()) + .with_arguments(serde_json::Map::new()), + ); + MessageContent::tool_request(format!("tool_{}", tool_name), tool_call) + } else { + MessageContent::text(s) + } + } + + fn create_test_stream( + items: Vec, + ) -> impl Stream, Option), ProviderError>> { + use futures::stream; + stream::iter(items.into_iter().map(|item| { + let content = content_from_str(item); + let message = Message::new( + rmcp::model::Role::Assistant, + chrono::Utc::now().timestamp(), + vec![content], + ); + Ok((Some(message), None)) + })) + } + + fn content_to_strings(msg: &Message) -> Vec { + msg.content + .iter() + .map(|c| match c { + MessageContent::Text(t) => t.text.clone(), + MessageContent::Image(_) => "*img".to_string(), + MessageContent::ToolRequest(tr) => { + if let Ok(call) = &tr.tool_call { + format!("*tool:{}", call.name) + } else { + "*tool:error".to_string() + } + } + _ => "*other".to_string(), + }) + .collect() + } + + #[test_case( + vec!["Hello", " ", "world"], + vec!["Hello world"] + ; "consecutive text coalesces" + )] + #[test_case( + vec!["Hello", "*img:pic1", "world"], + vec!["Hello", "*img", "world"] + ; "non-text breaks coalescing" + )] + #[test_case( + vec!["A", "B", "*img:pic1", "C", "D", "*tool:read", "E", "F"], + vec!["AB", "*img", "CD", "*tool:read", "EF"] + ; "multiple text groups" + )] + #[test_case( + vec!["Text1", "*img:pic", "Text2"], + vec!["Text1", "*img", "Text2"] + ; "mixed content in chunk" + )] + #[tokio::test] + async fn test_collect_stream_coalescing(input_items: Vec<&str>, expected: Vec<&str>) { + let items: Vec = input_items.into_iter().map(|s| s.to_string()).collect(); + let stream = create_test_stream(items); + let (msg, _) = collect_stream(Box::pin(stream)).await.unwrap(); + assert_eq!(content_to_strings(&msg), expected); + } + + #[tokio::test] + async fn test_collect_stream_defaults_usage() { + let stream = create_test_stream(vec!["Hello".to_string()]); + let (msg, usage) = collect_stream(Box::pin(stream)).await.unwrap(); + assert_eq!(content_to_strings(&msg), vec!["Hello"]); + assert_eq!(usage.model, "unknown"); + } + + #[test] + fn test_model_info_creation() { + // Test direct ModelInfo creation + let info = ModelInfo { + name: "test-model".to_string(), + resolved_model: None, + context_limit: 1000, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + }; + assert_eq!(info.context_limit, 1000); + + // Test equality + let info2 = ModelInfo { + name: "test-model".to_string(), + resolved_model: None, + context_limit: 1000, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + }; + assert_eq!(info, info2); + + // Test inequality + let info3 = ModelInfo { + name: "test-model".to_string(), + resolved_model: None, + context_limit: 2000, + input_token_cost: None, + output_token_cost: None, + currency: None, + supports_cache_control: None, + reasoning: false, + }; + assert_ne!(info, info3); + } -pub trait Provider { - fn stream(req: StreamingRequest) -> impl Future>; + #[test] + fn test_model_info_with_cost() { + let info = ModelInfo::with_cost("gpt-4o", 128000, 0.0000025, 0.00001); + assert_eq!(info.name, "gpt-4o"); + assert_eq!(info.context_limit, 128000); + assert_eq!(info.input_token_cost, Some(0.0000025)); + assert_eq!(info.output_token_cost, Some(0.00001)); + assert_eq!(info.currency, Some("$".to_string())); + } } diff --git a/crates/goose-providers/src/canonical/catalog.rs b/crates/goose-providers/src/canonical/catalog.rs index d7e3617d91a7..e63dfb23d6b0 100644 --- a/crates/goose-providers/src/canonical/catalog.rs +++ b/crates/goose-providers/src/canonical/catalog.rs @@ -693,6 +693,12 @@ const SETUP_METADATA: &[CuratedSetupMetadata] = &[ placeholder: Some("Paste your API key"), default_value: None, }, + CuratedFieldMetadata { + key: "AZURE_OPENAI_AD_TOKEN", + label: "Entra ID Token", + placeholder: Some("Optional: short-lived Microsoft Entra access token"), + default_value: None, + }, ], }, CuratedSetupMetadata { diff --git a/crates/goose-providers/src/canonical/data/canonical_models.json b/crates/goose-providers/src/canonical/data/canonical_models.json index 8d18a7b63c45..03ec380e34c7 100644 --- a/crates/goose-providers/src/canonical/data/canonical_models.json +++ b/crates/goose-providers/src/canonical/data/canonical_models.json @@ -9330,6 +9330,520 @@ "output": 65536 } }, + { + "id": "alibaba-coding-plan/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "alibaba-token-plan-cn/MiniMax-M2.5", + "name": "MiniMax-M2.5", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-12", + "last_updated": "2026-02-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 196608, + "output": 24576 + } + }, + { + "id": "alibaba-token-plan-cn/deepseek-v3.2", + "name": "DeepSeek V3.2", + "family": "deepseek", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-12-03", + "last_updated": "2025-12-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 131072, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "alibaba-token-plan-cn/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "alibaba-token-plan-cn/glm-5", + "name": "GLM-5", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-11", + "last_updated": "2026-02-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 202752, + "output": 16384 + } + }, + { + "id": "alibaba-token-plan-cn/glm-5.1", + "name": "GLM-5.1", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-27", + "last_updated": "2026-03-27", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 202752, + "output": 128000 + } + }, + { + "id": "alibaba-token-plan-cn/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "alibaba-token-plan-cn/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "alibaba-token-plan-cn/qwen-image-2.0", + "name": "Qwen Image 2.0", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan-cn/qwen-image-2.0-pro", + "name": "Qwen Image 2.0 Pro", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan-cn/qwen3.6-flash", + "name": "Qwen3.6 Flash", + "family": "qwen3.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-27", + "last_updated": "2026-04-27", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/qwen3.6-plus", + "name": "Qwen3.6 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/qwen3.7-max", + "name": "Qwen3.7 Max", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "alibaba-token-plan-cn/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "alibaba-token-plan-cn/wan2.7-image", + "name": "Wan2.7 Image", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, + { + "id": "alibaba-token-plan-cn/wan2.7-image-pro", + "name": "Wan2.7 Image Pro", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 0 + } + }, { "id": "alibaba-token-plan/MiniMax-M2.5", "name": "MiniMax-M2.5", @@ -9726,6 +10240,38 @@ "output": 65536 } }, + { + "id": "alibaba-token-plan/qwen3.7-plus", + "name": "Qwen3.7 Plus", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "alibaba-token-plan/wan2.7-image", "name": "Wan2.7 Image", @@ -22768,7 +23314,7 @@ "tool_call": true, "temperature": true, "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "last_updated": "2026-06-10", "modalities": { "input": [ "text" @@ -22779,52 +23325,23 @@ }, "open_weights": true, "cost": { - "input": 0.25, - "output": 0.69 + "input": 0.35, + "output": 0.75 }, "limit": { "context": 131072, - "output": 32768 - } - }, - { - "id": "cerebras/llama3.1-8b", - "name": "Llama 3.1 8B", - "family": "llama", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-12", - "release_date": "2025-01-01", - "last_updated": "2026-05-27", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 32000, - "output": 8000 + "output": 40960 } }, { "id": "cerebras/zai-glm-4.7", "name": "Z.AI GLM-4.7", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-01-10", - "last_updated": "2026-01-10", + "release_date": "2026-01-07", + "last_updated": "2026-06-10", "modalities": { "input": [ "text" @@ -22842,7 +23359,7 @@ }, "limit": { "context": 131072, - "output": 40000 + "output": 40960 } }, { @@ -30698,13 +31215,13 @@ }, "open_weights": true, "cost": { - "input": 0.27, - "output": 0.95, + "input": 0.15, + "output": 1.15, "cache_read": 0.03, "cache_write": 0.375 }, "limit": { - "context": 204800, + "context": 196608, "output": 131072 } }, @@ -30730,7 +31247,7 @@ "open_weights": true, "cost": { "input": 0.3, - "output": 1.2 + "output": 1.0 }, "limit": { "context": 262144, @@ -30760,8 +31277,9 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.95 + "input": 0.14, + "output": 1.0, + "cache_read": 0.05 }, "limit": { "context": 262144, @@ -30791,8 +31309,9 @@ }, "open_weights": true, "cost": { - "input": 0.54, - "output": 3.4 + "input": 0.45, + "output": 3.0, + "cache_read": 0.22 }, "limit": { "context": 262144, @@ -30821,8 +31340,8 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 1.0 + "input": 0.15, + "output": 0.95 }, "limit": { "context": 262144, @@ -30897,7 +31416,7 @@ "name": "DeepSeek-R1-0528", "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, "knowledge": "2024-07", "release_date": "2025-05-28", @@ -31101,7 +31620,7 @@ "family": "llama", "attachment": false, "reasoning": false, - "tool_call": true, + "tool_call": false, "release_date": "2025-04-05", "last_updated": "2025-04-05", "modalities": { @@ -31119,7 +31638,7 @@ "output": 0.6 }, "limit": { - "context": 1000000, + "context": 1048576, "output": 16384 } }, @@ -31143,11 +31662,11 @@ }, "open_weights": true, "cost": { - "input": 0.08, + "input": 0.1, "output": 0.3 }, "limit": { - "context": 10000000, + "context": 327680, "output": 16384 } }, @@ -31174,8 +31693,9 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.8 + "input": 0.45, + "output": 2.25, + "cache_read": 0.07 }, "limit": { "context": 262144, @@ -31234,8 +31754,8 @@ }, "open_weights": true, "cost": { - "input": 0.05, - "output": 0.24 + "input": 0.039, + "output": 0.19 }, "limit": { "context": 131072, @@ -31296,7 +31816,7 @@ "cache_read": 0.08 }, "limit": { - "context": 204800, + "context": 202752, "output": 131072 } }, @@ -31321,7 +31841,7 @@ }, "open_weights": true, "cost": { - "input": 0.43, + "input": 0.4, "output": 1.75, "cache_read": 0.08 }, @@ -31380,9 +31900,9 @@ }, "open_weights": true, "cost": { - "input": 0.8, - "output": 2.56, - "cache_read": 0.16 + "input": 0.6, + "output": 2.08, + "cache_read": 0.12 }, "limit": { "context": 202752, @@ -31410,9 +31930,9 @@ }, "open_weights": true, "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 + "input": 1.05, + "output": 3.5, + "cache_read": 0.205 }, "limit": { "context": 202752, @@ -31853,6 +32373,32 @@ "output": 64000 } }, + { + "id": "digitalocean/anthropic-claude-fable-5", + "name": "Anthropic Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-09", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "digitalocean/anthropic-claude-haiku-4.5", "name": "Claude Haiku 4.5", @@ -32882,6 +33428,31 @@ "output": 65536 } }, + { + "id": "digitalocean/nemotron-3-ultra-550b", + "name": "Nemotron 3 Ultra", + "family": "nemotron", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 131072, + "output": 8192 + } + }, { "id": "digitalocean/nemotron-nano-12b-v2-vl", "name": "Nemotron Nano 12B v2 VL", @@ -37525,6 +38096,39 @@ "output": 8192 } }, + { + "id": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 10.0, + "output": 50.0, + "cache_read": 1.0, + "cache_write": 12.5 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "github-copilot/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -88692,7 +89296,7 @@ "name": "Qwen3 235B A22B Instruct 2507", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-07", @@ -89045,36 +89649,6 @@ "output": 384000 } }, - { - "id": "nebius/google/gemma-2-2b-it", - "name": "Gemma-2-2b-it", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "knowledge": "2024-06", - "release_date": "2024-07-31", - "last_updated": "2026-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.06, - "cache_read": 0.002, - "cache_write": 0.025 - }, - "limit": { - "context": 8192, - "output": 4096 - } - }, { "id": "nebius/google/gemma-3-27b-it", "name": "Gemma-3-27b-it", @@ -89136,36 +89710,6 @@ "output": 8192 } }, - { - "id": "nebius/meta-llama/Meta-Llama-3.1-8B-Instruct", - "name": "Meta-Llama-3.1-8B-Instruct", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-12", - "release_date": "2024-07-23", - "last_updated": "2026-02-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.06, - "cache_read": 0.002, - "cache_write": 0.025 - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, { "id": "nebius/moonshotai/Kimi-K2.5", "name": "Kimi-K2.5", @@ -89442,6 +89986,811 @@ "output": 16384 } }, + { + "id": "neon/claude-haiku-4.5", + "name": "Claude Haiku 4.5 (latest)", + "family": "claude-haiku", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "neon/claude-opus-4.1", + "name": "Claude Opus 4.1 (latest)", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03-31", + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 15.0, + "output": 75.0, + "cache_read": 1.5, + "cache_write": 18.75 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "neon/claude-opus-4.5", + "name": "Claude Opus 4.5 (latest)", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03-31", + "release_date": "2025-11-24", + "last_updated": "2025-11-24", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "neon/claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05-31", + "release_date": "2026-02-05", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "neon/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "neon/claude-sonnet-4", + "name": "Claude Sonnet 4.5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-07-31", + "release_date": "2025-09-29", + "last_updated": "2025-09-29", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "neon/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (latest)", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-07-31", + "release_date": "2025-09-29", + "last_updated": "2025-09-29", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "neon/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "neon/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-03-20", + "last_updated": "2025-06-05", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-03-20", + "last_updated": "2025-06-05", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3-flash", + "name": "Gemini 3 Flash Preview", + "family": "gemini-flash", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-12-17", + "last_updated": "2025-12-17", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 3.0, + "cache_read": 0.05 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3-pro", + "name": "Gemini 3 Pro Preview", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2025-11-18", + "last_updated": "2025-11-18", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite Preview", + "family": "gemini-flash-lite", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gemini-3.1-pro", + "name": "Gemini 3.1 Pro Preview Custom Tools", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-19", + "last_updated": "2026-02-19", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 12.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "neon/gpt-5", + "name": "GPT-5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5-mini", + "name": "GPT-5 Mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 2.0, + "cache_read": 0.025 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5-nano", + "name": "GPT-5 Nano", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.4, + "cache_read": 0.005 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.1", + "name": "GPT-5.1", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-11-13", + "last_updated": "2025-11-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.2", + "name": "GPT-5.2", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.4", + "name": "GPT-5.4", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "neon/gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "neon/gpt-oss-120b", + "name": "GPT OSS 120B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.072, + "output": 0.28 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "neon/gpt-oss-20b", + "name": "GPT OSS 20B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.2 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, { "id": "neuralwatt/MiniMaxAI/MiniMax-M2.5", "name": "MiniMax M2.5", @@ -98040,7 +99389,7 @@ }, { "id": "opencode-go/minimax-m3", - "name": "MiniMax M3", + "name": "MiniMax M3 (3x usage)", "family": "minimax-m3", "attachment": false, "reasoning": true, @@ -98061,9 +99410,9 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.06 + "input": 0.1, + "output": 0.4, + "cache_read": 0.02 }, "limit": { "context": 512000, @@ -101861,12 +103210,12 @@ }, "open_weights": true, "cost": { - "input": 0.7, + "input": 0.8, "output": 0.8 }, "limit": { - "context": 131072, - "output": 16384 + "context": 8192, + "output": 8192 } }, { @@ -102821,12 +104170,12 @@ "open_weights": true, "cost": { "input": 0.12, - "output": 0.36, + "output": 0.35, "cache_read": 0.09 }, "limit": { - "context": 256000, - "output": 8192 + "context": 262144, + "output": 262144 } }, { @@ -104011,12 +105360,12 @@ }, "open_weights": true, "cost": { - "input": 0.27, - "output": 1.08, - "cache_read": 0.054 + "input": 0.25, + "output": 1.0, + "cache_read": 0.05 }, "limit": { - "context": 204800, + "context": 196608, "output": 131072 } }, @@ -104681,13 +106030,13 @@ }, "open_weights": true, "cost": { - "input": 0.68, - "output": 3.41, - "cache_read": 0.34 + "input": 0.67, + "output": 3.39, + "cache_read": 0.14 }, "limit": { - "context": 262142, - "output": 262142 + "context": 262144, + "output": 262144 } }, { @@ -105206,34 +106555,6 @@ "output": 128000 } }, - { - "id": "openrouter/nvidia/nemotron-nano-9b-v2", - "name": "Nemotron Nano 9B v2", - "family": "nemotron", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-08-18", - "last_updated": "2025-08-18", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.04, - "output": 0.16 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, { "id": "openrouter/nvidia/nemotron-nano-9b-v2:free", "name": "Nemotron Nano 9B V2 (free)", @@ -108683,12 +110004,13 @@ }, "open_weights": true, "cost": { - "input": 0.14, - "output": 1.0 + "input": 0.15, + "output": 1.0, + "cache_read": 0.05 }, "limit": { - "context": 262140, - "output": 262140 + "context": 262144, + "output": 262144 } }, { @@ -108836,10 +110158,10 @@ }, "open_weights": false, "cost": { - "input": 0.4, - "output": 1.6, - "cache_read": 0.08, - "cache_write": 0.5 + "input": 0.32, + "output": 1.28, + "cache_read": 0.064, + "cache_write": 0.4 }, "limit": { "context": 1000000, @@ -110154,13 +111476,13 @@ }, "open_weights": false, "cost": { - "input": 0.68, - "output": 3.41, - "cache_read": 0.34 + "input": 0.67, + "output": 3.39, + "cache_read": 0.14 }, "limit": { - "context": 262142, - "output": 262142 + "context": 262144, + "output": 262144 } }, { @@ -128503,6 +129825,34 @@ "output": 131072 } }, + { + "id": "snowflake-cortex/claude-fable-5", + "name": "Claude Fable 5", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "snowflake-cortex/claude-haiku-4.5", "name": "Claude Haiku 4.5 (latest)", @@ -128559,6 +129909,33 @@ "output": 128000 } }, + { + "id": "snowflake-cortex/claude-opus-4.8", + "name": "Claude Opus 4.8", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "snowflake-cortex/claude-sonnet-4.5", "name": "Claude Sonnet 4.5 (latest)", @@ -128615,6 +129992,89 @@ "output": 16384 } }, + { + "id": "snowflake-cortex/deepseek-r1", + "name": "DeepSeek-R1", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-07", + "release_date": "2025-01-20", + "last_updated": "2025-05-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 128000, + "output": 32768 + } + }, + { + "id": "snowflake-cortex/gemini-3.1-pro", + "name": "Gemini 3.1 Pro Preview", + "family": "gemini-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-19", + "last_updated": "2026-02-19", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1048576, + "output": 65536 + } + }, + { + "id": "snowflake-cortex/mistral-large2", + "name": "Mistral Large (latest)", + "family": "mistral-large", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-11", + "release_date": "2024-11-01", + "last_updated": "2025-12-02", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "snowflake-cortex/openai-gpt-4.1", "name": "GPT-4.1", @@ -128806,6 +130266,60 @@ "output": 128000 } }, + { + "id": "snowflake-cortex/openai-gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 1050000, + "output": 128000 + } + }, + { + "id": "snowflake-cortex/snowflake-llama3.3-70b", + "name": "Llama-3.3-70B-Instruct", + "family": "llama", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-12-06", + "last_updated": "2024-12-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 128000, + "output": 4096 + } + }, { "id": "stackit/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8", "name": "Qwen3-VL 235B", @@ -130796,6 +132310,34 @@ "output": 16000 } }, + { + "id": "togetherai/LiquidAI/LFM2-24B-A2B", + "name": "LFM2-24B-A2B", + "family": "liquid", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-02-25", + "last_updated": "2026-02-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.03, + "output": 0.12 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, { "id": "togetherai/MiniMaxAI/MiniMax-M2.5", "name": "MiniMax-M2.5", @@ -130854,12 +132396,40 @@ "output": 131072 } }, + { + "id": "togetherai/Qwen/Qwen2.5-7B-Instruct-Turbo", + "name": "Qwen 2.5 7B Instruct Turbo", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2024-09-19", + "last_updated": "2024-09-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 0.3 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, { "id": "togetherai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput", "name": "Qwen3 235B A22B Instruct 2507 FP8", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-07", @@ -130917,7 +132487,7 @@ "name": "Qwen3 Coder Next FP8", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2026-02-03", @@ -130970,6 +132540,35 @@ "output": 130000 } }, + { + "id": "togetherai/Qwen/Qwen3.5-9B", + "name": "Qwen3.5 9B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-03", + "last_updated": "2026-03-03", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.17, + "output": 0.25 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "togetherai/Qwen/Qwen3.6-Plus", "name": "Qwen3.6 Plus", @@ -131003,7 +132602,7 @@ "name": "Qwen3.7 Max", "family": "qwen", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "release_date": "2026-05-21", @@ -131026,6 +132625,34 @@ "output": 500000 } }, + { + "id": "togetherai/deepcogito/cogito-v2-1-671b", + "name": "Cogito v2.1 671B", + "family": "cogito", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2025-11-13", + "last_updated": "2025-11-13", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 1.25 + }, + "limit": { + "context": 163840, + "output": 163840 + } + }, { "id": "togetherai/deepseek-ai/DeepSeek-R1", "name": "DeepSeek-R1", @@ -131060,7 +132687,7 @@ "name": "DeepSeek-V3", "family": "deepseek", "attachment": false, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2024-07", @@ -131133,8 +132760,8 @@ }, "open_weights": true, "cost": { - "input": 2.1, - "output": 4.4, + "input": 1.74, + "output": 3.48, "cache_read": 0.2 }, "limit": { @@ -131171,6 +132798,34 @@ "output": 32768 } }, + { + "id": "togetherai/google/gemma-3n-E4B-it", + "name": "Gemma 3N E4B Instruct", + "family": "gemma", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-05-20", + "last_updated": "2025-05-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.06, + "output": 0.12 + }, + "limit": { + "context": 32768, + "output": 32768 + } + }, { "id": "togetherai/google/gemma-4-31B-it", "name": "Gemma 4 31B Instruct", @@ -131193,8 +132848,8 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.5 + "input": 0.39, + "output": 0.97 }, "limit": { "context": 262144, @@ -131230,6 +132885,34 @@ "output": 131072 } }, + { + "id": "togetherai/meta-llama/Meta-Llama-3-8B-Instruct-Lite", + "name": "Meta Llama 3 8B Instruct Lite", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2024-04-18", + "last_updated": "2024-04-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.14 + }, + "limit": { + "context": 8192, + "output": 8192 + } + }, { "id": "togetherai/moonshotai/Kimi-K2.5", "name": "Kimi K2.5", @@ -131350,6 +133033,90 @@ "output": 131072 } }, + { + "id": "togetherai/openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.2 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "togetherai/pearl-ai/gemma-4-31b-it", + "name": "Pearl AI Gemma 4 31B Instruct", + "family": "gemma", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.28, + "output": 0.86 + }, + "limit": { + "context": 32000, + "output": 32000 + } + }, + { + "id": "togetherai/zai-org/GLM-5", + "name": "GLM-5", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-11", + "last_updated": "2026-02-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.2 + }, + "limit": { + "context": 202752, + "output": 131072 + } + }, { "id": "togetherai/zai-org/GLM-5.1", "name": "GLM-5.1", @@ -133705,6 +135472,39 @@ "output": 4096 } }, + { + "id": "venice/xiaomi-mimo-v2-5", + "name": "MiMo-V2.5", + "family": "mimo", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-06-11", + "last_updated": "2026-06-11", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.175, + "output": 0.35, + "cache_read": 0.0625 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "venice/z-ai-glm-5-turbo", "name": "GLM 5 Turbo", @@ -136140,7 +137940,7 @@ "open_weights": false, "cost": {}, "limit": { - "context": 8192, + "context": 128000, "output": 1536 } }, @@ -138699,66 +140499,6 @@ "output": 262114 } }, - { - "id": "vercel/moonshotai/kimi-k2-thinking-turbo", - "name": "Kimi K2 Thinking Turbo", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-11-06", - "last_updated": "2025-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.15, - "output": 8.0, - "cache_read": 0.15 - }, - "limit": { - "context": 262114, - "output": 262114 - } - }, - { - "id": "vercel/moonshotai/kimi-k2-turbo", - "name": "Kimi K2 Turbo", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-09-05", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.15, - "output": 8.0, - "cache_read": 0.15 - }, - "limit": { - "context": 256000, - "output": 16384 - } - }, { "id": "vercel/moonshotai/kimi-k2.5", "name": "Kimi K2.5", @@ -145162,6 +146902,36 @@ "output": 131072 } }, + { + "id": "zeldoc/z-code", + "name": "Z-Code", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-15", + "last_updated": "2026-04-15", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "zenmux/anthropic/claude-3.5-haiku", "name": "Claude 3.5 Haiku", diff --git a/crates/goose-providers/src/canonical/data/provider_metadata.json b/crates/goose-providers/src/canonical/data/provider_metadata.json index 2b0213b6bc88..16135a6cf02f 100644 --- a/crates/goose-providers/src/canonical/data/provider_metadata.json +++ b/crates/goose-providers/src/canonical/data/provider_metadata.json @@ -63,7 +63,7 @@ "env": [ "ALIBABA_CODING_PLAN_API_KEY" ], - "model_count": 11 + "model_count": 12 }, { "id": "alibaba-coding-plan-cn", @@ -85,7 +85,18 @@ "env": [ "ALIBABA_TOKEN_PLAN_API_KEY" ], - "model_count": 15 + "model_count": 16 + }, + { + "id": "alibaba-token-plan-cn", + "display_name": "Alibaba Token Plan (China)", + "npm": "@ai-sdk/openai-compatible", + "api": "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "doc": "https://www.alibabacloud.com/help/zh/model-studio/token-plan-overview", + "env": [ + "ALIBABA_TOKEN_PLAN_API_KEY" + ], + "model_count": 16 }, { "id": "ambient", @@ -274,7 +285,7 @@ "env": [ "DIGITALOCEAN_ACCESS_TOKEN" ], - "model_count": 78 + "model_count": 80 }, { "id": "dinference", @@ -384,7 +395,7 @@ "env": [ "GITHUB_TOKEN" ], - "model_count": 22 + "model_count": 23 }, { "id": "github-models", @@ -758,7 +769,19 @@ "env": [ "NEBIUS_API_KEY" ], - "model_count": 31 + "model_count": 29 + }, + { + "id": "neon", + "display_name": "Neon", + "npm": "@ai-sdk/openai-compatible", + "api": "${NEON_AI_GATEWAY_BASE_URL}/ai-gateway/mlflow/v1", + "doc": "https://neon.com/docs", + "env": [ + "NEON_AI_GATEWAY_BASE_URL", + "NEON_AI_GATEWAY_TOKEN" + ], + "model_count": 25 }, { "id": "neuralwatt", @@ -1013,7 +1036,7 @@ "SNOWFLAKE_ACCOUNT", "SNOWFLAKE_CORTEX_PAT" ], - "model_count": 11 + "model_count": 18 }, { "id": "stackit", @@ -1246,6 +1269,17 @@ ], "model_count": 5 }, + { + "id": "zeldoc", + "display_name": "Zeldoc", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.zeldoc.ai/v1", + "doc": "https://docs.zeldoc.ai", + "env": [ + "ZELDOC_API_KEY" + ], + "model_count": 1 + }, { "id": "zenmux", "display_name": "ZenMux", diff --git a/crates/goose-providers/src/conversation/message.rs b/crates/goose-providers/src/conversation/message.rs index 5b0a1d047dc7..bf6d2d5cf95c 100644 --- a/crates/goose-providers/src/conversation/message.rs +++ b/crates/goose-providers/src/conversation/message.rs @@ -3,9 +3,9 @@ use crate::mcp_utils::extract_text_from_resource; use crate::utils::sanitize_unicode_tags; use chrono::Utc; use rmcp::model::{ - AnnotateAble, CallToolRequestParams, CallToolResult, Content, ImageContent, JsonObject, - PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, RawImageContent, - RawTextContent, Role, TextContent, + AnnotateAble, CallToolRequestParams, CallToolResult, Content, ElicitationAction, ImageContent, + JsonObject, PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, + RawImageContent, RawTextContent, Role, TextContent, }; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashSet; @@ -212,9 +212,16 @@ pub enum ActionRequiredData { ElicitationResponse { id: String, user_data: serde_json::Value, + #[serde(default = "default_elicitation_action")] + #[schema(value_type = String)] + action: ElicitationAction, }, } +fn default_elicitation_action() -> ElicitationAction { + ElicitationAction::Accept +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ActionRequired { @@ -478,11 +485,13 @@ impl MessageContent { pub fn action_required_elicitation_response>( id: S, user_data: serde_json::Value, + action: ElicitationAction, ) -> Self { MessageContent::ActionRequired(ActionRequired { data: ActionRequiredData::ElicitationResponse { id: id.into(), user_data, + action, }, }) } @@ -1087,13 +1096,15 @@ pub struct TokenState { #[cfg(test)] mod tests { - use crate::conversation::message::{Message, MessageContent, MessageMetadata}; + use crate::conversation::message::{ + ActionRequiredData, Message, MessageContent, MessageMetadata, + }; use crate::conversation::*; use rmcp::model::{ AnnotateAble, CallToolRequestParams, PromptMessage, PromptMessageContent, PromptMessageRole, RawEmbeddedResource, RawImageContent, ResourceContents, }; - use rmcp::model::{ErrorCode, ErrorData}; + use rmcp::model::{ElicitationAction, ErrorCode, ErrorData}; use rmcp::object; use serde_json::Value; @@ -1250,6 +1261,29 @@ mod tests { assert!(thinking.signature.is_empty()); } + #[test] + fn test_elicitation_response_defaults_action_to_accept() { + let action_required: ActionRequiredData = serde_json::from_value(serde_json::json!({ + "actionType": "elicitationResponse", + "id": "request-123", + "user_data": { "name": "goose" } + })) + .unwrap(); + + let ActionRequiredData::ElicitationResponse { + id, + user_data, + action, + } = action_required + else { + panic!("Expected elicitation response"); + }; + + assert_eq!(id, "request-123"); + assert_eq!(user_data, serde_json::json!({ "name": "goose" })); + assert_eq!(action, ElicitationAction::Accept); + } + #[test] fn test_agent_visible_content_preserves_thinking_for_provider() { let message = Message::assistant() diff --git a/crates/goose-providers/src/conversation/token_usage.rs b/crates/goose-providers/src/conversation/token_usage.rs index b0419be385ea..7d481fc30daa 100644 --- a/crates/goose-providers/src/conversation/token_usage.rs +++ b/crates/goose-providers/src/conversation/token_usage.rs @@ -6,11 +6,40 @@ use serde::{Deserialize, Serialize}; pub struct ProviderUsage { pub model: String, pub usage: Usage, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stats: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProviderStats { + pub time_to_first_token_ms: Option, + pub elapsed_ms: Option, + pub output_tokens: Option, + pub draft: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DraftStats { + pub model: Option, + pub draft_tokens: usize, + pub accepted_tokens: usize, + pub target_tokens: usize, + pub rounds: usize, + pub accept_rate: f64, } impl ProviderUsage { pub fn new(model: String, usage: Usage) -> Self { - Self { model, usage } + Self { + model, + usage, + stats: None, + } + } + + pub fn with_stats(mut self, stats: ProviderStats) -> Self { + self.stats = Some(stats); + self } /// Combine this ProviderUsage with another, adding their token counts @@ -19,6 +48,7 @@ impl ProviderUsage { ProviderUsage { model: self.model.clone(), usage: self.usage + other.usage, + stats: self.stats.clone().or_else(|| other.stats.clone()), } } } diff --git a/crates/goose-providers/src/errors.rs b/crates/goose-providers/src/errors.rs index 839b7f4ce1c7..ed38de7e2542 100644 --- a/crates/goose-providers/src/errors.rs +++ b/crates/goose-providers/src/errors.rs @@ -2,6 +2,8 @@ use reqwest::StatusCode; use std::time::Duration; use thiserror::Error; +use crate::request_log::LogError; + #[derive(Error, Debug, Clone, PartialEq)] pub enum ProviderError { #[error("Authentication error: {0}")] @@ -142,6 +144,12 @@ impl From for ProviderError { } } +impl From for ProviderError { + fn from(value: LogError) -> Self { + ProviderError::ExecutionError(value.to_string()) + } +} + #[derive(Debug)] pub enum GoogleErrorCode { BadRequest = 400, diff --git a/crates/goose-providers/src/formats/openai.rs b/crates/goose-providers/src/formats/openai.rs index 3edfb50672da..836a01212283 100644 --- a/crates/goose-providers/src/formats/openai.rs +++ b/crates/goose-providers/src/formats/openai.rs @@ -4,6 +4,7 @@ use crate::errors::ProviderError; use crate::images::{convert_image, detect_image_path, load_image_file, ImageFormat}; use crate::json::safely_parse_json; use crate::mcp_utils::extract_text_from_resource; +use crate::model::ModelConfig; use crate::thinking::{ split_think_blocks, ThinkFilter, ThinkingEffort, GEMINI_THOUGHT_SIGNATURE_KEY, }; @@ -715,6 +716,12 @@ pub fn get_usage(usage: &Value) -> Usage { let cache_read_input_tokens = usage .get("cache_read_input_tokens") .and_then(|v| v.as_i64()) + .or_else(|| { + usage + .get("prompt_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_i64()) + }) .map(|v| v as i32); let cache_write_input_tokens = usage @@ -747,10 +754,7 @@ fn extract_usage_with_output_tokens( .model .as_deref() .or(fallback_model) - .map(|model| ProviderUsage { - usage: get_usage(u), - model: model.to_string(), - }) + .map(|model| ProviderUsage::new(model.to_string(), get_usage(u))) }) .filter(|u| u.usage.output_tokens.is_some()) } @@ -1223,7 +1227,7 @@ where } pub fn create_request( - model_config: ModelConfigParams, + model_config: &ModelConfig, system: &str, messages: &[Message], tools: &[Tool], @@ -1243,16 +1247,8 @@ pub fn create_request( ) } -pub struct ModelConfigParams<'a> { - pub model_name: &'a str, - pub thinking_effort: Option, - pub temperature: Option, - pub max_tokens: Option, - pub request_params: Option<&'a HashMap>, -} - pub fn create_request_with_options( - model_config: ModelConfigParams, + model_config: &ModelConfig, system: &str, messages: &[Message], tools: &[Tool], @@ -1266,11 +1262,11 @@ pub fn create_request_with_options( )); } - let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(model_config.model_name); + let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(&model_config.model_name); let is_reasoning_model = is_openai_responses_model(&model_name); let reasoning_effort = if is_reasoning_model { model_config - .thinking_effort + .thinking_effort() .map_or(legacy_reasoning_effort, |effort| { openai_reasoning_effort_for_thinking(&model_name, effort) }) @@ -1332,7 +1328,7 @@ pub fn create_request_with_options( payload["stream_options"] = json!({"include_usage": true}); } - if let Some(params) = model_config.request_params { + if let Some(params) = &model_config.request_params { if let Some(obj) = payload.as_object_mut() { for (key, value) in params { if key != "thinking_effort" && !is_reserved_request_param_key(key) { @@ -1455,6 +1451,13 @@ mod tests { use tokio::pin; use tokio_stream::{self, StreamExt}; + fn test_model_config(model_name: &str) -> ModelConfig { + ModelConfig { + model_name: model_name.to_string(), + ..Default::default() + } + } + #[test] fn test_validate_tool_schemas() { // Test case 1: Empty parameters object @@ -2138,15 +2141,9 @@ mod tests { #[test] fn test_create_request_gpt_4o() -> anyhow::Result<()> { // Test default medium reasoning effort for O3 model - let model_config = ModelConfigParams { - model_name: "gpt-4o", - thinking_effort: None, - temperature: None, - max_tokens: Some(1024), - request_params: None, - }; + let model_config = test_model_config("gpt-4o").with_max_tokens(Some(1024)); let request = create_request( - model_config, + &model_config, "system", &[], &[], @@ -2177,15 +2174,9 @@ mod tests { // Unknown models on OpenAI-compatible local providers (llama_swap, // lmstudio) have no canonical record and no GOOSE_MAX_TOKENS, so the // request must not pin the legacy 4096 default. See issue #9007. - let model_config = ModelConfigParams { - model_name: "some-unknown-local-model", - thinking_effort: None, - temperature: None, - max_tokens: None, - request_params: None, - }; + let model_config = test_model_config("some-unknown-local-model"); let request = create_request( - model_config, + &model_config, "system", &[], &[], @@ -2225,15 +2216,18 @@ mod tests { ("temperature".to_string(), json!(2.0)), ("provider_custom".to_string(), json!("allowed")), ]); - let model_config = ModelConfigParams { - model_name: "glm-4.7", - thinking_effort: None, - temperature: None, - max_tokens: Some(4096), - request_params: Some(¶ms), - }; + let model_config = test_model_config("glm-4.7") + .with_max_tokens(Some(4096)) + .with_merged_request_params(params); - let request = create_request(model_config, "system", &[], &[], &ImageFormat::OpenAi, true)?; + let request = create_request( + &model_config, + "system", + &[], + &[], + &ImageFormat::OpenAi, + true, + )?; assert_eq!( request["thinking"], @@ -2255,15 +2249,9 @@ mod tests { #[test] fn test_create_request_o1_default() -> anyhow::Result<()> { - let model_config = ModelConfigParams { - model_name: "o1", - thinking_effort: None, - temperature: None, - max_tokens: Some(1024), - request_params: None, - }; + let model_config = test_model_config("o1").with_max_tokens(Some(1024)); let request = create_request( - model_config, + &model_config, "system", &[], &[], @@ -2295,17 +2283,11 @@ mod tests { #[test] fn test_create_request_o1_medium_effort() -> anyhow::Result<()> { - let mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("medium")); - let model_config = ModelConfigParams { - model_name: "o1", - thinking_effort: Some(ThinkingEffort::Medium), - temperature: None, - max_tokens: Some(1024), - request_params: Some(¶ms), - }; + let model_config = test_model_config("o1") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::Medium); let request = create_request( - model_config, + &model_config, "system", &[], &[], @@ -2322,17 +2304,11 @@ mod tests { #[test] fn test_create_request_o3_off_effort_preserves_none() -> anyhow::Result<()> { - let mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("off")); - let model_config = ModelConfigParams { - model_name: "o3", - thinking_effort: Some(ThinkingEffort::Off), - temperature: None, - max_tokens: Some(1024), - request_params: Some(¶ms), - }; + let model_config = test_model_config("o3") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::Off); let request = create_request( - model_config, + &model_config, "system", &[], &[], @@ -2349,17 +2325,11 @@ mod tests { #[test] fn test_create_request_gpt5_pro_max_effort_uses_supported_level() -> anyhow::Result<()> { - let mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("max")); - let model_config = ModelConfigParams { - model_name: "gpt-5.2-pro-2025-12-11", - thinking_effort: Some(ThinkingEffort::Max), - temperature: None, - max_tokens: Some(1024), - request_params: Some(¶ms), - }; + let model_config = test_model_config("gpt-5.2-pro-2025-12-11") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::Max); let request = create_request( - model_config, + &model_config, "system", &[], &[], @@ -2376,17 +2346,11 @@ mod tests { #[test] fn test_create_request_o3_custom_reasoning_effort() -> anyhow::Result<()> { - let mut params = std::collections::HashMap::new(); - params.insert("thinking_effort".to_string(), json!("high")); - let model_config = ModelConfigParams { - model_name: "o3-mini", - thinking_effort: Some(ThinkingEffort::High), - temperature: None, - max_tokens: Some(1024), - request_params: Some(¶ms), - }; + let model_config = test_model_config("o3-mini") + .with_max_tokens(Some(1024)) + .with_thinking_effort(ThinkingEffort::High); let request = create_request( - model_config, + &model_config, "system", &[], &[], @@ -2478,31 +2442,17 @@ mod tests { } #[test] - fn test_get_usage_preserves_provider_totals_with_cache_fields() { - let usage = get_usage(&json!({ - "prompt_tokens": 120, - "completion_tokens": 30, - "total_tokens": 150, - "cache_read_input_tokens": 80, - "cache_creation_input_tokens": 20 - })); - - assert_eq!(usage.input_tokens, Some(120)); - assert_eq!(usage.output_tokens, Some(30)); - assert_eq!(usage.total_tokens, Some(150)); - assert_eq!(usage.cache_read_input_tokens, Some(80)); - assert_eq!(usage.cache_write_input_tokens, Some(20)); - } - - #[test] - fn test_get_usage_reads_nested_usage_object() { + fn test_get_usage_reads_nested_usage_with_cache_fields() { let usage = get_usage(&json!({ "id": "chatcmpl_test", + "object": "chat.completion", "usage": { "prompt_tokens": 84, "completion_tokens": 21, "total_tokens": 105, - "cache_read_input_tokens": 60, + "prompt_tokens_details": { + "cached_tokens": 60 + }, "cache_creation_input_tokens": 10 } })); @@ -3017,13 +2967,7 @@ data: [DONE]"#; #[test] fn test_create_request_preserves_reasoning_content_for_legacy_compat() -> anyhow::Result<()> { - let model_config = ModelConfigParams { - model_name: "deepseek-reasoner", - thinking_effort: None, - temperature: None, - max_tokens: Some(1024), - request_params: None, - }; + let model_config = test_model_config("deepseek-reasoner").with_max_tokens(Some(1024)); let message = Message::assistant() .with_content(MessageContent::thinking("preserve this", "")) .with_tool_request( @@ -3033,7 +2977,7 @@ data: [DONE]"#; ); let request = create_request( - model_config, + &model_config, "system", &[message], &[], diff --git a/crates/goose/src/config/goose_mode.rs b/crates/goose-providers/src/goose_mode.rs similarity index 100% rename from crates/goose/src/config/goose_mode.rs rename to crates/goose-providers/src/goose_mode.rs diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index ebbfbd210ee9..1f194862fcc3 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -33,28 +33,79 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value } } -/// Detect if a string contains a path to an image file pub fn detect_image_path(text: &str) -> Option<&str> { - // Basic image file extension check - let extensions = [".png", ".jpg", ".jpeg"]; + const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; + const MAX_PATH_LEN: usize = 4096; - // Find any word that ends with an image extension - for word in text.split_whitespace() { - if extensions + let mut best: Option<(usize, &str)> = None; + let mut from = 0; + while from < text.len() { + let Some(end) = EXTENSIONS .iter() - .any(|ext| word.to_lowercase().ends_with(ext)) - { - let path = Path::new(word); - // Check if it's an absolute path and file exists - if path.is_absolute() && path.is_file() { - // Verify it's actually an image file - if is_image_file(path) { - return Some(word); + .filter_map(|ext| find_ascii_ci(text, ext, from).map(|i| i + ext.len())) + .min() + else { + break; + }; + + let terminator = text.get(end..).and_then(|rest| rest.chars().next()); + let terminated = + terminator.is_none_or(|c| c == '/' || c.is_whitespace() || c == '"' || c == '\''); + + if terminated { + let mut floor = end.saturating_sub(MAX_PATH_LEN); + while floor < end && !text.is_char_boundary(floor) { + floor += 1; + } + if let Some(window) = text.get(floor..end) { + for (rel, _) in window.match_indices('/') { + let start = floor + rel; + let preceded_by_boundary = text + .get(..start) + .and_then(|prefix| prefix.chars().next_back()) + .is_none_or(|c| c.is_whitespace() || c == '"' || c == '\''); + if !preceded_by_boundary { + continue; + } + let Some(candidate) = text.get(start..end) else { + continue; + }; + let path = Path::new(candidate); + if path.is_absolute() && path.is_file() && is_image_file(path) { + // Keep the first referenced path, but allow a longer + // match anchored at the same start to extend it (a + // whitespace-terminated extension may be a prefix of a + // spaced filename ending in a later extension). + match best { + Some((best_start, _)) if start == best_start => { + best = Some((start, candidate)); + } + None => best = Some((start, candidate)), + Some(_) => {} + } + break; + } } } } + from = end; } - None + best.map(|(_, candidate)| candidate) +} + +/// Case-insensitive ASCII substring search returning a byte index into +/// `haystack` (no allocation, so the index stays valid for slicing). +fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option { + let (hb, nb) = (haystack.as_bytes(), needle.as_bytes()); + if nb.is_empty() || hb.len() < nb.len() || from > hb.len() - nb.len() { + return None; + } + (from..=hb.len() - nb.len()).find(|&i| { + hb[i..i + nb.len()] + .iter() + .zip(nb) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + }) } /// Check if a file is actually an image by examining its magic bytes @@ -164,6 +215,89 @@ mod tests { assert_eq!(detect_image_path(text), None); } + #[test] + fn test_detect_image_path_with_spaces() { + // Absolute path containing spaces (macOS screenshot style). + let temp_dir = tempfile::tempdir().unwrap(); + let png_path = temp_dir.path().join("Screen Shot 2026.png"); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + let text = format!("please describe {} for me", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); + + // Case-insensitive extension also matches. + let upper = temp_dir.path().join("Another Shot.PNG"); + std::fs::write(&upper, png_data).unwrap(); + let upper_str = upper.to_str().unwrap(); + let text = format!("see {}", upper_str); + assert_eq!(detect_image_path(&text), Some(upper_str)); + + // Quoted path with spaces: the closing quote terminates the candidate. + let text = format!("describe \"{}\" please", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); + let text = format!("describe '{}'", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); + + // A stray closing quote in prose must not act as a terminator for an + // unquoted path. + let text = format!("here {}\" trailing", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); + + // When a spaced filename contains an earlier image extension, prefer + // the longer existing candidate over the embedded prefix. + let edited = temp_dir.path().join("Screen Shot.png edited.jpg"); + std::fs::write(&edited, png_data).unwrap(); + let edited_str = edited.to_str().unwrap(); + let prefix = temp_dir.path().join("Screen Shot.png"); + std::fs::write(&prefix, png_data).unwrap(); + let text = format!("look at {}", edited_str); + assert_eq!(detect_image_path(&text), Some(edited_str)); + + // With multiple distinct images, the first referenced one wins even if + // a later one has a longer path. + let a = temp_dir.path().join("a.png"); + std::fs::write(&a, png_data).unwrap(); + let longer = temp_dir.path().join("much-longer.png"); + std::fs::write(&longer, png_data).unwrap(); + let text = format!( + "compare {} with {}", + a.to_str().unwrap(), + longer.to_str().unwrap() + ); + assert_eq!(detect_image_path(&text), Some(a.to_str().unwrap())); + } + + #[test] + fn test_detect_image_path_ignores_urls_and_longer_extensions() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + // A real image whose path is a suffix of a URL must not be extracted + // from that URL via the `://` separator. + let dir = temp_dir.path().to_str().unwrap().trim_start_matches('/'); + let png_path = temp_dir.path().join("photo.png"); + std::fs::write(&png_path, png_data).unwrap(); + let url = format!("https:/{}/photo.png", dir); + assert_eq!(detect_image_path(&url), None); + + // A backup file sharing the image extension prefix must not be + // truncated to the bare image path. + let real = temp_dir.path().join("shot.png"); + std::fs::write(&real, png_data).unwrap(); + let backup = format!("{}.backup", real.to_str().unwrap()); + assert_eq!(detect_image_path(&backup), None); + } + + #[test] + fn test_detect_image_path_ignores_extension_flood() { + // Many extension-like tokens but no real absolute path: must scan + // cheaply (bounded) and find nothing. + let text = "see foo.png and bar.jpg and baz.jpeg ".repeat(500); + assert_eq!(detect_image_path(&text), None); + } + #[test] fn test_load_image_file() { // Create a temporary PNG file with valid PNG magic numbers diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index 17a6d4e23a5d..41c62f7b1df0 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -1,10 +1,16 @@ +pub mod api_client; pub mod base; pub mod canonical; pub mod conversation; pub mod errors; pub mod formats; +pub mod goose_mode; pub mod images; pub mod json; pub(crate) mod mcp_utils; +pub mod model; +pub mod permission; +pub mod request_log; +pub mod retry; pub mod thinking; pub mod utils; diff --git a/crates/goose/src/model.rs b/crates/goose-providers/src/model.rs similarity index 62% rename from crates/goose/src/model.rs rename to crates/goose-providers/src/model.rs index 3c1c08a720c1..22159af5df08 100644 --- a/crates/goose/src/model.rs +++ b/crates/goose-providers/src/model.rs @@ -1,6 +1,5 @@ -use goose_providers::formats::openai::{extract_reasoning_effort, is_openai_responses_model}; -use goose_providers::thinking::ThinkingEffort; -use once_cell::sync::Lazy; +use crate::formats::openai::{extract_reasoning_effort, is_openai_responses_model}; +use crate::thinking::ThinkingEffort; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -10,33 +9,6 @@ use utoipa::ToSchema; pub const DEFAULT_CONTEXT_LIMIT: usize = 128_000; -#[derive(Debug, Clone, Deserialize)] -struct PredefinedModel { - name: String, - #[serde(default)] - context_limit: Option, - #[serde(default)] - request_params: Option>, -} - -fn get_predefined_models() -> Vec { - static PREDEFINED_MODELS: Lazy> = - Lazy::new(|| match std::env::var("GOOSE_PREDEFINED_MODELS") { - Ok(json_str) => serde_json::from_str(&json_str).unwrap_or_else(|e| { - tracing::warn!("Failed to parse GOOSE_PREDEFINED_MODELS: {}", e); - Vec::new() - }), - Err(_) => Vec::new(), - }); - PREDEFINED_MODELS.clone() -} - -fn find_predefined_model(model_name: &str) -> Option { - get_predefined_models() - .into_iter() - .find(|m| m.name == model_name) -} - #[derive(Error, Debug)] pub enum ConfigError { #[error("Environment variable '{0}' not found")] @@ -103,72 +75,16 @@ impl<'de> Deserialize<'de> for ModelConfig { } impl ModelConfig { - pub fn new(model_name: &str) -> Result { - Self::new_base(model_name.to_string(), None) - } - - pub fn new_with_context_env( - model_name: String, - provider_name: &str, - context_env_var: Option<&str>, - ) -> Result { - let config = Self::new_base(model_name, context_env_var)?; - Ok(config.with_canonical_limits(provider_name)) - } - - fn new_base(model_name: String, context_env_var: Option<&str>) -> Result { - // Check a provider-specific env var first (e.g. DATABRICKS_CONTEXT_LIMIT), - // then fall back to GOOSE_CONTEXT_LIMIT. Using Config::global().get_param() - // reads from both environment variables and config.yaml, so users can set - // `GOOSE_CONTEXT_LIMIT: 1000000` in config.yaml instead of exporting an - // env var. See #7839. - let context_limit = if let Some(env_var) = context_env_var { - if let Ok(val) = std::env::var(env_var) { - Some(Self::validate_context_limit(&val, env_var)?) - } else { - None - } - } else { - match crate::config::Config::global().get_param::("GOOSE_CONTEXT_LIMIT") { - Ok(limit) => { - if limit == 0 { - return Err(ConfigError::InvalidRange( - "GOOSE_CONTEXT_LIMIT".to_string(), - "must be greater than 0".to_string(), - )); - } - Some(limit) - } - Err(crate::config::ConfigError::NotFound(_)) => None, - Err(e) => { - return Err(ConfigError::InvalidValue( - "GOOSE_CONTEXT_LIMIT".to_string(), - String::new(), - e.to_string(), - )) - } - } - }; - - let max_tokens = Self::parse_max_tokens()?; - let temperature = Self::parse_temperature()?; - let toolshim = Self::parse_toolshim()?; - let toolshim_model = Self::parse_toolshim_model()?; - - // Pick up predefined model settings before legacy suffix normalization. - let predefined = find_predefined_model(&model_name); - let predefined_context_limit = predefined.as_ref().and_then(|pm| pm.context_limit); - let request_params = predefined.and_then(|pm| pm.request_params); - + pub fn new(model_name: impl AsRef) -> Result { let mut config = Self { - model_name, - context_limit: context_limit.or(predefined_context_limit), - temperature, - max_tokens, - toolshim, - toolshim_model, + model_name: model_name.as_ref().to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, fast_model_config: None, - request_params, + request_params: None, reasoning: None, }; config.normalize_effort_suffix(); @@ -176,25 +92,20 @@ impl ModelConfig { } pub fn with_canonical_limits(mut self, provider_name: &str) -> Self { - if let Some(pm) = find_predefined_model(&self.model_name) { - if self.context_limit.is_none() { - self.context_limit = pm.context_limit; - } - } - // Try canonical lookup with the full model name first, then fall back // to the name with reasoning-effort suffixes stripped (e.g. // "databricks-gpt-5.4-high" → "databricks-gpt-5.4"). let canonical = - crate::providers::canonical::maybe_get_canonical_model(provider_name, &self.model_name) - .or_else(|| { + crate::canonical::maybe_get_canonical_model(provider_name, &self.model_name).or_else( + || { let (base, _effort) = extract_reasoning_effort(&self.model_name); if base != self.model_name { - crate::providers::canonical::maybe_get_canonical_model(provider_name, &base) + crate::canonical::maybe_get_canonical_model(provider_name, &base) } else { None } - }); + }, + ); if let Some(canonical) = canonical { if self.context_limit.is_none() { @@ -215,108 +126,44 @@ impl ModelConfig { self } - fn validate_context_limit(val: &str, env_var: &str) -> Result { - let limit = val.parse::().map_err(|_| { - ConfigError::InvalidValue( - env_var.to_string(), - val.to_string(), - "must be a positive integer".to_string(), - ) - })?; - - if limit < 4 * 1024 { - return Err(ConfigError::InvalidRange( - env_var.to_string(), - "must be greater than 4K".to_string(), - )); + pub fn with_context_limit(mut self, limit: Option) -> Self { + if limit.is_some() { + self.context_limit = limit; } - - Ok(limit) + self } - fn parse_temperature() -> Result, ConfigError> { - if let Ok(val) = std::env::var("GOOSE_TEMPERATURE") { - let temp = val.parse::().map_err(|_| { - ConfigError::InvalidValue( - "GOOSE_TEMPERATURE".to_string(), - val.clone(), - "must be a valid number".to_string(), - ) - })?; - if temp < 0.0 { - return Err(ConfigError::InvalidRange( - "GOOSE_TEMPERATURE".to_string(), - val, - )); - } - Ok(Some(temp)) - } else { - Ok(None) - } + pub fn with_temperature(mut self, temp: Option) -> Self { + self.temperature = temp; + self } - fn parse_max_tokens() -> Result, ConfigError> { - match crate::config::Config::global().get_param::("GOOSE_MAX_TOKENS") { - Ok(tokens) => { - if tokens <= 0 { - return Err(ConfigError::InvalidRange( - "goose_max_tokens".to_string(), - "must be greater than 0".to_string(), - )); - } - Ok(Some(tokens)) - } - Err(crate::config::ConfigError::NotFound(_)) => Ok(None), - Err(e) => Err(ConfigError::InvalidValue( - "goose_max_tokens".to_string(), - String::new(), - e.to_string(), - )), - } + pub fn with_max_tokens(mut self, tokens: Option) -> Self { + self.max_tokens = tokens; + self } - fn parse_toolshim() -> Result { - if let Ok(val) = std::env::var("GOOSE_TOOLSHIM") { - match val.to_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Ok(true), - "0" | "false" | "no" | "off" => Ok(false), - _ => Err(ConfigError::InvalidValue( - "GOOSE_TOOLSHIM".to_string(), - val, - "must be one of: 1, true, yes, on, 0, false, no, off".to_string(), - )), - } - } else { - Ok(false) + pub fn with_default_context_limit(mut self, limit: Option) -> Self { + if self.context_limit.is_none() { + self.context_limit = limit; } - } - fn parse_toolshim_model() -> Result, ConfigError> { - match std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL") { - Ok(val) if val.trim().is_empty() => Err(ConfigError::InvalidValue( - "GOOSE_TOOLSHIM_OLLAMA_MODEL".to_string(), - val, - "cannot be empty if set".to_string(), - )), - Ok(val) => Ok(Some(val)), - Err(_) => Ok(None), + if let Some(fast_config) = self.fast_model_config.take() { + self.fast_model_config = Some(Box::new(fast_config.with_default_context_limit(limit))); } - } - pub fn with_context_limit(mut self, limit: Option) -> Self { - if limit.is_some() { - self.context_limit = limit; - } self } - pub fn with_temperature(mut self, temp: Option) -> Self { - self.temperature = temp; - self - } + pub fn with_default_max_tokens(mut self, tokens: Option) -> Self { + if self.max_tokens.is_none() { + self.max_tokens = tokens; + } + + if let Some(fast_config) = self.fast_model_config.take() { + self.fast_model_config = Some(Box::new(fast_config.with_default_max_tokens(tokens))); + } - pub fn with_max_tokens(mut self, tokens: Option) -> Self { - self.max_tokens = tokens; self } @@ -335,16 +182,16 @@ impl ModelConfig { fast_model_name: &str, provider_name: &str, ) -> Result { - let name = std::env::var("GOOSE_FAST_MODEL") - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| fast_model_name.to_string()); - let fast_config = ModelConfig::new(&name)?.with_canonical_limits(provider_name); + let fast_config = ModelConfig::new(fast_model_name)?.with_canonical_limits(provider_name); self.fast_model_config = Some(Box::new(fast_config)); Ok(self) } + pub fn with_fast_model_config(mut self, fast_model_config: ModelConfig) -> Self { + self.fast_model_config = Some(Box::new(fast_model_config)); + self + } + pub fn with_merged_request_params(mut self, params: HashMap) -> Self { match self.request_params.as_mut() { Some(existing) => { @@ -368,6 +215,21 @@ impl ModelConfig { self } + pub fn with_default_thinking_effort(mut self, effort: Option) -> Self { + if self.thinking_effort().is_none() { + if let Some(effort) = effort { + self = self.with_thinking_effort(effort); + } + } + + if let Some(fast_config) = self.fast_model_config.take() { + self.fast_model_config = + Some(Box::new(fast_config.with_default_thinking_effort(effort))); + } + + self + } + pub fn with_inherited_session_settings_from( mut self, previous: Option<&ModelConfig>, @@ -472,63 +334,18 @@ impl ModelConfig { } pub fn thinking_effort(&self) -> Option { - self.get_config_param::("thinking_effort", "GOOSE_THINKING_EFFORT") + self.request_param::("thinking_effort") .and_then(|s| s.parse::().ok()) - .or_else(Self::legacy_thinking_effort) - } - - fn legacy_thinking_effort() -> Option { - let config = crate::config::Config::global(); - - if let Ok(value) = config.get_param::("CLAUDE_THINKING_TYPE") { - if let Some(effort) = match value.to_lowercase().as_str() { - "adaptive" | "enabled" => Some(ThinkingEffort::High), - "disabled" => Some(ThinkingEffort::Off), - _ => None, - } { - return Some(effort); - } - } - - if let Ok(enabled) = config.get_param::("CLAUDE_THINKING_ENABLED") { - return Some(if enabled { - ThinkingEffort::High - } else { - ThinkingEffort::Off - }); - } - - if let Ok(value) = config.get_param::("GEMINI3_THINKING_LEVEL") { - if let Some(effort) = Self::legacy_gemini3_thinking_effort(&value) { - return Some(effort); - } - } - - None - } - - fn legacy_gemini3_thinking_effort(value: &str) -> Option { - match value.to_lowercase().as_str() { - "low" => Some(ThinkingEffort::Low), - "high" => Some(ThinkingEffort::High), - _ => None, - } } - pub fn get_config_param serde::Deserialize<'de>>( + pub fn request_param serde::Deserialize<'de>>( &self, request_key: &str, - config_key: &str, ) -> Option { self.request_params .as_ref() .and_then(|params| params.get(request_key)) .and_then(|v| serde_json::from_value(v.clone()).ok()) - .or_else(|| { - crate::config::Config::global() - .get_param::(config_key) - .ok() - }) } pub fn new_or_fail(model_name: &str) -> ModelConfig { @@ -541,105 +358,6 @@ impl ModelConfig { mod tests { use super::*; - #[test] - fn test_parse_max_tokens_valid() { - let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("4096"))]); - let result = ModelConfig::parse_max_tokens().unwrap(); - assert_eq!(result, Some(4096)); - } - - #[test] - fn test_parse_max_tokens_not_set() { - let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", None::<&str>)]); - let result = ModelConfig::parse_max_tokens().unwrap(); - assert_eq!(result, None); - } - - #[test] - fn test_parse_max_tokens_invalid_string() { - let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("not_a_number"))]); - let result = ModelConfig::parse_max_tokens(); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ConfigError::InvalidValue(..))); - } - - #[test] - fn test_parse_max_tokens_zero() { - let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("0"))]); - let result = ModelConfig::parse_max_tokens(); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ConfigError::InvalidRange(..))); - } - - #[test] - fn test_parse_max_tokens_negative() { - let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("-100"))]); - let result = ModelConfig::parse_max_tokens(); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ConfigError::InvalidRange(..))); - } - - #[test] - fn test_model_config_with_max_tokens_env() { - let _guard = env_lock::lock_env([ - ("GOOSE_MAX_TOKENS", Some("8192")), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let config = ModelConfig::new("test-model").unwrap(); - assert_eq!(config.max_tokens, Some(8192)); - } - - #[test] - fn test_model_config_without_max_tokens_env() { - let _guard = env_lock::lock_env([ - ("GOOSE_MAX_TOKENS", None::<&str>), - ("GOOSE_TEMPERATURE", None::<&str>), - ("GOOSE_CONTEXT_LIMIT", None::<&str>), - ("GOOSE_TOOLSHIM", None::<&str>), - ("GOOSE_TOOLSHIM_OLLAMA_MODEL", None::<&str>), - ]); - let config = ModelConfig::new("test-model").unwrap(); - assert_eq!(config.max_tokens, None); - } - - #[test] - fn test_get_config_param() { - let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); - - let mut params = HashMap::new(); - params.insert("thinking_effort".to_string(), serde_json::json!("low")); - - let config_with_params = ModelConfig { - model_name: "test".to_string(), - request_params: Some(params), - ..Default::default() - }; - - let config_without_params = ModelConfig { - request_params: None, - ..config_with_params.clone() - }; - - assert_eq!( - config_with_params - .get_config_param::("thinking_effort", "GOOSE_THINKING_EFFORT"), - Some("low".to_string()) - ); - assert_eq!( - config_without_params - .get_config_param::("thinking_effort", "GOOSE_THINKING_EFFORT"), - Some("high".to_string()) - ); - assert_eq!( - config_without_params - .get_config_param::("nonexistent", "NONEXISTENT_CONFIG_KEY"), - None - ); - } - #[test] fn test_deserialize_preserves_fast_model_config() { let config: ModelConfig = serde_json::from_value(serde_json::json!({ @@ -672,7 +390,6 @@ mod tests { #[test] fn from_request_params() { - let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]); let mut params = HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("medium")); let config = ModelConfig { @@ -683,29 +400,6 @@ mod tests { assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Medium)); } - #[test] - fn from_env_var() { - let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); - let config = ModelConfig { - model_name: "test".to_string(), - ..Default::default() - }; - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); - } - - #[test] - fn request_params_override_env() { - let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); - let mut params = HashMap::new(); - params.insert("thinking_effort".to_string(), serde_json::json!("low")); - let config = ModelConfig { - model_name: "test".to_string(), - request_params: Some(params), - ..Default::default() - }; - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::Low)); - } - #[test] fn with_thinking_effort_sets_request_param() { let config = ModelConfig { @@ -796,22 +490,6 @@ mod tests { assert!(config.request_params.is_none()); } - #[test] - fn does_not_materialize_env_thinking_effort() { - let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); - let previous = ModelConfig { - model_name: "previous".to_string(), - ..Default::default() - }; - let config = ModelConfig { - model_name: "next".to_string(), - ..Default::default() - } - .with_inherited_session_settings_from(Some(&previous), None); - - assert!(config.request_params.is_none()); - } - #[test] fn explicit_request_params_override_preserved_session_settings() { let previous = ModelConfig { @@ -843,60 +521,6 @@ mod tests { ); } - #[test] - fn legacy_claude_thinking_type_fallback() { - for value in ["enabled", "adaptive"] { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("CLAUDE_THINKING_TYPE", Some(value)), - ("CLAUDE_THINKING_ENABLED", None::<&str>), - ("GEMINI3_THINKING_LEVEL", None::<&str>), - ("ANTHROPIC_THINKING_BUDGET", None::<&str>), - ("CLAUDE_THINKING_BUDGET", None::<&str>), - ("GEMINI25_THINKING_BUDGET", None::<&str>), - ]); - let config = ModelConfig { - model_name: "test".to_string(), - ..Default::default() - }; - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); - } - } - - #[test] - fn legacy_gemini3_thinking_level_mapping() { - assert_eq!( - ModelConfig::legacy_gemini3_thinking_effort("low"), - Some(ThinkingEffort::Low) - ); - assert_eq!( - ModelConfig::legacy_gemini3_thinking_effort("high"), - Some(ThinkingEffort::High) - ); - assert_eq!(ModelConfig::legacy_gemini3_thinking_effort("auto"), None); - } - - #[test] - fn legacy_gemini3_thinking_level_fallback() { - let temp_dir = tempfile::tempdir().unwrap(); - let temp_root = temp_dir.path().to_string_lossy().to_string(); - let _guard = env_lock::lock_env([ - ("GOOSE_PATH_ROOT", Some(temp_root.as_str())), - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("CLAUDE_THINKING_TYPE", None::<&str>), - ("CLAUDE_THINKING_ENABLED", None::<&str>), - ("GEMINI3_THINKING_LEVEL", Some("high")), - ("ANTHROPIC_THINKING_BUDGET", None::<&str>), - ("CLAUDE_THINKING_BUDGET", None::<&str>), - ("GEMINI25_THINKING_BUDGET", None::<&str>), - ]); - let config = ModelConfig { - model_name: "gemini-3-pro".to_string(), - ..Default::default() - }; - assert_eq!(config.thinking_effort(), Some(ThinkingEffort::High)); - } - #[test] fn effort_suffix_stripped_from_model_name() { let _guard = env_lock::lock_env([ diff --git a/crates/goose/src/permission/permission_confirmation.rs b/crates/goose-providers/src/permission.rs similarity index 100% rename from crates/goose/src/permission/permission_confirmation.rs rename to crates/goose-providers/src/permission.rs diff --git a/crates/goose-providers/src/request_log.rs b/crates/goose-providers/src/request_log.rs new file mode 100644 index 000000000000..f3b9c53815de --- /dev/null +++ b/crates/goose-providers/src/request_log.rs @@ -0,0 +1,134 @@ +use std::{ + error::Error, + fmt::Display, + sync::{Arc, OnceLock}, +}; + +use serde::Serialize; +use serde_json::json; + +use crate::conversation::token_usage::Usage; + +type RequestLogError = Box; + +static LOGGER: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +pub struct LoggerAlreadyInstalled; + +impl Display for LoggerAlreadyInstalled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "request logger is already installed") + } +} + +impl Error for LoggerAlreadyInstalled {} + +pub fn install_logger(r: R) -> Result<(), LoggerAlreadyInstalled> { + LOGGER.set(Arc::new(r)).map_err(|_| LoggerAlreadyInstalled) +} + +pub trait RequestLogger: Send + Sync { + fn start(&self) -> Result, RequestLogError>; +} + +pub trait RequestLogHandle: Send { + fn write(&mut self, s: &str) -> Result<(), RequestLogError>; +} + +#[derive(Debug)] +pub enum LogError { + LoggerError(String), + SerializeError(serde_json::Error), +} + +impl Display for LogError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogError::LoggerError(msg) => write!(f, "{}", msg), + LogError::SerializeError(error) => write!(f, "serialize error: {}", error), + } + } +} + +impl Error for LogError {} + +impl From for LogError { + fn from(value: RequestLogError) -> Self { + Self::LoggerError(value.to_string()) + } +} + +fn serialize(v: &serde_json::Value) -> Result { + serde_json::to_string(v).map_err(LogError::SerializeError) +} + +pub fn start_log( + model_config: M, + payload: P, +) -> Result>, LogError> +where + M: Serialize, + P: Serialize, +{ + let logger = if let Some(logger) = LOGGER.get() { + logger + } else { + return Ok(None); + }; + + let mut handle = logger.start()?; + let payload = json!({ + "model_config": model_config, + "input": payload, + }); + + handle.write(serialize(&payload)?.as_str())?; + Ok(Some(handle)) +} + +pub trait LoggerHandleExt { + fn write(&mut self, data: &Payload, usage: Option<&Usage>) -> Result<(), LogError> + where + Payload: Serialize; + fn error(&mut self, error: E) -> Result<(), LogError> + where + E: Display; +} + +impl LoggerHandleExt for Option> { + fn write(&mut self, data: &Payload, usage: Option<&Usage>) -> Result<(), LogError> + where + Payload: Serialize, + { + let log = if let Some(log) = self { + log + } else { + return Ok(()); + }; + + let line = serialize(&json!({ + "data": data, + "usage": usage, + }))?; + + Ok(log.write(line.as_str())?) + } + + fn error(&mut self, error: E) -> Result<(), LogError> + where + E: Display, + { + let log = if let Some(log) = self { + log + } else { + return Ok(()); + }; + + let line = serialize(&json!({ + "error": format!("{}", error), + }))?; + + Ok(log.write(line.as_str())?) + } +} diff --git a/crates/goose/src/providers/retry.rs b/crates/goose-providers/src/retry.rs similarity index 97% rename from crates/goose/src/providers/retry.rs rename to crates/goose-providers/src/retry.rs index 5cb12faa3a2a..8660a9f1cfc2 100644 --- a/crates/goose/src/providers/retry.rs +++ b/crates/goose-providers/src/retry.rs @@ -1,6 +1,6 @@ -use crate::providers::base::Provider; +use crate::base::Provider; +use crate::errors::ProviderError; use async_trait::async_trait; -use goose_providers::errors::ProviderError; use std::future::Future; use std::time::Duration; use tokio::time::sleep; @@ -13,16 +13,16 @@ pub const DEFAULT_MAX_RETRY_INTERVAL_MS: u64 = 30_000; #[derive(Debug, Clone)] pub struct RetryConfig { /// Maximum number of retry attempts - pub(crate) max_retries: usize, + pub max_retries: usize, /// Initial interval between retries in milliseconds - pub(crate) initial_interval_ms: u64, + pub initial_interval_ms: u64, /// Multiplier for backoff (exponential) - pub(crate) backoff_multiplier: f64, + pub backoff_multiplier: f64, /// Maximum interval between retries in milliseconds - pub(crate) max_interval_ms: u64, + pub max_interval_ms: u64, /// When true, only retry on transient errors (ServerError, NetworkError, /// RateLimitExceeded). RequestFailed (4xx client errors) will not be retried. - pub(crate) transient_only: bool, + pub transient_only: bool, } impl Default for RetryConfig { diff --git a/crates/goose-sdk-types/src/custom_notifications.rs b/crates/goose-sdk-types/src/custom_notifications.rs index 44424fb3b3b1..3170e93ccbaf 100644 --- a/crates/goose-sdk-types/src/custom_notifications.rs +++ b/crates/goose-sdk-types/src/custom_notifications.rs @@ -25,14 +25,12 @@ pub struct GooseSessionNotification { "propertyName": "sessionUpdate", "mapping": { "usage_update": "#/$defs/SessionUsageUpdate", - "status_message": "#/$defs/StatusMessageUpdate", - "interaction_update": "#/$defs/InteractionUpdate" + "status_message": "#/$defs/StatusMessageUpdate" } }))] pub enum GooseSessionUpdate { UsageUpdate(SessionUsageUpdate), StatusMessage(StatusMessageUpdate), - InteractionUpdate(InteractionUpdate), } impl Default for GooseSessionUpdate { @@ -70,48 +68,6 @@ pub enum StatusMessage { Progress { message: String }, } -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct InteractionUpdate { - pub interaction: Interaction, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "_meta")] - pub meta: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Interaction { - #[serde(rename_all = "camelCase")] - Elicitation { - id: String, - state: InteractionState, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - requested_schema: Option, - }, -} - -impl Default for Interaction { - fn default() -> Self { - Self::Elicitation { - id: String::new(), - state: InteractionState::Pending, - message: None, - requested_schema: None, - } - } -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum InteractionState { - #[default] - Pending, - Submitted, -} - fn notification_schema(generator: &mut SchemaGenerator) -> CustomMethodSchema where T: Default + JsonRpcMessage + JsonSchema, diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index f9b0bd8924a1..d5dc8729b22f 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -491,6 +491,18 @@ pub struct GetSessionInfoResponse { pub session: SessionInfo, } +/// Truncate a session conversation from the given message timestamp onward. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/conversation/truncate", + response = EmptyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct TruncateSessionConversationRequest { + pub session_id: String, + pub truncate_from: i64, +} + /// Update the project association for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/project/update", response = EmptyResponse)] @@ -556,17 +568,6 @@ pub struct ImportSessionResponse { pub message_count: u64, } -/// Submit a response for a pending MCP elicitation in an active session. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/elicitation/respond", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct ElicitationRespondRequest { - pub session_id: String, - pub elicitation_id: String, - #[serde(default)] - pub user_data: serde_json::Value, -} - #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ProviderConfigKey { diff --git a/crates/goose-sdk/Cargo.toml b/crates/goose-sdk/Cargo.toml index 17a6ec4b545c..4b17285e635e 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -26,7 +26,7 @@ goose-sdk-types = { path = "../goose-sdk-types" } agent-client-protocol = { workspace = true, features = ["unstable"] } agent-client-protocol-schema = { workspace = true } -uniffi = { version = "0.29", features = ["cli"], optional = true } +uniffi = { version = "0.31", features = ["cli"], optional = true } thiserror = { version = "2", optional = true } [dev-dependencies] diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index add298a07d62..dd4304798a63 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -27,6 +27,7 @@ local-inference = ["goose/local-inference"] aws-providers = ["goose/aws-providers"] cuda = ["goose/cuda", "local-inference"] vulkan = ["goose/vulkan", "local-inference"] +mlx = ["goose/mlx", "local-inference"] telemetry = ["goose/telemetry"] nostr = ["goose/nostr"] otel = ["goose/otel"] @@ -40,6 +41,7 @@ rustls-tls = [ "dep:aws-lc-rs", "goose/rustls-tls", "goose-mcp/rustls-tls", + "goose-providers/rustls-tls", ] native-tls = [ "reqwest/native-tls", @@ -48,6 +50,7 @@ native-tls = [ "dep:openssl", "goose/native-tls", "goose-mcp/native-tls", + "goose-providers/native-tls", ] [dependencies] diff --git a/crates/goose-server/src/logging.rs b/crates/goose-server/src/logging.rs index 88af03127c50..499cd118453d 100644 --- a/crates/goose-server/src/logging.rs +++ b/crates/goose-server/src/logging.rs @@ -1,9 +1,11 @@ use anyhow::Result; +use goose::providers::utils::init_goose_request_log; use tracing_subscriber::util::SubscriberInitExt; /// Sets up the logging infrastructure for the server. /// Logs go to a JSON file and a pretty console layer on stderr. pub fn setup_logging(name: Option<&str>) -> Result<()> { + init_goose_request_log()?; let config = goose::logging::LoggingConfig { component: "server", name, diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 27824489830a..272f5a385db3 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -5,10 +5,11 @@ use goose::config::permission::PermissionLevel; use goose::config::ExtensionEntry; use goose::conversation::Conversation; use goose::download_manager::{DownloadProgress, DownloadStatus}; -use goose::model::ModelConfig; -use goose::permission::permission_confirmation::{Permission, PrincipalType}; use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; use goose::session::{Session, SessionType, SystemInfo}; +use goose_providers::model::ModelConfig; +use goose_providers::permission::Permission; +use goose_providers::permission::PrincipalType; use goose_providers::thinking::ThinkingEffort; use rmcp::model::{ Annotations, Content, EmbeddedResource, Icon, IconTheme, ImageContent, JsonObject, @@ -578,7 +579,7 @@ derive_utoipa!(IconTheme as IconThemeSchema); ThinkingEffort, super::routes::config_management::ProviderModelInfoQuery, Session, - goose::config::goose_mode::GooseMode, + goose_providers::goose_mode::GooseMode, SessionType, SystemInfo, Conversation, @@ -696,6 +697,7 @@ pub struct ApiDoc; super::routes::local_inference::ModelDownloadStatus, super::routes::local_inference::DownloadModelRequest, goose::providers::local_inference::hf_models::HfModelInfo, + goose::providers::local_inference::hf_models::HfModelVariant, goose::providers::local_inference::hf_models::HfGgufFile, goose::providers::local_inference::hf_models::HfQuantVariant, super::routes::local_inference::RepoVariantsResponse, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 25b27eecf9f3..06cf11652575 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -19,7 +19,6 @@ use goose::agents::reply_parts::is_tool_visible_to_app; use goose::agents::ExtensionConfig; use goose::config::resolve_extensions_for_new_session; use goose::config::{Config, GooseMode}; -use goose::model::ModelConfig; use goose::providers::create; use goose::recipe::Recipe; use goose::recipe_deeplink; @@ -267,8 +266,14 @@ async fn start_agent( let recipe_extensions = original_recipe .as_ref() .and_then(|r| r.extensions.as_deref()); - let extensions_to_use = + let has_extension_overrides = extension_overrides.is_some(); + let mut extensions_to_use = resolve_extensions_for_new_session(recipe_extensions, extension_overrides); + if recipe_extensions.is_none() && !has_extension_overrides { + extensions_to_use.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( + Some(&PathBuf::from(&working_dir)), + )); + } let mut extension_data = session.extension_data.clone(); let extensions_state = EnabledExtensionsState::new(extensions_to_use); @@ -297,7 +302,9 @@ async fn start_agent( update = update.provider_name(provider); if let Some(ref model) = settings.goose_model { - if let Ok(model_config) = ModelConfig::new(model) { + if let Ok(model_config) = + goose::model_config::model_config_from_user_config(provider, model) + { update = update.model_config(model_config); } } @@ -400,6 +407,20 @@ async fn resume_agent( status: code, })?; + if !state.has_extension_loading_task(&payload.session_id).await { + let session_for_task = session.clone(); + let agent_for_task = agent.clone(); + let session_id_for_task = payload.session_id.clone(); + let task = tokio::spawn(async move { + agent_for_task + .load_extensions_from_session(&session_for_task) + .await + }); + state + .set_extension_loading_task(session_id_for_task, task) + .await; + } + let provider_changed = agent .restore_provider_from_session(&session) .await @@ -421,8 +442,8 @@ async fn resume_agent( session }; - let extension_results = - if let Some(results) = state.take_extension_loading_task(&payload.session_id).await { + let extension_results = match state.take_extension_loading_task(&payload.session_id).await { + Ok(Some(results)) => { tracing::debug!( "Using background extension loading results for session {}", payload.session_id @@ -431,13 +452,26 @@ async fn resume_agent( .remove_extension_loading_task(&payload.session_id) .await; results - } else { + } + Ok(None) => { tracing::debug!( - "No background task found, loading extensions for session {}", + "Extension loading task for session {} was already consumed", payload.session_id ); + vec![] + } + Err(e) => { + state + .remove_extension_loading_task(&payload.session_id) + .await; + tracing::warn!( + "Background extension loading failed for session {}, retrying synchronously: {}", + payload.session_id, + e + ); agent.load_extensions_from_session(&session).await - }; + } + }; (Some(extension_results), session) } else { @@ -604,15 +638,15 @@ async fn update_agent_provider( } }; - let mut model_config = ModelConfig::new(&model) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("Invalid model config: {}", e), - ) - })? - .with_canonical_limits(&payload.provider) - .with_context_limit(payload.context_limit); + let mut model_config = + goose::model_config::model_config_from_user_config(&payload.provider, &model) + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid model config: {}", e), + ) + })? + .with_context_limit(payload.context_limit); if let Some(request_params) = payload.request_params { model_config = model_config.with_merged_request_params(request_params); @@ -719,6 +753,8 @@ async fn agent_add_extension( #[cfg(feature = "telemetry")] let extension_name = request.config.name(); + ensure_extensions_loaded(&state, &request.session_id).await?; + let agent = state.get_agent(request.session_id.clone()).await?; agent @@ -751,6 +787,8 @@ async fn agent_remove_extension( State(state): State>, Json(request): Json, ) -> Result { + ensure_extensions_loaded(&state, &request.session_id).await?; + let agent = state.get_agent(request.session_id.clone()).await?; agent @@ -981,13 +1019,47 @@ async fn update_working_dir( Ok(StatusCode::OK) } -async fn ensure_extensions_loaded(state: &AppState, session_id: &str) { - if let Some(_results) = state.take_extension_loading_task(session_id).await { - tracing::debug!( - "Awaited background extension loading for session {} before serving request", - session_id - ); - state.remove_extension_loading_task(session_id).await; +async fn ensure_extensions_loaded(state: &AppState, session_id: &str) -> Result<(), ErrorResponse> { + match state.take_extension_loading_task(session_id).await { + Ok(Some(_)) => { + tracing::debug!( + "Awaited background extension loading for session {} before serving request", + session_id + ); + state.remove_extension_loading_task(session_id).await; + Ok(()) + } + Ok(None) => Ok(()), + Err(e) => { + state.remove_extension_loading_task(session_id).await; + tracing::warn!( + "Background extension loading failed for session {}, retrying synchronously: {}", + session_id, + e + ); + let session = state + .session_manager() + .get_session(session_id, false) + .await + .map_err(|err| ErrorResponse { + message: format!( + "Failed to get session after extension loading failed: {}", + err + ), + status: StatusCode::NOT_FOUND, + })?; + let agent = state + .get_agent(session_id.to_string()) + .await + .map_err(|err| { + ErrorResponse::internal(format!( + "Failed to get agent after extension loading failed: {}", + err + )) + })?; + agent.load_extensions_from_session(&session).await; + Ok(()) + } } } @@ -1009,7 +1081,9 @@ async fn read_resource( ) -> Result, StatusCode> { use rmcp::model::ResourceContents; - ensure_extensions_loaded(&state, &payload.session_id).await; + ensure_extensions_loaded(&state, &payload.session_id) + .await + .map_err(|err| err.status)?; let agent = state .get_agent_for_route(payload.session_id.clone()) @@ -1091,7 +1165,7 @@ async fn call_tool( State(state): State>, Json(payload): Json, ) -> Result, ErrorResponse> { - ensure_extensions_loaded(&state, &payload.session_id).await; + ensure_extensions_loaded(&state, &payload.session_id).await?; let agent = state .get_agent_for_route(payload.session_id.clone()) diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index f556c39a6510..6cae9dad1bd4 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -13,7 +13,6 @@ use goose::config::paths::Paths; use goose::config::ExtensionEntry; use goose::config::{Config, ConfigError}; use goose::custom_requests::SourceType; -use goose::model::ModelConfig; use goose::providers::base::{ModelInfo, ProviderMetadata, ProviderType}; use goose::providers::canonical::maybe_get_canonical_model; use goose::providers::catalog::{ @@ -27,6 +26,7 @@ use goose::{ agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel, slash_commands::recipe_slash_command, }; +use goose_providers::model::ModelConfig; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_yaml; @@ -937,7 +937,8 @@ pub async fn get_provider_models( ))); } - let model_config = ModelConfig::new(&metadata.default_model)?.with_canonical_limits(&name); + let model_config = + goose::model_config::model_config_from_user_config(&name, &metadata.default_model)?; let provider = goose::providers::create(&name, model_config, Vec::new()).await?; let models_result = provider.fetch_recommended_model_info().await; @@ -971,7 +972,7 @@ pub async fn resolve_provider_model_info( ))); } - let model_config = ModelConfig::new(model)?.with_canonical_limits(name); + let model_config = goose::model_config::model_config_from_user_config(name, model)?; let provider = goose::providers::create(name, model_config.clone(), Vec::new()).await?; match provider.fetch_model_info(model).await { Ok(info) => Ok(info), @@ -1419,7 +1420,6 @@ pub async fn get_provider_catalog_template( pub async fn configure_provider_oauth( Path(provider_name): Path, ) -> Result, ErrorResponse> { - use goose::model::ModelConfig; use goose::providers::create; if !is_valid_provider_name(&provider_name) { @@ -1440,11 +1440,10 @@ pub async fn configure_provider_oauth( return Ok(Json("OAuth configuration completed".to_string())); } - let temp_model = ModelConfig::new("temp") + let temp_model = goose::model_config::model_config_from_user_config(&provider_name, "temp") .map_err(|e| { ErrorResponse::bad_request(format!("Failed to create temporary model config: {}", e)) - })? - .with_canonical_limits(&provider_name); + })?; // OAuth configuration does not use extensions. let provider = create(&provider_name, temp_model, Vec::new()) diff --git a/crates/goose-server/src/routes/errors.rs b/crates/goose-server/src/routes/errors.rs index aa7ffa553acf..9d3de5a373dd 100644 --- a/crates/goose-server/src/routes/errors.rs +++ b/crates/goose-server/src/routes/errors.rs @@ -4,8 +4,8 @@ use axum::{ Json, }; use goose::config::ConfigError; -use goose::model::ConfigError as ModelConfigError; use goose_providers::errors::ProviderError; +use goose_providers::model::ConfigError as ModelConfigError; use serde::Serialize; use utoipa::ToSchema; diff --git a/crates/goose-server/src/routes/local_inference.rs b/crates/goose-server/src/routes/local_inference.rs index c53733f2c30d..39e84bb46378 100644 --- a/crates/goose-server/src/routes/local_inference.rs +++ b/crates/goose-server/src/routes/local_inference.rs @@ -10,16 +10,19 @@ use axum::{ }; use futures::future::join_all; use goose::config::paths::Paths; -use goose::download_manager::{get_download_manager, DownloadProgress}; +use goose::download_manager::{get_download_manager, DownloadProgress, DownloadStatus}; use goose::providers::huggingface_auth; -use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfQuantVariant}; +use goose::providers::local_inference::hf_models::{self, HfModelInfo, HfModelVariant}; use goose::providers::local_inference::{ available_inference_memory_bytes, builtin_chat_template_names, - hf_models::{resolve_model_spec_full, HfGgufFile}, + hf_models::{ + register_resolved_model, resolve_local_model_selection, resolve_local_model_spec, + resolve_model_spec, HfGgufFile, + }, local_model_registry::{ - default_settings_for_model, get_registry, is_featured_model, mmproj_local_path, - model_id_from_repo, LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus, - ModelSettings, ShardFile, FEATURED_MODELS, + default_settings_for_model, featured_mmproj_spec, get_registry, model_id_from_repo, + LocalModelEntry, LocalModelStorage, ModelDownloadStatus as RegistryDownloadStatus, + ModelSettings, FEATURED_MODELS, }, recommend_local_model, }; @@ -80,18 +83,26 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { .lock() .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; if let Some(existing) = registry.get_model(&model_id) { - if let Some(path) = &existing.mmproj_path { - if existing.is_downloaded() && !path.exists() { - if let Some(url) = &existing.mmproj_source_url { - mmproj_downloads_needed.push(( - model_id.clone(), - url.clone(), - path.clone(), - )); - } + let needs_backfill = existing.mmproj_path.is_none() && featured.mmproj.is_some(); + let needs_download = existing.is_downloaded() + && featured.mmproj.is_some() + && !existing.mmproj_path.as_ref().is_some_and(|p| p.exists()); + + if needs_download { + if let Some(mmproj) = featured.mmproj.as_ref() { + let path = mmproj.local_path(); + let url = format!( + "https://huggingface.co/{}/resolve/main/{}", + mmproj.repo, mmproj.filename + ); + mmproj_downloads_needed.push((model_id.clone(), url, path)); } + } + + if !needs_backfill { continue; } + // Fall through to resolve for backfill } } @@ -103,45 +114,36 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { }); } - let resolved: Vec<(PendingResolve, HfGgufFile, Option)> = + let resolved: Vec<(PendingResolve, HfGgufFile)> = join_all(to_resolve.into_iter().map(|pending| async move { - let (hf_file, mmproj) = match resolve_model_spec_full(pending.spec).await { - Ok((_repo, resolved)) => (resolved.files[0].clone(), resolved.mmproj), + let hf_file = match resolve_model_spec(pending.spec).await { + Ok((_repo, file)) => file, Err(_) => { let filename = format!( "{}-{}.gguf", pending.repo_id.split('/').next_back().unwrap_or("model"), pending.quantization ); - ( - HfGgufFile { - filename: filename.clone(), - size_bytes: 0, - quantization: pending.quantization.to_string(), - download_url: format!( - "https://huggingface.co/{}/resolve/main/{}", - pending.repo_id, filename - ), - }, - None, - ) + HfGgufFile { + filename: filename.clone(), + size_bytes: 0, + quantization: pending.quantization.to_string(), + download_url: format!( + "https://huggingface.co/{}/resolve/main/{}", + pending.repo_id, filename + ), + } } }; - (pending, hf_file, mmproj) + (pending, hf_file) })) .await; let entries_to_add: Vec = resolved .into_iter() - .map(|(pending, hf_file, mmproj)| { + .map(|(pending, hf_file)| { let local_path = Paths::in_data_dir("models").join(&hf_file.filename); let settings = default_settings_for_model(&pending.model_id); - let mmproj_path = mmproj - .as_ref() - .map(|mmproj| mmproj_local_path(&pending.repo_id, &mmproj.filename)); - let mmproj_source_url = mmproj.as_ref().map(|mmproj| mmproj.download_url.clone()); - let mmproj_size_bytes = mmproj.as_ref().map_or(0, |mmproj| mmproj.size_bytes); - let mmproj_checked = mmproj.is_some(); LocalModelEntry { id: pending.model_id, repo_id: pending.repo_id, @@ -149,12 +151,14 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { quantization: pending.quantization, local_path, source_url: hf_file.download_url, + backend_id: settings.backend_id.clone(), + storage: LocalModelStorage::GooseManaged, settings, size_bytes: hf_file.size_bytes, - mmproj_path, - mmproj_source_url, - mmproj_size_bytes, - mmproj_checked, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: false, shard_files: vec![], } }) @@ -168,80 +172,20 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { if !entries_to_add.is_empty() { registry.sync_with_featured(entries_to_add); } - } - - let to_backfill: Vec<(String, String, String)> = { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - registry - .list_models() - .iter() - .filter(|model| model.is_downloaded()) - .filter(|model| model.mmproj_path.is_none()) - .filter(|model| !model.mmproj_checked) - .map(|model| { - ( - model.id.clone(), - model.repo_id.clone(), - model.quantization.clone(), - ) - }) - .collect() - }; - - let mmproj_backfills: Vec<(String, String, Option>)> = join_all( - to_backfill - .into_iter() - .map(|(id, repo_id, quantization)| async move { - let spec = format!("{repo_id}:{quantization}"); - let mmproj = resolve_model_spec_full(&spec) - .await - .ok() - .map(|(_, resolved)| resolved.mmproj); - (id, repo_id, mmproj) - }), - ) - .await; - - { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - - for (model_id, repo_id, mmproj_result) in mmproj_backfills { - if let Some(model) = registry - .list_models_mut() - .iter_mut() - .find(|model| model.id == model_id) - { - let Some(mmproj) = mmproj_result else { - continue; - }; - - model.mmproj_checked = true; - if let Some(mmproj) = mmproj { - model.mmproj_path = Some(mmproj_local_path(&repo_id, &mmproj.filename)); - model.mmproj_source_url = Some(mmproj.download_url); - model.mmproj_size_bytes = mmproj.size_bytes; - } - model.refresh_mmproj_metadata(); - } - } + // Backfill mmproj data for all registry models and collect any + // needed mmproj downloads for models already on disk. for model in registry.list_models_mut() { - model.refresh_mmproj_metadata(); + model.enrich_with_featured_mmproj(); if model.is_downloaded() { - if let Some(path) = &model.mmproj_path { + if let Some(mmproj) = featured_mmproj_spec(&model.id) { + let path = mmproj.local_path(); if !path.exists() { - if let Some(url) = &model.mmproj_source_url { - mmproj_downloads_needed.push(( - model.id.clone(), - url.clone(), - path.clone(), - )); - } + let url = format!( + "https://huggingface.co/{}/resolve/main/{}", + mmproj.repo, mmproj.filename + ); + mmproj_downloads_needed.push((model.id.clone(), url, path)); } } } @@ -390,10 +334,11 @@ pub struct SearchQuery { #[derive(Debug, Serialize, ToSchema)] pub struct RepoVariantsResponse { - pub variants: Vec, + pub variants: Vec, pub recommended_index: Option, pub available_memory_bytes: u64, pub downloaded_quants: Vec, + pub downloaded_variants: Vec, } #[utoipa::path( @@ -412,7 +357,7 @@ pub async fn search_hf_models( Query(params): Query, ) -> Result>, ErrorResponse> { let limit = params.limit.unwrap_or(20).min(50); - let results = hf_models::search_gguf_models(¶ms.q, limit) + let results = hf_models::search_local_models(¶ms.q, limit) .await .map_err(|e| ErrorResponse::internal(format!("Search failed: {}", e)))?; Ok(Json(results)) @@ -430,24 +375,42 @@ pub async fn get_repo_files( Path((author, repo)): Path<(String, String)>, ) -> Result, ErrorResponse> { let repo_id = format!("{}/{}", author, repo); - let variants = hf_models::get_repo_gguf_variants(&repo_id) + let variants = hf_models::get_repo_local_variants(&repo_id) .await .map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?; let runtime = state.get_inference_runtime()?; let available_memory = available_inference_memory_bytes(&runtime); - let recommended_index = hf_models::recommend_variant(&variants, available_memory); + let gguf_variants: Vec<_> = variants + .iter() + .filter(|variant| variant.backend_id == "llamacpp") + .map( + |variant| goose::providers::local_inference::hf_models::HfQuantVariant { + quantization: variant.variant_id.clone(), + size_bytes: variant.size_bytes, + filename: variant.filename.clone().unwrap_or_default(), + download_url: variant.download_url.clone().unwrap_or_default(), + description: "", + quality_rank: variant.quality_rank, + sharded: variant.sharded, + }, + ) + .collect(); + let recommended_index = hf_models::recommend_variant(&gguf_variants, available_memory); - let downloaded_quants = { + let (downloaded_quants, downloaded_variants) = { let registry = get_registry() .lock() .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - registry + let models: Vec<_> = registry .list_models() .iter() .filter(|m| m.repo_id == repo_id && m.is_downloaded()) - .map(|m| m.quantization.clone()) - .collect() + .collect(); + ( + models.iter().map(|m| m.quantization.clone()).collect(), + models.iter().map(|m| m.id.clone()).collect(), + ) }; Ok(Json(RepoVariantsResponse { @@ -455,13 +418,178 @@ pub async fn get_repo_files( recommended_index, available_memory_bytes: available_memory, downloaded_quants, + downloaded_variants, })) } #[derive(Debug, Deserialize, ToSchema)] pub struct DownloadModelRequest { - /// Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" + /// Model spec/download id like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" or "google/gemma-4-31B-it" pub spec: String, + /// Optional backend id for callers selecting a concrete variant row. + pub backend_id: Option, + /// Optional backend-specific variant id, such as a GGUF quantization or MLX dtype. + pub variant_id: Option, +} + +#[derive(Clone)] +struct LocalModelSelection { + repo_id: String, + backend_id: String, + variant_id: Option, +} + +fn explicit_model_selection( + req: &DownloadModelRequest, +) -> anyhow::Result> { + if let Some(backend_id) = req.backend_id.as_deref() { + let (repo_id, parsed_variant_id) = hf_models::parse_model_spec(&req.spec) + .map(|(repo_id, quantization)| (repo_id, Some(quantization))) + .unwrap_or_else(|_| (req.spec.clone(), None)); + let variant_id = req.variant_id.clone().or(parsed_variant_id); + match backend_id { + "mlx" | "llamacpp" => Ok(Some(LocalModelSelection { + repo_id, + backend_id: backend_id.to_string(), + variant_id, + })), + _ => anyhow::bail!("Unknown local inference backend '{}'", backend_id), + } + } else { + Ok(None) + } +} + +async fn local_model_id_from_request( + req: &DownloadModelRequest, + selection: Option<&LocalModelSelection>, +) -> anyhow::Result { + if let Some(selection) = selection { + return match selection.backend_id.as_str() { + "mlx" => Ok(selection.repo_id.clone()), + "llamacpp" => { + let quantization = selection.variant_id.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "llama.cpp model '{}' is missing a quantization", + selection.repo_id + ) + })?; + Ok(model_id_from_repo(&selection.repo_id, quantization)) + } + _ => anyhow::bail!("Unknown local inference backend '{}'", selection.backend_id), + }; + } + + if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { + return Ok(model_id_from_repo(&repo_id, &quantization)); + } + + let variants = hf_models::get_repo_local_variants(&req.spec).await?; + let has_llamacpp = variants + .iter() + .any(|variant| variant.backend_id == "llamacpp"); + let mlx_variants: Vec<_> = variants + .iter() + .filter(|variant| variant.backend_id == "mlx") + .collect(); + if mlx_variants.len() == 1 && !has_llamacpp { + Ok(req.spec.clone()) + } else { + anyhow::bail!( + "Model spec '{}' is ambiguous; choose one of: {}", + req.spec, + variants + .iter() + .map(|variant| variant.download_id.as_str()) + .collect::>() + .join(", ") + ) + } +} + +fn mark_download_failed(model_id: &str, error: impl std::fmt::Display) { + let manager = get_download_manager(); + let download_id = format!("{}-model", model_id); + if manager.get_progress(&download_id).is_none() { + manager.set_progress(DownloadProgress { + model_id: download_id.clone(), + status: DownloadStatus::Failed, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: Some(error.to_string()), + task_exited: true, + }); + return; + } + + manager.update_progress(&download_id, |progress| { + if progress.status != DownloadStatus::Cancelled { + progress.status = DownloadStatus::Failed; + progress.error = Some(error.to_string()); + } + progress.task_exited = true; + }); +} + +fn model_download_completed(model_id: &str) -> bool { + get_download_manager() + .get_progress(&format!("{}-model", model_id)) + .is_some_and(|progress| progress.status == DownloadStatus::Completed) +} + +fn register_pending_download_model( + model_id: &str, + req: &DownloadModelRequest, + selection: Option<&LocalModelSelection>, +) -> anyhow::Result<()> { + let (repo_id, backend_id, variant_id) = if let Some(selection) = selection { + ( + selection.repo_id.clone(), + selection.backend_id.clone(), + selection + .variant_id + .clone() + .unwrap_or_else(|| "default".to_string()), + ) + } else if let Ok((repo_id, quantization)) = hf_models::parse_model_spec(&req.spec) { + (repo_id, "llamacpp".to_string(), quantization) + } else { + (req.spec.clone(), "mlx".to_string(), "default".to_string()) + }; + + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; + if registry.has_model(model_id) { + return Ok(()); + } + + let mut settings = default_settings_for_model(model_id); + if backend_id != "llamacpp" { + settings.backend_id = Some(backend_id.clone()); + } + + let filename = variant_id.clone(); + registry.add_model(LocalModelEntry { + id: model_id.to_string(), + repo_id, + filename: filename.clone(), + quantization: variant_id, + local_path: Paths::in_data_dir("models").join(filename), + source_url: req.spec.clone(), + backend_id: settings.backend_id.clone(), + storage: LocalModelStorage::HuggingFaceCache, + settings, + size_bytes: 0, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: false, + shard_files: vec![], + }) } #[utoipa::path( @@ -476,110 +604,63 @@ pub struct DownloadModelRequest { pub async fn download_hf_model( Json(req): Json, ) -> Result<(StatusCode, Json), ErrorResponse> { - let (repo_id, quantization) = hf_models::parse_model_spec(&req.spec) - .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec format: {e}")))?; - - let (_repo, resolved) = resolve_model_spec_full(&req.spec) + let selection = explicit_model_selection(&req) + .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; + let model_id = local_model_id_from_request(&req, selection.as_ref()) .await .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; - let hf_token = huggingface_auth::resolve_token_async().await.ok().flatten(); - - let model_id = model_id_from_repo(&repo_id, &quantization); - let models_dir = Paths::in_data_dir("models"); - let first_file = &resolved.files[0]; - let first_local_path = models_dir.join(&first_file.filename); - - let shard_files: Vec = if resolved.files.len() > 1 { - resolved - .files - .iter() - .skip(1) - .map(|f| ShardFile { - filename: f.filename.clone(), - local_path: models_dir.join(&f.filename), - source_url: f.download_url.clone(), - size_bytes: f.size_bytes, - }) - .collect() - } else { - vec![] - }; - - let mmproj_path = resolved - .mmproj - .as_ref() - .map(|mmproj| mmproj_local_path(&repo_id, &mmproj.filename)); - let mmproj_source_url = resolved - .mmproj - .as_ref() - .map(|mmproj| mmproj.download_url.clone()); - let mmproj_size_bytes = resolved - .mmproj - .as_ref() - .map_or(0, |mmproj| mmproj.size_bytes); - let mmproj_checked = true; - - let entry = LocalModelEntry { - id: model_id.clone(), - repo_id, - filename: first_file.filename.clone(), - quantization, - local_path: first_local_path.clone(), - source_url: first_file.download_url.clone(), - settings: default_settings_for_model(&model_id), - size_bytes: resolved.total_size, - mmproj_path, - mmproj_source_url, - mmproj_size_bytes, - mmproj_checked, - shard_files: shard_files.clone(), - }; - - let mmproj_path = { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - registry - .add_model(entry) - .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; - registry.get_model(&model_id).and_then(|e| { - e.mmproj_path - .as_ref() - .zip(e.mmproj_source_url.as_ref()) - .map(|(p, u)| (p.clone(), u.clone())) + let download_id = format!("{}-model", model_id); + let download_reserved = get_download_manager() + .reserve_download(DownloadProgress { + model_id: download_id, + status: DownloadStatus::Downloading, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: false, }) - }; + .map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?; + if !download_reserved { + return Ok((StatusCode::ACCEPTED, Json(model_id))); + } - let dm = get_download_manager(); - let all_files: Vec<(String, std::path::PathBuf)> = resolved - .files - .iter() - .map(|f| (f.download_url.clone(), models_dir.join(&f.filename))) - .collect(); + if let Err(error) = register_pending_download_model(&model_id, &req, selection.as_ref()) { + mark_download_failed(&model_id, &error); + return Err(ErrorResponse::internal(format!( + "Failed to register download: {}", + error + ))); + } - dm.download_model_sharded_with_bearer_token( - format!("{}-model", model_id), - all_files, - resolved.total_size, - hf_token.clone(), - None, - ) - .await - .map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?; - - if let Some((mmproj_path, mmproj_url)) = mmproj_path { - if !mmproj_path.exists() { - dm.download_model_with_bearer_token( - format!("{}-mmproj", model_id), - mmproj_url, - mmproj_path, - hf_token, - None, + let spec = req.spec.clone(); + let selection_for_task = selection.clone(); + let model_id_for_task = model_id.clone(); + tokio::spawn(async move { + let resolved = if let Some(selection) = selection_for_task { + resolve_local_model_selection( + &selection.repo_id, + &selection.backend_id, + selection.variant_id.as_deref(), ) .await - .map_err(|e| ErrorResponse::internal(format!("mmproj download failed: {}", e)))?; + } else { + resolve_local_model_spec(&spec).await + }; + match resolved { + Ok(resolved) => { + if !model_download_completed(&model_id_for_task) { + return; + } + if let Err(error) = register_resolved_model(resolved, &spec) { + mark_download_failed(&model_id_for_task, error); + } + } + Err(error) => mark_download_failed(&model_id_for_task, error), } - } + }); Ok((StatusCode::ACCEPTED, Json(model_id))) } @@ -636,58 +717,15 @@ pub async fn cancel_local_model_download( ) )] pub async fn delete_local_model(Path(model_id): Path) -> Result { - let (all_paths, primary_path, mmproj_path, other_uses_mmproj) = { - let registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - let entry = registry - .get_model(&model_id) - .ok_or_else(|| ErrorResponse::not_found("Model not found"))?; - let paths: Vec = - entry.all_local_paths().map(|p| p.to_path_buf()).collect(); - let primary = entry.local_path.clone(); - let mp = entry.mmproj_path.clone(); - let shared = mp.as_ref().is_some_and(|target| { - registry.list_models().iter().any(|m| { - m.id != model_id && m.is_downloaded() && m.mmproj_path.as_ref() == Some(target) - }) - }); - (paths, primary, mp, shared) - }; - - for path in &all_paths { - if path.exists() { - tokio::fs::remove_file(path) - .await - .map_err(|e| ErrorResponse::internal(format!("Failed to delete: {}", e)))?; - } - } - - // Clean up empty parent directories (e.g. BF16/ subdirectory) - if let Some(parent) = primary_path.parent() { - let models_dir = Paths::in_data_dir("models"); - if parent != models_dir { - let _ = tokio::fs::remove_dir(parent).await; - } - } - - if !other_uses_mmproj { - if let Some(mmproj) = mmproj_path { - if mmproj.exists() { - let _ = tokio::fs::remove_file(&mmproj).await; - } - } - } - - // Only remove non-featured models from registry (featured ones stay as placeholders) - if !is_featured_model(&model_id) { - let mut registry = get_registry() - .lock() - .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; - registry - .remove_model(&model_id) - .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + if registry.get_model(&model_id).is_none() { + return Err(ErrorResponse::not_found("Model not found")); } + registry + .delete_model(&model_id) + .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; Ok(StatusCode::OK) } @@ -804,3 +842,44 @@ pub fn routes(state: Arc) -> Router { ) .with_state(state) } + +#[cfg(test)] +mod tests { + use super::*; + + fn progress_for(model_id: &str, status: DownloadStatus) -> DownloadProgress { + DownloadProgress { + model_id: format!("{}-model", model_id), + status, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: true, + } + } + + #[test] + fn model_download_completed_requires_completed_progress() { + let model_id = "test-completed-registration-gate"; + let manager = get_download_manager(); + manager.set_progress(progress_for(model_id, DownloadStatus::Completed)); + + assert!(model_download_completed(model_id)); + + manager.clear_completed(&format!("{}-model", model_id)); + } + + #[test] + fn model_download_completed_rejects_cancelled_progress() { + let model_id = "test-cancelled-registration-gate"; + let manager = get_download_manager(); + manager.set_progress(progress_for(model_id, DownloadStatus::Cancelled)); + + assert!(!model_download_completed(model_id)); + + manager.clear_completed(&format!("{}-model", model_id)); + } +} diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 68d0b4cad122..bfec2e00143f 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -356,6 +356,7 @@ pub async fn reply( stream_event(MessageEvent::Message { message, token_state }, &tx, &cancel_token).await; } + Ok(Some(Ok(AgentEvent::Usage(_)))) => {} Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => { all_messages = new_messages.clone(); stream_event(MessageEvent::UpdateConversation {conversation: new_messages}, &tx, &cancel_token).await; diff --git a/crates/goose-server/src/routes/session_events.rs b/crates/goose-server/src/routes/session_events.rs index dc5d3d5401b7..c744a6443eb8 100644 --- a/crates/goose-server/src/routes/session_events.rs +++ b/crates/goose-server/src/routes/session_events.rs @@ -480,6 +480,7 @@ pub async fn session_reply( ) .await; } + Ok(Some(Ok(AgentEvent::Usage(_)))) => {} Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => { all_messages = new_messages.clone(); publish( diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 6d109c51e50e..3543d051ee6c 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -84,27 +84,39 @@ impl AppState { tasks.insert(session_id, Arc::new(Mutex::new(Some(task)))); } + pub async fn has_extension_loading_task(&self, session_id: &str) -> bool { + let tasks = self.extension_loading_tasks.lock().await; + tasks.contains_key(session_id) + } + pub async fn take_extension_loading_task( &self, session_id: &str, - ) -> Option> { + ) -> Result>, tokio::task::JoinError> { let task_holder = { let tasks = self.extension_loading_tasks.lock().await; tasks.get(session_id).cloned() }; if let Some(holder) = task_holder { - let task = holder.lock().await.take(); - if let Some(handle) = task { + let mut task = holder.lock().await; + if let Some(handle) = task.as_mut() { + // Keep the per-session task locked and discoverable while awaiting so + // concurrent routes cannot mutate extensions before background loading finishes. match handle.await { - Ok(results) => return Some(results), + Ok(results) => { + task.take(); + return Ok(Some(results)); + } Err(e) => { + task.take(); tracing::warn!("Background extension loading task failed: {}", e); + return Err(e); } } } } - None + Ok(None) } pub async fn remove_extension_loading_task(&self, session_id: &str) { diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 47f172bda99b..4cba9d8c3711 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -24,6 +24,7 @@ local-inference = [ "dep:candle-core", "dep:candle-nn", "dep:candle-transformers", + "dep:hf-hub", "dep:llama-cpp-2", "dep:llama-cpp-sys-2", "dep:tokenizers", @@ -40,6 +41,7 @@ aws-providers = [ ] cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"] vulkan = ["local-inference", "llama-cpp-2/vulkan"] +mlx = ["local-inference", "dep:mlx-rs", "dep:mlx-lm", "dep:mlx-lm-utils"] rustls-tls = [ "dep:rustls", "reqwest/rustls", @@ -49,12 +51,10 @@ rustls-tls = [ "jsonwebtoken/aws_lc_rs", "oauth2/reqwest", "oauth2/rustls-tls", + "hf-hub/rustls-tls", + "goose-providers/rustls-tls", ] native-tls = [ - "dep:pem", - "dep:pkcs1", - "dep:pkcs8", - "dep:sec1", "reqwest/native-tls", "rmcp/reqwest-native-tls", "smithy-transport-reqwest?/native-tls", @@ -62,6 +62,7 @@ native-tls = [ "jsonwebtoken/rust_crypto", "oauth2/reqwest", "oauth2/native-tls", + "goose-providers/native-tls", ] system-keyring = ["dep:keyring"] portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel"] @@ -193,10 +194,6 @@ pulldown-cmark = { version = "0.13", default-features = false } encoding_rs = { version = "0.8.35", default-features = false } pastey = { version = "0.2", default-features = false } shell-words = { workspace = true } -pem = { version = "3.0.2", default-features = false, features = ["std"], optional = true } -pkcs1 = { version = "0.7.5", default-features = false, features = ["pkcs8", "std"], optional = true } -pkcs8 = { version = "0.11.0", default-features = false, features = ["alloc", "std"], optional = true } -sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8", "std"], optional = true } goose-acp-macros = { path = "../goose-acp-macros", default-features = false } tower-http = { workspace = true, features = ["cors"] } http-body-util = { version = "0.1.2", default-features = false } @@ -205,6 +202,10 @@ process-wrap = { version = "9", default-features = false, features = ["std"] } nostr = { version = "0.44", default-features = false, features = ["nip44", "std"], optional = true } nostr-sdk = { version = "0.44", default-features = false, features = ["nip44"], optional = true } rustls = { workspace = true, optional = true } +hf-hub = { version = "1.0.0-rc.1", default-features = false, optional = true } +mlx-rs = { version = "0.25.3", default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true } +mlx-lm = { git = "https://github.com/jh-block/mlx-lm", optional = true } +mlx-lm-utils = { git = "https://github.com/jh-block/mlx-lm", optional = true } pctx_code_mode = { version = "0.3", default-features = false, optional = true } diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 27294da66c62..2c4ed6ecd0e4 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -201,8 +201,8 @@ "responseType": "GetSessionInfoResponse_unstable" }, { - "method": "_goose/unstable/elicitation/respond", - "requestType": "ElicitationRespondRequest_unstable", + "method": "_goose/unstable/session/conversation/truncate", + "requestType": "TruncateSessionConversationRequest_unstable", "responseType": "EmptyResponse" }, { diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 97732c520210..4fa7028d9fcc 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -2768,26 +2768,23 @@ "type": "string", "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)" }, - "ElicitationRespondRequest_unstable": { + "TruncateSessionConversationRequest_unstable": { "type": "object", "properties": { "sessionId": { "type": "string" }, - "elicitationId": { - "type": "string" - }, - "userData": { - "default": null + "truncateFrom": { + "type": "integer" } }, "required": [ "sessionId", - "elicitationId" + "truncateFrom" ], - "description": "Submit a response for a pending MCP elicitation in an active session.", + "description": "Truncate a session conversation from the given message timestamp onward.", "x-side": "agent", - "x-method": "_goose/unstable/elicitation/respond" + "x-method": "_goose/unstable/session/conversation/truncate" }, "UpdateSessionProjectRequest_unstable": { "type": "object", @@ -3598,19 +3595,6 @@ "required": [ "sessionUpdate" ] - }, - { - "$ref": "#/$defs/InteractionUpdate", - "type": "object", - "properties": { - "sessionUpdate": { - "type": "string", - "const": "interaction_update" - } - }, - "required": [ - "sessionUpdate" - ] } ], "description": "Discriminated union of goose-specific session update payloads.\nVariant tag matches ACP's convention (`sessionUpdate: \"\"`).\n\n`discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`)\nemit the correct snake_case tag value even when this enum has a single\nvariant. Add a mapping entry per variant.", @@ -3618,8 +3602,7 @@ "propertyName": "sessionUpdate", "mapping": { "usage_update": "#/$defs/SessionUsageUpdate", - "status_message": "#/$defs/StatusMessageUpdate", - "interaction_update": "#/$defs/InteractionUpdate" + "status_message": "#/$defs/StatusMessageUpdate" } } }, @@ -3706,56 +3689,6 @@ ], "description": "Live UI/session status. This is not conversation transcript content, and\nshould not be persisted or replayed as history." }, - "Interaction": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "state": { - "$ref": "#/$defs/InteractionState" - }, - "message": { - "type": [ - "string", - "null" - ] - }, - "requestedSchema": {}, - "type": { - "type": "string", - "const": "elicitation" - } - }, - "required": [ - "type", - "id", - "state" - ] - } - ] - }, - "InteractionState": { - "type": "string", - "enum": [ - "pending", - "submitted" - ] - }, - "InteractionUpdate": { - "type": "object", - "properties": { - "interaction": { - "$ref": "#/$defs/Interaction" - }, - "_meta": {} - }, - "required": [ - "interaction" - ] - }, "ExtRequest": { "properties": { "id": { @@ -4131,11 +4064,11 @@ { "allOf": [ { - "$ref": "#/$defs/ElicitationRespondRequest_unstable" + "$ref": "#/$defs/TruncateSessionConversationRequest_unstable" } ], - "description": "Params for _goose/unstable/elicitation/respond", - "title": "ElicitationRespondRequest_unstable" + "description": "Params for _goose/unstable/session/conversation/truncate", + "title": "TruncateSessionConversationRequest_unstable" }, { "allOf": [ diff --git a/crates/goose/src/acp/provider.rs b/crates/goose/src/acp/provider.rs index 16bca02d5b98..fe970a10f309 100644 --- a/crates/goose/src/acp/provider.rs +++ b/crates/goose/src/acp/provider.rs @@ -34,12 +34,12 @@ use crate::acp::{map_permission_response, PermissionDecision}; use crate::config::{ExtensionConfig, GooseMode}; use crate::context_mgmt::format_message_for_compacting; use crate::conversation::message::{Message, MessageContent, TOOL_META_EXTERNAL_DISPATCH_KEY}; -use crate::model::ModelConfig; use crate::permission::permission_confirmation::PrincipalType; use crate::permission::{Permission, PermissionConfirmation}; use crate::providers::base::{MessageStream, PermissionRouting, Provider}; use crate::subprocess::configure_subprocess; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; /// Sentinel: resolved to the actual model name during connect(). pub const ACP_CURRENT_MODEL: &str = "current"; @@ -1661,7 +1661,7 @@ mod tests { let provider = test_provider(); assert_eq!( provider.get_model_config().context_limit(), - crate::model::DEFAULT_CONTEXT_LIMIT + goose_providers::model::DEFAULT_CONTEXT_LIMIT ); provider.context_size.store(200_000, Ordering::Relaxed); @@ -1716,6 +1716,7 @@ mod tests { envs: Envs::new([("GITHUB_PERSONAL_ACCESS_TOKEN".into(), "ghp_xxxxxxxxxxxx".into())].into()), env_keys: vec![], timeout: None, + cwd: None, bundled: Some(false), available_tools: vec![], }, diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index 41789bb81b72..ffdfe61dd4bf 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -1,5 +1,4 @@ -use crate::config::GooseMode; -use crate::model::ModelConfig; +use crate::config::{Config, GooseMode}; use crate::providers::inventory::{ProviderInventoryEntry, ProviderInventoryService}; use crate::session::Session; use agent_client_protocol::schema::{ @@ -9,6 +8,7 @@ use agent_client_protocol::schema::{ SessionNotification, SessionUpdate, UnstructuredCommandInput, }; use agent_client_protocol::{Client, ConnectionTo}; +use goose_providers::model::ModelConfig; use goose_providers::thinking::ThinkingEffort; use strum::{EnumMessage, VariantNames}; @@ -64,17 +64,22 @@ pub(super) fn session_meta(session: &Session) -> serde_json::Map SessionInfo { let meta = session_meta(&session); - let title = session.display_title(); let mut info = SessionInfo::new(SessionId::new(session.id), session.working_dir) .updated_at(session.updated_at.to_rfc3339()) .meta(meta); - if let Some(title) = title { - info = info.title(title); + if !session.name.is_empty() { + info = info.title(session.name); } info } @@ -291,6 +296,7 @@ fn current_thinking_effort_value(model_config: &ModelConfig) -> String { if model_config.is_reasoning_model() { model_config .thinking_effort() + .or_else(|| Config::global().get_goose_thinking_effort()) .map(|effort| effort.to_string()) .unwrap_or_else(|| "off".to_string()) } else { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 1883fec3b66a..1133c3cdf0a0 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -8,7 +8,6 @@ pub(super) use crate::acp::response_builder::{ }; use crate::acp::tools::AcpAwareToolMeta; use crate::acp::{PermissionDecision, ACP_CURRENT_MODEL}; -use crate::action_required_manager::ActionRequiredManager; use crate::agents::extension::{Envs, PLATFORM_EXTENSIONS}; use crate::agents::extension_manager::TRUSTED_TOOL_UPDATE_META_KEY; use crate::agents::mcp_client::{GooseMcpHostInfo, McpClientTrait}; @@ -44,10 +43,10 @@ use agent_client_protocol::schema::{ AuthenticateResponse, BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse, ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities, - ForkSessionRequest, ForkSessionResponse, ImageContent, InitializeRequest, InitializeResponse, - ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, - McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption, - PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + ForkSessionRequest, ForkSessionResponse, ImageContent, Implementation, InitializeRequest, + InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, + LoadSessionResponse, McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, + PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionCloseCapabilities, SessionConfigOption, SessionId, SessionInfoUpdate, SessionListCapabilities, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, @@ -63,9 +62,8 @@ use agent_client_protocol::{ }; use anyhow::Result; use fs_err as fs; -use futures::future::BoxFuture; +use futures::future::{BoxFuture, FutureExt}; use futures::stream::{self, StreamExt}; -use futures::FutureExt; use rmcp::model::{ AnnotateAble, CallToolResult, RawContent, RawTextContent, ResourceContents, Role, }; @@ -85,6 +83,7 @@ mod config; mod custom_dispatch; mod dictation; mod dispatch; +mod elicitation; mod extensions; mod fork_session; mod list_sessions; @@ -95,12 +94,13 @@ mod onboarding; mod providers; mod resources; mod sources; +mod tool_notifications; mod tools; pub type AcpProviderFactory = Arc< dyn Fn( String, - crate::model::ModelConfig, + goose_providers::model::ModelConfig, Vec, Option, ) -> BoxFuture<'static, Result>> @@ -170,8 +170,11 @@ struct GooseAcpSession { /// Tool_call_ids of chains that have already had a summary task fired. /// Idempotence guard so we summarize each chain at most once. summarized_chains: HashSet, - cancel_token: Option, - active_run_id: Option, +} + +struct ActivePromptRun { + run_id: String, + cancel_token: CancellationToken, } /// A run of consecutive ToolRequest blocks within one assistant message, @@ -198,12 +201,15 @@ pub struct GooseAcpAgentOptions { pub struct GooseAcpAgent { sessions: Arc>>, + active_prompt_runs: Arc>>, + closed_session_ids: Arc>>, agent_manager: Arc, provider_factory: AcpProviderFactory, builtins: Vec, client_fs_capabilities: OnceCell, client_terminal: OnceCell, client_mcp_host_info: OnceCell, + client_supports_acp_elicitation: OnceCell, client_supports_goose_custom_notifications: OnceCell, use_login_shell_path: OnceCell, client_cx: OnceCell>, @@ -351,6 +357,7 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result, extension: E fn resolve_default_provider_model_config( config: &Config, -) -> Result<(String, crate::model::ModelConfig), agent_client_protocol::Error> { +) -> Result<(String, goose_providers::model::ModelConfig), agent_client_protocol::Error> { let resolved_provider = config.get_goose_provider().map_err(|error| { agent_client_protocol::Error::internal_error() .data(format!("Failed to resolve provider: {}", error)) @@ -401,30 +408,32 @@ fn resolve_default_provider_model_config( agent_client_protocol::Error::internal_error() .data(format!("Failed to resolve model: {}", error)) })?; - let resolved_model_config = crate::model::ModelConfig::new(&resolved_model) - .map(|model_config| model_config.with_canonical_limits(&resolved_provider)) - .map_err(|error| { - agent_client_protocol::Error::internal_error() - .data(format!("Failed to resolve model: {}", error)) - })?; + let resolved_model_config = + crate::model_config::model_config_from_user_config(&resolved_provider, &resolved_model) + .map_err(|error| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to resolve model: {}", error)) + })?; Ok((resolved_provider, resolved_model_config)) } async fn resolve_provider_default_model_config( provider_name: &str, -) -> Result { +) -> Result { let entry = crate::providers::get_from_registry(provider_name) .await .map_err(|error| { agent_client_protocol::Error::invalid_params() .data(format!("Unknown provider '{}': {}", provider_name, error)) })?; - crate::model::ModelConfig::new(&entry.metadata().default_model) - .map(|model_config| model_config.with_canonical_limits(provider_name)) - .map_err(|error| { - agent_client_protocol::Error::internal_error() - .data(format!("Failed to resolve model: {}", error)) - }) + crate::model_config::model_config_from_user_config( + provider_name, + &entry.metadata().default_model, + ) + .map_err(|error| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to resolve model: {}", error)) + }) } fn get_requested_line(arguments: Option<&rmcp::model::JsonObject>) -> Option { @@ -846,6 +855,13 @@ impl GooseAcpAgent { .unwrap_or(false) } + fn supports_acp_elicitation(&self) -> bool { + self.client_supports_acp_elicitation + .get() + .copied() + .unwrap_or(false) + } + // TODO: goose reads Paths::in_state_dir globally (e.g. RequestLog), ignoring this data_dir. pub async fn new(options: GooseAcpAgentOptions) -> Result { let session_manager = Arc::new(SessionManager::new(options.data_dir)); @@ -870,12 +886,15 @@ impl GooseAcpAgent { Ok(Self { sessions: Arc::new(Mutex::new(HashMap::new())), + active_prompt_runs: Arc::new(Mutex::new(HashMap::new())), + closed_session_ids: Arc::new(Mutex::new(HashSet::new())), agent_manager, provider_factory: options.provider_factory, builtins: options.builtins, client_fs_capabilities: OnceCell::new(), client_terminal: OnceCell::new(), client_mcp_host_info: OnceCell::new(), + client_supports_acp_elicitation: OnceCell::new(), client_supports_goose_custom_notifications: OnceCell::new(), use_login_shell_path: OnceCell::new(), client_cx: OnceCell::new(), @@ -895,7 +914,7 @@ impl GooseAcpAgent { async fn create_provider( &self, provider_name: &str, - model_config: crate::model::ModelConfig, + model_config: goose_providers::model::ModelConfig, extensions: Vec, working_dir: Option, ) -> Result> { @@ -1144,8 +1163,6 @@ impl GooseAcpAgent { chain_membership: HashMap::new(), responded_tool_ids: HashSet::new(), summarized_chains: HashSet::new(), - cancel_token: None, - active_run_id: None, }; self.sessions.lock().await.insert(session_id, acp_session); } @@ -1319,20 +1336,15 @@ impl GooseAcpAgent { message, requested_schema, } => { - send_elicitation_interaction_update( + self.handle_form_elicitation( cx, - self.supports_goose_custom_notifications(), - session_id.0.as_ref(), - InteractionUpdate { - interaction: Interaction::Elicitation { - id: id.clone(), - state: InteractionState::Pending, - message: Some(message.clone()), - requested_schema: Some(requested_schema.clone()), - }, - meta: Some(interaction_update_meta(message_id, message_created)), - }, - )?; + session_id, + id, + message, + requested_schema, + message_update_meta(message_id, message_created, false), + ) + .await?; } ActionRequiredData::ElicitationResponse { .. } => {} }, @@ -1943,25 +1955,6 @@ fn status_message_from_system_notification( } } -fn send_elicitation_interaction_update( - cx: &ConnectionTo, - supports_goose_custom_notifications: bool, - session_id: &str, - update: InteractionUpdate, -) -> Result<(), agent_client_protocol::Error> { - if supports_goose_custom_notifications { - cx.send_notification(GooseSessionNotification { - session_id: session_id.to_string(), - update: GooseSessionUpdate::InteractionUpdate(update), - })?; - } - Ok(()) -} - -fn interaction_update_meta(message_id: Option<&str>, created: i64) -> serde_json::Value { - serde_json::Value::Object(message_update_meta(message_id, created, false)) -} - fn message_update_meta(message_id: Option<&str>, created: i64, steer: bool) -> Meta { let mut goose = serde_json::Map::new(); goose.insert("created".to_string(), serde_json::json!(created)); @@ -2102,6 +2095,9 @@ impl GooseAcpAgent { let _ = self.client_supports_goose_custom_notifications.set( extract_client_supports_goose_custom_notifications(goose_client_capabilities.as_ref()), ); + let _ = self + .client_supports_acp_elicitation + .set(elicitation::client_supports_form_elicitation(&args)); let _ = self .use_login_shell_path .set(extract_use_login_shell_path(&args)); @@ -2121,6 +2117,7 @@ impl GooseAcpAgent { ) .mcp_capabilities(McpCapabilities::new().http(true)); Ok(InitializeResponse::new(args.protocol_version) + .agent_info(Implementation::new("goose", env!("CARGO_PKG_VERSION"))) .agent_capabilities(capabilities) .auth_methods(vec![AuthMethod::Agent( AuthMethodAgent::new("goose-provider", "Configure Provider") @@ -2136,19 +2133,21 @@ impl GooseAcpAgent { self.handle_new_session(cx, args).await } - /// Look up the session's agent. Optionally sets a cancellation token on - /// the session (needed by `on_prompt`). + /// Look up the session's agent. async fn get_session_agent( &self, session_id: &str, - cancel_token: Option, ) -> Result, agent_client_protocol::Error> { + if self.closed_session_ids.lock().await.contains(session_id) { + return Err(agent_client_protocol::Error::resource_not_found(Some( + session_id.to_string(), + )) + .data(format!("Session not found: {}", session_id))); + } + { - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get_mut(session_id) { - if let Some(token) = cancel_token { - session.cancel_token = Some(token); - } + let sessions = self.sessions.lock().await; + if let Some(session) = sessions.get(session_id) { return Ok(session.agent.clone()); } } @@ -2168,13 +2167,6 @@ impl GooseAcpAgent { let (agent, _) = self .activate_acp_session(cx, &session, HashMap::new()) .await?; - - if let Some(token) = cancel_token { - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get_mut(session_id) { - session.cancel_token = Some(token); - } - } Ok(agent) } @@ -2184,37 +2176,59 @@ impl GooseAcpAgent { run_id: String, cancel_token: CancellationToken, ) -> Result<(), agent_client_protocol::Error> { - let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(session_id).ok_or_else(|| { - agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) - .data(format!("Session not found: {}", session_id)) - })?; + if self.closed_session_ids.lock().await.contains(session_id) { + return Err(agent_client_protocol::Error::resource_not_found(Some( + session_id.to_string(), + )) + .data(format!("Session not found: {}", session_id))); + } - if let Some(active_run_id) = &session.active_run_id { + let mut active_prompt_runs = self.active_prompt_runs.lock().await; + if let Some(active_run) = active_prompt_runs.get(session_id) { return Err(agent_client_protocol::Error::invalid_params().data(format!( - "session already has active run `{active_run_id}`; use _goose/unstable/session/steer" + "session already has active run `{}`; use _goose/unstable/session/steer", + active_run.run_id.as_str() ))); } - session.cancel_token = Some(cancel_token); - session.active_run_id = Some(run_id); + active_prompt_runs.insert( + session_id.to_string(), + ActivePromptRun { + run_id, + cancel_token, + }, + ); Ok(()) } async fn clear_active_run(&self, session_id: &str, run_id: &str) { - let agent = { - let mut sessions = self.sessions.lock().await; - let Some(session) = sessions.get_mut(session_id) else { + { + let mut active_prompt_runs = self.active_prompt_runs.lock().await; + let Some(active_run) = active_prompt_runs.get(session_id) else { return; }; - if session.active_run_id.as_deref() != Some(run_id) { + + if active_run.run_id != run_id { return; } - session.cancel_token = None; - session.active_run_id = None; - session.agent.clone() + + active_prompt_runs.remove(session_id); + } + + let agent = { + let sessions = self.sessions.lock().await; + sessions + .get(session_id) + .map(|session| session.agent.clone()) }; - agent.discard_pending_steers(session_id).await; + if let Some(agent) = agent { + agent.discard_pending_steers(session_id).await; + } + + if self.closed_session_ids.lock().await.contains(session_id) { + self.sessions.lock().await.remove(session_id); + let _ = self.agent_manager.remove_session(session_id).await; + } } async fn require_active_run( @@ -2227,26 +2241,23 @@ impl GooseAcpAgent { .data("expectedRunId must not be empty")); } - let sessions = self.sessions.lock().await; - let session = sessions.get(session_id).ok_or_else(|| { - agent_client_protocol::Error::resource_not_found(Some(session_id.to_string())) - .data(format!("Session not found: {}", session_id)) - })?; - let active_run_id = session.active_run_id.as_ref().ok_or_else(|| { + let active_prompt_runs = self.active_prompt_runs.lock().await; + let active_run = active_prompt_runs.get(session_id).ok_or_else(|| { agent_client_protocol::Error::invalid_params().data("no active run to steer") })?; - if active_run_id != expected_run_id { + if active_run.run_id != expected_run_id { return Err( agent_client_protocol::Error::invalid_params().data(serde_json::json!({ "message": format!( - "expected active run id `{expected_run_id}` but found `{active_run_id}`" + "expected active run id `{expected_run_id}` but found `{}`", + active_run.run_id.as_str() ), "expectedRunId": expected_run_id, - "actualRunId": active_run_id, + "actualRunId": active_run.run_id.as_str(), })), ); } - Ok(active_run_id.clone()) + Ok(active_run.run_id.clone()) } fn active_run_meta(active_run_id: Option<&str>) -> Meta { @@ -2358,20 +2369,26 @@ impl GooseAcpAgent { let cancel_token = CancellationToken::new(); self.start_active_run(&session_id, run_id.clone(), cancel_token.clone()) .await?; - if let Err(error) = Self::send_active_run_update(cx, &args.session_id, Some(&run_id)) { - self.clear_active_run(&session_id, &run_id).await; - return Err(error); - } - let agent = match self.get_session_agent(&session_id, None).await { + let agent = match self.get_session_agent(&session_id).await { Ok(agent) => agent, Err(error) => { self.clear_active_run(&session_id, &run_id).await; - let _ = Self::send_active_run_update(cx, &args.session_id, None); return Err(error); } }; + if cancel_token.is_cancelled() { + self.clear_active_run(&session_id, &run_id).await; + Self::send_active_run_update(cx, &args.session_id, None)?; + return Ok(PromptResponse::new(StopReason::Cancelled)); + } + + if let Err(error) = Self::send_active_run_update(cx, &args.session_id, Some(&run_id)) { + self.clear_active_run(&session_id, &run_id).await; + return Err(error); + } + let user_message = Self::convert_acp_prompt_to_message(&args.prompt); let message_text = user_message.as_concat_text(); @@ -2525,6 +2542,16 @@ impl GooseAcpAgent { break; } } + Ok(crate::agents::AgentEvent::McpNotification((request_id, notification))) => { + if let Some(update) = + tool_notifications::tool_notification_update(request_id, notification) + { + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + update, + ))?; + } + } Ok(_) => {} Err(e) => { stream_error = Some( @@ -2603,7 +2630,7 @@ impl GooseAcpAgent { self.require_active_run(&req.session_id, &req.expected_run_id) .await?; - let agent = self.get_session_agent(&req.session_id, None).await?; + let agent = self.get_session_agent(&req.session_id).await?; let active_run_id = self .require_active_run(&req.session_id, &req.expected_run_id) .await?; @@ -2640,81 +2667,44 @@ impl GooseAcpAgent { debug!(?args, "cancel request"); let session_id = args.session_id.0.to_string(); - let mut sessions = self.sessions.lock().await; + let token = { + let active_prompt_runs = self.active_prompt_runs.lock().await; + active_prompt_runs + .get(&session_id) + .map(|active_run| active_run.cancel_token.clone()) + }; - if let Some(session) = sessions.get_mut(&session_id) { - if let Some(ref token) = session.cancel_token { - info!(session_id = %session_id, "prompt cancelled"); - token.cancel(); - } - } else { + if let Some(token) = token { + info!(session_id = %session_id, "prompt cancelled"); + token.cancel(); + } else if !self.sessions.lock().await.contains_key(&session_id) { warn!(session_id = %session_id, "cancel request for unknown session"); } Ok(()) } - async fn on_elicitation_respond( - &self, - cx: &ConnectionTo, - req: ElicitationRespondRequest, - ) -> Result { - ActionRequiredManager::global() - .submit_response(req.elicitation_id.clone(), req.user_data.clone()) - .await - .invalid_params_err_ctx("Failed to submit elicitation response")?; - - let response_message = Message::user() - .with_generated_id() - .with_content(MessageContent::action_required_elicitation_response( - req.elicitation_id.clone(), - req.user_data, - )) - .agent_only(); - - self.session_manager - .add_message(&req.session_id, &response_message) - .await - .internal_err_ctx("Failed to persist elicitation response")?; - - send_elicitation_interaction_update( - cx, - self.supports_goose_custom_notifications(), - &req.session_id, - InteractionUpdate { - interaction: Interaction::Elicitation { - id: req.elicitation_id, - state: InteractionState::Submitted, - message: None, - requested_schema: None, - }, - meta: Some(interaction_update_meta( - response_message.id.as_deref(), - response_message.created, - )), - }, - )?; - - Ok(EmptyResponse {}) - } - async fn on_set_model( &self, session_id: &str, model_id: &str, ) -> Result { - let agent = self.get_session_agent(session_id, None).await?; + let agent = self.get_session_agent(session_id).await?; let current_provider = agent .provider() .await .internal_err_ctx("Failed to get provider")?; let provider_name = current_provider.get_name().to_string(); let current_model_config = current_provider.get_model_config(); - let model_config = crate::model::ModelConfig::new(model_id) - .invalid_params_err_ctx("Invalid model config")? - .with_canonical_limits(&provider_name); let model_config = - model_config.with_inherited_session_settings_from(Some(¤t_model_config), None); + crate::model_config::model_config_from_user_config_with_session_settings( + &provider_name, + model_id, + Some(¤t_model_config), + None, + None, + ) + .invalid_params_err_ctx("Invalid model config")?; agent .recreate_provider_for_session(session_id, &provider_name, model_config) .await @@ -2732,7 +2722,7 @@ impl GooseAcpAgent { .get_session(&session_id.0, false) .await .internal_err()?; - let agent = self.get_session_agent(&session_id.0, None).await?; + let agent = self.get_session_agent(&session_id.0).await?; let provider = agent .provider() .await @@ -2777,7 +2767,7 @@ impl GooseAcpAgent { .data(format!("Invalid mode: {}", mode_id)) })?; - let agent = self.get_session_agent(session_id, None).await?; + let agent = self.get_session_agent(session_id).await?; agent .update_goose_mode(mode, session_id) .await @@ -2799,7 +2789,7 @@ impl GooseAcpAgent { agent_client_protocol::Error::invalid_params() .data(format!("Invalid thinking effort: {}", effort_id)) })?; - let agent = self.get_session_agent(session_id, None).await?; + let agent = self.get_session_agent(session_id).await?; agent .update_thinking_effort(session_id, effort) .await @@ -2817,7 +2807,7 @@ impl GooseAcpAgent { request_params: Option>, ) -> Result<(), agent_client_protocol::Error> { let config = self.config()?; - let agent = self.get_session_agent(session_id, None).await?; + let agent = self.get_session_agent(session_id).await?; let current_provider = agent .provider() .await @@ -2846,12 +2836,15 @@ impl GooseAcpAgent { current_model }; let model = model_name.unwrap_or(&default_model); - let mut model_config = crate::model::ModelConfig::new(model) - .invalid_params_err_ctx("Invalid model config")? - .with_canonical_limits(&resolved_provider_name) - .with_context_limit(context_limit); - model_config = model_config - .with_inherited_session_settings_from(Some(¤t_model_config), request_params); + let model_config = + crate::model_config::model_config_from_user_config_with_session_settings( + &resolved_provider_name, + model, + Some(¤t_model_config), + request_params, + context_limit, + ) + .invalid_params_err_ctx("Invalid model config")?; agent .recreate_provider_for_session(session_id, &resolved_provider_name, model_config) @@ -2874,12 +2867,23 @@ impl GooseAcpAgent { &self, session_id: &str, ) -> Result { - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get(session_id) { - if let Some(ref token) = session.cancel_token { - token.cancel(); - } + self.closed_session_ids + .lock() + .await + .insert(session_id.to_string()); + + let active_run_token = { + let active_prompt_runs = self.active_prompt_runs.lock().await; + active_prompt_runs + .get(session_id) + .map(|active_run| active_run.cancel_token.clone()) + }; + + if let Some(token) = active_run_token { + token.cancel(); } + + let mut sessions = self.sessions.lock().await; sessions.remove(session_id); drop(sessions); @@ -2971,6 +2975,7 @@ mod tests { ), env_keys: vec![], timeout: None, + cwd: None, bundled: Some(false), available_tools: vec![], }) @@ -3713,6 +3718,7 @@ print(\"hello, world\") goose_mode: GooseMode::default(), archived_at: None, project_id: None, + last_message_snippet: None, } } @@ -3751,7 +3757,7 @@ print(\"hello, world\") fn test_build_usage_update_clamps_negative_used_to_zero() { let mut session = make_session_with_usage(Some(-7), Some(0), Some(0), None, None, None); session.model_config = Some( - crate::model::ModelConfig::new("test-model") + goose_providers::model::ModelConfig::new("test-model") .unwrap() .with_context_limit(Some(258_000)), ); diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 943685b5d01b..ed45793b9e92 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -329,13 +329,12 @@ impl GooseAcpAgent { self.on_get_session_info(req).await } - #[custom_method(ElicitationRespondRequest)] - async fn dispatch_elicitation_respond( + #[custom_method(TruncateSessionConversationRequest)] + async fn dispatch_truncate_session_conversation( &self, - _req: ElicitationRespondRequest, + req: TruncateSessionConversationRequest, ) -> Result { - Err(agent_client_protocol::Error::invalid_params() - .data("_goose/unstable/elicitation/respond must be handled by the connection-scoped dispatcher")) + self.on_truncate_session_conversation(req).await } #[custom_method(UpdateSessionProjectRequest)] diff --git a/crates/goose/src/acp/server/dispatch.rs b/crates/goose/src/acp/server/dispatch.rs index 2e622f3893f8..f01285df8c98 100644 --- a/crates/goose/src/acp/server/dispatch.rs +++ b/crates/goose/src/acp/server/dispatch.rs @@ -95,19 +95,6 @@ impl HandleDispatchFrom for GooseAcpHandler { Ok(()) }) .await - .if_request({ - let agent = agent.clone(); - let cx = cx.clone(); - |req: ElicitationRespondRequest, responder: Responder| async move { - let cx_spawn = cx.clone(); - cx.spawn(async move { - responder.respond_with_result(agent.on_elicitation_respond(&cx_spawn, req).await)?; - Ok(()) - })?; - Ok(()) - } - }) - .await // set_config_option (SACP 11) and legacy set_mode/set_model; custom _goose/* in otherwise. .if_request({ let agent = agent.clone(); @@ -188,7 +175,7 @@ impl HandleDispatchFrom for GooseAcpHandler { let provider_result: Result> = AssertUnwindSafe(async { let session_agent = - agent_bg.get_session_agent(&session_id_bg.0, None).await?; + agent_bg.get_session_agent(&session_id_bg.0).await?; let provider = session_agent .provider() .await diff --git a/crates/goose/src/acp/server/elicitation.rs b/crates/goose/src/acp/server/elicitation.rs new file mode 100644 index 000000000000..10c395d444a0 --- /dev/null +++ b/crates/goose/src/acp/server/elicitation.rs @@ -0,0 +1,253 @@ +use std::sync::Arc; + +use agent_client_protocol::schema::{ + CreateElicitationRequest, CreateElicitationResponse, ElicitationAction as AcpElicitationAction, + ElicitationFormMode, ElicitationSchema, ElicitationSessionScope, Meta, SessionId, + CLIENT_METHOD_NAMES, +}; +use agent_client_protocol::{ + Client, ConnectionTo, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, UntypedMessage, +}; +use tracing::warn; + +use crate::action_required_manager::ElicitationOutcome; +use crate::session::SessionManager; + +impl super::GooseAcpAgent { + pub(super) async fn handle_form_elicitation( + &self, + cx: &ConnectionTo, + session_id: &SessionId, + elicitation_id: &str, + message: &str, + requested_schema: &serde_json::Value, + meta: Meta, + ) -> Result<(), agent_client_protocol::Error> { + if self.supports_acp_elicitation() { + self.send_form_elicitation( + cx, + session_id, + elicitation_id, + message, + requested_schema, + meta, + ) + .await?; + } else { + warn!( + session_id = %session_id.0.as_ref(), + elicitation_id = %elicitation_id, + "ACP client does not support form elicitation" + ); + self.cancel_form_elicitation(session_id.0.as_ref(), elicitation_id) + .await; + } + + Ok(()) + } + + async fn send_form_elicitation( + &self, + cx: &ConnectionTo, + session_id: &SessionId, + elicitation_id: &str, + message: &str, + requested_schema: &serde_json::Value, + meta: Meta, + ) -> Result<(), agent_client_protocol::Error> { + let session_id = session_id.0.as_ref().to_string(); + let elicitation_id = elicitation_id.to_string(); + if requested_schema + .get("url") + .and_then(|url| url.as_str()) + .is_some() + { + warn!( + session_id = %session_id, + elicitation_id = %elicitation_id, + "ACP URL elicitation is not supported" + ); + record_acp_elicitation_response( + &self.session_manager, + &session_id, + &elicitation_id, + ElicitationOutcome::Cancel, + ) + .await; + return Ok(()); + } + + let requested_schema: ElicitationSchema = + match serde_json::from_value(requested_schema.clone()) { + Ok(schema) => schema, + Err(error) => { + record_acp_elicitation_response( + &self.session_manager, + &session_id, + &elicitation_id, + ElicitationOutcome::Cancel, + ) + .await; + return Err(agent_client_protocol::Error::internal_error() + .data(format!("Failed to parse ACP elicitation schema: {error}"))); + } + }; + let request = CreateElicitationRequest::new( + ElicitationFormMode::new( + ElicitationSessionScope::new(session_id.clone()), + requested_schema, + ), + message.to_string(), + ) + .meta(meta); + + let callback_session_manager = Arc::clone(&self.session_manager); + let callback_session_id = session_id.clone(); + let callback_elicitation_id = elicitation_id.clone(); + if let Err(error) = cx + .send_request(CreateElicitationRequestMessage(request)) + .on_receiving_result(move |result| async move { + let response = match result { + Ok(response) => elicitation_response_from_acp(response.0), + Err(error) => { + warn!( + error = %error, + session_id = %callback_session_id, + elicitation_id = %callback_elicitation_id, + "ACP elicitation request failed" + ); + ElicitationOutcome::Cancel + } + }; + + record_acp_elicitation_response( + &callback_session_manager, + &callback_session_id, + &callback_elicitation_id, + response, + ) + .await; + + Ok(()) + }) + { + record_acp_elicitation_response( + &self.session_manager, + &session_id, + &elicitation_id, + ElicitationOutcome::Cancel, + ) + .await; + return Err(error); + } + + Ok(()) + } + + async fn cancel_form_elicitation(&self, session_id: &str, elicitation_id: &str) { + record_acp_elicitation_response( + &self.session_manager, + session_id, + elicitation_id, + ElicitationOutcome::Cancel, + ) + .await; + } +} + +#[derive(Debug, Clone)] +struct CreateElicitationRequestMessage(CreateElicitationRequest); + +impl JsonRpcMessage for CreateElicitationRequestMessage { + fn matches_method(method: &str) -> bool { + method == CLIENT_METHOD_NAMES.elicitation_create + } + + fn method(&self) -> &str { + CLIENT_METHOD_NAMES.elicitation_create + } + + fn to_untyped_message(&self) -> Result { + UntypedMessage::new(CLIENT_METHOD_NAMES.elicitation_create, &self.0) + } + + fn parse_message( + method: &str, + params: &impl serde::Serialize, + ) -> Result { + if !Self::matches_method(method) { + return Err(agent_client_protocol::Error::method_not_found()); + } + + Ok(Self(agent_client_protocol::util::json_cast_params(params)?)) + } +} + +impl JsonRpcRequest for CreateElicitationRequestMessage { + type Response = CreateElicitationResponseMessage; +} + +#[derive(Debug, Clone)] +struct CreateElicitationResponseMessage(CreateElicitationResponse); + +impl JsonRpcResponse for CreateElicitationResponseMessage { + fn into_json(self, _method: &str) -> Result { + serde_json::to_value(self.0).map_err(agent_client_protocol::Error::into_internal_error) + } + + fn from_value( + _method: &str, + value: serde_json::Value, + ) -> Result { + Ok(Self(agent_client_protocol::util::json_cast(&value)?)) + } +} + +pub(super) fn client_supports_form_elicitation( + args: &agent_client_protocol::schema::InitializeRequest, +) -> bool { + args.client_capabilities + .elicitation + .as_ref() + .and_then(|elicitation| elicitation.form.as_ref()) + .is_some() +} + +fn elicitation_response_from_acp(response: CreateElicitationResponse) -> ElicitationOutcome { + match response.action { + AcpElicitationAction::Accept(action) => { + let content = serde_json::to_value(action.content.unwrap_or_default()) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())); + ElicitationOutcome::Accept(content) + } + AcpElicitationAction::Decline => ElicitationOutcome::Decline, + AcpElicitationAction::Cancel => ElicitationOutcome::Cancel, + action => { + warn!(?action, "Unsupported ACP elicitation action"); + ElicitationOutcome::Cancel + } + } +} + +async fn record_acp_elicitation_response( + session_manager: &SessionManager, + session_id: &str, + elicitation_id: &str, + response: ElicitationOutcome, +) { + if let Err(error) = crate::elicitation::complete_elicitation_with_generated_message( + session_manager, + session_id, + elicitation_id, + response, + ) + .await + { + warn!( + error = %error, + session_id = %session_id, + elicitation_id = %elicitation_id, + "Failed to record ACP elicitation response" + ); + } +} diff --git a/crates/goose/src/acp/server/extensions.rs b/crates/goose/src/acp/server/extensions.rs index ff58cb21d358..fae9bdadba6d 100644 --- a/crates/goose/src/acp/server/extensions.rs +++ b/crates/goose/src/acp/server/extensions.rs @@ -12,7 +12,7 @@ impl GooseAcpAgent { let config: ExtensionConfig = serde_json::from_value(req.config).map_err(|e| { agent_client_protocol::Error::invalid_params().data(format!("bad config: {e}")) })?; - let agent = self.get_session_agent(&req.session_id, None).await?; + let agent = self.get_session_agent(&req.session_id).await?; agent .add_extension(config, session_id) .await @@ -25,7 +25,7 @@ impl GooseAcpAgent { req: RemoveExtensionRequest, ) -> Result { let session_id = &req.session_id; - let agent = self.get_session_agent(&req.session_id, None).await?; + let agent = self.get_session_agent(&req.session_id).await?; agent .remove_extension(&req.name, session_id) .await @@ -279,6 +279,7 @@ fn goose_extension_to_config( envs: Envs::default(), env_keys, timeout, + cwd: None, bundled, available_tools: Vec::new(), } @@ -421,6 +422,7 @@ mod tests { )])), env_keys: vec!["SECRET_TOKEN".to_string()], timeout: Some(42), + cwd: None, bundled: None, available_tools: vec![], }; @@ -596,6 +598,7 @@ mod tests { timeout, bundled, available_tools, + .. } = conversion.config else { panic!("expected stdio config"); diff --git a/crates/goose/src/acp/server/fork_session.rs b/crates/goose/src/acp/server/fork_session.rs index 976dd047dff8..cb2b399f52ba 100644 --- a/crates/goose/src/acp/server/fork_session.rs +++ b/crates/goose/src/acp/server/fork_session.rs @@ -8,6 +8,7 @@ impl GooseAcpAgent { args: ForkSessionRequest, ) -> Result { validate_absolute_cwd(&args.cwd)?; + let conversation_before = conversation_before_from_meta(args.meta.as_ref())?; let source_session_id = &*args.session_id.0; let source = self @@ -28,7 +29,14 @@ impl GooseAcpAgent { .internal_err()?; let new_session_id = new_session.id.clone(); - let goose_session = self + if let Some(conversation_before) = conversation_before { + self.session_manager + .truncate_conversation(&new_session_id, conversation_before) + .await + .internal_err()?; + } + + let new_session = self .session_manager .get_session(&new_session_id, false) .await @@ -36,7 +44,7 @@ impl GooseAcpAgent { let goose_session = self .prepare_session_for_activation( - goose_session, + new_session.clone(), args.cwd.clone(), args.mcp_servers, false, @@ -74,3 +82,70 @@ impl GooseAcpAgent { Ok(response) } } + +fn conversation_before_from_meta( + meta: Option<&Meta>, +) -> Result, agent_client_protocol::Error> { + let Some(value) = meta.and_then(|meta| meta.get("conversationBefore")) else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + + value.as_i64().map(Some).ok_or_else(|| { + agent_client_protocol::Error::invalid_params() + .data("conversationBefore must be an integer timestamp") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta_with_conversation_before(value: serde_json::Value) -> Meta { + let mut meta = Meta::new(); + meta.insert("conversationBefore".to_string(), value); + meta + } + + #[test] + fn conversation_before_from_meta_returns_none_when_absent() { + assert_eq!(conversation_before_from_meta(None).unwrap(), None); + assert_eq!( + conversation_before_from_meta(Some(&Meta::new())).unwrap(), + None + ); + } + + #[test] + fn conversation_before_from_meta_treats_null_as_absent() { + let meta = meta_with_conversation_before(serde_json::Value::Null); + + assert_eq!(conversation_before_from_meta(Some(&meta)).unwrap(), None); + } + + #[test] + fn conversation_before_from_meta_reads_integer_timestamp() { + let meta = meta_with_conversation_before(serde_json::json!(1_718_000_000)); + + assert_eq!( + conversation_before_from_meta(Some(&meta)).unwrap(), + Some(1_718_000_000) + ); + } + + #[test] + fn conversation_before_from_meta_rejects_non_integer_timestamp() { + for value in [ + serde_json::json!("1718000000"), + serde_json::json!(1718000000.5), + serde_json::json!(true), + serde_json::json!({ "created": 1718000000 }), + ] { + assert!( + conversation_before_from_meta(Some(&meta_with_conversation_before(value))).is_err() + ); + } + } +} diff --git a/crates/goose/src/acp/server/list_sessions.rs b/crates/goose/src/acp/server/list_sessions.rs index ada46233c8b9..48e51f10e82c 100644 --- a/crates/goose/src/acp/server/list_sessions.rs +++ b/crates/goose/src/acp/server/list_sessions.rs @@ -70,6 +70,32 @@ fn session_types_from_meta( } } +fn include_last_message_snippet_from_meta( + meta: Option<&Meta>, +) -> Result { + let Some(value) = meta.and_then(|meta| meta.get("goose")) else { + return Ok(false); + }; + if value.is_null() { + return Ok(false); + } + + let Some(goose_meta) = value.as_object() else { + return Err(agent_client_protocol::Error::invalid_params().data("goose must be an object")); + }; + let Some(value) = goose_meta.get("includeLastMessageSnippet") else { + return Ok(false); + }; + if value.is_null() { + return Ok(false); + } + + value.as_bool().ok_or_else(|| { + agent_client_protocol::Error::invalid_params() + .data("goose.includeLastMessageSnippet must be a boolean") + }) +} + // bind cursors to the effective filters so they cannot be reused for a different list. fn session_list_filter_hash( cwd: Option<&std::path::Path>, @@ -156,6 +182,8 @@ impl GooseAcpAgent { let cwd = req.cwd.as_deref(); let keyword = session_keyword_from_meta(req.meta.as_ref())?; let session_types = session_types_from_meta(req.meta.as_ref())?; + let include_last_message_snippet = + include_last_message_snippet_from_meta(req.meta.as_ref())?; let cursor = decode_session_list_cursor( req.cursor.as_deref(), cwd, @@ -175,9 +203,11 @@ impl GooseAcpAgent { }, cursor: cursor.as_ref(), page_size: SESSION_LIST_PAGE_SIZE, + include_last_message_snippet, }) .await .internal_err()?; + let session_infos: Vec = page.sessions.into_iter().map(build_session_info).collect(); let next_cursor = page diff --git a/crates/goose/src/acp/server/load_session.rs b/crates/goose/src/acp/server/load_session.rs index 94681a3bcf47..9600ec4312d4 100644 --- a/crates/goose/src/acp/server/load_session.rs +++ b/crates/goose/src/acp/server/load_session.rs @@ -29,7 +29,6 @@ fn send_replay_content_chunk( fn replay_conversation_to_client( cx: &ConnectionTo, session: &Session, - supports_goose_custom_notifications: bool, ) -> Result, agent_client_protocol::Error> { let session_id = SessionId::new(session.id.clone()); @@ -49,7 +48,6 @@ fn replay_conversation_to_client( let mut replay_tool_requests = HashMap::::new(); - let submitted_elicitation_ids = collect_submitted_elicitation_ids(&messages); for message in &messages { if !message.metadata.user_visible { @@ -156,33 +154,6 @@ fn replay_conversation_to_client( ), ))?; } - MessageContent::ActionRequired(action_required) => { - if let ActionRequiredData::Elicitation { - id, - message: elicitation_message, - requested_schema, - } = &action_required.data - { - if !submitted_elicitation_ids.contains(id) { - send_elicitation_interaction_update( - cx, - supports_goose_custom_notifications, - session_id.0.as_ref(), - InteractionUpdate { - interaction: Interaction::Elicitation { - id: id.clone(), - state: InteractionState::Pending, - message: Some(elicitation_message.clone()), - requested_schema: Some(requested_schema.clone()), - }, - meta: Some(serde_json::Value::Object(replay_message_meta( - message, - ))), - }, - )?; - } - } - } MessageContent::SystemNotification(_) => {} _ => {} } @@ -192,22 +163,6 @@ fn replay_conversation_to_client( Ok(replay_tool_requests) } -fn collect_submitted_elicitation_ids(messages: &[Message]) -> HashSet { - let mut submitted_ids = HashSet::new(); - - for message in messages { - for content_item in &message.content { - if let MessageContent::ActionRequired(action_required) = content_item { - if let ActionRequiredData::ElicitationResponse { id, .. } = &action_required.data { - submitted_ids.insert(id.clone()); - } - } - } - } - - submitted_ids -} - impl GooseAcpAgent { pub(super) async fn handle_load_session( &self, @@ -234,11 +189,7 @@ impl GooseAcpAgent { .prepare_session_for_activation(session, args.cwd.clone(), args.mcp_servers, true) .await?; - let replay_tool_requests = replay_conversation_to_client( - cx, - &session, - self.supports_goose_custom_notifications(), - )?; + let replay_tool_requests = replay_conversation_to_client(cx, &session)?; let (agent, extension_results) = self.prepare_acp_session_agent(cx, &session).await?; self.register_acp_session(session_id_str.clone(), agent.clone(), replay_tool_requests) .await; @@ -295,6 +246,7 @@ impl GooseAcpAgent { ms = t_start.elapsed().as_millis() as u64, "perf: load_session_refactor done" ); + self.closed_session_ids.lock().await.remove(&session_id_str); Ok(response) } } diff --git a/crates/goose/src/acp/server/manage_sessions.rs b/crates/goose/src/acp/server/manage_sessions.rs index a3382c208be5..15fd717bf3c2 100644 --- a/crates/goose/src/acp/server/manage_sessions.rs +++ b/crates/goose/src/acp/server/manage_sessions.rs @@ -42,7 +42,7 @@ impl GooseAcpAgent { ); } - let agent = self.get_session_agent(session_id, None).await?; + let agent = self.get_session_agent(session_id).await?; match req.mode { SessionSystemPromptMode::Set => { if req.text.trim().is_empty() { @@ -142,6 +142,24 @@ impl GooseAcpAgent { }) } + pub(super) async fn on_truncate_session_conversation( + &self, + req: TruncateSessionConversationRequest, + ) -> Result { + let session_id = req.session_id.trim(); + if session_id.is_empty() { + return Err( + agent_client_protocol::Error::invalid_params().data("sessionId cannot be empty") + ); + } + + self.session_manager + .truncate_conversation(session_id, req.truncate_from) + .await + .internal_err()?; + Ok(EmptyResponse {}) + } + pub(super) async fn on_update_session_project( &self, req: UpdateSessionProjectRequest, diff --git a/crates/goose/src/acp/server/onboarding.rs b/crates/goose/src/acp/server/onboarding.rs index 233f7a43e4f9..bb4176b51ecf 100644 --- a/crates/goose/src/acp/server/onboarding.rs +++ b/crates/goose/src/acp/server/onboarding.rs @@ -519,6 +519,7 @@ fn apply_claude_desktop_candidate( envs: Envs::new(server.env), env_keys: Vec::new(), timeout: Some(crate::config::DEFAULT_EXTENSION_TIMEOUT), + cwd: None, bundled: None, available_tools: Vec::new(), }, diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs index c24105fb098f..fc5f9671cb1f 100644 --- a/crates/goose/src/acp/server/providers.rs +++ b/crates/goose/src/acp/server/providers.rs @@ -446,8 +446,11 @@ impl GooseAcpAgent { let entry = crate::providers::get_from_registry(&req.provider_id) .await .invalid_params_err_ctx("Unknown provider")?; - let model_config = crate::model::ModelConfig::new(&entry.metadata().default_model) - .invalid_params_err_ctx("Invalid default model")?; + let model_config = crate::model_config::model_config_from_user_config( + &req.provider_id, + &entry.metadata().default_model, + ) + .invalid_params_err_ctx("Invalid default model")?; let provider = self .create_provider(&req.provider_id, model_config, Vec::new(), None) .await @@ -720,9 +723,10 @@ impl GooseAcpAgent { let mut refresh_guard = provider_inventory.refresh_guard(&identity); let provider_result = AssertUnwindSafe(async { let metadata = crate::providers::get_from_registry(&provider_id).await?; - let model_config = - crate::model::ModelConfig::new(&metadata.metadata().default_model)? - .with_canonical_limits(&provider_id); + let model_config = crate::model_config::model_config_from_user_config( + &provider_id, + &metadata.metadata().default_model, + )?; provider_factory(provider_id.clone(), model_config, Vec::new(), None).await }) .catch_unwind() diff --git a/crates/goose/src/acp/server/resources.rs b/crates/goose/src/acp/server/resources.rs index ba2f07bff5b1..8022b7c2ce53 100644 --- a/crates/goose/src/acp/server/resources.rs +++ b/crates/goose/src/acp/server/resources.rs @@ -6,7 +6,7 @@ impl GooseAcpAgent { req: ReadResourceRequest, ) -> Result { let session_id = &req.session_id; - let agent = self.get_session_agent(&req.session_id, None).await?; + let agent = self.get_session_agent(&req.session_id).await?; let cancel_token = CancellationToken::new(); let result = agent .extension_manager diff --git a/crates/goose/src/acp/server/tool_notifications.rs b/crates/goose/src/acp/server/tool_notifications.rs new file mode 100644 index 000000000000..412dd16c34b0 --- /dev/null +++ b/crates/goose/src/acp/server/tool_notifications.rs @@ -0,0 +1,138 @@ +use agent_client_protocol::schema::{ + Meta, SessionUpdate, ToolCallId, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, +}; +use rmcp::model::{LoggingMessageNotificationParam, ProgressNotificationParam, ServerNotification}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ToolNotification { + Message { + params: LoggingMessageNotificationParam, + }, + Progress { + params: ProgressNotificationParam, + }, +} + +pub(super) fn tool_notification_update( + tool_call_id: impl Into, + notification: ServerNotification, +) -> Option { + let tool_notification = match notification { + ServerNotification::LoggingMessageNotification(notification) => ToolNotification::Message { + params: notification.params, + }, + ServerNotification::ProgressNotification(notification) => ToolNotification::Progress { + params: notification.params, + }, + _ => return None, + }; + + let mut meta = Meta::new(); + meta.insert( + "toolNotification".to_string(), + serde_json::to_value(tool_notification).ok()?, + ); + + Some(SessionUpdate::ToolCallUpdate( + ToolCallUpdate::new( + tool_call_id, + ToolCallUpdateFields::new().status(ToolCallStatus::InProgress), + ) + .meta(meta), + )) +} + +#[cfg(test)] +mod tests { + use super::tool_notification_update; + use rmcp::model::{ + CancelledNotificationParam, LoggingLevel, LoggingMessageNotificationParam, Notification, + NumberOrString, ProgressNotificationParam, ProgressToken, ServerNotification, + }; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn maps_logging_message_notification_to_tool_update_meta() { + let notification = ServerNotification::LoggingMessageNotification(Notification::new( + LoggingMessageNotificationParam::new( + LoggingLevel::Info, + json!({ + "type": "subagent_tool_request", + "subagent_id": "session_1", + "tool_call": { + "name": "developer__shell" + } + }), + ) + .with_logger("subagent:session_1"), + )); + + let update = tool_notification_update("tool_1", notification).expect("expected update"); + let value = serde_json::to_value(update).expect("update should serialize"); + + assert_eq!(value["sessionUpdate"], "tool_call_update"); + assert_eq!(value["toolCallId"], "tool_1"); + assert_eq!(value["status"], "in_progress"); + assert_eq!(value["_meta"]["toolNotification"]["type"], "message"); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["level"], + "info" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["logger"], + "subagent:session_1" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["data"]["tool_call"]["name"], + "developer__shell" + ); + } + + #[test] + fn maps_progress_notification_to_tool_update_meta() { + let notification = ServerNotification::ProgressNotification(Notification::new( + ProgressNotificationParam::new( + ProgressToken(NumberOrString::String(Arc::from("scan-repo"))), + 3.0, + ) + .with_total(10.0) + .with_message("Scanned 3 of 10 directories"), + )); + + let update = tool_notification_update("tool_1", notification).expect("expected update"); + let value = serde_json::to_value(update).expect("update should serialize"); + + assert_eq!(value["sessionUpdate"], "tool_call_update"); + assert_eq!(value["toolCallId"], "tool_1"); + assert_eq!(value["status"], "in_progress"); + assert_eq!(value["_meta"]["toolNotification"]["type"], "progress"); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["progressToken"], + "scan-repo" + ); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["progress"], + 3.0 + ); + assert_eq!(value["_meta"]["toolNotification"]["params"]["total"], 10.0); + assert_eq!( + value["_meta"]["toolNotification"]["params"]["message"], + "Scanned 3 of 10 directories" + ); + } + + #[test] + fn ignores_non_tool_live_notification_variants() { + let notification = ServerNotification::CancelledNotification(Notification::new( + CancelledNotificationParam { + request_id: NumberOrString::String(Arc::from("request_1")), + reason: None, + }, + )); + + assert!(tool_notification_update("tool_1", notification).is_none()); + } +} diff --git a/crates/goose/src/acp/server/tools.rs b/crates/goose/src/acp/server/tools.rs index 6204f327b542..24b58acac748 100644 --- a/crates/goose/src/acp/server/tools.rs +++ b/crates/goose/src/acp/server/tools.rs @@ -8,7 +8,7 @@ impl GooseAcpAgent { req: GetToolsRequest, ) -> Result { let session_id = &req.session_id; - let agent = self.get_session_agent(&req.session_id, None).await?; + let agent = self.get_session_agent(&req.session_id).await?; let tools = agent.list_tools(session_id, None).await; let tools_json = tools .into_iter() @@ -23,7 +23,7 @@ impl GooseAcpAgent { req: GooseToolCallRequest, ) -> Result { let session_id = &req.session_id; - let agent = self.get_session_agent(&req.session_id, None).await?; + let agent = self.get_session_agent(&req.session_id).await?; let tools = agent.list_tools(session_id, None).await; let Some(tool) = tools.iter().find(|t| *t.name == req.name) else { diff --git a/crates/goose/src/action_required_manager.rs b/crates/goose/src/action_required_manager.rs index a35b52d73341..b76e0eaf8612 100644 --- a/crates/goose/src/action_required_manager.rs +++ b/crates/goose/src/action_required_manager.rs @@ -1,99 +1,425 @@ use anyhow::Result; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::Duration; -use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio::sync::{Mutex, OwnedMutexGuard, RwLock}; use tokio::time::timeout; use tracing::warn; use uuid::Uuid; use crate::conversation::message::{Message, MessageContent}; +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ElicitationOutcome { + Accept(Value), + Decline, + Cancel, +} + struct PendingRequest { - response_tx: Option>, + session_id: String, + response_tx: Option>, +} + +pub(crate) struct PendingResponseClaim { + request_id: String, + pending: OwnedMutexGuard, +} + +impl PendingResponseClaim { + pub(crate) fn submit(mut self, response: ElicitationOutcome) -> Result<()> { + let tx = self + .pending + .response_tx + .take() + .ok_or_else(|| anyhow::anyhow!("Request already completed: {}", self.request_id))?; + drop(self.pending); + + if tx.send(response).is_err() { + return Err(anyhow::anyhow!("Response channel closed")); + } + + Ok(()) + } } -pub struct ActionRequiredManager { +pub(crate) struct ActionRequiredManager { pending: Arc>>>>, - request_tx: mpsc::UnboundedSender, - pub request_rx: Mutex>, + queued_requests: Mutex>>, } impl ActionRequiredManager { fn new() -> Self { - let (request_tx, request_rx) = mpsc::unbounded_channel(); Self { pending: Arc::new(RwLock::new(HashMap::new())), - request_tx, - request_rx: Mutex::new(request_rx), + queued_requests: Mutex::new(HashMap::new()), } } - pub fn global() -> &'static Self { + pub(crate) fn global() -> &'static Self { static INSTANCE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(ActionRequiredManager::new); &INSTANCE } - pub async fn request_and_wait( + pub(crate) async fn request_and_wait( &self, + session_id: String, message: String, schema: Value, timeout_duration: Duration, - ) -> Result { + ) -> Result { let id = Uuid::new_v4().to_string(); let (tx, rx) = tokio::sync::oneshot::channel(); let pending_request = PendingRequest { + session_id: session_id.clone(), response_tx: Some(tx), }; + let pending_request = Arc::new(Mutex::new(pending_request)); self.pending .write() .await - .insert(id.clone(), Arc::new(Mutex::new(pending_request))); + .insert(id.clone(), Arc::clone(&pending_request)); let action_required_message = Message::assistant().with_content( MessageContent::action_required_elicitation(id.clone(), message, schema), ); - if let Err(e) = self.request_tx.send(action_required_message) { - warn!("Failed to send action required message: {}", e); + self.queued_requests + .lock() + .await + .entry(session_id) + .or_default() + .push_back(action_required_message); + + let result = self + .wait_for_response(&id, pending_request, rx, timeout_duration) + .await; + + self.pending.write().await.remove(&id); + + result + } + + pub(crate) async fn claim_response( + &self, + session_id: &str, + request_id: &str, + ) -> Result { + let pending_arc = self.pending_request(request_id).await?; + let mut pending = pending_arc.lock_owned().await; + + if pending.session_id != session_id { + return Err(anyhow::anyhow!( + "Request {} belongs to session {}, not {}", + request_id, + pending.session_id, + session_id + )); } - let result = match timeout(timeout_duration, rx).await { - Ok(Ok(user_data)) => Ok(user_data), - Ok(Err(_)) => { - warn!("Response channel closed for request: {}", id); - Err(anyhow::anyhow!("Response channel closed")) + let tx = pending + .response_tx + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Request already completed: {}", request_id))?; + if tx.is_closed() { + pending.response_tx.take(); + return Err(anyhow::anyhow!("Response channel closed")); + } + + Ok(PendingResponseClaim { + request_id: request_id.to_string(), + pending, + }) + } + + async fn pending_request(&self, request_id: &str) -> Result>> { + let pending = self.pending.read().await; + pending + .get(request_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id)) + } + + async fn wait_for_response( + &self, + request_id: &str, + pending_request: Arc>, + mut rx: tokio::sync::oneshot::Receiver, + timeout_duration: Duration, + ) -> Result { + match timeout(timeout_duration, &mut rx).await { + Ok(response) => Self::finish_waiting(request_id, response), + Err(_) => { + let mut pending = pending_request.lock().await; + if pending.response_tx.is_some() { + pending.response_tx.take(); + warn!("Timeout waiting for response: {}", request_id); + return Err(anyhow::anyhow!("Timeout waiting for user response")); + } + drop(pending); + + Self::finish_waiting(request_id, rx.await) } + } + } + + fn finish_waiting( + request_id: &str, + response: Result, + ) -> Result { + match response { + Ok(user_data) => Ok(user_data), Err(_) => { - warn!("Timeout waiting for response: {}", id); - Err(anyhow::anyhow!("Timeout waiting for user response")) + warn!("Response channel closed for request: {}", request_id); + Err(anyhow::anyhow!("Response channel closed")) } + } + } + + pub(crate) async fn drain_requests_for_session(&self, session_id: &str) -> Vec { + self.queued_requests + .lock() + .await + .remove(session_id) + .map(|queue| queue.into_iter().collect()) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conversation::message::ActionRequiredData; + use serde_json::json; + + fn elicitation_id(message: &Message) -> String { + match &message.content[0] { + MessageContent::ActionRequired(action_required) => match &action_required.data { + ActionRequiredData::Elicitation { id, .. } => id.clone(), + _ => panic!("expected elicitation action-required message"), + }, + _ => panic!("expected action-required message"), + } + } + + async fn wait_for_elicitation_messages( + manager: &ActionRequiredManager, + session_id: &str, + ) -> Vec { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let messages = manager.drain_requests_for_session(session_id).await; + if !messages.is_empty() { + return messages; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for elicitation message for {session_id}")) + } + + #[tokio::test] + async fn wrong_session_does_not_consume_pending_response() { + let manager = Arc::new(ActionRequiredManager::new()); + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { + manager + .request_and_wait( + "session-a".to_string(), + "Need input".to_string(), + json!({ "type": "object" }), + Duration::from_secs(5), + ) + .await + }) }; - self.pending.write().await.remove(&id); + let messages = wait_for_elicitation_messages(&manager, "session-a").await; + assert_eq!(messages.len(), 1); + let request_id = elicitation_id(&messages[0]); - result + let err = match manager.claim_response("session-b", &request_id).await { + Ok(_) => panic!("wrong session should not claim pending response"), + Err(error) => error, + }; + assert!(err.to_string().contains("belongs to session session-a")); + + manager + .claim_response("session-a", &request_id) + .await + .unwrap() + .submit(ElicitationOutcome::Accept(json!({ "answer": "right" }))) + .unwrap(); + + let response = waiter.await.unwrap().unwrap(); + assert_eq!( + response, + ElicitationOutcome::Accept(json!({ "answer": "right" })) + ); } - pub async fn submit_response(&self, request_id: String, user_data: Value) -> Result<()> { - let pending_arc = { - let pending = self.pending.read().await; - pending - .get(&request_id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))? + #[tokio::test] + async fn drains_only_requested_session() { + let manager = Arc::new(ActionRequiredManager::new()); + let waiter_a = { + let manager = manager.clone(); + tokio::spawn(async move { + manager + .request_and_wait( + "session-a".to_string(), + "Need input A".to_string(), + json!({ "type": "object" }), + Duration::from_secs(5), + ) + .await + }) + }; + let waiter_b = { + let manager = manager.clone(); + tokio::spawn(async move { + manager + .request_and_wait( + "session-b".to_string(), + "Need input B".to_string(), + json!({ "type": "object" }), + Duration::from_secs(5), + ) + .await + }) }; - let mut pending = pending_arc.lock().await; - if let Some(tx) = pending.response_tx.take() { - if tx.send(user_data).is_err() { - warn!("Failed to send response through oneshot channel"); - } - } + let session_a_messages = wait_for_elicitation_messages(&manager, "session-a").await; + assert_eq!(session_a_messages.len(), 1); + let request_id_a = elicitation_id(&session_a_messages[0]); - Ok(()) + let empty_messages = manager.drain_requests_for_session("session-a").await; + assert!(empty_messages.is_empty()); + + let session_b_messages = wait_for_elicitation_messages(&manager, "session-b").await; + assert_eq!(session_b_messages.len(), 1); + let request_id_b = elicitation_id(&session_b_messages[0]); + + manager + .claim_response("session-a", &request_id_a) + .await + .unwrap() + .submit(ElicitationOutcome::Accept(json!({ "answer": "a" }))) + .unwrap(); + manager + .claim_response("session-b", &request_id_b) + .await + .unwrap() + .submit(ElicitationOutcome::Accept(json!({ "answer": "b" }))) + .unwrap(); + + assert_eq!( + waiter_a.await.unwrap().unwrap(), + ElicitationOutcome::Accept(json!({ "answer": "a" })) + ); + assert_eq!( + waiter_b.await.unwrap().unwrap(), + ElicitationOutcome::Accept(json!({ "answer": "b" })) + ); + } + + #[tokio::test] + async fn claimed_response_can_complete_after_timeout_deadline() { + let manager = Arc::new(ActionRequiredManager::new()); + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { + manager + .request_and_wait( + "session-a".to_string(), + "Need input".to_string(), + json!({ "type": "object" }), + Duration::from_millis(25), + ) + .await + }) + }; + + let messages = wait_for_elicitation_messages(&manager, "session-a").await; + assert_eq!(messages.len(), 1); + let request_id = elicitation_id(&messages[0]); + + let claim = manager + .claim_response("session-a", &request_id) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + claim + .submit(ElicitationOutcome::Accept(json!({ "answer": "late" }))) + .unwrap(); + + assert_eq!( + waiter.await.unwrap().unwrap(), + ElicitationOutcome::Accept(json!({ "answer": "late" })) + ); + } + + #[tokio::test] + async fn request_and_wait_returns_decline_and_cancel_actions() { + let manager = Arc::new(ActionRequiredManager::new()); + let decline_waiter = { + let manager = manager.clone(); + tokio::spawn(async move { + manager + .request_and_wait( + "session-a".to_string(), + "Need input A".to_string(), + json!({ "type": "object" }), + Duration::from_secs(5), + ) + .await + }) + }; + let cancel_waiter = { + let manager = manager.clone(); + tokio::spawn(async move { + manager + .request_and_wait( + "session-b".to_string(), + "Need input B".to_string(), + json!({ "type": "object" }), + Duration::from_secs(5), + ) + .await + }) + }; + + let decline_messages = wait_for_elicitation_messages(&manager, "session-a").await; + let decline_request_id = elicitation_id(&decline_messages[0]); + let cancel_messages = wait_for_elicitation_messages(&manager, "session-b").await; + let cancel_request_id = elicitation_id(&cancel_messages[0]); + + manager + .claim_response("session-a", &decline_request_id) + .await + .unwrap() + .submit(ElicitationOutcome::Decline) + .unwrap(); + manager + .claim_response("session-b", &cancel_request_id) + .await + .unwrap() + .submit(ElicitationOutcome::Cancel) + .unwrap(); + + assert_eq!( + decline_waiter.await.unwrap().unwrap(), + ElicitationOutcome::Decline + ); + assert_eq!( + cancel_waiter.await.unwrap().unwrap(), + ElicitationOutcome::Cancel + ); } } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 441cd5c63f8b..c2299e83b268 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -16,7 +16,7 @@ use super::mcp_client::GooseMcpHostInfo; use super::platform_tools; use super::tool_confirmation_router::ToolConfirmationRouter; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; -use crate::action_required_manager::ActionRequiredManager; +use crate::action_required_manager::{ActionRequiredManager, ElicitationOutcome}; use crate::agents::extension::{ExtensionConfig, ExtensionResult, ToolInfo}; use crate::agents::extension_manager::{ get_parameter_names, ExtensionManager, ExtensionManagerCapabilities, @@ -57,8 +57,8 @@ use goose_providers::errors::ProviderError; use goose_providers::thinking::ThinkingEffort; use regex::Regex; use rmcp::model::{ - CallToolRequestParams, CallToolResult, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, - ServerNotification, Tool, + CallToolRequestParams, CallToolResult, Content, ElicitationAction, ErrorCode, ErrorData, + GetPromptResult, Prompt, ServerNotification, Tool, }; use serde_json::Value; use tokio::sync::{mpsc, Mutex}; @@ -221,6 +221,14 @@ impl AgentConfig { self.use_login_shell_path = Some(use_login_shell_path); self } + + fn resolve_use_login_shell_path(&self) -> bool { + resolve_use_login_shell_path(self.use_login_shell_path, &self.goose_platform) + } +} + +fn resolve_use_login_shell_path(explicit: Option, platform: &GoosePlatform) -> bool { + explicit.unwrap_or(matches!(platform, GoosePlatform::GooseDesktop)) } /// The main goose Agent @@ -253,6 +261,7 @@ pub struct Agent { #[derive(Clone, Debug)] pub enum AgentEvent { Message(Message), + Usage(crate::providers::base::ProviderUsage), McpNotification((String, ServerNotification)), HistoryReplaced(Conversation), } @@ -336,9 +345,7 @@ impl Agent { .unwrap_or_else(|| goose_platform.to_string()); let session_manager = Arc::clone(&config.session_manager); let permission_manager = Arc::clone(&config.permission_manager); - let use_login_shell_path = config - .use_login_shell_path - .unwrap_or(matches!(goose_platform, GoosePlatform::GooseDesktop)); + let use_login_shell_path = config.resolve_use_login_shell_path(); Self { provider: provider.clone(), config, @@ -363,7 +370,10 @@ impl Agent { permission_manager, provider.clone(), ), - hook_manager: crate::hooks::HookManager::load(std::env::current_dir().ok().as_deref()), + hook_manager: crate::hooks::HookManager::load( + std::env::current_dir().ok().as_deref(), + use_login_shell_path, + ), #[cfg(test)] stop_hook_block_cap_override: None, container: Mutex::new(None), @@ -630,8 +640,10 @@ impl Agent { async fn drain_elicitation_messages(&self, session_id: &str) -> Vec { let mut messages = Vec::new(); let manager = self.config.session_manager.clone(); - let mut elicitation_rx = ActionRequiredManager::global().request_rx.lock().await; - while let Ok(mut elicitation_message) = elicitation_rx.try_recv() { + for mut elicitation_message in ActionRequiredManager::global() + .drain_requests_for_session(session_id) + .await + { if elicitation_message.id.is_none() { elicitation_message = elicitation_message.with_generated_id(); } @@ -695,7 +707,7 @@ impl Agent { .provider() .await .map(|p| p.get_model_config().context_limit()) - .unwrap_or(crate::model::DEFAULT_CONTEXT_LIMIT); + .unwrap_or(goose_providers::model::DEFAULT_CONTEXT_LIMIT); let compaction_threshold = Config::global() .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") .unwrap_or(crate::context_mgmt::DEFAULT_COMPACTION_THRESHOLD); @@ -1450,25 +1462,35 @@ impl Agent { for content in &user_message.content { if let MessageContent::ActionRequired(action_required) = content { - if let ActionRequiredData::ElicitationResponse { id, user_data } = - &action_required.data + if let ActionRequiredData::ElicitationResponse { + id, + user_data, + action, + } = &action_required.data { // Surface stale/cancelled/timed-out elicitations as a hard // error so callers (e.g. the HTTP handler) can propagate // failure to the client instead of silently reporting // success while the blocked tool call stays unblocked. - // The success path returns an empty stream; an Err here - // makes the contract: Ok(empty) on accept, Err on reject. - ActionRequiredManager::global() - .submit_response(id.clone(), user_data.clone()) - .await - .map_err(|e| { - error!("Failed to submit elicitation response: {}", e); - anyhow!("Failed to submit elicitation response: {}", e) - })?; - session_manager - .add_message(&session_config.id, &user_message) - .await?; + // The success path returns an empty stream after the MCP + // server receives the user's accept/decline/cancel action. + let response = match action { + ElicitationAction::Accept => ElicitationOutcome::Accept(user_data.clone()), + ElicitationAction::Decline => ElicitationOutcome::Decline, + ElicitationAction::Cancel => ElicitationOutcome::Cancel, + }; + crate::elicitation::complete_elicitation_with_message( + &session_manager, + &session_config.id, + id, + response, + &user_message, + ) + .await + .map_err(|e| { + error!("Failed to submit elicitation response: {}", e); + anyhow!("Failed to submit elicitation response: {}", e) + })?; return Ok(Box::pin(futures::stream::empty())); } } @@ -1494,6 +1516,8 @@ impl Agent { .execute_command(&message_text, &session_config.id) .await; + let mut command_preamble: Vec = Vec::new(); + match command_result { Err(e) => { let error_message = Message::assistant() @@ -1503,6 +1527,44 @@ impl Agent { Ok(AgentEvent::Message(error_message)) }))); } + Ok(Some(response)) + if response.role == rmcp::model::Role::Assistant + && crate::agents::execute_commands::command_starts_turn(&message_text) => + { + // Setting a goal/grind should immediately start a turn so the + // agent begins pursuing it, rather than waiting for the next + // user prompt. Record the command and its confirmation as + // user-visible only, then inject an agent-visible kickoff and + // fall through into the reply loop. + session_manager + .add_message( + &session_config.id, + &user_message.clone().with_visibility(true, false), + ) + .await?; + session_manager + .add_message( + &session_config.id, + &response.clone().with_visibility(true, false), + ) + .await?; + let goal_text = crate::agents::execute_commands::parse_slash_command(&message_text) + .map(|parsed| parsed.params_str.to_string()) + .unwrap_or_default(); + let kickoff = Message::user() + .with_text(format!( + "Start working toward this goal now:\n\n**Goal:** {goal_text}" + )) + .with_visibility(false, true); + session_manager + .add_message(&session_config.id, &kickoff) + .await?; + + command_preamble = vec![ + AgentEvent::Message(user_message.clone()), + AgentEvent::Message(response.clone()), + ]; + } Ok(Some(response)) if response.role == rmcp::model::Role::Assistant => { session_manager .add_message( @@ -1577,6 +1639,10 @@ impl Agent { let conversation_to_compact = conversation.clone(); Ok(Box::pin(async_stream::try_stream! { + for event in command_preamble { + yield event; + } + let final_conversation = if !needs_auto_compact { conversation } else { @@ -1832,7 +1898,8 @@ impl Agent { &session_config.id, conversation.clone(), &self.extension_manager, - &working_dir, + turns_taken, + max_turns, ).await; let mut stream = Self::stream_response_from_provider( @@ -1885,6 +1952,7 @@ impl Agent { if let Some(ref usage) = usage { self.update_session_metrics(&session_config.id, session_config.schedule_id.clone(), usage, false).await?; + yield AgentEvent::Usage(usage.clone()); } if let Some(response) = response { @@ -2143,10 +2211,14 @@ impl Agent { request.metadata.as_ref(), request.tool_meta.clone(), ); - messages_to_add.push(request_msg); let final_response = request_to_response_map .remove(&request.id) .unwrap_or_else(|| Message::user().with_generated_id()); + // Response placeholder is created before tools run, so clamp request to avoid inverted ordering. + if request_msg.created > final_response.created { + request_msg.created = final_response.created; + } + messages_to_add.push(request_msg); yield AgentEvent::Message(final_response.clone()); messages_to_add.push(final_response); } else { @@ -2580,7 +2652,7 @@ impl Agent { &self, session_id: &str, provider_name: &str, - model_config: crate::model::ModelConfig, + model_config: goose_providers::model::ModelConfig, ) -> Result<()> { let session = self .config @@ -2643,9 +2715,8 @@ impl Agent { .get_goose_model() .ok() .ok_or_else(|| anyhow!("Could not configure agent: missing model"))?; - crate::model::ModelConfig::new(&model_name) + crate::model_config::model_config_from_user_config(&provider_name, &model_name) .map_err(|e| anyhow!("Could not configure agent: invalid model {}", e))? - .with_canonical_limits(&provider_name) } }; @@ -2687,9 +2758,11 @@ impl Agent { .get_goose_model() .ok() .ok_or_else(|| anyhow!("Could not configure fallback provider: missing model"))?; - let fallback_model_config = crate::model::ModelConfig::new(&fallback_model_name) - .map_err(|e| anyhow!("Could not configure fallback provider: invalid model {}", e))? - .with_canonical_limits(&fallback_provider_name); + let fallback_model_config = crate::model_config::model_config_from_user_config( + &fallback_provider_name, + &fallback_model_name, + ) + .map_err(|e| anyhow!("Could not configure fallback provider: invalid model {}", e))?; let fallback_provider = crate::providers::create_with_working_dir( &fallback_provider_name, @@ -3071,6 +3144,30 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::TempDir; + #[test] + fn resolve_use_login_shell_path_defaults_by_platform() { + assert!(resolve_use_login_shell_path( + None, + &GoosePlatform::GooseDesktop + )); + assert!(!resolve_use_login_shell_path( + None, + &GoosePlatform::GooseCli + )); + } + + #[test] + fn resolve_use_login_shell_path_explicit_overrides_platform() { + assert!(resolve_use_login_shell_path( + Some(true), + &GoosePlatform::GooseCli + )); + assert!(!resolve_use_login_shell_path( + Some(false), + &GoosePlatform::GooseDesktop + )); + } + struct ActionRequiredProvider { handled: tokio::sync::Mutex>, } @@ -3094,12 +3191,12 @@ mod tests { fn get_name(&self) -> &str { "test-action-required" } - fn get_model_config(&self) -> crate::model::ModelConfig { - crate::model::ModelConfig::new("test").unwrap() + fn get_model_config(&self) -> goose_providers::model::ModelConfig { + goose_providers::model::ModelConfig::new("test").unwrap() } async fn stream( &self, - _: &crate::model::ModelConfig, + _: &goose_providers::model::ModelConfig, _: &str, _: &str, _: &[crate::conversation::message::Message], @@ -3281,7 +3378,7 @@ exit 0 impl crate::providers::base::Provider for CountingTextProvider { async fn stream( &self, - _model_config: &crate::model::ModelConfig, + _model_config: &goose_providers::model::ModelConfig, _session_id: &str, _system_prompt: &str, _messages: &[Message], @@ -3293,8 +3390,8 @@ exit 0 Ok(stream_from_single_message(message, usage)) } - fn get_model_config(&self) -> crate::model::ModelConfig { - crate::model::ModelConfig::new("mock-model").unwrap() + fn get_model_config(&self) -> goose_providers::model::ModelConfig { + goose_providers::model::ModelConfig::new("mock-model").unwrap() } fn get_name(&self) -> &str { @@ -3310,7 +3407,7 @@ exit 0 impl crate::providers::base::Provider for RefusingProvider { async fn stream( &self, - _model_config: &crate::model::ModelConfig, + _model_config: &goose_providers::model::ModelConfig, _session_id: &str, _system_prompt: &str, _messages: &[Message], @@ -3325,8 +3422,8 @@ exit 0 }))) } - fn get_model_config(&self) -> crate::model::ModelConfig { - crate::model::ModelConfig::new("mock-model").unwrap() + fn get_model_config(&self) -> goose_providers::model::ModelConfig { + goose_providers::model::ModelConfig::new("mock-model").unwrap() } fn get_name(&self) -> &str { @@ -3435,7 +3532,9 @@ exit 0 while let Some(event) = reply_stream.next().await { match event? { AgentEvent::Message(message) => messages.push(message), - AgentEvent::McpNotification(_) | AgentEvent::HistoryReplaced(_) => {} + AgentEvent::McpNotification(_) + | AgentEvent::HistoryReplaced(_) + | AgentEvent::Usage(_) => {} } } Ok(messages) diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index 8860c89fec16..eebb0e823440 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -84,6 +84,22 @@ pub fn list_commands() -> &'static [CommandDef] { COMMANDS } +fn is_clear_goal_param(params_str: &str) -> bool { + matches!(params_str, "off" | "clear" | "none") +} + +/// Whether a slash command should kick off an agent turn instead of just +/// returning a confirmation. Setting a `/goal` or `/grind` (with a description, +/// not the query or `off` forms) makes the agent start pursuing it immediately. +pub fn command_starts_turn(message_text: &str) -> bool { + let Some(parsed) = parse_slash_command(message_text) else { + return false; + }; + matches!(parsed.command, "goal" | "grind") + && !parsed.params_str.is_empty() + && !is_clear_goal_param(parsed.params_str) +} + impl Agent { pub async fn execute_command( &self, @@ -380,7 +396,7 @@ impl Agent { return Ok(Some(Message::assistant().with_text(text))); } - if params_str == "off" || params_str == "clear" || params_str == "none" { + if is_clear_goal_param(params_str) { self.set_goal(None).await; return Ok(Some( Message::assistant().with_text("Goal cleared. The agent will finish normally."), @@ -404,7 +420,7 @@ impl Agent { return Ok(Some(Message::assistant().with_text(text))); } - if params_str == "off" || params_str == "clear" { + if is_clear_goal_param(params_str) { self.set_grind(None).await; return Ok(Some( Message::assistant().with_text("Grind cleared. The agent will finish normally."), @@ -447,6 +463,24 @@ mod tests { assert_eq!(parsed.params_str, ""); } + #[test] + fn command_starts_turn_only_for_goal_and_grind_with_description() { + assert!(command_starts_turn("/goal make all tests pass")); + assert!(command_starts_turn("/grind keep refactoring")); + + // Query and clear forms must not start a turn. + assert!(!command_starts_turn("/goal")); + assert!(!command_starts_turn("/goal off")); + assert!(!command_starts_turn("/goal clear")); + assert!(!command_starts_turn("/goal none")); + assert!(!command_starts_turn("/grind")); + assert!(!command_starts_turn("/grind off")); + + // Other commands and plain prompts never start a turn here. + assert!(!command_starts_turn("/compact")); + assert!(!command_starts_turn("just a normal message")); + } + #[test] fn user_only_assistant_text_is_durable_text_not_system_notification() { let message = user_only_assistant_text("Conversation cleared"); diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 51349e0115e1..8ffa0f8c6ede 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -181,6 +181,8 @@ pub enum ExtensionConfig { env_keys: Vec, timeout: Option, #[serde(default)] + cwd: Option, + #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, @@ -333,6 +335,7 @@ impl ExtensionConfig { env_keys: Vec::new(), description: description.into(), timeout: Some(timeout.into()), + cwd: None, bundled: None, available_tools: Vec::new(), } @@ -366,6 +369,7 @@ impl ExtensionConfig { envs, env_keys, timeout, + cwd, description, bundled, available_tools, @@ -378,6 +382,7 @@ impl ExtensionConfig { args: args.into_iter().map(Into::into).collect(), description, timeout, + cwd, bundled, available_tools, }, @@ -443,6 +448,7 @@ impl ExtensionConfig { envs, env_keys, timeout, + cwd, bundled, available_tools, } => { @@ -452,9 +458,10 @@ impl ExtensionConfig { description, cmd, args, - envs: Envs::new(merged), + envs: Envs::new(merged.clone()), env_keys: vec![], timeout, + cwd: cwd.map(|s| substitute_env_vars(&s, &merged)), bundled, available_tools, }) @@ -731,6 +738,7 @@ available_tools: [] envs: extension::Envs::default(), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, @@ -742,6 +750,7 @@ available_tools: [] envs: extension::Envs::default(), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], } @@ -756,6 +765,7 @@ available_tools: [] envs: extension::Envs::default(), env_keys: vec!["MY_SECRET".into()], timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, @@ -771,6 +781,7 @@ available_tools: [] }), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], } @@ -858,6 +869,7 @@ available_tools: [] }), env_keys: vec!["MY_SECRET".into()], timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, @@ -873,6 +885,7 @@ available_tools: [] }), env_keys: vec![], timeout: None, + cwd: None, bundled: None, available_tools: vec![], } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index cf7f67641f05..a8ad771318b3 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -387,11 +387,11 @@ async fn child_process_client( } /// Retry with OAuth for typed auth challenges and wrapped bare HTTP 401 responses. -fn should_attempt_oauth_fallback(res: &Result) -> bool { - let Err(ClientInitializeError::TransportError { +fn is_oauth_auth_failure(err: &ClientInitializeError) -> bool { + let ClientInitializeError::TransportError { error: DynamicTransportError { error, .. }, .. - }) = res + } = err else { return false; }; @@ -421,6 +421,32 @@ fn should_attempt_oauth_fallback(res: &Result) .contains("unexpected server response: HTTP 401") } +fn should_attempt_oauth_fallback(res: &Result) -> bool { + res.as_ref().err().is_some_and(is_oauth_auth_failure) +} + +async fn clear_credentials_on_post_refresh_auth_failure( + credential_store: &dyn CredentialStore, + name: &str, + error: &ExtensionError, +) -> bool { + let ExtensionError::InitializeError(err) = error else { + return false; + }; + + if !is_oauth_auth_failure(err) { + return false; + } + + if let Err(e) = credential_store.clear().await { + warn!( + "[OAuth:{}] error clearing rejected credentials: {}", + name, e + ); + } + true +} + /// Merge environment variables from direct envs and keychain-stored env_keys pub(crate) async fn merge_environments( envs: &Envs, @@ -625,17 +651,36 @@ async fn create_streamable_http_client( if credential_store.load().await.is_ok_and(|c| c.is_some()) { match oauth_flow(&uri.to_string(), &name.to_string()).await { Ok(auth_manager) => { - return connect_with_auth( + let auth_result = connect_with_auth( auth_manager, uri, timeout_duration, headers, - provider, - client_name, - capabilities, + provider.clone(), + client_name.clone(), + capabilities.clone(), roots_dir, ) .await; + + if let Err(error) = &auth_result { + if clear_credentials_on_post_refresh_auth_failure( + credential_store.as_ref(), + name, + error, + ) + .await + { + warn!( + "[OAuth:{}] Refreshed token was rejected, falling back to browser auth", + name + ); + } else { + return auth_result; + } + } else { + return auth_result; + } } Err(e) => { warn!( @@ -956,11 +1001,16 @@ impl ExtensionManager { envs, env_keys, timeout, + cwd, .. } => { let config = Config::global(); let mut all_envs = merge_environments(envs, env_keys, &sanitized_name, config).await?; + let process_working_dir = cwd + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| effective_working_dir.clone()); if let Some(sid) = session_id { all_envs.insert("AGENT_SESSION_ID".to_string(), sid.to_string()); @@ -996,7 +1046,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - &effective_working_dir, + &process_working_dir, container.map(|c| c.id().to_string()), self.client_name.clone(), self.mcp_client_capabilities(), @@ -1899,51 +1949,7 @@ impl ExtensionManager { .map(|ext| ext.get_client()) } - pub async fn collect_moim( - &self, - session_id: &str, - working_dir: &std::path::Path, - ) -> Option { - // Skip MOIM for models with small context windows to avoid consuming limited context - const MIN_CONTEXT_FOR_MOIM: usize = 32_000; - if let Ok(provider_guard) = self.provider.try_lock() { - if let Some(provider) = provider_guard.as_ref() { - if provider.get_model_config().context_limit() < MIN_CONTEXT_FOR_MOIM { - return None; - } - } - } - - // Use minute-level granularity to prevent conversation changes every second - let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:00").to_string(); - let mut content = format!( - "\nIt is currently {}\nWorking directory: {}\n", - timestamp, - working_dir.display() - ); - - if let Ok(session) = self - .context - .session_manager - .get_session(session_id, false) - .await - { - if let (Some(total), Some(config)) = - (session.total_tokens, session.model_config.as_ref()) - { - let limit = config.context_limit(); - if total > 0 && limit > 0 { - let pct = (total as f64 / limit as f64 * 100.0).round() as u32; - content.push_str(&format!( - "Context: ~{}k/{}k tokens used ({}%)\n", - total / 1000, - limit / 1000, - pct - )); - } - } - } - + pub async fn collect_moim_parts(&self, session_id: &str) -> Vec { let platform_clients: Vec<(String, McpClientBox)> = { let extensions = self.extensions.lock().await; extensions @@ -1965,17 +1971,14 @@ impl ExtensionManager { .collect() }; + let mut parts = Vec::new(); for (name, client) in platform_clients { if let Some(moim_content) = client.get_moim(session_id).await { tracing::debug!("MOIM content from {}: {} chars", name, moim_content.len()); - content.push('\n'); - content.push_str(&moim_content); + parts.push(moim_content); } } - - content.push_str("\n"); - - Some(content) + parts } } @@ -2367,21 +2370,6 @@ mod tests { assert_eq!(result, "abc$KEY"); } - #[tokio::test] - async fn test_collect_moim_uses_minute_granularity() { - let temp_dir = tempfile::tempdir().unwrap(); - let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf()); - let working_dir = std::path::Path::new("/tmp"); - - if let Some(moim) = em.collect_moim("test-session-id", working_dir).await { - // Timestamp should end with :00 (seconds fixed to 00) - assert!( - moim.contains(":00\n"), - "Timestamp should use minute granularity" - ); - } - } - #[tokio::test] async fn test_tools_cache_invalidated_on_add_extension() { let temp_dir = tempfile::tempdir().unwrap(); @@ -2725,6 +2713,41 @@ mod tests { assert!(should_attempt_oauth_fallback(&Err(err))); } + #[tokio::test] + async fn test_post_refresh_auth_failure_clears_credentials() { + use rmcp::transport::auth::{ + InMemoryCredentialStore, OAuthTokenResponse, StoredCredentials, + }; + + let token_response: OAuthTokenResponse = serde_json::from_value(serde_json::json!({ + "access_token": "rejected-token", + "token_type": "bearer", + })) + .expect("valid fake token JSON"); + let store = InMemoryCredentialStore::new(); + store + .save(StoredCredentials::new( + "test-client".to_string(), + Some(token_response), + vec![], + None, + )) + .await + .unwrap(); + + let err = streamable_err( + rmcp::transport::streamable_http_client::StreamableHttpError::AuthRequired( + rmcp::transport::streamable_http_client::AuthRequiredError::new( + "Bearer error=\"invalid_token\"".to_string(), + ), + ), + ); + let error = ExtensionError::InitializeError(err); + + assert!(clear_credentials_on_post_refresh_auth_failure(&store, "test-ext", &error).await); + assert!(store.load().await.unwrap().is_none()); + } + #[tokio::test] async fn test_invalid_header_name_returns_config_error() { let mut headers = HashMap::new(); diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index c801b37ac8de..56567a005065 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -1,4 +1,4 @@ -use crate::action_required_manager::ActionRequiredManager; +use crate::action_required_manager::{ActionRequiredManager, ElicitationOutcome}; use crate::agents::tool_execution::ToolCallContext; use crate::agents::types::SharedProvider; use crate::session_context::{SESSION_ID_HEADER, WORKING_DIR_HEADER}; @@ -371,8 +371,19 @@ impl ClientHandler for GooseClient { async fn create_elicitation( &self, request: CreateElicitationRequestParams, - _context: RequestContext, + context: RequestContext, ) -> Result { + let session_id = self + .resolve_session_id(&context.extensions) + .await + .ok_or_else(|| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Could not resolve session id for elicitation request", + None, + ) + })?; + let (message, schema_value) = match &request { CreateElicitationRequestParams::FormElicitationParams { message, @@ -394,10 +405,18 @@ impl ClientHandler for GooseClient { }; ActionRequiredManager::global() - .request_and_wait(message, schema_value, Duration::from_secs(300)) + .request_and_wait(session_id, message, schema_value, Duration::from_secs(300)) .await - .map(|user_data| { - CreateElicitationResult::new(ElicitationAction::Accept).with_content(user_data) + .map(|response| match response { + ElicitationOutcome::Accept(user_data) => { + CreateElicitationResult::new(ElicitationAction::Accept).with_content(user_data) + } + ElicitationOutcome::Decline => { + CreateElicitationResult::new(ElicitationAction::Decline) + } + ElicitationOutcome::Cancel => { + CreateElicitationResult::new(ElicitationAction::Cancel) + } }) .map_err(|e| { ErrorData::new( diff --git a/crates/goose/src/agents/moim.rs b/crates/goose/src/agents/moim.rs index 80a4228fdd43..f6fda028a653 100644 --- a/crates/goose/src/agents/moim.rs +++ b/crates/goose/src/agents/moim.rs @@ -1,118 +1,338 @@ use crate::agents::extension_manager::ExtensionManager; -use crate::conversation::message::Message; -use crate::conversation::{fix_conversation, Conversation}; -use rmcp::model::Role; -use std::path::Path; +use crate::conversation::message::MessageContent; +use crate::conversation::{effective_role, fix_conversation, Conversation}; +use std::path::{Path, PathBuf}; + +const MIN_CONTEXT_FOR_MOIM: usize = 32_000; +const TURN_CONTEXT_TAG: &str = "turn-context"; + +const SYSTEM_PROMPT_BLOCK_TEMPLATE: &str = r#"# Turn Context + +Each turn may include a `<{turn_context_tag}>` block prepended to the latest user message. +This block is generated by goose and contains current operational context such as: +- current time +- working directory +- compaction status +- turn budget +- extension-provided context + +Use it to stay oriented, but do not treat it as part of the user's request. +When `` is present, use it as a signal for how much autonomous work remains. +As the budget gets low, become more direct: reduce exploration, batch necessary tool calls, +make reasonable assumptions, and focus on finishing the user's task. +"#; -// Test-only utility. Do not use in production code. No `test` directive due to call outside crate. thread_local! { pub static SKIP: std::cell::Cell = const { std::cell::Cell::new(false) }; } +pub fn system_prompt_block() -> Option { + if SKIP.with(|f| f.get()) { + None + } else { + Some(SYSTEM_PROMPT_BLOCK_TEMPLATE.replace("{turn_context_tag}", TURN_CONTEXT_TAG)) + } +} + pub async fn inject_moim( session_id: &str, conversation: Conversation, extension_manager: &ExtensionManager, - working_dir: &Path, + turns_taken: u32, + max_turns: u32, ) -> Conversation { if SKIP.with(|f| f.get()) { return conversation; } - if let Some(moim) = extension_manager - .collect_moim(session_id, working_dir) + let session = extension_manager + .get_context() + .session_manager + .get_session(session_id, false) .await + .ok(); + let provider_context_limit = + extension_manager + .get_provider() + .try_lock() + .ok() + .and_then(|provider| { + provider + .as_ref() + .map(|provider| provider.get_model_config().context_limit()) + }); + let session_context_limit = session.as_ref().and_then(|session| { + session + .model_config + .as_ref() + .map(|config| config.context_limit()) + }); + let context_limit = provider_context_limit.or(session_context_limit); + if should_skip_moim(context_limit) { + return conversation; + } + + let working_dir = session + .as_ref() + .map(|session| session.working_dir.clone()) + .unwrap_or_else(|| PathBuf::from(".")); + let total_tokens = session.as_ref().and_then(|session| session.total_tokens); + let compaction_threshold = crate::config::Config::global() + .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") + .unwrap_or(crate::context_mgmt::DEFAULT_COMPACTION_THRESHOLD); + let extension_parts = extension_manager.collect_moim_parts(session_id).await; + let moim = compose_moim( + &working_dir, + total_tokens, + context_limit, + compaction_threshold, + turns_taken, + max_turns, + extension_parts, + ); + + let mut messages = conversation.messages().clone(); + let Some(idx) = messages + .iter() + .rposition(|m| m.is_agent_visible() && effective_role(m) == "user") + else { + return conversation; + }; + let insert_idx = messages[idx] + .content + .iter() + .take_while(|content| matches!(content, MessageContent::ToolResponse(_))) + .count(); + messages[idx] + .content + .insert(insert_idx, MessageContent::text(moim)); + + let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages)); + + let has_unexpected_issues = issues.iter().any(|issue| { + !issue.contains("Merged consecutive user messages") + && !issue.contains("Merged consecutive assistant messages") + && !issue.contains("Added placeholder to empty tool result") + && !issue.contains("Trimmed trailing whitespace from assistant message") + && !issue.contains("Removed trailing assistant message") + && !issue.contains("Merged text content") + }); + + if has_unexpected_issues { + tracing::warn!("MOIM injection caused unexpected issues: {:?}", issues); + return conversation; + } + + fixed +} + +fn should_skip_moim(context_limit: Option) -> bool { + context_limit.is_some_and(|limit| limit < MIN_CONTEXT_FOR_MOIM) +} + +fn compose_moim( + working_dir: &Path, + total_tokens: Option, + context_limit: Option, + compaction_threshold: f64, + turns_taken: u32, + max_turns: u32, + extension_parts: Vec, +) -> String { + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:00"); + let mut lines = vec![ + open_tag(TURN_CONTEXT_TAG), + tag("current-time", ×tamp.to_string()), + tag("working-directory", &working_dir.display().to_string()), + ]; + + if let Some(value) = + compaction_remaining_line(total_tokens, context_limit, compaction_threshold) { - let mut messages = conversation.messages().clone(); - let idx = messages - .iter() - .rposition(|m| m.role == Role::Assistant) - .unwrap_or(0); - messages.insert(idx, Message::user().with_text(moim)); - - let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages)); - - let has_unexpected_issues = issues.iter().any(|issue| { - !issue.contains("Merged consecutive user messages") - && !issue.contains("Merged consecutive assistant messages") - && !issue.contains("Added placeholder to empty tool result") - && !issue.contains("Trimmed trailing whitespace from assistant message") - && !issue.contains("Removed trailing assistant message") - && !issue.contains("Merged text content") - }); - - if has_unexpected_issues { - tracing::warn!("MOIM injection caused unexpected issues: {:?}", issues); - return conversation; + lines.push(tag("compaction", &value)); + } + if let Some(value) = turn_budget_line(turns_taken, max_turns) { + lines.push(tag("turn-budget", &value)); + } + + for part in extension_parts { + if !part.trim().is_empty() { + lines.push(String::new()); + lines.push(part); } + } + + lines.push(close_tag(TURN_CONTEXT_TAG)); + lines.join("\n") +} + +fn open_tag(name: &str) -> String { + format!("<{name}>") +} - return fixed; +fn close_tag(name: &str) -> String { + format!("") +} + +fn tag(name: &str, value: &str) -> String { + format!("<{name}>{}", escape_xml_text(value)) +} + +fn escape_xml_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn compaction_remaining_line( + total_tokens: Option, + context_limit: Option, + threshold: f64, +) -> Option { + let total_tokens = total_tokens?; + let context_limit = context_limit?; + + if total_tokens <= 0 || context_limit == 0 || threshold <= 0.0 || threshold >= 1.0 { + return None; + } + + let compaction_at = (context_limit as f64 * threshold) as i32; + if compaction_at <= 0 || (total_tokens as f64 / compaction_at as f64) < 0.5 { + return None; } - conversation + + Some(format!( + "~{}k tokens remaining", + compaction_at.saturating_sub(total_tokens) / 1000 + )) +} + +fn turn_budget_line(turns_taken: u32, max_turns: u32) -> Option { + if max_turns == 0 || turns_taken.saturating_mul(2) < max_turns { + return None; + } + + Some(format!("{turns_taken}/{max_turns} used")) } #[cfg(test)] mod tests { use super::*; + use crate::conversation::message::Message; use rmcp::model::CallToolRequestParams; - use std::path::PathBuf; + + fn text_at(message: &crate::conversation::message::Message, index: usize) -> &str { + message.content[index].as_text().unwrap() + } + + fn is_moim(content: &MessageContent) -> bool { + content + .as_text() + .is_some_and(|text| text.starts_with(&format!("<{}>\n", TURN_CONTEXT_TAG))) + } #[tokio::test] - async fn test_moim_injection_before_assistant() { + async fn test_moim_prepended_to_latest_user_message() { let temp_dir = tempfile::tempdir().unwrap(); let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf()); - let working_dir = PathBuf::from("/test/dir"); + let session = em + .get_context() + .session_manager + .create_session( + PathBuf::from("/test/dir"), + "test".to_string(), + crate::session::SessionType::User, + crate::config::GooseMode::Auto, + ) + .await + .unwrap(); let conv = Conversation::new_unvalidated(vec![ Message::user().with_text("Hello"), Message::assistant().with_text("Hi"), Message::user().with_text("Bye"), ]); - let result = inject_moim("test-session-id", conv, &em, &working_dir).await; + let result = inject_moim(&session.id, conv, &em, 0, 100).await; let msgs = result.messages(); assert_eq!(msgs.len(), 3); - assert_eq!(msgs[0].content[0].as_text().unwrap(), "Hello"); - assert_eq!(msgs[1].content[0].as_text().unwrap(), "Hi"); - - let merged_content = msgs[0] - .content - .iter() - .filter_map(|c| c.as_text()) - .collect::>() - .join(""); - assert!(merged_content.contains("Hello")); - assert!(merged_content.contains("")); - assert!(merged_content.contains("Working directory: /test/dir")); + assert_eq!(text_at(&msgs[0], 0), "Hello"); + assert_eq!(text_at(&msgs[1], 0), "Hi"); + assert!(is_moim(&msgs[2].content[0])); + assert_eq!(text_at(&msgs[2], 1), "Bye"); } #[tokio::test] async fn test_moim_injection_no_assistant() { let temp_dir = tempfile::tempdir().unwrap(); let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf()); - let working_dir = PathBuf::from("/test/dir"); + let session = em + .get_context() + .session_manager + .create_session( + PathBuf::from("/test/dir"), + "test".to_string(), + crate::session::SessionType::User, + crate::config::GooseMode::Auto, + ) + .await + .unwrap(); let conv = Conversation::new_unvalidated(vec![Message::user().with_text("Hello")]); - let result = inject_moim("test-session-id", conv, &em, &working_dir).await; + let result = inject_moim(&session.id, conv, &em, 0, 100).await; assert_eq!(result.messages().len(), 1); + assert!(is_moim(&result.messages()[0].content[0])); + assert_eq!(text_at(&result.messages()[0], 1), "Hello"); + } + + #[tokio::test] + async fn test_moim_skips_user_messages_not_visible_to_agent() { + let temp_dir = tempfile::tempdir().unwrap(); + let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf()); + let session = em + .get_context() + .session_manager + .create_session( + PathBuf::from("/test/dir"), + "test".to_string(), + crate::session::SessionType::User, + crate::config::GooseMode::Auto, + ) + .await + .unwrap(); + + let conv = Conversation::new_unvalidated(vec![ + Message::user().with_text("agent visible"), + Message::assistant().with_text("reply"), + Message::user().with_text("user only").user_only(), + ]); + let result = inject_moim(&session.id, conv, &em, 0, 100).await; + let msgs = result.messages(); - let merged_content = result.messages()[0] - .content - .iter() - .filter_map(|c| c.as_text()) - .collect::>() - .join(""); - assert!(merged_content.contains("Hello")); - assert!(merged_content.contains("")); - assert!(merged_content.contains("Working directory: /test/dir")); + assert_eq!(msgs.len(), 2); + assert!(is_moim(&msgs[0].content[0])); + assert_eq!(text_at(&msgs[0], 1), "agent visible"); + assert_eq!(text_at(&msgs[1], 0), "user only"); + assert!(!msgs[1].is_agent_visible()); } #[tokio::test] async fn test_moim_with_tool_calls() { let temp_dir = tempfile::tempdir().unwrap(); let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf()); - let working_dir = PathBuf::from("/test/dir"); + let session = em + .get_context() + .session_manager + .create_session( + PathBuf::from("/test/dir"), + "test".to_string(), + crate::session::SessionType::User, + crate::config::GooseMode::Auto, + ) + .await + .unwrap(); let conv = Conversation::new_unvalidated(vec![ Message::user().with_text("Search for something"), @@ -121,27 +341,18 @@ mod tests { .with_tool_request("search_1", Ok(CallToolRequestParams::new("search"))), Message::user() .with_tool_response("search_1", Ok(rmcp::model::CallToolResult::success(vec![]))), - Message::assistant() - .with_text("I need to search more") - .with_tool_request("search_2", Ok(CallToolRequestParams::new("search"))), - Message::user() - .with_tool_response("search_2", Ok(rmcp::model::CallToolResult::success(vec![]))), ]); - let result = inject_moim("test-session-id", conv, &em, &working_dir).await; + let result = inject_moim(&session.id, conv, &em, 0, 100).await; let msgs = result.messages(); - assert_eq!(msgs.len(), 6); - - let moim_msg = &msgs[3]; - let has_moim = moim_msg - .content - .iter() - .any(|c| c.as_text().is_some_and(|t| t.contains(""))); - - assert!( - has_moim, - "MOIM should be in message before latest assistant message" - ); + assert_eq!(msgs.len(), 3); + assert!(is_moim(&msgs[0].content[0])); + assert_eq!(text_at(&msgs[0], 1), "Search for something"); + assert!(matches!( + &msgs[2].content[0], + MessageContent::ToolResponse(_) + )); + assert_eq!(msgs[2].content.len(), 1); } } diff --git a/crates/goose/src/agents/platform_extensions/code_execution.rs b/crates/goose/src/agents/platform_extensions/code_execution.rs index 6fc50fcbf6c3..935b51f2f606 100644 --- a/crates/goose/src/agents/platform_extensions/code_execution.rs +++ b/crates/goose/src/agents/platform_extensions/code_execution.rs @@ -478,21 +478,8 @@ impl McpClientTrait for CodeExecutionClient { let disclosure_style_moim = match self.disclosure { ToolDisclosure::Catalog => { - let functions = code_mode.list_functions().functions; - let sandbox_only: Vec<_> = functions - .iter() - .filter(|f| !crate::agents::extension_manager::is_first_class_extension(&f.namespace)) - .map(|f| format!("{}.{}", &f.namespace, &f.name)) - .collect(); - let mut msg = String::new(); - if !sandbox_only.is_empty() { - msg.push_str(&format!( - "Additional functions available ONLY via execute_typescript (do NOT call these as direct tool calls): {}", - sandbox_only.join(", ") - )); - } - msg.push_str("\n\n Use the list_functions & get_function_details tools to see tool signatures and input/output types before calling execute_typescript."); - msg + let function_count = code_mode.list_functions().functions.len(); + catalog_disclosure_moim(function_count) } ToolDisclosure::Filesystem => { let available_filepaths: Vec<_> = code_mode @@ -515,6 +502,16 @@ impl McpClientTrait for CodeExecutionClient { } } +fn catalog_disclosure_moim(function_count: usize) -> String { + if function_count == 0 { + "No execute_typescript callback functions are currently registered.".to_string() + } else { + format!( + "{function_count} callback functions are available only from inside execute_typescript. Do not call callback function names directly as tools. Use list_functions and get_function_details to inspect signatures before writing one execute_typescript call." + ) + } +} + pub fn get_tool_disclosure() -> ToolDisclosure { let config = crate::config::Config::global(); let tool_disclosure_str: String = config @@ -554,3 +551,19 @@ impl CodeModeState { hasher.finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_moim_mentions_inspection_tools_without_function_names() { + let moim = catalog_disclosure_moim(3); + + assert!(moim.contains("3 callback functions")); + assert!(moim.contains("list_functions")); + assert!(moim.contains("get_function_details")); + assert!(!moim.contains("extract_relations")); + assert!(!moim.contains("ask_heimdall")); + } +} diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index 2f7245944b95..a721756a40a7 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -26,7 +26,7 @@ use crate::subprocess::SubprocessExt; /// When inside Flatpak, shell commands must be wrapped with `flatpak-spawn --host` /// to execute on the host system rather than inside the sandbox. #[cfg(not(windows))] -fn is_flatpak() -> bool { +pub(crate) fn is_flatpak() -> bool { std::path::Path::new("/.flatpak-info").exists() } @@ -34,7 +34,7 @@ fn is_flatpak() -> bool { const FLATPAK_HOST_ARGS: [&str; 2] = ["--host", "--watch-bus"]; #[cfg(not(windows))] -fn flatpak_spawn_command() -> tokio::process::Command { +pub(crate) fn flatpak_spawn_command() -> tokio::process::Command { let mut command = tokio::process::Command::new("flatpak-spawn"); command.args(FLATPAK_HOST_ARGS); command @@ -201,7 +201,7 @@ pub struct ShellOutput { /// a minimal PATH like `/usr/bin:/bin`. This function spawns a login shell to /// source the user's profile and recover the full PATH. #[cfg(not(windows))] -fn resolve_login_shell_path() -> Option { +pub(crate) fn resolve_login_shell_path() -> Option { use process_wrap::std::{CommandWrap, ProcessSession}; let shell = unix_shell(); diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 320d77fbb1fc..af22aeef5968 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -58,6 +58,8 @@ pub struct DelegateParams { pub model: Option, pub temperature: Option, pub max_turns: Option, + pub context: Option, + pub working_dir: Option, #[serde(default)] pub r#async: bool, } @@ -79,6 +81,16 @@ pub struct CompletedTask { pub result: Result, pub turns_taken: u32, pub duration: Duration, + pub completed_at: Instant, +} + +/// Result from handle_load_task_result with structured metadata for the caller +#[derive(Debug)] +struct TaskLoadResult { + content: Vec, + status: &'static str, + turns: Option, + duration_secs: Option, } #[derive(Debug, Deserialize)] @@ -130,6 +142,7 @@ fn parse_agent_content(content: &str, path: &Path) -> Option { fn scan_recipes_from_dir( dir: &Path, kind: SourceType, + suppress_config_warnings: bool, sources: &mut Vec, seen: &mut std::collections::HashSet, ) { @@ -175,6 +188,13 @@ fn scan_recipes_from_dir( }); } Err(e) => { + // The working directory commonly contains project config like package.json + // and tsconfig.json, which parse as valid JSON but lack Recipe fields. In that + // case treat them as "not a recipe" rather than warning. Dedicated recipe + // directories still warn so a real recipe with a typo is not silently dropped. + if suppress_config_warnings && e.to_string().contains("missing field") { + continue; + } warn!("Failed to parse recipe {}: {}", path.display(), e); } } @@ -227,7 +247,6 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec { let config = Paths::config_dir(); let local_recipe_dirs: Vec = vec![ - working_dir.to_path_buf(), working_dir.join(".goose/recipes"), working_dir.join(".agents/recipes"), ]; @@ -266,8 +285,16 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec { .flatten() .collect(); + scan_recipes_from_dir( + working_dir, + SourceType::Recipe, + true, + &mut sources, + &mut seen, + ); + for dir in local_recipe_dirs { - scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen); + scan_recipes_from_dir(&dir, SourceType::Recipe, false, &mut sources, &mut seen); } for dir in local_agent_dirs { @@ -275,7 +302,7 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec { } for dir in global_recipe_dirs { - scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen); + scan_recipes_from_dir(&dir, SourceType::Recipe, false, &mut sources, &mut seen); } for dir in global_agent_dirs { @@ -285,6 +312,14 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec { sources } +fn build_instructions_with_context(context: &str, instructions: &str) -> String { + let mut result = format!("# Reference Context\n\n{}", context); + if !instructions.is_empty() { + result.push_str(&format!("\n\n# Task Instructions\n\n{}", instructions)); + } + result +} + fn build_subagent_instructions(session: Option<&crate::session::Session>) -> String { let Some(session) = session else { return String::new(); @@ -401,6 +436,13 @@ fn max_background_tasks() -> usize { .unwrap_or(5) } +fn completed_task_ttl() -> Duration { + let secs = Config::global() + .get_param::("GOOSE_COMPLETED_TASK_TTL_SECS") + .unwrap_or(600); + Duration::from_secs(secs) +} + fn is_session_id(s: &str) -> bool { let parts: Vec<&str> = s.split('_').collect(); parts.len() == 2 && parts[0].len() == 8 && parts[0].chars().all(|c| c.is_ascii_digit()) @@ -475,6 +517,11 @@ impl SummonClient { "type": "boolean", "default": false, "description": "For running background tasks: cancel and return output." + }, + "peek": { + "type": "boolean", + "default": false, + "description": "For running background tasks: check progress without blocking. Returns turn count, idle time, and recent tool activity." } } }); @@ -485,11 +532,13 @@ impl SummonClient { Call with no arguments to list all available sources (subrecipes, recipes, agents).\n\ Call with a source name to load its content into your context.\n\ For background tasks: load(source: \"task_id\") waits for the task and returns the result.\n\ - To cancel a running task: load(source: \"task_id\", cancel: true) stops and returns output.\n\n\ + To cancel a running task: load(source: \"task_id\", cancel: true) stops and returns output.\n\ + To check progress: load(source: \"task_id\", peek: true) returns status without blocking.\n\n\ Examples:\n\ - load() → Lists available sources\n\ - load(source: \"deploy\") → Loads the deploy recipe\n\ - - load(source: \"20260219_1\") → Waits for background task, then returns result" + - load(source: \"20260219_1\") → Waits for background task, then returns result\n\ + - load(source: \"20260219_1\", peek: true) → Check task progress without waiting" .to_string(), schema.as_object().unwrap().clone(), ) @@ -534,6 +583,14 @@ impl SummonClient { "minimum": 1, "description": "Maximum turns for this delegate. Overrides recipe settings.max_turns and GOOSE_SUBAGENT_MAX_TURNS." }, + "context": { + "type": "string", + "description": "Reference context to inject into the delegate's system prompt. Use for background information, file contents, or constraints the delegate needs but that aren't part of the task instructions." + }, + "working_dir": { + "type": "string", + "description": "Working directory for the delegate. Must be within the parent session's working directory. Defaults to the parent's working directory." + }, "async": { "type": "boolean", "default": false, @@ -765,6 +822,12 @@ impl SummonClient { .and_then(|v| v.as_bool()) .unwrap_or(false); + let peek = arguments + .as_ref() + .and_then(|args| args.get("peek")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let working_dir = self.get_working_dir(session_id).await; if source_name.is_none() { @@ -777,13 +840,29 @@ impl SummonClient { let name = source_name.unwrap(); if is_session_id(name) { - let content = self.handle_load_task_result(name, cancel).await?; + let task_result = self.handle_load_task_result(name, cancel, peek).await?; let mut meta = Meta::new(); meta.0.insert( "subagent_session_id".to_string(), serde_json::Value::String(name.to_string()), ); - return Ok(CallToolResult::success(content).with_meta(Some(meta))); + meta.0.insert( + "task_status".to_string(), + serde_json::Value::String(task_result.status.to_string()), + ); + if let Some(turns) = task_result.turns { + meta.0.insert( + "turns_taken".to_string(), + serde_json::Value::Number(turns.into()), + ); + } + if let Some(secs) = task_result.duration_secs { + meta.0.insert( + "duration_secs".to_string(), + serde_json::Value::Number(secs.into()), + ); + } + return Ok(CallToolResult::success(task_result.content).with_meta(Some(meta))); } self.handle_load_source(session_id, name, &working_dir) @@ -795,39 +874,103 @@ impl SummonClient { &self, task_id: &str, cancel: bool, - ) -> Result, String> { + peek: bool, + ) -> Result { let mut completed = self.completed_tasks.lock().await; - if let Some(task) = completed.remove(task_id) { - let status = if task.result.is_ok() { - "✓ Completed" - } else { - "✗ Failed" + let completed_entry = if peek { + completed.get(task_id).map(|task| { + ( + task.result.clone(), + task.description.clone(), + task.duration, + task.turns_taken, + ) + }) + } else { + completed.remove(task_id).map(|task| { + ( + task.result, + task.description, + task.duration, + task.turns_taken, + ) + }) + }; + + if let Some((result, description, duration, turns_taken)) = completed_entry { + let status_key = match &result { + Ok(_) => "completed", + Err(e) if e.starts_with("Task panicked:") => "panicked", + Err(_) => "failed", + }; + let status = match status_key { + "completed" => "✓ Completed", + "panicked" => "✗ Panicked", + _ => "✗ Failed", }; - let output = match task.result { + let output = match result { Ok(output) => output, Err(error) => format!("Error: {}", error), }; - - return Ok(vec![Content::text(format!( - "# Background Task Result: {}\n\n\ - **Task:** {}\n\ - **Status:** {}\n\ - **Duration:** {} ({} turns)\n\n\ - ## Output\n\n{}", - task_id, - task.description, - status, - round_duration(task.duration), - task.turns_taken, - output - ))]); + return Ok(TaskLoadResult { + content: vec![Content::text(format!( + "# Background Task Result: {}\n\n\ + **Task:** {}\n\ + **Status:** {}\n\ + **Duration:** {} ({} turns)\n\n\ + ## Output\n\n{}", + task_id, + description, + status, + round_duration(duration), + turns_taken, + output + ))], + status: status_key, + turns: Some(turns_taken), + duration_secs: Some(duration.as_secs()), + }); } drop(completed); let mut running = self.background_tasks.lock().await; if running.contains_key(task_id) { + if peek { + let task = running.get(task_id).unwrap(); + let elapsed = task.started_at.elapsed(); + let turns_taken = task.turns.load(Ordering::Relaxed); + let now = current_epoch_millis(); + let idle_ms = now.saturating_sub(task.last_activity.load(Ordering::Relaxed)); + let description = task.description.clone(); + + let buffered_count = task.notification_buffer.lock().await.len(); + + drop(running); + + let mut output = format!( + "# Background Task Status: {}\n\n**Task:** {}\n**Status:** ⏳ Running\n**Elapsed:** {}\n**Turns taken:** {}\n**Idle:** {}\n**Buffered tool calls:** {}", + task_id, + description, + round_duration(elapsed), + turns_taken, + round_duration(Duration::from_millis(idle_ms)), + buffered_count, + ); + + if buffered_count == 0 && turns_taken == 0 { + output.push_str("\n\n_Task is initialising (no tool activity yet)._"); + } + + return Ok(TaskLoadResult { + content: vec![Content::text(output)], + status: "running", + turns: Some(turns_taken), + duration_secs: Some(elapsed.as_secs()), + }); + } + if cancel { let task = running.remove(task_id).unwrap(); drop(running); @@ -852,18 +995,23 @@ impl SummonClient { } }; - return Ok(vec![Content::text(format!( - "# Background Task Result: {}\n\n\ - **Task:** {}\n\ - **Status:** ⊘ Cancelled\n\ - **Duration:** {} ({} turns)\n\n\ - ## Output\n\n{}", - task_id, - task.description, - round_duration(duration), - turns_taken, - output - ))]); + return Ok(TaskLoadResult { + content: vec![Content::text(format!( + "# Background Task Result: {}\n\n\ + **Task:** {}\n\ + **Status:** ⊘ Cancelled\n\ + **Duration:** {} ({} turns)\n\n\ + ## Output\n\n{}", + task_id, + task.description, + round_duration(duration), + turns_taken, + output + ))], + status: "cancelled", + turns: Some(turns_taken), + duration_secs: Some(duration.as_secs()), + }); } // Wait for the running task to complete, keeping the tool call @@ -886,24 +1034,37 @@ impl SummonClient { tokio::select! { result = &mut task.handle => { - let output = match result { - Ok(Ok(s)) => s, - Ok(Err(e)) => format!("Error: {}", e), - Err(e) => format!("Task panicked: {}", e), + let (output, status_key) = match result { + Ok(Ok(s)) => (s, "completed"), + Ok(Err(e)) => (format!("Error: {}", e), "failed"), + Err(e) => (format!("Task panicked: {}", e), "panicked"), }; - return Ok(vec![Content::text(format!( - "# Background Task Result: {}\n\n\ - **Task:** {}\n\ - **Status:** ✓ Completed\n\ - **Duration:** {} ({} turns)\n\n\ - ## Output\n\n{}", - task_id, - task.description, - round_duration(task.started_at.elapsed()), - task.turns.load(Ordering::Relaxed), - output - ))]); + let turns_taken = task.turns.load(Ordering::Relaxed); + let elapsed = task.started_at.elapsed(); + let status_display = match status_key { + "completed" => "✓ Completed", + "panicked" => "✗ Panicked", + _ => "✗ Failed", + }; + return Ok(TaskLoadResult { + content: vec![Content::text(format!( + "# Background Task Result: {}\n\n\ + **Task:** {}\n\ + **Status:** {}\n\ + **Duration:** {} ({} turns)\n\n\ + ## Output\n\n{}", + task_id, + task.description, + status_display, + round_duration(elapsed), + turns_taken, + output + ))], + status: status_key, + turns: Some(turns_taken), + duration_secs: Some(elapsed.as_secs()), + }); } _ = tokio::time::sleep(Duration::from_secs(300)) => { self.background_tasks.lock().await.insert(task_id.to_string(), task); @@ -1090,13 +1251,14 @@ impl SummonClient { GooseMode::Auto, true, // disable session naming for subagents crate::agents::GoosePlatform::GooseCli, - ); + ) + .with_use_login_shell_path(self.context.use_login_shell_path); let subagent_session = self .context .session_manager .create_session( - working_dir, + task_config.parent_working_dir.clone(), "Delegated task".to_string(), SessionType::SubAgent, GooseMode::Auto, @@ -1167,12 +1329,19 @@ impl SummonClient { session_id: &str, working_dir: &Path, ) -> Result { - if let Some(source_name) = ¶ms.source { + let mut recipe = if let Some(source_name) = ¶ms.source { self.build_source_recipe(source_name, params, session_id, working_dir) - .await + .await? } else { - self.build_adhoc_recipe(params) + self.build_adhoc_recipe(params)? + }; + + if let Some(ref context) = params.context { + let existing = recipe.instructions.unwrap_or_default(); + recipe.instructions = Some(build_instructions_with_context(context, &existing)); } + + Ok(recipe) } fn build_adhoc_recipe(&self, params: &DelegateParams) -> Result { @@ -1212,7 +1381,7 @@ impl SummonClient { return Err(format!( "Source '{}' has kind '{}' which cannot be delegated from summon", source_name, source.source_type - )) + )); } }; @@ -1370,7 +1539,20 @@ impl SummonClient { if filter.is_empty() { extensions = Vec::new(); } else { + let available_names: Vec = + extensions.iter().map(|ext| ext.name()).collect(); extensions.retain(|ext| filter.contains(&ext.name())); + let unmatched: Vec<&str> = filter + .iter() + .filter(|name| !available_names.iter().any(|n| n == *name)) + .map(String::as_str) + .collect(); + if !unmatched.is_empty() { + warn!( + "Delegate requested extensions not available in session: {:?}. Available: {:?}", + unmatched, available_names + ); + } } } @@ -1387,8 +1569,14 @@ impl SummonClient { ); } - let task_config = TaskConfig::new(provider, &session.id, &session.working_dir, extensions) - .with_max_turns(Some(max_turns)); + let effective_working_dir = match ¶ms.working_dir { + Some(dir) => resolve_working_dir(&session.working_dir, dir)?, + None => session.working_dir.clone(), + }; + + let task_config = + TaskConfig::new(provider, &session.id, &effective_working_dir, extensions) + .with_max_turns(Some(max_turns)); Ok(task_config) } @@ -1399,10 +1587,9 @@ impl SummonClient { recipe: &Recipe, session: &crate::session::Session, provider_name: &str, - ) -> Result { + ) -> Result { let mut model_config = session.model_config.clone().map(Ok).unwrap_or_else(|| { - crate::model::ModelConfig::new("default") - .map(|c| c.with_canonical_limits(provider_name)) + crate::model_config::model_config_from_user_config(provider_name, "default") })?; let override_model = params @@ -1424,7 +1611,7 @@ impl SummonClient { // not model-specific from the parent. let parent = model_config; let mut cfg = - crate::model::ModelConfig::new(&model)?.with_canonical_limits(provider_name); + crate::model_config::model_config_from_user_config(provider_name, &model)?; cfg.toolshim = parent.toolshim; cfg.toolshim_model = parent.toolshim_model; cfg.fast_model_config = parent.fast_model_config; @@ -1536,9 +1723,13 @@ impl SummonClient { result, turns_taken, duration, + completed_at: Instant::now(), }, ); } + + let ttl = completed_task_ttl(); + completed.retain(|_id, task| task.completed_at.elapsed() <= ttl); } fn get_task_description(params: &DelegateParams) -> String { @@ -1593,13 +1784,14 @@ impl SummonClient { GooseMode::Auto, true, // disable session naming for subagents crate::agents::GoosePlatform::GooseCli, - ); + ) + .with_use_login_shell_path(self.context.use_login_shell_path); let subagent_session = self .context .session_manager .create_session( - working_dir, + task_config.parent_working_dir.clone(), description.clone(), SessionType::SubAgent, GooseMode::Auto, @@ -1817,6 +2009,34 @@ impl McpClientTrait for SummonClient { } } +/// Resolve a requested `working_dir` override against the parent session +/// directory. Relative paths are joined to the parent dir; the result must +/// canonicalize to an existing directory contained within the parent dir. +fn resolve_working_dir(parent_dir: &Path, requested: &str) -> Result { + let requested_path = PathBuf::from(requested); + let resolved = if requested_path.is_absolute() { + requested_path + } else { + parent_dir.join(&requested_path) + }; + let canonical = resolved + .canonicalize() + .map_err(|e| anyhow::anyhow!("working_dir '{}' could not be resolved: {}", requested, e))?; + let parent_canonical = parent_dir + .canonicalize() + .unwrap_or_else(|_| parent_dir.to_path_buf()); + if !canonical.starts_with(&parent_canonical) { + anyhow::bail!( + "working_dir '{}' is outside the parent session directory", + requested + ); + } + if !canonical.is_dir() { + anyhow::bail!("working_dir '{}' is not a directory", requested); + } + Ok(canonical) +} + #[cfg(test)] mod tests { use super::*; @@ -1847,6 +2067,58 @@ You review code."#; assert!(source.description.contains("sonnet")); } + #[test] + fn test_resolve_working_dir_relative_subdir() { + let temp_dir = TempDir::new().unwrap(); + let parent = temp_dir.path().canonicalize().unwrap(); + let subdir = parent.join("sub"); + fs::create_dir(&subdir).unwrap(); + + let resolved = resolve_working_dir(&parent, "sub").unwrap(); + assert_eq!(resolved, subdir.canonicalize().unwrap()); + } + + #[test] + fn test_resolve_working_dir_rejects_traversal_outside_parent() { + let temp_dir = TempDir::new().unwrap(); + let parent = temp_dir.path().join("parent"); + let sibling = temp_dir.path().join("sibling"); + fs::create_dir(&parent).unwrap(); + fs::create_dir(&sibling).unwrap(); + + let err = resolve_working_dir(&parent, "../sibling").unwrap_err(); + assert!( + err.to_string() + .contains("outside the parent session directory"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_resolve_working_dir_rejects_file_path() { + let temp_dir = TempDir::new().unwrap(); + let parent = temp_dir.path().canonicalize().unwrap(); + let file = parent.join("a.txt"); + fs::write(&file, "hello").unwrap(); + + let err = resolve_working_dir(&parent, "a.txt").unwrap_err(); + assert!( + err.to_string().contains("is not a directory"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_resolve_working_dir_rejects_nonexistent_path() { + let temp_dir = TempDir::new().unwrap(); + let parent = temp_dir.path().canonicalize().unwrap(); + + let err = resolve_working_dir(&parent, "does-not-exist").unwrap_err(); + assert!( + err.to_string().contains("could not be resolved"), + "unexpected error: {err}" + ); + } #[test] fn test_agent_scan_skips_non_agent_markdown() { let temp_dir = TempDir::new().unwrap(); @@ -1882,6 +2154,40 @@ You review code."#; assert_eq!(sources[0].name, "reviewer"); } + #[test] + fn test_recipe_scan_skips_non_recipe_project_config_files() { + let temp_dir = TempDir::new().unwrap(); + fs::write( + temp_dir.path().join("package.json"), + r#"{"scripts":{"test":"cargo test"}}"#, + ) + .unwrap(); + fs::write( + temp_dir.path().join("tsconfig.json"), + r#"{"compilerOptions":{"strict":true}}"#, + ) + .unwrap(); + fs::write( + temp_dir.path().join("valid.yaml"), + "title: Valid\ndescription: Real recipe\ninstructions: Run valid steps", + ) + .unwrap(); + + let mut sources = Vec::new(); + let mut seen = HashSet::new(); + scan_recipes_from_dir( + temp_dir.path(), + SourceType::Recipe, + true, + &mut sources, + &mut seen, + ); + + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].name, "valid"); + assert_eq!(sources[0].description, "Real recipe"); + } + #[tokio::test] async fn test_discover_recipes_and_agents() { let temp_dir = TempDir::new().unwrap(); @@ -2087,6 +2393,41 @@ You review code."#; ); } + #[tokio::test] + async fn test_context_injected_into_adhoc_recipe() { + let temp_dir = TempDir::new().unwrap(); + let client = SummonClient::new(create_test_context()).unwrap(); + + let params = DelegateParams { + instructions: Some("do the task".to_string()), + context: Some("background info".to_string()), + ..Default::default() + }; + + let recipe = client + .build_delegate_recipe(¶ms, "test", temp_dir.path()) + .await + .unwrap(); + + assert_eq!( + recipe.instructions.as_deref(), + Some("# Reference Context\n\nbackground info") + ); + assert_eq!(recipe.prompt.as_deref(), Some("do the task")); + } + + #[test] + fn test_build_instructions_with_context_wraps_existing_instructions() { + assert_eq!( + build_instructions_with_context("background info", "Run deploy steps"), + "# Reference Context\n\nbackground info\n\n# Task Instructions\n\nRun deploy steps" + ); + assert_eq!( + build_instructions_with_context("background info", ""), + "# Reference Context\n\nbackground info" + ); + } + #[test] fn test_validate_delegate_params_rejects_zero_max_turns() { let context = create_test_context(); @@ -2213,7 +2554,7 @@ You review code."#; const OVERRIDE_MODEL: &str = "claude-opus-4-6"; const PROVIDER: &str = "anthropic"; - fn session_with(parent: crate::model::ModelConfig) -> crate::session::Session { + fn session_with(parent: goose_providers::model::ModelConfig) -> crate::session::Session { crate::session::Session { provider_name: Some(PROVIDER.to_string()), model_config: Some(parent), @@ -2223,8 +2564,8 @@ You review code."#; fn resolve_with_override( model: Option<&str>, - parent: crate::model::ModelConfig, - ) -> crate::model::ModelConfig { + parent: goose_providers::model::ModelConfig, + ) -> goose_providers::model::ModelConfig { let client = SummonClient::new(create_test_context()).unwrap(); let params = DelegateParams { model: model.map(String::from), @@ -2235,8 +2576,8 @@ You review code."#; .expect("resolve_model_config") } - fn parent_config() -> crate::model::ModelConfig { - crate::model::ModelConfig::new(PARENT_MODEL) + fn parent_config() -> goose_providers::model::ModelConfig { + goose_providers::model::ModelConfig::new(PARENT_MODEL) .unwrap() .with_canonical_limits(PROVIDER) } @@ -2251,7 +2592,7 @@ You review code."#; ]); let parent = parent_config(); - let overridden = crate::model::ModelConfig::new(OVERRIDE_MODEL) + let overridden = goose_providers::model::ModelConfig::new(OVERRIDE_MODEL) .unwrap() .with_canonical_limits(PROVIDER); assert_ne!(parent.context_limit, overridden.context_limit); @@ -2315,7 +2656,9 @@ You review code."#; let client = SummonClient::new(create_test_context()).unwrap(); let temp_dir = TempDir::new().unwrap(); - let result = client.handle_load_task_result("20260204_999", false).await; + let result = client + .handle_load_task_result("20260204_999", false, false) + .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); @@ -2357,10 +2700,10 @@ You review code."#; let mut subscriber = client.subscribe().await; let result = client - .handle_load_task_result("20260204_1", false) + .handle_load_task_result("20260204_1", false, false) .await .expect("load should wait and return result"); - let text = extract_text(&result[0]); + let text = extract_text(&result.content[0]); assert!(text.contains("Completed")); assert!(text.contains("done")); @@ -2387,6 +2730,7 @@ You review code."#; result: Ok("Task completed successfully with output".to_string()), turns_taken: 5, duration: Duration::from_secs(60), + completed_at: Instant::now(), }, ); completed.insert( @@ -2397,6 +2741,7 @@ You review code."#; result: Err("Something went wrong".to_string()), turns_taken: 3, duration: Duration::from_secs(30), + completed_at: Instant::now(), }, ); } @@ -2417,16 +2762,18 @@ You review code."#; assert!(discovery_text.contains("20260204_3")); let result = client - .handle_load_task_result("20260204_2", false) + .handle_load_task_result("20260204_2", false, false) .await .unwrap(); - let text = extract_text(&result[0]); + let text = extract_text(&result.content[0]); assert!(text.contains("20260204_2")); assert!(text.contains("Successful task")); assert!(text.contains("✓ Completed")); assert!(text.contains("1m")); assert!(text.contains("5 turns")); assert!(text.contains("Task completed successfully with output")); + assert_eq!(result.status, "completed"); + assert_eq!(result.turns, Some(5)); assert!(!client .completed_tasks @@ -2435,14 +2782,17 @@ You review code."#; .contains_key("20260204_2")); let result = client - .handle_load_task_result("20260204_3", false) + .handle_load_task_result("20260204_3", false, false) .await .unwrap(); - let text = extract_text(&result[0]); + let text = extract_text(&result.content[0]); assert!(text.contains("✗ Failed")); assert!(text.contains("Error: Something went wrong")); + assert_eq!(result.status, "failed"); - let result = client.handle_load_task_result("20260204_3", false).await; + let result = client + .handle_load_task_result("20260204_3", false, false) + .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); @@ -2476,13 +2826,15 @@ You review code."#; } let result = client - .handle_load_task_result("20260204_1", true) + .handle_load_task_result("20260204_1", true, false) .await .unwrap(); - let text = extract_text(&result[0]); + let text = extract_text(&result.content[0]); assert!(text.contains("Cancelled")); assert!(text.contains("20260204_1")); assert!(text.contains("Cancellable task")); + assert_eq!(result.status, "cancelled"); + assert_eq!(result.turns, Some(3)); assert!(token.is_cancelled()); assert!(!client .background_tasks @@ -2490,4 +2842,98 @@ You review code."#; .await .contains_key("20260204_1")); } + + #[tokio::test] + async fn test_peek_running_task() { + let client = SummonClient::new(create_test_context()).unwrap(); + + { + let mut running = client.background_tasks.lock().await; + running.insert( + "20260204_1".to_string(), + BackgroundTask { + id: "20260204_1".to_string(), + description: "Long running analysis".to_string(), + started_at: Instant::now(), + turns: Arc::new(AtomicU32::new(7)), + last_activity: Arc::new(AtomicU64::new(current_epoch_millis())), + handle: tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(1000)).await; + Ok("eventual result".to_string()) + }), + cancellation_token: CancellationToken::new(), + notification_buffer: Arc::new(Mutex::new(Vec::new())), + }, + ); + } + + // Peek should return status without removing the task + let result = client + .handle_load_task_result("20260204_1", false, true) + .await + .unwrap(); + let text = extract_text(&result.content[0]); + assert!(text.contains("Running")); + assert!(text.contains("Long running analysis")); + assert!(text.contains("7")); // turns taken + + // Task should still be in background_tasks (not consumed) + assert!(client + .background_tasks + .lock() + .await + .contains_key("20260204_1")); + } + + #[tokio::test] + async fn test_peek_nonexistent_task() { + let client = SummonClient::new(create_test_context()).unwrap(); + + let result = client + .handle_load_task_result("20260204_999", false, true) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[tokio::test] + async fn test_peek_completed_task_returns_result() { + let client = SummonClient::new(create_test_context()).unwrap(); + + { + let mut completed = client.completed_tasks.lock().await; + completed.insert( + "20260204_1".to_string(), + CompletedTask { + id: "20260204_1".to_string(), + description: "Finished task".to_string(), + result: Ok("final output".to_string()), + turns_taken: 4, + duration: Duration::from_secs(30), + completed_at: Instant::now(), + }, + ); + } + + // Peek on a completed task should return the full result (same as non-peek) + let result = client + .handle_load_task_result("20260204_1", false, true) + .await + .unwrap(); + let text = extract_text(&result.content[0]); + assert!(text.contains("Completed")); + assert!(text.contains("final output")); + + // Peek must be non-destructive: the result is still retrievable afterwards. + assert!(client + .completed_tasks + .lock() + .await + .contains_key("20260204_1")); + let result = client + .handle_load_task_result("20260204_1", false, false) + .await + .unwrap(); + assert!(extract_text(&result.content[0]).contains("final output")); + } } diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index 09b03212a8fa..810d7e6739d6 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -6,7 +6,7 @@ use serde::Serialize; use serde_json::Value; use std::collections::HashMap; -use crate::agents::extension::ExtensionInfo; +use crate::agents::{extension::ExtensionInfo, moim}; use crate::hints::load_hints::build_gitignore; use crate::hints::{get_context_filenames, load_hint_files, SubdirectoryHintTracker}; use crate::{ @@ -44,6 +44,8 @@ struct SystemPromptContext { max_extensions: usize, max_tools: usize, code_execution_mode: bool, + #[serde(skip_serializing_if = "Option::is_none")] + moim_system_prompt_block: Option, } pub struct SystemPromptBuilder<'a, M> { @@ -152,6 +154,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { max_extensions: MAX_EXTENSIONS, max_tools: MAX_TOOLS, code_execution_mode: self.code_execution_mode, + moim_system_prompt_block: moim::system_prompt_block(), }; let base_prompt = if let Some(override_prompt) = &self.manager.system_prompt_override { diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 84c7db62a6fd..0a38731793cd 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -11,6 +11,7 @@ use tracing::debug; use super::super::agents::Agent; #[cfg(feature = "code-mode")] use crate::agents::platform_extensions::code_execution; +use crate::config::Config; use crate::conversation::message::{Message, MessageContent, ToolRequest}; use crate::conversation::Conversation; #[cfg(test)] @@ -280,7 +281,9 @@ impl Agent { // Capture errors during stream creation and return them as part of the stream // so they can be handled by the existing error handling logic in the agent - let model_config = provider.get_model_config(); + let model_config = provider + .get_model_config() + .with_default_thinking_effort(Config::global().get_goose_thinking_effort()); debug!("WAITING_LLM_STREAM_START"); let stream_result = provider .stream( @@ -419,6 +422,17 @@ impl Agent { }) .collect(); + // Providers should emit unique tool-call ids within a turn, but a + // malformed or malicious provider can repeat one. Keep only the first + // occurrence of each id, in the order the provider sent them, so tools + // aren't executed twice and duplicate tool_results don't pollute the + // conversation history. + let mut seen_ids = std::collections::HashSet::new(); + let tool_requests: Vec = tool_requests + .into_iter() + .filter(|req| seen_ids.insert(req.id.clone())) + .collect(); + let has_tool_requests = !tool_requests.is_empty(); let should_suppress_replayed_thinking = suppress_replayed_thinking && has_tool_requests; @@ -430,29 +444,32 @@ impl Agent { // accumulated reasoning after streamed thought chunks while still // preserving final-only non-streaming thoughts. let mut filtered_content = Vec::new(); - let mut tool_request_index = 0; + let mut deduped_requests = tool_requests.iter(); + let mut next_request = deduped_requests.next(); for content in &response.content { match content { - MessageContent::ToolRequest(_) => { - if tool_request_index < tool_requests.len() { - let coerced_req = &tool_requests[tool_request_index]; - tool_request_index += 1; - - // Always keep externally-dispatched requests visible, even if - // their name happens to overlap a registered frontend tool — - // they're observation-only and must not be removed from history. - let should_include = if coerced_req.is_externally_dispatched() { - true - } else if let Ok(tool_call) = &coerced_req.tool_call { - !self.is_frontend_tool(&tool_call.name).await - } else { - true - }; - - if should_include { - filtered_content.push(MessageContent::ToolRequest(coerced_req.clone())); - } + MessageContent::ToolRequest(req) => { + // Drop content for requests removed during dedup so duplicate + // ids don't survive into the filtered (history) message. + let Some(coerced_req) = next_request.filter(|r| r.id == req.id) else { + continue; + }; + next_request = deduped_requests.next(); + + // Always keep externally-dispatched requests visible, even if + // their name happens to overlap a registered frontend tool — + // they're observation-only and must not be removed from history. + let should_include = if coerced_req.is_externally_dispatched() { + true + } else if let Ok(tool_call) = &coerced_req.tool_call { + !self.is_frontend_tool(&tool_call.name).await + } else { + true + }; + + if should_include { + filtered_content.push(MessageContent::ToolRequest(coerced_req.clone())); } } MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) @@ -622,11 +639,11 @@ mod tests { use super::*; use crate::config::GooseMode; use crate::conversation::message::Message; - use crate::model::ModelConfig; use crate::providers::base::Provider; use crate::session::session_manager::SessionType; use async_trait::async_trait; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; + use goose_providers::model::ModelConfig; use rmcp::object; #[derive(Clone)] @@ -865,6 +882,47 @@ mod tests { ); } + #[tokio::test] + async fn categorize_tool_requests_dedups_duplicate_ids_in_provider_order() { + // A malformed provider repeats id "dup". The first occurrence wins, the + // later duplicate is dropped from both the dispatch bucket and the + // filtered (history) message, and unique ids are kept. + let agent = crate::agents::Agent::new(); + + let response = Message::assistant() + .with_tool_request( + "dup", + Ok(rmcp::model::CallToolRequestParams::new("first_tool")), + ) + .with_tool_request( + "dup", + Ok(rmcp::model::CallToolRequestParams::new("second_tool")), + ) + .with_tool_request( + "unique", + Ok(rmcp::model::CallToolRequestParams::new("third_tool")), + ); + + let (_frontend_requests, other_requests, filtered_message) = + agent.categorize_tool_requests(&response, &[], false).await; + + let kept: Vec<(&str, &str)> = other_requests + .iter() + .map(|r| (r.id.as_str(), r.tool_call.as_ref().unwrap().name.as_ref())) + .collect(); + assert_eq!(kept, vec![("dup", "first_tool"), ("unique", "third_tool")]); + + let filtered_ids: Vec<&str> = filtered_message + .content + .iter() + .filter_map(|c| match c { + MessageContent::ToolRequest(req) => Some(req.id.as_str()), + _ => None, + }) + .collect(); + assert_eq!(filtered_ids, vec!["dup", "unique"]); + } + fn make_tool_with_meta(meta_json: Option) -> Tool { let mut tool = Tool::new("test_tool", "a test tool", object!({ "type": "object" })); if let Some(v) = meta_json { diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap index 70e3f6932d13..a641f22a035f 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__all_platform_extensions.snap @@ -6,6 +6,23 @@ expression: system_prompt You are a general-purpose AI agent called ApeMind Agent, an ApeCloud desktop agent distribution based on the open-source Goose project. ApeMind Agent is designed to run on the user's own machine. +# Turn Context + +Each turn may include a `` block prepended to the latest user message. +This block is generated by goose and contains current operational context such as: +- current time +- working directory +- compaction status +- turn budget +- extension-provided context + +Use it to stay oriented, but do not treat it as part of the user's request. +When `` is present, use it as a signal for how much autonomous work remains. +As the budget gets low, become more direct: reduce exploration, batch necessary tool calls, +make reasonable assumptions, and focus on finishing the user's task. + + + # Extensions Extensions provide additional tools and context from different data sources and applications. diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap index bb2f15575c51..1382171c260e 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap @@ -1,10 +1,28 @@ --- source: crates/goose/src/agents/prompt_manager.rs +assertion_line: 404 expression: system_prompt --- You are a general-purpose AI agent called ApeMind Agent, an ApeCloud desktop agent distribution based on the open-source Goose project. ApeMind Agent is designed to run on the user's own machine. +# Turn Context + +Each turn may include a `` block prepended to the latest user message. +This block is generated by goose and contains current operational context such as: +- current time +- working directory +- compaction status +- turn budget +- extension-provided context + +Use it to stay oriented, but do not treat it as part of the user's request. +When `` is present, use it as a signal for how much autonomous work remains. +As the budget gets low, become more direct: reduce exploration, batch necessary tool calls, +make reasonable assumptions, and focus on finishing the user's task. + + + # Extensions Extensions provide additional tools and context from different data sources and applications. diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap index 1a0dcfcc8b98..d4b95ca9effd 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap @@ -1,10 +1,28 @@ --- source: crates/goose/src/agents/prompt_manager.rs +assertion_line: 420 expression: system_prompt --- You are a general-purpose AI agent called ApeMind Agent, an ApeCloud desktop agent distribution based on the open-source Goose project. ApeMind Agent is designed to run on the user's own machine. +# Turn Context + +Each turn may include a `` block prepended to the latest user message. +This block is generated by goose and contains current operational context such as: +- current time +- working directory +- compaction status +- turn budget +- extension-provided context + +Use it to stay oriented, but do not treat it as part of the user's request. +When `` is present, use it as a signal for how much autonomous work remains. +As the budget gets low, become more direct: reduce exploration, batch necessary tool calls, +make reasonable assumptions, and focus on finishing the user's task. + + + # Extensions Extensions provide additional tools and context from different data sources and applications. diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap index 9ad0b67274b8..f058bed9094b 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap @@ -1,10 +1,28 @@ --- source: crates/goose/src/agents/prompt_manager.rs +assertion_line: 442 expression: system_prompt --- You are a general-purpose AI agent called ApeMind Agent, an ApeCloud desktop agent distribution based on the open-source Goose project. ApeMind Agent is designed to run on the user's own machine. +# Turn Context + +Each turn may include a `` block prepended to the latest user message. +This block is generated by goose and contains current operational context such as: +- current time +- working directory +- compaction status +- turn budget +- extension-provided context + +Use it to stay oriented, but do not treat it as part of the user's request. +When `` is present, use it as a signal for how much autonomous work remains. +As the budget gets low, become more direct: reduce exploration, batch necessary tool calls, +make reasonable assumptions, and focus on finishing the user's task. + + + # Extensions Extensions provide additional tools and context from different data sources and applications. diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 1bbf2d0c33b9..b506376ef793 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -208,6 +208,7 @@ fn get_agent_messages(params: SubagentRunParams) -> AgentMessagesFuture { } conversation.push(msg); } + Ok(AgentEvent::Usage(_)) => {} Ok(AgentEvent::McpNotification(_)) => {} Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { conversation = updated_conversation; diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index f26d9bdab0ef..81585e7ad847 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -1,6 +1,7 @@ use crate::config::paths::Paths; use crate::config::GooseMode; use fs2::FileExt; +use goose_providers::thinking::ThinkingEffort; #[cfg(feature = "system-keyring")] use keyring::Entry; use once_cell::sync::OnceCell; @@ -1136,7 +1137,78 @@ config_value!(GOOSE_PROMPT_EDITOR_ALWAYS, Option); config_value!(GOOSE_MAX_ACTIVE_AGENTS, usize); config_value!(GOOSE_DISABLE_SESSION_NAMING, bool); config_value!(GOOSE_DISABLE_TOOL_CALL_SUMMARY, bool); -config_value!(GOOSE_THINKING_EFFORT, String); + +impl Config { + pub fn get_goose_context_limit(&self) -> Result, ConfigError> { + match self.get_param::("GOOSE_CONTEXT_LIMIT") { + Ok(0) => Err(ConfigError::DeserializeError( + "GOOSE_CONTEXT_LIMIT must be greater than 0".to_string(), + )), + Ok(limit) => Ok(Some(limit)), + Err(ConfigError::NotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } + + pub fn get_goose_max_tokens(&self) -> Result, ConfigError> { + match self.get_param::("GOOSE_MAX_TOKENS") { + Ok(tokens) if tokens <= 0 => Err(ConfigError::DeserializeError( + "GOOSE_MAX_TOKENS must be greater than 0".to_string(), + )), + Ok(tokens) => Ok(Some(tokens)), + Err(ConfigError::NotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } + + pub fn get_goose_thinking_effort(&self) -> Option { + self.get_param::("GOOSE_THINKING_EFFORT") + .ok() + .and_then(|e| e.parse().ok()) + .or_else(|| self.legacy_thinking_effort()) + } + + pub fn set_goose_thinking_effort(&self, v: ThinkingEffort) -> Result<(), ConfigError> { + self.set_param("GOOSE_THINKING_EFFORT", v) + } + + fn legacy_thinking_effort(&self) -> Option { + if let Ok(value) = self.get_param::("CLAUDE_THINKING_TYPE") { + if let Some(effort) = match value.to_lowercase().as_str() { + "adaptive" | "enabled" => Some(ThinkingEffort::High), + "disabled" => Some(ThinkingEffort::Off), + _ => None, + } { + return Some(effort); + } + } + + if let Ok(enabled) = self.get_param::("CLAUDE_THINKING_ENABLED") { + return Some(if enabled { + ThinkingEffort::High + } else { + ThinkingEffort::Off + }); + } + + if let Ok(value) = self.get_param::("GEMINI3_THINKING_LEVEL") { + if let Some(effort) = Self::legacy_gemini3_thinking_effort(&value) { + return Some(effort); + } + } + + None + } + + fn legacy_gemini3_thinking_effort(value: &str) -> Option { + match value.to_lowercase().as_str() { + "low" => Some(ThinkingEffort::Low), + "high" => Some(ThinkingEffort::High), + _ => None, + } + } +} + config_value!(GOOSE_DEFAULT_EXTENSION_TIMEOUT, u64); fn find_workspace_or_exe_root() -> Option { @@ -2398,4 +2470,132 @@ extensions: assert!(openai.get("enabled").unwrap().as_bool().unwrap()); assert!(openai.get("configured").unwrap().as_bool().unwrap()); } + + #[test] + fn get_goose_context_limit_reads_env() { + let _guard = env_lock::lock_env([("GOOSE_CONTEXT_LIMIT", Some("4096"))]); + let config = new_test_config(); + + assert_eq!(config.get_goose_context_limit().unwrap(), Some(4096)); + } + + #[test] + fn get_goose_context_limit_reads_quoted_yaml_value() { + let _guard = env_lock::lock_env([("GOOSE_CONTEXT_LIMIT", None::<&str>)]); + let config = new_test_config(); + config.set_param("GOOSE_CONTEXT_LIMIT", "200000").unwrap(); + + assert_eq!(config.get_goose_context_limit().unwrap(), Some(200_000)); + } + + #[test] + fn get_goose_context_limit_returns_none_when_not_set() { + let _guard = env_lock::lock_env([("GOOSE_CONTEXT_LIMIT", None::<&str>)]); + let config = new_test_config(); + + assert_eq!(config.get_goose_context_limit().unwrap(), None); + } + + #[test] + fn get_goose_context_limit_rejects_zero() { + let _guard = env_lock::lock_env([("GOOSE_CONTEXT_LIMIT", Some("0"))]); + let config = new_test_config(); + + assert!(matches!( + config.get_goose_context_limit().unwrap_err(), + ConfigError::DeserializeError(_) + )); + } + + #[test] + fn get_goose_max_tokens_reads_env() { + let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some("4096"))]); + let config = new_test_config(); + + assert_eq!(config.get_goose_max_tokens().unwrap(), Some(4096)); + } + + #[test] + fn get_goose_max_tokens_returns_none_when_not_set() { + let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", None::<&str>)]); + let config = new_test_config(); + + assert_eq!(config.get_goose_max_tokens().unwrap(), None); + } + + #[test] + fn get_goose_max_tokens_rejects_invalid_values() { + for value in ["not_a_number", "0", "-100"] { + let _guard = env_lock::lock_env([("GOOSE_MAX_TOKENS", Some(value))]); + let config = new_test_config(); + + assert!(matches!( + config.get_goose_max_tokens().unwrap_err(), + ConfigError::DeserializeError(_) + )); + } + } + + #[test] + fn get_goose_thinking_effort_reads_env() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", Some("high")), + ("CLAUDE_THINKING_TYPE", None::<&str>), + ("CLAUDE_THINKING_ENABLED", None::<&str>), + ("GEMINI3_THINKING_LEVEL", None::<&str>), + ]); + let config = new_test_config(); + + assert_eq!( + config.get_goose_thinking_effort(), + Some(ThinkingEffort::High) + ); + } + + #[test] + fn get_goose_thinking_effort_uses_legacy_claude_fallback() { + for value in ["enabled", "adaptive"] { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("CLAUDE_THINKING_TYPE", Some(value)), + ("CLAUDE_THINKING_ENABLED", None::<&str>), + ("GEMINI3_THINKING_LEVEL", None::<&str>), + ]); + let config = new_test_config(); + + assert_eq!( + config.get_goose_thinking_effort(), + Some(ThinkingEffort::High) + ); + } + } + + #[test] + fn get_goose_thinking_effort_uses_legacy_gemini3_fallback() { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("CLAUDE_THINKING_TYPE", None::<&str>), + ("CLAUDE_THINKING_ENABLED", None::<&str>), + ("GEMINI3_THINKING_LEVEL", Some("high")), + ]); + let config = new_test_config(); + + assert_eq!( + config.get_goose_thinking_effort(), + Some(ThinkingEffort::High) + ); + } + + #[test] + fn legacy_gemini3_thinking_level_mapping() { + assert_eq!( + Config::legacy_gemini3_thinking_effort("low"), + Some(ThinkingEffort::Low) + ); + assert_eq!( + Config::legacy_gemini3_thinking_effort("high"), + Some(ThinkingEffort::High) + ); + assert_eq!(Config::legacy_gemini3_thinking_effort("auto"), None); + } } diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index a3a8fdc10475..d3a162a2f077 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -1,8 +1,9 @@ use crate::config::paths::Paths; use crate::config::Config; use crate::providers::anthropic::AnthropicProvider; -use crate::providers::base::{ModelInfo, ProviderDef, ProviderType}; +use crate::providers::base::{ModelInfo, ProviderType}; use crate::providers::huggingface::HuggingFaceProvider; +use crate::providers::huggingface_auth; use crate::providers::inventory::declarative_inventory_identity; use crate::providers::ollama::OllamaProvider; use crate::providers::openai::OpenAiProvider; @@ -584,10 +585,10 @@ pub fn register_declarative_provider( &config, provider_type, config.dynamic_models.unwrap_or(false), - move |model| { + move |model, tls_config| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; - HuggingFaceProvider::from_custom_config(model, cfg) + HuggingFaceProvider::from_custom_config(model, cfg, tls_config) }, move || { let mut cfg = identity_config.clone(); @@ -607,10 +608,10 @@ pub fn register_declarative_provider( &config, provider_type, config.dynamic_models.unwrap_or(false), - move |model| { + move |model, tls_config| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; - OpenAiProvider::from_custom_config(model, cfg) + OpenAiProvider::from_custom_config(model, cfg, tls_config) }, move || { let mut cfg = identity_config.clone(); @@ -627,10 +628,10 @@ pub fn register_declarative_provider( &config, provider_type, config.dynamic_models.unwrap_or(false), - move |model| { + move |model, tls_config| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; - OllamaProvider::from_custom_config(model, cfg) + OllamaProvider::from_custom_config(model, cfg, tls_config) }, move || { let mut cfg = identity_config.clone(); @@ -646,10 +647,10 @@ pub fn register_declarative_provider( &config, provider_type, config.dynamic_models.unwrap_or(false), - move |model| { + move |model, tls_config| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; - AnthropicProvider::from_custom_config(model, cfg) + AnthropicProvider::from_custom_config(model, cfg, tls_config) }, move || { let mut cfg = identity_config.clone(); @@ -665,7 +666,7 @@ fn huggingface_declarative_inventory_configured(config: &DeclarativeProviderConf huggingface_declarative_inventory_configured_from_sources( config, |key| Config::global().get_secret::(key).is_ok(), - HuggingFaceProvider::inventory_configured, + || huggingface_auth::has_configured_token().unwrap_or(false), ) } diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index f3641997234f..3a81e9154413 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -127,11 +127,24 @@ where } pub fn get_extension_by_name(name: &str) -> Option { - let extensions = get_extensions_map(); - extensions + get_extension_by_name_with_config(Config::global(), name) +} + +fn get_extension_by_name_with_config(config: &Config, name: &str) -> Option { + let extensions = get_extensions_map_with_config(config); + let key = name_to_key(name); + + if let Some(entry) = extensions .values() .find(|entry| entry.config.name() == name) - .map(|entry| entry.config.clone()) + .or_else(|| extensions.get(&key)) + { + return Some(entry.config.clone()); + } + + get_available_extensions() + .into_iter() + .find(|config| config.name() == name || config.key() == key) } pub fn set_extension(entry: ExtensionEntry) { @@ -444,6 +457,57 @@ extensions: assert!(extensions.contains_key("newextension")); } + #[test] + fn test_get_extension_by_name_falls_back_to_available_builtin() { + fn spawn_builtin(_: tokio::io::DuplexStream, _: tokio::io::DuplexStream) {} + crate::builtin_extension::register_builtin_extension("memory", spawn_builtin); + + let extension = get_extension_by_name("memory").unwrap(); + + assert!(matches!( + extension, + ExtensionConfig::Builtin { ref name, .. } if name == "memory" + )); + } + + #[test] + fn test_get_extension_by_name_resolves_saved_entry_by_key() { + let saved = ExtensionEntry { + enabled: true, + config: ExtensionConfig::Stdio { + name: "My Tool".to_string(), + description: "saved description".to_string(), + cmd: "my-tool".to_string(), + args: Vec::new(), + envs: Default::default(), + env_keys: Vec::new(), + timeout: Some(120), + cwd: None, + bundled: None, + available_tools: vec!["run".to_string()], + }, + }; + let key = saved.config.key(); + assert_ne!(key, saved.config.name()); + + let (config, _config_file, _secrets_file) = test_config(""); + set_extension_with_config(&config, saved); + + let resolved = get_extension_by_name_with_config(&config, &key).unwrap(); + + match resolved { + ExtensionConfig::Stdio { + timeout, + available_tools, + .. + } => { + assert_eq!(timeout, Some(120)); + assert_eq!(available_tools, vec!["run".to_string()]); + } + other => panic!("expected stdio, got {other:?}"), + } + } + #[test] fn test_remove_extension_preserves_unparseable_sibling() { let (config, _config_file, _secrets_file) = test_config( diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index ae6c46265fc2..13abeec0077a 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -2,7 +2,6 @@ pub mod base; pub mod declarative_providers; mod experiments; pub mod extensions; -pub mod goose_mode; mod migrations; pub mod paths; pub mod permission; @@ -11,6 +10,7 @@ pub mod search_path; pub mod signup_nanogpt; pub mod signup_openrouter; pub mod signup_tetrate; +pub mod tls; pub use crate::agents::ExtensionConfig; pub use base::{merge_config_values, Config, ConfigError}; @@ -21,7 +21,7 @@ pub use extensions::{ get_extension_by_name, get_warnings, is_extension_enabled, remove_extension, resolve_extensions_for_new_session, set_extension, set_extension_enabled, ExtensionEntry, }; -pub use goose_mode::GooseMode; +pub use goose_providers::goose_mode::GooseMode; pub use permission::PermissionManager; pub use signup_nanogpt::configure_nanogpt; pub use signup_openrouter::configure_openrouter; diff --git a/crates/goose/src/config/paths.rs b/crates/goose/src/config/paths.rs index be142869bedf..4a79cbf30685 100644 --- a/crates/goose/src/config/paths.rs +++ b/crates/goose/src/config/paths.rs @@ -13,6 +13,7 @@ impl Paths { DirType::State => base.join("state"), DirType::Plugins => base.join(".agents").join("plugins"), DirType::Agents => base.join(".agents").join("agents"), + DirType::AgentsHome => base.join(".agents"), } } else { // NOTE: "Block" is kept here for backwards compatibility with existing @@ -31,6 +32,7 @@ impl Paths { DirType::State => strategy.state_dir().unwrap_or(strategy.data_dir()), DirType::Plugins => strategy.home_dir().join(".agents").join("plugins"), DirType::Agents => strategy.home_dir().join(".agents").join("agents"), + DirType::AgentsHome => strategy.home_dir().join(".agents"), } } } @@ -55,6 +57,14 @@ impl Paths { Self::get_dir(DirType::Agents) } + pub fn agents_home_dir() -> PathBuf { + Self::get_dir(DirType::AgentsHome) + } + + pub fn in_agents_home_dir(subpath: &str) -> PathBuf { + Self::agents_home_dir().join(subpath) + } + pub fn in_state_dir(subpath: &str) -> PathBuf { Self::state_dir().join(subpath) } @@ -74,4 +84,5 @@ enum DirType { State, Plugins, Agents, + AgentsHome, } diff --git a/crates/goose/src/config/signup_nanogpt/mod.rs b/crates/goose/src/config/signup_nanogpt/mod.rs index 45699f6c4bd9..ecd6e906ae71 100644 --- a/crates/goose/src/config/signup_nanogpt/mod.rs +++ b/crates/goose/src/config/signup_nanogpt/mod.rs @@ -23,8 +23,13 @@ struct PollResponse { } fn build_client() -> Result { - ApiClient::new(NANOGPT_CLI_LOGIN_HOST.to_string(), AuthMethod::NoAuth)? - .with_header("x-client", "goose") + let tls_config = crate::config::tls::provider_tls_config_from_config(Config::global())?; + ApiClient::new_with_tls( + NANOGPT_CLI_LOGIN_HOST.to_string(), + AuthMethod::NoAuth, + tls_config, + )? + .with_header("x-client", "goose") } async fn poll_for_token(client: &ApiClient, device_code: &str) -> Result { diff --git a/crates/goose/src/config/tls.rs b/crates/goose/src/config/tls.rs new file mode 100644 index 000000000000..b53e76276e65 --- /dev/null +++ b/crates/goose/src/config/tls.rs @@ -0,0 +1,38 @@ +use crate::config::Config; +use crate::providers::api_client::TlsConfig; +use anyhow::{bail, Result}; +use std::path::PathBuf; + +pub fn provider_tls_config_from_config(config: &Config) -> Result> { + let mut tls_config = TlsConfig::new(); + let mut has_tls_config = false; + + let client_cert_path = config.get_param::("GOOSE_CLIENT_CERT_PATH").ok(); + let client_key_path = config.get_param::("GOOSE_CLIENT_KEY_PATH").ok(); + + match (client_cert_path, client_key_path) { + (Some(cert_path), Some(key_path)) => { + tls_config = tls_config + .with_client_cert_and_key(PathBuf::from(cert_path), PathBuf::from(key_path)); + has_tls_config = true; + } + (Some(_), None) => { + bail!( + "Client certificate provided (GOOSE_CLIENT_CERT_PATH) but no private key (GOOSE_CLIENT_KEY_PATH)" + ); + } + (None, Some(_)) => { + bail!( + "Client private key provided (GOOSE_CLIENT_KEY_PATH) but no certificate (GOOSE_CLIENT_CERT_PATH)" + ); + } + (None, None) => {} + } + + if let Ok(ca_cert_path) = config.get_param::("GOOSE_CA_CERT_PATH") { + tls_config = tls_config.with_ca_cert(PathBuf::from(ca_cert_path)); + has_tls_config = true; + } + + Ok(has_tls_config.then_some(tls_config)) +} diff --git a/crates/goose/src/context_mgmt/mod.rs b/crates/goose/src/context_mgmt/mod.rs index dbae784b4b82..1cc4173db982 100644 --- a/crates/goose/src/context_mgmt/mod.rs +++ b/crates/goose/src/context_mgmt/mod.rs @@ -567,10 +567,10 @@ pub fn maybe_summarize_tool_pairs( #[cfg(test)] mod tests { use super::*; - use crate::model::ModelConfig; use async_trait::async_trait; use goose_providers::conversation::token_usage::Usage; use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; use rmcp::model::{AnnotateAble, CallToolRequestParams, RawContent, Tool}; fn create_tool_pair( diff --git a/crates/goose/src/dictation/providers.rs b/crates/goose/src/dictation/providers.rs index 2d2378970b61..8dd3d1ee5307 100644 --- a/crates/goose/src/dictation/providers.rs +++ b/crates/goose/src/dictation/providers.rs @@ -1,3 +1,4 @@ +use crate::config::tls::provider_tls_config_from_config; use crate::config::Config; #[cfg(feature = "local-inference")] use crate::dictation::whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY; @@ -251,10 +252,12 @@ fn build_api_client(provider: DictationProvider) -> Result<(ApiClient, String)> DictationProvider::Local => anyhow::bail!("Local provider should not use API client"), }; - let mut client = ApiClient::with_timeout(base_url, auth, REQUEST_TIMEOUT).map_err(|e| { - tracing::error!("Failed to create API client: {}", e); - e - })?; + let tls = provider_tls_config_from_config(config)?; + let mut client = ApiClient::with_timeout_and_tls(base_url, auth, REQUEST_TIMEOUT, tls) + .map_err(|e| { + tracing::error!("Failed to create API client: {}", e); + e + })?; if !query_params.is_empty() { client = client.with_query(query_params); } diff --git a/crates/goose/src/doctor.rs b/crates/goose/src/doctor.rs index 2fadd576dd33..5d9624682c68 100644 --- a/crates/goose/src/doctor.rs +++ b/crates/goose/src/doctor.rs @@ -4,7 +4,6 @@ use crate::agents::platform_extensions::developer; use crate::agents::ExtensionConfig; use crate::config::Config; use crate::conversation::message::Message; -use crate::model::ModelConfig; use crate::providers; use crate::providers::base::Provider; use crate::session::{ @@ -168,9 +167,9 @@ async fn try_create_and_test( provider_name: &str, model_name: &str, ) -> Result, ProviderError> { - let model_config = ModelConfig::new(model_name) - .map_err(|e| ProviderError::ExecutionError(e.to_string()))? - .with_canonical_limits(provider_name); + let model_config = + crate::model_config::model_config_from_user_config(provider_name, model_name) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; let provider = providers::create(provider_name, model_config, vec![]) .await diff --git a/crates/goose/src/download_manager.rs b/crates/goose/src/download_manager.rs index 7ad8dd0a895b..89942e527c26 100644 --- a/crates/goose/src/download_manager.rs +++ b/crates/goose/src/download_manager.rs @@ -106,6 +106,50 @@ impl DownloadManager { self.downloads.lock().ok()?.get(model_id).cloned() } + pub fn is_downloading(&self, model_id: &str) -> bool { + self.get_progress(model_id) + .is_some_and(|progress| progress.status == DownloadStatus::Downloading) + } + + pub fn list_progress(&self) -> Vec { + self.downloads + .lock() + .map(|downloads| downloads.values().cloned().collect()) + .unwrap_or_default() + } + + pub fn set_progress(&self, progress: DownloadProgress) { + if let Ok(mut downloads) = self.downloads.lock() { + downloads.insert(progress.model_id.clone(), progress); + } + } + + pub fn reserve_download(&self, progress: DownloadProgress) -> Result { + let mut downloads = self + .downloads + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire lock"))?; + + if let Some(existing) = downloads.get(&progress.model_id) { + if existing.status == DownloadStatus::Downloading + || (existing.status == DownloadStatus::Cancelled && !existing.task_exited) + { + return Ok(false); + } + } + + downloads.insert(progress.model_id.clone(), progress); + Ok(true) + } + + pub fn update_progress(&self, model_id: &str, update: impl FnOnce(&mut DownloadProgress)) { + if let Ok(mut downloads) = self.downloads.lock() { + if let Some(progress) = downloads.get_mut(model_id) { + update(progress); + } + } + } + pub fn cancel_download(&self, model_id: &str) -> Result<()> { let mut downloads = self .downloads diff --git a/crates/goose/src/elicitation.rs b/crates/goose/src/elicitation.rs new file mode 100644 index 000000000000..271f437063f4 --- /dev/null +++ b/crates/goose/src/elicitation.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use rmcp::model::ElicitationAction; +use serde_json::Value; + +use crate::action_required_manager::{ActionRequiredManager, ElicitationOutcome}; +use crate::conversation::message::{Message, MessageContent}; +use crate::session::SessionManager; + +fn elicitation_response_user_data(response: &ElicitationOutcome) -> Value { + match response { + ElicitationOutcome::Accept(user_data) => user_data.clone(), + ElicitationOutcome::Decline | ElicitationOutcome::Cancel => serde_json::json!({}), + } +} + +fn elicitation_response_action(response: &ElicitationOutcome) -> ElicitationAction { + match response { + ElicitationOutcome::Accept(_) => ElicitationAction::Accept, + ElicitationOutcome::Decline => ElicitationAction::Decline, + ElicitationOutcome::Cancel => ElicitationAction::Cancel, + } +} + +fn generated_elicitation_response_message( + elicitation_id: &str, + response: &ElicitationOutcome, +) -> Message { + Message::user() + .with_generated_id() + .with_content(MessageContent::action_required_elicitation_response( + elicitation_id.to_string(), + elicitation_response_user_data(response), + elicitation_response_action(response), + )) + .agent_only() +} + +pub(crate) async fn complete_elicitation_with_message( + session_manager: &SessionManager, + session_id: &str, + elicitation_id: &str, + response: ElicitationOutcome, + response_message: &Message, +) -> Result<()> { + let claim = ActionRequiredManager::global() + .claim_response(session_id, elicitation_id) + .await?; + + session_manager + .add_message(session_id, response_message) + .await?; + + claim.submit(response) +} + +pub(crate) async fn complete_elicitation_with_generated_message( + session_manager: &SessionManager, + session_id: &str, + elicitation_id: &str, + response: ElicitationOutcome, +) -> Result<()> { + let response_message = generated_elicitation_response_message(elicitation_id, &response); + complete_elicitation_with_message( + session_manager, + session_id, + elicitation_id, + response, + &response_message, + ) + .await +} diff --git a/crates/goose/src/execution/manager.rs b/crates/goose/src/execution/manager.rs index 0e4604bc7480..68ce714da4c1 100644 --- a/crates/goose/src/execution/manager.rs +++ b/crates/goose/src/execution/manager.rs @@ -609,10 +609,10 @@ mod tests { use rmcp::model::Tool; use crate::conversation::message::Message; - use crate::model::ModelConfig; use crate::providers::base::{MessageStream, Provider}; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; struct FailingProvider; diff --git a/crates/goose/src/gateway/handler.rs b/crates/goose/src/gateway/handler.rs index b7060c5ee094..bbe1ecf3c16c 100644 --- a/crates/goose/src/gateway/handler.rs +++ b/crates/goose/src/gateway/handler.rs @@ -11,7 +11,6 @@ use crate::config::paths::Paths; use crate::config::Config; use crate::conversation::message::{Message, MessageContent}; use crate::execution::manager::AgentManager; -use crate::model::ModelConfig; use crate::session::SessionType; use crate::session::{EnabledExtensionsState, ExtensionState, Session}; @@ -164,17 +163,23 @@ impl GatewayHandler { // Store the current provider and model config on the session so the agent // can be restored after LRU eviction, matching the start_agent flow. let mut update = manager.update(&session.id); - if let Ok(provider) = config.get_goose_provider() { + let provider = config.get_goose_provider().ok(); + if let Some(ref provider) = provider { update = update.provider_name(provider); } - if let Ok(model_name) = config.get_goose_model() { - if let Ok(model_config) = ModelConfig::new(&model_name) { + if let (Some(ref provider), Ok(model_name)) = (&provider, config.get_goose_model()) { + if let Ok(model_config) = + crate::model_config::model_config_from_user_config(provider, &model_name) + { update = update.model_config(model_config); } } // Store default extensions so load_extensions_from_session works. - let extensions = get_enabled_extensions(); + let mut extensions = get_enabled_extensions(); + extensions.extend(crate::plugins::mcp_servers::enabled_plugin_mcp_servers( + Some(&session.working_dir), + )); let extensions_state = EnabledExtensionsState::new(extensions); let mut extension_data = session.extension_data.clone(); if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { @@ -220,7 +225,10 @@ impl GatewayHandler { // --- current global config --- let current_provider = config.get_goose_provider().ok(); let current_model_name = config.get_goose_model().ok(); - let current_extensions = get_enabled_extensions(); + let mut current_extensions = get_enabled_extensions(); + current_extensions.extend(crate::plugins::mcp_servers::enabled_plugin_mcp_servers( + Some(&session.working_dir), + )); let current_mode = config.get_goose_mode().unwrap_or_default(); // --- what the session has --- @@ -253,8 +261,11 @@ impl GatewayHandler { if let Some(ref provider) = current_provider { update = update.provider_name(provider); } - if let Some(ref model_name) = current_model_name { - if let Ok(model_config) = ModelConfig::new(model_name) { + if let (Some(ref provider), Some(ref model_name)) = (¤t_provider, ¤t_model_name) + { + if let Ok(model_config) = + crate::model_config::model_config_from_user_config(provider, model_name) + { update = update.model_config(model_config); } } @@ -466,6 +477,7 @@ impl GatewayHandler { } } } + Ok(AgentEvent::Usage(_)) => {} Ok(AgentEvent::McpNotification(_)) => { tracing::debug!( session_id, diff --git a/crates/goose/src/hints/load_hints.rs b/crates/goose/src/hints/load_hints.rs index 4050b530bd66..6cf553cbe331 100644 --- a/crates/goose/src/hints/load_hints.rs +++ b/crates/goose/src/hints/load_hints.rs @@ -230,17 +230,30 @@ pub fn load_hint_files( let mut global_hints_contents = Vec::with_capacity(hints_filenames.len()); let mut local_hints_contents = Vec::with_capacity(hints_filenames.len()); - for hints_filename in hints_filenames { - let global_hints_path = Paths::in_config_dir(hints_filename); + let mut global_hints_paths: Vec = hints_filenames + .iter() + .map(|name| Paths::in_config_dir(name)) + .collect(); + if hints_filenames + .iter() + .any(|name| name == AGENTS_MD_FILENAME) + { + global_hints_paths.push(Paths::in_agents_home_dir(AGENTS_MD_FILENAME)); + } + + for global_hints_path in &global_hints_paths { if global_hints_path.is_file() { let mut visited = HashSet::new(); let hints_dir = global_hints_path.parent().unwrap(); + let global_ignore_patterns = GitignoreBuilder::new(hints_dir) + .build() + .unwrap_or_else(|_| Gitignore::empty()); let expanded_content = read_referenced_files( - &global_hints_path, + global_hints_path, hints_dir, &mut visited, 0, - ignore_patterns, + &global_ignore_patterns, ); if !expanded_content.is_empty() { global_hints_contents.push(expanded_content); @@ -314,6 +327,98 @@ mod tests { assert!(hints.contains("Test hint content")); } + #[test] + #[serial_test::serial] + fn test_global_agents_md_in_agents_home() { + let root = TempDir::new().unwrap(); + std::env::set_var("GOOSE_PATH_ROOT", root.path()); + + let agents_home = root.path().join(".agents"); + fs::create_dir_all(&agents_home).unwrap(); + fs::write( + agents_home.join(AGENTS_MD_FILENAME), + "Global agents home instructions", + ) + .unwrap(); + + let project = TempDir::new().unwrap(); + let gitignore = create_dummy_gitignore(); + let hints = load_hint_files( + project.path(), + &[ + GOOSE_HINTS_FILENAME.to_string(), + AGENTS_MD_FILENAME.to_string(), + ], + &gitignore, + ); + + std::env::remove_var("GOOSE_PATH_ROOT"); + + assert!(hints.contains("Global Hints")); + assert!(hints.contains("Global agents home instructions")); + } + + #[test] + #[serial_test::serial] + fn test_global_agents_md_imports_not_filtered_by_project_gitignore() { + let root = TempDir::new().unwrap(); + std::env::set_var("GOOSE_PATH_ROOT", root.path()); + + let agents_home = root.path().join(".agents"); + fs::create_dir_all(&agents_home).unwrap(); + fs::write(agents_home.join("policy.md"), "Imported policy content").unwrap(); + fs::write( + agents_home.join(AGENTS_MD_FILENAME), + "Global header\n@policy.md\n", + ) + .unwrap(); + + let project = TempDir::new().unwrap(); + let mut builder = GitignoreBuilder::new(project.path()); + builder.add_line(None, "*.md").unwrap(); + let gitignore = builder.build().unwrap(); + + let hints = load_hint_files( + project.path(), + &[ + GOOSE_HINTS_FILENAME.to_string(), + AGENTS_MD_FILENAME.to_string(), + ], + &gitignore, + ); + + std::env::remove_var("GOOSE_PATH_ROOT"); + + assert!(hints.contains("Imported policy content")); + } + + #[test] + #[serial_test::serial] + fn test_global_agents_md_skipped_when_not_in_context_file_names() { + let root = TempDir::new().unwrap(); + std::env::set_var("GOOSE_PATH_ROOT", root.path()); + + let agents_home = root.path().join(".agents"); + fs::create_dir_all(&agents_home).unwrap(); + fs::write( + agents_home.join(AGENTS_MD_FILENAME), + "Global agents home instructions", + ) + .unwrap(); + + let project = TempDir::new().unwrap(); + let gitignore = create_dummy_gitignore(); + let hints = load_hint_files( + project.path(), + &[GOOSE_HINTS_FILENAME.to_string()], + &gitignore, + ); + + std::env::remove_var("GOOSE_PATH_ROOT"); + + assert!(!hints.contains("Global agents home instructions")); + } + #[test] fn test_goosehints_when_missing() { let dir = TempDir::new().unwrap(); diff --git a/crates/goose/src/hooks/mod.rs b/crates/goose/src/hooks/mod.rs index 590b2adcf8b4..c333e9a4c03a 100644 --- a/crates/goose/src/hooks/mod.rs +++ b/crates/goose/src/hooks/mod.rs @@ -26,6 +26,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::OnceLock; use std::time::Duration; use anyhow::{Context, Result}; @@ -224,21 +225,22 @@ pub enum HookDecision { #[derive(Debug, Default, Clone)] pub struct HookManager { rules: HashMap>, + use_login_shell_path: bool, } impl HookManager { /// Build a manager by scanning all enabled plugins for `hooks/hooks.json`. - pub fn load(project_root: Option<&Path>) -> Self { + pub fn load(project_root: Option<&Path>, use_login_shell_path: bool) -> Self { let plugins = discover_enabled_plugins(project_root); - Self::from_plugins(plugins) + Self::from_plugins(plugins, use_login_shell_path) } #[cfg(test)] pub(crate) fn from_plugins_for_test(plugins: Vec) -> Self { - Self::from_plugins(plugins) + Self::from_plugins(plugins, false) } - fn from_plugins(plugins: Vec) -> Self { + fn from_plugins(plugins: Vec, use_login_shell_path: bool) -> Self { let mut rules: HashMap> = HashMap::new(); let mut total = 0usize; @@ -271,7 +273,10 @@ impl HookManager { ); } - Self { rules } + Self { + rules, + use_login_shell_path, + } } /// Returns true if any rule is registered for `event`. @@ -314,19 +319,25 @@ impl HookManager { command = %command, "Running plugin hook", ); - let res = run_command_hook(command, &rule.plugin_root, &payload, *timeout) - .await - .and_then(|o| { - if o.status.success() { - Ok(()) - } else { - anyhow::bail!( - "hook `{command}` exited with {:?}: {}", - o.status.code(), - String::from_utf8_lossy(&o.stderr).trim() - ) - } - }); + let res = run_command_hook( + command, + &rule.plugin_root, + &payload, + *timeout, + self.use_login_shell_path, + ) + .await + .and_then(|o| { + if o.status.success() { + Ok(()) + } else { + anyhow::bail!( + "hook `{command}` exited with {:?}: {}", + o.status.code(), + String::from_utf8_lossy(&o.stderr).trim() + ) + } + }); if let Err(err) = res { warn!( plugin = %rule.plugin_name, @@ -368,20 +379,27 @@ impl HookManager { for action in &rule.actions { let LoadedAction::Command { command, timeout } = action; - let output = - match run_command_hook(command, &rule.plugin_root, &payload, *timeout).await { - Ok(o) => o, - Err(err) => { - warn!( - plugin = %rule.plugin_name, - event = %event, - command = %command, - error = %err, - "Plugin hook failed", - ); - continue; - } - }; + let output = match run_command_hook( + command, + &rule.plugin_root, + &payload, + *timeout, + self.use_login_shell_path, + ) + .await + { + Ok(o) => o, + Err(err) => { + warn!( + plugin = %rule.plugin_name, + event = %event, + command = %command, + error = %err, + "Plugin hook failed", + ); + continue; + } + }; if let Some(reason) = deny_reason(&output) { info!( @@ -507,16 +525,38 @@ async fn run_command_hook( plugin_root: &Path, payload: &str, timeout: Duration, + use_login_shell_path: bool, +) -> Result { + match tokio::time::timeout( + timeout, + run_command_hook_inner(raw_command, plugin_root, payload, use_login_shell_path), + ) + .await + { + Ok(res) => res, + Err(_) => anyhow::bail!("hook `{raw_command}` timed out after {:?}", timeout), + } +} + +async fn run_command_hook_inner( + raw_command: &str, + plugin_root: &Path, + payload: &str, + use_login_shell_path: bool, ) -> Result { let command = expand_plugin_root(raw_command, plugin_root); - let mut child = Command::new("sh") - .arg("-c") - .arg(&command) - .env("PLUGIN_ROOT", plugin_root) + let path = if use_login_shell_path { + hook_path().await + } else { + None + }; + let mut process = hook_command(&command, plugin_root, path.as_deref()); + process .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true) + .kill_on_drop(true); + let mut child = process .spawn() .with_context(|| format!("spawning hook `{command}`"))?; @@ -525,10 +565,87 @@ async fn run_command_hook( let _ = stdin.shutdown().await; } - match tokio::time::timeout(timeout, child.wait_with_output()).await { - Ok(res) => res.with_context(|| format!("waiting on hook `{command}`")), - Err(_) => anyhow::bail!("hook `{command}` timed out after {:?}", timeout), + child + .wait_with_output() + .await + .with_context(|| format!("waiting on hook `{command}`")) +} + +fn hook_command(command: &str, plugin_root: &Path, path: Option<&str>) -> Command { + #[cfg(not(windows))] + { + if crate::agents::platform_extensions::developer::shell::is_flatpak() { + let mut process = + crate::agents::platform_extensions::developer::shell::flatpak_spawn_command(); + process.arg(format!("--env=PLUGIN_ROOT={}", plugin_root.display())); + if let Some(path) = path { + process.arg(format!("--env=PATH={path}")); + } + process.arg("sh").arg("-c").arg(command); + return process; + } + } + + let mut process = Command::new("sh"); + process + .arg("-c") + .arg(command) + .env("PLUGIN_ROOT", plugin_root); + if let Some(path) = path { + process.env("PATH", path); } + process +} + +async fn hook_path() -> Option { + static HOOK_PATH: OnceLock>> = OnceLock::new(); + let mut rx = HOOK_PATH + .get_or_init(|| { + let (tx, rx) = tokio::sync::watch::channel(None); + tokio::spawn(async move { + let path = resolve_hook_path().await; + let _ = tx.send(path); + }); + rx + }) + .clone(); + + if rx.borrow().is_some() { + return rx.borrow().clone(); + } + if rx.changed().await.is_ok() { + rx.borrow().clone() + } else { + None + } +} + +async fn resolve_hook_path() -> Option { + #[cfg(not(windows))] + { + tokio::task::spawn_blocking(|| { + crate::agents::platform_extensions::developer::shell::resolve_login_shell_path() + .map(|login| merge_paths(&login, &std::env::var("PATH").unwrap_or_default())) + }) + .await + .ok() + .flatten() + } + #[cfg(windows)] + { + None + } +} + +fn merge_paths(first: &str, second: &str) -> String { + let mut seen = std::collections::HashSet::new(); + let mut merged = Vec::new(); + for entry in first.split(':').chain(second.split(':')) { + if !entry.is_empty() && seen.insert(entry) { + merged.push(entry); + } + } + merged.join(":") } fn expand_plugin_root(command: &str, plugin_root: &Path) -> String { @@ -548,7 +665,7 @@ mod tests { } fn make_manager(plugins: Vec) -> HookManager { - HookManager::from_plugins(plugins) + HookManager::from_plugins(plugins, false) } #[test] @@ -652,6 +769,69 @@ mod tests { ); } + #[test] + fn merge_paths_keeps_login_entries_first() { + assert_eq!( + merge_paths("/opt/homebrew/bin:/bin", "/bin:/usr/bin:/custom/bin"), + "/opt/homebrew/bin:/bin:/usr/bin:/custom/bin" + ); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn command_hooks_repair_path_when_enabled() { + let tmp = tempfile::tempdir().unwrap(); + let login_bin = tmp.path().join("login-bin"); + std::fs::create_dir(&login_bin).unwrap(); + + let fake_shell = tmp.path().join("fake-login-shell"); + std::fs::write( + &fake_shell, + "#!/bin/sh\nprintf '%s\\n' \"$FAKE_LOGIN_PATH\"\n", + ) + .unwrap(); + let helper = login_bin.join("hook-visible-tool"); + std::fs::write(&helper, "#!/bin/sh\nprintf 'hook-visible-tool-ran'\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&fake_shell, &helper] { + let mut perms = std::fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).unwrap(); + } + } + + let fake_shell = fake_shell.to_string_lossy().into_owned(); + let fake_login_path = format!("{}:/usr/bin:/bin", login_bin.display()); + let _guard = env_lock::lock_env([ + ("GOOSE_SHELL", Some(fake_shell.as_str())), + ("FAKE_LOGIN_PATH", Some(fake_login_path.as_str())), + ( + "PATH", + Some( + "/Applications/Goose.app/Contents/Resources/bin:/usr/bin:/bin:/usr/sbin:/sbin", + ), + ), + ]); + + let output = run_command_hook( + "hook-visible-tool", + tmp.path(), + "{}", + Duration::from_secs(5), + true, + ) + .await + .unwrap(); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "hook-visible-tool-ran" + ); + } + #[tokio::test] async fn matcher_filters_by_tool_name() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index c610d4a78e02..86805ffa1ead 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -15,6 +15,7 @@ pub mod conversation { pub mod dictation; pub mod doctor; pub mod download_manager; +pub mod elicitation; pub mod execution; pub mod gateway; pub mod goose_apps; @@ -23,7 +24,7 @@ pub mod hooks; pub mod instance_id; pub mod logging; pub mod mcp_utils; -pub mod model; +pub mod model_config; pub mod oauth; #[cfg(feature = "otel")] pub mod otel; diff --git a/crates/goose/src/model_config.rs b/crates/goose/src/model_config.rs new file mode 100644 index 000000000000..779ab3e9aa2a --- /dev/null +++ b/crates/goose/src/model_config.rs @@ -0,0 +1,150 @@ +use crate::config::{Config, ConfigError}; +use anyhow::{anyhow, Result}; +use goose_providers::model::ModelConfig; +use serde_json::Value; +use std::collections::HashMap; + +pub fn model_config_from_user_config( + provider_name: &str, + model_name: impl AsRef, +) -> Result { + let model = base_model_config_from_user_config(model_name.as_ref())?; + materialize_model_config(provider_name, model) +} + +pub fn model_config_from_user_config_with_session_settings( + provider_name: &str, + model_name: impl AsRef, + previous: Option<&ModelConfig>, + request_params: Option>, + context_limit: Option, +) -> Result { + let config = Config::global(); + let model = base_model_config_from_user_config(model_name.as_ref())?; + let model = materialize_model_config_inner(model, false)? + .with_context_limit(context_limit) + .with_inherited_session_settings_from(previous, request_params) + .with_default_thinking_effort(config.get_goose_thinking_effort()); + + Ok(model.with_canonical_limits(provider_name)) +} + +pub fn materialize_model_config(provider_name: &str, model: ModelConfig) -> Result { + let model = materialize_model_config_inner(model, true)?; + Ok(model.with_canonical_limits(provider_name)) +} + +fn materialize_model_config_inner( + mut model: ModelConfig, + include_default_thinking_effort: bool, +) -> Result { + let config = Config::global(); + + if model.temperature.is_none() { + model = model.with_temperature(get_goose_temperature(config)?); + } + + if model.toolshim && model.toolshim_model.is_none() { + model = model.with_toolshim_model(get_goose_toolshim_model(config)?); + } + + model = model + .with_default_context_limit(config.get_goose_context_limit()?) + .with_default_max_tokens(config.get_goose_max_tokens()?); + + if include_default_thinking_effort { + model = model.with_default_thinking_effort(config.get_goose_thinking_effort()); + } + + Ok(model) +} + +pub fn configured_fast_model_name(default_model: &str) -> String { + Config::global() + .get_param::("GOOSE_FAST_MODEL") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| default_model.to_string()) +} + +pub fn with_configured_fast_model( + model: ModelConfig, + provider_name: &str, + default_fast_model_name: &str, +) -> Result { + let fast_model_name = configured_fast_model_name(default_fast_model_name); + let fast_model_config = model_config_from_user_config(provider_name, fast_model_name)?; + Ok(model.with_fast_model_config(fast_model_config)) +} + +fn base_model_config_from_user_config(model_name: &str) -> Result { + let config = Config::global(); + let mut model = ModelConfig { + model_name: model_name.to_string(), + context_limit: None, + temperature: get_goose_temperature(config)?, + max_tokens: None, + toolshim: get_goose_toolshim(config)?.unwrap_or(false), + toolshim_model: get_goose_toolshim_model(config)?, + fast_model_config: None, + request_params: None, + reasoning: None, + }; + model.normalize_effort_suffix(); + Ok(model) +} + +fn get_goose_temperature(config: &Config) -> Result> { + match config.get_param::("GOOSE_TEMPERATURE") { + Ok(temp) if temp < 0.0 => Err(anyhow!( + "Value for 'GOOSE_TEMPERATURE' is out of valid range: {temp}" + )), + Ok(temp) => Ok(Some(temp)), + Err(ConfigError::NotFound(_)) => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn get_goose_toolshim(config: &Config) -> Result> { + match config.get_param::("GOOSE_TOOLSHIM") { + Ok(value) => parse_yaml_bool_config("GOOSE_TOOLSHIM", value).map(Some), + Err(ConfigError::NotFound(_)) => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn get_goose_toolshim_model(config: &Config) -> Result> { + match config.get_param::("GOOSE_TOOLSHIM_OLLAMA_MODEL") { + Ok(value) if value.trim().is_empty() => Err(anyhow!( + "Invalid value for 'GOOSE_TOOLSHIM_OLLAMA_MODEL': '{value}' - cannot be empty if set" + )), + Ok(value) => Ok(Some(value)), + Err(ConfigError::NotFound(_)) => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn parse_bool_config(key: &str, value: &str) -> Result { + match value.to_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + _ => Err(anyhow!( + "Invalid value for '{key}': '{value}' - must be one of: 1, true, yes, on, 0, false, no, off" + )), + } +} + +fn parse_yaml_bool_config(key: &str, value: serde_yaml::Value) -> Result { + match value { + serde_yaml::Value::Bool(value) => Ok(value), + serde_yaml::Value::Number(value) => parse_bool_config(key, &value.to_string()), + serde_yaml::Value::String(value) => parse_bool_config(key, &value), + other => { + Err(anyhow!( + "Invalid value for '{key}': '{}' - must be one of: 1, true, yes, on, 0, false, no, off", + serde_yaml::to_string(&other).unwrap_or_else(|_| "".to_string()).trim() + )) + } + } +} diff --git a/crates/goose/src/oauth/mod.rs b/crates/goose/src/oauth/mod.rs index 5f0610ea4d2a..9fbaf8fe7ead 100644 --- a/crates/goose/src/oauth/mod.rs +++ b/crates/goose/src/oauth/mod.rs @@ -13,11 +13,14 @@ use rmcp::transport::AuthorizationManager; use serde::Deserialize; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use tokio::sync::{oneshot, Mutex}; use tracing::warn; const CALLBACK_TEMPLATE: &str = include_str!("oauth_callback.html"); const CLIENT_METADATA_URL: &str = "https://goose-docs.ai/oauth/client-metadata.json"; +const DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECS: u64 = 300; +const OAUTH_CALLBACK_TIMEOUT_ENV: &str = "GOOSE_OAUTH_CALLBACK_TIMEOUT_SECONDS"; #[derive(Clone)] struct AppState { @@ -30,6 +33,55 @@ struct CallbackParams { state: String, } +fn resolve_oauth_callback_timeout(value: Option<&str>) -> Duration { + value + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECS)) +} + +fn oauth_callback_timeout() -> Duration { + let timeout = std::env::var(OAUTH_CALLBACK_TIMEOUT_ENV).ok(); + resolve_oauth_callback_timeout(timeout.as_deref()) +} + +fn announce_authorization_url(name: &str, authorization_url: &str) { + warn!( + "[OAuth:{}] If the browser did not open, authorize manually at: {}", + name, authorization_url + ); + eprintln!( + "If the browser did not open, authorize {} at:\n {}", + name, authorization_url + ); +} + +async fn wait_for_callback( + code_receiver: oneshot::Receiver, + timeout_duration: Duration, + name: &str, + authorization_url: &str, +) -> Result { + match tokio::time::timeout(timeout_duration, code_receiver).await { + Ok(Ok(params)) => Ok(params), + Ok(Err(e)) => Err(anyhow::anyhow!( + "OAuth authorization for {} ended before the callback was received: {}", + name, + e + )), + Err(_) => { + let message = format!( + "OAuth authorization for {} timed out waiting for the local callback. \ + Start the OAuth flow again and open this URL manually if the browser does not open: {}", + name, authorization_url + ); + warn!("[OAuth:{}] {}", name, message); + Err(anyhow::anyhow!(message)) + } + } +} + pub async fn oauth_flow( mcp_server_url: &String, name: &String, @@ -83,7 +135,7 @@ pub async fn oauth_flow( let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = tokio::net::TcpListener::bind(addr).await?; let used_addr = listener.local_addr()?; - tokio::spawn(async move { + let server_handle = tokio::spawn(async move { let result = axum::serve(listener, app).await; if let Err(e) = result { eprintln!("Callback server error: {}", e); @@ -103,15 +155,26 @@ pub async fn oauth_flow( .await?; let authorization_url = oauth_state.get_authorization_url().await?; - if webbrowser::open(authorization_url.as_str()).is_err() { - eprintln!("Open the following URL to authorize {}:", name); - eprintln!(" {}", authorization_url); + announce_authorization_url(name, authorization_url.as_str()); + if let Err(e) = webbrowser::open(authorization_url.as_str()) { + warn!( + "[OAuth:{}] Failed to open browser automatically: {}", + name, e + ); } + let callback_params = wait_for_callback( + code_receiver, + oauth_callback_timeout(), + name, + authorization_url.as_str(), + ) + .await; + server_handle.abort(); let CallbackParams { code: auth_code, state: csrf_token, - } = code_receiver.await?; + } = callback_params?; oauth_state.handle_callback(&auth_code, &csrf_token).await?; let (client_id, token_response) = oauth_state.get_credentials().await?; @@ -144,3 +207,74 @@ pub async fn oauth_flow( Ok(auth_manager) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_oauth_callback_timeout_uses_default_for_missing_or_invalid_values() { + assert_eq!( + resolve_oauth_callback_timeout(None), + Duration::from_secs(DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECS) + ); + assert_eq!( + resolve_oauth_callback_timeout(Some("not-a-number")), + Duration::from_secs(DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECS) + ); + assert_eq!( + resolve_oauth_callback_timeout(Some("0")), + Duration::from_secs(DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECS) + ); + } + + #[test] + fn resolve_oauth_callback_timeout_uses_positive_values() { + assert_eq!( + resolve_oauth_callback_timeout(Some("42")), + Duration::from_secs(42) + ); + } + + #[tokio::test] + async fn wait_for_callback_returns_received_callback_params() { + let (sender, receiver) = oneshot::channel(); + sender + .send(CallbackParams { + code: "auth-code".to_string(), + state: "csrf-state".to_string(), + }) + .unwrap(); + + let params = wait_for_callback( + receiver, + Duration::from_secs(1), + "test-server", + "https://auth.example/authorize", + ) + .await + .unwrap(); + + assert_eq!(params.code, "auth-code"); + assert_eq!(params.state, "csrf-state"); + } + + #[tokio::test] + async fn wait_for_callback_times_out_with_authorization_url() { + let (_sender, receiver) = oneshot::channel(); + + let error = wait_for_callback( + receiver, + Duration::from_millis(1), + "test-server", + "https://auth.example/authorize", + ) + .await + .unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("test-server")); + assert!(message.contains("timed out")); + assert!(message.contains("https://auth.example/authorize")); + } +} diff --git a/crates/goose/src/otel/otlp.rs b/crates/goose/src/otel/otlp.rs index ae1c59390ee5..d8a5d76fc8d5 100644 --- a/crates/goose/src/otel/otlp.rs +++ b/crates/goose/src/otel/otlp.rs @@ -8,7 +8,7 @@ use opentelemetry_sdk::resource::{EnvResourceDetector, TelemetryResourceDetector use opentelemetry_sdk::trace::SdkTracerProvider; use opentelemetry_sdk::Resource; use std::env; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use tracing::{Level, Metadata}; use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; use tracing_subscriber::filter::FilterFn; @@ -45,6 +45,133 @@ fn warn_grpc_protocol_skipped_once() { }); } +/// Dedicated single-thread Tokio runtime for OTLP HTTP export. +/// +/// `BatchSpanProcessor`, `BatchLogProcessor`, and `PeriodicReader` all +/// spawn raw `std::thread`s with no Tokio reactor. The async reqwest HTTP +/// client calls `tokio::time::sleep` during export, which panics without a +/// reactor. We drive every OTLP export call through this runtime so the +/// reqwest futures always have a live Tokio handle. +static OTEL_RT: Mutex>> = Mutex::new(None); + +fn get_or_create_otel_rt() -> OtlpResult> { + let mut guard = OTEL_RT.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(rt) = guard.as_ref() { + return Ok(Arc::clone(rt)); + } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let rt = Arc::new(rt); + *guard = Some(Arc::clone(&rt)); + Ok(rt) +} + +/// Wraps an OTLP `SpanExporter` so that each `export` call is driven inside +/// a dedicated Tokio runtime. Required because `BatchSpanProcessor` runs in +/// a raw `std::thread` with no Tokio reactor. +#[derive(Debug)] +struct TokioSpanExporter { + inner: opentelemetry_otlp::SpanExporter, + rt: Arc, +} + +impl opentelemetry_sdk::trace::SpanExporter for TokioSpanExporter { + async fn export( + &self, + batch: Vec, + ) -> opentelemetry_sdk::error::OTelSdkResult { + if tokio::runtime::Handle::try_current().is_ok() { + self.inner.export(batch).await + } else { + self.rt.block_on(self.inner.export(batch)) + } + } + + fn shutdown_with_timeout( + &self, + timeout: std::time::Duration, + ) -> opentelemetry_sdk::error::OTelSdkResult { + self.inner.shutdown_with_timeout(timeout) + } + + fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult { + self.inner.force_flush() + } + + fn set_resource(&mut self, resource: &Resource) { + self.inner.set_resource(resource); + } +} + +/// Wraps an OTLP `LogExporter` so that each export runs inside a dedicated +/// Tokio runtime for the same reason as `TokioSpanExporter`. +#[derive(Debug)] +struct TokioLogExporter { + inner: opentelemetry_otlp::LogExporter, + rt: Arc, +} + +impl opentelemetry_sdk::logs::LogExporter for TokioLogExporter { + async fn export( + &self, + batch: opentelemetry_sdk::logs::LogBatch<'_>, + ) -> opentelemetry_sdk::error::OTelSdkResult { + if tokio::runtime::Handle::try_current().is_ok() { + self.inner.export(batch).await + } else { + self.rt.block_on(self.inner.export(batch)) + } + } + + fn shutdown_with_timeout( + &self, + timeout: std::time::Duration, + ) -> opentelemetry_sdk::error::OTelSdkResult { + self.inner.shutdown_with_timeout(timeout) + } + + fn set_resource(&mut self, resource: &Resource) { + self.inner.set_resource(resource); + } +} + +/// Wraps an OTLP `MetricExporter` so that periodic export runs inside a +/// dedicated Tokio runtime for the same reason as `TokioSpanExporter`. +#[derive(Debug)] +struct TokioMetricExporter { + inner: opentelemetry_otlp::MetricExporter, + rt: Arc, +} + +impl opentelemetry_sdk::metrics::exporter::PushMetricExporter for TokioMetricExporter { + async fn export( + &self, + metrics: &opentelemetry_sdk::metrics::data::ResourceMetrics, + ) -> opentelemetry_sdk::error::OTelSdkResult { + if tokio::runtime::Handle::try_current().is_ok() { + self.inner.export(metrics).await + } else { + self.rt.block_on(self.inner.export(metrics)) + } + } + + fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult { + self.inner.force_flush() + } + + fn shutdown_with_timeout( + &self, + timeout: std::time::Duration, + ) -> opentelemetry_sdk::error::OTelSdkResult { + self.inner.shutdown_with_timeout(timeout) + } + + fn temporality(&self) -> Temporality { + self.inner.temporality() + } +} + #[derive(Debug, Clone, PartialEq)] pub enum ExporterType { Otlp, @@ -68,7 +195,7 @@ impl ExporterType { /// (matching what `.with_http()` produces in this build). /// /// goose's `opentelemetry-otlp` build only enables the `http-proto` / -/// `reqwest-client` transport features — not `grpc-tonic`. If the caller's +/// `reqwest-blocking-client` transport features — not `grpc-tonic`. If the caller's /// environment sets `…_PROTOCOL=grpc`, the `.with_http()` exporter still /// builds successfully but its background batch / metric reader threads /// panic on the first export with @@ -206,9 +333,13 @@ fn create_otlp_tracing_layer() -> OtlpResult { warn_grpc_protocol_skipped_once(); return Err("OTLP traces protocol is grpc but goose was built without grpc-tonic; skipping traces exporter".into()); } - let exporter = opentelemetry_otlp::SpanExporter::builder() - .with_http() - .build()?; + let rt = get_or_create_otel_rt()?; + let exporter = TokioSpanExporter { + inner: opentelemetry_otlp::SpanExporter::builder() + .with_http() + .build()?, + rt, + }; SdkTracerProvider::builder() .with_batch_exporter(exporter) .with_resource(resource) @@ -254,10 +385,14 @@ fn create_otlp_metrics_layer() -> OtlpResult { warn_grpc_protocol_skipped_once(); return Err("OTLP metrics protocol is grpc but goose was built without grpc-tonic; skipping metrics exporter".into()); } - let exporter = opentelemetry_otlp::MetricExporter::builder() - .with_http() - .with_temporality(temporality_preference()) - .build()?; + let rt = get_or_create_otel_rt()?; + let exporter = TokioMetricExporter { + inner: opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_temporality(temporality_preference()) + .build()?, + rt, + }; SdkMeterProvider::builder() .with_resource(resource) .with_periodic_exporter(exporter) @@ -289,9 +424,13 @@ fn create_otlp_logs_layer() -> OtlpResult { warn_grpc_protocol_skipped_once(); return Err("OTLP logs protocol is grpc but goose was built without grpc-tonic; skipping logs exporter".into()); } - let exporter = opentelemetry_otlp::LogExporter::builder() - .with_http() - .build()?; + let rt = get_or_create_otel_rt()?; + let exporter = TokioLogExporter { + inner: opentelemetry_otlp::LogExporter::builder() + .with_http() + .build()?, + rt, + }; SdkLoggerProvider::builder() .with_batch_exporter(exporter) .with_resource(resource) @@ -464,11 +603,26 @@ pub fn shutdown_otlp() { tracing::warn!("OTLP logger provider shutdown error: {e}"); } } + + if let Some(rt) = OTEL_RT.lock().unwrap_or_else(|e| e.into_inner()).take() { + // Dropping a `Runtime` inside an async context panics with "Cannot drop + // a runtime in a context where blocking is not allowed". Move the drop + // off-runtime via `shutdown_background` (which takes ownership of + // `Runtime` and shuts down without blocking the caller). If we still + // have other `Arc` clones alive, fall back to dropping on a plain thread. + match Arc::try_unwrap(rt) { + Ok(runtime) => runtime.shutdown_background(), + Err(arc) => { + std::thread::spawn(move || drop(arc)); + } + } + } } #[cfg(test)] mod tests { use super::*; + use crate::session_context::{session_host, session_user}; use goose_test_support::otel::clear_otel_env; use opentelemetry_sdk::metrics::Temporality; use test_case::test_case; @@ -682,6 +836,66 @@ mod tests { assert!(get("user.name").is_some()); } + /// Verify that OTLP layers initialize without panicking even when no + /// Tokio runtime is active on the calling thread. This is the scenario + /// that triggered the "no reactor running" panic: `BatchSpanProcessor` + /// spawns a raw `std::thread`, and the async reqwest client inside the + /// exporter calls `tokio::time::sleep`, which requires a reactor. + #[test] + fn test_otlp_layers_ok_without_tokio_context() { + let _env = clear_otel_env(&[ + ("OTEL_TRACES_EXPORTER", "otlp"), + ("OTEL_METRICS_EXPORTER", "otlp"), + ("OTEL_LOGS_EXPORTER", "otlp"), + ("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), + ]); + assert!(create_otlp_tracing_layer().is_ok()); + assert!(create_otlp_metrics_layer().is_ok()); + assert!(create_otlp_logs_layer().is_ok()); + shutdown_otlp(); + } + + #[test_case( + &[], + Resource::builder_empty() + .with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose"), KeyValue::new("host.name", session_host()), KeyValue::new("user.name", session_user())]) + .with_detector(Box::new(TelemetryResourceDetector)) + .build(); + "no env vars uses goose defaults" + )] + #[test_case( + &[("OTEL_SERVICE_NAME", "custom")], + Resource::builder_empty() + .with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose"), KeyValue::new("host.name", session_host()), KeyValue::new("user.name", session_user())]) + .with_detector(Box::new(TelemetryResourceDetector)) + .with_service_name("custom") + .build(); + "OTEL_SERVICE_NAME overrides service.name" + )] + #[test_case( + &[("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")], + Resource::builder_empty() + .with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose"), KeyValue::new("host.name", session_host()), KeyValue::new("user.name", session_user())]) + .with_detector(Box::new(TelemetryResourceDetector)) + .with_attribute(KeyValue::new("deployment.environment", "prod")) + .build(); + "OTEL_RESOURCE_ATTRIBUTES adds custom attributes" + )] + #[test_case( + &[("OTEL_SERVICE_NAME", "custom"), ("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")], + Resource::builder_empty() + .with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose"), KeyValue::new("host.name", session_host()), KeyValue::new("user.name", session_user())]) + .with_detector(Box::new(TelemetryResourceDetector)) + .with_service_name("custom") + .with_attribute(KeyValue::new("deployment.environment", "prod")) + .build(); + "OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES combine" + )] + fn test_create_resource(env: &[(&'static str, &'static str)], expected: Resource) { + let _guard = clear_otel_env(env); + assert_eq!(create_resource(), expected); + } + #[test_case(&[("RUST_LOG", "")], Level::INFO; "default is info")] #[test_case(&[("RUST_LOG", "debug")], Level::DEBUG; "RUST_LOG takes precedence")] #[test_case(&[("RUST_LOG", ""), ("OTEL_LOG_LEVEL", "error")], Level::ERROR; "OTEL_LOG_LEVEL fallback")] diff --git a/crates/goose/src/permission/mod.rs b/crates/goose/src/permission/mod.rs index dea80164d8e1..bfe2caf86da0 100644 --- a/crates/goose/src/permission/mod.rs +++ b/crates/goose/src/permission/mod.rs @@ -1,8 +1,10 @@ -pub mod permission_confirmation; pub mod permission_inspector; pub mod permission_judge; pub mod permission_store; -pub use permission_confirmation::{Permission, PermissionConfirmation}; +pub use goose_providers::permission::{Permission, PermissionConfirmation}; +pub mod permission_confirmation { + pub use goose_providers::permission::PrincipalType; +} pub use permission_inspector::PermissionInspector; pub use permission_store::ToolPermissionStore; diff --git a/crates/goose/src/plugins/discovery.rs b/crates/goose/src/plugins/discovery.rs index 76815be44d68..cdd80a6529dc 100644 --- a/crates/goose/src/plugins/discovery.rs +++ b/crates/goose/src/plugins/discovery.rs @@ -1,10 +1,20 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use crate::config::Config; use crate::plugins::plugin_install_dir; +const PLUGINS_CONFIG_KEY: &str = "plugins"; + +/// Per-plugin entry stored under the `plugins` map in `config.yaml`, keyed by +/// the plugin's filesystem path. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PluginConfigEntry { + enabled: bool, +} + /// A plugin found on disk and not disabled by any settings file. #[derive(Debug, Clone)] pub struct DiscoveredPlugin { @@ -40,6 +50,13 @@ enum SettingsScope { /// `project_root`, when supplied, enables project + local scope settings and /// project-scope `.agents/plugins/` lookups. pub fn discover_enabled_plugins(project_root: Option<&Path>) -> Vec { + discover_enabled_plugins_with_config(project_root, Config::global()) +} + +fn discover_enabled_plugins_with_config( + project_root: Option<&Path>, + config: &Config, +) -> Vec { let scoped_settings = load_all_settings(project_root); let mut found: HashMap = HashMap::new(); @@ -60,10 +77,46 @@ pub fn discover_enabled_plugins(project_root: Option<&Path>) -> Vec = found .into_values() .filter(|plugin| is_enabled(&plugin.name, &scoped_settings)) - .collect() + .collect(); + + filter_by_config(enabled_by_settings, config) +} + +/// Apply the `plugins` map in `config.yaml`. Newly discovered plugins are added +/// to the map with `enabled: true`; plugins explicitly set to `enabled: false` +/// are dropped. +fn filter_by_config(plugins: Vec, config: &Config) -> Vec { + let mut entries: HashMap = + config.get_param(PLUGINS_CONFIG_KEY).unwrap_or_default(); + + let mut dirty = false; + let mut enabled = Vec::new(); + for plugin in plugins { + let key = plugin.root.to_string_lossy().to_string(); + match entries.get(&key) { + Some(entry) => { + if entry.enabled { + enabled.push(plugin); + } + } + None => { + entries.insert(key, PluginConfigEntry { enabled: true }); + dirty = true; + enabled.push(plugin); + } + } + } + + if dirty { + if let Err(e) = config.set_param(PLUGINS_CONFIG_KEY, entries) { + tracing::warn!(error = %e, "Failed to persist plugin config entries"); + } + } + + enabled } fn is_enabled(plugin_name: &str, scoped_settings: &[(SettingsScope, PluginSettings)]) -> bool { @@ -197,13 +250,22 @@ mod tests { std::fs::write(dir.join("settings.local.json"), contents).unwrap(); } + fn test_config(dir: &Path) -> Config { + Config::new(dir.join("config.yaml"), "goose-discovery-test").unwrap() + } + + fn discover(project: &Path) -> Vec { + let cfg_dir = tempfile::tempdir().unwrap(); + discover_enabled_plugins_with_config(Some(project), &test_config(cfg_dir.path())) + } + #[test] fn finds_project_scope_plugin() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path(); write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); assert!(names.contains(&"demo"), "got: {names:?}"); let demo = found.iter().find(|p| p.name == "demo").unwrap(); @@ -221,7 +283,7 @@ mod tests { r#"{"disabledPlugins":["demo"]}"#, ); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); assert!(found.iter().all(|p| p.name != "demo")); } @@ -237,7 +299,7 @@ mod tests { r#"{"enabledPlugins":["demo"]}"#, ); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); assert!(names.contains(&"demo"), "got: {names:?}"); assert!(names.contains(&"other"), "got: {names:?}"); @@ -258,7 +320,7 @@ mod tests { r#"{"enabledPlugins":["demo"]}"#, ); - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); assert!( found.iter().any(|p| p.name == "demo"), "local scope should win; got: {:?}", @@ -285,7 +347,7 @@ mod tests { let prev = std::env::var("GOOSE_PATH_ROOT").ok(); unsafe { std::env::set_var("GOOSE_PATH_ROOT", fake_home.path()) }; - let found = discover_enabled_plugins(Some(project)); + let found = discover(project); match prev { Some(v) => unsafe { std::env::set_var("GOOSE_PATH_ROOT", v) }, None => unsafe { std::env::remove_var("GOOSE_PATH_ROOT") }, @@ -297,4 +359,80 @@ mod tests { found.iter().map(|p| &p.name).collect::>() ); } + + #[test] + fn newly_discovered_plugin_is_added_to_config_as_enabled() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + + let cfg_dir = tempfile::tempdir().unwrap(); + let config = test_config(cfg_dir.path()); + + let found = discover_enabled_plugins_with_config(Some(project), &config); + assert!(found.iter().any(|p| p.name == "demo")); + + let entries: HashMap = + config.get_param(PLUGINS_CONFIG_KEY).unwrap(); + let key = project + .join(".agents") + .join("plugins") + .join("demo") + .to_string_lossy() + .to_string(); + assert!( + entries.get(&key).is_some_and(|e| e.enabled), + "got: {entries:?}" + ); + } + + #[test] + fn disabled_in_config_drops_plugin() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + + let cfg_dir = tempfile::tempdir().unwrap(); + let config = test_config(cfg_dir.path()); + let key = project + .join(".agents") + .join("plugins") + .join("demo") + .to_string_lossy() + .to_string(); + let entries = HashMap::from([(key, PluginConfigEntry { enabled: false })]); + config.set_param(PLUGINS_CONFIG_KEY, entries).unwrap(); + + let found = discover_enabled_plugins_with_config(Some(project), &config); + assert!(found.iter().all(|p| p.name != "demo")); + } + + #[test] + fn enabled_in_config_keeps_plugin_without_modifying_config() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + + let cfg_dir = tempfile::tempdir().unwrap(); + let config = test_config(cfg_dir.path()); + let key = project + .join(".agents") + .join("plugins") + .join("demo") + .to_string_lossy() + .to_string(); + config + .set_param( + PLUGINS_CONFIG_KEY, + HashMap::from([(key.clone(), PluginConfigEntry { enabled: true })]), + ) + .unwrap(); + + let found = discover_enabled_plugins_with_config(Some(project), &config); + assert!(found.iter().any(|p| p.name == "demo")); + + let entries: HashMap = + config.get_param(PLUGINS_CONFIG_KEY).unwrap(); + assert!(entries.get(&key).is_some_and(|e| e.enabled)); + } } diff --git a/crates/goose/src/plugins/formats/open_plugins.rs b/crates/goose/src/plugins/formats/open_plugins.rs index 2ad3362dbb90..4cf8ce3bbfd9 100644 --- a/crates/goose/src/plugins/formats/open_plugins.rs +++ b/crates/goose/src/plugins/formats/open_plugins.rs @@ -18,16 +18,18 @@ const MANIFESTS: [&str; 3] = [ ]; const FORMAT: &str = "open-plugins"; -const COMPONENT_MARKERS: &[&str] = &["hooks/hooks.json", "commands", "agents"]; +const COMPONENT_MARKERS: &[&str] = &["hooks/hooks.json", "commands", "agents", ".mcp.json"]; #[derive(Debug, Deserialize)] -struct OpenPluginsManifest { +pub struct OpenPluginsManifest { #[serde(default)] - name: Option, + pub name: Option, #[serde(default)] - version: Option, + pub version: Option, #[serde(default)] - skills: Option, + pub skills: Option, + #[serde(default, rename = "mcpServers")] + pub mcp_servers: Option, } #[derive(Debug)] @@ -88,6 +90,7 @@ fn install_from_manifest( } let skills = find_agent_skills(checkout_dir, manifest.skills.as_ref())?; + validate_mcp_servers(checkout_dir, manifest.mcp_servers.as_ref())?; copy_dir_all(checkout_dir, &destination)?; @@ -146,7 +149,10 @@ pub(in crate::plugins) fn installed_skill_dirs(plugin_dir: &Path) -> Vec Result { +pub(in crate::plugins) fn read_manifest( + plugin_dir: &Path, + source: &str, +) -> Result { let mut manifest = match manifest_path(plugin_dir) { Some(manifest_path) => { serde_json::from_str::(&fs::read_to_string(&manifest_path)?) @@ -156,6 +162,7 @@ fn read_manifest(plugin_dir: &Path, source: &str) -> Result name: None, version: None, skills: None, + mcp_servers: None, }, }; @@ -240,6 +247,50 @@ fn namespaced_component_name(plugin_name: &str, component_name: &str) -> String format!("{plugin_name}:{component_name}") } +fn validate_mcp_servers( + plugin_dir: &Path, + mcp_servers_config: Option<&serde_json::Value>, +) -> Result<()> { + if let Some(value) = mcp_servers_config { + crate::plugins::mcp_servers::validate_mcp_servers_manifest_value(value)?; + } + + for path in mcp_config_paths_for_validation(plugin_dir, mcp_servers_config)? { + if !path.is_file() { + continue; + } + let value = serde_json::from_str::(&fs::read_to_string(&path)?) + .with_context(|| format!("Failed to parse {}", path.display()))?; + crate::plugins::mcp_servers::validate_mcp_server_document(&value)?; + } + + Ok(()) +} + +fn mcp_config_paths_for_validation( + plugin_dir: &Path, + config: Option<&serde_json::Value>, +) -> Result> { + let custom_paths = config + .filter(|value| { + !value + .as_object() + .is_some_and(|object| object.contains_key("mcpServers")) + }) + .map(parse_component_paths) + .transpose()? + .unwrap_or_default(); + + let mut paths = Vec::new(); + if !custom_paths.exclusive { + paths.push(plugin_dir.join(".mcp.json")); + } + for path in custom_paths.paths { + paths.push(plugin_dir.join(validate_relative_plugin_path(&path)?)); + } + Ok(dedupe_paths(paths)) +} + fn find_agent_skills( plugin_dir: &Path, skills_config: Option<&serde_json::Value>, @@ -283,12 +334,14 @@ fn skill_root_directories( } #[derive(Default)] -struct ComponentPaths { - paths: Vec, - exclusive: bool, +pub(in crate::plugins) struct ComponentPaths { + pub paths: Vec, + pub exclusive: bool, } -fn parse_component_paths(value: &serde_json::Value) -> Result { +pub(in crate::plugins) fn parse_component_paths( + value: &serde_json::Value, +) -> Result { match value { serde_json::Value::String(path) => Ok(ComponentPaths { paths: vec![path.clone()], @@ -332,7 +385,7 @@ fn parse_component_paths(value: &serde_json::Value) -> Result { } } -fn validate_relative_plugin_path(path: &str) -> Result { +pub(in crate::plugins) fn validate_relative_plugin_path(path: &str) -> Result { if !path.starts_with("./") { bail!( "Open Plugins component paths must start with './': {}", @@ -460,7 +513,7 @@ fn build_skill_md(name: &str, body: &str) -> String { ) } -fn dedupe_paths(paths: Vec) -> Vec { +pub(in crate::plugins) fn dedupe_paths(paths: Vec) -> Vec { let mut seen = HashSet::new(); paths .into_iter() diff --git a/crates/goose/src/plugins/mcp_servers.rs b/crates/goose/src/plugins/mcp_servers.rs new file mode 100644 index 000000000000..b5fa6cee9ef4 --- /dev/null +++ b/crates/goose/src/plugins/mcp_servers.rs @@ -0,0 +1,335 @@ +use crate::agents::extension::{Envs, ExtensionConfig}; +use crate::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT}; +use crate::plugins::discovery::discover_enabled_plugins; +use crate::plugins::formats::open_plugins; +use anyhow::{bail, Context, Result}; +use fs_err as fs; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use tracing::warn; + +const DEFAULT_MCP_CONFIG: &str = ".mcp.json"; +const PLUGIN_ROOT: &str = "${PLUGIN_ROOT}"; + +#[derive(Debug, Deserialize)] +struct McpServersDocument { + #[serde(default, rename = "mcpServers")] + mcp_servers: HashMap, +} + +#[derive(Debug, Deserialize)] +struct McpServerConfig { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, + #[serde(default)] + cwd: Option, +} + +pub fn enabled_plugin_mcp_servers(project_root: Option<&Path>) -> Vec { + let mut configs = Vec::new(); + for plugin in discover_enabled_plugins(project_root) { + match plugin_mcp_servers(&plugin.name, &plugin.root) { + Ok(plugin_configs) => configs.extend(plugin_configs), + Err(err) => warn!( + plugin = %plugin.name, + root = %plugin.root.display(), + error = %err, + "Failed to load plugin MCP servers; skipping", + ), + } + } + configs +} + +pub fn plugin_mcp_servers(plugin_name: &str, plugin_root: &Path) -> Result> { + let manifest = open_plugins::read_manifest(plugin_root, "")?; + let mut configs = Vec::new(); + let mut seen = HashSet::new(); + + for path in mcp_config_paths(plugin_root, manifest.mcp_servers.as_ref())? { + if !seen.insert(path.clone()) || !path.is_file() { + continue; + } + + let document = serde_json::from_str::(&fs::read_to_string(&path)?) + .with_context(|| format!("Failed to parse {}", path.display()))?; + configs.extend(document_to_extension_configs( + plugin_name, + plugin_root, + document.mcp_servers, + )); + } + + if let Some(value) = manifest + .mcp_servers + .as_ref() + .filter(|value| is_inline_config(value)) + { + let servers = parse_inline_servers(value)?; + configs.extend(document_to_extension_configs( + plugin_name, + plugin_root, + servers, + )); + } + + Ok(configs) +} + +fn mcp_config_paths( + plugin_root: &Path, + config: Option<&serde_json::Value>, +) -> Result> { + let custom_paths = config + .filter(|value| !is_inline_config(value)) + .map(open_plugins::parse_component_paths) + .transpose()? + .unwrap_or_default(); + + let mut paths = Vec::new(); + if !custom_paths.exclusive { + paths.push(plugin_root.join(DEFAULT_MCP_CONFIG)); + } + + for path in custom_paths.paths { + paths.push(plugin_root.join(open_plugins::validate_relative_plugin_path(&path)?)); + } + + Ok(open_plugins::dedupe_paths(paths)) +} + +fn is_inline_config(value: &serde_json::Value) -> bool { + value.as_object().is_some_and(|object| { + !object + .keys() + .all(|key| matches!(key.as_str(), "paths" | "exclusive")) + }) +} + +fn parse_inline_servers(value: &serde_json::Value) -> Result> { + serde_json::from_value(value.clone()) + .with_context(|| "Failed to parse inline Open Plugins MCP servers") +} + +fn document_to_extension_configs( + plugin_name: &str, + plugin_root: &Path, + servers: HashMap, +) -> Vec { + let mut entries: Vec<_> = servers.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + entries + .into_iter() + .map(|(server_name, server)| { + server_to_extension_config(plugin_name, plugin_root, server_name, server) + }) + .collect() +} + +fn server_to_extension_config( + plugin_name: &str, + plugin_root: &Path, + server_name: String, + server: McpServerConfig, +) -> ExtensionConfig { + let root = plugin_root.to_string_lossy(); + let mut env = HashMap::from([("PLUGIN_ROOT".to_string(), root.to_string())]); + env.extend( + server + .env + .into_iter() + .map(|(key, value)| (key, expand_plugin_root(&value, &root))), + ); + + ExtensionConfig::Stdio { + name: format!("{plugin_name}:{server_name}"), + description: DEFAULT_EXTENSION_DESCRIPTION.to_string(), + cmd: expand_plugin_root(&server.command, &root), + args: server + .args + .into_iter() + .map(|arg| expand_plugin_root(&arg, &root)) + .collect(), + envs: Envs::new(env), + env_keys: Vec::new(), + timeout: Some(DEFAULT_EXTENSION_TIMEOUT), + cwd: server.cwd.map(|cwd| expand_plugin_root(&cwd, &root)), + bundled: Some(false), + available_tools: Vec::new(), + } +} + +fn expand_plugin_root(value: &str, plugin_root: &str) -> String { + value.replace(PLUGIN_ROOT, plugin_root) +} + +pub fn validate_mcp_servers_manifest_value(value: &serde_json::Value) -> Result<()> { + if is_inline_config(value) { + validate_servers(parse_inline_servers(value)?)?; + return Ok(()); + } + + open_plugins::parse_component_paths(value)?; + Ok(()) +} + +pub fn validate_mcp_server_document(value: &serde_json::Value) -> Result<()> { + let document = serde_json::from_value::(value.clone())?; + validate_servers(document.mcp_servers) +} + +fn validate_servers(servers: HashMap) -> Result<()> { + for (name, server) in servers { + if server.command.trim().is_empty() { + bail!( + "Open Plugins MCP server '{}' command must not be empty", + name + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::extension::ExtensionConfig; + + #[test] + fn loads_default_mcp_json_with_plugin_root_expansion() { + let plugin = tempfile::tempdir().unwrap(); + fs::write( + plugin.path().join(DEFAULT_MCP_CONFIG), + r#"{ + "mcpServers": { + "database": { + "command": "${PLUGIN_ROOT}/servers/db-server", + "args": ["--config", "${PLUGIN_ROOT}/config.json"], + "env": {"DB_PATH": "${PLUGIN_ROOT}/data"}, + "cwd": "${PLUGIN_ROOT}" + } + } + }"#, + ) + .unwrap(); + + let configs = plugin_mcp_servers("test-plugin", plugin.path()).unwrap(); + assert_eq!(configs.len(), 1); + let ExtensionConfig::Stdio { + name, + cmd, + args, + envs, + cwd, + .. + } = &configs[0] + else { + panic!("expected stdio config"); + }; + + assert_eq!(name, "test-plugin:database"); + assert_eq!( + cmd, + plugin + .path() + .join("servers/db-server") + .to_string_lossy() + .as_ref() + ); + assert_eq!( + args, + &vec![ + "--config".to_string(), + plugin + .path() + .join("config.json") + .to_string_lossy() + .to_string() + ] + ); + assert_eq!( + envs.get_env().get("DB_PATH"), + Some(&plugin.path().join("data").to_string_lossy().to_string()) + ); + assert_eq!( + cwd.as_deref(), + Some(plugin.path().to_string_lossy().as_ref()) + ); + } + + #[test] + fn loads_inline_manifest_mcp_servers() { + let plugin = tempfile::tempdir().unwrap(); + fs::create_dir_all(plugin.path().join(".plugin")).unwrap(); + fs::write( + plugin.path().join(".plugin/plugin.json"), + r#"{ + "name": "test-plugin", + "mcpServers": { + "api": {"command": "npx", "args": ["@company/mcp-server"]} + } + }"#, + ) + .unwrap(); + + let configs = plugin_mcp_servers("test-plugin", plugin.path()).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].name(), "test-plugin:api"); + } + + #[test] + fn manifest_paths_supplement_default_config() { + let plugin = tempfile::tempdir().unwrap(); + fs::create_dir_all(plugin.path().join(".plugin")).unwrap(); + fs::write( + plugin.path().join(".plugin/plugin.json"), + r#"{"name":"test-plugin","mcpServers":"./custom-mcp.json"}"#, + ) + .unwrap(); + fs::write( + plugin.path().join(DEFAULT_MCP_CONFIG), + r#"{"mcpServers":{"default":{"command":"default-server"}}}"#, + ) + .unwrap(); + fs::write( + plugin.path().join("custom-mcp.json"), + r#"{"mcpServers":{"custom":{"command":"custom-server"}}}"#, + ) + .unwrap(); + + let names: Vec<_> = plugin_mcp_servers("test-plugin", plugin.path()) + .unwrap() + .into_iter() + .map(|config| config.name()) + .collect(); + + assert_eq!(names, vec!["test-plugin:default", "test-plugin:custom"]); + } + + #[test] + fn validates_manifest_mcp_servers_value() { + validate_mcp_servers_manifest_value(&serde_json::json!({ + "api": {"command": "npx"} + })) + .unwrap(); + validate_mcp_servers_manifest_value(&serde_json::json!({ + "paths": ["./mcp.json"], + "exclusive": true + })) + .unwrap(); + } + + #[test] + fn rejects_inline_mcp_server_with_empty_command() { + let error = validate_mcp_servers_manifest_value(&serde_json::json!({ + "api": {"command": ""} + })) + .unwrap_err(); + assert!(error.to_string().contains("command must not be empty")); + } +} diff --git a/crates/goose/src/plugins/mod.rs b/crates/goose/src/plugins/mod.rs index 44805d766e4e..eabe5d8f8d78 100644 --- a/crates/goose/src/plugins/mod.rs +++ b/crates/goose/src/plugins/mod.rs @@ -1,5 +1,6 @@ pub mod discovery; pub mod formats; +pub mod mcp_servers; use crate::config::paths::Paths; use crate::subprocess::SubprocessExt; diff --git a/crates/goose/src/prompts/system.md b/crates/goose/src/prompts/system.md index 16062f693b40..d3804047bf2c 100644 --- a/crates/goose/src/prompts/system.md +++ b/crates/goose/src/prompts/system.md @@ -1,5 +1,10 @@ You are a general-purpose AI agent called ApeMind Agent, an ApeCloud desktop agent distribution based on the open-source Goose project. ApeMind Agent is designed to run on the user's own machine. + +{% if moim_system_prompt_block is defined %} +{{ moim_system_prompt_block }} +{% endif %} + {% if not code_execution_mode %} # Extensions diff --git a/crates/goose/src/providers/acp_tooling.rs b/crates/goose/src/providers/acp_tooling.rs index 91c9351b6c00..ef69d8d9600b 100644 --- a/crates/goose/src/providers/acp_tooling.rs +++ b/crates/goose/src/providers/acp_tooling.rs @@ -1,5 +1,4 @@ use crate::config::search_path::SearchPaths; -use crate::providers::inventory::InventoryIdentityInput; use anyhow::Result; use std::path::PathBuf; @@ -7,10 +6,8 @@ pub fn acp_adapter_installed(command: &str) -> bool { resolve_acp_command(command).is_ok() } -pub fn acp_inventory_identity(provider_id: &str, command: &str) -> Result { - let resolved_command = resolve_acp_command(command)?; - Ok(InventoryIdentityInput::new(provider_id, provider_id) - .with_public("command", resolved_command.display().to_string())) +pub fn resolved_acp_command(command: &str) -> Result { + resolve_acp_command(command) } fn resolve_acp_command(command: &str) -> Result { diff --git a/crates/goose/src/providers/amp_acp.rs b/crates/goose/src/providers/amp_acp.rs index 96d4f3c40902..0082df2d38f5 100644 --- a/crates/goose/src/providers/amp_acp.rs +++ b/crates/goose/src/providers/amp_acp.rs @@ -8,14 +8,12 @@ use crate::acp::{ }; use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; -use crate::model::ModelConfig; -use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{current_working_dir, ProviderDef, ProviderMetadata}; -use crate::providers::inventory::InventoryIdentityInput; +use goose_providers::model::ModelConfig; -const AMP_ACP_PROVIDER_NAME: &str = "amp-acp"; +pub(crate) const AMP_ACP_PROVIDER_NAME: &str = "amp-acp"; const AMP_ACP_DOC_URL: &str = "https://ampcode.com"; -const AMP_ACP_BINARY: &str = "amp-acp"; +pub(crate) const AMP_ACP_BINARY: &str = "amp-acp"; pub struct AmpAcpProvider; @@ -45,14 +43,16 @@ impl ProviderDef for AmpAcpProvider { fn from_env( model: ModelConfig, extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Self::from_env_with_working_dir(model, extensions, current_working_dir()) + Self::from_env_with_working_dir(model, extensions, current_working_dir(), tls_config) } fn from_env_with_working_dir( model: ModelConfig, extensions: Vec, working_dir: PathBuf, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = Config::global(); @@ -84,16 +84,4 @@ impl ProviderDef for AmpAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } - - fn supports_inventory_refresh() -> bool { - false - } - - fn inventory_identity() -> Result { - acp_inventory_identity(AMP_ACP_PROVIDER_NAME, AMP_ACP_BINARY) - } - - fn inventory_configured() -> bool { - acp_adapter_installed(AMP_ACP_BINARY) - } } diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index d5a4a607f73f..519f571057ec 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -3,6 +3,7 @@ use async_stream::try_stream; use async_trait::async_trait; use futures::TryStreamExt; use goose_providers::errors::ProviderError; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use reqwest::StatusCode; use serde_json::Value; use std::io; @@ -15,15 +16,13 @@ use super::formats::anthropic::{ create_request_with_options_for_provider, response_to_streaming_message, thinking_type, AnthropicFormatOptions, ThinkingType, ANTHROPIC_PROVIDER_NAME, }; -use super::inventory::{config_secret_value, serialize_string_map, InventoryIdentityInput}; use super::openai_compatible::handle_status; use super::openai_compatible::map_http_error_to_provider_error; use super::retry::ProviderRetry; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::conversation::message::Message; -use crate::model::ModelConfig; -use crate::providers::utils::RequestLog; use futures::future::BoxFuture; +use goose_providers::model::ModelConfig; use rmcp::model::Tool; pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-sonnet-4-5"; @@ -64,8 +63,15 @@ pub struct AnthropicProvider { } impl AnthropicProvider { - pub async fn from_env(model: ModelConfig) -> Result { - let model = model.with_fast(ANTHROPIC_DEFAULT_FAST_MODEL, ANTHROPIC_PROVIDER_NAME)?; + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { + let model = crate::model_config::with_configured_fast_model( + model, + ANTHROPIC_PROVIDER_NAME, + ANTHROPIC_DEFAULT_FAST_MODEL, + )?; let config = crate::config::Config::global(); let api_key: String = config.get_secret("ANTHROPIC_API_KEY")?; @@ -78,8 +84,8 @@ impl AnthropicProvider { key: api_key, }; - let api_client = - ApiClient::new(host, auth)?.with_header("anthropic-version", ANTHROPIC_API_VERSION)?; + let api_client = ApiClient::new_with_tls(host, auth, tls_config)? + .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; Ok(Self { api_client, @@ -96,6 +102,7 @@ impl AnthropicProvider { pub fn from_custom_config( model: ModelConfig, config: DeclarativeProviderConfig, + tls_config: Option, ) -> Result { let custom_models = if !config.models.is_empty() { Some( @@ -129,7 +136,7 @@ impl AnthropicProvider { let format_options = Self::format_options_for_provider(config.preserves_thinking); - let mut api_client = ApiClient::new(config.base_url, auth)? + let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)? .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; if let Some(headers) = &config.headers { @@ -152,7 +159,7 @@ impl AnthropicProvider { } let model = if let Some(ref fast_model_name) = config.fast_model { - model.with_fast(fast_model_name, &config.name)? + crate::model_config::with_configured_fast_model(model, &config.name, fast_model_name)? } else { model }; @@ -264,35 +271,9 @@ impl ProviderDef for AnthropicProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_identity() -> Result { - let config = crate::config::Config::global(); - let mut identity = - InventoryIdentityInput::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_NAME) - .with_public( - "host", - config - .get_param::("ANTHROPIC_HOST") - .unwrap_or_else(|_| "https://api.anthropic.com".to_string()), - ); - - if let Some(api_key) = config_secret_value(config, "ANTHROPIC_API_KEY") { - identity = identity.with_secret("api_key", api_key); - } - if let Ok(headers) = config - .get_secret::>("ANTHROPIC_CUSTOM_HEADERS") - { - identity = identity.with_secret("headers", serialize_string_map(&headers)?); - } - - Ok(identity) + Box::pin(Self::from_env(model, tls_config)) } } @@ -354,7 +335,7 @@ impl Provider for AnthropicProvider { .insert("stream".to_string(), Value::Bool(true)); let conditional_headers = self.get_conditional_headers(); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -386,6 +367,7 @@ impl Provider for AnthropicProvider { })) } } + #[cfg(test)] mod tests { use super::*; @@ -402,7 +384,7 @@ mod tests { header_name: "x-api-key".to_string(), key: "test-key".to_string(), }; - let api_client = ApiClient::new(server_uri.to_string(), auth) + let api_client = ApiClient::new_with_tls(server_uri.to_string(), auth, None) .unwrap() .with_header("anthropic-version", ANTHROPIC_API_VERSION) .unwrap(); @@ -467,10 +449,13 @@ mod tests { #[test] fn from_custom_config_rejects_static_only_without_models() { let config = base_declarative_config(vec![], Some(false)); - let err = - AnthropicProvider::from_custom_config(ModelConfig::new_or_fail("claude-test"), config) - .err() - .expect("expected construction error for dynamic_models: false with empty models"); + let err = AnthropicProvider::from_custom_config( + ModelConfig::new_or_fail("claude-test"), + config, + None, + ) + .err() + .expect("expected construction error for dynamic_models: false with empty models"); let msg = err.to_string(); assert!( msg.contains("dynamic_models: false"), diff --git a/crates/goose/src/providers/avian.rs b/crates/goose/src/providers/avian.rs index 6efcd1f12575..ad179b5edc59 100644 --- a/crates/goose/src/providers/avian.rs +++ b/crates/goose/src/providers/avian.rs @@ -1,9 +1,9 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, ProviderDef, ProviderMetadata}; use super::openai_compatible::OpenAiCompatibleProvider; -use crate::model::ModelConfig; use anyhow::Result; use futures::future::BoxFuture; +use goose_providers::model::ModelConfig; const AVIAN_PROVIDER_NAME: &str = "avian"; pub const AVIAN_API_HOST: &str = "https://api.avian.io/v1"; @@ -39,6 +39,7 @@ impl ProviderDef for AvianProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = crate::config::Config::global(); @@ -47,7 +48,8 @@ impl ProviderDef for AvianProvider { .get_param("AVIAN_HOST") .unwrap_or_else(|_| AVIAN_API_HOST.to_string()); - let api_client = ApiClient::new(host, AuthMethod::BearerToken(api_key))?; + let api_client = + ApiClient::new_with_tls(host, AuthMethod::BearerToken(api_key), tls_config)?; Ok(OpenAiCompatibleProvider::new( AVIAN_PROVIDER_NAME.to_string(), diff --git a/crates/goose/src/providers/azure.rs b/crates/goose/src/providers/azure.rs index 74c966b7ff6c..b2e096a61c6c 100644 --- a/crates/goose/src/providers/azure.rs +++ b/crates/goose/src/providers/azure.rs @@ -5,8 +5,8 @@ use super::api_client::{ApiClient, AuthMethod, AuthProvider}; use super::azureauth::{AuthError, AzureAuth}; use super::base::{ConfigKey, ProviderDef, ProviderMetadata}; use super::openai_compatible::OpenAiCompatibleProvider; -use crate::model::ModelConfig; use futures::future::BoxFuture; +use goose_providers::model::ModelConfig; const AZURE_PROVIDER_NAME: &str = "azure_openai"; pub const AZURE_DEFAULT_MODEL: &str = "gpt-4o"; @@ -41,7 +41,8 @@ impl AuthProvider for AzureAuthProvider { super::azureauth::AzureCredentials::ApiKey(_) => { Ok(("api-key".to_string(), auth_token.token_value)) } - super::azureauth::AzureCredentials::DefaultCredential => Ok(( + super::azureauth::AzureCredentials::BearerToken(_) + | super::azureauth::AzureCredentials::DefaultCredential => Ok(( "Authorization".to_string(), format!("Bearer {}", auth_token.token_value), )), @@ -56,7 +57,7 @@ impl ProviderDef for AzureProvider { ProviderMetadata::new( AZURE_PROVIDER_NAME, "Azure OpenAI", - "Models through Azure OpenAI Service (uses Azure credential chain by default)", + "Models through Azure OpenAI Service (supports API key, Entra ID bearer token, and Azure credential chain)", "gpt-4o", AZURE_OPENAI_KNOWN_MODELS.to_vec(), AZURE_DOC_URL, @@ -65,6 +66,7 @@ impl ProviderDef for AzureProvider { ConfigKey::new("AZURE_OPENAI_DEPLOYMENT_NAME", true, false, None, true), ConfigKey::new("AZURE_OPENAI_API_VERSION", false, false, None, false), ConfigKey::new("AZURE_OPENAI_API_KEY", false, true, Some(""), true), + ConfigKey::new("AZURE_OPENAI_AD_TOKEN", false, true, Some(""), true), ], ) } @@ -72,6 +74,7 @@ impl ProviderDef for AzureProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = crate::config::Config::global(); @@ -92,14 +95,22 @@ impl ProviderDef for AzureProvider { .get_secret("AZURE_OPENAI_API_KEY") .ok() .filter(|key: &String| !key.is_empty()); - let auth = AzureAuth::new(api_key).map_err(|e| match e { + let ad_token = config + .get_secret("AZURE_OPENAI_AD_TOKEN") + .ok() + .filter(|token: &String| !token.is_empty()); + let auth = AzureAuth::new(api_key, ad_token).map_err(|e| match e { AuthError::Credentials(msg) => anyhow::anyhow!("Credentials error: {}", msg), AuthError::TokenExchange(msg) => anyhow::anyhow!("Token exchange error: {}", msg), })?; let auth_provider = AzureAuthProvider { auth }; let host = format!("{}/openai", endpoint.trim_end_matches('/')); - let mut api_client = ApiClient::new(host, AuthMethod::Custom(Box::new(auth_provider)))?; + let mut api_client = ApiClient::new_with_tls( + host, + AuthMethod::Custom(Box::new(auth_provider)), + tls_config, + )?; if let Some(version) = api_version { api_client = api_client.with_query(vec![("api-version".to_string(), version)]); } @@ -136,4 +147,22 @@ mod tests { "https://my-resource.openai.azure.com/openai" )); } + + #[tokio::test] + async fn test_auth_header_bearer_token() { + let auth = AzureAuth::new(None, Some("my-token".to_string())).unwrap(); + let provider = AzureAuthProvider { auth }; + let (header, value) = provider.get_auth_header().await.unwrap(); + assert_eq!(header, "Authorization"); + assert_eq!(value, "Bearer my-token"); + } + + #[tokio::test] + async fn test_auth_header_api_key() { + let auth = AzureAuth::new(Some("my-key".to_string()), None).unwrap(); + let provider = AzureAuthProvider { auth }; + let (header, value) = provider.get_auth_header().await.unwrap(); + assert_eq!(header, "api-key"); + assert_eq!(value, "my-key"); + } } diff --git a/crates/goose/src/providers/azureauth.rs b/crates/goose/src/providers/azureauth.rs index e1d61f0f7f5f..cce77b585b70 100644 --- a/crates/goose/src/providers/azureauth.rs +++ b/crates/goose/src/providers/azureauth.rs @@ -31,6 +31,8 @@ pub struct AuthToken { pub enum AzureCredentials { /// API key based authentication ApiKey(String), + /// Pre-acquired Microsoft Entra ID access token (e.g. AZURE_OPENAI_AD_TOKEN) + BearerToken(String), /// Azure credential chain based authentication DefaultCredential, } @@ -70,10 +72,11 @@ impl AzureAuth { /// /// # Returns /// * `Result` - A new AzureAuth instance or an error if initialization fails - pub fn new(api_key: Option) -> Result { - let credentials = match api_key { - Some(key) => AzureCredentials::ApiKey(key), - None => AzureCredentials::DefaultCredential, + pub fn new(api_key: Option, ad_token: Option) -> Result { + let credentials = match (ad_token, api_key) { + (Some(token), _) => AzureCredentials::BearerToken(token), + (None, Some(key)) => AzureCredentials::ApiKey(key), + (None, None) => AzureCredentials::DefaultCredential, }; Ok(Self { @@ -91,7 +94,8 @@ impl AzureAuth { /// /// This method implements an efficient token management strategy: /// 1. For API key auth, returns the API key directly - /// 2. For Azure credential chain: + /// 2. For bearer token auth, returns the pre-acquired token directly + /// 3. For Azure credential chain: /// a. Checks the cache for a valid token /// b. Returns the cached token if not expired /// c. Obtains a new token if needed or expired @@ -105,6 +109,10 @@ impl AzureAuth { token_type: "Bearer".to_string(), token_value: key.clone(), }), + AzureCredentials::BearerToken(token) => Ok(AuthToken { + token_type: "Bearer".to_string(), + token_value: token.clone(), + }), AzureCredentials::DefaultCredential => self.get_default_credential_token().await, } } @@ -170,3 +178,43 @@ impl AzureAuth { Ok(auth_token) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ad_token_takes_precedence_over_api_key() { + let auth = AzureAuth::new(Some("key".to_string()), Some("token".to_string())).unwrap(); + assert!(matches!( + auth.credential_type(), + AzureCredentials::BearerToken(_) + )); + } + + #[test] + fn test_api_key_when_no_ad_token() { + let auth = AzureAuth::new(Some("key".to_string()), None).unwrap(); + assert!(matches!( + auth.credential_type(), + AzureCredentials::ApiKey(_) + )); + } + + #[test] + fn test_default_credential_when_neither() { + let auth = AzureAuth::new(None, None).unwrap(); + assert!(matches!( + auth.credential_type(), + AzureCredentials::DefaultCredential + )); + } + + #[tokio::test] + async fn test_bearer_token_get_token() { + let auth = AzureAuth::new(None, Some("my-token".to_string())).unwrap(); + let token = auth.get_token().await.unwrap(); + assert_eq!(token.token_type, "Bearer"); + assert_eq!(token.token_value, "my-token"); + } +} diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index 43f8a5939b86..d64e7309e6e4 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -1,10 +1,9 @@ +use super::api_client::TlsConfig; use anyhow::Result; -use async_trait::async_trait; use futures::future::BoxFuture; -use futures::Stream; -use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; -use goose_providers::errors::ProviderError; -use regex::Regex; +pub use goose_providers::conversation::token_usage::{ + DraftStats, ProviderStats, ProviderUsage, Usage, +}; use serde::{Deserialize, Serialize}; /// Default HTTP timeout for all provider API calls. @@ -12,176 +11,20 @@ use serde::{Deserialize, Serialize}; /// before giving up. Individual providers may override this via their own config key. pub const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600; -use super::canonical::{map_to_canonical_model, CanonicalModelRegistry}; -use super::inventory::{default_inventory_identity, InventoryIdentityInput}; -use super::retry::RetryConfig; use crate::config::base::ConfigValue; -use crate::config::{Config, ExtensionConfig, GooseMode}; -use crate::conversation::message::{Message, MessageContent}; -use crate::conversation::Conversation; -use crate::model::ModelConfig; -use crate::permission::PermissionConfirmation; -use crate::utils::safe_truncate; -use rmcp::model::Tool; +use crate::config::ExtensionConfig; +use goose_providers::model::ModelConfig; use utoipa::ToSchema; use once_cell::sync::Lazy; use std::path::PathBuf; -use std::pin::Pin; -use std::sync::{LazyLock, Mutex}; +use std::sync::Mutex; + +pub use goose_providers::base::*; /// A global store for the current model being used, we use this as when a provider returns, it tells us the real model, not an alias pub static CURRENT_MODEL: Lazy>> = Lazy::new(|| Mutex::new(None)); -fn strip_xml_tags(text: &str) -> String { - static BLOCK_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?s)<([a-zA-Z][a-zA-Z0-9_]*)[^>]*>.*?").unwrap() - }); - static TAG_RE: LazyLock = - LazyLock::new(|| Regex::new(r"]*>").unwrap()); - let pass1 = BLOCK_RE.replace_all(text, ""); - TAG_RE.replace_all(&pass1, "").into_owned() -} - -fn extract_short_title(text: &str) -> String { - let word_count = text.split_whitespace().count(); - if word_count <= 8 { - return text.to_string(); - } - - { - let mut results = Vec::new(); - let mut quote_char: Option = None; - let mut current = String::new(); - let mut prev_char: Option = None; - - for ch in text.chars() { - match quote_char { - None => { - if matches!(ch, '"' | '\'' | '`') { - let after_alnum = prev_char.map(|p| p.is_alphanumeric()).unwrap_or(false); - if !after_alnum { - quote_char = Some(ch); - current.clear(); - } - } - } - Some(q) => { - if ch == q { - let trimmed = current.trim().to_string(); - let wc = trimmed.split_whitespace().count(); - if (2..=8).contains(&wc) { - results.push(trimmed); - } - quote_char = None; - current.clear(); - } else { - current.push(ch); - } - } - } - prev_char = Some(ch); - } - - if let Some(title) = results.last() { - return title.clone(); - } - } - - if let Some(last) = text.lines().rev().find(|l| !l.trim().is_empty()) { - return last.trim().to_string(); - } - - text.to_string() -} - -pub static MSG_COUNT_FOR_SESSION_NAME_GENERATION: usize = 3; - -/// Information about a model's capabilities -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] -pub struct ModelInfo { - /// The name of the model - pub name: String, - /// The underlying model resolved from provider metadata, when the configured model is an alias or endpoint. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resolved_model: Option, - /// The maximum context length this model supports - pub context_limit: usize, - /// Cost per token for input in USD (optional) - pub input_token_cost: Option, - /// Cost per token for output in USD (optional) - pub output_token_cost: Option, - /// Currency for the costs (default: "$") - pub currency: Option, - /// Whether this model supports cache control - pub supports_cache_control: Option, - /// Whether this model supports reasoning/thinking controls - #[serde(default)] - pub reasoning: bool, -} - -impl ModelInfo { - /// Create a new ModelInfo with just name and context limit - pub fn new(name: impl Into, context_limit: usize) -> Self { - Self { - name: name.into(), - resolved_model: None, - context_limit, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - } - } - - /// Create a new ModelInfo with cost information (per token) - pub fn with_cost( - name: impl Into, - context_limit: usize, - input_cost: f64, - output_cost: f64, - ) -> Self { - Self { - name: name.into(), - resolved_model: None, - context_limit, - input_token_cost: Some(input_cost), - output_token_cost: Some(output_cost), - currency: Some("$".to_string()), - supports_cache_control: None, - reasoning: false, - } - } -} - -fn model_info_for_provider_model(provider_name: &str, model_name: &str) -> ModelInfo { - let registry = CanonicalModelRegistry::bundled().ok(); - let canonical = registry.as_ref().and_then(|registry| { - let canonical_id = map_to_canonical_model(provider_name, model_name, registry)?; - let (provider, model) = canonical_id.split_once('/')?; - registry.get(provider, model) - }); - - let reasoning = canonical - .as_ref() - .and_then(|model| model.reasoning) - .unwrap_or_else(|| ModelConfig::new_or_fail(model_name).is_reasoning_model()); - - ModelInfo { - name: model_name.to_string(), - resolved_model: None, - context_limit: ModelConfig::new_or_fail(model_name) - .with_canonical_limits(provider_name) - .context_limit(), - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning, - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub enum ProviderType { Preferred, @@ -403,6 +246,7 @@ pub trait ProviderDef: Send + Sync { fn from_env( model: ModelConfig, extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> where Self: Sized; @@ -411,457 +255,14 @@ pub trait ProviderDef: Send + Sync { model: ModelConfig, extensions: Vec, _working_dir: PathBuf, + tls_config: Option, ) -> BoxFuture<'static, Result> where Self: Sized, { // ACP subprocess providers must override this so session cwd is preserved. // Non-subprocess providers can rely on the default because cwd is irrelevant. - Self::from_env(model, extensions) - } - - fn supports_inventory_refresh() -> bool - where - Self: Sized, - { - false - } - - fn inventory_identity() -> Result - where - Self: Sized, - { - let metadata = Self::metadata(); - Ok(default_inventory_identity( - &metadata.name, - &metadata.name, - &metadata.config_keys, - Config::global(), - )) - } - - fn inventory_configured() -> bool - where - Self: Sized, - { - let metadata = Self::metadata(); - super::inventory::default_inventory_configured(&metadata.config_keys, Config::global()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PermissionRouting { - ActionRequired, - Noop, -} - -/// Base trait for AI providers (OpenAI, Anthropic, etc) -#[async_trait] -pub trait Provider: Send + Sync { - /// Get the name of this provider instance - fn get_name(&self) -> &str; - - /// Primary streaming method that all providers must implement. - /// - /// Note: Do not add `#[instrument]` here — the call sites (`complete` and - /// `stream_response_from_provider`) create the telemetry span so that - /// `session.id` is set once rather than in every provider. - async fn stream( - &self, - model_config: &ModelConfig, - session_id: &str, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result; - - /// Complete with a specific model config. - #[tracing::instrument( - skip(self, model_config, session_id, system, messages, tools), - fields(session.id = %session_id, gen_ai.request.model = %model_config.model_name) - )] - async fn complete( - &self, - model_config: &ModelConfig, - session_id: &str, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - let stream = self - .stream(model_config, session_id, system, messages, tools) - .await?; - collect_stream(stream).await - } - - /// Try fast model first, fall back to regular model on failure. - async fn complete_fast( - &self, - session_id: &str, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - let model_config = self.get_model_config(); - let fast_config = model_config.use_fast_model(); - - let result = self - .complete(&fast_config, session_id, system, messages, tools) - .await; - - match result { - Ok(response) => Ok(response), - Err(e) => { - if fast_config.model_name != model_config.model_name { - tracing::warn!( - "Fast model {} failed with error: {}. Falling back to regular model {}", - fast_config.model_name, - e, - model_config.model_name - ); - self.complete(&model_config, session_id, system, messages, tools) - .await - } else { - Err(e) - } - } - } - } - - /// Get the model config from the provider - fn get_model_config(&self) -> ModelConfig; - - fn retry_config(&self) -> RetryConfig { - RetryConfig::default() - } - - async fn fetch_supported_models(&self) -> Result, ProviderError> { - Ok(vec![]) - } - - async fn fetch_supported_model_info(&self) -> Result, ProviderError> { - Ok(self - .fetch_supported_models() - .await? - .iter() - .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) - .collect()) - } - - async fn fetch_model_info(&self, model_name: &str) -> Result { - Ok(model_info_for_provider_model(self.get_name(), model_name)) - } - - fn skip_canonical_filtering(&self) -> bool { - false - } - - /// Fetch inventory models filtered by canonical registry and usability. - async fn fetch_recommended_models(&self) -> Result, ProviderError> { - let all_models = self.fetch_supported_models().await?; - - if self.skip_canonical_filtering() { - return Ok(all_models); - } - - let registry = CanonicalModelRegistry::bundled().map_err(|e| { - ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) - })?; - - let provider_name = self.get_name(); - - // Get all text-capable models with their release dates - let mut models_with_dates: Vec<(String, Option)> = all_models - .iter() - .filter_map(|model| { - let canonical_id = map_to_canonical_model(provider_name, model, registry)?; - - let (provider, model_name) = canonical_id.split_once('/')?; - let canonical_model = registry.get(provider, model_name)?; - - if !canonical_model - .modalities - .input - .contains(&crate::providers::canonical::Modality::Text) - { - return None; - } - - if !canonical_model.tool_call && !self.get_model_config().toolshim { - return None; - } - - let release_date = canonical_model.release_date.clone(); - - Some((model.clone(), release_date)) - }) - .collect(); - - // Sort by release date (most recent first), then alphabetically for models without dates - models_with_dates.sort_by(|a, b| match (&a.1, &b.1) { - (Some(date_a), Some(date_b)) => date_b.cmp(date_a), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.0.cmp(&b.0), - }); - - let inventory_models: Vec = models_with_dates - .into_iter() - .map(|(name, _)| name) - .collect(); - - if inventory_models.is_empty() { - Ok(all_models) - } else { - Ok(inventory_models) - } - } - - async fn fetch_recommended_model_info(&self) -> Result, ProviderError> { - Ok(self - .fetch_recommended_models() - .await? - .iter() - .map(|model_name| model_info_for_provider_model(self.get_name(), model_name)) - .collect()) - } - - async fn map_to_canonical_model( - &self, - provider_model: &str, - ) -> Result, ProviderError> { - let registry = CanonicalModelRegistry::bundled().map_err(|e| { - ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e)) - })?; - - Ok(map_to_canonical_model( - self.get_name(), - provider_model, - registry, - )) - } - - fn supports_embeddings(&self) -> bool { - false - } - - /// Whether the provider manages its own conversation context (e.g. CLI - /// wrappers like Claude Code or Gemini CLI). When true, goose-side - /// context management such as tool-pair summarization is skipped because - /// the provider's internal state is the source of truth. - fn manages_own_context(&self) -> bool { - false - } - - async fn supports_cache_control(&self) -> bool { - false - } - - /// Create embeddings if supported. Default implementation returns an error. - async fn create_embeddings( - &self, - _session_id: &str, - _texts: Vec, - ) -> Result>, ProviderError> { - Err(ProviderError::ExecutionError( - "This provider does not support embeddings".to_string(), - )) - } - - /// Returns the first 3 user messages as strings for session naming, - /// filtering out assistant-only content (e.g. preprompt blocks). - fn get_initial_user_messages(&self, messages: &Conversation) -> Vec { - messages - .iter() - .filter(|m| m.role == rmcp::model::Role::User) - .take(MSG_COUNT_FOR_SESSION_NAME_GENERATION) - .map(|m| { - m.content - .iter() - .filter_map(|c| c.filter_for_audience(rmcp::model::Role::User)) - .filter_map(|c| c.as_text().map(|s| s.to_string())) - .collect::>() - .join("\n") - }) - .collect() - } - - /// Extracts preprompt context (assistant-audience blocks) from the first user message. - /// These are content blocks visible to the assistant but not the user. - fn get_preprompt_context(&self, messages: &Conversation) -> String { - messages - .iter() - .filter(|m| m.role == rmcp::model::Role::User) - .take(1) - .flat_map(|m| m.content.iter()) - .filter_map(|c| { - // If this block is NOT visible to the user, it's preprompt/assistant-only content - if c.filter_for_audience(rmcp::model::Role::User).is_none() { - c.as_text().map(|s| s.to_string()) - } else { - None - } - }) - .collect::>() - .join("\n") - } - - /// Generate a session name/description based on the conversation history - /// Creates a prompt asking for a concise description in 4 words or less. - async fn generate_session_name( - &self, - session_id: &str, - messages: &Conversation, - ) -> Result { - let context = self.get_initial_user_messages(messages); - let preprompt_context = self.get_preprompt_context(messages); - let system = crate::prompt_template::render_template( - "session_name.md", - &std::collections::HashMap::::new(), - ) - .map_err(|e| ProviderError::ContextLengthExceeded(e.to_string()))?; - - use super::cli_common::{ - SESSION_NAME_BEGIN_MARKER, SESSION_NAME_END_MARKER, SESSION_NAME_SUFFIX, - }; - - let preprompt_section = if preprompt_context.is_empty() { - String::new() - } else { - format!( - "---BEGIN BACKGROUND CONTEXT (for understanding only, do NOT base the title on this)---\n{}\n---END BACKGROUND CONTEXT---\n\n", - preprompt_context - ) - }; - - let user_text = format!( - "{}{}\n{}\n{}\n\n{}", - preprompt_section, - SESSION_NAME_BEGIN_MARKER, - context.join("\n"), - SESSION_NAME_END_MARKER, - SESSION_NAME_SUFFIX, - ); - let message = Message::user().with_text(&user_text); - let result = self - .complete_fast(session_id, &system, &[message], &[]) - .await?; - - let raw: String = result - .0 - .content - .iter() - .filter_map(|c| c.as_text()) - .collect(); - let description = strip_xml_tags(&raw) - .split_whitespace() - .collect::>() - .join(" "); - - Ok(safe_truncate(&extract_short_title(&description), 100)) - } - - /// Configure OAuth authentication for this provider - /// - /// This method is called when a provider has configuration keys marked with oauth_flow = true. - /// Providers that support OAuth should override this method to implement their specific OAuth flow. - /// - /// # Returns - /// * `Ok(())` if OAuth configuration succeeds and credentials are saved - /// * `Err(ProviderError)` if OAuth fails or is not supported by this provider - /// - /// # Default Implementation - /// The default implementation returns an error indicating OAuth is not supported. - async fn configure_oauth(&self) -> Result<(), ProviderError> { - Err(ProviderError::ExecutionError( - "OAuth configuration not supported by this provider".to_string(), - )) - } - - async fn refresh_credentials(&self) -> Result<(), ProviderError> { - Err(ProviderError::NotImplemented( - "credential refresh not supported by this provider".to_string(), - )) - } - - async fn update_mode(&self, _session_id: &str, _mode: GooseMode) -> Result<(), ProviderError> { - Ok(()) - } - - fn permission_routing(&self) -> PermissionRouting { - PermissionRouting::Noop - } - - async fn handle_permission_confirmation( - &self, - _request_id: &str, - _confirmation: &PermissionConfirmation, - ) -> bool { - false - } -} - -/// A message stream yields partial text content but complete tool calls, all within the Message object -/// So a message with text will contain potentially just a word of a longer response, but tool calls -/// messages will only be yielded once concatenated. -pub type MessageStream = Pin< - Box, Option), ProviderError>> + Send>, ->; - -pub fn stream_from_single_message(message: Message, usage: ProviderUsage) -> MessageStream { - let stream = futures::stream::once(async move { Ok((Some(message), Some(usage))) }); - Box::pin(stream) -} - -/// Collect all chunks from a MessageStream into a single Message and ProviderUsage -pub async fn collect_stream( - mut stream: MessageStream, -) -> Result<(Message, ProviderUsage), ProviderError> { - use futures::StreamExt; - - let mut final_message: Option = None; - let mut final_usage: Option = None; - - while let Some(result) = stream.next().await { - let (msg_opt, usage_opt) = result?; - - if let Some(msg) = msg_opt { - final_message = Some(match final_message { - Some(mut prev) => { - for new_content in msg.content { - match (&mut prev.content.last_mut(), &new_content) { - // Coalesce consecutive text blocks - ( - Some(MessageContent::Text(last_text)), - MessageContent::Text(new_text), - ) => { - last_text.text.push_str(&new_text.text); - } - _ => { - prev.content.push(new_content); - } - } - } - prev - } - None => msg, - }); - } - - if let Some(usage) = usage_opt { - final_usage = Some(usage); - } - } - - match final_message { - Some(msg) => { - let usage = final_usage - .unwrap_or_else(|| ProviderUsage::new("unknown".to_string(), Usage::default())); - Ok((msg, usage)) - } - None => Err(ProviderError::ExecutionError( - "Stream yielded no message".to_string(), - )), + Self::from_env(model, extensions, tls_config) } } @@ -869,179 +270,6 @@ pub async fn collect_stream( mod tests { use super::*; use std::collections::HashMap; - use test_case::test_case; - - #[test] - fn test_strip_xml_tags() { - assert_eq!(strip_xml_tags("reasoninganswer"), "answer"); - assert_eq!(strip_xml_tags("beforemidafter"), "beforeafter"); - assert_eq!(strip_xml_tags("xyz"), "z"); - assert_eq!(strip_xml_tags("no tags here"), "no tags here"); - assert_eq!(strip_xml_tags("a < b > c"), "a < b > c"); - assert_eq!(strip_xml_tags("überok"), "ok"); - assert_eq!(strip_xml_tags("日本語hello"), "hello"); - assert_eq!(strip_xml_tags(""), ""); - assert_eq!(strip_xml_tags("<>stuff"), "<>stuff"); - // attributes - assert_eq!( - strip_xml_tags(r#"reasoninganswer"#), - "answer" - ); - // self-closing tags - assert_eq!(strip_xml_tags("
self closing"), "self closing"); - // orphan closing tags - assert_eq!(strip_xml_tags("orphan tag"), "orphan tag"); - // multiline content - assert_eq!( - strip_xml_tags("\nline1\nline2\nresult"), - "result" - ); - } - - #[test] - fn test_extract_short_title() { - assert_eq!(extract_short_title("List files"), "List files"); - assert_eq!( - extract_short_title( - r#"blah blah blah blah blah blah blah blah blah "List files in folder""# - ), - "List files in folder" - ); - assert_eq!( - extract_short_title( - "blah blah blah blah blah blah blah blah blah `View current files`" - ), - "View current files" - ); - assert_eq!( - extract_short_title( - r#"stuff stuff stuff stuff stuff stuff stuff stuff "Abc title" "Zzz title""# - ), - "Zzz title" - ); - assert_eq!( - extract_short_title( - "long long long long long long long long long\nList files in folder" - ), - "List files in folder" - ); - assert_eq!( - extract_short_title( - r#"lots of words here and there and more and more "single" final line here"# - ), - "lots of words here and there and more and more \"single\" final line here" - ); - assert_eq!(extract_short_title("Hello world"), "Hello world"); - assert_eq!( - extract_short_title( - r#"1. Analyze the request. 2. The user's message says list files. 3. "List current folder files" fits perfectly. Result: List current folder files"# - ), - "List current folder files" - ); - assert_eq!( - extract_short_title( - r#"the user's phrasing is about listing files and the user's intent is clear. "List folder files" is best"# - ), - "List folder files" - ); - assert_eq!( - extract_short_title( - "lots of reasoning here about what to call it\nList current folder files" - ), - "List current folder files" - ); - } - - #[test] - fn test_usage_creation() { - let usage = Usage::new(Some(10), Some(20), Some(30)); - assert_eq!(usage.input_tokens, Some(10)); - assert_eq!(usage.output_tokens, Some(20)); - assert_eq!(usage.total_tokens, Some(30)); - } - - fn content_from_str(s: String) -> MessageContent { - if let Some(img_data) = s.strip_prefix("*img:") { - MessageContent::image(format!("http://example.com/{}", img_data), "image/png") - } else if let Some(tool_name) = s.strip_prefix("*tool:") { - let tool_call = Ok( - rmcp::model::CallToolRequestParams::new(tool_name.to_string()) - .with_arguments(serde_json::Map::new()), - ); - MessageContent::tool_request(format!("tool_{}", tool_name), tool_call) - } else { - MessageContent::text(s) - } - } - - fn create_test_stream( - items: Vec, - ) -> impl Stream, Option), ProviderError>> { - use futures::stream; - stream::iter(items.into_iter().map(|item| { - let content = content_from_str(item); - let message = Message::new( - rmcp::model::Role::Assistant, - chrono::Utc::now().timestamp(), - vec![content], - ); - Ok((Some(message), None)) - })) - } - - fn content_to_strings(msg: &Message) -> Vec { - msg.content - .iter() - .map(|c| match c { - MessageContent::Text(t) => t.text.clone(), - MessageContent::Image(_) => "*img".to_string(), - MessageContent::ToolRequest(tr) => { - if let Ok(call) = &tr.tool_call { - format!("*tool:{}", call.name) - } else { - "*tool:error".to_string() - } - } - _ => "*other".to_string(), - }) - .collect() - } - - #[test_case( - vec!["Hello", " ", "world"], - vec!["Hello world"] - ; "consecutive text coalesces" - )] - #[test_case( - vec!["Hello", "*img:pic1", "world"], - vec!["Hello", "*img", "world"] - ; "non-text breaks coalescing" - )] - #[test_case( - vec!["A", "B", "*img:pic1", "C", "D", "*tool:read", "E", "F"], - vec!["AB", "*img", "CD", "*tool:read", "EF"] - ; "multiple text groups" - )] - #[test_case( - vec!["Text1", "*img:pic", "Text2"], - vec!["Text1", "*img", "Text2"] - ; "mixed content in chunk" - )] - #[tokio::test] - async fn test_collect_stream_coalescing(input_items: Vec<&str>, expected: Vec<&str>) { - let items: Vec = input_items.into_iter().map(|s| s.to_string()).collect(); - let stream = create_test_stream(items); - let (msg, _) = collect_stream(Box::pin(stream)).await.unwrap(); - assert_eq!(content_to_strings(&msg), expected); - } - - #[tokio::test] - async fn test_collect_stream_defaults_usage() { - let stream = create_test_stream(vec!["Hello".to_string()]); - let (msg, usage) = collect_stream(Box::pin(stream)).await.unwrap(); - assert_eq!(content_to_strings(&msg), vec!["Hello"]); - assert_eq!(usage.model, "unknown"); - } #[test] fn test_provider_metadata_context_limits() { @@ -1075,56 +303,4 @@ mod tests { // unknown model should have default limit (128k) assert_eq!(*model_info.get("unknown-model").unwrap(), 128_000); } - - #[test] - fn test_model_info_creation() { - // Test direct ModelInfo creation - let info = ModelInfo { - name: "test-model".to_string(), - resolved_model: None, - context_limit: 1000, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - }; - assert_eq!(info.context_limit, 1000); - - // Test equality - let info2 = ModelInfo { - name: "test-model".to_string(), - resolved_model: None, - context_limit: 1000, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - }; - assert_eq!(info, info2); - - // Test inequality - let info3 = ModelInfo { - name: "test-model".to_string(), - resolved_model: None, - context_limit: 2000, - input_token_cost: None, - output_token_cost: None, - currency: None, - supports_cache_control: None, - reasoning: false, - }; - assert_ne!(info, info3); - } - - #[test] - fn test_model_info_with_cost() { - let info = ModelInfo::with_cost("gpt-4o", 128000, 0.0000025, 0.00001); - assert_eq!(info.name, "gpt-4o"); - assert_eq!(info.context_limit, 128000); - assert_eq!(info.input_token_cost, Some(0.0000025)); - assert_eq!(info.output_token_cost, Some(0.00001)); - assert_eq!(info.currency, Some("$".to_string())); - } } diff --git a/crates/goose/src/providers/bedrock.rs b/crates/goose/src/providers/bedrock.rs index d364937f38df..0193c6652666 100644 --- a/crates/goose/src/providers/bedrock.rs +++ b/crates/goose/src/providers/bedrock.rs @@ -1,30 +1,37 @@ use std::collections::HashMap; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; +use super::formats::openai_responses::create_responses_request; +use super::openai_compatible::{handle_status, stream_responses_compat}; use super::retry::{ProviderRetry, RetryConfig}; use crate::conversation::message::Message; -use crate::model::ModelConfig; -use crate::providers::utils::RequestLog; +use crate::session_context::SESSION_ID_HEADER; use anyhow::Result; +use async_stream::try_stream; use async_trait::async_trait; use aws_sdk_bedrockruntime::config::ProvideCredentials; use aws_sdk_bedrockruntime::operation::converse::ConverseError; +use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamError; +use aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError; use aws_sdk_bedrockruntime::{types as bedrock, Client}; +use base64::Engine; use futures::future::BoxFuture; -use goose_providers::conversation::token_usage::ProviderUsage; +use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; -use reqwest::header::HeaderValue; -use rmcp::model::Tool; +use goose_providers::formats::openai::extract_reasoning_effort; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; +use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION}; +use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, Tool}; use serde_json::Value; use smithy_transport_reqwest::ReqwestHttpClient; use super::formats::bedrock::{ - from_bedrock_message, from_bedrock_usage, to_bedrock_message_with_caching, - to_bedrock_tool_config, + bedrock_anthropic_thinking_fields, from_bedrock_message, from_bedrock_usage, + to_bedrock_message_with_caching, to_bedrock_tool_config, }; -use crate::session_context::SESSION_ID_HEADER; -const BEDROCK_PROVIDER_NAME: &str = "aws_bedrock"; +pub(crate) const BEDROCK_PROVIDER_NAME: &str = "aws_bedrock"; pub const BEDROCK_DOC_LINK: &str = "https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html"; @@ -35,6 +42,8 @@ pub const BEDROCK_KNOWN_MODELS: &[&str] = &[ "us.anthropic.claude-3-7-sonnet-20250219-v1:0", "us.anthropic.claude-opus-4-20250514-v1:0", "us.anthropic.claude-opus-4-1-20250805-v1:0", + "openai.gpt-5.5", + "openai.gpt-5.4", ]; pub const BEDROCK_DEFAULT_MAX_RETRIES: usize = 6; @@ -51,10 +60,29 @@ pub struct BedrockProvider { retry_config: RetryConfig, #[serde(skip)] name: String, + #[serde(skip)] + region: Option, + #[serde(skip)] + bearer_token: Option, + #[serde(skip)] + http_client: reqwest::Client, + #[serde(skip)] + mantle_base_url: Option, +} + +/// Request inputs shared by the `Converse` and `ConverseStream` APIs. +struct ConverseRequestParts { + system_blocks: Vec, + messages: Vec, + tool_config: Option, + thinking_fields: Option, } impl BedrockProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); // Attempt to load config and secrets to get AWS_ prefixed keys @@ -123,13 +151,15 @@ impl BedrockProvider { )); } - let client = if let Some(bearer_token) = bearer_token { + let resolved_region = sdk_config.region().map(|r| r.to_string()); + + let client = if let Some(ref token) = bearer_token { // Build from sdk_config to inherit all settings (endpoint overrides, timeouts, etc.) // then override authentication with bearer token let bedrock_config = aws_sdk_bedrockruntime::Config::new(&sdk_config) .to_builder() .bearer_token(aws_sdk_bedrockruntime::config::Token::new( - bearer_token, + token.clone(), None, )) .build(); @@ -146,6 +176,10 @@ impl BedrockProvider { model, retry_config, name: BEDROCK_PROVIDER_NAME.to_string(), + region: resolved_region, + bearer_token, + http_client: reqwest::Client::new(), + mantle_base_url: None, }) } @@ -199,15 +233,62 @@ impl BedrockProvider { enabled && self.model.model_name.contains("anthropic.claude") } - async fn converse( + async fn post_mantle_streaming( &self, session_id: Option<&str>, + payload: &Value, + ) -> Result { + let region = self.region.as_deref().ok_or_else(|| { + ProviderError::Authentication( + "AWS region is required for Bedrock mantle endpoint".to_string(), + ) + })?; + let token = self.bearer_token.as_deref().ok_or_else(|| { + ProviderError::Authentication( + "AWS_BEARER_TOKEN_BEDROCK is required for openai.gpt-* models".to_string(), + ) + })?; + + let url = self.mantle_base_url.clone().unwrap_or_else(|| { + format!( + "https://bedrock-mantle.{}.api.aws/openai/v1/responses", + region + ) + }); + + let mut req = self + .http_client + .post(&url) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .json(payload); + + if let Some(id) = session_id.filter(|id| !id.is_empty()) { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(SESSION_ID_HEADER.as_bytes()), + HeaderValue::from_str(id), + ) { + req = req.header(name, value); + } + } + + let response = req + .send() + .await + .map_err(|e| ProviderError::RequestFailed(format!("Mantle request failed: {}", e)))?; + + handle_status(response).await + } + + /// Build the request inputs shared by [`Self::converse`] and + /// [`Self::converse_stream`]: system blocks (with optional cache point), + /// converted messages (with optional trailing-message cache point), and + /// the tool configuration. + fn build_request_parts( + &self, system: &str, messages: &[Message], tools: &[Tool], - ) -> Result<(bedrock::Message, Option), ProviderError> { - let model_name = &self.model.model_name; - + ) -> Result { let enable_caching = self.should_enable_caching(); let system_blocks = if enable_caching { @@ -235,23 +316,50 @@ impl BedrockProvider { let last_idx = visible_messages.len().saturating_sub(1); + let bedrock_messages = visible_messages + .iter() + .enumerate() + .map(|(idx, m)| to_bedrock_message_with_caching(m, enable_caching && idx == last_idx)) + .collect::>>()?; + + let tool_config = if tools.is_empty() { + None + } else { + Some(to_bedrock_tool_config(tools)?) + }; + + Ok(ConverseRequestParts { + system_blocks, + messages: bedrock_messages, + tool_config, + thinking_fields: bedrock_anthropic_thinking_fields(&self.model), + }) + } + + async fn converse( + &self, + session_id: Option<&str>, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(bedrock::Message, Option), ProviderError> { + let model_name = &self.model.model_name; + + let parts = self.build_request_parts(system, messages, tools)?; + let mut request = self .client .converse() - .set_system(Some(system_blocks)) + .set_system(Some(parts.system_blocks)) .model_id(model_name.to_string()) - .set_messages(Some( - visible_messages - .iter() - .enumerate() - .map(|(idx, m)| { - to_bedrock_message_with_caching(m, enable_caching && idx == last_idx) - }) - .collect::>()?, - )); + .set_messages(Some(parts.messages)); - if !tools.is_empty() { - request = request.tool_config(to_bedrock_tool_config(tools)?); + if let Some(fields) = parts.thinking_fields { + request = request.additional_model_request_fields(fields); + } + + if let Some(tool_config) = parts.tool_config { + request = request.tool_config(tool_config); } let mut request = request.customize(); @@ -307,6 +415,272 @@ impl BedrockProvider { )), } } + + /// Escape hatch: `BEDROCK_DISABLE_STREAMING=true` restores the previous + /// blocking `Converse` behaviour in case a model or region misbehaves + /// with `ConverseStream`. + fn streaming_disabled(&self) -> bool { + let config = crate::config::Config::global(); + config + .get_param::("BEDROCK_DISABLE_STREAMING") + .unwrap_or(false) + } + + /// Streaming variant of [`Self::converse`]. Builds an identical request + /// but calls the AWS `ConverseStream` API, returning the raw event + /// receiver so [`Provider::stream`] can forward deltas incrementally. + async fn converse_stream( + &self, + session_id: Option<&str>, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result< + aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput, + ProviderError, + > { + let model_name = &self.model.model_name; + + let parts = self.build_request_parts(system, messages, tools)?; + + let mut request = self + .client + .converse_stream() + .set_system(Some(parts.system_blocks)) + .model_id(model_name.to_string()) + .set_messages(Some(parts.messages)); + + if let Some(fields) = parts.thinking_fields { + request = request.additional_model_request_fields(fields); + } + + if let Some(tool_config) = parts.tool_config { + request = request.tool_config(tool_config); + } + + let mut request = request.customize(); + + if let Some(session_id) = session_id.filter(|id| !id.is_empty()) { + let session_id = session_id.to_string(); + request = request.mutate_request(move |req| { + if let Ok(value) = HeaderValue::from_str(&session_id) { + req.headers_mut().insert(SESSION_ID_HEADER, value); + } + }); + } + + request + .send() + .await + .map_err(|err| match err.into_service_error() { + ConverseStreamError::ThrottlingException(throttle_err) => { + ProviderError::RateLimitExceeded { + details: format!("Bedrock throttling error: {:?}", throttle_err), + retry_delay: None, + } + } + ConverseStreamError::AccessDeniedException(err) => { + ProviderError::Authentication(format!("Failed to call Bedrock: {:?}", err)) + } + ConverseStreamError::ValidationException(err) + if { + let msg = err.message().unwrap_or_default(); + msg.contains("Input is too long for requested model.") + || msg.contains("prompt is too long") + } => + { + ProviderError::ContextLengthExceeded(format!( + "Failed to call Bedrock: {:?}", + err + )) + } + ConverseStreamError::ModelErrorException(err) => { + ProviderError::ExecutionError(format!("Failed to call Bedrock: {:?}", err)) + } + err => ProviderError::ServerError(format!("Failed to call Bedrock: {:?}", err)), + }) + } + + /// Pre-ConverseStream behaviour: blocking `Converse` call wrapped in a + /// single-item stream. Kept as the `BEDROCK_DISABLE_STREAMING=true` + /// escape hatch. + async fn stream_via_converse( + &self, + session_id: Option<&str>, + system: &str, + messages: &[Message], + tools: &[Tool], + model_name: &str, + ) -> Result { + let (bedrock_message, bedrock_usage) = self + .with_retry(|| self.converse(session_id, system, messages, tools)) + .await?; + + let usage = bedrock_usage + .as_ref() + .map(from_bedrock_usage) + .unwrap_or_default(); + + let message = from_bedrock_message(&bedrock_message)?; + + // Add debug trace with input context + let debug_payload = serde_json::json!({ + "system": system, + "messages": messages, + "tools": tools + }); + let mut log = start_log(&self.model, &debug_payload)?; + log.write( + &serde_json::to_value(&message).unwrap_or_default(), + Some(&usage), + )?; + + let provider_usage = ProviderUsage::new(model_name.to_string(), usage); + Ok(super::base::stream_from_single_message( + message, + provider_usage, + )) + } +} + +/// Accumulation state for in-flight content blocks while consuming a +/// `ConverseStream` response. Tool inputs and reasoning content arrive as +/// fragments that only become a complete [`Message`] at `ContentBlockStop`. +#[derive(Default)] +struct StreamBlockState { + /// content_block_index -> (tool_use_id, tool_name, accumulated input JSON) + tool_blocks: HashMap, + /// content_block_index -> (accumulated reasoning text, accumulated signature) + reasoning_blocks: HashMap, + /// content_block_index -> accumulated redacted (encrypted) reasoning bytes + redacted_blocks: HashMap>, +} + +/// Convert a single `ConverseStream` event into zero or more [`Message`]s +/// ready to be yielded, plus token usage when the event carries it. +/// +/// Mirrors the delta-yield contract of +/// `formats::anthropic::response_to_streaming_message`: text deltas yield +/// immediately (token-level chunks); tool-use inputs and reasoning blocks +/// accumulate in `state` until their `ContentBlockStop`. +fn process_stream_event( + event: bedrock::ConverseStreamOutput, + state: &mut StreamBlockState, + message_id: &str, +) -> (Vec, Option) { + let mut messages = Vec::new(); + let mut usage = None; + + match event { + bedrock::ConverseStreamOutput::ContentBlockStart(ev) => { + if let Some(bedrock::ContentBlockStart::ToolUse(tu)) = ev.start { + state.tool_blocks.insert( + ev.content_block_index, + (tu.tool_use_id, tu.name, String::new()), + ); + } + } + bedrock::ConverseStreamOutput::ContentBlockDelta(ev) => match ev.delta { + Some(bedrock::ContentBlockDelta::Text(text)) => { + if !text.is_empty() { + messages.push(Message::assistant().with_text(text).with_id(message_id)); + } + } + Some(bedrock::ContentBlockDelta::ToolUse(tu)) => { + if let Some(entry) = state.tool_blocks.get_mut(&ev.content_block_index) { + entry.2.push_str(&tu.input); + } + } + Some(bedrock::ContentBlockDelta::ReasoningContent(rc)) => match rc { + bedrock::ReasoningContentBlockDelta::Text(t) => { + state + .reasoning_blocks + .entry(ev.content_block_index) + .or_default() + .0 + .push_str(&t); + } + bedrock::ReasoningContentBlockDelta::Signature(s) => { + state + .reasoning_blocks + .entry(ev.content_block_index) + .or_default() + .1 + .push_str(&s); + } + bedrock::ReasoningContentBlockDelta::RedactedContent(blob) => { + state + .redacted_blocks + .entry(ev.content_block_index) + .or_default() + .extend_from_slice(blob.as_ref()); + } + _ => {} + }, + _ => {} + }, + bedrock::ConverseStreamOutput::ContentBlockStop(ev) => { + let idx = ev.content_block_index; + if let Some((text, signature)) = state.reasoning_blocks.remove(&idx) { + if !text.is_empty() { + messages.push( + Message::assistant() + .with_thinking(text, signature) + .with_id(message_id), + ); + } + } + if let Some(bytes) = state.redacted_blocks.remove(&idx) { + if !bytes.is_empty() { + // Same base64 encoding as the non-streaming path + // (formats::bedrock::from_bedrock_reasoning_content_block) + // so redacted thinking round-trips back to Bedrock intact. + let encoded = base64::prelude::BASE64_STANDARD.encode(&bytes); + messages.push( + Message::assistant() + .with_redacted_thinking(encoded) + .with_id(message_id), + ); + } + } + if let Some((id, name, input_json)) = state.tool_blocks.remove(&idx) { + // Parse the accumulated tool input. On failure, yield an + // error tool request (not a stream error) so the agent can + // report it back to the model — same behaviour as the + // Anthropic provider. + let tool_call = if input_json.trim().is_empty() { + Ok(CallToolRequestParams::new(name) + .with_arguments(object(serde_json::json!({})))) + } else { + match serde_json::from_str::(&input_json) { + Ok(parsed) => { + Ok(CallToolRequestParams::new(name).with_arguments(object(parsed))) + } + Err(_) => Err(ErrorData::new( + ErrorCode::INVALID_PARAMS, + format!("Could not parse tool arguments: {}", input_json), + None, + )), + } + }; + messages.push( + Message::assistant() + .with_tool_request(id, tool_call) + .with_id(message_id), + ); + } + } + bedrock::ConverseStreamOutput::Metadata(ev) => { + if let Some(u) = ev.usage { + usage = Some(from_bedrock_usage(&u)); + } + } + // MessageStart / MessageStop / unknown variants carry no content + // that needs forwarding. + _ => {} + } + + (messages, usage) } impl ProviderDef for BedrockProvider { @@ -316,7 +690,7 @@ impl ProviderDef for BedrockProvider { ProviderMetadata::new( BEDROCK_PROVIDER_NAME, "Amazon Bedrock", - "Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile ' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true.", + "Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile ' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true. Responses stream via the ConverseStream API; set BEDROCK_DISABLE_STREAMING=true to fall back to blocking Converse calls.", BEDROCK_DEFAULT_MODEL, BEDROCK_KNOWN_MODELS.to_vec(), BEDROCK_DOC_LINK, @@ -325,6 +699,13 @@ impl ProviderDef for BedrockProvider { ConfigKey::new("AWS_REGION", true, false, Some("us-east-1"), true), ConfigKey::new("AWS_BEARER_TOKEN_BEDROCK", false, true, None, true), ConfigKey::new("BEDROCK_ENABLE_CACHING", false, false, Some("false"), false), + ConfigKey::new( + "BEDROCK_DISABLE_STREAMING", + false, + false, + Some("false"), + false, + ), ], ) } @@ -332,8 +713,9 @@ impl ProviderDef for BedrockProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -363,41 +745,159 @@ impl Provider for BedrockProvider { messages: &[Message], tools: &[Tool], ) -> Result { - let session_id = if session_id.is_empty() { + let session_id_opt = if session_id.is_empty() { None } else { Some(session_id) }; - let model_name = model_config.model_name.clone(); - let (bedrock_message, bedrock_usage) = self - .with_retry(|| self.converse(session_id, system, messages, tools)) - .await?; + let without_prefix = model_config + .model_name + .strip_prefix("openai.") + .unwrap_or(&model_config.model_name); + let (base_name, effort) = extract_reasoning_effort(without_prefix); + let bedrock_model_id = format!("openai.{}", base_name); + + let is_mantle_model = BEDROCK_KNOWN_MODELS.contains(&bedrock_model_id.as_str()); + + if is_mantle_model { + let mut normalized_config = ModelConfig { + model_name: base_name, + ..model_config.clone() + }; + // `ModelConfig::new` cannot normalize the effort suffix for `openai.gpt-*` names + // because the `openai.` prefix breaks the reasoning-model regex. Inject it here. + if let Some(e) = effort { + let params = normalized_config + .request_params + .get_or_insert_with(Default::default); + params + .entry("thinking_effort".to_string()) + .or_insert_with(|| serde_json::json!(e)); + } + let mut payload = + create_responses_request(&normalized_config, system, messages, tools)?; + payload["model"] = Value::String(bedrock_model_id.clone()); + payload["stream"] = Value::Bool(true); + let mut log = start_log(model_config, &payload).map_err(anyhow::Error::from)?; + + let response = self + .with_retry(|| self.post_mantle_streaming(session_id_opt, &payload)) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + return stream_responses_compat(response, log); + } - let usage = bedrock_usage - .as_ref() - .map(from_bedrock_usage) - .unwrap_or_default(); + let model_name = model_config.model_name.clone(); - let message = from_bedrock_message(&bedrock_message)?; + // Escape hatch: restore the previous blocking-Converse behaviour. + if self.streaming_disabled() { + return self + .stream_via_converse(session_id_opt, system, messages, tools, &model_name) + .await; + } - // Add debug trace with input context + // Open the AWS ConverseStream event stream. Retry wraps the request + // setup only — mid-stream errors are surfaced, not retried (matching + // the Anthropic provider's behaviour). + let response = self + .with_retry(|| self.converse_stream(session_id_opt, system, messages, tools)) + .await?; + + // Debug trace with input context; the streamed text is written once + // the stream completes. let debug_payload = serde_json::json!({ "system": system, "messages": messages, "tools": tools }); - let mut log = RequestLog::start(&self.model, &debug_payload)?; - log.write( - &serde_json::to_value(&message).unwrap_or_default(), - Some(&usage), - )?; + let mut log = start_log(&self.model, &debug_payload)?; + + let mut event_stream = response.stream; + + Ok(Box::pin(try_stream! { + let mut state = StreamBlockState::default(); + // One id for the whole assistant turn so consumers + // (Conversation::push) can coalesce consecutive deltas into a + // single message — mirrors the Anthropic provider, which stamps + // the API-provided message id on every chunk. Bedrock's + // MessageStart event carries no id, so generate one. + let message_id = format!("msg_{}", uuid::Uuid::new_v4()); + let mut full_text = String::new(); + let mut final_usage: Option = None; + + loop { + let event = event_stream.recv().await.map_err(|err| { + // Map Bedrock mid-stream exceptions to specific ProviderError + // variants so the agent's retry / context-length / server-error + // handling kicks in, mirroring the non-streaming Converse error + // mapping. Without this, a mid-stream throttling or + // context-length failure would be flattened to a generic + // RequestFailed and lose its retryable / context semantics. + match err.as_service_error() { + Some(ConverseStreamOutputError::ThrottlingException(e)) => { + ProviderError::RateLimitExceeded { + details: format!("Bedrock streaming throttling error: {:?}", e), + retry_delay: None, + } + } + Some(ConverseStreamOutputError::ValidationException(e)) + if { + let msg = e.message().unwrap_or_default(); + msg.contains("Input is too long for requested model.") + || msg.contains("prompt is too long") + } => + { + ProviderError::ContextLengthExceeded(format!( + "Bedrock streaming validation error: {:?}", + e + )) + } + Some(ConverseStreamOutputError::ServiceUnavailableException(_)) + | Some(ConverseStreamOutputError::InternalServerException(_)) => { + ProviderError::ServerError(format!( + "Bedrock streaming server error: {:?}", + err + )) + } + Some(ConverseStreamOutputError::ModelStreamErrorException(e)) => { + ProviderError::ExecutionError(format!( + "Bedrock model stream error: {:?}", + e + )) + } + _ => ProviderError::RequestFailed(format!( + "Bedrock stream receive error: {:?}", + err + )), + } + })?; + let Some(event) = event else { break }; - let provider_usage = ProviderUsage::new(model_name.to_string(), usage); - Ok(super::base::stream_from_single_message( - message, - provider_usage, - )) + let (messages, usage) = process_stream_event(event, &mut state, &message_id); + if let Some(usage) = usage { + final_usage = Some(ProviderUsage::new(model_name.clone(), usage)); + } + for message in messages { + if let Some(text) = message.content.first().and_then(|c| c.as_text()) { + full_text.push_str(text); + } + yield (Some(message), None); + } + } + + let usage = final_usage.unwrap_or_else(|| { + ProviderUsage::new(model_name.clone(), Usage::default()) + }); + let _ = log.write( + &serde_json::json!({ "streamed_text": full_text }), + Some(&usage.usage), + ); + yield (None, Some(usage)); + })) } } @@ -428,6 +928,10 @@ mod tests { }, retry_config: RetryConfig::default(), name: "aws_bedrock".to_string(), + region: None, + bearer_token: None, + http_client: reqwest::Client::new(), + mantle_base_url: None, } } @@ -528,4 +1032,472 @@ mod tests { std::env::remove_var("BEDROCK_ENABLE_CACHING"); } + + #[tokio::test] + async fn test_post_mantle_streaming_missing_region() { + let provider = create_mock_provider("openai.gpt-5.5"); + let payload = serde_json::json!({"model": "openai.gpt-5.5"}); + let result = provider.post_mantle_streaming(None, &payload).await; + assert!(result.is_err()); + if let Err(ProviderError::Authentication(msg)) = result { + assert!( + msg.contains("region"), + "Error message should mention region: {}", + msg + ); + } else { + panic!("Expected ProviderError::Authentication"); + } + } + + #[tokio::test] + async fn test_post_mantle_streaming_missing_bearer_token() { + let mut provider = create_mock_provider("openai.gpt-5.5"); + provider.region = Some("us-east-1".to_string()); + + let payload = serde_json::json!({"model": "openai.gpt-5.5"}); + let result = provider.post_mantle_streaming(None, &payload).await; + assert!(result.is_err()); + if let Err(ProviderError::Authentication(msg)) = result { + assert!( + msg.contains("AWS_BEARER_TOKEN_BEDROCK"), + "Error message should mention AWS_BEARER_TOKEN_BEDROCK: {}", + msg + ); + } else { + panic!("Expected ProviderError::Authentication"); + } + } + + #[tokio::test] + async fn test_mantle_stream_returns_text_message() { + use futures::StreamExt; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + + let sse_body = [ + r#"data: {"type":"response.output_text.delta","sequence_number":1,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}"#, + r#"data: {"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}"#, + "data: [DONE]", + ] + .join("\n"); + + Mock::given(method("POST")) + .and(path("/openai/v1/responses")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream")) + .mount(&server) + .await; + + let sdk_config = aws_config::SdkConfig::builder() + .behavior_version(aws_config::BehaviorVersion::latest()) + .region(aws_config::Region::new("us-east-1")) + .build(); + + let provider = BedrockProvider { + client: Client::new(&sdk_config), + model: ModelConfig::new("openai.gpt-5.5").unwrap(), + retry_config: RetryConfig::default(), + name: "aws_bedrock".to_string(), + region: Some("us-east-1".to_string()), + bearer_token: Some("test-token".to_string()), + http_client: reqwest::Client::new(), + mantle_base_url: Some(format!("{}/openai/v1/responses", server.uri())), + }; + + let messages = vec![crate::conversation::message::Message::user().with_text("hi")]; + let mut stream = provider + .stream(&provider.model.clone(), "", "", &messages, &[]) + .await + .unwrap(); + + let mut text = String::new(); + while let Some(item) = stream.next().await { + let (msg, _usage) = item.unwrap(); + if let Some(m) = msg { + for c in m.content { + if let MessageContent::Text(t) = c { + text.push_str(&t.text); + } + } + } + } + + assert_eq!(text, "Hello world"); + + let received = server.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + let body: serde_json::Value = serde_json::from_slice(&received[0].body).unwrap(); + assert_eq!(body["model"].as_str().unwrap(), "openai.gpt-5.5"); + } + + // ── ConverseStream event processing ────────────────────────────────── + + use crate::conversation::message::MessageContent; + + /// Stand-in for the per-turn message id that `stream()` generates. + const TEST_MESSAGE_ID: &str = "msg_test"; + + fn delta_event(idx: i32, delta: bedrock::ContentBlockDelta) -> bedrock::ConverseStreamOutput { + bedrock::ConverseStreamOutput::ContentBlockDelta( + bedrock::ContentBlockDeltaEvent::builder() + .delta(delta) + .content_block_index(idx) + .build() + .unwrap(), + ) + } + + fn tool_start_event(idx: i32, id: &str, name: &str) -> bedrock::ConverseStreamOutput { + bedrock::ConverseStreamOutput::ContentBlockStart( + bedrock::ContentBlockStartEvent::builder() + .start(bedrock::ContentBlockStart::ToolUse( + bedrock::ToolUseBlockStart::builder() + .tool_use_id(id) + .name(name) + .build() + .unwrap(), + )) + .content_block_index(idx) + .build() + .unwrap(), + ) + } + + fn tool_delta_event(idx: i32, fragment: &str) -> bedrock::ConverseStreamOutput { + delta_event( + idx, + bedrock::ContentBlockDelta::ToolUse( + bedrock::ToolUseBlockDelta::builder() + .input(fragment) + .build() + .unwrap(), + ), + ) + } + + fn stop_event(idx: i32) -> bedrock::ConverseStreamOutput { + bedrock::ConverseStreamOutput::ContentBlockStop( + bedrock::ContentBlockStopEvent::builder() + .content_block_index(idx) + .build() + .unwrap(), + ) + } + + #[test] + fn test_stream_text_delta_yields_immediately() { + let mut state = StreamBlockState::default(); + let (messages, usage) = process_stream_event( + delta_event(0, bedrock::ContentBlockDelta::Text("Hello".to_string())), + &mut state, + TEST_MESSAGE_ID, + ); + assert_eq!(messages.len(), 1, "text delta should yield one message"); + assert_eq!(messages[0].as_concat_text(), "Hello"); + assert!(usage.is_none()); + } + + #[test] + fn test_stream_empty_text_delta_yields_nothing() { + let mut state = StreamBlockState::default(); + let (messages, _) = process_stream_event( + delta_event(0, bedrock::ContentBlockDelta::Text(String::new())), + &mut state, + TEST_MESSAGE_ID, + ); + assert!(messages.is_empty(), "empty text delta should be skipped"); + } + + #[test] + fn test_stream_tool_use_accumulates_until_stop() { + let mut state = StreamBlockState::default(); + + let (messages, _) = process_stream_event( + tool_start_event(1, "tool-1", "file_write"), + &mut state, + TEST_MESSAGE_ID, + ); + assert!(messages.is_empty(), "tool start should not yield"); + + // Input arrives as partial-JSON fragments + for fragment in [r#"{"path": "#, r#""a.txt"}"#] { + let (messages, _) = + process_stream_event(tool_delta_event(1, fragment), &mut state, TEST_MESSAGE_ID); + assert!(messages.is_empty(), "tool input fragments should not yield"); + } + + let (messages, _) = process_stream_event(stop_event(1), &mut state, TEST_MESSAGE_ID); + assert_eq!( + messages.len(), + 1, + "tool stop should yield the complete request" + ); + match &messages[0].content[0] { + MessageContent::ToolRequest(req) => { + assert_eq!(req.id, "tool-1"); + let call = req + .tool_call + .as_ref() + .expect("accumulated JSON should parse"); + assert_eq!(call.name.to_string(), "file_write"); + let args = call.arguments.as_ref().expect("arguments should be set"); + assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt")); + } + other => panic!("expected ToolRequest, got {:?}", other), + } + } + + #[test] + fn test_stream_tool_use_invalid_json_yields_error_request() { + let mut state = StreamBlockState::default(); + + process_stream_event( + tool_start_event(0, "tool-2", "shell"), + &mut state, + TEST_MESSAGE_ID, + ); + process_stream_event( + tool_delta_event(0, "this is {{{ not json"), + &mut state, + TEST_MESSAGE_ID, + ); + + let (messages, _) = process_stream_event(stop_event(0), &mut state, TEST_MESSAGE_ID); + assert_eq!(messages.len(), 1); + match &messages[0].content[0] { + MessageContent::ToolRequest(req) => { + assert!( + req.tool_call.is_err(), + "unparseable input should yield an error tool request, not a stream failure" + ); + } + other => panic!("expected ToolRequest, got {:?}", other), + } + } + + #[test] + fn test_stream_tool_use_empty_input_yields_empty_args() { + let mut state = StreamBlockState::default(); + + process_stream_event( + tool_start_event(0, "tool-3", "list_files"), + &mut state, + TEST_MESSAGE_ID, + ); + // No input deltas at all — some tools take no arguments. + + let (messages, _) = process_stream_event(stop_event(0), &mut state, TEST_MESSAGE_ID); + assert_eq!(messages.len(), 1); + match &messages[0].content[0] { + MessageContent::ToolRequest(req) => { + let call = req + .tool_call + .as_ref() + .expect("empty input should parse as {}"); + assert_eq!(call.name.to_string(), "list_files"); + } + other => panic!("expected ToolRequest, got {:?}", other), + } + } + + #[test] + fn test_stream_reasoning_accumulates_until_stop() { + let mut state = StreamBlockState::default(); + + for (delta, expect_empty) in [ + ( + bedrock::ReasoningContentBlockDelta::Text("Let me think".to_string()), + true, + ), + ( + bedrock::ReasoningContentBlockDelta::Text(" about this.".to_string()), + true, + ), + ( + bedrock::ReasoningContentBlockDelta::Signature("sig-abc".to_string()), + true, + ), + ] { + let (messages, _) = process_stream_event( + delta_event(0, bedrock::ContentBlockDelta::ReasoningContent(delta)), + &mut state, + TEST_MESSAGE_ID, + ); + assert_eq!( + messages.is_empty(), + expect_empty, + "reasoning deltas accumulate" + ); + } + + let (messages, _) = process_stream_event(stop_event(0), &mut state, TEST_MESSAGE_ID); + assert_eq!( + messages.len(), + 1, + "reasoning stop should yield thinking message" + ); + match &messages[0].content[0] { + MessageContent::Thinking(t) => { + assert_eq!(t.thinking, "Let me think about this."); + assert_eq!(t.signature, "sig-abc"); + } + other => panic!("expected Thinking, got {:?}", other), + } + } + + #[test] + fn test_stream_metadata_returns_usage() { + let mut state = StreamBlockState::default(); + let event = bedrock::ConverseStreamOutput::Metadata( + bedrock::ConverseStreamMetadataEvent::builder() + .usage( + bedrock::TokenUsage::builder() + .input_tokens(100) + .output_tokens(50) + .total_tokens(150) + .build() + .unwrap(), + ) + .build(), + ); + let (messages, usage) = process_stream_event(event, &mut state, TEST_MESSAGE_ID); + assert!(messages.is_empty()); + let usage = usage.expect("metadata event should carry usage"); + assert_eq!(usage.input_tokens, Some(100)); + assert_eq!(usage.output_tokens, Some(50)); + assert_eq!(usage.total_tokens, Some(150)); + } + + #[test] + fn test_stream_interleaved_text_and_tool_blocks() { + // Bedrock interleaves block indices: text at index 0, tool at index 1. + // Text yields immediately even while a tool block is mid-accumulation. + let mut state = StreamBlockState::default(); + + process_stream_event( + tool_start_event(1, "tool-4", "search"), + &mut state, + TEST_MESSAGE_ID, + ); + process_stream_event(tool_delta_event(1, r#"{"q":"#), &mut state, TEST_MESSAGE_ID); + + let (messages, _) = process_stream_event( + delta_event( + 0, + bedrock::ContentBlockDelta::Text("Searching now".to_string()), + ), + &mut state, + TEST_MESSAGE_ID, + ); + assert_eq!( + messages.len(), + 1, + "text should stream while tool accumulates" + ); + assert_eq!(messages[0].as_concat_text(), "Searching now"); + + process_stream_event( + tool_delta_event(1, r#""rust"}"#), + &mut state, + TEST_MESSAGE_ID, + ); + let (messages, _) = process_stream_event(stop_event(1), &mut state, TEST_MESSAGE_ID); + assert_eq!(messages.len(), 1); + match &messages[0].content[0] { + MessageContent::ToolRequest(req) => { + let call = req.tool_call.as_ref().unwrap(); + let args = call.arguments.as_ref().unwrap(); + assert_eq!(args.get("q").and_then(|v| v.as_str()), Some("rust")); + } + other => panic!("expected ToolRequest, got {:?}", other), + } + } + + #[test] + fn test_metadata_includes_disable_streaming_key() { + let meta = BedrockProvider::metadata(); + + let key = meta + .config_keys + .iter() + .find(|k| k.name == "BEDROCK_DISABLE_STREAMING") + .expect("BEDROCK_DISABLE_STREAMING config key should exist"); + assert!( + !key.required, + "BEDROCK_DISABLE_STREAMING should not be required" + ); + assert!( + !key.secret, + "BEDROCK_DISABLE_STREAMING should not be marked as secret" + ); + assert_eq!( + key.default.as_deref(), + Some("false"), + "BEDROCK_DISABLE_STREAMING should default to false (streaming on)" + ); + } + + #[test] + fn test_stream_messages_carry_turn_message_id() { + // Every message from one turn must share the caller-provided id so + // Conversation::push can coalesce consecutive deltas instead of + // persisting one message per token (same contract as the Anthropic + // provider, which stamps the API message id on every chunk). + let mut state = StreamBlockState::default(); + + let (messages, _) = process_stream_event( + delta_event(0, bedrock::ContentBlockDelta::Text("Hello".to_string())), + &mut state, + TEST_MESSAGE_ID, + ); + assert_eq!(messages[0].id.as_deref(), Some(TEST_MESSAGE_ID)); + + process_stream_event( + tool_start_event(1, "tool-9", "shell"), + &mut state, + TEST_MESSAGE_ID, + ); + let (messages, _) = process_stream_event(stop_event(1), &mut state, TEST_MESSAGE_ID); + assert_eq!( + messages[0].id.as_deref(), + Some(TEST_MESSAGE_ID), + "tool requests must carry the same turn id as text deltas" + ); + } + + #[test] + fn test_stream_redacted_reasoning_accumulates_until_stop() { + let mut state = StreamBlockState::default(); + + let raw = b"encrypted-reasoning-bytes"; + let (messages, _) = process_stream_event( + delta_event( + 0, + bedrock::ContentBlockDelta::ReasoningContent( + bedrock::ReasoningContentBlockDelta::RedactedContent( + aws_smithy_types::Blob::new(raw.to_vec()), + ), + ), + ), + &mut state, + TEST_MESSAGE_ID, + ); + assert!(messages.is_empty(), "redacted deltas accumulate until stop"); + + let (messages, _) = process_stream_event(stop_event(0), &mut state, TEST_MESSAGE_ID); + assert_eq!(messages.len(), 1); + match &messages[0].content[0] { + MessageContent::RedactedThinking(redacted) => { + let expected = base64::prelude::BASE64_STANDARD.encode(raw); + assert_eq!( + redacted.data, expected, + "blob must round-trip as base64, matching the non-streaming path" + ); + } + other => panic!("expected RedactedThinking, got {:?}", other), + } + } } diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 4ce83f1f4e75..7a78368acacf 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -1,6 +1,5 @@ use crate::config::paths::Paths; use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::providers::api_client::AuthProvider; use crate::providers::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use crate::providers::formats::openai_responses::responses_api_to_streaming_message; @@ -16,6 +15,7 @@ use chrono::{DateTime, Utc}; use futures::future::BoxFuture; use futures::{StreamExt, TryStreamExt}; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use jsonwebtoken::jwk::JwkSet; use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; use reqwest::header::{HeaderName, HeaderValue}; @@ -319,7 +319,7 @@ struct TokenData { } #[derive(Debug, Clone)] -struct TokenCache { +pub(crate) struct TokenCache { cache_path: PathBuf, } @@ -328,7 +328,7 @@ fn get_cache_path() -> PathBuf { } impl TokenCache { - fn new() -> Self { + pub(crate) fn new() -> Self { let cache_path = get_cache_path(); if let Some(parent) = cache_path.parent() { let _ = std::fs::create_dir_all(parent); @@ -343,6 +343,9 @@ impl TokenCache { None } } + pub(crate) fn has_token(&self) -> bool { + self.load().is_some() + } fn save(&self, token_data: &TokenData) -> Result<()> { if let Some(parent) = self.cache_path.parent() { @@ -891,7 +894,10 @@ impl ChatGptCodexProvider { Ok(()) } - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { let auth_provider = Arc::new(ChatGptCodexAuthProvider::new( ChatGptCodexAuthState::instance(), )); @@ -973,12 +979,9 @@ impl ProviderDef for ChatGptCodexProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn inventory_configured() -> bool { - TokenCache::new().load().is_some() + Box::pin(Self::from_env(model, tls_config)) } } @@ -1098,7 +1101,7 @@ mod tests { let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(root_path.as_str()))]); TokenCache::new().clear(); - assert!(!ChatGptCodexProvider::inventory_configured()); + assert!(!TokenCache::new().has_token()); TokenCache::new() .save(&TokenData { @@ -1110,7 +1113,7 @@ mod tests { }) .unwrap(); - assert!(ChatGptCodexProvider::inventory_configured()); + assert!(TokenCache::new().has_token()); } #[test_case( diff --git a/crates/goose/src/providers/claude_acp.rs b/crates/goose/src/providers/claude_acp.rs index 93f31976cbbd..839e42fd5d0a 100644 --- a/crates/goose/src/providers/claude_acp.rs +++ b/crates/goose/src/providers/claude_acp.rs @@ -8,14 +8,12 @@ use crate::acp::{ }; use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; -use crate::model::ModelConfig; -use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{current_working_dir, ProviderDef, ProviderMetadata}; -use crate::providers::inventory::InventoryIdentityInput; +use goose_providers::model::ModelConfig; -const CLAUDE_ACP_PROVIDER_NAME: &str = "claude-acp"; +pub(crate) const CLAUDE_ACP_PROVIDER_NAME: &str = "claude-acp"; const CLAUDE_ACP_DOC_URL: &str = "https://github.com/agentclientprotocol/claude-agent-acp"; -const CLAUDE_ACP_BINARY: &str = "claude-agent-acp"; +pub(crate) const CLAUDE_ACP_BINARY: &str = "claude-agent-acp"; pub struct ClaudeAcpProvider; @@ -43,14 +41,16 @@ impl ProviderDef for ClaudeAcpProvider { fn from_env( model: ModelConfig, extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Self::from_env_with_working_dir(model, extensions, current_working_dir()) + Self::from_env_with_working_dir(model, extensions, current_working_dir(), tls_config) } fn from_env_with_working_dir( model: ModelConfig, extensions: Vec, working_dir: PathBuf, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = Config::global(); @@ -88,16 +88,4 @@ impl ProviderDef for ClaudeAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_identity() -> Result { - acp_inventory_identity(CLAUDE_ACP_PROVIDER_NAME, CLAUDE_ACP_BINARY) - } - - fn inventory_configured() -> bool { - acp_adapter_installed(CLAUDE_ACP_BINARY) - } } diff --git a/crates/goose/src/providers/claude_code.rs b/crates/goose/src/providers/claude_code.rs index 3899a642e146..1e51f8b7db1d 100644 --- a/crates/goose/src/providers/claude_code.rs +++ b/crates/goose/src/providers/claude_code.rs @@ -27,10 +27,10 @@ use crate::config::paths::Paths; use crate::config::search_path::SearchPaths; use crate::config::{Config, ExtensionConfig, GooseMode}; use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::permission::permission_confirmation::PrincipalType; use crate::permission::{Permission, PermissionConfirmation}; use crate::subprocess::configure_subprocess; +use goose_providers::model::ModelConfig; use super::cli_common::{error_from_event, extract_usage_tokens}; @@ -604,6 +604,7 @@ impl ProviderDef for ClaudeCodeProvider { fn from_env( model: ModelConfig, extensions: Vec, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = crate::config::Config::global(); @@ -1128,6 +1129,7 @@ mod tests { envs: Envs::new([("API_KEY".into(), "secret".into())].into()), env_keys: vec![], timeout: None, + cwd: None, bundled: Some(false), available_tools: vec![], }], diff --git a/crates/goose/src/providers/codex.rs b/crates/goose/src/providers/codex.rs index 094c3e10b058..c7f054dd4028 100644 --- a/crates/goose/src/providers/codex.rs +++ b/crates/goose/src/providers/codex.rs @@ -14,15 +14,16 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; -use super::utils::{filter_extensions_from_system_prompt, RequestLog}; +use super::utils::filter_extensions_from_system_prompt; use crate::config::base::{CodexCommand, CodexSkipGitCheck}; use crate::config::paths::Paths; use crate::config::search_path::SearchPaths; use crate::config::{Config, ExtensionConfig, GooseMode}; use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::subprocess::configure_subprocess; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Role; use rmcp::model::Tool; @@ -636,6 +637,7 @@ impl ProviderDef for CodexProvider { fn from_env( model: ModelConfig, extensions: Vec, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = Config::global(); @@ -717,9 +719,7 @@ impl Provider for CodexProvider { "messages_count": messages.len() }); - let mut log = RequestLog::start(model_config, &payload).map_err(|e| { - ProviderError::RequestFailed(format!("Failed to start request log: {}", e)) - })?; + let mut log = start_log(model_config, &payload)?; let response = json!({ "lines": lines.len(), @@ -780,6 +780,7 @@ mod tests { env_keys: vec![], description: "Lookup".into(), timeout: Some(30), + cwd: None, bundled: None, available_tools: vec![], }, @@ -836,6 +837,7 @@ mod tests { env_keys: vec![], description: String::new(), timeout: None, + cwd: None, bundled: None, available_tools: vec![], }, diff --git a/crates/goose/src/providers/codex_acp.rs b/crates/goose/src/providers/codex_acp.rs index 9447e67b868a..83b3095fc820 100644 --- a/crates/goose/src/providers/codex_acp.rs +++ b/crates/goose/src/providers/codex_acp.rs @@ -8,12 +8,10 @@ use crate::acp::{ }; use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; -use crate::model::ModelConfig; -use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{current_working_dir, ProviderDef, ProviderMetadata}; -use crate::providers::inventory::InventoryIdentityInput; +use goose_providers::model::ModelConfig; -const CODEX_ACP_PROVIDER_NAME: &str = "codex-acp"; +pub(crate) const CODEX_ACP_PROVIDER_NAME: &str = "codex-acp"; const CODEX_ACP_DOC_URL: &str = "https://github.com/zed-industries/codex-acp"; pub struct CodexAcpProvider; @@ -42,14 +40,16 @@ impl ProviderDef for CodexAcpProvider { fn from_env( model: ModelConfig, extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Self::from_env_with_working_dir(model, extensions, current_working_dir()) + Self::from_env_with_working_dir(model, extensions, current_working_dir(), tls_config) } fn from_env_with_working_dir( model: ModelConfig, extensions: Vec, working_dir: PathBuf, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = Config::global(); @@ -107,18 +107,6 @@ impl ProviderDef for CodexAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_identity() -> Result { - acp_inventory_identity(CODEX_ACP_PROVIDER_NAME, CODEX_ACP_PROVIDER_NAME) - } - - fn inventory_configured() -> bool { - acp_adapter_installed(CODEX_ACP_PROVIDER_NAME) - } } // Codex sandbox scope determines what needs approval: operations within the diff --git a/crates/goose/src/providers/copilot_acp.rs b/crates/goose/src/providers/copilot_acp.rs index 1c0d05a2ae33..db41641e888b 100644 --- a/crates/goose/src/providers/copilot_acp.rs +++ b/crates/goose/src/providers/copilot_acp.rs @@ -8,14 +8,12 @@ use crate::acp::{ }; use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; -use crate::model::ModelConfig; -use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{current_working_dir, ProviderDef, ProviderMetadata}; -use crate::providers::inventory::InventoryIdentityInput; +use goose_providers::model::ModelConfig; -const COPILOT_ACP_PROVIDER_NAME: &str = "copilot-acp"; +pub(crate) const COPILOT_ACP_PROVIDER_NAME: &str = "copilot-acp"; const COPILOT_ACP_DOC_URL: &str = "https://github.com/github/copilot-cli"; -const COPILOT_ACP_BINARY: &str = "copilot"; +pub(crate) const COPILOT_ACP_BINARY: &str = "copilot"; const MODE_AGENT: &str = "https://agentclientprotocol.com/protocol/session-modes#agent"; const MODE_PLAN: &str = "https://agentclientprotocol.com/protocol/session-modes#plan"; @@ -46,14 +44,16 @@ impl ProviderDef for CopilotAcpProvider { fn from_env( model: ModelConfig, extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Self::from_env_with_working_dir(model, extensions, current_working_dir()) + Self::from_env_with_working_dir(model, extensions, current_working_dir(), tls_config) } fn from_env_with_working_dir( model: ModelConfig, extensions: Vec, working_dir: PathBuf, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = Config::global(); @@ -94,16 +94,4 @@ impl ProviderDef for CopilotAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_identity() -> Result { - acp_inventory_identity(COPILOT_ACP_PROVIDER_NAME, COPILOT_ACP_BINARY) - } - - fn inventory_configured() -> bool { - acp_adapter_installed(COPILOT_ACP_BINARY) - } } diff --git a/crates/goose/src/providers/cursor_agent.rs b/crates/goose/src/providers/cursor_agent.rs index 12477f1c722d..7b8a4468f451 100644 --- a/crates/goose/src/providers/cursor_agent.rs +++ b/crates/goose/src/providers/cursor_agent.rs @@ -10,15 +10,16 @@ use tokio::process::Command; use super::base::{ stream_from_single_message, ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, }; -use super::utils::{filter_extensions_from_system_prompt, RequestLog}; +use super::utils::filter_extensions_from_system_prompt; use crate::config::base::CursorAgentCommand; use crate::config::search_path::SearchPaths; use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::subprocess::configure_subprocess; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const CURSOR_AGENT_PROVIDER_NAME: &str = "cursor-agent"; @@ -36,7 +37,10 @@ pub struct CursorAgentProvider { } impl CursorAgentProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let command: String = config.get_cursor_agent_command().unwrap_or_default().into(); let resolved_command = SearchPaths::builder().with_npm().resolve(&command)?; @@ -296,8 +300,9 @@ impl ProviderDef for CursorAgentProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -352,7 +357,7 @@ impl Provider for CursorAgentProvider { "usage": usage }); - let mut log = RequestLog::start(&self.model, &payload)?; + let mut log = start_log(&self.model, &payload)?; log.write(&response, Some(&usage))?; let provider_usage = ProviderUsage::new(model_config.model_name.clone(), usage); diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index 35d8afd8aff1..683337d1a5dc 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -17,24 +17,23 @@ use super::base::{ DEFAULT_PROVIDER_TIMEOUT_SECS, }; use super::databricks_auth::{DatabricksAuth, DatabricksAuthProvider}; -use super::embedding::EmbeddingCapable; use super::formats::databricks::{create_request_for_provider, DATABRICKS_PROVIDER_NAME}; use super::formats::openai_responses::create_responses_request; use super::openai_compatible::{ - handle_response_openai_compat, handle_status, map_http_error_to_provider_error, sanitize_url, - stream_openai_compat, stream_responses_compat, + handle_status, map_http_error_to_provider_error, sanitize_url, stream_openai_compat, + stream_responses_compat, }; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::config::ConfigError; use crate::conversation::message::Message; use crate::instance_id::get_instance_id; -use crate::model::ModelConfig; use crate::providers::retry::{ RetryConfig, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_RETRY_INTERVAL_MS, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RETRY_INTERVAL_MS, }; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; use serde_json::json; @@ -44,6 +43,7 @@ struct DatabricksEndpointInfo { upstream_model_name: Option, upstream_model_provider: Option, reasoning: Option, + supports_responses_api: bool, } #[derive(Debug, Clone)] @@ -85,8 +85,6 @@ pub struct DatabricksProvider { #[serde(skip)] retry_config: RetryConfig, #[serde(skip)] - fast_retry_config: RetryConfig, - #[serde(skip)] name: String, #[serde(skip)] token_cache: Arc>>, @@ -99,7 +97,10 @@ impl DatabricksProvider { super::oauth::cleanup_oauth_cache() } - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let mut host: Result = config.get_param("DATABRICKS_HOST"); @@ -116,7 +117,6 @@ impl DatabricksProvider { let host = host?; let retry_config = Self::load_retry_config(config); - let fast_retry_config = Self::load_fast_retry_config(config); let auth = if let Ok(api_key) = config.get_secret("DATABRICKS_TOKEN") { DatabricksAuth::token(api_key) @@ -134,10 +134,11 @@ impl DatabricksProvider { token_cache: token_cache.clone(), })); - let api_client = ApiClient::with_timeout( + let api_client = ApiClient::with_timeout_and_tls( host.clone(), auth_method, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), + tls_config.clone(), )?; let mut provider = Self { @@ -147,13 +148,15 @@ impl DatabricksProvider { model: model.clone(), image_format: ImageFormat::OpenAi, retry_config, - fast_retry_config, name: DATABRICKS_PROVIDER_NAME.to_string(), token_cache, instance_id: Self::resolve_instance_id(), }; - provider.model = - model.with_fast(DATABRICKS_DEFAULT_FAST_MODEL, DATABRICKS_PROVIDER_NAME)?; + provider.model = crate::model_config::with_configured_fast_model( + model, + DATABRICKS_PROVIDER_NAME, + DATABRICKS_DEFAULT_FAST_MODEL, + )?; Ok(provider) } @@ -190,39 +193,6 @@ impl DatabricksProvider { ) } - fn load_fast_retry_config(_config: &crate::config::Config) -> RetryConfig { - // Fast models are hardcoded to 0 retries for quick failure on Databricks - RetryConfig::new(0, 0, 1.0, 0) - } - - pub fn from_params(host: String, api_key: String, model: ModelConfig) -> Result { - let token_cache = Arc::new(Mutex::new(Some(api_key.clone()))); - let auth = DatabricksAuth::token(api_key); - let auth_method = AuthMethod::Custom(Box::new(DatabricksAuthProvider { - auth: auth.clone(), - token_cache: token_cache.clone(), - })); - - let api_client = ApiClient::with_timeout( - host.clone(), - auth_method, - Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), - )?; - - Ok(Self { - api_client, - host, - auth, - model, - image_format: ImageFormat::OpenAi, - retry_config: RetryConfig::default(), - fast_retry_config: RetryConfig::new(0, 0, 1.0, 0), - name: DATABRICKS_PROVIDER_NAME.to_string(), - token_cache, - instance_id: Self::resolve_instance_id(), - }) - } - fn resolve_instance_id() -> Option { let enabled = crate::config::Config::global() .get_param::("GOOSE_DATABRICKS_CLIENT_REQUEST_ID") @@ -234,16 +204,24 @@ impl DatabricksProvider { } } - fn is_responses_model(model_name: &str) -> bool { - is_openai_responses_model(model_name) - } - fn is_claude_model(model_name: &str) -> bool { model_name.to_lowercase().contains("claude") } fn is_reasoning_capable_model_name(model_name: &str) -> bool { - Self::is_claude_model(model_name) || Self::is_responses_model(model_name) + Self::is_claude_model(model_name) || is_openai_responses_model(model_name) + } + + fn uses_responses_api( + endpoint_info: Option<&DatabricksEndpointInfo>, + model_names: &[&str], + ) -> bool { + match endpoint_info { + Some(info) => info.supports_responses_api, + None => model_names + .iter() + .any(|name| is_openai_responses_model(name)), + } } fn endpoint_model_candidates(value: &Value) -> Vec { @@ -304,6 +282,7 @@ impl DatabricksProvider { fn endpoint_info_from_value(endpoint: &Value) -> Option { let name = endpoint.get("name")?.as_str()?.to_string(); + let supports_responses_api = Self::endpoint_supports_responses_api(endpoint); let upstream_model = Self::endpoint_model_candidates(endpoint) .into_iter() .find(|candidate| candidate.name != name); @@ -320,9 +299,45 @@ impl DatabricksProvider { upstream_model_name, upstream_model_provider, reasoning, + supports_responses_api, }) } + fn endpoint_supports_responses_api(endpoint: &Value) -> bool { + fn value_contains_responses_api(value: &Value) -> bool { + match value { + Value::Object(map) => { + map.get("api_types") + .and_then(|api_types| api_types.as_array()) + .is_some_and(|api_types| { + api_types + .iter() + .any(|api_type| api_type.as_str() == Some("openai/v1/responses")) + }) + || map.values().any(value_contains_responses_api) + } + Value::Array(values) => values.iter().any(value_contains_responses_api), + _ => false, + } + } + + let Some(config) = endpoint.get("config") else { + return false; + }; + + for collection_key in ["served_entities", "served_models"] { + let Some(entities) = config.get(collection_key).and_then(|v| v.as_array()) else { + continue; + }; + + if entities.iter().any(value_contains_responses_api) { + return true; + } + } + + false + } + async fn fetch_endpoint_info( &self, endpoint_name: &str, @@ -378,6 +393,7 @@ impl DatabricksProvider { let mut current_endpoint_name = endpoint_name.to_string(); let mut visited = HashSet::new(); let mut last_info: Option = None; + let mut first_hop_supports_responses_api: Option = None; for _ in 0..MAX_MODEL_SERVING_HOPS { if !visited.insert(current_endpoint_name.clone()) { @@ -385,6 +401,8 @@ impl DatabricksProvider { } let info = self.fetch_endpoint_info(¤t_endpoint_name).await?; + let supports_responses_api = + *first_hop_supports_responses_api.get_or_insert(info.supports_responses_api); let next_endpoint_name = match ( info.upstream_model_provider.as_deref(), info.upstream_model_name.as_deref(), @@ -403,7 +421,7 @@ impl DatabricksProvider { continue; } - return Ok(if info.name == original_endpoint_name { + let mut resolved_info = if info.name == original_endpoint_name { info } else { let upstream_model_name = info @@ -415,8 +433,11 @@ impl DatabricksProvider { upstream_model_name, upstream_model_provider: info.upstream_model_provider.clone(), reasoning: info.reasoning, + supports_responses_api, } - }); + }; + resolved_info.supports_responses_api = supports_responses_api; + return Ok(resolved_info); } last_info @@ -425,6 +446,7 @@ impl DatabricksProvider { upstream_model_name: info.upstream_model_name, upstream_model_provider: info.upstream_model_provider, reasoning: info.reasoning, + supports_responses_api: first_hop_supports_responses_api.unwrap_or(false), }) .ok_or_else(|| { ProviderError::RequestFailed( @@ -484,16 +506,12 @@ impl DatabricksProvider { } } - fn get_endpoint_path(&self, model_name: &str, is_embedding: bool) -> String { - if is_embedding { - "serving-endpoints/text-embedding-3-small/invocations".to_string() + fn get_endpoint_path(&self, model_name: &str, is_responses_model: bool) -> String { + if is_responses_model { + "serving-endpoints/responses".to_string() } else { let (clean_name, _) = extract_reasoning_effort(model_name); - if Self::is_responses_model(&clean_name) { - "serving-endpoints/responses".to_string() - } else { - format!("serving-endpoints/{}/invocations", clean_name) - } + format!("serving-endpoints/{}/invocations", clean_name) } } @@ -505,29 +523,6 @@ impl DatabricksProvider { .to_string() }) } - - async fn post( - &self, - session_id: Option<&str>, - mut payload: Value, - model_name: Option<&str>, - ) -> Result { - let is_embedding = payload.get("input").is_some() && payload.get("messages").is_none(); - let model_to_use = model_name.unwrap_or(&self.model.model_name); - let path = self.get_endpoint_path(model_to_use, is_embedding); - - if let Some(session_id) = session_id { - if let Some(client_request_id) = self.build_client_request_id(session_id) { - payload["client_request_id"] = Value::String(client_request_id); - } - } - - let response = self - .api_client - .response_post(session_id, &path, &payload) - .await?; - handle_response_openai_compat(response).await - } } impl ProviderDef for DatabricksProvider { @@ -551,12 +546,9 @@ impl ProviderDef for DatabricksProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true + Box::pin(Self::from_env(model, tls_config)) } } @@ -595,12 +587,14 @@ impl Provider for DatabricksProvider { .as_ref() .and_then(|info| info.upstream_model_name.as_deref()) .unwrap_or(&model_config.model_name); - let is_responses_model = Self::is_responses_model(&model_config.model_name) - || Self::is_responses_model(effective_model_name); + let is_responses_model = Self::uses_responses_api( + endpoint_info.as_ref(), + &[&model_config.model_name, effective_model_name], + ); let path = if is_responses_model { "serving-endpoints/responses".to_string() } else { - self.get_endpoint_path(&model_config.model_name, false) + self.get_endpoint_path(&model_config.model_name, is_responses_model) }; let client_request_id = self.build_client_request_id(session_id); @@ -637,7 +631,7 @@ impl Provider for DatabricksProvider { payload["client_request_id"] = Value::String(client_request_id.clone()); } - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -702,7 +696,7 @@ impl Provider for DatabricksProvider { .insert("stream_options".to_string(), json!({"include_usage": true})); } - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { let resp = self @@ -758,20 +752,6 @@ impl Provider for DatabricksProvider { } } - fn supports_embeddings(&self) -> bool { - true - } - - async fn create_embeddings( - &self, - session_id: &str, - texts: Vec, - ) -> Result>, ProviderError> { - EmbeddingCapable::create_embeddings(self, session_id, texts) - .await - .map_err(|e| ProviderError::ExecutionError(e.to_string())) - } - async fn fetch_supported_models(&self) -> Result, ProviderError> { Ok(self .fetch_supported_model_info() @@ -835,92 +815,10 @@ impl Provider for DatabricksProvider { } } -#[async_trait] -impl EmbeddingCapable for DatabricksProvider { - async fn create_embeddings( - &self, - session_id: &str, - texts: Vec, - ) -> Result>> { - if texts.is_empty() { - return Ok(vec![]); - } - - let request = json!({ - "input": texts, - }); - - let response = self - .with_retry_config( - || self.post(Some(session_id), request.clone(), None), - self.fast_retry_config.clone(), - ) - .await?; - - let embeddings = response["data"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("Invalid response format: missing data array"))? - .iter() - .map(|item| { - item["embedding"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("Invalid embedding format"))? - .iter() - .map(|v| v.as_f64().map(|f| f as f32)) - .collect::>>() - .ok_or_else(|| anyhow::anyhow!("Invalid embedding values")) - }) - .collect::>>>()?; - - Ok(embeddings) - } -} - #[cfg(test)] mod tests { use super::*; - fn test_provider() -> DatabricksProvider { - DatabricksProvider { - api_client: super::super::api_client::ApiClient::new( - "https://example.com".to_string(), - super::super::api_client::AuthMethod::NoAuth, - ) - .unwrap(), - host: "https://example.com".to_string(), - auth: DatabricksAuth::Token("fake".into()), - model: ModelConfig::new_or_fail("databricks-gpt-5.4"), - image_format: ImageFormat::OpenAi, - retry_config: RetryConfig::default(), - fast_retry_config: RetryConfig::new(0, 0, 1.0, 0), - name: "databricks".into(), - token_cache: std::sync::Arc::new(std::sync::Mutex::new(None)), - instance_id: None, - } - } - - #[test] - fn responses_models_route_to_responses_endpoint() { - let provider = test_provider(); - - for (model_name, expected_path) in [ - ("gpt-5.4", "serving-endpoints/responses"), - ("databricks-gpt-5.4-high", "serving-endpoints/responses"), - ("databricks-gpt-5-4-xhigh", "serving-endpoints/responses"), - ("o3-mini", "serving-endpoints/responses"), - ( - "databricks-claude-sonnet-4", - "serving-endpoints/databricks-claude-sonnet-4/invocations", - ), - ] { - assert_eq!( - provider.get_endpoint_path(model_name, false), - expected_path, - "unexpected endpoint for {model_name}" - ); - } - } - #[test] fn endpoint_metadata_marks_reasoning_alias_from_external_model() { let endpoint = json!({ @@ -942,6 +840,7 @@ mod tests { assert_eq!(info.name, "goose"); assert_eq!(info.upstream_model_name.as_deref(), Some("claude-opus-4.6")); assert_eq!(info.reasoning, Some(true)); + assert!(!info.supports_responses_api); let model_info = DatabricksProvider::model_info_from_endpoint(info); assert_eq!(model_info.name, "goose"); @@ -1014,5 +913,118 @@ mod tests { assert_eq!(info.name, "goose-gpt-5-5"); assert_eq!(info.upstream_model_name, None); assert_eq!(info.reasoning, Some(true)); + assert!(!info.supports_responses_api); + } + + #[test] + fn endpoint_metadata_detects_responses_api_from_foundation_model_api_types() { + let endpoint = json!({ + "name": "databricks-gpt-5-4", + "config": { + "served_entities": [{ + "name": "databricks-gpt-5-4", + "entity_name": "system.ai.databricks-gpt-5-4", + "type": "FOUNDATION_MODEL", + "foundation_model": { + "name": "system.ai.databricks-gpt-5-4", + "display_name": "GPT-5.4", + "api_types": [ + "mlflow/v1/chat/completions", + "openai/v1/responses", + "cursor/v1/chat/completions" + ] + } + }] + } + }); + + let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); + + assert_eq!(info.name, "databricks-gpt-5-4"); + assert_eq!( + info.upstream_model_name.as_deref(), + Some("system.ai.databricks-gpt-5-4") + ); + assert!(info.supports_responses_api); + } + + #[test] + fn endpoint_metadata_detects_responses_api_from_served_models() { + let endpoint = json!({ + "name": "databricks-gpt-5-4", + "config": { + "served_models": [{ + "foundation_model": { + "api_types": ["openai/v1/responses"] + } + }] + } + }); + + let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); + + assert!(info.supports_responses_api); + } + + #[test] + fn endpoint_metadata_ignores_pending_config_for_responses_routing() { + let endpoint = json!({ + "name": "databricks-gpt-5-4", + "config": { + "served_entities": [{ + "foundation_model": { + "api_types": ["mlflow/v1/chat/completions"] + } + }] + }, + "pending_config": { + "served_entities": [{ + "foundation_model": { + "api_types": ["openai/v1/responses"] + } + }] + } + }); + + let info = DatabricksProvider::endpoint_info_from_value(&endpoint).unwrap(); + + assert!(!info.supports_responses_api); + } + + #[test] + fn responses_routing_prefers_metadata_over_model_name() { + let responses_info = DatabricksEndpointInfo { + name: "custom".into(), + upstream_model_name: None, + upstream_model_provider: None, + reasoning: None, + supports_responses_api: true, + }; + assert!(DatabricksProvider::uses_responses_api( + Some(&responses_info), + &["databricks-claude-sonnet-4"] + )); + + let chat_info = DatabricksEndpointInfo { + supports_responses_api: false, + ..responses_info + }; + assert!(!DatabricksProvider::uses_responses_api( + Some(&chat_info), + &["gpt-5.4"] + )); + } + + #[test] + fn responses_routing_falls_back_to_model_name_without_metadata() { + assert!(DatabricksProvider::uses_responses_api(None, &["gpt-5.4"])); + assert!(DatabricksProvider::uses_responses_api( + None, + &["databricks-claude-sonnet-4", "gpt-5.4"] + )); + assert!(!DatabricksProvider::uses_responses_api( + None, + &["databricks-claude-sonnet-4"] + )); } } diff --git a/crates/goose/src/providers/databricks_v2.rs b/crates/goose/src/providers/databricks_v2.rs index 9d0bc276659b..fb6ee4d8bbf0 100644 --- a/crates/goose/src/providers/databricks_v2.rs +++ b/crates/goose/src/providers/databricks_v2.rs @@ -3,9 +3,7 @@ use async_stream::try_stream; use async_trait::async_trait; use futures::future::BoxFuture; use futures::TryStreamExt; -use goose_providers::formats::openai::{ - self, extract_reasoning_effort, is_openai_responses_model, ModelConfigParams, -}; +use goose_providers::formats::openai::{self, extract_reasoning_effort, is_openai_responses_model}; use goose_providers::images::ImageFormat; use serde::Serialize; use serde_json::Value; @@ -24,15 +22,15 @@ use super::databricks_auth::{DatabricksAuth, DatabricksAuthProvider}; use super::formats::{anthropic, openai_responses}; use super::openai_compatible::{handle_status, stream_openai_compat, stream_responses_compat}; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::config::ConfigError; use crate::conversation::message::Message; -use crate::model::ModelConfig; use crate::providers::retry::{ RetryConfig, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_RETRY_INTERVAL_MS, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RETRY_INTERVAL_MS, }; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const DATABRICKS_V2_PROVIDER_NAME: &str = "databricks_v2"; @@ -69,7 +67,10 @@ impl DatabricksV2Provider { super::oauth::cleanup_oauth_cache() } - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let mut host: Result = config.get_param("DATABRICKS_HOST"); @@ -93,16 +94,7 @@ impl DatabricksV2Provider { DatabricksAuth::oauth(host.clone()) }; - Self::new(host, auth, model, retry_config) - } - - pub fn from_params(host: String, api_key: String, model: ModelConfig) -> Result { - Self::new( - host, - DatabricksAuth::token(api_key), - model, - RetryConfig::default(), - ) + Self::new(host, auth, model, retry_config, tls_config) } fn new( @@ -110,6 +102,7 @@ impl DatabricksV2Provider { auth: DatabricksAuth, model: ModelConfig, retry_config: RetryConfig, + tls_config: Option, ) -> Result { let token_cache = Arc::new(Mutex::new(match &auth { DatabricksAuth::Token(t) => Some(t.clone()), @@ -121,10 +114,11 @@ impl DatabricksV2Provider { token_cache: token_cache.clone(), })); - let api_client = ApiClient::with_timeout( + let api_client = ApiClient::with_timeout_and_tls( host, auth_method, Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS), + tls_config, )?; Ok(Self { @@ -233,7 +227,7 @@ impl DatabricksV2Provider { let mut payload = openai_responses::create_responses_request(model_config, system, messages, tools)?; payload["stream"] = Value::Bool(true); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -260,13 +254,7 @@ impl DatabricksV2Provider { tools: &[Tool], ) -> Result { let mut payload = openai::create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -276,7 +264,7 @@ impl DatabricksV2Provider { if payload.get("max_tokens").is_none() { payload["max_tokens"] = Value::from(model_config.max_output_tokens()); } - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -308,7 +296,7 @@ impl DatabricksV2Provider { ) -> Result { let mut payload = anthropic::create_request(model_config, system, messages, tools)?; payload["stream"] = Value::Bool(true); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -366,12 +354,9 @@ impl ProviderDef for DatabricksV2Provider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true + Box::pin(Self::from_env(model, tls_config)) } } diff --git a/crates/goose/src/providers/declarative/empiriolabs.json b/crates/goose/src/providers/declarative/empiriolabs.json new file mode 100644 index 000000000000..5357e73972c8 --- /dev/null +++ b/crates/goose/src/providers/declarative/empiriolabs.json @@ -0,0 +1,53 @@ +{ + "name": "empiriolabs", + "engine": "openai", + "display_name": "EmpirioLabs", + "description": "Frontier open and proprietary chat models through one OpenAI-compatible API with streaming support", + "api_key_env": "EMPIRIOLABS_API_KEY", + "base_url": "https://api.empiriolabs.ai/v1/chat/completions", + "models": [ + { + "name": "qwen3-7-plus", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "qwen3-7-max", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "deepseek-v4-pro", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "deepseek-v4-flash", + "context_limit": 1000000, + "max_tokens": 65536 + }, + { + "name": "glm-5-1", + "context_limit": 202000, + "max_tokens": 131072 + }, + { + "name": "kimi-k2-7-code", + "context_limit": 256000, + "max_tokens": 131072 + }, + { + "name": "minimax-m3", + "context_limit": 524000, + "max_tokens": 131072 + } + ], + "supports_streaming": true, + "dynamic_models": false, + "model_doc_link": "https://docs.empiriolabs.ai", + "setup_steps": [ + "Go to https://platform.empiriolabs.ai/dashboard/api-keys", + "Create or copy an existing API key", + "Paste the key above as EMPIRIOLABS_API_KEY" + ] +} diff --git a/crates/goose/src/providers/declarative/moonshot.json b/crates/goose/src/providers/declarative/moonshot.json index d21c6faee063..489e8e47a920 100644 --- a/crates/goose/src/providers/declarative/moonshot.json +++ b/crates/goose/src/providers/declarative/moonshot.json @@ -2,9 +2,18 @@ "name": "moonshot", "engine": "openai", "display_name": "Moonshot", - "description": "Moonshot AI (Kimi) models", + "description": "Moonshot AI (Kimi) models. Set MOONSHOT_BASE_URL to override the default endpoint (e.g. https://api.moonshot.ai/v1 for international users).", "api_key_env": "MOONSHOT_API_KEY", - "base_url": "https://api.moonshot.cn/v1/chat/completions", + "base_url": "${MOONSHOT_BASE_URL}", + "env_vars": [ + { + "name": "MOONSHOT_BASE_URL", + "required": false, + "secret": false, + "default": "https://api.moonshot.cn/v1/chat/completions", + "description": "Moonshot API base URL. Use https://api.moonshot.ai/v1 for international access outside China Mainland." + } + ], "models": [ {"name": "kimi-latest", "context_limit": 131072}, {"name": "kimi-thinking-preview", "context_limit": 131072}, diff --git a/crates/goose/src/providers/declarative/orcarouter.json b/crates/goose/src/providers/declarative/orcarouter.json new file mode 100644 index 000000000000..fa830284b067 --- /dev/null +++ b/crates/goose/src/providers/declarative/orcarouter.json @@ -0,0 +1,31 @@ +{ + "name": "orcarouter", + "engine": "openai", + "display_name": "OrcaRouter", + "description": "Adaptive router for OpenAI, Anthropic, Google, xAI, DeepSeek, and 150+ other frontier models through a single OpenAI-compatible API.", + "api_key_env": "ORCAROUTER_API_KEY", + "base_url": "https://api.orcarouter.ai/v1", + "catalog_provider_id": "orcarouter", + "headers": { + "http-referer": "https://goose-docs.ai", + "x-title": "goose" + }, + "models": [ + { "name": "anthropic/claude-sonnet-4.6", "context_limit": 200000 }, + { "name": "openai/gpt-5.5", "context_limit": 400000 }, + { "name": "google/gemini-3-flash-preview", "context_limit": 1000000 }, + { "name": "anthropic/claude-opus-4.7", "context_limit": 200000 }, + { "name": "grok/grok-4.3", "context_limit": 256000 }, + { "name": "deepseek/deepseek-v4-pro", "context_limit": 163840 }, + { "name": "orcarouter/auto", "context_limit": 128000 } + ], + "dynamic_models": true, + "supports_streaming": true, + "preserves_thinking": true, + "model_doc_link": "https://www.orcarouter.ai/models", + "setup_steps": [ + "Sign up at https://www.orcarouter.ai", + "Create an API key in the console", + "Paste the key above as ORCAROUTER_API_KEY" + ] +} diff --git a/crates/goose/src/providers/declarative/together.json b/crates/goose/src/providers/declarative/together.json new file mode 100644 index 000000000000..203fe96717a1 --- /dev/null +++ b/crates/goose/src/providers/declarative/together.json @@ -0,0 +1,49 @@ +{ + "name": "together", + "engine": "openai", + "display_name": "Together AI", + "description": "Open-source and custom AI models via Together AI's OpenAI-compatible API.", + "api_key_env": "TOGETHER_API_KEY", + "base_url": "https://api.together.xyz/v1", + "catalog_provider_id": "togetherai", + "dynamic_models": true, + "models": [ + { + "name": "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "context_limit": 131072 + }, + { + "name": "deepseek-ai/DeepSeek-V3", + "context_limit": 131072 + }, + { + "name": "deepseek-ai/DeepSeek-R1", + "context_limit": 163839 + }, + { + "name": "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + "context_limit": 262144 + }, + { + "name": "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", + "context_limit": 262144 + }, + { + "name": "moonshotai/Kimi-K2.5", + "context_limit": 262144 + }, + { + "name": "openai/gpt-oss-120b", + "context_limit": 131072 + } + ], + "preserves_thinking": true, + "supports_streaming": true, + "model_doc_link": "https://docs.together.ai/docs/inference-models", + "setup_steps": [ + "Sign in to https://api.together.xyz", + "Navigate to API Keys in your account settings", + "Create a new API key", + "Copy the key and paste it above" + ] +} diff --git a/crates/goose/src/providers/embedding.rs b/crates/goose/src/providers/embedding.rs deleted file mode 100644 index 3f956b11c020..000000000000 --- a/crates/goose/src/providers/embedding.rs +++ /dev/null @@ -1,28 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbeddingRequest { - pub input: Vec, - pub model: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbeddingResponse { - pub data: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbeddingData { - pub embedding: Vec, -} - -#[async_trait] -pub trait EmbeddingCapable { - async fn create_embeddings( - &self, - session_id: &str, - texts: Vec, - ) -> Result>>; -} diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose/src/providers/formats/anthropic.rs index b1f8c765f7b7..9f7534e30d66 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose/src/providers/formats/anthropic.rs @@ -1,12 +1,12 @@ use crate::conversation::message::{Message, MessageContent}; use crate::mcp_utils::extract_text_from_resource; -use crate::model::ModelConfig; use crate::providers::canonical::maybe_get_canonical_model; use anyhow::{anyhow, Result}; use goose_providers::canonical::ThinkingMode; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; use goose_providers::images::{convert_image, ImageFormat}; +use goose_providers::model::ModelConfig; use goose_providers::thinking::ThinkingEffort; use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, JsonObject, Role, Tool}; use rmcp::object as json_object; @@ -52,16 +52,20 @@ pub struct AnthropicFormatOptions { impl AnthropicFormatOptions { fn for_model(self, model_config: &ModelConfig) -> Self { let preserve_thinking_context = model_config - .get_config_param::( - "preserve_thinking_context", - "ANTHROPIC_PRESERVE_THINKING_CONTEXT", - ) + .request_param::("preserve_thinking_context") + .or_else(|| { + crate::config::Config::global() + .get_param("ANTHROPIC_PRESERVE_THINKING_CONTEXT") + .ok() + }) .unwrap_or(self.preserve_thinking_context); let preserve_unsigned_thinking = model_config - .get_config_param::( - "preserve_unsigned_thinking", - "ANTHROPIC_PRESERVE_UNSIGNED_THINKING", - ) + .request_param::("preserve_unsigned_thinking") + .or_else(|| { + crate::config::Config::global() + .get_param("ANTHROPIC_PRESERVE_UNSIGNED_THINKING") + .ok() + }) .unwrap_or(self.preserve_unsigned_thinking) || preserve_thinking_context; @@ -586,6 +590,11 @@ fn legacy_thinking_budget_tokens() -> Option { None } +// Anthropic counts thinking tokens against max_tokens, so the budget must leave +// room for a response. Clamp it to preserve at least this many answer tokens, and +// drop thinking only when even a minimal budget wouldn't fit under the cap. +const MIN_ANSWER_TOKENS: i32 = 1024; + fn apply_thinking_config( payload: &mut Value, provider_name: &str, @@ -601,31 +610,34 @@ fn apply_thinking_config( obj.insert("output_config".to_string(), json!({"effort": effort})); } ThinkingType::Enabled => { - let budget_tokens = thinking_budget_tokens(model_config); - - obj.insert("max_tokens".to_string(), json!(max_tokens + budget_tokens)); - obj.insert( - "thinking".to_string(), - json!({ - "type": "enabled", - "budget_tokens": budget_tokens - }), - ); + let budget_tokens = thinking_budget_tokens(model_config) + .min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS)); + if budget_tokens >= MIN_ANSWER_TOKENS { + obj.insert( + "thinking".to_string(), + json!({ + "type": "enabled", + "budget_tokens": budget_tokens + }), + ); + } } ThinkingType::Disabled => {} } if options.preserve_thinking_context { if !obj.contains_key("thinking") { - let budget_tokens = thinking_budget_tokens(model_config); - obj.insert("max_tokens".to_string(), json!(max_tokens + budget_tokens)); - obj.insert( - "thinking".to_string(), - json!({ - "type": "enabled", - "budget_tokens": budget_tokens - }), - ); + let budget_tokens = thinking_budget_tokens(model_config) + .min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS)); + if budget_tokens >= MIN_ANSWER_TOKENS { + obj.insert( + "thinking".to_string(), + json!({ + "type": "enabled", + "budget_tokens": budget_tokens + }), + ); + } } if let Some(thinking) = obj.get_mut("thinking").and_then(|t| t.as_object_mut()) { @@ -1012,7 +1024,7 @@ where mod tests { use super::*; use crate::conversation::message::Message; - use crate::model::ModelConfig; + use goose_providers::model::ModelConfig; use rmcp::object; use serde_json::json; @@ -1301,7 +1313,7 @@ mod tests { ]); let mut config = cfg_with_effort("claude-3-7-sonnet-20250219", "high"); - config.max_tokens = Some(4096); + config.max_tokens = Some(64000); let messages = vec![Message::user().with_text("Hello")]; let payload = create_request(&config, "system", &messages, &[])?; @@ -1309,7 +1321,35 @@ mod tests { assert_eq!(payload["thinking"]["type"], "enabled"); let budget = payload["thinking"]["budget_tokens"].as_i64().unwrap(); assert!(budget > 0); - assert_eq!(payload["max_tokens"], 4096 + budget); + assert_eq!(payload["max_tokens"], 64000); + assert!(budget < 64000); + + Ok(()) + } + + #[test] + fn test_create_request_clamps_thinking_budget_to_fit_max_tokens() -> Result<()> { + let _guard = env_lock::lock_env([ + ("GOOSE_THINKING_EFFORT", None::<&str>), + ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), + ]); + + let mut config = cfg_with_effort("claude-3-7-sonnet-20250219", "high"); + let messages = vec![Message::user().with_text("Hello")]; + + // Budget larger than max_tokens is clamped to leave room for a response. + config.max_tokens = Some(4096); + let payload = create_request(&config, "system", &messages, &[])?; + let budget = payload["thinking"]["budget_tokens"].as_i64().unwrap(); + assert!(budget >= 1024); + assert!(budget <= 4096 - 1024); + assert_eq!(payload["max_tokens"], 4096); + + // Too small to fit any thinking alongside a response — drop it. + config.max_tokens = Some(1500); + let payload = create_request(&config, "system", &messages, &[])?; + assert!(payload.get("thinking").is_none()); + assert_eq!(payload["max_tokens"], 1500); Ok(()) } @@ -1343,7 +1383,7 @@ mod tests { ]); let mut config = cfg("glm-4.7"); - config.max_tokens = Some(4096); + config.max_tokens = Some(64000); let messages = vec![ Message::assistant().with_content(MessageContent::thinking("internal", "")), Message::user().with_text("Continue"), @@ -1363,7 +1403,7 @@ mod tests { assert_eq!(payload["thinking"]["type"], "enabled"); assert_eq!(payload["thinking"]["budget_tokens"], 16000); assert_eq!(payload["thinking"]["clear_thinking"], false); - assert_eq!(payload["max_tokens"], 4096 + 16000); + assert_eq!(payload["max_tokens"], 64000); assert_eq!(payload["messages"][0]["content"][0]["type"], "thinking"); assert_eq!(payload["messages"][0]["content"][0]["thinking"], "internal"); assert!(payload["messages"][0]["content"][0] @@ -1389,6 +1429,7 @@ mod tests { let mut config = cfg("glm-4.7"); config.request_params = Some(params); + config.max_tokens = Some(64000); let messages = vec![ Message::assistant().with_content(MessageContent::thinking("internal", "")), Message::user().with_text("Continue"), diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index 7c531761db78..85f354b2012a 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -15,7 +15,71 @@ use rmcp::model::{ use serde_json::Value; use crate::conversation::message::{Message, MessageContent}; +use crate::providers::formats::anthropic::{ + adaptive_output_effort, thinking_budget_tokens, thinking_type_for_provider, ThinkingType, + ANTHROPIC_PROVIDER_NAME, +}; use goose_providers::conversation::token_usage::Usage; +use goose_providers::model::ModelConfig; +use once_cell::sync::Lazy; +use regex::Regex; + +static BEDROCK_VERSION_SUFFIX_RE: Lazy = Lazy::new(|| Regex::new(r"-v\d+(:\d+)?$").unwrap()); + +pub fn bedrock_anthropic_thinking_fields(model_config: &ModelConfig) -> Option { + let thinking_type = bedrock_anthropic_thinking_type(model_config); + let thinking = match thinking_type { + ThinkingType::Adaptive => Document::Object(HashMap::from([( + "type".to_string(), + Document::String("adaptive".to_string()), + )])), + ThinkingType::Enabled => Document::Object(HashMap::from([ + ("type".to_string(), Document::String("enabled".to_string())), + ( + "budget_tokens".to_string(), + Document::Number(Number::PosInt(thinking_budget_tokens(model_config) as u64)), + ), + ])), + ThinkingType::Disabled => return None, + }; + + let mut fields = HashMap::from([("thinking".to_string(), thinking)]); + + if thinking_type == ThinkingType::Adaptive { + fields.insert( + "output_config".to_string(), + Document::Object(HashMap::from([( + "effort".to_string(), + Document::String(adaptive_output_effort(model_config).to_string()), + )])), + ); + } + + Some(Document::Object(fields)) +} + +fn bedrock_anthropic_thinking_type(model_config: &ModelConfig) -> ThinkingType { + let Some((_, anthropic_model)) = model_config.model_name.rsplit_once("anthropic.") else { + return ThinkingType::Disabled; + }; + + let anthropic_config = ModelConfig { + model_name: strip_bedrock_version_suffix(anthropic_model), + ..model_config.clone() + }; + + thinking_type_for_provider(ANTHROPIC_PROVIDER_NAME, &anthropic_config) +} + +/// Bedrock model ids carry a `-v1:0` style suffix (e.g. +/// `claude-opus-4-1-20250805-v1:0`) that the canonical Anthropic registry does +/// not recognise. Dropping it lets the date stamp become the terminal segment +/// the registry already knows how to normalise. +fn strip_bedrock_version_suffix(model_name: &str) -> String { + BEDROCK_VERSION_SUFFIX_RE + .replace(model_name, "") + .into_owned() +} pub fn to_bedrock_message_with_caching( message: &Message, @@ -463,6 +527,108 @@ mod tests { use anyhow::Result; use goose_test_support::TEST_IMAGE_B64; use rmcp::model::{AnnotateAble, RawImageContent}; + use serde_json::json; + + #[test] + fn test_bedrock_anthropic_thinking_fields_enabled() { + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), json!("low")); + let mut config = ModelConfig::new_or_fail("us.anthropic.claude-3-7-sonnet-20250219-v1:0"); + config.request_params = Some(params); + config.reasoning = Some(true); + + let fields = bedrock_anthropic_thinking_fields(&config).expect("thinking fields"); + assert_eq!( + from_bedrock_json(&fields).unwrap(), + json!({ + "thinking": { + "type": "enabled", + "budget_tokens": 4000 + } + }) + ); + } + + #[test] + fn test_bedrock_anthropic_thinking_fields_disabled() { + let mut config = ModelConfig::new_or_fail("us.anthropic.claude-3-7-sonnet-20250219-v1:0"); + config.reasoning = Some(true); + config.request_params = Some(HashMap::from([( + "thinking_effort".to_string(), + json!("off"), + )])); + + assert!(bedrock_anthropic_thinking_fields(&config).is_none()); + } + + #[test] + fn test_bedrock_anthropic_thinking_fields_always_on_adaptive() { + let mut config = ModelConfig::new_or_fail("global.anthropic.claude-fable-5"); + config.reasoning = Some(true); + config.request_params = Some(HashMap::from([( + "thinking_effort".to_string(), + json!("off"), + )])); + + let fields = bedrock_anthropic_thinking_fields(&config).expect("thinking fields"); + assert_eq!( + from_bedrock_json(&fields).unwrap(), + json!({ + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"} + }) + ); + } + + #[test] + fn test_bedrock_anthropic_thinking_fields_adaptive_with_effort() { + let mut config = ModelConfig::new_or_fail("us.anthropic.claude-opus-4.7"); + config.reasoning = Some(true); + config.request_params = Some(HashMap::from([( + "thinking_effort".to_string(), + json!("low"), + )])); + + let fields = bedrock_anthropic_thinking_fields(&config).expect("thinking fields"); + assert_eq!( + from_bedrock_json(&fields).unwrap(), + json!({ + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "low"} + }) + ); + } + + #[test] + fn test_bedrock_anthropic_thinking_fields_adaptive_with_version_suffix() { + let mut config = ModelConfig::new_or_fail("us.anthropic.claude-opus-4-7-20251101-v1:0"); + config.reasoning = Some(true); + config.request_params = Some(HashMap::from([( + "thinking_effort".to_string(), + json!("low"), + )])); + + let fields = bedrock_anthropic_thinking_fields(&config).expect("thinking fields"); + assert_eq!( + from_bedrock_json(&fields).unwrap(), + json!({ + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "low"} + }) + ); + } + + #[test] + fn test_bedrock_thinking_fields_skipped_for_non_anthropic() { + let mut config = ModelConfig::new_or_fail("us.deepseek.r1-v1:0"); + config.reasoning = Some(true); + config.request_params = Some(HashMap::from([( + "thinking_effort".to_string(), + json!("low"), + )])); + + assert!(bedrock_anthropic_thinking_fields(&config).is_none()); + } #[test] fn test_to_bedrock_image_supported_formats() -> Result<()> { diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index 6d5243736fac..b86542c87eab 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -1,9 +1,9 @@ use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::providers::formats::anthropic::{ adaptive_output_effort, model_supports_temperature, thinking_budget_tokens, thinking_type_for_provider, ThinkingType, }; +use goose_providers::model::ModelConfig; use anyhow::{anyhow, Error}; use goose_providers::formats::openai::{ diff --git a/crates/goose/src/providers/formats/gcpvertexai.rs b/crates/goose/src/providers/formats/gcpvertexai.rs index 6e064eee0ec1..4dbca6a031db 100644 --- a/crates/goose/src/providers/formats/gcpvertexai.rs +++ b/crates/goose/src/providers/formats/gcpvertexai.rs @@ -1,8 +1,8 @@ use super::{anthropic, google}; use crate::conversation::message::Message; -use crate::model::ModelConfig; use anyhow::{Context, Result}; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; +use goose_providers::model::ModelConfig; use rmcp::model::Tool; use serde_json::Value; diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose/src/providers/formats/google.rs index 7d3f6b1edbc3..f869959d95af 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose/src/providers/formats/google.rs @@ -1,8 +1,8 @@ -use crate::model::ModelConfig; use anyhow::Result; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; use goose_providers::formats::openai::{is_valid_function_name, sanitize_function_name}; +use goose_providers::model::ModelConfig; use goose_providers::thinking::ThinkingEffort; use rmcp::model::{ object, AnnotateAble, CallToolRequestParams, ErrorCode, ErrorData, RawContent, Role, Tool, @@ -560,8 +560,12 @@ fn get_thinking_config(model_config: &ModelConfig) -> Option { }) } else { let thinking_budget = match model_config - .get_config_param::("thinking_budget", "GEMINI25_THINKING_BUDGET") - { + .request_param::("thinking_budget") + .or_else(|| { + crate::config::Config::global() + .get_param("GEMINI25_THINKING_BUDGET") + .ok() + }) { Some(budget) if budget >= 0 => budget, Some(budget) => { tracing::warn!( @@ -1369,7 +1373,7 @@ data: [DONE]"#; #[test] fn test_get_thinking_config() { - use crate::model::ModelConfig; + use goose_providers::model::ModelConfig; // Test 1: Gemini 3 model with low thinking effort let mut params = std::collections::HashMap::new(); diff --git a/crates/goose/src/providers/formats/openai_responses.rs b/crates/goose/src/providers/formats/openai_responses.rs index dc49ce1f12fc..9dd46028a85a 100644 --- a/crates/goose/src/providers/formats/openai_responses.rs +++ b/crates/goose/src/providers/formats/openai_responses.rs @@ -1,6 +1,5 @@ use crate::conversation::message::{Message, MessageContent}; use crate::mcp_utils::extract_text_from_resource; -use crate::model::ModelConfig; use anyhow::Error; use async_stream::try_stream; use chrono; @@ -10,6 +9,7 @@ use goose_providers::errors::ProviderError; use goose_providers::formats::openai::{ extract_reasoning_effort, is_openai_responses_model, openai_reasoning_effort_for_thinking, }; +use goose_providers::model::ModelConfig; use rmcp::model::{object, CallToolRequestParams, RawContent, Role, Tool}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -864,10 +864,7 @@ where Some(u.total_tokens), ), ); - final_usage = Some(ProviderUsage { - usage, - model: model.clone(), - }); + final_usage = Some(ProviderUsage::new(model.clone(), usage)); // For complete output, use the response output items if !response.output.is_empty() { @@ -948,8 +945,8 @@ where mod tests { use super::*; use crate::conversation::message::MessageContent; - use crate::model::ModelConfig; use futures::StreamExt; + use goose_providers::model::ModelConfig; use rmcp::model::CallToolRequestParams; use rmcp::object; diff --git a/crates/goose/src/providers/formats/openrouter.rs b/crates/goose/src/providers/formats/openrouter.rs index a6d68e0c46d9..a68465fe9bc5 100644 --- a/crates/goose/src/providers/formats/openrouter.rs +++ b/crates/goose/src/providers/formats/openrouter.rs @@ -1,6 +1,6 @@ use crate::conversation::message::{Message, MessageContent, ProviderMetadata}; -use crate::model::ModelConfig; use goose_providers::formats::openai; +use goose_providers::model::ModelConfig; use goose_providers::thinking::ThinkingEffort; use rmcp::model::Role; use serde_json::{json, Value}; diff --git a/crates/goose/src/providers/formats/snowflake.rs b/crates/goose/src/providers/formats/snowflake.rs index 011c99b15197..ba574f9ff8e7 100644 --- a/crates/goose/src/providers/formats/snowflake.rs +++ b/crates/goose/src/providers/formats/snowflake.rs @@ -1,9 +1,9 @@ use crate::conversation::message::{Message, MessageContent}; use crate::mcp_utils::extract_text_from_resource; -use crate::model::ModelConfig; use anyhow::{anyhow, Result}; use goose_providers::conversation::token_usage::Usage; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use rmcp::model::{object, CallToolRequestParams, Role, Tool}; use rmcp::object; use serde_json::{json, Value}; @@ -560,7 +560,7 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-sonnet-4-2025 #[test] fn test_create_request_format() -> Result<()> { use crate::conversation::message::Message; - use crate::model::ModelConfig; + use goose_providers::model::ModelConfig; let model_config = ModelConfig::new_or_fail("claude-4-sonnet").with_canonical_limits("snowflake"); @@ -670,7 +670,7 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-sonnet-4-2025 #[test] fn test_create_request_excludes_tools_for_description() -> Result<()> { use crate::conversation::message::Message; - use crate::model::ModelConfig; + use goose_providers::model::ModelConfig; let model_config = ModelConfig::new_or_fail("claude-4-sonnet").with_canonical_limits("snowflake"); diff --git a/crates/goose/src/providers/gcpauth.rs b/crates/goose/src/providers/gcpauth.rs index 448249b8b7ae..3223c3c303f3 100644 --- a/crates/goose/src/providers/gcpauth.rs +++ b/crates/goose/src/providers/gcpauth.rs @@ -324,7 +324,7 @@ struct TokenResponse { #[derive(Debug)] pub struct GcpAuth { /// The loaded credentials (service account or authorized user) - credentials: AdcCredentials, + credentials: RwLock, /// HTTP client for making token exchange requests client: reqwest::Client, /// Thread-safe cache for the current token @@ -348,12 +348,19 @@ impl GcpAuth { /// * `Result` - A new GcpAuth instance or an error if initialization fails pub async fn new() -> Result { Ok(Self { - credentials: AdcCredentials::load().await?, + credentials: RwLock::new(AdcCredentials::load().await?), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(None)), }) } + pub async fn refresh_credentials(&self) -> Result<(), AuthError> { + let reloaded = AdcCredentials::load().await?; + *self.credentials.write().await = reloaded; + *self.cached_token.write().await = None; + Ok(()) + } + /// Retrieves a valid authentication token. /// /// This method implements an efficient token management strategy: @@ -386,7 +393,7 @@ impl GcpAuth { } // Get new token - let token_response = match &self.credentials { + let token_response = match &*self.credentials.read().await { AdcCredentials::ServiceAccount(creds) => self.get_service_account_token(creds).await?, AdcCredentials::AuthorizedUser(creds) => self.get_authorized_user_token(creds).await?, AdcCredentials::DefaultAccount(creds) => self.get_default_access_token(creds).await?, @@ -687,7 +694,7 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== // Helper function to create a test GcpAuth instance with credentials async fn create_test_auth_with_creds(creds: AdcCredentials) -> GcpAuth { GcpAuth { - credentials: creds, + credentials: RwLock::new(creds), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(None)), } @@ -696,7 +703,7 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== #[tokio::test] async fn test_token_caching() { let auth = GcpAuth { - credentials: AdcCredentials::ServiceAccount(mock_service_account()), + credentials: RwLock::new(AdcCredentials::ServiceAccount(mock_service_account())), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(Some(CachedToken { token: AuthToken { @@ -719,7 +726,7 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== #[tokio::test] async fn test_token_expiration() { let auth = GcpAuth { - credentials: AdcCredentials::ServiceAccount(mock_service_account()), + credentials: RwLock::new(AdcCredentials::ServiceAccount(mock_service_account())), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(Some(CachedToken { token: AuthToken { @@ -757,7 +764,7 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== #[tokio::test] async fn test_concurrent_token_access() { let auth = Arc::new(GcpAuth { - credentials: AdcCredentials::ServiceAccount(mock_service_account()), + credentials: RwLock::new(AdcCredentials::ServiceAccount(mock_service_account())), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(Some(CachedToken { token: AuthToken { @@ -788,7 +795,7 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== #[tokio::test] async fn test_token_refresh_race_condition() { let auth = Arc::new(GcpAuth { - credentials: AdcCredentials::ServiceAccount(mock_service_account()), + credentials: RwLock::new(AdcCredentials::ServiceAccount(mock_service_account())), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(Some(CachedToken { token: AuthToken { @@ -841,7 +848,7 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== #[tokio::test] async fn test_authorized_user_token() { let auth = GcpAuth { - credentials: AdcCredentials::AuthorizedUser(mock_authorized_user()), + credentials: RwLock::new(AdcCredentials::AuthorizedUser(mock_authorized_user())), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(None)), }; @@ -858,7 +865,7 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== #[tokio::test] async fn test_service_account_jwt_creation() { let auth = GcpAuth { - credentials: AdcCredentials::ServiceAccount(mock_service_account()), + credentials: RwLock::new(AdcCredentials::ServiceAccount(mock_service_account())), client: reqwest::Client::new(), cached_token: Arc::new(RwLock::new(None)), }; @@ -1124,4 +1131,9 @@ iXVBc2YmAuU8hiOFUPxtyQfNzG5fQ0rhJSewdtyWxIadJSLj6fsK+AEsNQ== .await; assert!(matches!(result, Err(AuthError::Credentials(_)))); } + + // Note: there is intentionally no test that exercises refresh_credentials() end to end. + // Doing so would require pointing GOOGLE_APPLICATION_CREDENTIALS at a temp file via + // std::env::set_var, which is unsafe in the 2024 edition and races with any other thread + // reading the environment. We don't mutate process-global env state in tests for that. } diff --git a/crates/goose/src/providers/gcpvertexai.rs b/crates/goose/src/providers/gcpvertexai.rs index 7aba01fa3cfe..2d3dc654a11e 100644 --- a/crates/goose/src/providers/gcpvertexai.rs +++ b/crates/goose/src/providers/gcpvertexai.rs @@ -15,11 +15,11 @@ use tokio_util::io::StreamReader; use url::Url; use crate::conversation::message::Message; -use crate::model::ModelConfig; use crate::providers::base::{ ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, DEFAULT_PROVIDER_TIMEOUT_SECS, }; +use goose_providers::model::ModelConfig; use crate::providers::formats::gcpvertexai::{ create_request, response_to_streaming_message, GcpLocation, ModelProvider, RequestContext, @@ -28,9 +28,9 @@ use crate::providers::formats::gcpvertexai::{ use crate::providers::gcpauth::GcpAuth; use crate::providers::openai_compatible::{map_http_error_to_provider_error, sanitize_url}; use crate::providers::retry::RetryConfig; -use crate::providers::utils::RequestLog; use crate::session_context::SESSION_ID_HEADER; use goose_providers::errors::ProviderError; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const GCP_VERTEX_AI_PROVIDER_NAME: &str = "gcp_vertex_ai"; @@ -165,7 +165,10 @@ impl GcpVertexAIProvider { /// /// # Arguments /// * `model` - Configuration for the model to be used - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let project_id = config.get_param("GCP_PROJECT_ID")?; let location = Self::determine_location(config)?; @@ -284,6 +287,7 @@ impl GcpVertexAIProvider { let mut overloaded_attempts = 0; let mut last_error = None; let max_retries = self.retry_config.max_retries; + let mut retried_auth = false; loop { if rate_limit_attempts > max_retries && overloaded_attempts > max_retries { @@ -295,10 +299,21 @@ impl GcpVertexAIProvider { ); } - let auth_header = self - .get_auth_header() - .await - .map_err(|e| ProviderError::Authentication(e.to_string()))?; + let auth_header = match self.get_auth_header().await { + Ok(header) => header, + Err(e) => { + if !retried_auth { + retried_auth = true; + if self.auth.refresh_credentials().await.is_ok() { + tracing::info!( + "gcloud token exchange failed ({e}); reloaded credentials and retrying" + ); + continue; + } + } + return Err(ProviderError::Authentication(e.to_string())); + } + }; let mut request = self .client @@ -355,6 +370,17 @@ impl GcpVertexAIProvider { } else if status == StatusCode::OK { return Ok(response); } else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + if !retried_auth { + retried_auth = true; + if let Err(e) = self.auth.refresh_credentials().await { + tracing::warn!("Failed to reload gcloud credentials after {status}: {e}"); + } else { + tracing::info!( + "Vertex AI returned {status}; reloaded gcloud credentials and retrying" + ); + continue; + } + } return Err(ProviderError::Authentication(format!( "Authentication failed with status: {status}" ))); @@ -557,8 +583,9 @@ impl ProviderDef for GcpVertexAIProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -595,7 +622,7 @@ impl Provider for GcpVertexAIProvider { } } - let mut log = RequestLog::start(model_config, &request)?; + let mut log = start_log(model_config, &request)?; let response = self .post_stream(Some(session_id), &request, &context) diff --git a/crates/goose/src/providers/gemini_cli.rs b/crates/goose/src/providers/gemini_cli.rs index 0d1c29aaf6cd..30b51fa8a31f 100644 --- a/crates/goose/src/providers/gemini_cli.rs +++ b/crates/goose/src/providers/gemini_cli.rs @@ -16,13 +16,13 @@ use crate::config::base::GeminiCliCommand; use crate::config::search_path::SearchPaths; use crate::config::Config; use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::providers::base::ConfigKey; use crate::subprocess::configure_subprocess; use async_stream::try_stream; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use rmcp::model::Role; use rmcp::model::Tool; @@ -47,7 +47,10 @@ pub struct GeminiCliProvider { } impl GeminiCliProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { let config = Config::global(); let command: String = config.get_gemini_cli_command().unwrap_or_default().into(); let resolved_command = SearchPaths::builder().with_npm().resolve(&command)?; @@ -174,8 +177,9 @@ impl ProviderDef for GeminiCliProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } diff --git a/crates/goose/src/providers/gemini_oauth.rs b/crates/goose/src/providers/gemini_oauth.rs index 2c9320d2711e..f7af107fc929 100644 --- a/crates/goose/src/providers/gemini_oauth.rs +++ b/crates/goose/src/providers/gemini_oauth.rs @@ -1,6 +1,5 @@ use crate::config::paths::Paths; use crate::conversation::message::Message; -use crate::model::ModelConfig; use crate::providers::base::{ ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, DEFAULT_PROVIDER_TIMEOUT_SECS, @@ -8,11 +7,12 @@ use crate::providers::base::{ use crate::providers::formats::google::{create_request, response_to_streaming_message}; use crate::providers::google::GOOGLE_DOC_URL; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; const GEMINI_OAUTH_DEFAULT_MODEL: &str = "gemini-3-flash-preview"; const GEMINI_OAUTH_DEFAULT_FAST_MODEL: &str = "gemini-2.5-flash-lite"; use crate::providers::retry::ProviderRetry; -use crate::providers::utils::RequestLog; use crate::session_context::SESSION_ID_HEADER; use anyhow::{anyhow, Result}; use async_stream::try_stream; @@ -837,8 +837,15 @@ pub struct GeminiOAuthProvider { } impl GeminiOAuthProvider { - pub async fn from_env(model: ModelConfig) -> Result { - let model = model.with_fast(GEMINI_OAUTH_DEFAULT_FAST_MODEL, GEMINI_OAUTH_PROVIDER_NAME)?; + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { + let model = crate::model_config::with_configured_fast_model( + model, + GEMINI_OAUTH_PROVIDER_NAME, + GEMINI_OAUTH_DEFAULT_FAST_MODEL, + )?; let token_provider = Arc::new(GeminiOAuthTokenProvider::new( GeminiOAuthAuthState::instance(), @@ -952,8 +959,9 @@ impl ProviderDef for GeminiOAuthProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -991,7 +999,7 @@ impl Provider for GeminiOAuthProvider { tools: &[Tool], ) -> Result { let payload = create_request(model_config, system, messages, tools)?; - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index 2cfbcf289c3f..b8c65821e857 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use axum::http; use chrono::{DateTime, Utc}; use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::{is_openai_responses_model, ModelConfigParams}; +use goose_providers::formats::openai::is_openai_responses_model; use goose_providers::images::ImageFormat; use reqwest::{Client, Response}; use serde::{Deserialize, Serialize}; @@ -30,16 +30,17 @@ use super::base::{ use super::formats::openai_responses::create_responses_request; use super::openai_compatible::handle_response_openai_compat; use super::retry::ProviderRetry; -use super::utils::{get_model, RequestLog}; +use super::utils::get_model; use goose_providers::formats::openai::{create_request, get_usage, response_to_message}; use crate::config::{Config, ConfigError}; use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::providers::base::{ConfigKey, MessageStream}; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::{RawContent, Tool}; use std::ops::Deref; @@ -201,6 +202,8 @@ pub struct GithubCopilotProvider { client_id: String, #[serde(skip)] name: String, + #[serde(skip)] + tls_config: Option, } impl GithubCopilotProvider { @@ -228,7 +231,10 @@ impl GithubCopilotProvider { }) } - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = Config::global(); let host = normalize_host( &config @@ -253,6 +259,7 @@ impl GithubCopilotProvider { urls, client_id, name: GITHUB_COPILOT_PROVIDER_NAME.to_string(), + tls_config, }) } @@ -272,7 +279,8 @@ impl GithubCopilotProvider { } let initiator = if is_user_initiated { "user" } else { "agent" }; headers.insert("X-Initiator", initiator.parse().unwrap()); - let api_client = ApiClient::new(endpoint.clone(), auth)?.with_headers(headers)?; + let api_client = ApiClient::new_with_tls(endpoint.clone(), auth, self.tls_config.clone())? + .with_headers(headers)?; api_client .response_post(session_id, path, payload) @@ -403,7 +411,7 @@ impl GithubCopilotProvider { .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; payload["stream"] = serde_json::Value::Bool(true); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -444,20 +452,14 @@ impl GithubCopilotProvider { if supports_streaming { let payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, &ImageFormat::OpenAi, true, )?; - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -486,20 +488,14 @@ impl GithubCopilotProvider { Some(session_id) }; let payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, &ImageFormat::OpenAi, false, )?; - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -557,8 +553,9 @@ impl ProviderDef for GithubCopilotProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } diff --git a/crates/goose/src/providers/google.rs b/crates/goose/src/providers/google.rs index 590215bc8193..251c3d240a41 100644 --- a/crates/goose/src/providers/google.rs +++ b/crates/goose/src/providers/google.rs @@ -2,19 +2,18 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::MessageStream; use super::openai_compatible::{handle_status, map_http_error_to_provider_error, sanitize_url}; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::conversation::message::Message; use goose_providers::errors::ProviderError; -use crate::model::ModelConfig; use crate::providers::base::{ConfigKey, Provider, ProviderDef, ProviderMetadata}; use crate::providers::formats::google::{create_request, response_to_streaming_message}; -use crate::providers::inventory::{config_secret_value, InventoryIdentityInput}; use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; use futures::future::BoxFuture; use futures::TryStreamExt; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; use serde_json::Value; use std::io; @@ -23,7 +22,7 @@ use tokio_stream::StreamExt; use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::io::StreamReader; -const GOOGLE_PROVIDER_NAME: &str = "google"; +pub(crate) const GOOGLE_PROVIDER_NAME: &str = "google"; pub const GOOGLE_API_HOST: &str = "https://generativelanguage.googleapis.com"; pub const GOOGLE_DEFAULT_MODEL: &str = "gemini-2.5-pro"; pub const GOOGLE_DEFAULT_FAST_MODEL: &str = "gemini-2.5-flash"; @@ -67,8 +66,15 @@ pub struct GoogleProvider { } impl GoogleProvider { - pub async fn from_env(model: ModelConfig) -> Result { - let model = model.with_fast(GOOGLE_DEFAULT_FAST_MODEL, GOOGLE_PROVIDER_NAME)?; + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { + let model = crate::model_config::with_configured_fast_model( + model, + GOOGLE_PROVIDER_NAME, + GOOGLE_DEFAULT_FAST_MODEL, + )?; let config = crate::config::Config::global(); let api_key: String = config.get_secret("GOOGLE_API_KEY")?; @@ -81,8 +87,8 @@ impl GoogleProvider { key: api_key, }; - let api_client = - ApiClient::new(host, auth)?.with_header("Content-Type", "application/json")?; + let api_client = ApiClient::new_with_tls(host, auth, tls_config)? + .with_header("Content-Type", "application/json")?; Ok(Self { api_client, @@ -133,29 +139,9 @@ impl ProviderDef for GoogleProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_identity() -> Result { - let config = crate::config::Config::global(); - let mut identity = InventoryIdentityInput::new(GOOGLE_PROVIDER_NAME, GOOGLE_PROVIDER_NAME) - .with_public( - "host", - config - .get_param::("GOOGLE_HOST") - .unwrap_or_else(|_| GOOGLE_API_HOST.to_string()), - ); - - if let Some(api_key) = config_secret_value(config, "GOOGLE_API_KEY") { - identity = identity.with_secret("api_key", api_key); - } - - Ok(identity) + Box::pin(Self::from_env(model, tls_config)) } } @@ -210,7 +196,7 @@ impl Provider for GoogleProvider { tools: &[Tool], ) -> Result { let payload = create_request(model_config, system, messages, tools)?; - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { diff --git a/crates/goose/src/providers/http_status.rs b/crates/goose/src/providers/http_status.rs index 58e317e1bc30..2c24153554b7 100644 --- a/crates/goose/src/providers/http_status.rs +++ b/crates/goose/src/providers/http_status.rs @@ -107,25 +107,75 @@ fn parse_http_date(value: &str) -> Option { None } -fn check_context_length_exceeded(text: &str) -> bool { - let check_phrases = [ - "too long", +pub(crate) fn is_context_length_exceeded_message(text: &str) -> bool { + let text_lower = text.to_lowercase(); + + let direct_context_phrases = [ "context length", "context_length_exceeded", - "reduce the length", - "token count", - "exceeds", - "exceed context limit", - "input length", - "max_tokens", - "decrease input length", + "context window", + "context_window_exceeded", "context limit", + "maximum context", + "max context", "maximum prompt length", + "max prompt length", ]; - let text_lower = text.to_lowercase(); - check_phrases + if direct_context_phrases .iter() .any(|phrase| text_lower.contains(phrase)) + { + return true; + } + + if text_lower.contains("reduce the length") + && ["message", "messages", "input", "prompt"] + .iter() + .any(|word| text_lower.contains(word)) + { + return true; + } + + if [ + "input is too long", + "input too long", + "prompt is too long", + "prompt too long", + ] + .iter() + .any(|phrase| text_lower.contains(phrase)) + { + return true; + } + + let mentions_prompt_input_tokens = [ + "input token", + "input length", + "prompt token", + "prompt length", + "message token", + "messages token", + "request token", + "total token", + ] + .iter() + .any(|phrase| text_lower.contains(phrase)); + let mentions_limit = [ + "model limit", + "model's limit", + "maximum allowed", + "max allowed", + "maximum number of tokens", + "token limit", + "tokens limit", + ] + .iter() + .any(|phrase| text_lower.contains(phrase)); + let mentions_overflow = ["exceed", "too long", "too large", "over the limit"] + .iter() + .any(|phrase| text_lower.contains(phrase)); + + mentions_prompt_input_tokens && mentions_limit && mentions_overflow } pub fn map_http_error_to_provider_error( @@ -164,7 +214,7 @@ pub fn map_http_error_to_provider_error( StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()), StatusCode::BAD_REQUEST => { let payload_str = extract_message(); - if check_context_length_exceeded(&payload_str) { + if is_context_length_exceeded_message(&payload_str) { ProviderError::ContextLengthExceeded(payload_str) } else { ProviderError::RequestFailed(format!("Bad request (400): {}", payload_str)) @@ -342,4 +392,42 @@ mod tests { let payload = json!({ "error": { "metadata": { "retry_after_seconds": f64::INFINITY } } }); assert!(extract_retry_after(&empty_headers(), Some(&payload)).is_none()); } + + #[test] + fn context_length_classifier_accepts_context_window_errors() { + let messages = [ + "This request exceeds the maximum context length", + "context_length_exceeded", + "context window exceeded", + "Input token count exceeds the maximum number of tokens allowed", + "Please reduce the length of the messages", + "prompt is too long for this model", + ]; + + for message in messages { + assert!( + is_context_length_exceeded_message(message), + "expected context-length match for: {message}" + ); + } + } + + #[test] + fn context_length_classifier_rejects_generic_bad_request_errors() { + let messages = [ + "max_tokens must be less than or equal to 4096", + "Requested max_tokens exceeds the model output limit", + "Current token count exceeds your organization quota", + "temperature exceeds maximum allowed value", + "schema is too long", + "metadata length exceeds maximum allowed", + ]; + + for message in messages { + assert!( + !is_context_length_exceeded_message(message), + "expected generic bad request for: {message}" + ); + } + } } diff --git a/crates/goose/src/providers/huggingface.rs b/crates/goose/src/providers/huggingface.rs index 5416b00e5f8b..c72ac3def41e 100644 --- a/crates/goose/src/providers/huggingface.rs +++ b/crates/goose/src/providers/huggingface.rs @@ -4,15 +4,14 @@ use super::base::{ DEFAULT_PROVIDER_TIMEOUT_SECS, }; use super::huggingface_auth; -use super::inventory::{default_inventory_identity, InventoryIdentityInput}; use super::openai_compatible::OpenAiCompatibleProvider; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::config::{Config, ConfigError}; use crate::conversation::message::Message; -use crate::model::ModelConfig; use anyhow::{anyhow, Result}; use futures::future::BoxFuture; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use rmcp::model::Tool; pub const HUGGINGFACE_API_HOST: &str = "https://router.huggingface.co/v1"; @@ -75,6 +74,7 @@ impl HuggingFaceProvider { pub fn from_custom_config( model: ModelConfig, config: DeclarativeProviderConfig, + tls_config: Option, ) -> Result { let custom_models = static_model_names(&config); if config.dynamic_models == Some(false) && custom_models.is_none() { @@ -92,10 +92,11 @@ impl HuggingFaceProvider { let timeout_secs = config .timeout_seconds .unwrap_or(DEFAULT_PROVIDER_TIMEOUT_SECS); - let mut api_client = ApiClient::with_timeout( + let mut api_client = ApiClient::with_timeout_and_tls( host, auth_method, std::time::Duration::from_secs(timeout_secs), + tls_config, )? .with_query(query_params); @@ -110,7 +111,7 @@ impl HuggingFaceProvider { } let model = if let Some(ref fast_model_name) = config.fast_model { - model.with_fast(fast_model_name, &config.name)? + crate::model_config::with_configured_fast_model(model, &config.name, fast_model_name)? } else { model }; @@ -207,6 +208,7 @@ impl ProviderDef for HuggingFaceProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = Config::global(); @@ -215,7 +217,7 @@ impl ProviderDef for HuggingFaceProvider { let host: String = config .get_param("HF_HOST") .unwrap_or_else(|_| HUGGINGFACE_API_HOST.to_string()); - let api_client = ApiClient::new(host, auth_method)?; + let api_client = ApiClient::new_with_tls(host, auth_method, tls_config)?; Ok(Self { inner: OpenAiCompatibleProvider::new( @@ -229,20 +231,6 @@ impl ProviderDef for HuggingFaceProvider { }) }) } - - fn inventory_identity() -> Result { - let metadata = Self::metadata(); - Ok(default_inventory_identity( - &metadata.name, - &metadata.name, - &metadata.config_keys, - Config::global(), - )) - } - - fn inventory_configured() -> bool { - huggingface_auth::has_configured_token().unwrap_or(false) - } } fn missing_token_error() -> anyhow::Error { @@ -457,9 +445,12 @@ mod tests { ModelInfo::new("static-b".to_string(), 128000), ]; - let provider = - HuggingFaceProvider::from_custom_config(ModelConfig::new("static-a").unwrap(), config) - .unwrap(); + let provider = HuggingFaceProvider::from_custom_config( + ModelConfig::new("static-a").unwrap(), + config, + None, + ) + .unwrap(); assert_eq!( provider.fetch_supported_models().await.unwrap(), @@ -476,6 +467,7 @@ mod tests { let error = match HuggingFaceProvider::from_custom_config( ModelConfig::new("model").unwrap(), config, + None, ) { Ok(_) => panic!("expected dynamic_models: false without static models to fail"), Err(error) => error, diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 06d5773de0ad..48935e79056a 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -42,55 +42,102 @@ use super::{ xai_oauth::XaiOAuthProvider, }; use crate::config::ExtensionConfig; -use crate::model::ModelConfig; use crate::providers::base::ProviderType; use crate::{ config::declarative_providers::register_declarative_providers, providers::provider_registry::ProviderEntry, }; use anyhow::Result; +use goose_providers::model::ModelConfig; use tokio::sync::OnceCell; static REGISTRY: OnceCell> = OnceCell::const_new(); async fn init_registry() -> RwLock { - let mut registry = ProviderRegistry::new().with_providers(|registry| { - registry.register::(false); - registry.register::(true); + let tls_config = + crate::config::tls::provider_tls_config_from_config(crate::config::Config::global()) + .expect("failed to load provider TLS config"); + let mut registry = ProviderRegistry::new(tls_config).with_providers(|registry| { + use super::inventory::registrations; + + registry.register_with_inventory::( + false, + Some(registrations::amp_acp_inventory()), + ); + registry.register_with_inventory::( + true, + Some(registrations::anthropic_inventory()), + ); registry.register::(false); registry.register::(false); #[cfg(feature = "aws-providers")] registry.register::(false); #[cfg(feature = "local-inference")] registry.register::(false); - registry.register::(true); - registry.register::(false); + registry.register_with_inventory::( + true, + Some(registrations::chatgpt_codex_inventory()), + ); + registry.register_with_inventory::( + false, + Some(registrations::claude_acp_inventory()), + ); registry.register::(true); - registry.register::(false); - registry.register::(false); + registry.register_with_inventory::( + false, + Some(registrations::codex_acp_inventory()), + ); + registry.register_with_inventory::( + false, + Some(registrations::copilot_acp_inventory()), + ); registry.register::(true); registry.register::(false); - registry.register::(true); - registry.register::(false); + registry.register_with_inventory::( + true, + Some(registrations::refresh_only()), + ); + registry.register_with_inventory::( + false, + Some(registrations::refresh_only()), + ); registry.register::(false); registry.register::(false); registry.register::(true); registry.register::(false); - registry.register::(true); - registry.register::(true); + registry.register_with_inventory::( + true, + Some(registrations::google_inventory()), + ); + registry.register_with_inventory::( + true, + Some(registrations::huggingface_inventory()), + ); registry.register::(true); registry.register::(false); registry.register::(true); - registry.register::(true); - registry.register::(true); + registry.register_with_inventory::( + true, + Some(registrations::ollama_inventory()), + ); + registry.register_with_inventory::( + true, + Some(registrations::openai_inventory()), + ); registry.register::(true); - registry.register::(false); + registry.register_with_inventory::( + false, + Some(registrations::pi_acp_inventory()), + ); #[cfg(feature = "aws-providers")] registry.register::(false); registry.register::(false); registry.register::(true); registry.register::(false); - registry.register::(true); + registry.register_with_inventory::( + true, + Some(registrations::xai_oauth_inventory()), + ); }); // Register cleanup functions for providers with cached state registry.set_cleanup( @@ -224,7 +271,7 @@ pub async fn create_with_named_model( model_name: &str, extensions: Vec, ) -> Result> { - let config = ModelConfig::new(model_name)?; + let config = crate::model_config::model_config_from_user_config(provider_name, model_name)?; create(provider_name, config, extensions).await } diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs index 589f2ea3e611..28d9ca73fe5e 100644 --- a/crates/goose/src/providers/inventory/mod.rs +++ b/crates/goose/src/providers/inventory/mod.rs @@ -1,3 +1,11 @@ +pub mod registrations; +mod resolver; + +pub use resolver::{ + default_inventory_identity_resolver, InventoryConfiguredResolver, InventoryIdentityResolver, + InventoryRegistration, InventoryResolvers, +}; + use super::base::{ConfigKey, ModelInfo, Provider, ProviderType}; use super::canonical::{map_provider_name, map_to_canonical_model, CanonicalModelRegistry}; use super::catalog::ProviderSetupCategory; diff --git a/crates/goose/src/providers/inventory/registrations.rs b/crates/goose/src/providers/inventory/registrations.rs new file mode 100644 index 000000000000..f85c2639a766 --- /dev/null +++ b/crates/goose/src/providers/inventory/registrations.rs @@ -0,0 +1,194 @@ +use super::{ + config_secret_value, default_inventory_identity, default_inventory_identity_resolver, + serialize_string_map, InventoryIdentityInput, InventoryRegistration, +}; +use crate::config::Config; +use crate::providers::acp_tooling::{acp_adapter_installed, resolved_acp_command}; +use crate::providers::amp_acp::{AMP_ACP_BINARY, AMP_ACP_PROVIDER_NAME}; +use crate::providers::base::ProviderDef; +use crate::providers::chatgpt_codex::TokenCache as ChatGptCodexTokenCache; +use crate::providers::claude_acp::{CLAUDE_ACP_BINARY, CLAUDE_ACP_PROVIDER_NAME}; +use crate::providers::codex_acp::CODEX_ACP_PROVIDER_NAME; +use crate::providers::copilot_acp::{COPILOT_ACP_BINARY, COPILOT_ACP_PROVIDER_NAME}; +use crate::providers::formats::anthropic::ANTHROPIC_PROVIDER_NAME; +use crate::providers::google::{GOOGLE_API_HOST, GOOGLE_PROVIDER_NAME}; +use crate::providers::huggingface::HuggingFaceProvider; +use crate::providers::huggingface_auth; +use crate::providers::ollama::{ollama_host_configured, OLLAMA_PROVIDER_NAME}; +use crate::providers::openai::{OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_PROVIDER_NAME}; +use crate::providers::pi_acp::{PI_ACP_BINARY, PI_ACP_PROVIDER_NAME}; +use crate::providers::xai_oauth::TokenCache as XaiOAuthTokenCache; + +pub fn openai_inventory() -> InventoryRegistration { + InventoryRegistration::new(true, || { + let config = Config::global(); + let mut identity = + InventoryIdentityInput::new(OPEN_AI_PROVIDER_NAME, OPEN_AI_PROVIDER_NAME) + .with_public( + "host", + config + .get_param::("OPENAI_HOST") + .unwrap_or_else(|_| "https://api.openai.com".to_string()), + ) + .with_public( + "base_path", + config + .get_param::("OPENAI_BASE_PATH") + .unwrap_or_else(|_| OPEN_AI_DEFAULT_BASE_PATH.to_string()), + ); + + if let Ok(organization) = config.get_param::("OPENAI_ORGANIZATION") { + identity = identity.with_public("organization", organization); + } + if let Ok(project) = config.get_param::("OPENAI_PROJECT") { + identity = identity.with_public("project", project); + } + if let Some(api_key) = config_secret_value(config, "OPENAI_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + if let Some(custom_headers) = config_secret_value(config, "OPENAI_CUSTOM_HEADERS") { + identity = identity.with_secret("custom_headers", custom_headers); + } + + Ok(identity) + }) + .with_configured(|| { + let config = Config::global(); + if let Ok(host) = config.get_param::("OPENAI_HOST") { + if host != "https://api.openai.com" { + return true; + } + } + config + .get_secret::("OPENAI_API_KEY") + .is_ok() + }) +} + +pub fn anthropic_inventory() -> InventoryRegistration { + InventoryRegistration::new(true, || { + let config = Config::global(); + let mut identity = + InventoryIdentityInput::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_NAME) + .with_public( + "host", + config + .get_param::("ANTHROPIC_HOST") + .unwrap_or_else(|_| "https://api.anthropic.com".to_string()), + ); + + if let Some(api_key) = config_secret_value(config, "ANTHROPIC_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + if let Ok(headers) = config + .get_secret::>("ANTHROPIC_CUSTOM_HEADERS") + { + identity = identity.with_secret("headers", serialize_string_map(&headers)?); + } + Ok(identity) + }) +} + +pub fn google_inventory() -> InventoryRegistration { + InventoryRegistration::new(true, || { + let config = Config::global(); + let mut identity = InventoryIdentityInput::new(GOOGLE_PROVIDER_NAME, GOOGLE_PROVIDER_NAME) + .with_public( + "host", + config + .get_param::("GOOGLE_HOST") + .unwrap_or_else(|_| GOOGLE_API_HOST.to_string()), + ); + if let Some(api_key) = config_secret_value(config, "GOOGLE_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + Ok(identity) + }) +} + +pub fn ollama_inventory() -> InventoryRegistration { + InventoryRegistration::new(true, || { + let config = Config::global(); + Ok( + InventoryIdentityInput::new(OLLAMA_PROVIDER_NAME, OLLAMA_PROVIDER_NAME).with_public( + "host", + config + .get_param::("OLLAMA_HOST") + .unwrap_or_else(|_| "http://localhost:11434".to_string()), + ), + ) + }) + .with_configured(|| ollama_host_configured(Config::global())) +} + +pub fn huggingface_inventory() -> InventoryRegistration { + InventoryRegistration::new(false, || { + let metadata = HuggingFaceProvider::metadata(); + Ok(default_inventory_identity( + &metadata.name, + &metadata.name, + &metadata.config_keys, + Config::global(), + )) + }) + .with_configured(|| huggingface_auth::has_configured_token().unwrap_or(false)) +} + +pub fn refresh_only() -> InventoryRegistration { + InventoryRegistration { + supports_refresh: true, + identity: default_inventory_identity_resolver(), + configured: None, + } +} + +pub fn chatgpt_codex_inventory() -> InventoryRegistration { + InventoryRegistration { + supports_refresh: false, + identity: default_inventory_identity_resolver(), + configured: None, + } + .with_configured(|| ChatGptCodexTokenCache::new().has_token()) +} + +pub fn xai_oauth_inventory() -> InventoryRegistration { + InventoryRegistration { + supports_refresh: false, + identity: default_inventory_identity_resolver(), + configured: None, + } + .with_configured(|| XaiOAuthTokenCache::new().has_token()) +} + +pub fn acp_inventory( + provider_id: &'static str, + command: &'static str, + supports_refresh: bool, +) -> InventoryRegistration { + InventoryRegistration::new(supports_refresh, move || { + let resolved_command = resolved_acp_command(command)?; + Ok(InventoryIdentityInput::new(provider_id, provider_id) + .with_public("command", resolved_command.display().to_string())) + }) + .with_configured(move || acp_adapter_installed(command)) +} + +pub fn amp_acp_inventory() -> InventoryRegistration { + acp_inventory(AMP_ACP_PROVIDER_NAME, AMP_ACP_BINARY, false) +} + +pub fn claude_acp_inventory() -> InventoryRegistration { + acp_inventory(CLAUDE_ACP_PROVIDER_NAME, CLAUDE_ACP_BINARY, true) +} + +pub fn codex_acp_inventory() -> InventoryRegistration { + acp_inventory(CODEX_ACP_PROVIDER_NAME, CODEX_ACP_PROVIDER_NAME, true) +} + +pub fn copilot_acp_inventory() -> InventoryRegistration { + acp_inventory(COPILOT_ACP_PROVIDER_NAME, COPILOT_ACP_BINARY, true) +} + +pub fn pi_acp_inventory() -> InventoryRegistration { + acp_inventory(PI_ACP_PROVIDER_NAME, PI_ACP_BINARY, false) +} diff --git a/crates/goose/src/providers/inventory/resolver.rs b/crates/goose/src/providers/inventory/resolver.rs new file mode 100644 index 000000000000..2c7181ac1a1c --- /dev/null +++ b/crates/goose/src/providers/inventory/resolver.rs @@ -0,0 +1,92 @@ +use super::{default_inventory_configured, default_inventory_identity, InventoryIdentityInput}; +use crate::config::Config; +use crate::providers::base::ProviderMetadata; +use anyhow::Result; +use once_cell::sync::Lazy; +use std::sync::Arc; + +static DEFAULT_INVENTORY_IDENTITY_RESOLVER: Lazy = + Lazy::new(|| Arc::new(|| unreachable!("default inventory identity resolver marker"))); + +pub fn default_inventory_identity_resolver() -> InventoryIdentityResolver { + Arc::clone(&DEFAULT_INVENTORY_IDENTITY_RESOLVER) +} + +pub type InventoryIdentityResolver = Arc Result + Send + Sync>; +pub type InventoryConfiguredResolver = Arc bool + Send + Sync>; + +#[derive(Clone)] +pub struct InventoryRegistration { + pub supports_refresh: bool, + pub identity: InventoryIdentityResolver, + pub configured: Option, +} + +impl InventoryRegistration { + pub fn new(supports_refresh: bool, identity: G) -> Self + where + G: Fn() -> Result + Send + Sync + 'static, + { + Self { + supports_refresh, + identity: Arc::new(identity), + configured: None, + } + } + + pub fn with_configured(mut self, configured: H) -> Self + where + H: Fn() -> bool + Send + Sync + 'static, + { + self.configured = Some(Arc::new(configured)); + self + } +} + +#[derive(Clone)] +pub struct InventoryResolvers { + pub supports_refresh: bool, + pub identity: InventoryIdentityResolver, + pub configured: InventoryConfiguredResolver, +} + +impl InventoryResolvers { + pub fn for_metadata( + metadata: &ProviderMetadata, + registration: Option, + ) -> Self { + let metadata_for_identity = metadata.clone(); + let default_identity = Arc::new(move || { + Ok(default_inventory_identity( + &metadata_for_identity.name, + &metadata_for_identity.name, + &metadata_for_identity.config_keys, + Config::global(), + )) + }); + + let config_keys = metadata.config_keys.clone(); + let default_configured = + Arc::new(move || default_inventory_configured(&config_keys, Config::global())); + + match registration { + Some(registration) => Self { + supports_refresh: registration.supports_refresh, + identity: if Arc::ptr_eq( + ®istration.identity, + &default_inventory_identity_resolver(), + ) { + default_identity + } else { + registration.identity + }, + configured: registration.configured.unwrap_or(default_configured), + }, + None => Self { + supports_refresh: false, + identity: default_identity, + configured: default_configured, + }, + } + } +} diff --git a/crates/goose/src/providers/kimicode.rs b/crates/goose/src/providers/kimicode.rs index 5f00521cef06..92352c7f284a 100644 --- a/crates/goose/src/providers/kimicode.rs +++ b/crates/goose/src/providers/kimicode.rs @@ -26,11 +26,11 @@ use super::oauth_device_flow::{ }; use super::openai_compatible::handle_status; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::conversation::message::Message; -use crate::model::ModelConfig; use futures::future::BoxFuture; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const KIMI_CODE_PROVIDER_NAME: &str = "kimi_code"; @@ -162,8 +162,15 @@ impl KimiCodeProvider { TokenCache::new().clear().await } - pub async fn from_env(model: ModelConfig) -> Result { - let model = model.with_fast(KIMI_CODE_DEFAULT_FAST_MODEL, KIMI_CODE_PROVIDER_NAME)?; + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { + let model = crate::model_config::with_configured_fast_model( + model, + KIMI_CODE_PROVIDER_NAME, + KIMI_CODE_DEFAULT_FAST_MODEL, + )?; let client = Client::builder() .timeout(StdDuration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS)) .build()?; @@ -368,8 +375,9 @@ impl ProviderDef for KimiCodeProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -400,7 +408,7 @@ impl Provider for KimiCodeProvider { .unwrap() .insert("stream".to_string(), Value::Bool(true)); - let mut log = RequestLog::start(model_config, &payload) + let mut log = start_log(model_config, &payload) .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; let response = self diff --git a/crates/goose/src/providers/litellm.rs b/crates/goose/src/providers/litellm.rs index 7a9c29317bef..b0aeff7141b9 100644 --- a/crates/goose/src/providers/litellm.rs +++ b/crates/goose/src/providers/litellm.rs @@ -12,13 +12,12 @@ use super::base::{ ConfigKey, MessageStream, ModelInfo, Provider, ProviderDef, ProviderMetadata, DEFAULT_PROVIDER_TIMEOUT_SECS, }; -use super::embedding::EmbeddingCapable; use super::openai_compatible::handle_response_openai_compat; use super::retry::ProviderRetry; -use super::utils::{get_model, RequestLog}; +use super::utils::get_model; use crate::conversation::message::Message; -use crate::model::ModelConfig; -use goose_providers::formats::openai::ModelConfigParams; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const LITELLM_PROVIDER_NAME: &str = "litellm"; @@ -38,7 +37,10 @@ pub struct LiteLLMProvider { } impl LiteLLMProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let secrets = config .get_secrets("LITELLM_API_KEY", &["LITELLM_CUSTOM_HEADERS"]) @@ -64,8 +66,12 @@ impl LiteLLMProvider { AuthMethod::BearerToken(api_key) }; - let mut api_client = - ApiClient::with_timeout(host, auth, std::time::Duration::from_secs(timeout_secs))?; + let mut api_client = ApiClient::with_timeout_and_tls( + host, + auth, + std::time::Duration::from_secs(timeout_secs), + tls_config, + )?; if let Some(headers) = custom_headers { let mut header_map = reqwest::header::HeaderMap::new(); @@ -185,8 +191,9 @@ impl ProviderDef for LiteLLMProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -229,13 +236,7 @@ impl Provider for LiteLLMProvider { Some(session_id) }; let mut payload = goose_providers::formats::openai::create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -257,7 +258,7 @@ impl Provider for LiteLLMProvider { let message = goose_providers::formats::openai::response_to_message(&response)?; let usage = goose_providers::formats::openai::get_usage(&response); let response_model = get_model(&response); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; log.write(&response, Some(&usage))?; let provider_usage = ProviderUsage::new(response_model, usage); Ok(super::base::stream_from_single_message( @@ -266,10 +267,6 @@ impl Provider for LiteLLMProvider { )) } - fn supports_embeddings(&self) -> bool { - true - } - async fn supports_cache_control(&self) -> bool { if let Ok(models) = self.get_or_fetch_models().await { if let Some(model_info) = models.iter().find(|m| m.name == self.model.model_name) { @@ -286,48 +283,6 @@ impl Provider for LiteLLMProvider { } } -#[async_trait] -impl EmbeddingCapable for LiteLLMProvider { - async fn create_embeddings( - &self, - session_id: &str, - texts: Vec, - ) -> Result>, anyhow::Error> { - let embedding_model = std::env::var("GOOSE_EMBEDDING_MODEL") - .unwrap_or_else(|_| "text-embedding-3-small".to_string()); - - let payload = json!({ - "input": texts, - "model": embedding_model, - "encoding_format": "float" - }); - - let response = self - .api_client - .response_post(Some(session_id), "v1/embeddings", &payload) - .await?; - let response_text = response.text().await?; - let response_json: Value = serde_json::from_str(&response_text)?; - - let data = response_json["data"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("Missing data field"))?; - - let mut embeddings = Vec::new(); - for item in data { - let embedding: Vec = item["embedding"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("Missing embedding field"))? - .iter() - .map(|v| v.as_f64().unwrap_or(0.0) as f32) - .collect(); - embeddings.push(embedding); - } - - Ok(embeddings) - } -} - /// Updates the request payload to include cache control headers for automatic prompt caching /// Adds ephemeral cache control to the last 2 user messages, system message, and last tool pub fn update_request_for_cache_control(original_payload: &Value) -> Value { diff --git a/crates/goose/src/providers/local_inference.rs b/crates/goose/src/providers/local_inference.rs index e0c4ef3316d4..0f821d51964d 100644 --- a/crates/goose/src/providers/local_inference.rs +++ b/crates/goose/src/providers/local_inference.rs @@ -2,14 +2,17 @@ mod backend; pub mod hf_models; mod llamacpp; pub mod local_model_registry; +mod mlx; pub(crate) mod multimodal; +#[cfg(feature = "mlx")] +mod native_tool_parsing; +#[cfg(feature = "mlx")] +mod tool_emulation; mod tool_parsing; use crate::config::ExtensionConfig; use crate::conversation::message::{Message, MessageContent}; -use crate::model::ModelConfig; use crate::providers::base::{MessageStream, Provider, ProviderDef, ProviderMetadata}; -use crate::providers::utils::RequestLog; use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; @@ -18,8 +21,11 @@ use futures::future::BoxFuture; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; use goose_providers::images::ImageFormat; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt, RequestLogHandle}; use llamacpp::{LlamaCppBackend, LLAMACPP_BACKEND_ID}; use local_model_registry::ChatTemplate; +use mlx::{MlxBackend, MLX_BACKEND_ID}; use rmcp::model::Tool; use serde_json::{json, Value}; use std::collections::HashMap; @@ -74,8 +80,10 @@ impl InferenceRuntime { return Ok(runtime); } let llamacpp_backend: Arc = Arc::new(LlamaCppBackend::new()?); + let mlx_backend: Arc = Arc::new(MlxBackend::new()); let mut backends = HashMap::new(); backends.insert(LLAMACPP_BACKEND_ID, llamacpp_backend); + backends.insert(MLX_BACKEND_ID, mlx_backend); let runtime = Arc::new(Self { models: StdMutex::new(HashMap::new()), backends, @@ -93,14 +101,18 @@ impl InferenceRuntime { fn backend_for_model( &self, - _resolved: &ResolvedModelPaths, + resolved: &ResolvedModelPaths, ) -> Result, ProviderError> { - self.backends - .get(LLAMACPP_BACKEND_ID) - .cloned() - .ok_or_else(|| { - ProviderError::ExecutionError("Local inference backend unavailable".to_string()) - }) + let backend_id = resolved + .backend_id + .as_deref() + .unwrap_or(LLAMACPP_BACKEND_ID); + self.backends.get(backend_id).cloned().ok_or_else(|| { + ProviderError::ExecutionError(format!( + "Local inference backend '{}' unavailable", + backend_id + )) + }) } fn get_or_create_model_slot(&self, key: ModelCacheKey) -> ModelSlot { @@ -130,23 +142,51 @@ pub(super) struct ResolvedModelPaths { pub context_limit: usize, pub settings: crate::providers::local_inference::local_model_registry::ModelSettings, pub mmproj_path: Option, + pub backend_id: Option, + pub draft_model_path: Option, +} + +fn resolve_model_local_path(model_id: &str) -> Option { + use crate::providers::local_inference::local_model_registry::get_registry; + + get_registry() + .lock() + .ok()? + .get_model(model_id) + .map(|entry| entry.local_path.clone()) } /// Resolve model path, context limit, settings, and mmproj path for a model ID from the registry. fn resolve_model_path(model_id: &str) -> Option { - use crate::providers::local_inference::local_model_registry::get_registry; + use crate::providers::local_inference::local_model_registry::{ + default_settings_for_model, get_registry, + }; if let Ok(registry) = get_registry().lock() { if let Some(entry) = registry.get_model(model_id) { let ctx = entry.settings.context_size.unwrap_or(0) as usize; let mut settings = entry.settings.clone(); + let defaults = default_settings_for_model(model_id); + settings.vision_capable = defaults.vision_capable; settings.mmproj_size_bytes = entry.mmproj_size_bytes; let mmproj_path = entry.mmproj_path.as_ref().filter(|p| p.exists()).cloned(); + let backend_id = entry + .backend_id + .clone() + .or_else(|| settings.backend_id.clone()); + let draft_model = settings + .draft_model + .clone() + .or_else(|| std::env::var("GOOSE_LOCAL_DRAFT_MODEL").ok()) + .filter(|draft_model| draft_model != model_id); + let draft_model_path = draft_model.as_deref().and_then(resolve_model_local_path); return Some(ResolvedModelPaths { model_path: entry.local_path.clone(), context_limit: ctx, settings, mmproj_path, + backend_id, + draft_model_path, }); } } @@ -188,16 +228,27 @@ pub fn recommend_local_model(runtime: &InferenceRuntime) -> String { FEATURED_MODELS[0].spec.to_string() } -fn build_openai_messages_json(system: &str, messages: &[Message]) -> String { +fn build_openai_messages_json( + system: &str, + messages: &[Message], + media_marker: Option<&str>, +) -> String { use goose_providers::formats::openai::format_messages; let mut arr: Vec = vec![json!({"role": "system", "content": system})]; arr.extend(format_messages(messages, &ImageFormat::OpenAi)); strip_image_parts_from_messages(&mut arr); + if let Some(marker) = media_marker { + convert_text_media_markers(&mut arr, marker); + } serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) } -fn build_openai_text_messages_json(system: &str, messages: &[Message]) -> String { +fn build_openai_text_messages_json( + system: &str, + messages: &[Message], + media_marker: Option<&str>, +) -> String { let mut arr: Vec = vec![json!({"role": "system", "content": system})]; arr.extend(messages.iter().filter_map(|m| { let content = extract_text_content(m); @@ -210,9 +261,75 @@ fn build_openai_text_messages_json(system: &str, messages: &[Message]) -> String }; Some(json!({"role": role, "content": content})) })); + if let Some(marker) = media_marker { + convert_text_media_markers(&mut arr, marker); + } serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) } +fn convert_text_media_markers(messages: &mut [Value], marker: &str) { + if marker.is_empty() { + return; + } + + for msg in messages { + let Some(content) = msg.get_mut("content") else { + continue; + }; + + if let Some(text) = content.as_str() { + if let Some(parts) = split_media_marker_text(text, marker) { + *content = json!(parts); + } + continue; + } + + let Some(content_parts) = content.as_array_mut() else { + continue; + }; + let mut updated = Vec::new(); + let mut changed = false; + for part in content_parts.iter() { + if part.get("type").and_then(|v| v.as_str()) == Some("text") { + if let Some(text) = part.get("text").and_then(|v| v.as_str()) { + if let Some(parts) = split_media_marker_text(text, marker) { + updated.extend(parts); + changed = true; + continue; + } + } + } + updated.push(part.clone()); + } + if changed { + *content_parts = updated; + } + } +} + +fn split_media_marker_text(text: &str, marker: &str) -> Option> { + let mut parts = Vec::new(); + let mut rest = text; + let mut found_marker = false; + while let Some((before, after)) = rest.split_once(marker) { + found_marker = true; + let before = before.strip_suffix('\n').unwrap_or(before); + if !before.is_empty() { + parts.push(json!({"type": "text", "text": before})); + } + parts.push(json!({"type": "media_marker", "text": marker})); + rest = after; + rest = rest.strip_prefix('\n').unwrap_or(rest); + } + if !found_marker { + return None; + } + if !rest.is_empty() { + parts.push(json!({"type": "text", "text": rest})); + } + Some(parts) +} + /// Remove `image_url` content parts from OpenAI-format messages JSON, replacing /// each with a text note. This prevents an FFI crash in llama.cpp which does not /// accept `image_url` content-part types. @@ -257,7 +374,10 @@ fn extract_text_content(msg: &Message) -> String { for content in &msg.content { match content { MessageContent::Text(text) => { - parts.push(text.text.clone()); + let text = strip_info_messages(&text.text); + if !text.trim().is_empty() { + parts.push(text); + } } MessageContent::ToolRequest(req) => { if let Ok(call) = &req.tool_call { @@ -307,9 +427,27 @@ fn extract_text_content(msg: &Message) -> String { parts.join("\n") } +fn strip_info_messages(text: &str) -> String { + let mut remaining = text; + let mut output = String::new(); + + while let Some((before, after_start)) = remaining.split_once("") { + output.push_str(before); + if let Some((_, after_end)) = after_start.split_once("") { + remaining = after_end; + } else { + remaining = ""; + break; + } + } + + output.push_str(remaining); + output.trim().to_string() +} + /// Build a `ProviderUsage` and write the request log entry. fn finalize_usage( - log: &mut RequestLog, + log: &mut Option>, model_name: String, path_label: &str, prompt_token_count: usize, @@ -394,6 +532,7 @@ impl ProviderDef for LocalInferenceProvider { fn from_env( model: ModelConfig, extensions: Vec, + _tls_config: Option, ) -> BoxFuture<'static, Result> where Self: Sized, @@ -474,8 +613,13 @@ impl Provider for LocalInferenceProvider { // Allow request_params to override thinking let mut model_settings = model_settings; - if let Some(false) = - model_config.get_config_param::("enable_thinking", "GOOSE_LOCAL_ENABLE_THINKING") + if let Some(false) = model_config + .request_param::("enable_thinking") + .or_else(|| { + crate::config::Config::global() + .get_param("GOOSE_LOCAL_ENABLE_THINKING") + .ok() + }) { model_settings.enable_thinking = false; } @@ -483,6 +627,8 @@ impl Provider for LocalInferenceProvider { let model_arc = model_slot.clone(); let backend = backend.clone(); let model_name = model_config.model_name.clone(); + let temperature = model_config.temperature; + let max_tokens = model_config.max_tokens; let context_limit = model_context_limit; let settings = model_settings; let resolved_model = resolved.clone(); @@ -506,8 +652,7 @@ impl Provider for LocalInferenceProvider { }, }); - let mut log = RequestLog::start(&self.model_config, &log_payload) - .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + let mut log = start_log(&self.model_config, &log_payload)?; let (tx, mut rx) = tokio::sync::mpsc::channel::< Result<(Option, Option), ProviderError>, @@ -547,8 +692,11 @@ impl Provider for LocalInferenceProvider { messages: &messages, tools: &tools, settings: &settings, + temperature, + max_tokens, context_limit, resolved_model: &resolved_model, + draft_model_path: resolved_model.draft_model_path.clone(), message_id: &message_id, tx: &tx, log: &mut log, @@ -576,3 +724,51 @@ impl Provider for LocalInferenceProvider { })) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_marker_in_string_content_to_media_marker_part() { + let mut messages = vec![json!({ + "role": "user", + "content": "look\n<__media__>\nclosely", + })]; + + convert_text_media_markers(&mut messages, "<__media__>"); + + assert_eq!( + messages[0]["content"], + json!([ + {"type": "text", "text": "look"}, + {"type": "media_marker", "text": "<__media__>"}, + {"type": "text", "text": "closely"}, + ]) + ); + } + + #[test] + fn converts_marker_inside_text_content_parts() { + let mut messages = vec![json!({ + "role": "user", + "content": [ + {"type": "text", "text": "<__media__>describe"}, + {"type": "text", "text": "next"}, + {"type": "media_marker", "text": "<__media__>"}, + ], + })]; + + convert_text_media_markers(&mut messages, "<__media__>"); + + assert_eq!( + messages[0]["content"], + json!([ + {"type": "media_marker", "text": "<__media__>"}, + {"type": "text", "text": "describe"}, + {"type": "text", "text": "next"}, + {"type": "media_marker", "text": "<__media__>"}, + ]) + ); + } +} diff --git a/crates/goose/src/providers/local_inference/backend.rs b/crates/goose/src/providers/local_inference/backend.rs index d3eac5de1581..78a2cc3a91a4 100644 --- a/crates/goose/src/providers/local_inference/backend.rs +++ b/crates/goose/src/providers/local_inference/backend.rs @@ -3,8 +3,8 @@ use std::any::Any; use crate::conversation::message::Message; use crate::providers::local_inference::local_model_registry::ModelSettings; -use crate::providers::utils::RequestLog; use goose_providers::errors::ProviderError; +use goose_providers::request_log::RequestLogHandle; use super::{ResolvedModelPaths, StreamSender}; @@ -12,17 +12,21 @@ pub(super) trait BackendLoadedModel: Send { fn as_any_mut(&mut self) -> &mut dyn Any; } +#[cfg_attr(not(feature = "mlx"), allow(dead_code))] pub(super) struct LocalGenerationRequest<'a> { pub model_name: String, pub system: &'a str, pub messages: &'a [Message], pub tools: &'a [Tool], pub settings: &'a ModelSettings, + pub temperature: Option, + pub max_tokens: Option, pub context_limit: usize, pub resolved_model: &'a ResolvedModelPaths, + pub draft_model_path: Option, pub message_id: &'a str, pub tx: &'a StreamSender, - pub log: &'a mut RequestLog, + pub log: &'a mut Option>, } pub(super) trait LocalInferenceBackend: Send + Sync { diff --git a/crates/goose/src/providers/local_inference/hf_models.rs b/crates/goose/src/providers/local_inference/hf_models.rs index c9133747b016..bb31fb8deb33 100644 --- a/crates/goose/src/providers/local_inference/hf_models.rs +++ b/crates/goose/src/providers/local_inference/hf_models.rs @@ -1,5 +1,12 @@ use anyhow::{bail, Result}; +use futures::StreamExt; +use hf_hub::progress::{DownloadEvent, FileStatus, ProgressEvent, ProgressHandler}; +use hf_hub::repository::{ModelInfo, RepoSibling}; +use hf_hub::{HFClient, HFRepository, RepoTypeModel}; + +use super::local_model_registry::{get_registry, model_id_from_repo, LocalModelStorage, ShardFile}; use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; use crate::providers::huggingface_auth; @@ -7,6 +14,11 @@ use utoipa::ToSchema; const HF_API_BASE: &str = "https://huggingface.co/api/models"; const HF_DOWNLOAD_BASE: &str = "https://huggingface.co"; +const LLAMACPP_BACKEND_ID: &str = "llamacpp"; +const MLX_BACKEND_ID: &str = "mlx"; +const GGUF_FORMAT: &str = "gguf"; +const MLX_FORMAT: &str = "mlx-safetensors"; +const MLX_VARIANT_ID: &str = "default"; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct HfModelInfo { @@ -15,6 +27,35 @@ pub struct HfModelInfo { pub model_name: String, pub downloads: u64, pub gguf_files: Vec, + #[serde(default)] + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfModelVariant { + pub variant_id: String, + pub label: String, + pub backend_id: String, + pub format: String, + pub model_id: String, + pub download_id: String, + pub size_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub download_url: Option, + pub description: String, + pub quality_rank: u8, + #[serde(default)] + pub sharded: bool, + #[serde(default = "default_supported")] + pub supported: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub unsupported_reason: Option, +} + +fn default_supported() -> bool { + true } /// A single downloadable GGUF file (used internally and for downloads). @@ -39,6 +80,28 @@ pub struct HfQuantVariant { pub sharded: bool, } +impl HfQuantVariant { + pub fn to_model_variant(&self, repo_id: &str) -> HfModelVariant { + let model_id = model_id_from_repo(repo_id, &self.quantization); + HfModelVariant { + variant_id: self.quantization.clone(), + label: self.quantization.clone(), + backend_id: LLAMACPP_BACKEND_ID.to_string(), + format: GGUF_FORMAT.to_string(), + model_id: model_id.clone(), + download_id: model_id, + size_bytes: self.size_bytes, + filename: Some(self.filename.clone()), + download_url: Some(self.download_url.clone()), + description: self.description.to_string(), + quality_rank: self.quality_rank, + sharded: self.sharded, + supported: true, + unsupported_reason: None, + } + } +} + /// Result of resolving a model spec — may contain multiple shard files. #[derive(Debug, Clone)] pub struct ResolvedModel { @@ -47,6 +110,71 @@ pub struct ResolvedModel { pub mmproj: Option, } +#[derive(Debug, Clone)] +pub enum ResolvedLocalModel { + Gguf { + repo_id: String, + quantization: String, + resolved: ResolvedModel, + local_paths: Vec, + mmproj_path: Option, + storage: LocalModelStorage, + }, + Mlx { + repo_id: String, + variant_id: String, + snapshot_path: std::path::PathBuf, + total_size: u64, + }, +} + +impl ResolvedLocalModel { + pub fn repo_id(&self) -> &str { + match self { + Self::Gguf { repo_id, .. } | Self::Mlx { repo_id, .. } => repo_id, + } + } + + pub fn variant_id(&self) -> &str { + match self { + Self::Gguf { quantization, .. } => quantization, + Self::Mlx { variant_id, .. } => variant_id, + } + } + + pub fn backend_id(&self) -> &'static str { + match self { + Self::Gguf { .. } => "llamacpp", + Self::Mlx { .. } => "mlx", + } + } + + pub fn model_id(&self) -> String { + match self { + Self::Gguf { + repo_id, + quantization, + .. + } => model_id_from_repo(repo_id, quantization), + Self::Mlx { repo_id, .. } => repo_id.clone(), + } + } + + pub fn total_size(&self) -> u64 { + match self { + Self::Gguf { resolved, .. } => resolved.total_size, + Self::Mlx { total_size, .. } => *total_size, + } + } + + pub fn storage(&self) -> LocalModelStorage { + match self { + Self::Gguf { storage, .. } => *storage, + Self::Mlx { .. } => LocalModelStorage::HuggingFaceCache, + } + } +} + #[derive(Debug, Deserialize)] struct HfApiModel { id: Option, @@ -424,6 +552,55 @@ fn group_into_variants(repo_id: &str, files: Vec) -> Vec Result> { + let mut results = Vec::new(); + + if looks_like_repo_id(query) { + if let Some(model) = get_local_model_info_for_repo(query).await? { + results.push(model); + } + } else if let Some(model) = get_exact_name_local_model_info(query).await? { + results.push(model); + } + + let mut gguf_results = search_gguf_models(query, limit).await?; + for model in &mut gguf_results { + let gguf_variants = get_repo_gguf_variants(&model.repo_id) + .await + .unwrap_or_default(); + model.variants = gguf_variants + .iter() + .map(|variant| variant.to_model_variant(&model.repo_id)) + .collect(); + } + + results.extend(gguf_results); + append_optional_mlx_results(&mut results, search_mlx_models(query, limit).await, query); + dedupe_models(&mut results); + results.sort_by(|a, b| { + model_search_rank(query, a) + .cmp(&model_search_rank(query, b)) + .then_with(|| b.downloads.cmp(&a.downloads)) + }); + results.truncate(limit); + Ok(results) +} + +fn append_optional_mlx_results( + results: &mut Vec, + mlx_results: Result>, + query: &str, +) { + match mlx_results { + Ok(models) => results.extend(models), + Err(error) => tracing::warn!( + query, + error = %error, + "Failed to search MLX models; returning non-MLX results" + ), + } +} + pub async fn search_gguf_models(query: &str, limit: usize) -> Result> { let client = reqwest::Client::new(); let token = optional_hf_token(huggingface_auth::resolve_token_async()).await; @@ -481,6 +658,7 @@ pub async fn search_gguf_models(query: &str, limit: usize) -> Result Result<(String, String)> { bail!("Invalid repo_id '{}': expected format 'user/repo'", repo_id); } + if quant.is_empty() { + bail!( + "Invalid model spec '{}': expected format 'user/repo:quantization'", + spec + ); + } + Ok((repo_id.to_string(), quant.to_string())) } @@ -807,6 +992,169 @@ mod tests { fn test_parse_model_spec_invalid() { assert!(parse_model_spec("no-colon").is_err()); assert!(parse_model_spec("noslash:Q4_K_M").is_err()); + assert!(parse_model_spec("owner/repo:").is_err()); + } + + #[test] + fn test_dedupe_models_merges_variants_for_same_repo() { + let repo_id = "mixed/repo".to_string(); + let gguf_variant = HfModelVariant { + variant_id: "Q4_K_M".to_string(), + label: "Q4_K_M".to_string(), + backend_id: LLAMACPP_BACKEND_ID.to_string(), + format: GGUF_FORMAT.to_string(), + model_id: "mixed/repo:Q4_K_M".to_string(), + download_id: "mixed/repo:Q4_K_M".to_string(), + size_bytes: 4, + filename: Some("model-Q4_K_M.gguf".to_string()), + download_url: Some("https://example.test/model-Q4_K_M.gguf".to_string()), + description: "Medium".to_string(), + quality_rank: 45, + sharded: false, + supported: true, + unsupported_reason: None, + }; + let mlx_variant = HfModelVariant { + variant_id: MLX_VARIANT_ID.to_string(), + label: "Default".to_string(), + backend_id: MLX_BACKEND_ID.to_string(), + format: MLX_FORMAT.to_string(), + model_id: repo_id.clone(), + download_id: repo_id.clone(), + size_bytes: 8, + filename: None, + download_url: None, + description: "MLX".to_string(), + quality_rank: 91, + sharded: true, + supported: true, + unsupported_reason: None, + }; + let mut models = vec![ + HfModelInfo { + repo_id: repo_id.clone(), + author: "mixed".to_string(), + model_name: "repo".to_string(), + downloads: 1, + gguf_files: vec![HfGgufFile { + filename: "model-Q4_K_M.gguf".to_string(), + size_bytes: 4, + quantization: "Q4_K_M".to_string(), + download_url: "https://example.test/model-Q4_K_M.gguf".to_string(), + }], + variants: vec![gguf_variant], + }, + HfModelInfo { + repo_id, + author: "mixed".to_string(), + model_name: "repo".to_string(), + downloads: 2, + gguf_files: Vec::new(), + variants: vec![mlx_variant], + }, + ]; + + dedupe_models(&mut models); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].downloads, 2); + assert_eq!(models[0].gguf_files.len(), 1); + assert_eq!(models[0].variants.len(), 2); + assert!(models[0] + .variants + .iter() + .any(|variant| variant.backend_id == LLAMACPP_BACKEND_ID)); + assert!(models[0] + .variants + .iter() + .any(|variant| variant.backend_id == MLX_BACKEND_ID)); + } + + fn sibling(filename: &str) -> RepoSibling { + RepoSibling { + rfilename: filename.to_string(), + size: Some(1), + lfs: None, + } + } + + fn mlx_siblings(tokenizer_files: &[&str]) -> Vec { + let mut siblings = vec![sibling("config.json"), sibling("model.safetensors")]; + siblings.extend(tokenizer_files.iter().map(|filename| sibling(filename))); + siblings + } + + #[test] + fn mlx_compatible_repo_accepts_supported_tokenizer_formats() { + let config = Some(serde_json::json!({ "model_type": "llama" })); + + for tokenizer_files in [ + vec!["tokenizer.json"], + vec!["tokenizer.model"], + vec!["tokenizer.tiktoken"], + vec!["vocab.json", "merges.txt"], + ] { + assert!( + is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)), + "{tokenizer_files:?}" + ); + } + } + + #[test] + fn mlx_compatible_repo_rejects_incomplete_tokenizer_files() { + let config = Some(serde_json::json!({ "model_type": "llama" })); + + for tokenizer_files in [ + vec!["tokenizer_config.json"], + vec!["vocab.json"], + vec!["merges.txt"], + ] { + assert!( + !is_mlx_compatible_repo(&config, &mlx_siblings(&tokenizer_files)), + "{tokenizer_files:?}" + ); + } + } + + fn test_model(repo_id: &str) -> HfModelInfo { + HfModelInfo { + repo_id: repo_id.to_string(), + author: repo_id.split_once('/').unwrap().0.to_string(), + model_name: repo_id.split_once('/').unwrap().1.to_string(), + downloads: 1, + gguf_files: Vec::new(), + variants: Vec::new(), + } + } + + #[test] + fn append_optional_mlx_results_extends_on_success() { + let mut results = vec![test_model("gguf/repo")]; + + append_optional_mlx_results( + &mut results, + Ok(vec![test_model("mlx/repo")]), + "search-query", + ); + + assert_eq!(results.len(), 2); + assert!(results.iter().any(|model| model.repo_id == "gguf/repo")); + assert!(results.iter().any(|model| model.repo_id == "mlx/repo")); + } + + #[test] + fn append_optional_mlx_results_preserves_existing_on_error() { + let mut results = vec![test_model("gguf/repo")]; + + append_optional_mlx_results( + &mut results, + Err(anyhow::anyhow!("hf-hub unavailable")), + "search-query", + ); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].repo_id, "gguf/repo"); } #[test] @@ -859,6 +1207,38 @@ mod tests { assert_eq!(model_stem_from_repo("someone/SomeModel"), "somemodel"); } + #[test] + fn hf_download_progress_init_preserves_cancelled_reservation() { + let model_id = "test-cancelled-hf-progress-init"; + let download_id = format!("{}-model", model_id); + let manager = crate::download_manager::get_download_manager(); + manager.set_progress(crate::download_manager::DownloadProgress { + model_id: download_id.clone(), + status: crate::download_manager::DownloadStatus::Cancelled, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: false, + }); + + HfDownloadProgress::new(model_id.to_string(), 42).init(); + + let progress = manager.get_progress(&download_id).expect("progress"); + assert_eq!( + progress.status, + crate::download_manager::DownloadStatus::Cancelled + ); + assert!(!progress.task_exited); + + manager.update_progress(&download_id, |progress| { + progress.task_exited = true; + }); + manager.clear_completed(&download_id); + } + #[test] fn test_is_model_file() { let stem = "gemma-3-27b-it"; @@ -1040,3 +1420,890 @@ mod tests { assert_eq!(mmproj.filename, "mmproj-F32.gguf"); } } + +async fn hf_client() -> Result { + let mut builder = HFClient::builder().user_agent("goose-ai-agent"); + if let Some(token) = optional_hf_token(huggingface_auth::resolve_token_async()).await { + builder = builder.token(token); + } + builder.build().map_err(Into::into) +} + +fn model_repo(client: &HFClient, repo_id: &str) -> Result> { + let (owner, name) = split_repo_id(repo_id)?; + Ok(client.model(owner, name)) +} + +fn split_repo_id(repo_id: &str) -> Result<(&str, &str)> { + repo_id + .split_once('/') + .ok_or_else(|| anyhow::anyhow!("Invalid repo id '{}': expected owner/name", repo_id)) +} + +async fn search_mlx_models(query: &str, limit: usize) -> Result> { + let mut results = search_mlx_models_with_query(query, limit).await?; + if !query.contains('/') { + results + .extend(search_mlx_models_with_query(&format!("mlx-community/{query}"), limit).await?); + results.extend(search_mlx_models_with_query(&format!("google/{query}"), limit).await?); + } + dedupe_models(&mut results); + results.truncate(limit); + Ok(results) +} + +async fn search_mlx_models_with_query(query: &str, limit: usize) -> Result> { + let client = hf_client().await?; + let stream = client + .list_models() + .search(query.to_string()) + .sort("downloads".to_string()) + .limit(limit.saturating_mul(5).max(limit)) + .send()?; + futures::pin_mut!(stream); + + let mut results = Vec::new(); + while let Some(info) = stream.next().await { + let info = info?; + if results.len() >= limit { + break; + } + if let Some(model) = get_local_model_info_for_repo_with_client(&client, &info.id).await? { + results.push(model); + } + } + + Ok(results) +} + +async fn get_local_model_info_for_repo(repo_id: &str) -> Result> { + let client = hf_client().await?; + get_local_model_info_for_repo_with_client(&client, repo_id).await +} + +async fn get_local_model_info_for_repo_with_client( + client: &HFClient, + repo_id: &str, +) -> Result> { + let repo = model_repo(client, repo_id)?; + let info = repo + .info() + .expand(vec![ + "siblings".to_string(), + "config".to_string(), + "safetensors".to_string(), + ]) + .send() + .await?; + model_info_to_local_model_info(info).await +} + +async fn get_exact_name_local_model_info(model_name: &str) -> Result> { + for owner in ["google", "mlx-community"] { + let repo_id = format!("{owner}/{model_name}"); + if let Ok(Some(model)) = get_local_model_info_for_repo(&repo_id).await { + return Ok(Some(model)); + } + } + Ok(None) +} + +async fn model_info_to_local_model_info(info: ModelInfo) -> Result> { + let repo_id = info.id.clone(); + let mut variants: Vec = get_repo_gguf_variants(&repo_id) + .await + .unwrap_or_default() + .iter() + .map(|variant| variant.to_model_variant(&repo_id)) + .collect(); + variants.extend(mlx_variants_from_model_info(&repo_id, &info)); + + if variants.is_empty() { + return Ok(None); + } + + let author = info + .author + .unwrap_or_else(|| repo_id.split('/').next().unwrap_or_default().to_string()); + let model_name = repo_id + .split('/') + .next_back() + .unwrap_or(&repo_id) + .to_string(); + + Ok(Some(HfModelInfo { + repo_id, + author, + model_name, + downloads: info.downloads.unwrap_or(0), + gguf_files: Vec::new(), + variants, + })) +} + +pub async fn get_repo_local_variants(repo_id: &str) -> Result> { + let mut variants: Vec = get_repo_gguf_variants(repo_id) + .await + .unwrap_or_default() + .iter() + .map(|variant| variant.to_model_variant(repo_id)) + .collect(); + variants.extend(get_repo_mlx_variants(repo_id).await.unwrap_or_default()); + variants.sort_by(|a, b| { + a.backend_id + .cmp(&b.backend_id) + .then_with(|| b.quality_rank.cmp(&a.quality_rank)) + .then_with(|| a.variant_id.cmp(&b.variant_id)) + }); + Ok(variants) +} + +pub async fn get_repo_mlx_variants(repo_id: &str) -> Result> { + let client = hf_client().await?; + let repo = model_repo(&client, repo_id)?; + let info = repo + .info() + .expand(vec![ + "siblings".to_string(), + "config".to_string(), + "safetensors".to_string(), + ]) + .send() + .await?; + Ok(mlx_variants_from_model_info(repo_id, &info)) +} + +fn mlx_variants_from_model_info(repo_id: &str, info: &ModelInfo) -> Vec { + let siblings = info.siblings.as_deref().unwrap_or(&[]); + + if !is_mlx_compatible_repo(&info.config, siblings) { + return Vec::new(); + } + + let size_bytes = mlx_download_filenames(siblings) + .into_iter() + .filter_map(|filename| { + siblings + .iter() + .find(|s| s.rfilename == filename) + .and_then(|s| s.size) + }) + .sum(); + let variant_id = mlx_variant_id(repo_id, &info.config); + + vec![HfModelVariant { + variant_id: variant_id.clone(), + label: mlx_variant_label(&variant_id), + backend_id: MLX_BACKEND_ID.to_string(), + format: MLX_FORMAT.to_string(), + model_id: repo_id.to_string(), + download_id: repo_id.to_string(), + size_bytes, + filename: None, + download_url: None, + description: mlx_variant_description(&info.config), + quality_rank: 91, + sharded: siblings + .iter() + .filter(|s| s.rfilename.ends_with(".safetensors")) + .count() + > 1, + supported: is_mlx_runtime_supported(&info.config) + && cfg!(target_os = "macos") + && cfg!(feature = "mlx"), + unsupported_reason: mlx_unsupported_reason(&info.config), + }] +} + +fn is_mlx_compatible_repo(config: &Option, siblings: &[RepoSibling]) -> bool { + let has_config = siblings.iter().any(|s| s.rfilename == "config.json"); + let has_tokenizer = has_mlx_tokenizer(siblings); + let has_safetensors = siblings + .iter() + .any(|s| s.rfilename.ends_with(".safetensors")); + + has_config && has_tokenizer && has_safetensors && mlx_model_type(config).is_some() +} + +fn has_mlx_tokenizer(siblings: &[RepoSibling]) -> bool { + let has_file = |filename: &str| siblings.iter().any(|s| s.rfilename == filename); + siblings + .iter() + .any(|s| is_standalone_mlx_tokenizer_file(&s.rfilename)) + || (has_file("vocab.json") && has_file("merges.txt")) +} + +fn is_standalone_mlx_tokenizer_file(filename: &str) -> bool { + filename == "tokenizer.json" || filename.ends_with(".model") || filename.ends_with(".tiktoken") +} + +fn mlx_model_type(config: &Option) -> Option<&str> { + config + .as_ref() + .and_then(|config| config.get("model_type")) + .and_then(|value| value.as_str()) +} + +fn is_mlx_runtime_supported_model_type(model_type: &str) -> bool { + matches!(model_type, "gemma4" | "gemma4_text" | "llama" | "qwen3") +} + +fn is_mlx_moe_model(config: &Option) -> bool { + config + .as_ref() + .and_then(|config| config.get("text_config")) + .and_then(|text_config| text_config.get("enable_moe_block")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + +fn is_mlx_runtime_supported(config: &Option) -> bool { + mlx_model_type(config).is_some_and(is_mlx_runtime_supported_model_type) + && !is_mlx_moe_model(config) +} + +fn mlx_unsupported_reason(config: &Option) -> Option { + if !cfg!(target_os = "macos") { + return Some("MLX requires macOS".to_string()); + } + if !cfg!(feature = "mlx") { + return Some("MLX support was not compiled in".to_string()); + } + + let model_type = mlx_model_type(config)?; + if !is_mlx_runtime_supported_model_type(model_type) { + return Some(format!( + "MLX backend does not support '{}' models yet", + model_type + )); + } + if is_mlx_moe_model(config) { + return Some("MLX backend does not support Gemma 4 MoE models yet".to_string()); + } + None +} + +fn mlx_variant_description(config: &Option) -> String { + match mlx_unsupported_reason(config) { + None => "MLX safetensors snapshot".to_string(), + Some(reason) => format!("MLX safetensors snapshot ({reason})"), + } +} + +fn mlx_download_filenames(siblings: &[RepoSibling]) -> Vec { + siblings + .iter() + .filter(|s| should_download_for_mlx(&s.rfilename)) + .map(|s| s.rfilename.clone()) + .collect() +} + +fn looks_like_repo_id(query: &str) -> bool { + let Some((owner, repo)) = query.split_once('/') else { + return false; + }; + !owner.is_empty() && !repo.is_empty() && !repo.contains('/') +} + +fn model_search_rank(query: &str, model: &HfModelInfo) -> u8 { + let query = query.to_lowercase(); + let repo_id = model.repo_id.to_lowercase(); + let model_name = model.model_name.to_lowercase(); + + if repo_id == query { + 0 + } else if model_name == query { + 1 + } else if repo_id.ends_with(&format!("/{query}")) { + 2 + } else if repo_id.contains(&query) { + 3 + } else { + 4 + } +} + +fn dedupe_models(models: &mut Vec) { + let mut merged: Vec = Vec::with_capacity(models.len()); + for model in std::mem::take(models) { + if let Some(existing) = merged + .iter_mut() + .find(|existing| existing.repo_id == model.repo_id) + { + merge_model_info(existing, model); + } else { + merged.push(model); + } + } + *models = merged; +} + +fn merge_model_info(existing: &mut HfModelInfo, duplicate: HfModelInfo) { + existing.downloads = existing.downloads.max(duplicate.downloads); + + let mut filenames: std::collections::HashSet = existing + .gguf_files + .iter() + .map(|file| file.filename.clone()) + .collect(); + existing.gguf_files.extend( + duplicate + .gguf_files + .into_iter() + .filter(|file| filenames.insert(file.filename.clone())), + ); + + let mut variant_keys: std::collections::HashSet<(String, String)> = existing + .variants + .iter() + .map(|variant| (variant.backend_id.clone(), variant.variant_id.clone())) + .collect(); + existing + .variants + .extend(duplicate.variants.into_iter().filter(|variant| { + variant_keys.insert((variant.backend_id.clone(), variant.variant_id.clone())) + })); +} + +fn should_download_for_mlx(filename: &str) -> bool { + filename.ends_with(".safetensors") + || filename == "config.json" + || is_standalone_mlx_tokenizer_file(filename) + || filename == "tokenizer_config.json" + || filename == "generation_config.json" + || filename == "special_tokens_map.json" + || filename == "model.safetensors.index.json" + || filename == "vocab.json" + || filename == "merges.txt" + || filename == "added_tokens.json" +} + +fn mlx_variant_id(repo_id: &str, config: &Option) -> String { + let repo_lower = repo_id.to_lowercase(); + for marker in ["bf16", "f16", "fp16", "f32", "fp32", "4bit", "8bit"] { + if repo_lower.contains(marker) { + return marker.to_string(); + } + } + config + .as_ref() + .and_then(|config| config.get("torch_dtype")) + .and_then(|value| value.as_str()) + .map(|dtype| dtype.replace("float", "f")) + .unwrap_or_else(|| MLX_VARIANT_ID.to_string()) +} + +fn mlx_variant_label(variant_id: &str) -> String { + if variant_id == MLX_VARIANT_ID { + "MLX".to_string() + } else { + format!("MLX {}", variant_id.to_uppercase()) + } +} + +pub async fn resolve_local_model_selection( + repo_id: &str, + backend_id: &str, + variant_id: Option<&str>, +) -> Result { + match backend_id { + MLX_BACKEND_ID => resolve_mlx_model(repo_id, variant_id.unwrap_or(MLX_VARIANT_ID)).await, + LLAMACPP_BACKEND_ID => { + let quantization = variant_id.ok_or_else(|| { + anyhow::anyhow!("llama.cpp model '{}' is missing a quantization", repo_id) + })?; + resolve_gguf_model(repo_id, quantization).await + } + _ => bail!("Unknown local inference backend '{}'", backend_id), + } +} + +fn snapshot_root_for_file( + path: &std::path::Path, + repo_filename: &str, +) -> Option { + let mut root = path.to_path_buf(); + for _ in 0..repo_filename.split('/').count() { + root.pop(); + } + Some(root) +} + +async fn resolve_gguf_model(repo_id: &str, quantization: &str) -> Result { + let spec = format!("{}:{}", repo_id, quantization); + let (_repo, resolved) = resolve_model_spec_full(&spec).await?; + let (local_paths, mmproj_path) = + download_gguf_to_hf_cache(repo_id, quantization, &resolved).await?; + Ok(ResolvedLocalModel::Gguf { + repo_id: repo_id.to_string(), + quantization: quantization.to_string(), + resolved, + local_paths, + mmproj_path, + storage: LocalModelStorage::HuggingFaceCache, + }) +} + +async fn download_gguf_to_hf_cache( + repo_id: &str, + quantization: &str, + resolved: &ResolvedModel, +) -> Result<(Vec, Option)> { + let (owner, name) = split_repo_id(repo_id)?; + let model_id = model_id_from_repo(repo_id, quantization); + let total_size = resolved + .files + .iter() + .chain(resolved.mmproj.iter()) + .map(|file| file.size_bytes) + .sum(); + let progress = HfDownloadProgress::new(model_id, total_size); + progress.init(); + let client = hf_client().await?; + let repo = client.model(owner.to_string(), name.to_string()); + let mut paths = Vec::with_capacity(resolved.files.len()); + for file in &resolved.files { + let path = match repo + .download_file() + .filename(file.filename.clone()) + .progress(progress.clone()) + .send() + .await + .map_err(anyhow::Error::from) + { + Ok(path) => path, + Err(error) => { + progress.fail(&error); + return Err(error); + } + }; + progress.finish_file(file.size_bytes); + paths.push(path); + } + + let mmproj_path = if let Some(mmproj) = &resolved.mmproj { + let path = match repo + .download_file() + .filename(mmproj.filename.clone()) + .progress(progress.clone()) + .send() + .await + .map_err(anyhow::Error::from) + { + Ok(path) => path, + Err(error) => { + progress.fail(&error); + return Err(error); + } + }; + progress.finish_file(mmproj.size_bytes); + Some(path) + } else { + None + }; + + progress.complete(); + Ok((paths, mmproj_path)) +} + +pub async fn resolve_local_model_spec(spec: &str) -> Result { + match parse_model_spec(spec) { + Ok((repo_id, quantization)) => return resolve_gguf_model(&repo_id, &quantization).await, + Err(error) if spec.contains(':') => return Err(error), + Err(_) => {} + } + + if looks_like_repo_id(spec) { + let variants = get_repo_local_variants(spec).await?; + let mlx_variants: Vec<_> = variants + .iter() + .filter(|variant| variant.backend_id == MLX_BACKEND_ID) + .collect(); + if mlx_variants.len() == 1 + && !variants + .iter() + .any(|variant| variant.backend_id == LLAMACPP_BACKEND_ID) + { + return resolve_mlx_model(spec, &mlx_variants[0].variant_id).await; + } + bail!( + "Model spec '{}' is ambiguous; choose one of: {}", + spec, + variants + .iter() + .map(|variant| variant.download_id.as_str()) + .collect::>() + .join(", ") + ); + } + + let (repo_id, quantization) = parse_model_spec(spec)?; + resolve_gguf_model(&repo_id, &quantization).await +} + +async fn resolve_mlx_model(repo_id: &str, variant_id: &str) -> Result { + let variants = get_repo_mlx_variants(repo_id).await?; + if !variants + .iter() + .any(|variant| variant.variant_id == variant_id) + { + bail!("No MLX variant '{}' found in {}", variant_id, repo_id); + } + let (owner, name) = split_repo_id(repo_id)?; + let client = hf_client().await?; + let repo = client.model(owner.to_string(), name.to_string()); + let info = repo + .info() + .expand(vec!["siblings".to_string()]) + .send() + .await?; + let siblings = info.siblings.as_deref().unwrap_or(&[]); + let filenames = mlx_download_filenames(siblings); + let total_size = filenames + .iter() + .filter_map(|filename| { + siblings + .iter() + .find(|s| s.rfilename == *filename) + .and_then(|s| s.size) + }) + .sum(); + let progress = HfDownloadProgress::new(repo_id.to_string(), total_size); + progress.init(); + let mut snapshot_path = None; + for filename in filenames { + let file_size = siblings + .iter() + .find(|s| s.rfilename == filename) + .and_then(|s| s.size) + .unwrap_or(0); + let path = match repo + .download_file() + .filename(filename.clone()) + .progress(progress.clone()) + .send() + .await + .map_err(anyhow::Error::from) + { + Ok(path) => path, + Err(error) => { + progress.fail(&error); + return Err(error); + } + }; + if snapshot_path.is_none() { + snapshot_path = snapshot_root_for_file(&path, &filename); + } + progress.finish_file(file_size); + } + progress.complete(); + let snapshot_path = snapshot_path + .ok_or_else(|| anyhow::anyhow!("MLX model {} has no downloadable files", repo_id))?; + let total_size = if total_size > 0 { + total_size + } else { + dir_size(&snapshot_path) + }; + Ok(ResolvedLocalModel::Mlx { + repo_id: repo_id.to_string(), + variant_id: variant_id.to_string(), + snapshot_path, + total_size, + }) +} + +#[derive(Clone)] +struct HfDownloadProgress { + model_id: String, + total_bytes: u64, + completed_bytes: Arc>, + state: Arc>, +} + +#[derive(Default)] +struct HfDownloadState { + bytes_downloaded: u64, + current_file_total_bytes: u64, + speed_bps: Option, +} + +impl HfDownloadProgress { + fn new(model_id: String, total_bytes: u64) -> Self { + Self { + model_id, + total_bytes, + completed_bytes: Arc::new(Mutex::new(0)), + state: Arc::new(Mutex::new(HfDownloadState::default())), + } + } + + fn init(&self) { + let manager = crate::download_manager::get_download_manager(); + let download_id = format!("{}-model", self.model_id); + if manager.get_progress(&download_id).is_some() { + manager.update_progress(&download_id, |progress| { + if progress.status != crate::download_manager::DownloadStatus::Cancelled { + progress.status = crate::download_manager::DownloadStatus::Downloading; + progress.bytes_downloaded = 0; + progress.total_bytes = self.total_bytes; + progress.progress_percent = 0.0; + progress.speed_bps = None; + progress.eta_seconds = None; + progress.error = None; + progress.task_exited = false; + } + }); + } else { + manager.set_progress(crate::download_manager::DownloadProgress { + model_id: download_id, + status: crate::download_manager::DownloadStatus::Downloading, + bytes_downloaded: 0, + total_bytes: self.total_bytes, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: false, + }); + } + } + + fn is_cancelled(&self) -> bool { + crate::download_manager::get_download_manager() + .get_progress(&format!("{}-model", self.model_id)) + .is_some_and(|progress| { + progress.status == crate::download_manager::DownloadStatus::Cancelled + }) + } + + fn complete(&self) { + crate::download_manager::get_download_manager().update_progress( + &format!("{}-model", self.model_id), + |progress| { + if progress.status != crate::download_manager::DownloadStatus::Cancelled { + progress.status = crate::download_manager::DownloadStatus::Completed; + progress.progress_percent = 100.0; + } + progress.task_exited = true; + }, + ); + } + + fn fail(&self, error: impl ToString) { + crate::download_manager::get_download_manager().update_progress( + &format!("{}-model", self.model_id), + |progress| { + if progress.status != crate::download_manager::DownloadStatus::Cancelled { + progress.status = crate::download_manager::DownloadStatus::Failed; + progress.error = Some(error.to_string()); + } + progress.task_exited = true; + }, + ); + } + + fn finish_file(&self, size_bytes: u64) { + if let Ok(mut completed_bytes) = self.completed_bytes.lock() { + *completed_bytes = completed_bytes.saturating_add(size_bytes); + } + if let Ok(mut state) = self.state.lock() { + state.bytes_downloaded = 0; + state.current_file_total_bytes = 0; + } + self.update_progress_from_state(); + } + + fn update_progress_from_state(&self) { + let completed_bytes = self.completed_bytes.lock().map(|value| *value).unwrap_or(0); + if let Ok(state) = self.state.lock() { + let bytes_downloaded = completed_bytes.saturating_add(state.bytes_downloaded); + update_download_manager_progress( + &self.model_id, + bytes_downloaded.min(self.total_bytes), + self.total_bytes.max(state.current_file_total_bytes), + state.speed_bps, + ); + } + } +} + +impl ProgressHandler for HfDownloadProgress { + fn on_progress(&self, event: &ProgressEvent) { + let ProgressEvent::Download(event) = event else { + return; + }; + match event { + DownloadEvent::Start { total_bytes, .. } => { + if let Ok(mut state) = self.state.lock() { + state.current_file_total_bytes = *total_bytes; + } + self.update_progress_from_state(); + } + DownloadEvent::Progress { files } => { + if self.is_cancelled() { + return; + } + let bytes_downloaded = files + .iter() + .map(|file| { + if file.status == FileStatus::Complete { + file.total_bytes + } else { + file.bytes_completed + } + }) + .max() + .unwrap_or(0); + if let Ok(mut state) = self.state.lock() { + state.bytes_downloaded = state.bytes_downloaded.max(bytes_downloaded); + } + self.update_progress_from_state(); + } + DownloadEvent::AggregateProgress { + bytes_completed, + total_bytes, + bytes_per_sec, + } => { + if self.is_cancelled() { + return; + } + if let Ok(mut state) = self.state.lock() { + state.bytes_downloaded = *bytes_completed; + state.current_file_total_bytes = + (*total_bytes).max(state.current_file_total_bytes); + state.speed_bps = bytes_per_sec.map(|speed| speed as u64); + } + self.update_progress_from_state(); + } + DownloadEvent::Complete => {} + } + } +} + +pub fn register_resolved_model(resolved: ResolvedLocalModel, source: &str) -> Result { + let model_id = resolved.model_id(); + let repo_id = resolved.repo_id().to_string(); + let variant_id = resolved.variant_id().to_string(); + let backend_id = resolved.backend_id().to_string(); + let storage = resolved.storage(); + + let entry = match resolved { + ResolvedLocalModel::Gguf { + resolved, + local_paths, + mmproj_path, + .. + } => { + let first_file = &resolved.files[0]; + let first_local_path = local_paths + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("Resolved GGUF model has no local files"))?; + let shard_files: Vec = resolved + .files + .iter() + .skip(1) + .zip(local_paths.iter().skip(1)) + .map(|(file, local_path)| ShardFile { + filename: file.filename.clone(), + local_path: local_path.clone(), + source_url: file.download_url.clone(), + size_bytes: file.size_bytes, + }) + .collect(); + let settings = super::local_model_registry::default_settings_for_model(&model_id); + super::local_model_registry::LocalModelEntry { + id: model_id.clone(), + repo_id, + filename: first_file.filename.clone(), + quantization: variant_id, + local_path: first_local_path, + source_url: first_file.download_url.clone(), + backend_id: settings.backend_id.clone(), + storage, + settings, + size_bytes: resolved.total_size, + mmproj_path, + mmproj_source_url: resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj.download_url.clone()), + mmproj_size_bytes: resolved + .mmproj + .as_ref() + .map(|mmproj| mmproj.size_bytes) + .unwrap_or(0), + mmproj_checked: true, + shard_files, + } + } + ResolvedLocalModel::Mlx { + snapshot_path, + total_size, + .. + } => { + let mut settings = super::local_model_registry::default_settings_for_model(&model_id); + settings.backend_id = Some(backend_id.clone()); + super::local_model_registry::LocalModelEntry { + id: model_id.clone(), + repo_id, + filename: variant_id.clone(), + quantization: variant_id, + local_path: snapshot_path, + source_url: source.to_string(), + backend_id: Some(backend_id), + storage, + settings, + size_bytes: total_size, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: true, + shard_files: vec![], + } + } + }; + + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; + registry.add_model(entry)?; + Ok(model_id) +} + +fn update_download_manager_progress( + model_id: &str, + bytes_downloaded: u64, + total_bytes: u64, + speed_bps: Option, +) { + crate::download_manager::get_download_manager().update_progress( + &format!("{}-model", model_id), + |progress| { + if progress.status == crate::download_manager::DownloadStatus::Cancelled { + return; + } + progress.bytes_downloaded = bytes_downloaded; + progress.total_bytes = total_bytes; + progress.progress_percent = if total_bytes > 0 { + (bytes_downloaded as f64 / total_bytes as f64 * 100.0) as f32 + } else { + 0.0 + }; + progress.speed_bps = speed_bps; + }, + ); +} + +fn dir_size(path: &std::path::Path) -> u64 { + if path.is_file() { + return std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + } + let mut total = 0; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + total += dir_size(&entry.path()); + } + } + total +} diff --git a/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs b/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs index 5725bff18e67..166f6476c913 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs +++ b/crates/goose/src/providers/local_inference/llamacpp/inference_engine.rs @@ -1,8 +1,8 @@ use crate::providers::local_inference::backend::LocalInferenceBackend; use crate::providers::local_inference::local_model_registry::ModelSettings; use crate::providers::local_inference::multimodal::ExtractedImage; -use crate::providers::utils::RequestLog; use goose_providers::errors::ProviderError; +use goose_providers::request_log::{LoggerHandleExt, RequestLogHandle}; use goose_providers::thinking::{FilterOut, ThinkFilter}; use llama_cpp_2::context::params::LlamaContextParams; use llama_cpp_2::llama_batch::LlamaBatch; @@ -24,7 +24,7 @@ pub(super) struct GenerationContext<'a> { pub model_name: String, pub message_id: &'a str, pub tx: &'a StreamSender, - pub log: &'a mut RequestLog, + pub log: &'a mut Option>, pub images: &'a [ExtractedImage], } diff --git a/crates/goose/src/providers/local_inference/llamacpp/mod.rs b/crates/goose/src/providers/local_inference/llamacpp/mod.rs index 218745f477bb..9a77d181a1e1 100644 --- a/crates/goose/src/providers/local_inference/llamacpp/mod.rs +++ b/crates/goose/src/providers/local_inference/llamacpp/mod.rs @@ -449,6 +449,7 @@ impl LocalInferenceBackend for LlamaCppBackend { } else { (Vec::new(), None) }; + let has_media = !images.is_empty(); let effective_messages = vision_messages.as_deref().unwrap_or(request.messages); let code_mode_enabled = request.tools.iter().any(|t| t.name == CODE_EXECUTION_TOOL); @@ -469,7 +470,11 @@ impl LocalInferenceBackend for LlamaCppBackend { if matches!(request.settings.tool_calling, ToolCallingMode::Auto) && has_native_tool_payload { - let messages_json = build_openai_messages_json(request.system, effective_messages); + let messages_json = build_openai_messages_json( + request.system, + effective_messages, + has_media.then_some(marker), + ); if let Some(template) = loaded.templates.tool_use.as_ref() { supports_native_tool_calling( loaded, @@ -506,9 +511,17 @@ impl LocalInferenceBackend for LlamaCppBackend { }; let oai_messages_json = if use_emulator { - build_openai_text_messages_json(&system_prompt, effective_messages) + build_openai_text_messages_json( + &system_prompt, + effective_messages, + has_media.then_some(marker), + ) } else { - build_openai_messages_json(&system_prompt, effective_messages) + build_openai_messages_json( + &system_prompt, + effective_messages, + has_media.then_some(marker), + ) }; if !images.is_empty() && loaded.mtmd_ctx.is_none() { diff --git a/crates/goose/src/providers/local_inference/local_model_registry.rs b/crates/goose/src/providers/local_inference/local_model_registry.rs index abe9ee635568..ed5a7a008d02 100644 --- a/crates/goose/src/providers/local_inference/local_model_registry.rs +++ b/crates/goose/src/providers/local_inference/local_model_registry.rs @@ -61,8 +61,13 @@ pub enum ChatTemplate { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ModelSettings { + /// Backend implementation to use for this model. Defaults to llama.cpp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, pub context_size: Option, pub max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_model: Option, #[serde(default)] pub sampling: SamplingConfig, #[serde(default = "default_repeat_penalty")] @@ -117,8 +122,10 @@ fn default_repeat_last_n() -> i32 { impl Default for ModelSettings { fn default() -> Self { Self { + backend_id: None, context_size: None, max_output_tokens: None, + draft_model: None, sampling: SamplingConfig::default(), repeat_penalty: 1.0, repeat_last_n: 64, @@ -139,38 +146,106 @@ impl Default for ModelSettings { } } +/// HuggingFace repo + filename for multimodal projection weights (vision encoder). +pub struct MmprojSpec { + pub repo: &'static str, + pub filename: &'static str, +} + +impl MmprojSpec { + /// Local path for this mmproj, namespaced by repo to avoid collisions + /// between different models that use the same filename. + pub fn local_path(&self) -> std::path::PathBuf { + let repo_name = self.repo.split('/').next_back().unwrap_or(self.repo); + Paths::in_data_dir("models") + .join(repo_name) + .join(self.filename) + } +} + pub struct FeaturedModel { /// HuggingFace spec in "author/repo-GGUF:quantization" format. pub spec: &'static str, + /// Whether this model's GGUF template supports native tool calling via llama.cpp. + pub native_tool_calling: bool, + /// Multimodal projection weights spec. None for text-only models. + pub mmproj: Option, } pub const FEATURED_MODELS: &[FeaturedModel] = &[ FeaturedModel { spec: "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "bartowski/Hermes-2-Pro-Mistral-7B-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "bartowski/Mistral-Small-24B-Instruct-2501-GGUF:Q4_K_M", + native_tool_calling: false, + mmproj: None, }, FeaturedModel { spec: "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M", + native_tool_calling: true, + mmproj: Some(MmprojSpec { + repo: "unsloth/gemma-4-E4B-it-GGUF", + filename: "mmproj-BF16.gguf", + }), }, FeaturedModel { spec: "unsloth/gemma-4-26B-A4B-it-GGUF:Q4_K_M", + native_tool_calling: true, + mmproj: Some(MmprojSpec { + repo: "unsloth/gemma-4-26B-A4B-it-GGUF", + filename: "mmproj-BF16.gguf", + }), }, ]; -pub fn default_settings_for_model(_model_id: &str) -> ModelSettings { +pub fn default_settings_for_model(model_id: &str) -> ModelSettings { + use super::hf_models::parse_model_spec; + let model_repo = model_id.split(':').next().unwrap_or(model_id); + let featured = FEATURED_MODELS.iter().find(|m| { + if let Ok((repo_id, _quant)) = parse_model_spec(m.spec) { + repo_id == model_repo + } else { + false + } + }); ModelSettings { + tool_calling: if featured.is_some_and(|m| m.native_tool_calling) { + ToolCallingMode::ForceNative + } else { + ToolCallingMode::Auto + }, + vision_capable: featured.is_some_and(|m| m.mmproj.is_some()), ..ModelSettings::default() } } +/// Look up the `MmprojSpec` for a featured model by its model ID. +pub fn featured_mmproj_spec(model_id: &str) -> Option<&'static MmprojSpec> { + use super::hf_models::parse_model_spec; + let model_repo = model_id.split(':').next().unwrap_or(model_id); + FEATURED_MODELS.iter().find_map(|m| { + if let Ok((repo_id, _quant)) = parse_model_spec(m.spec) { + if repo_id == model_repo { + return m.mmproj.as_ref(); + } + } + None + }) +} + /// Local path for an mmproj file, namespaced by repo to avoid collisions /// between different models that use the same filename. pub fn mmproj_local_path(repo_id: &str, filename: &str) -> PathBuf { @@ -199,6 +274,15 @@ pub fn get_registry() -> &'static Mutex { }) } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LocalModelStorage { + #[default] + GooseManaged, + HuggingFaceCache, + ManualPath, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShardFile { pub filename: String, @@ -215,6 +299,14 @@ pub struct LocalModelEntry { pub quantization: String, pub local_path: PathBuf, pub source_url: String, + /// Backend implementation to use for this model. Defaults to llama.cpp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, + /// Where the model artifacts are stored. Goose keeps this registry as a + /// lightweight settings overlay; Hugging Face cache entries are not deleted + /// by Goose because they may be shared with other tools. + #[serde(default)] + pub storage: LocalModelStorage, #[serde(default)] pub settings: ModelSettings, #[serde(default)] @@ -235,6 +327,35 @@ pub struct LocalModelEntry { } impl LocalModelEntry { + /// Populate mmproj metadata and vision settings from the featured model + /// table if this model's repo has a known vision encoder. + pub fn enrich_with_featured_mmproj(&mut self) { + if let Some(mmproj) = featured_mmproj_spec(&self.id) { + let existing_path = self + .mmproj_path + .as_ref() + .filter(|path| path.exists()) + .cloned(); + let preserve_existing_path = existing_path.is_some(); + let path = existing_path.unwrap_or_else(|| mmproj.local_path()); + if self.mmproj_path.as_ref() != Some(&path) { + self.mmproj_path = Some(path.clone()); + } + if !preserve_existing_path || self.mmproj_source_url.is_none() { + self.mmproj_source_url = Some(format!( + "https://huggingface.co/{}/resolve/main/{}", + mmproj.repo, mmproj.filename + )); + } + self.mmproj_size_bytes = path_size(&path); + self.settings.vision_capable = true; + self.settings.mmproj_size_bytes = self.mmproj_size_bytes; + if matches!(self.settings.tool_calling, ToolCallingMode::Auto) { + self.settings.tool_calling = ToolCallingMode::ForceNative; + } + } + } + pub fn refresh_mmproj_metadata(&mut self) { self.settings.vision_capable = self.mmproj_path.is_some(); if let Some(path) = &self.mmproj_path { @@ -256,17 +377,19 @@ impl LocalModelEntry { self.local_path.exists() && self.shard_files.iter().all(|s| s.local_path.exists()) } - /// Returns all GGUF model file paths (primary + shards). + /// Returns all local paths owned by Goose for this model. /// Does NOT include mmproj — that has separate shared-ownership deletion logic. pub fn all_local_paths(&self) -> impl Iterator { + let goose_managed = self.storage == LocalModelStorage::GooseManaged; std::iter::once(self.local_path.as_path()) .chain(self.shard_files.iter().map(|s| s.local_path.as_path())) + .filter(move |path| goose_managed && !path.is_dir()) } pub fn is_downloading(&self) -> bool { let download_id = format!("{}-model", self.id); let manager = get_download_manager(); - manager.get_progress(&download_id).is_some() + manager.is_downloading(&download_id) } pub fn download_status(&self) -> ModelDownloadStatus { @@ -328,9 +451,7 @@ impl LocalModelEntry { if self.size_bytes > 0 { return self.size_bytes; } - std::fs::metadata(&self.local_path) - .map(|m| m.len()) - .unwrap_or(0) + path_size(&self.local_path) } } @@ -397,7 +518,7 @@ impl LocalModelRegistry { for mut entry in featured_entries { if !self.models.iter().any(|m| m.id == entry.id) { - entry.refresh_mmproj_metadata(); + entry.enrich_with_featured_mmproj(); self.models.push(entry); changed = true; } @@ -416,6 +537,7 @@ impl LocalModelRegistry { } pub fn add_model(&mut self, mut entry: LocalModelEntry) -> Result<()> { + entry.enrich_with_featured_mmproj(); entry.refresh_mmproj_metadata(); if let Some(existing) = self.models.iter_mut().find(|m| m.id == entry.id) { *existing = entry; @@ -430,6 +552,42 @@ impl LocalModelRegistry { self.save() } + pub fn delete_model(&mut self, id: &str) -> Result<()> { + let plan = self.deletion_plan(id)?; + delete_model_artifacts(&plan)?; + + if is_featured_model(id) { + if let Some(entry) = self.models.iter_mut().find(|m| m.id == id) { + entry.local_path = Paths::in_data_dir("models").join(&entry.filename); + entry.storage = LocalModelStorage::GooseManaged; + entry.size_bytes = 0; + entry.shard_files.clear(); + } + self.save() + } else { + self.remove_model(id) + } + } + + fn deletion_plan(&self, id: &str) -> Result { + let entry = self + .get_model(id) + .ok_or_else(|| anyhow::anyhow!("Model not found: {}", id))?; + let mmproj_path = entry.mmproj_path.clone(); + let other_uses_mmproj = mmproj_path.as_ref().is_some_and(|target| { + self.models + .iter() + .any(|m| m.id != id && m.is_downloaded() && m.mmproj_path.as_ref() == Some(target)) + }); + + Ok(ModelDeletionPlan { + all_paths: entry.all_local_paths().map(|p| p.to_path_buf()).collect(), + primary_path: entry.local_path.clone(), + mmproj_path, + delete_mmproj: entry.storage == LocalModelStorage::GooseManaged && !other_uses_mmproj, + }) + } + pub fn get_model(&self, id: &str) -> Option<&LocalModelEntry> { self.models.iter().find(|m| m.id == id) } @@ -461,7 +619,142 @@ impl LocalModelRegistry { } } +struct ModelDeletionPlan { + all_paths: Vec, + primary_path: PathBuf, + mmproj_path: Option, + delete_mmproj: bool, +} + +fn delete_model_artifacts(plan: &ModelDeletionPlan) -> Result<()> { + for path in &plan.all_paths { + if path.exists() { + std::fs::remove_file(path)?; + } + } + + if !plan.all_paths.is_empty() { + if let Some(parent) = plan.primary_path.parent() { + let models_dir = Paths::in_data_dir("models"); + if parent != models_dir { + let _ = std::fs::remove_dir(parent); + } + } + } + + if plan.delete_mmproj { + if let Some(mmproj) = &plan.mmproj_path { + if mmproj.exists() { + std::fs::remove_file(mmproj)?; + } + } + } + + Ok(()) +} + /// Generate a unique ID for a model from its repo_id and quantization. +fn path_size(path: &std::path::Path) -> u64 { + if path.is_file() { + return std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + } + let mut total = 0; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + total += path_size(&entry.path()); + } + } + total +} + pub fn model_id_from_repo(repo_id: &str, quantization: &str) -> String { format!("{}:{}", repo_id, quantization) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::download_manager::DownloadProgress; + + fn test_entry(id: &str) -> LocalModelEntry { + LocalModelEntry { + id: id.to_string(), + repo_id: "test/repo".to_string(), + filename: "model.gguf".to_string(), + quantization: "Q4_K_M".to_string(), + local_path: PathBuf::from(format!("/tmp/{id}.gguf")), + source_url: "https://example.test/model.gguf".to_string(), + backend_id: None, + storage: LocalModelStorage::GooseManaged, + settings: ModelSettings::default(), + size_bytes: 0, + mmproj_path: None, + mmproj_source_url: None, + mmproj_size_bytes: 0, + mmproj_checked: false, + shard_files: vec![], + } + } + + fn set_progress(entry: &LocalModelEntry, status: DownloadStatus) { + get_download_manager().set_progress(DownloadProgress { + model_id: format!("{}-model", entry.id), + status, + bytes_downloaded: 0, + total_bytes: 0, + progress_percent: 0.0, + speed_bps: None, + eta_seconds: None, + error: None, + task_exited: true, + }); + } + + #[test] + fn is_downloading_only_for_active_progress() { + let entry = test_entry("test-is-downloading-only-active-progress"); + let download_id = format!("{}-model", entry.id); + + set_progress(&entry, DownloadStatus::Downloading); + assert!(entry.is_downloading()); + + set_progress(&entry, DownloadStatus::Failed); + assert!(!entry.is_downloading()); + + set_progress(&entry, DownloadStatus::Cancelled); + assert!(!entry.is_downloading()); + + get_download_manager().clear_completed(&download_id); + } + + #[test] + fn enrich_with_featured_mmproj_preserves_existing_downloaded_path() { + let existing_path = std::env::temp_dir().join(format!( + "goose-mmproj-preserve-{}-{}.gguf", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&existing_path, b"mmproj").unwrap(); + + let mut entry = test_entry("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"); + entry.mmproj_path = Some(existing_path.clone()); + entry.mmproj_source_url = Some("https://example.test/mmproj.gguf".to_string()); + + entry.enrich_with_featured_mmproj(); + + assert_eq!(entry.mmproj_path.as_ref(), Some(&existing_path)); + assert_eq!( + entry.mmproj_source_url.as_deref(), + Some("https://example.test/mmproj.gguf") + ); + assert_eq!(entry.mmproj_size_bytes, 6); + assert!(entry.settings.vision_capable); + assert_eq!(entry.settings.mmproj_size_bytes, 6); + assert_eq!(entry.settings.tool_calling, ToolCallingMode::ForceNative); + + let _ = std::fs::remove_file(existing_path); + } +} diff --git a/crates/goose/src/providers/local_inference/mlx.rs b/crates/goose/src/providers/local_inference/mlx.rs new file mode 100644 index 000000000000..0051e5e91406 --- /dev/null +++ b/crates/goose/src/providers/local_inference/mlx.rs @@ -0,0 +1,607 @@ +#[cfg(feature = "mlx")] +mod imp { + use std::any::Any; + use std::path::{Path, PathBuf}; + + use mlx_lm::cache::ConcatKeyValueCache; + use mlx_lm::gemma4_mtp::generate_gemma4_mtp; + use mlx_lm::models::{gemma4_assistant::load_gemma4_assistant_model, LoadedModel, Model}; + use mlx_lm_utils::tokenizer::{Chat, Conversation, Role}; + use mlx_rs::transforms::eval; + use serde_json::json; + + use crate::conversation::message::Message; + use crate::providers::base::{DraftStats, ProviderStats, ProviderUsage, Usage}; + use crate::providers::local_inference::backend::{ + BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend, + }; + use crate::providers::local_inference::local_model_registry::{ModelSettings, ToolCallingMode}; + use crate::providers::local_inference::native_tool_parsing::message_from_native_tool_text; + use crate::providers::local_inference::tool_emulation::{ + build_emulator_tool_description, load_tiny_model_prompt, message_for_emulator_action, + StreamingEmulatorParser, CODE_EXECUTION_TOOL, + }; + use crate::providers::local_inference::{extract_text_content, ResolvedModelPaths}; + use crate::providers::utils::filter_extensions_from_system_prompt; + use goose_providers::errors::ProviderError; + use goose_providers::formats::openai; + use goose_providers::images::ImageFormat; + + pub(in crate::providers::local_inference) const MLX_BACKEND_ID: &str = "mlx"; + + pub(in crate::providers::local_inference) struct MlxBackend; + + impl MlxBackend { + pub(in crate::providers::local_inference) fn new() -> Self { + Self + } + } + + impl LocalInferenceBackend for MlxBackend { + fn id(&self) -> &'static str { + MLX_BACKEND_ID + } + + fn load_model( + &self, + model_id: &str, + resolved: &ResolvedModelPaths, + _settings: &ModelSettings, + ) -> Result, ProviderError> { + if !resolved.model_path.exists() { + return Err(ProviderError::ExecutionError(format!( + "Model not downloaded: {}. Please download it from Settings > Local Inference.", + model_id + ))); + } + + let model_dir = model_dir_from_path(&resolved.model_path)?; + let model = LoadedModel::load(&model_dir).map_err(mlx_error)?; + tracing::info!( + backend = self.id(), + model_id, + model_type = model.model_type(), + "MLX model loaded successfully" + ); + Ok(Box::new(MlxLoadedModel { model, model_dir })) + } + + fn generate( + &self, + loaded: &mut dyn BackendLoadedModel, + request: LocalGenerationRequest<'_>, + ) -> Result<(), ProviderError> { + let loaded = loaded + .as_any_mut() + .downcast_mut::() + .ok_or_else(|| { + ProviderError::ExecutionError("Loaded model backend mismatch".to_string()) + })?; + + let tool_mode = if request.tools.is_empty() { + ToolMode::None + } else { + match request.settings.tool_calling { + ToolCallingMode::ForceNative => ToolMode::Native, + ToolCallingMode::Auto | ToolCallingMode::ForceEmulated => ToolMode::Emulated { + code_mode_enabled: request + .tools + .iter() + .any(|t| t.name == CODE_EXECUTION_TOOL), + }, + } + }; + let prompt = build_prompt( + &mut loaded.model, + &request.model_name, + request.system, + request.messages, + request.tools, + tool_mode, + )?; + let prompt_tokens = loaded.model.encode(&prompt, false).map_err(mlx_error)?; + if prompt_tokens.len() >= request.context_limit && request.context_limit > 0 { + return Err(ProviderError::ContextLengthExceeded(format!( + "Prompt ({} tokens) exceeds context limit ({} tokens). Try reducing conversation length.", + prompt_tokens.len(), request.context_limit + ))); + } + + let prompt_array = loaded + .model + .encode_to_array(&prompt, false) + .map_err(mlx_error)?; + let max_tokens = request + .settings + .max_output_tokens + .or_else(|| { + request + .max_tokens + .and_then(|tokens| usize::try_from(tokens).ok()) + }) + .unwrap_or(512); + let temp = request + .temperature + .unwrap_or_else(|| temperature(request.settings)); + let eos_token_ids = loaded.model.eos_token_ids().to_vec(); + let generation_started = std::time::Instant::now(); + let (generated_ids, draft_stats) = + if let Some(draft_model_path) = &request.draft_model_path { + if matches!(loaded.model.model_mut(), Model::Gemma4(_)) { + let mut assistant = + load_gemma4_assistant_model(draft_model_path).map_err(|error| { + mlx_error(format!("failed to load MLX draft model: {error}")) + })?; + let target = match loaded.model.model_mut() { + Model::Gemma4(target) => target, + _ => unreachable!(), + }; + let (ids, stats) = generate_gemma4_mtp( + target, + &mut assistant, + &prompt_array, + &eos_token_ids, + max_tokens, + temp, + ) + .map_err(mlx_error)?; + ( + ids, + Some(DraftStats { + model: Some(draft_model_path.display().to_string()), + draft_tokens: stats.draft_tokens, + accepted_tokens: stats.accepted_tokens, + target_tokens: stats.target_tokens, + rounds: stats.rounds, + accept_rate: stats.accept_rate(), + }), + ) + } else { + generate_single_model( + &mut loaded.model, + &prompt_array, + &eos_token_ids, + max_tokens, + temp, + )? + } + } else { + generate_single_model( + &mut loaded.model, + &prompt_array, + &eos_token_ids, + max_tokens, + temp, + )? + }; + + let generated_text = loaded + .model + .decode(&generated_ids, true) + .map_err(mlx_error)?; + emit_generated_response(&generated_text, request.message_id, tool_mode, request.tx)?; + + let output_tokens = generated_ids.len() as i32; + let input_tokens = prompt_tokens.len() as i32; + let usage = Usage::new( + Some(input_tokens), + Some(output_tokens), + Some(input_tokens + output_tokens), + ); + let log_json = serde_json::json!({ + "path": "mlx", + "model_dir": loaded.model_dir, + "prompt_tokens": input_tokens, + "output_tokens": output_tokens, + "generated_text": generated_text, + "draft": draft_stats, + }); + let _ = request.log.write(&log_json, Some(&usage)); + let stats = ProviderStats { + time_to_first_token_ms: None, + elapsed_ms: Some(generation_started.elapsed().as_millis() as u64), + output_tokens: Some(generated_ids.len()), + draft: draft_stats, + }; + let provider_usage = ProviderUsage::new(request.model_name, usage).with_stats(stats); + let _ = request.tx.blocking_send(Ok((None, Some(provider_usage)))); + Ok(()) + } + + fn available_memory_bytes(&self) -> u64 { + 0 + } + } + + #[derive(Clone, Copy)] + enum ToolMode { + None, + Native, + Emulated { code_mode_enabled: bool }, + } + + struct MlxLoadedModel { + model: LoadedModel, + model_dir: PathBuf, + } + + impl BackendLoadedModel for MlxLoadedModel { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + } + + fn model_dir_from_path(path: &Path) -> Result { + if path.is_dir() { + Ok(path.to_path_buf()) + } else { + path.parent() + .map(Path::to_path_buf) + .ok_or_else(|| mlx_error("MLX model path has no parent directory")) + } + } + + fn build_prompt( + model: &mut LoadedModel, + model_name: &str, + system: &str, + messages: &[Message], + tools: &[rmcp::model::Tool], + tool_mode: ToolMode, + ) -> Result { + match tool_mode { + ToolMode::Native => { + let conversations = openai_messages(system, messages); + let tool_specs = openai::format_tools(tools) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + if let Some(prompt) = model + .apply_chat_template_json([conversations], Some(&tool_specs), true) + .map_err(mlx_error)? + { + return Ok(prompt); + } + + Ok(render_prompt(system, messages)) + } + ToolMode::Emulated { code_mode_enabled } => { + let system_prompt = format!( + "{}{}", + load_tiny_model_prompt(), + build_emulator_tool_description(tools, code_mode_enabled) + ); + if is_gemma4(model) { + let conversations = gemma4_messages_with_system(&system_prompt, messages); + if let Some(prompt) = model + .apply_chat_template_json([conversations], None, true) + .map_err(mlx_error)? + { + return Ok(prompt); + } + } + + let conversations = chat_conversations(&system_prompt, messages); + if let Some(prompt) = model + .apply_chat_template([Chat::Owned(conversations)], None, true) + .map_err(mlx_error)? + { + return Ok(prompt); + } + + Ok(render_prompt(&system_prompt, messages)) + } + ToolMode::None => { + if is_gemma4(model) { + let conversations = gemma4_messages(model_name, system, messages); + if let Some(prompt) = model + .apply_chat_template_json([conversations], None, true) + .map_err(mlx_error)? + { + return Ok(prompt); + } + } + + let conversations = chat_conversations(system, messages); + if let Some(prompt) = model + .apply_chat_template([Chat::Owned(conversations)], None, true) + .map_err(mlx_error)? + { + return Ok(prompt); + } + + Ok(render_prompt(system, messages)) + } + } + } + + fn generate_single_model( + model: &mut LoadedModel, + prompt_array: &mlx_rs::Array, + eos_token_ids: &[u32], + max_tokens: usize, + temp: f32, + ) -> Result<(Vec, Option), ProviderError> { + let mut cache: Vec> = Vec::new(); + let mut generated_ids = Vec::new(); + { + let generator = model + .generate(&mut cache, temp, prompt_array) + .take(max_tokens); + for token in generator { + let token = token.map_err(mlx_error)?; + eval([&token]).map_err(mlx_error)?; + let token_id = token.item::(); + if eos_token_ids.contains(&token_id) { + break; + } + generated_ids.push(token_id); + } + } + Ok((generated_ids, None)) + } + + fn is_gemma4(model: &LoadedModel) -> bool { + matches!(model.model_type(), "gemma4" | "gemma4_text") + } + + fn gemma4_messages( + model_name: &str, + system: &str, + messages: &[Message], + ) -> Vec { + let system = gemma4_system_prompt(model_name, system); + gemma4_messages_with_optional_system(system.as_deref(), messages) + } + + fn gemma4_messages_with_system(system: &str, messages: &[Message]) -> Vec { + gemma4_messages_with_optional_system(Some(system), messages) + } + + fn gemma4_messages_with_optional_system( + system: Option<&str>, + messages: &[Message], + ) -> Vec { + let mut values = Vec::new(); + if let Some(system) = system.map(str::trim).filter(|system| !system.is_empty()) { + values.push(json!({ + "role": "system", + "content": system, + })); + } + + for message in messages.iter().filter(|message| message.is_agent_visible()) { + let text = extract_text_content(message); + if text.trim().is_empty() { + continue; + } + + match message.role { + rmcp::model::Role::User => values.push(json!({ + "role": "user", + "content": [{"type": "text", "text": text.trim(), "content": text.trim()}], + })), + rmcp::model::Role::Assistant => values.push(json!({ + "role": "assistant", + "content": text.trim(), + })), + } + } + + values + } + + fn gemma4_system_prompt(model_name: &str, system: &str) -> Option { + if should_use_tiny_system_prompt(model_name) { + return Some(load_tiny_model_prompt()); + } + + let filtered = filter_extensions_from_system_prompt(system); + let system = filtered.trim(); + if system.is_empty() { + None + } else { + Some(system.to_string()) + } + } + + fn should_use_tiny_system_prompt(model_name: &str) -> bool { + estimate_model_size_billions(model_name).is_some_and(|size| size <= 4.0) + } + + fn estimate_model_size_billions(model_name: &str) -> Option { + let normalized = model_name.to_ascii_lowercase().replace('-', "_"); + for part in normalized.split('_') { + if let Some(value) = part.strip_suffix('b') { + if let Ok(size) = value.parse::() { + return Some(size); + } + } + if let Some(value) = part + .strip_prefix('e') + .and_then(|value| value.strip_suffix('b')) + { + if let Ok(size) = value.parse::() { + return Some(size); + } + } + } + None + } + + fn openai_messages(system: &str, messages: &[Message]) -> Vec { + let mut values = vec![serde_json::json!({ + "role": "system", + "content": system, + })]; + values.extend(openai::format_messages(messages, &ImageFormat::OpenAi)); + values + } + + fn chat_conversations(system: &str, messages: &[Message]) -> Vec> { + let mut conversations = Vec::new(); + if !system.trim().is_empty() { + conversations.push(Conversation { + role: Role::System, + content: system.trim().to_string(), + }); + } + for message in messages.iter().filter(|message| message.is_agent_visible()) { + let role = match message.role { + rmcp::model::Role::User => Role::User, + rmcp::model::Role::Assistant => Role::Assistant, + }; + let text = extract_text_content(message); + if !text.trim().is_empty() { + conversations.push(Conversation { + role, + content: text.trim().to_string(), + }); + } + } + conversations + } + + fn emit_generated_response( + generated_text: &str, + message_id: &str, + tool_mode: ToolMode, + tx: &tokio::sync::mpsc::Sender< + Result<(Option, Option), ProviderError>, + >, + ) -> Result<(), ProviderError> { + if generated_text.is_empty() { + return Ok(()); + } + + match tool_mode { + ToolMode::None => { + let mut msg = Message::assistant().with_text(generated_text); + msg.id = Some(message_id.to_string()); + tx.blocking_send(Ok((Some(msg), None))).map_err(|_| { + ProviderError::ExecutionError("Failed to stream MLX response".to_string()) + })?; + } + ToolMode::Native => { + if let Some(message) = message_from_native_tool_text(generated_text, message_id)? { + tx.blocking_send(Ok((Some(message), None))).map_err(|_| { + ProviderError::ExecutionError("Failed to stream MLX response".to_string()) + })?; + } else { + let mut msg = Message::assistant().with_text(generated_text); + msg.id = Some(message_id.to_string()); + tx.blocking_send(Ok((Some(msg), None))).map_err(|_| { + ProviderError::ExecutionError("Failed to stream MLX response".to_string()) + })?; + } + } + ToolMode::Emulated { code_mode_enabled } => { + let mut parser = StreamingEmulatorParser::new(code_mode_enabled); + let mut actions = parser.process_chunk(generated_text); + actions.extend(parser.flush()); + + for action in actions { + let (message, _) = message_for_emulator_action(&action, message_id); + tx.blocking_send(Ok((Some(message), None))).map_err(|_| { + ProviderError::ExecutionError("Failed to stream MLX response".to_string()) + })?; + } + } + } + Ok(()) + } + + fn temperature(settings: &ModelSettings) -> f32 { + match &settings.sampling { + crate::providers::local_inference::local_model_registry::SamplingConfig::Greedy => 0.0, + crate::providers::local_inference::local_model_registry::SamplingConfig::Temperature { + temperature, + .. + } => *temperature, + crate::providers::local_inference::local_model_registry::SamplingConfig::MirostatV2 { + .. + } => 0.0, + } + } + + fn render_prompt(system: &str, messages: &[Message]) -> String { + let mut prompt = String::new(); + if !system.trim().is_empty() { + prompt.push_str("System: "); + prompt.push_str(system.trim()); + prompt.push('\n'); + } + for message in messages.iter().filter(|message| message.is_agent_visible()) { + let role = match message.role { + rmcp::model::Role::User => "User", + rmcp::model::Role::Assistant => "Assistant", + }; + let text = extract_text_content(message); + if !text.trim().is_empty() { + prompt.push_str(role); + prompt.push_str(": "); + prompt.push_str(text.trim()); + prompt.push('\n'); + } + } + prompt.push_str("Assistant: "); + prompt + } + + fn mlx_error(error: impl std::fmt::Display) -> ProviderError { + ProviderError::ExecutionError(format!("MLX backend error: {}", error)) + } +} + +#[cfg(not(feature = "mlx"))] +mod imp { + use crate::providers::local_inference::backend::{ + BackendLoadedModel, LocalGenerationRequest, LocalInferenceBackend, + }; + use crate::providers::local_inference::local_model_registry::ModelSettings; + use crate::providers::local_inference::ResolvedModelPaths; + use goose_providers::errors::ProviderError; + + pub(in crate::providers::local_inference) const MLX_BACKEND_ID: &str = "mlx"; + + pub(in crate::providers::local_inference) struct MlxBackend; + + impl MlxBackend { + pub(in crate::providers::local_inference) fn new() -> Self { + Self + } + } + + impl LocalInferenceBackend for MlxBackend { + fn id(&self) -> &'static str { + MLX_BACKEND_ID + } + + fn load_model( + &self, + _model_id: &str, + _resolved: &ResolvedModelPaths, + _settings: &ModelSettings, + ) -> Result, ProviderError> { + Err(ProviderError::ExecutionError( + "MLX backend support was not compiled in. Rebuild with the `mlx` feature." + .to_string(), + )) + } + + fn generate( + &self, + _loaded: &mut dyn BackendLoadedModel, + _request: LocalGenerationRequest<'_>, + ) -> Result<(), ProviderError> { + Err(ProviderError::ExecutionError( + "MLX backend support was not compiled in. Rebuild with the `mlx` feature." + .to_string(), + )) + } + + fn available_memory_bytes(&self) -> u64 { + 0 + } + } +} + +pub(in crate::providers::local_inference) use imp::{MlxBackend, MLX_BACKEND_ID}; diff --git a/crates/goose/src/providers/local_inference/native_tool_parsing.rs b/crates/goose/src/providers/local_inference/native_tool_parsing.rs new file mode 100644 index 000000000000..ffbe7f71d8a0 --- /dev/null +++ b/crates/goose/src/providers/local_inference/native_tool_parsing.rs @@ -0,0 +1,323 @@ +use crate::conversation::message::{Message, MessageContent}; +use goose_providers::errors::ProviderError; +use goose_providers::formats::openai::is_valid_function_name; +use goose_providers::json::safely_parse_json; +use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData}; +use serde_json::{json, Value}; +use std::borrow::Cow; +use uuid::Uuid; + +pub(crate) fn message_from_native_tool_text( + generated_text: &str, + message_id: &str, +) -> Result, ProviderError> { + let mut content = Vec::new(); + + if let Some(message) = parse_openai_message_json(generated_text) { + append_text(&mut content, message.get("content")); + append_tool_calls(&mut content, message.get("tool_calls")); + } else if let Some(tool_calls) = parse_tool_calls_json(generated_text) { + append_tool_calls(&mut content, Some(&tool_calls)); + } else if generated_text.contains(" Option { + json_candidates(generated_text) + .into_iter() + .find(|value| value.get("tool_calls").is_some_and(is_tool_call_array)) +} + +fn parse_tool_calls_json(generated_text: &str) -> Option { + for value in json_candidates(generated_text) { + if is_tool_call_array(&value) { + return Some(value); + } + if let Some(tool_calls) = value + .get("tool_calls") + .filter(|value| is_tool_call_array(value)) + { + return Some(tool_calls.clone()); + } + if is_tool_call_value(&value) { + return Some(Value::Array(vec![value])); + } + } + None +} + +fn is_tool_call_array(value: &Value) -> bool { + value + .as_array() + .is_some_and(|items| !items.is_empty() && items.iter().all(is_tool_call_value)) +} + +fn is_tool_call_value(value: &Value) -> bool { + let direct_name = value.get("name").and_then(|name| name.as_str()).is_some(); + let direct_arguments = value.get("arguments").is_some(); + let function_name = value + .get("function") + .and_then(|function| function.get("name")) + .and_then(|name| name.as_str()) + .is_some(); + + (direct_name && direct_arguments) || function_name +} + +fn json_candidates(text: &str) -> Vec { + let mut candidates = Vec::new(); + let trimmed = text.trim(); + if let Ok(value) = serde_json::from_str::(trimmed) { + candidates.push(value); + } + + for (open, close) in [('{', '}'), ('[', ']')] { + let starts = text.match_indices(open).map(|(idx, _)| idx); + for start in starts { + let mut depth = 0i32; + let mut in_string = false; + let mut escaped = false; + for (offset, ch) in text[start..].char_indices() { + if escaped { + escaped = false; + continue; + } + if ch == '\\' && in_string { + escaped = true; + continue; + } + if ch == '"' { + in_string = !in_string; + continue; + } + if in_string { + continue; + } + if ch == open { + depth += 1; + } else if ch == close { + depth -= 1; + if depth == 0 { + let end = start + offset + ch.len_utf8(); + if let Ok(value) = serde_json::from_str::(&text[start..end]) { + candidates.push(value); + } + break; + } + } + } + } + } + + candidates +} + +fn append_text(content: &mut Vec, value: Option<&Value>) { + if let Some(text) = value.and_then(|value| value.as_str()) { + if !text.is_empty() { + content.push(MessageContent::text(text)); + } + } +} + +fn append_tool_calls(content: &mut Vec, value: Option<&Value>) { + let Some(tool_calls) = value.and_then(|value| value.as_array()) else { + return; + }; + + for tool_call in tool_calls { + content.push(tool_call_content(tool_call)); + } +} + +fn tool_call_content(tool_call: &Value) -> MessageContent { + let id = tool_call + .get("id") + .and_then(|id| id.as_str()) + .map(ToString::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + let function = tool_call.get("function").unwrap_or(tool_call); + let name = function + .get("name") + .and_then(|name| name.as_str()) + .unwrap_or_default() + .to_string(); + + if !is_valid_function_name(&name) { + return MessageContent::tool_request( + id, + Err(ErrorData { + code: ErrorCode::INVALID_REQUEST, + message: Cow::from(format!( + "The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+", + name + )), + data: None, + }), + ); + } + + let arguments = function + .get("arguments") + .or_else(|| tool_call.get("arguments")) + .cloned() + .unwrap_or_else(|| json!({})); + + let raw_arguments = arguments.to_string(); + let parsed = match arguments { + Value::String(arguments) if arguments.trim().is_empty() => Ok(json!({})), + Value::String(arguments) => safely_parse_json(&arguments), + Value::Object(_) => Ok(arguments), + Value::Null => Ok(json!({})), + other => Ok(other), + }; + + match parsed { + Ok(params) => MessageContent::tool_request( + id, + Ok(CallToolRequestParams::new(name).with_arguments(object(params))), + ), + Err(error) => { + let message = format!( + "Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'", + id, error, raw_arguments + ); + MessageContent::tool_request( + id, + Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: Cow::from(message), + data: None, + }), + ) + } + } +} + +fn parse_xml_tool_calls(content: &str) -> (Option, Vec) { + let function_re = regex::Regex::new(r"]+)>([\s\S]*?)").unwrap(); + let param_re = regex::Regex::new(r"]+)>([\s\S]*?)").unwrap(); + + let prefix = content + .find(" usize { + message + .content + .iter() + .filter(|content| matches!(content, MessageContent::ToolRequest(_))) + .count() + } + + #[test] + fn parses_openai_message_tool_calls() { + let text = r#"{"content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"developer__shell","arguments":"{\"command\":\"pwd\"}"}}]}"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn parses_top_level_tool_calls() { + let text = r#"{"tool_calls":[{"name":"developer__shell","arguments":{"command":"pwd"}}]}"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn parses_top_level_tool_call_array() { + let text = r#"[{"name":"developer__shell","arguments":{"command":"pwd"}}]"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn parses_top_level_tool_call_object_with_arguments() { + let text = r#"{"name":"developer__shell","arguments":{"command":"pwd"}}"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } + + #[test] + fn ignores_plain_json_objects_with_name_fields() { + let text = r#"{"name":"Alice","age":30}"#; + assert!(message_from_native_tool_text(text, "msg") + .unwrap() + .is_none()); + } + + #[test] + fn ignores_plain_json_arrays() { + let text = r#"["a","b"]"#; + assert!(message_from_native_tool_text(text, "msg") + .unwrap() + .is_none()); + } + + #[test] + fn ignores_non_tool_call_arrays_in_tool_calls_field() { + let text = r#"{"tool_calls":["a","b"]}"#; + assert!(message_from_native_tool_text(text, "msg") + .unwrap() + .is_none()); + } + + #[test] + fn parses_xml_tool_calls() { + let text = r#"pwd"#; + let message = message_from_native_tool_text(text, "msg").unwrap().unwrap(); + assert_eq!(tool_count(&message), 1); + } +} diff --git a/crates/goose/src/providers/local_inference/tool_emulation.rs b/crates/goose/src/providers/local_inference/tool_emulation.rs new file mode 100644 index 000000000000..9bb645d6cdb8 --- /dev/null +++ b/crates/goose/src/providers/local_inference/tool_emulation.rs @@ -0,0 +1,555 @@ +//! Shared text-based tool call emulation for local inference backends. +//! +//! Models that do not have native tool-calling support are prompted to emit shell commands +//! as `$ command` on a new line and code blocks as ```execute_typescript fenced blocks. +//! The parser converts those patterns into Goose tool-call messages. + +use crate::conversation::message::{Message, MessageContent}; +use rmcp::model::{CallToolRequestParams, Tool}; +use serde_json::json; +use std::borrow::Cow; +use uuid::Uuid; + +pub(crate) const SHELL_TOOL: &str = "developer__shell"; +pub(crate) const CODE_EXECUTION_TOOL: &str = "code_execution__execute_typescript"; + +const HOLD_BACK_CODE_MODE: usize = " ```execute_typescript\n".len(); +const HOLD_BACK_SHELL_ONLY: usize = "\n$".len(); + +pub(crate) fn load_tiny_model_prompt() -> String { + use std::env; + + let os = if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "unknown" + }; + + let working_directory = env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + + let context = json!({ + "os": os, + "working_directory": working_directory, + "shell": shell, + }); + + crate::prompt_template::render_template("tiny_model_system.md", &context).unwrap_or_else(|e| { + tracing::warn!("Failed to load tiny_model_system.md: {:?}", e); + "You are Goose, an AI assistant. You can execute shell commands by starting lines with $." + .to_string() + }) +} + +pub(crate) fn build_emulator_tool_description(tools: &[Tool], code_mode_enabled: bool) -> String { + let mut tool_desc = String::new(); + + if code_mode_enabled { + tool_desc.push_str("\n\n# Running Code\n\n"); + tool_desc.push_str( + "You can call tools by writing code in a ```execute_typescript block. \ + The code runs immediately — do not explain it, just run it.\n\n", + ); + tool_desc.push_str("Example — counting files in /tmp:\n\n"); + tool_desc.push_str("```execute_typescript\nasync function run() {\n"); + tool_desc.push_str( + " const result = await Developer.shell({ command: \"ls -1 /tmp | wc -l\" });\n", + ); + tool_desc.push_str(" return result;\n}\n```\n\n"); + tool_desc.push_str("Rules:\n"); + tool_desc.push_str("- Code MUST define async function run() and return a result\n"); + tool_desc.push_str("- All function calls are async — use await\n"); + tool_desc.push_str( + "- Use ```execute_typescript for tool calls, $ for simple shell one-liners\n\n", + ); + tool_desc.push_str("Available functions:\n\n"); + + for tool in tools { + if tool.name.starts_with("code_execution__") { + continue; + } + let parts: Vec<&str> = tool.name.splitn(2, "__").collect(); + if parts.len() == 2 { + let namespace = { + let mut c = parts[0].chars(); + match c.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(c).collect::(), + } + }; + let camel_name: String = parts[1] + .split('_') + .enumerate() + .map(|(i, part)| { + if i == 0 { + part.to_string() + } else { + let mut c = part.chars(); + match c.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(c).collect(), + } + } + }) + .collect(); + let desc = tool.description.as_ref().map(|d| d.as_ref()).unwrap_or(""); + tool_desc.push_str(&format!("- {namespace}.{camel_name}(): {desc}\n")); + } + } + } else { + tool_desc.push_str("\n\n# Tools\n\nYou have access to the following tools:\n\n"); + for tool in tools { + let desc = tool + .description + .as_ref() + .map(|d| d.as_ref()) + .unwrap_or("No description"); + tool_desc.push_str(&format!("- {}: {}\n", tool.name, desc)); + } + } + + tool_desc +} + +pub(crate) enum EmulatorAction { + Text(String), + ShellCommand(String), + ExecuteCode(String), +} + +enum ParserState { + Normal, + InCommand, + InExecuteBlock, +} + +pub(crate) struct StreamingEmulatorParser { + buffer: String, + state: ParserState, + code_mode_enabled: bool, +} + +impl StreamingEmulatorParser { + pub(crate) fn new(code_mode_enabled: bool) -> Self { + Self { + buffer: String::new(), + state: ParserState::Normal, + code_mode_enabled, + } + } + + pub(crate) fn process_chunk(&mut self, chunk: &str) -> Vec { + self.buffer.push_str(chunk); + let mut results = Vec::new(); + + loop { + match self.state { + ParserState::InCommand => { + if let Some((command_line, rest)) = self.buffer.split_once('\n') { + if let Some(command) = command_line.strip_prefix('$') { + let command = command.trim(); + if !command.is_empty() { + results.push(EmulatorAction::ShellCommand(command.to_string())); + } + } + self.buffer = rest.to_string(); + self.state = ParserState::Normal; + } else { + break; + } + } + ParserState::InExecuteBlock => { + if let Some(end_idx) = self.buffer.find("\n```") { + #[allow(clippy::string_slice)] + let code = self.buffer[..end_idx].to_string(); + #[allow(clippy::string_slice)] + let rest = &self.buffer[end_idx + 4..]; + let rest = rest.strip_prefix('\n').unwrap_or(rest); + self.buffer = rest.to_string(); + self.state = ParserState::Normal; + if !code.trim().is_empty() { + results.push(EmulatorAction::ExecuteCode(code)); + } + } else { + break; + } + } + ParserState::Normal => { + if self.code_mode_enabled { + if let Some((before, after)) = + self.buffer.split_once("```execute_typescript\n") + { + if !before.trim().is_empty() { + results.push(EmulatorAction::Text(before.to_string())); + } + self.buffer = after.to_string(); + self.state = ParserState::InExecuteBlock; + continue; + } + if self.buffer.ends_with("```execute_typescript") { + let before = self.buffer.trim_end_matches("```execute_typescript"); + if !before.trim().is_empty() { + results.push(EmulatorAction::Text(before.to_string())); + } + self.buffer.clear(); + self.state = ParserState::InExecuteBlock; + continue; + } + } + + if let Some((before_dollar, from_dollar)) = self.buffer.split_once("\n$") { + let text = format!("{}\n", before_dollar); + if !text.trim().is_empty() { + results.push(EmulatorAction::Text(text)); + } + self.buffer = format!("${}", from_dollar); + self.state = ParserState::InCommand; + } else if self.buffer.starts_with('$') && self.buffer.len() == chunk.len() { + self.state = ParserState::InCommand; + } else { + let hold_back = if self.code_mode_enabled { + HOLD_BACK_CODE_MODE + } else { + HOLD_BACK_SHELL_ONLY + }; + let char_count = self.buffer.chars().count(); + if char_count > hold_back && !self.buffer.ends_with('\n') { + let mut chars = self.buffer.chars(); + let emit_count = char_count - hold_back; + let emit_text: String = chars.by_ref().take(emit_count).collect(); + let keep_text: String = chars.collect(); + if !emit_text.is_empty() { + results.push(EmulatorAction::Text(emit_text)); + } + self.buffer = keep_text; + } + break; + } + } + } + } + + results + } + + pub(crate) fn flush(&mut self) -> Vec { + let mut results = Vec::new(); + + if !self.buffer.is_empty() { + match self.state { + ParserState::InCommand => { + let command_line = self.buffer.trim(); + if let Some(command) = command_line.strip_prefix('$') { + let command = command.trim(); + if !command.is_empty() { + results.push(EmulatorAction::ShellCommand(command.to_string())); + } + } else if !command_line.is_empty() { + results.push(EmulatorAction::Text(self.buffer.clone())); + } + } + ParserState::InExecuteBlock => { + let code = self.buffer.trim(); + if !code.is_empty() { + results.push(EmulatorAction::ExecuteCode(code.to_string())); + } + } + ParserState::Normal => { + results.push(EmulatorAction::Text(self.buffer.clone())); + } + } + self.buffer.clear(); + self.state = ParserState::Normal; + } + + results + } +} + +pub(crate) fn message_for_emulator_action( + action: &EmulatorAction, + message_id: &str, +) -> (Message, bool) { + match action { + EmulatorAction::Text(text) => { + let mut message = Message::assistant().with_text(text); + message.id = Some(message_id.to_string()); + (message, false) + } + EmulatorAction::ShellCommand(command) => { + let tool_id = Uuid::new_v4().to_string(); + let mut args = serde_json::Map::new(); + args.insert("command".to_string(), json!(command)); + let tool_call = + CallToolRequestParams::new(Cow::Borrowed(SHELL_TOOL)).with_arguments(args); + let mut message = Message::assistant(); + message + .content + .push(MessageContent::tool_request(tool_id, Ok(tool_call))); + message.id = Some(message_id.to_string()); + (message, true) + } + EmulatorAction::ExecuteCode(code) => { + let tool_id = Uuid::new_v4().to_string(); + let wrapped = if code.contains("async function run()") { + code.clone() + } else { + format!("async function run() {{\n{}\n}}", code) + }; + let mut args = serde_json::Map::new(); + args.insert("code".to_string(), json!(wrapped)); + let tool_call = + CallToolRequestParams::new(Cow::Borrowed(CODE_EXECUTION_TOOL)).with_arguments(args); + let mut message = Message::assistant(); + message + .content + .push(MessageContent::tool_request(tool_id, Ok(tool_call))); + message.id = Some(message_id.to_string()); + (message, true) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_chunks(chunks: &[&str], code_mode: bool) -> Vec { + let mut parser = StreamingEmulatorParser::new(code_mode); + let mut actions = Vec::new(); + for chunk in chunks { + actions.extend(parser.process_chunk(chunk)); + } + actions.extend(parser.flush()); + actions + } + + fn parse_all(input: &str, code_mode: bool) -> Vec { + parse_chunks(&[input], code_mode) + } + + fn assert_text(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::Text(t) => assert_eq!(t.trim(), expected.trim(), "text mismatch"), + other => panic!("expected Text, got {:?}", action_label(other)), + } + } + + fn assert_shell(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::ShellCommand(cmd) => { + assert_eq!(cmd, expected, "shell command mismatch") + } + other => panic!("expected ShellCommand, got {:?}", action_label(other)), + } + } + + fn assert_execute(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::ExecuteCode(code) => { + assert_eq!(code.trim(), expected.trim(), "execute code mismatch") + } + other => panic!("expected ExecuteCode, got {:?}", action_label(other)), + } + } + + fn action_label(a: &EmulatorAction) -> &'static str { + match a { + EmulatorAction::Text(_) => "Text", + EmulatorAction::ShellCommand(_) => "ShellCommand", + EmulatorAction::ExecuteCode(_) => "ExecuteCode", + } + } + + #[test] + fn plain_text_no_tools() { + let actions = parse_all("Hello, world!", false); + let all_text: String = actions + .iter() + .map(|a| match a { + EmulatorAction::Text(t) => t.as_str(), + _ => panic!("expected only Text actions"), + }) + .collect(); + assert_eq!(all_text.trim(), "Hello, world!"); + } + + #[test] + fn single_shell_command() { + let actions = parse_all("$ ls -la\n", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "ls -la"); + } + + #[test] + fn text_then_shell_command() { + let actions = parse_all("Let me check:\n$ ls -la\n", false); + assert!(actions.len() >= 2); + assert_text(&actions[0], "Let me check:"); + assert_shell(&actions[actions.len() - 1], "ls -la"); + } + + #[test] + fn shell_command_at_start_of_output() { + let actions = parse_all("$ whoami\n", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "whoami"); + } + + #[test] + fn shell_command_without_trailing_newline() { + let actions = parse_all("$ whoami", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "whoami"); + } + + #[test] + fn dollar_sign_mid_sentence_is_not_command() { + let actions = parse_all("It costs $50 per month", false); + for action in &actions { + assert!(matches!(action, EmulatorAction::Text(_))); + } + let all_text: String = actions + .iter() + .filter_map(|a| match a { + EmulatorAction::Text(t) => Some(t.as_str()), + _ => None, + }) + .collect(); + assert_eq!(all_text.trim(), "It costs $50 per month"); + } + + #[test] + fn execute_block() { + let input = "Here's the code:\n```execute_typescript\nconsole.log('hi');\n```\n"; + let actions = parse_all(input, true); + assert!(actions.len() >= 2); + assert_text(&actions[0], "Here's the code:"); + assert_execute(&actions[actions.len() - 1], "console.log('hi');"); + } + + #[test] + fn tool_description_uses_parser_execute_fence() { + let description = build_emulator_tool_description(&[], true); + + assert!(description.contains("```execute_typescript")); + assert!(!description.contains("```execute block")); + assert!(!description.contains("Use ```execute for tool calls")); + } + + #[test] + fn execute_block_not_detected_without_code_mode() { + let input = "```execute_typescript\nconsole.log('hi');\n```\n"; + let actions = parse_all(input, false); + for action in &actions { + assert!(matches!(action, EmulatorAction::Text(_))); + } + } + + #[test] + fn dollar_split_across_chunks() { + let actions = parse_chunks(&["Let me check\n", "$ ls -la\n"], false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 1); + assert_shell(shells[0], "ls -la"); + } + + #[test] + fn execute_fence_split_across_chunks() { + let actions = parse_chunks( + &["Here:\n```ex", "ecute_typescript\nlet x = 1;\n", "```\n"], + true, + ); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + assert_execute(executes[0], "let x = 1;"); + } + + #[test] + fn multiple_commands_on_separate_lines() { + let actions = parse_chunks(&["Here:\n$ cd /tmp\n", "Done.\n$ ls\n"], false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 2); + assert_shell(shells[0], "cd /tmp"); + assert_shell(shells[1], "ls"); + } + + #[test] + fn regular_code_fence_not_treated_as_execute() { + let input = "```python\nprint('hi')\n```\n"; + let actions = parse_all(input, true); + for action in &actions { + assert!(matches!(action, EmulatorAction::Text(_))); + } + } + + #[test] + fn empty_command_ignored() { + let actions = parse_all("$\n", false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 0); + } + + #[test] + fn token_by_token_streaming() { + let input = "$ echo hello\n"; + let chars: Vec = input.chars().map(|c| c.to_string()).collect(); + let chunks: Vec<&str> = chars.iter().map(|s| s.as_str()).collect(); + let actions = parse_chunks(&chunks, false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 1); + assert_shell(shells[0], "echo hello"); + } + + #[test] + fn execute_block_with_multiline_code() { + let input = "```execute_typescript\nasync function run() {\n const r = await Developer.shell({ command: \"ls\" });\n return r;\n}\n```\n"; + let actions = parse_all(input, true); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + match executes[0] { + EmulatorAction::ExecuteCode(code) => { + assert!(code.contains("async function run()")); + assert!(code.contains("Developer.shell")); + } + _ => unreachable!(), + } + } + + #[test] + fn unclosed_execute_block_flushed() { + let input = "```execute_typescript\nlet x = 1;"; + let actions = parse_all(input, true); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + assert_execute(executes[0], "let x = 1;"); + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index d64215081f2f..da1eeed451fb 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -1,7 +1,9 @@ mod acp_tooling; pub mod amp_acp; pub mod anthropic; -pub mod api_client; +pub mod api_client { + pub use goose_providers::api_client::*; +} pub mod avian; pub mod azure; pub mod azureauth; @@ -26,7 +28,6 @@ pub mod cursor_agent; pub mod databricks; pub mod databricks_auth; pub mod databricks_v2; -pub mod embedding; pub mod formats; mod gcpauth; pub mod gcpvertexai; @@ -53,7 +54,9 @@ pub mod openrouter; pub mod pi_acp; pub mod provider_registry; pub mod provider_test; -mod retry; +mod retry { + pub use goose_providers::retry::*; +} #[cfg(feature = "aws-providers")] pub mod sagemaker_tgi; pub mod snowflake; diff --git a/crates/goose/src/providers/nanogpt.rs b/crates/goose/src/providers/nanogpt.rs index 7475c9df6a8d..eb83cb29c3d4 100644 --- a/crates/goose/src/providers/nanogpt.rs +++ b/crates/goose/src/providers/nanogpt.rs @@ -2,15 +2,15 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::openai_compatible::{handle_status, stream_openai_compat}; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::conversation::message::Message; -use crate::model::ModelConfig; use anyhow::Result; use async_trait::async_trait; use futures::future::BoxFuture; use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::{create_request, ModelConfigParams}; +use goose_providers::formats::openai::create_request; use goose_providers::images::ImageFormat; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; pub const NANOGPT_PROVIDER_NAME: &str = "nano-gpt"; @@ -30,16 +30,24 @@ pub struct NanoGptProvider { } impl NanoGptProvider { - fn build_client(host: &str, api_key: &str) -> Result { - ApiClient::new( + fn build_client( + host: &str, + api_key: &str, + tls_config: Option, + ) -> Result { + ApiClient::new_with_tls( host.to_string(), AuthMethod::BearerToken(api_key.to_string()), + tls_config, )? .with_header("x-client", "goose") } - async fn check_subscription(api_key: &str) -> bool { - let client = match Self::build_client(NANOGPT_SUBSCRIPTION_HOST, api_key) { + async fn check_subscription( + api_key: &str, + tls_config: Option, + ) -> bool { + let client = match Self::build_client(NANOGPT_SUBSCRIPTION_HOST, api_key, tls_config) { Ok(c) => c, Err(_) => return false, }; @@ -55,11 +63,14 @@ impl NanoGptProvider { } } - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let api_key: String = config.get_secret(NANOGPT_API_KEY)?; - let is_subscription = Self::check_subscription(&api_key).await; + let is_subscription = Self::check_subscription(&api_key, tls_config.clone()).await; let host = if is_subscription { tracing::debug!("NanoGPT subscription active, using subscription endpoint"); NANOGPT_SUBSCRIPTION_HOST.to_string() @@ -68,7 +79,7 @@ impl NanoGptProvider { NANOGPT_API_HOST.to_string() }; - let api_client = Self::build_client(&host, &api_key)?; + let api_client = Self::build_client(&host, &api_key, tls_config)?; Ok(Self { api_client, @@ -96,8 +107,9 @@ impl ProviderDef for NanoGptProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -176,13 +188,7 @@ impl Provider for NanoGptProvider { tools: &[Tool], ) -> Result { let payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -190,7 +196,7 @@ impl Provider for NanoGptProvider { true, )?; - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 75ef5e8543d7..7141cb8f8ff7 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -3,13 +3,10 @@ use super::base::{ ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, DEFAULT_PROVIDER_TIMEOUT_SECS, }; -use super::inventory::InventoryIdentityInput; use super::openai_compatible::handle_status; use super::retry::{ProviderRetry, RetryConfig}; -use super::utils::RequestLog; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::conversation::message::Message; -use crate::model::ModelConfig; use crate::providers::formats::ollama::{create_request, response_to_streaming_message_ollama}; use anyhow::{Error, Result}; use async_stream::try_stream; @@ -17,8 +14,9 @@ use async_trait::async_trait; use futures::future::BoxFuture; use futures::TryStreamExt; use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::ModelConfigParams; use goose_providers::images::ImageFormat; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt, RequestLogHandle}; use reqwest::Response; use rmcp::model::Tool; use serde_json::{json, Value}; @@ -29,7 +27,7 @@ use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::io::StreamReader; use url::Url; -const OLLAMA_PROVIDER_NAME: &str = "ollama"; +pub(crate) const OLLAMA_PROVIDER_NAME: &str = "ollama"; pub const OLLAMA_HOST: &str = "localhost"; pub const OLLAMA_TIMEOUT: u64 = DEFAULT_PROVIDER_TIMEOUT_SECS; pub const OLLAMA_DEFAULT_PORT: u16 = 11434; @@ -127,12 +125,15 @@ fn apply_ollama_options(payload: &mut Value, model_config: &ModelConfig) { } } -fn ollama_host_configured(config: &crate::config::Config) -> bool { +pub(crate) fn ollama_host_configured(config: &crate::config::Config) -> bool { config.get_param::("OLLAMA_HOST").is_ok() } impl OllamaProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let host: String = config .get_param("OLLAMA_HOST") @@ -160,8 +161,12 @@ impl OllamaProvider { .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } - let api_client = - ApiClient::with_timeout(base_url.to_string(), AuthMethod::NoAuth, timeout)?; + let api_client = ApiClient::with_timeout_and_tls( + base_url.to_string(), + AuthMethod::NoAuth, + timeout, + tls_config, + )?; Ok(Self { api_client, @@ -175,6 +180,7 @@ impl OllamaProvider { pub fn from_custom_config( model: ModelConfig, config: DeclarativeProviderConfig, + tls_config: Option, ) -> Result { let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); @@ -198,8 +204,12 @@ impl OllamaProvider { .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } - let mut api_client = - ApiClient::with_timeout(base_url.to_string(), AuthMethod::NoAuth, timeout)?; + let mut api_client = ApiClient::with_timeout_and_tls( + base_url.to_string(), + AuthMethod::NoAuth, + timeout, + tls_config, + )?; if let Some(headers) = &config.headers { let mut header_map = reqwest::header::HeaderMap::new(); @@ -221,7 +231,7 @@ impl OllamaProvider { } let model = if let Some(ref fast_model_name) = config.fast_model { - model.with_fast(fast_model_name, &config.name)? + crate::model_config::with_configured_fast_model(model, &config.name, fast_model_name)? } else { model }; @@ -263,28 +273,9 @@ impl ProviderDef for OllamaProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_configured() -> bool { - ollama_host_configured(crate::config::Config::global()) - } - - fn inventory_identity() -> Result { - let config = crate::config::Config::global(); - Ok( - InventoryIdentityInput::new(OLLAMA_PROVIDER_NAME, OLLAMA_PROVIDER_NAME).with_public( - "host", - config - .get_param::("OLLAMA_HOST") - .unwrap_or_else(|_| OLLAMA_HOST.to_string()), - ), - ) + Box::pin(Self::from_env(model, tls_config)) } } @@ -321,13 +312,7 @@ impl Provider for OllamaProvider { tools: &[Tool], ) -> Result { let mut payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -335,7 +320,7 @@ impl Provider for OllamaProvider { true, )?; apply_ollama_options(&mut payload, model_config); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -459,7 +444,10 @@ fn with_line_timeout( /// preventing duplicate content from being emitted to the UI. /// Timeout is applied at the raw SSE line level via with_line_timeout so that /// buffering inside response_to_streaming_message_ollama does not cause false stalls. -fn stream_ollama(response: Response, mut log: RequestLog) -> Result { +fn stream_ollama( + response: Response, + mut log: Option>, +) -> Result { let stream = response.bytes_stream().map_err(std::io::Error::other); Ok(Box::pin(try_stream! { @@ -571,13 +559,7 @@ mod tests { let messages = vec![crate::conversation::message::Message::user().with_text("hi")]; let payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + &model_config, "You are a helpful assistant.", &messages, &[], @@ -610,13 +592,7 @@ mod tests { let messages = vec![crate::conversation::message::Message::user().with_text("hi")]; let mut payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + &model_config, "You are a helpful assistant.", &messages, &[], @@ -660,13 +636,7 @@ mod tests { let messages = vec![crate::conversation::message::Message::user().with_text("hi")]; let mut payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + &model_config, "You are a helpful assistant.", &messages, &[], diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index dfec1948f386..21eaa6d582aa 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -2,11 +2,9 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ ConfigKey, ModelInfo, Provider, ProviderDef, ProviderMetadata, DEFAULT_PROVIDER_TIMEOUT_SECS, }; -use super::embedding::{EmbeddingCapable, EmbeddingRequest, EmbeddingResponse}; use super::formats::openai_responses::{ create_responses_request, get_responses_usage, responses_api_to_message, ResponsesApiResponse, }; -use super::inventory::{config_secret_value, InventoryIdentityInput}; use super::openai_compatible::{ handle_response_openai_compat, handle_status, stream_openai_compat, stream_responses_compat, }; @@ -18,25 +16,24 @@ use async_trait::async_trait; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::ProviderUsage; use goose_providers::errors::ProviderError; +use goose_providers::formats::openai::is_openai_responses_model; use goose_providers::formats::openai::{ create_request_with_options, get_usage, response_to_message, OpenAiFormatOptions, }; -use goose_providers::formats::openai::{is_openai_responses_model, ModelConfigParams}; use goose_providers::images::ImageFormat; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use reqwest::StatusCode; use std::collections::HashMap; -use crate::model::ModelConfig; use crate::providers::base::MessageStream; -use crate::providers::utils::RequestLog; +use goose_providers::model::ModelConfig; use rmcp::model::Tool; -const OPEN_AI_PROVIDER_NAME: &str = "openai"; -const OPEN_AI_DEFAULT_BASE_PATH: &str = "v1/chat/completions"; +pub(crate) const OPEN_AI_PROVIDER_NAME: &str = "openai"; +pub(crate) const OPEN_AI_DEFAULT_BASE_PATH: &str = "v1/chat/completions"; const OPEN_AI_VERSIONLESS_BASE_PATH: &str = "chat/completions"; const OPEN_AI_DEFAULT_RESPONSES_PATH: &str = "v1/responses"; const OPEN_AI_DEFAULT_MODELS_PATH: &str = "v1/models"; -const OPEN_AI_DEFAULT_EMBEDDINGS_PATH: &str = "v1/embeddings"; pub const OPEN_AI_DEFAULT_MODEL: &str = "gpt-4o"; pub const OPEN_AI_DEFAULT_FAST_MODEL: &str = "gpt-4o-mini"; pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ @@ -85,7 +82,37 @@ struct ParsedBaseUrl { from_base_url: bool, } +/// Ensure a base URL has an explicit scheme. +/// +/// Users frequently enter hosts like `localhost:1234` without a scheme. The +/// `url` crate parses such input as `scheme="localhost"`, `path="1234"`, +/// silently dropping both the host and the port. When no `://` is present we +/// prepend a sensible scheme (`http://` for local hosts, `https://` +/// otherwise) so the host and port survive parsing. +pub(crate) fn ensure_url_scheme(raw_url: &str) -> String { + let trimmed = raw_url.trim(); + if trimmed.contains("://") { + return trimmed.to_string(); + } + + let host_part = trimmed.split(['/', '?']).next().unwrap_or(trimmed); + let bare_host = if let Some(rest) = host_part.strip_prefix('[') { + rest.split(']').next().unwrap_or(rest) + } else { + host_part.split(':').next().unwrap_or(host_part) + }; + let is_local = bare_host == "localhost" + || bare_host == "127.0.0.1" + || bare_host == "0.0.0.0" + || bare_host == "::1"; + + let scheme = if is_local { "http" } else { "https" }; + format!("{}://{}", scheme, trimmed) +} + pub(crate) fn parse_openai_base_url(raw_url: &str) -> Result { + let raw_url = ensure_url_scheme(raw_url); + let raw_url = raw_url.as_str(); let parsed = url::Url::parse(raw_url) .map_err(|e| anyhow::anyhow!("Invalid OPENAI_BASE_URL '{}': {}", raw_url, e))?; @@ -128,7 +155,10 @@ pub struct OpenAiProvider { } impl OpenAiProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); // Resolve host and base_path. @@ -214,7 +244,11 @@ impl OpenAiProvider { .map(|h| h == "api.openai.com" || h.ends_with(".api.openai.com")) .unwrap_or(false); let model = if is_openai { - model.with_fast(OPEN_AI_DEFAULT_FAST_MODEL, OPEN_AI_PROVIDER_NAME)? + crate::model_config::with_configured_fast_model( + model, + OPEN_AI_PROVIDER_NAME, + OPEN_AI_DEFAULT_FAST_MODEL, + )? } else { model }; @@ -238,10 +272,11 @@ impl OpenAiProvider { Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), _ => AuthMethod::NoAuth, }; - let mut api_client = ApiClient::with_timeout( + let mut api_client = ApiClient::with_timeout_and_tls( parsed.host, auth, std::time::Duration::from_secs(timeout_secs), + tls_config, )?; if !parsed.query_params.is_empty() { @@ -266,7 +301,7 @@ impl OpenAiProvider { api_client = api_client.with_headers(header_map)?; } - Ok(Self { + let mut provider = Self { api_client, base_path, organization, @@ -279,7 +314,30 @@ impl OpenAiProvider { dynamic_models: None, skip_canonical_filtering: false, preserve_thinking_context: !is_openai, - }) + }; + + // Only fill the context limit when nothing else set it: an existing value may be + // an explicit GOOSE_CONTEXT_LIMIT, an ACP/server per-session override, or a + // GOOSE_PREDEFINED_MODELS entry, none of which we should overwrite. llama.cpp and + // Ollama report the real allocated window via the non-standard meta.n_ctx field; + // reading it fixes auto-compaction for local servers that would otherwise fall + // back to DEFAULT_CONTEXT_LIMIT. The probe is bounded by a short timeout so a + // hung /v1/models can't stall provider construction (the shared ApiClient uses + // OPENAI_TIMEOUT, up to 600s). + if provider.model.context_limit.is_none() { + const N_CTX_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + let model_name = provider.model.model_name.clone(); + if let Ok(Some(n_ctx)) = tokio::time::timeout( + N_CTX_PROBE_TIMEOUT, + provider.fetch_n_ctx_from_api(&model_name), + ) + .await + { + provider.model.context_limit = Some(n_ctx); + } + } + + Ok(provider) } #[doc(hidden)] @@ -350,6 +408,7 @@ impl OpenAiProvider { pub fn from_custom_config( model: ModelConfig, config: DeclarativeProviderConfig, + tls_config: Option, ) -> Result { let custom_models = if !config.models.is_empty() { Some( @@ -374,7 +433,8 @@ impl OpenAiProvider { let global_config = crate::config::Config::global(); let api_key = Self::resolve_api_key(&config, &|key| global_config.get_secret(key))?; - let url = url::Url::parse(&config.base_url) + let normalized_base_url = ensure_url_scheme(&config.base_url); + let url = url::Url::parse(&normalized_base_url) .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; let host = if let Some(port) = url.port() { @@ -401,8 +461,12 @@ impl OpenAiProvider { Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), _ => AuthMethod::NoAuth, }; - let mut api_client = - ApiClient::with_timeout(host, auth, std::time::Duration::from_secs(timeout_secs))?; + let mut api_client = ApiClient::with_timeout_and_tls( + host, + auth, + std::time::Duration::from_secs(timeout_secs), + tls_config, + )?; // Add custom headers if present if let Some(headers) = &config.headers { @@ -416,7 +480,7 @@ impl OpenAiProvider { } let model = if let Some(ref fast_model_name) = config.fast_model { - model.with_fast(fast_model_name, &config.name)? + crate::model_config::with_configured_fast_model(model, &config.name, fast_model_name)? } else { model }; @@ -452,13 +516,21 @@ impl OpenAiProvider { let normalized = stripped.trim_end_matches('/'); if normalized.is_empty() { "v1/chat/completions".to_string() - } else if normalized == "v1" || normalized.ends_with("/v1") { + } else if normalized.ends_with("chat/completions") { + stripped.to_string() + } else if Self::ends_with_version_segment(normalized) { format!("{}/chat/completions", normalized) } else { - stripped.to_string() + format!("{}/v1/chat/completions", normalized) } } + fn ends_with_version_segment(path: &str) -> bool { + let last = path.rsplit('/').next().unwrap_or(path); + last.strip_prefix('v') + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) + } + fn normalize_base_path(base_path: &str) -> String { if let Some(path) = base_path.strip_prefix('/') { format!("/{}", path.trim_end_matches('/')) @@ -612,6 +684,50 @@ impl OpenAiProvider { models.sort(); Ok(models) } + + /// llama.cpp and Ollama expose the actual allocated context window in the + /// non-standard `meta.n_ctx` field of `/v1/models`. Returns `None` when absent + /// (e.g. real OpenAI). + async fn fetch_n_ctx_from_api(&self, model_name: &str) -> Option { + let models_path = + Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); + let response = self + .api_client + .request(None, &models_path) + .response_get() + .await + .ok()?; + let json = handle_response_openai_compat(response).await.ok()?; + parse_n_ctx_from_models(&json, model_name) + } +} + +/// Extract `meta.n_ctx` for `model_name` from a `/v1/models` response body. +fn parse_n_ctx_from_models(json: &serde_json::Value, model_name: &str) -> Option { + let data = json.get("data")?.as_array()?; + + let n_ctx = |entry: &serde_json::Value| -> Option { + entry + .get("meta")? + .get("n_ctx")? + .as_u64() + .map(|v| v as usize) + }; + + if let Some(entry) = data + .iter() + .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(model_name)) + { + return n_ctx(entry); + } + + // For single-model servers without --alias, llama.cpp reports the loaded model + // file path as id rather than the client's alias, so no entry matches above. + // Fall back to the sole entry's n_ctx. + match data.as_slice() { + [only] => n_ctx(only), + _ => None, + } } impl ProviderDef for OpenAiProvider { @@ -663,60 +779,9 @@ impl ProviderDef for OpenAiProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) - } - - fn supports_inventory_refresh() -> bool { - true - } - - fn inventory_configured() -> bool { - let config = crate::config::Config::global(); - // If the host is explicitly set to something non-default, trust the user's - // custom setup (e.g. a local server that doesn't require an API key). - if let Ok(host) = config.get_param::("OPENAI_HOST") { - if host != "https://api.openai.com" { - return true; - } - } - // Standard OpenAI endpoint requires an API key. - config - .get_secret::("OPENAI_API_KEY") - .is_ok() - } - - fn inventory_identity() -> Result { - let config = crate::config::Config::global(); - let mut identity = - InventoryIdentityInput::new(OPEN_AI_PROVIDER_NAME, OPEN_AI_PROVIDER_NAME) - .with_public( - "host", - config - .get_param::("OPENAI_HOST") - .unwrap_or_else(|_| "https://api.openai.com".to_string()), - ) - .with_public( - "base_path", - config - .get_param::("OPENAI_BASE_PATH") - .unwrap_or_else(|_| OPEN_AI_DEFAULT_BASE_PATH.to_string()), - ); - - if let Ok(organization) = config.get_param::("OPENAI_ORGANIZATION") { - identity = identity.with_public("organization", organization); - } - if let Ok(project) = config.get_param::("OPENAI_PROJECT") { - identity = identity.with_public("project", project); - } - if let Some(api_key) = config_secret_value(config, "OPENAI_API_KEY") { - identity = identity.with_secret("api_key", api_key); - } - if let Some(custom_headers) = config_secret_value(config, "OPENAI_CUSTOM_HEADERS") { - identity = identity.with_secret("custom_headers", custom_headers); - } - - Ok(identity) + Box::pin(Self::from_env(model, tls_config)) } } @@ -756,20 +821,6 @@ impl Provider for OpenAiProvider { self.fetch_models_from_api().await } - fn supports_embeddings(&self) -> bool { - true - } - - async fn create_embeddings( - &self, - session_id: &str, - texts: Vec, - ) -> Result>, ProviderError> { - EmbeddingCapable::create_embeddings(self, session_id, texts) - .await - .map_err(|e| ProviderError::ExecutionError(e.to_string())) - } - async fn stream( &self, model_config: &ModelConfig, @@ -782,7 +833,7 @@ impl Provider for OpenAiProvider { let mut payload = create_responses_request(model_config, system, messages, tools)?; payload["stream"] = serde_json::Value::Bool(self.supports_streaming); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -834,13 +885,7 @@ impl Provider for OpenAiProvider { } } else { let payload = create_request_with_options( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -851,7 +896,7 @@ impl Provider for OpenAiProvider { }, )?; let payload = self.sanitize_request_for_compat(payload); - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { @@ -902,68 +947,6 @@ fn parse_custom_headers(s: String) -> HashMap { .collect() } -#[async_trait] -impl EmbeddingCapable for OpenAiProvider { - async fn create_embeddings( - &self, - session_id: &str, - texts: Vec, - ) -> Result>> { - if texts.is_empty() { - return Ok(vec![]); - } - - let embedding_model = std::env::var("GOOSE_EMBEDDING_MODEL") - .unwrap_or_else(|_| "text-embedding-3-small".to_string()); - - let request = EmbeddingRequest { - input: texts, - model: embedding_model, - }; - - let response = self - .with_retry(|| async { - let request_clone = EmbeddingRequest { - input: request.input.clone(), - model: request.model.clone(), - }; - let request_value = serde_json::to_value(request_clone) - .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; - let embeddings_path = Self::map_base_path( - &self.base_path, - "embeddings", - OPEN_AI_DEFAULT_EMBEDDINGS_PATH, - ); - self.api_client - .api_post(Some(session_id), &embeddings_path, &request_value) - .await - .map_err(|e| ProviderError::ExecutionError(e.to_string())) - }) - .await?; - - if response.status != StatusCode::OK { - let error_text = response - .payload - .as_ref() - .and_then(|p| p.as_str()) - .unwrap_or("Unknown error"); - return Err(anyhow::anyhow!("Embedding API error: {}", error_text)); - } - - let embedding_response: EmbeddingResponse = serde_json::from_value( - response - .payload - .ok_or_else(|| anyhow::anyhow!("Empty response body"))?, - )?; - - Ok(embedding_response - .data - .into_iter() - .map(|d| d.embedding) - .collect()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -971,7 +954,12 @@ mod tests { fn make_provider(name: &str) -> OpenAiProvider { OpenAiProvider { - api_client: ApiClient::new("http://localhost".to_string(), AuthMethod::NoAuth).unwrap(), + api_client: ApiClient::new_with_tls( + "http://localhost".to_string(), + AuthMethod::NoAuth, + None, + ) + .unwrap(), base_path: "v1/chat/completions".to_string(), organization: None, project: None, @@ -1213,6 +1201,39 @@ mod tests { assert!(!r.has_v1); } + #[test] + fn derive_base_path_not_removing_api_path() { + let r = OpenAiProvider::derive_base_path("https://opencode.ai/zen/go"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_v1() { + let r = OpenAiProvider::derive_base_path("https://opencode.ai/zen/go/v1"); + assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions"); + } + + #[test] + fn derive_base_path_should_support_no_base_path() { + let r = OpenAiProvider::derive_base_path("https://opencode.ai/"); + assert_eq!(r, "https://opencode.ai/v1/chat/completions"); + } + + #[test] + fn derive_base_path_preserves_non_v1_version_prefix() { + // Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and + // from_custom_config passes url.path() ("/api/paas/v4") here. The + // existing /api/paas/v4 version must not gain an extra /v1 segment. + let r = OpenAiProvider::derive_base_path("/api/paas/v4"); + assert_eq!(r, "api/paas/v4/chat/completions"); + } + + #[test] + fn derive_base_path_does_not_treat_v_word_as_version() { + let r = OpenAiProvider::derive_base_path("/api/voice"); + assert_eq!(r, "api/voice/v1/chat/completions"); + } + #[test] fn parse_base_url_preserves_query_params() { let r = OpenAiProvider::parse_base_url("https://gw.example.com/v1?api-version=2024-02-01") @@ -1270,7 +1291,8 @@ mod tests { dynamic_models: Option, ) -> OpenAiProvider { OpenAiProvider { - api_client: ApiClient::new(server_uri.to_string(), AuthMethod::NoAuth).unwrap(), + api_client: ApiClient::new_with_tls(server_uri.to_string(), AuthMethod::NoAuth, None) + .unwrap(), base_path: "v1/chat/completions".to_string(), organization: None, project: None, @@ -1313,6 +1335,52 @@ mod tests { } } + #[test] + fn ensure_url_scheme_adds_http_for_local_hosts() { + assert_eq!(ensure_url_scheme("localhost:1234"), "http://localhost:1234"); + assert_eq!( + ensure_url_scheme("127.0.0.1:8080/v1"), + "http://127.0.0.1:8080/v1" + ); + assert_eq!(ensure_url_scheme("0.0.0.0:3000"), "http://0.0.0.0:3000"); + assert_eq!(ensure_url_scheme("[::1]:1234"), "http://[::1]:1234"); + } + + #[test] + fn ensure_url_scheme_adds_https_for_remote_hosts() { + assert_eq!( + ensure_url_scheme("api.example.com:8443/v1"), + "https://api.example.com:8443/v1" + ); + assert_eq!(ensure_url_scheme("example.com"), "https://example.com"); + } + + #[test] + fn ensure_url_scheme_preserves_existing_scheme() { + assert_eq!( + ensure_url_scheme("http://localhost:1234"), + "http://localhost:1234" + ); + assert_eq!( + ensure_url_scheme("https://api.openai.com/v1"), + "https://api.openai.com/v1" + ); + } + + #[test] + fn from_custom_config_preserves_port_without_scheme() { + let mut config = + base_declarative_config(vec![ModelInfo::new("m1".to_string(), 128000)], None); + config.base_url = "localhost:1234".to_string(); + + let provider = + OpenAiProvider::from_custom_config(ModelConfig::new_or_fail("m1"), config, None) + .unwrap(); + + assert_eq!(provider.api_client.host(), "http://localhost:1234"); + assert_eq!(provider.base_path, "v1/chat/completions"); + } + #[tokio::test] async fn fetch_supported_models_static_only_skips_api() { // Any request to the mock returns 500 — if the fix calls the API, the test fails. @@ -1335,11 +1403,12 @@ mod tests { #[test] fn from_custom_config_rejects_static_only_without_models() { let config = base_declarative_config(vec![], Some(false)); - let err = - OpenAiProvider::from_custom_config(ModelConfig::new_or_fail("test-model"), config) - .expect_err( - "expected construction error for dynamic_models: false with empty models", - ); + let err = OpenAiProvider::from_custom_config( + ModelConfig::new_or_fail("test-model"), + config, + None, + ) + .expect_err("expected construction error for dynamic_models: false with empty models"); let msg = err.to_string(); assert!( msg.contains("dynamic_models: false"), @@ -1425,4 +1494,25 @@ mod tests { None ); } + + #[test] + fn parse_n_ctx_falls_back_to_sole_entry_when_id_differs() { + let body = json!({ + "data": [ + { "id": "/models/qwen3.gguf", "meta": { "n_ctx": 32768 } } + ] + }); + assert_eq!(parse_n_ctx_from_models(&body, "qwen3"), Some(32768)); + } + + #[test] + fn parse_n_ctx_no_fallback_with_multiple_unmatched_entries() { + let body = json!({ + "data": [ + { "id": "model-a", "meta": { "n_ctx": 4096 } }, + { "id": "model-b", "meta": { "n_ctx": 8192 } } + ] + }); + assert_eq!(parse_n_ctx_from_models(&body, "model-c"), None); + } } diff --git a/crates/goose/src/providers/openai_compatible.rs b/crates/goose/src/providers/openai_compatible.rs index b091a3979c0f..e62ba30b0b78 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose/src/providers/openai_compatible.rs @@ -15,15 +15,14 @@ use tokio_util::io::StreamReader; use super::api_client::ApiClient; use super::base::{stream_from_single_message, MessageStream, Provider}; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::conversation::message::Message; -use crate::model::ModelConfig; use crate::providers::formats::openai_responses::responses_api_to_streaming_message; use goose_providers::errors::ProviderError; use goose_providers::formats::openai::{ create_request, get_usage, response_to_message, response_to_streaming_message, - ModelConfigParams, }; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt, RequestLogHandle}; use rmcp::model::Tool; pub struct OpenAiCompatibleProvider { @@ -66,13 +65,7 @@ impl OpenAiCompatibleProvider { for_streaming: bool, ) -> Result { create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -135,7 +128,7 @@ impl Provider for OpenAiCompatibleProvider { tools, self.supports_streaming, )?; - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let completions_path = format!("{}chat/completions", self.completions_prefix); let response = self @@ -186,7 +179,7 @@ pub use super::http_status::handle_response as handle_response_openai_compat; pub fn stream_openai_compat( response: Response, - mut log: RequestLog, + mut log: Option>, ) -> Result { let stream = response.bytes_stream().map_err(std::io::Error::other); @@ -210,7 +203,7 @@ pub fn stream_openai_compat( pub fn stream_responses_compat( response: Response, - mut log: RequestLog, + mut log: Option>, ) -> Result { let stream = response.bytes_stream().map_err(std::io::Error::other); @@ -235,7 +228,7 @@ pub fn stream_responses_compat( #[cfg(test)] mod tests { use super::*; - use crate::model::ModelConfig; + use goose_providers::model::ModelConfig; use serde_json::json; use test_case::test_case; @@ -313,9 +306,10 @@ mod tests { fn build_request_respects_non_streaming_mode() { let provider = OpenAiCompatibleProvider::new( "test".to_string(), - ApiClient::new( + ApiClient::new_with_tls( "http://localhost".to_string(), super::super::api_client::AuthMethod::NoAuth, + None, ) .unwrap(), ModelConfig::new_or_fail("test-model"), diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index 7d1ad92b0462..695d77a20da2 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -8,12 +8,12 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::openai_compatible::{handle_status, stream_openai_compat}; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::conversation::message::Message; -use crate::model::ModelConfig; use crate::providers::formats::openrouter as openrouter_format; use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::{create_request, ModelConfigParams}; +use goose_providers::formats::openai::create_request; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; pub const OPENROUTER_PROVIDER_NAME: &str = "openrouter"; @@ -47,8 +47,15 @@ pub struct OpenRouterProvider { } impl OpenRouterProvider { - pub async fn from_env(model: ModelConfig) -> Result { - let model = model.with_fast(OPENROUTER_DEFAULT_FAST_MODEL, OPENROUTER_PROVIDER_NAME)?; + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { + let model = crate::model_config::with_configured_fast_model( + model, + OPENROUTER_PROVIDER_NAME, + OPENROUTER_DEFAULT_FAST_MODEL, + )?; let config = crate::config::Config::global(); let api_key: String = config.get_secret("OPENROUTER_API_KEY")?; @@ -57,7 +64,7 @@ impl OpenRouterProvider { .unwrap_or_else(|_| "https://openrouter.ai".to_string()); let auth = AuthMethod::BearerToken(api_key); - let api_client = ApiClient::new(host, auth)? + let api_client = ApiClient::new_with_tls(host, auth, tls_config)? .with_header("HTTP-Referer", "https://goose-docs.ai")? .with_header("X-Title", "goose")?; @@ -179,8 +186,9 @@ impl ProviderDef for OpenRouterProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -257,13 +265,7 @@ impl Provider for OpenRouterProvider { tools: &[Tool], ) -> Result { let mut payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -291,7 +293,7 @@ impl Provider for OpenRouterProvider { obj.insert("transforms".to_string(), json!(["middle-out"])); } - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { diff --git a/crates/goose/src/providers/pi_acp.rs b/crates/goose/src/providers/pi_acp.rs index 5e36292a41b2..dcb292db392a 100644 --- a/crates/goose/src/providers/pi_acp.rs +++ b/crates/goose/src/providers/pi_acp.rs @@ -8,14 +8,12 @@ use crate::acp::{ }; use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; -use crate::model::ModelConfig; -use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{current_working_dir, ProviderDef, ProviderMetadata}; -use crate::providers::inventory::InventoryIdentityInput; +use goose_providers::model::ModelConfig; -const PI_ACP_PROVIDER_NAME: &str = "pi-acp"; +pub(crate) const PI_ACP_PROVIDER_NAME: &str = "pi-acp"; const PI_ACP_DOC_URL: &str = "https://github.com/anthropics/pi"; -const PI_ACP_BINARY: &str = "pi-acp"; +pub(crate) const PI_ACP_BINARY: &str = "pi-acp"; pub struct PiAcpProvider; @@ -44,14 +42,16 @@ impl ProviderDef for PiAcpProvider { fn from_env( model: ModelConfig, extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Self::from_env_with_working_dir(model, extensions, current_working_dir()) + Self::from_env_with_working_dir(model, extensions, current_working_dir(), tls_config) } fn from_env_with_working_dir( model: ModelConfig, extensions: Vec, working_dir: PathBuf, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = Config::global(); @@ -81,16 +81,4 @@ impl ProviderDef for PiAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } - - fn supports_inventory_refresh() -> bool { - false - } - - fn inventory_identity() -> Result { - acp_inventory_identity(PI_ACP_PROVIDER_NAME, PI_ACP_BINARY) - } - - fn inventory_configured() -> bool { - acp_adapter_installed(PI_ACP_BINARY) - } } diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index b3da205e57b2..dc313a1889ae 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -1,9 +1,10 @@ +use super::api_client::TlsConfig; use super::base::{ConfigKey, ModelInfo, Provider, ProviderDef, ProviderMetadata, ProviderType}; -use super::inventory::InventoryIdentityInput; +use super::inventory::{InventoryIdentityInput, InventoryRegistration, InventoryResolvers}; use crate::config::{DeclarativeProviderConfig, ExtensionConfig}; -use crate::model::ModelConfig; use anyhow::Result; use futures::future::BoxFuture; +use goose_providers::model::ModelConfig; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -13,6 +14,7 @@ pub type ProviderConstructor = Arc< ModelConfig, Vec, Option, + Option, ) -> BoxFuture<'static, Result>> + Send + Sync, @@ -20,20 +22,16 @@ pub type ProviderConstructor = Arc< pub type ProviderCleanup = Arc BoxFuture<'static, Result<()>> + Send + Sync>; -pub type ProviderInventoryIdentityResolver = - Arc Result + Send + Sync>; - -pub type ProviderInventoryConfiguredResolver = Arc bool + Send + Sync>; - #[derive(Clone)] pub struct ProviderEntry { metadata: ProviderMetadata, pub(crate) constructor: ProviderConstructor, - pub(crate) inventory_identity: ProviderInventoryIdentityResolver, - pub(crate) inventory_configured: ProviderInventoryConfiguredResolver, + pub(crate) inventory_identity: super::inventory::InventoryIdentityResolver, + pub(crate) inventory_configured: super::inventory::InventoryConfiguredResolver, pub(crate) cleanup: Option, provider_type: ProviderType, supports_inventory_refresh: bool, + tls_config: Option, } impl ProviderEntry { @@ -57,8 +55,8 @@ impl ProviderEntry { (self.inventory_configured)() } - fn normalize_model_config(&self, mut model: ModelConfig) -> ModelConfig { - model = model.with_canonical_limits(&self.metadata.name); + fn normalize_model_config(&self, mut model: ModelConfig) -> Result { + model = crate::model_config::materialize_model_config(&self.metadata.name, model)?; if model.context_limit.is_none() { if let Some(info) = self @@ -71,16 +69,19 @@ impl ProviderEntry { } } - model + Ok(model) } pub async fn create_with_default_model( &self, extensions: Vec, ) -> Result> { - let default_model = &self.metadata.default_model; - let model_config = self.normalize_model_config(ModelConfig::new(default_model.as_str())?); - (self.constructor)(model_config, extensions, None).await + let model_config = crate::model_config::model_config_from_user_config( + &self.metadata.name, + &self.metadata.default_model, + )?; + let model_config = self.normalize_model_config(model_config)?; + (self.constructor)(model_config, extensions, None, self.tls_config.clone()).await } pub async fn create( @@ -88,8 +89,8 @@ impl ProviderEntry { model: ModelConfig, extensions: Vec, ) -> Result> { - let model = self.normalize_model_config(model); - (self.constructor)(model, extensions, None).await + let model = self.normalize_model_config(model)?; + (self.constructor)(model, extensions, None, self.tls_config.clone()).await } pub async fn create_with_working_dir( @@ -98,54 +99,81 @@ impl ProviderEntry { extensions: Vec, working_dir: PathBuf, ) -> Result> { - let model = self.normalize_model_config(model); - (self.constructor)(model, extensions, Some(working_dir)).await + let model = self.normalize_model_config(model)?; + (self.constructor)( + model, + extensions, + Some(working_dir), + self.tls_config.clone(), + ) + .await } } #[derive(Default)] pub struct ProviderRegistry { pub(crate) entries: HashMap, + tls_config: Option, } impl ProviderRegistry { - pub fn new() -> Self { + pub fn new(tls_config: Option) -> Self { Self { entries: HashMap::new(), + tls_config, } } pub fn register(&mut self, preferred: bool) where F: ProviderDef + 'static, + { + self.register_with_inventory::(preferred, None); + } + + pub fn register_with_inventory( + &mut self, + preferred: bool, + inventory_registration: Option, + ) where + F: ProviderDef + 'static, { let metadata = F::metadata(); let name = metadata.name.clone(); + let inventory = InventoryResolvers::for_metadata(&metadata, inventory_registration); + self.entries.insert( name, ProviderEntry { metadata, - constructor: Arc::new(|model, extensions, working_dir| { + constructor: Arc::new(|model, extensions, working_dir, tls_config| { Box::pin(async move { let provider = match working_dir { Some(working_dir) => { - F::from_env_with_working_dir(model, extensions, working_dir).await? + F::from_env_with_working_dir( + model, + extensions, + working_dir, + tls_config, + ) + .await? } - None => F::from_env(model, extensions).await?, + None => F::from_env(model, extensions, tls_config).await?, }; Ok(Arc::new(provider) as Arc) }) }), - inventory_identity: Arc::new(F::inventory_identity), - inventory_configured: Arc::new(F::inventory_configured), + inventory_identity: inventory.identity, + inventory_configured: inventory.configured, cleanup: None, provider_type: if preferred { ProviderType::Preferred } else { ProviderType::Builtin }, - supports_inventory_refresh: F::supports_inventory_refresh(), + supports_inventory_refresh: inventory.supports_refresh, + tls_config: self.tls_config.clone(), }, ); } @@ -159,7 +187,7 @@ impl ProviderRegistry { inventory_identity: G, ) where P: ProviderDef + 'static, - F: Fn(ModelConfig) -> Result + Send + Sync + 'static, + F: Fn(ModelConfig, Option) -> Result + Send + Sync + 'static, G: Fn() -> Result + Send + Sync + 'static, { self.register_with_name_impl::( @@ -182,7 +210,7 @@ impl ProviderRegistry { inventory_configured: H, ) where P: ProviderDef + 'static, - F: Fn(ModelConfig) -> Result + Send + Sync + 'static, + F: Fn(ModelConfig, Option) -> Result + Send + Sync + 'static, G: Fn() -> Result + Send + Sync + 'static, H: Fn() -> bool + Send + Sync + 'static, { @@ -203,10 +231,10 @@ impl ProviderRegistry { supports_inventory_refresh: bool, constructor: F, inventory_identity: G, - inventory_configured: Option, + inventory_configured: Option, ) where P: ProviderDef + 'static, - F: Fn(ModelConfig) -> Result + Send + Sync + 'static, + F: Fn(ModelConfig, Option) -> Result + Send + Sync + 'static, G: Fn() -> Result + Send + Sync + 'static, { let base_metadata = P::metadata(); @@ -301,8 +329,8 @@ impl ProviderRegistry { config.name.clone(), ProviderEntry { metadata: custom_metadata, - constructor: Arc::new(move |model, _extensions, _working_dir| { - let result = constructor(model); + constructor: Arc::new(move |model, _extensions, _working_dir, tls_config| { + let result = constructor(model, tls_config); Box::pin(async move { let provider = result?; Ok(Arc::new(provider) as Arc) @@ -313,6 +341,7 @@ impl ProviderRegistry { cleanup: None, provider_type, supports_inventory_refresh, + tls_config: self.tls_config.clone(), }, ); } @@ -390,12 +419,12 @@ mod tests { #[test] fn register_with_name_can_override_inventory_configured() { - let mut registry = ProviderRegistry::new(); + let mut registry = ProviderRegistry::new(None); registry.register_with_name_and_inventory_configured::( &test_config(), ProviderType::Declarative, false, - |_| unreachable!("constructor is not used by this test"), + |_, _| unreachable!("constructor is not used by this test"), || Ok(InventoryIdentityInput::new("custom_hf", "huggingface")), || false, ); diff --git a/crates/goose/src/providers/provider_test.rs b/crates/goose/src/providers/provider_test.rs index b8784d5ff257..7bb979a76279 100644 --- a/crates/goose/src/providers/provider_test.rs +++ b/crates/goose/src/providers/provider_test.rs @@ -1,4 +1,4 @@ -use crate::{conversation::message::Message, model::ModelConfig, providers::create}; +use crate::{conversation::message::Message, providers::create}; use anyhow::Result; use futures::StreamExt; use rmcp::model::ToolAnnotations; @@ -10,8 +10,7 @@ pub async fn test_provider_configuration( toolshim_enabled: bool, toolshim_model: Option, ) -> Result<()> { - let model_config = ModelConfig::new(model)? - .with_canonical_limits(provider_name) + let model_config = crate::model_config::model_config_from_user_config(provider_name, model)? .with_max_tokens(Some(50)) .with_toolshim(toolshim_enabled) .with_toolshim_model(toolshim_model); diff --git a/crates/goose/src/providers/sagemaker_tgi.rs b/crates/goose/src/providers/sagemaker_tgi.rs index bc4ba5d72c6d..9aaa6facef01 100644 --- a/crates/goose/src/providers/sagemaker_tgi.rs +++ b/crates/goose/src/providers/sagemaker_tgi.rs @@ -12,15 +12,15 @@ use smithy_transport_reqwest::ReqwestHttpClient; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::conversation::message::{Message, MessageContent}; use crate::session_context::SESSION_ID_HEADER; use goose_providers::errors::ProviderError; -use crate::model::ModelConfig; use chrono::Utc; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Role; const SAGEMAKER_TGI_PROVIDER_NAME: &str = "sagemaker_tgi"; @@ -40,7 +40,10 @@ pub struct SageMakerTgiProvider { } impl SageMakerTgiProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + _tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); // Get SageMaker endpoint name (just the name, not full URL) @@ -297,8 +300,9 @@ impl ProviderDef for SageMakerTgiProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -350,7 +354,7 @@ impl Provider for SageMakerTgiProvider { "messages": messages, "tools": tools }); - let mut log = RequestLog::start(&self.model, &debug_payload)?; + let mut log = start_log(&self.model, &debug_payload)?; log.write( &serde_json::to_value(&message).unwrap_or_default(), Some(&usage), diff --git a/crates/goose/src/providers/snowflake.rs b/crates/goose/src/providers/snowflake.rs index 7f3639a688a2..4b7923f17ad0 100644 --- a/crates/goose/src/providers/snowflake.rs +++ b/crates/goose/src/providers/snowflake.rs @@ -10,13 +10,14 @@ use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetad use super::formats::snowflake::{create_request, get_usage, response_to_message}; use super::openai_compatible::{map_http_error_to_provider_error, sanitize_url}; use super::retry::ProviderRetry; -use super::utils::{get_model, RequestLog}; +use super::utils::get_model; use crate::config::ConfigError; use crate::conversation::message::Message; use goose_providers::errors::ProviderError; -use crate::model::ModelConfig; use futures::future::BoxFuture; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; const SNOWFLAKE_PROVIDER_NAME: &str = "snowflake"; @@ -58,7 +59,10 @@ pub struct SnowflakeProvider { } impl SnowflakeProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let mut host: Result = config.get_param("SNOWFLAKE_HOST"); if host.is_err() { @@ -102,7 +106,8 @@ impl SnowflakeProvider { }; let auth = AuthMethod::BearerToken(token?); - let api_client = ApiClient::new(base_url, auth)?.with_header("User-Agent", "goose")?; + let api_client = ApiClient::new_with_tls(base_url, auth, tls_config)? + .with_header("User-Agent", "goose")?; Ok(Self { api_client, @@ -319,8 +324,9 @@ impl ProviderDef for SnowflakeProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -356,7 +362,7 @@ impl Provider for SnowflakeProvider { }; let payload = create_request(model_config, system, messages, tools)?; - let mut log = RequestLog::start(&self.model, &payload)?; + let mut log = start_log(&self.model, &payload)?; let response = self .with_retry(|| async { diff --git a/crates/goose/src/providers/testprovider.rs b/crates/goose/src/providers/testprovider.rs index ef8cb5c52e6d..78ba36789468 100644 --- a/crates/goose/src/providers/testprovider.rs +++ b/crates/goose/src/providers/testprovider.rs @@ -11,11 +11,11 @@ use std::sync::{Arc, Mutex}; use super::base::stream_from_single_message; use super::base::{MessageStream, Provider, ProviderDef, ProviderMetadata}; use crate::conversation::message::{Message, ToolResponse}; -use crate::model::ModelConfig; use crate::utils::bytes_to_hex; use futures::future::BoxFuture; use goose_providers::conversation::token_usage::ProviderUsage; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use rmcp::model::{CallToolResult, Tool}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -156,6 +156,7 @@ impl ProviderDef for TestProvider { fn from_env( _model: ModelConfig, _extensions: Vec, + _tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async { Err(anyhow!("TestProvider must be constructed explicitly")) }) } diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 00d4e9c77127..2393430308aa 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -5,7 +5,6 @@ use super::openai_compatible::{ stream_openai_compat, }; use super::retry::ProviderRetry; -use super::utils::RequestLog; use crate::config::signup_tetrate::TETRATE_DEFAULT_MODEL; use crate::conversation::message::Message; use anyhow::Result; @@ -14,8 +13,9 @@ use futures::future::BoxFuture; use goose_providers::errors::ProviderError; use goose_providers::images::ImageFormat; -use crate::model::ModelConfig; -use goose_providers::formats::openai::{create_request, ModelConfigParams}; +use goose_providers::formats::openai::create_request; +use goose_providers::model::ModelConfig; +use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; use serde_json::Value; @@ -47,7 +47,10 @@ pub struct TetrateProvider { } impl TetrateProvider { - pub async fn from_env(model: ModelConfig) -> Result { + pub async fn from_env( + model: ModelConfig, + tls_config: Option, + ) -> Result { let config = crate::config::Config::global(); let api_key: String = config.get_secret("TETRATE_API_KEY")?; let host: String = config @@ -55,7 +58,7 @@ impl TetrateProvider { .unwrap_or_else(|_| "https://api.router.tetrate.ai".to_string()); let auth = AuthMethod::BearerToken(api_key); - let api_client = ApiClient::new(host, auth)? + let api_client = ApiClient::new_with_tls(host, auth, tls_config)? .with_header("HTTP-Referer", "https://goose-docs.ai")? .with_header("X-Title", "goose")?; @@ -116,8 +119,9 @@ impl ProviderDef for TetrateProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(model)) + Box::pin(Self::from_env(model, tls_config)) } } @@ -140,13 +144,7 @@ impl Provider for TetrateProvider { tools: &[Tool], ) -> Result { let payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + model_config, system, messages, tools, @@ -154,7 +152,7 @@ impl Provider for TetrateProvider { true, )?; - let mut log = RequestLog::start(model_config, &payload)?; + let mut log = start_log(model_config, &payload)?; let response = self .with_retry(|| async { diff --git a/crates/goose/src/providers/toolshim.rs b/crates/goose/src/providers/toolshim.rs index 58f63297e81d..4e2b87570edc 100644 --- a/crates/goose/src/providers/toolshim.rs +++ b/crates/goose/src/providers/toolshim.rs @@ -36,13 +36,12 @@ use super::ollama::OLLAMA_DEFAULT_PORT; use super::ollama::OLLAMA_HOST; use crate::conversation::message::{Message, MessageContent}; use crate::conversation::Conversation; -use crate::model::ModelConfig; +use crate::model_config::model_config_from_user_config; use crate::providers::base::DEFAULT_PROVIDER_TIMEOUT_SECS; use anyhow::Result; use futures::StreamExt; use goose_providers::errors::ProviderError; use goose_providers::formats::openai::create_request; -use goose_providers::formats::openai::ModelConfigParams; use goose_providers::images::ImageFormat; use reqwest::Client; use rmcp::model::{object, CallToolRequestParams, RawContent, Tool}; @@ -567,9 +566,8 @@ impl LocalInterpreter { &self, format_instruction: &str, ) -> Result { - let model_config = ModelConfig::new(&self.model) + let model_config = crate::model_config::model_config_from_user_config("local", &self.model) .map_err(|e| ProviderError::RequestFailed(format!("Model config error: {e}")))? - .with_canonical_limits("local") .with_toolshim(false) .with_toolshim_model(None); @@ -693,18 +691,10 @@ impl OllamaInterpreter { let user_message = Message::user().with_text(format_instruction); messages.push(user_message); - let model_config = ModelConfig::new(model) - .map_err(|e| ProviderError::RequestFailed(format!("Model config error: {e}")))? - .with_canonical_limits("ollama"); + let model_config = model_config_from_user_config("ollama", model)?; let mut payload = create_request( - ModelConfigParams { - model_name: model_config.model_name.as_str(), - thinking_effort: model_config.thinking_effort(), - temperature: model_config.temperature, - max_tokens: model_config.max_tokens, - request_params: model_config.request_params.as_ref(), - }, + &model_config, system_prompt, &messages, &[], // No tools diff --git a/crates/goose/src/providers/utils.rs b/crates/goose/src/providers/utils.rs index 5044313a1964..bf3ac7ad4984 100644 --- a/crates/goose/src/providers/utils.rs +++ b/crates/goose/src/providers/utils.rs @@ -1,14 +1,14 @@ use crate::config::paths::Paths; use anyhow::{anyhow, Result}; use fs_err::File; -use goose_providers::conversation::token_usage::Usage; use goose_providers::errors::{GoogleErrorCode, ProviderError}; +use goose_providers::request_log::{install_logger, RequestLogHandle, RequestLogger}; use reqwest::{Response, StatusCode}; -use serde::Serialize; use serde_json::Value; -use std::fmt::Display; +use std::error::Error; use std::io::{BufWriter, Write}; use std::path::PathBuf; +use std::sync::OnceLock; use std::time::Duration; use uuid::Uuid; @@ -133,7 +133,9 @@ pub async fn handle_response_google_compat(response: Response) -> Result> = OnceLock::new(); + +pub fn init_goose_request_log() -> Result<()> { + INIT_LOGGER + .get_or_init(|| Ok(install_logger(RequestLog::new(LOGS_TO_KEEP)?)?)) + .as_ref() + .map_err(|e| anyhow::anyhow!("failed to set up logger: {}", e))?; + Ok(()) +} + pub struct RequestLog { + logs_to_keep: usize, +} + +impl RequestLog { + pub fn new(logs_to_keep: usize) -> Result { + let logs_dir = Paths::in_state_dir("logs"); + fs_err::create_dir_all(&logs_dir)?; + Ok(Self { logs_to_keep }) + } +} + +struct FileLogHandle { writer: Option>, temp_path: PathBuf, + logs_to_keep: usize, } -pub const LOGS_TO_KEEP: usize = 10; - -impl RequestLog { - pub fn start(model_config: ModelConfig, payload: &Payload) -> Result - where - ModelConfig: Serialize, - Payload: Serialize, - { +impl RequestLogger for RequestLog { + fn start(&self) -> Result, Box> { let logs_dir = Paths::in_state_dir("logs"); fs_err::create_dir_all(&logs_dir)?; @@ -230,7 +251,7 @@ impl RequestLog { let temp_name = format!("llm_request.{request_id}.jsonl"); let temp_path = logs_dir.join(PathBuf::from(temp_name)); - let mut writer = BufWriter::new( + let writer = BufWriter::new( File::options() .write(true) .create(true) @@ -238,53 +259,38 @@ impl RequestLog { .open(&temp_path)?, ); - let data = serde_json::json!({ - "model_config": model_config, - "input": payload, - }); - writeln!(writer, "{}", serde_json::to_string(&data)?)?; - - Ok(Self { + Ok(Box::new(FileLogHandle { writer: Some(writer), temp_path, - }) + logs_to_keep: self.logs_to_keep, + })) } +} - fn write_json(&mut self, line: &serde_json::Value) -> Result<()> { +impl RequestLogHandle for FileLogHandle { + fn write(&mut self, s: &str) -> Result<(), Box> { let writer = self .writer .as_mut() .ok_or_else(|| anyhow!("logger is finished"))?; - writeln!(writer, "{}", serde_json::to_string(line)?)?; + writeln!(writer, "{}", s)?; Ok(()) } +} - pub fn error(&mut self, error: E) -> Result<()> - where - E: Display, - { - self.write_json(&serde_json::json!({ - "error": format!("{}", error), - })) - } - - pub fn write(&mut self, data: &Payload, usage: Option<&Usage>) -> Result<()> - where - Payload: Serialize, - { - self.write_json(&serde_json::json!({ - "data": data, - "usage": usage, - })) - } - +impl FileLogHandle { fn finish(&mut self) -> Result<()> { if let Some(mut writer) = self.writer.take() { writer.flush()?; let logs_dir = Paths::in_state_dir("logs"); let log_path = |i| logs_dir.join(format!("llm_request.{}.jsonl", i)); - for i in (0..LOGS_TO_KEEP - 1).rev() { + if self.logs_to_keep == 0 { + fs_err::remove_file(&self.temp_path)?; + return Ok(()); + } + + for i in (0..self.logs_to_keep.saturating_sub(1)).rev() { let _ = fs_err::rename(log_path(i), log_path(i + 1)); } @@ -294,7 +300,7 @@ impl RequestLog { } } -impl Drop for RequestLog { +impl Drop for FileLogHandle { fn drop(&mut self) { if std::thread::panicking() { return; @@ -308,24 +314,6 @@ mod tests { use super::*; use serde_json::json; - #[test] - fn test_request_log_start_creates_logs_dir() { - let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", None::<&str>)]); - let temp_dir = tempfile::tempdir().unwrap(); - std::env::set_var("GOOSE_PATH_ROOT", temp_dir.path()); - - let logs_dir = Paths::in_state_dir("logs"); - assert!(!logs_dir.exists(), "logs dir should not exist yet"); - - let log = RequestLog::start(json!({"name": "test"}), &json!({"model": "test"})) - .expect("RequestLog::start should create missing logs dir"); - drop(log); - - assert!(logs_dir.is_dir(), "logs dir should have been created"); - - std::env::remove_var("GOOSE_PATH_ROOT"); - } - #[test] fn unescape_json_values_with_object() { let value = json!({"text": "Hello\\nWorld"}); diff --git a/crates/goose/src/providers/xai.rs b/crates/goose/src/providers/xai.rs index 7006ad39066b..e0afd1ab9a9f 100644 --- a/crates/goose/src/providers/xai.rs +++ b/crates/goose/src/providers/xai.rs @@ -1,9 +1,9 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, ProviderDef, ProviderMetadata}; use super::openai_compatible::OpenAiCompatibleProvider; -use crate::model::ModelConfig; use anyhow::Result; use futures::future::BoxFuture; +use goose_providers::model::ModelConfig; const XAI_PROVIDER_NAME: &str = "xai"; pub const XAI_API_HOST: &str = "https://api.x.ai/v1"; @@ -54,6 +54,7 @@ impl ProviderDef for XaiProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = crate::config::Config::global(); @@ -62,7 +63,8 @@ impl ProviderDef for XaiProvider { .get_param("XAI_HOST") .unwrap_or_else(|_| XAI_API_HOST.to_string()); - let api_client = ApiClient::new(host, AuthMethod::BearerToken(api_key))?; + let api_client = + ApiClient::new_with_tls(host, AuthMethod::BearerToken(api_key), tls_config)?; Ok(OpenAiCompatibleProvider::new( XAI_PROVIDER_NAME.to_string(), diff --git a/crates/goose/src/providers/xai_oauth.rs b/crates/goose/src/providers/xai_oauth.rs index 2e4e1afb0bc6..5887d11ebf40 100644 --- a/crates/goose/src/providers/xai_oauth.rs +++ b/crates/goose/src/providers/xai_oauth.rs @@ -4,7 +4,6 @@ use super::openai_compatible::OpenAiCompatibleProvider; use super::xai::{XAI_API_HOST, XAI_DEFAULT_MODEL, XAI_KNOWN_MODELS}; use crate::config::paths::Paths; use crate::conversation::message::Message; -use crate::model::ModelConfig; use anyhow::{anyhow, Result}; use async_trait::async_trait; use axum::{extract::Query, response::Html, routing::get, Router}; @@ -12,6 +11,7 @@ use base64::Engine; use chrono::{DateTime, Utc}; use futures::future::BoxFuture; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use rmcp::model::Tool; use serde::{Deserialize, Serialize}; use sha2::Digest; @@ -72,7 +72,7 @@ struct XaiAuthState { } impl XaiAuthState { - fn new() -> Self { + pub(crate) fn new() -> Self { Self { oauth_mutex: TokioMutex::new(()), refresh_mutex: TokioMutex::new(()), @@ -97,7 +97,7 @@ struct TokenData { } #[derive(Debug, Clone)] -struct TokenCache { +pub(crate) struct TokenCache { cache_path: PathBuf, } @@ -106,7 +106,7 @@ fn get_cache_path() -> PathBuf { } impl TokenCache { - fn new() -> Self { + pub(crate) fn new() -> Self { let cache_path = get_cache_path(); if let Some(parent) = cache_path.parent() { let _ = std::fs::create_dir_all(parent); @@ -118,6 +118,9 @@ impl TokenCache { let contents = std::fs::read_to_string(&self.cache_path).ok()?; serde_json::from_str(&contents).ok() } + pub(crate) fn has_token(&self) -> bool { + self.load().is_some() + } fn save(&self, token_data: &TokenData) -> Result<()> { if let Some(parent) = self.cache_path.parent() { @@ -778,6 +781,7 @@ impl ProviderDef for XaiOAuthProvider { fn from_env( model: ModelConfig, _extensions: Vec, + tls_config: Option, ) -> BoxFuture<'static, Result> { Box::pin(async move { let config = crate::config::Config::global(); @@ -787,9 +791,10 @@ impl ProviderDef for XaiOAuthProvider { let auth_provider = Arc::new(XaiOAuthAuthProvider::new(XaiAuthState::instance())); let auth_for_client = Arc::clone(&auth_provider); - let api_client = ApiClient::new( + let api_client = ApiClient::new_with_tls( host, AuthMethod::Custom(Box::new(SharedAuthProvider(auth_for_client))), + tls_config, )?; let inner = OpenAiCompatibleProvider::new( @@ -805,10 +810,6 @@ impl ProviderDef for XaiOAuthProvider { }) }) } - - fn inventory_configured() -> bool { - TokenCache::new().load().is_some() - } } /// Adapter so the same `XaiOAuthAuthProvider` can be both owned by the diff --git a/crates/goose/src/recipe/recipe_extension_adapter.rs b/crates/goose/src/recipe/recipe_extension_adapter.rs index 1e1504734c71..2d88c7aa3099 100644 --- a/crates/goose/src/recipe/recipe_extension_adapter.rs +++ b/crates/goose/src/recipe/recipe_extension_adapter.rs @@ -20,6 +20,8 @@ enum RecipeExtensionConfigInternal { env_keys: Vec, timeout: Option, #[serde(default)] + cwd: Option, + #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, @@ -122,6 +124,7 @@ impl From for ExtensionConfig { envs, env_keys, timeout, + cwd, bundled, available_tools }, diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 3e646bed323c..4aab4dbf088e 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -835,7 +835,7 @@ async fn execute_job( let provider_name = config.get_goose_provider()?; let model_name = config.get_goose_model()?; let model_config = - crate::model::ModelConfig::new(&model_name)?.with_canonical_limits(&provider_name); + crate::model_config::model_config_from_user_config(&provider_name, &model_name)?; let session = agent .config @@ -848,7 +848,12 @@ async fn execute_job( ) .await?; - let extensions = resolve_extensions_for_new_session(recipe.extensions.as_deref(), None); + let mut extensions = resolve_extensions_for_new_session(recipe.extensions.as_deref(), None); + if recipe.extensions.is_none() { + extensions.extend(crate::plugins::mcp_servers::enabled_plugin_mcp_servers( + std::env::current_dir().ok().as_deref(), + )); + } for ext in &extensions { agent.add_extension(ext.clone(), &session.id).await?; } diff --git a/crates/goose/src/session/last_message_snippet.rs b/crates/goose/src/session/last_message_snippet.rs new file mode 100644 index 000000000000..c645b5589a14 --- /dev/null +++ b/crates/goose/src/session/last_message_snippet.rs @@ -0,0 +1,556 @@ +use crate::conversation::message::Message; +use crate::session::session_manager::Session; +use anyhow::Result; +use rmcp::model::Role; +use sqlx::{Pool, Sqlite}; +use std::collections::HashMap; + +const LAST_MESSAGE_SNIPPET_MAX_CHARS: usize = 128; +const RECENT_MESSAGE_SNIPPET_SCAN_LIMIT: usize = 8; + +#[derive(Debug, sqlx::FromRow)] +struct RecentMessageRow { + row_id: i64, + session_id: String, + role: String, + content_json: String, + created_timestamp: i64, + metadata_json: Option, + message_id: Option, +} + +pub(super) async fn hydrate_last_message_snippets( + pool: &Pool, + sessions: &mut [Session], +) -> Result<()> { + if sessions.is_empty() { + return Ok(()); + } + + let session_ids = sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + let mut snippets = HashMap::with_capacity(session_ids.len()); + + let rows = recent_message_rows(pool, &session_ids).await?; + + for row in rows { + if snippets.contains_key(&row.session_id) { + continue; + } + + let session_id = row.session_id.clone(); + let Some(message) = message_from_recent_row(row)? else { + continue; + }; + if let Some(snippet) = message_snippet(&message, LAST_MESSAGE_SNIPPET_MAX_CHARS) { + snippets.insert(session_id, snippet); + } + } + + for session in sessions { + session.last_message_snippet = snippets.remove(&session.id); + } + + Ok(()) +} + +async fn recent_message_rows( + pool: &Pool, + session_ids: &[String], +) -> Result> { + if session_ids.is_empty() { + return Ok(Vec::new()); + } + + let branch = r#" + SELECT row_id, session_id, role, content_json, created_timestamp, metadata_json, message_id + FROM ( + SELECT id AS row_id, session_id, role, content_json, created_timestamp, metadata_json, message_id + FROM messages + WHERE session_id = ? + ORDER BY created_timestamp DESC, id DESC + LIMIT ? + ) + "#; + let sql = std::iter::repeat_n(branch, session_ids.len()) + .collect::>() + .join(" UNION ALL "); + + let mut query = sqlx::query_as::<_, RecentMessageRow>(&sql); + for session_id in session_ids { + query = query + .bind(session_id) + .bind(RECENT_MESSAGE_SNIPPET_SCAN_LIMIT as i64); + } + + let mut rows = query.fetch_all(pool).await?; + rows.sort_by(|left, right| { + left.session_id + .cmp(&right.session_id) + .then_with(|| right.created_timestamp.cmp(&left.created_timestamp)) + .then_with(|| right.row_id.cmp(&left.row_id)) + }); + Ok(rows) +} + +fn message_from_recent_row(row: RecentMessageRow) -> Result> { + let role = match row.role.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => return Ok(None), + }; + + let content = match serde_json::from_str(&row.content_json) { + Ok(content) => content, + Err(_) => return Ok(None), + }; + let metadata = row + .metadata_json + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + + let mut message = Message::new(role, row.created_timestamp, content); + message.metadata = metadata; + if let Some(id) = row.message_id { + message = message.with_id(id); + } + Ok(Some(message)) +} + +/// Build a bounded, single-line snippet from user-visible message text. +/// +/// Tool-request, tool-response, thinking, image-only, and assistant-audience +/// blocks collapse to an empty string and return `None`. Internal whitespace +/// and newlines are collapsed to single spaces, and the result includes at most +/// `max_chars` characters of content; if truncated, a trailing `…` is appended +/// so it can be rendered verbatim by clients. +fn message_snippet(message: &Message, max_chars: usize) -> Option { + if !message.metadata.user_visible { + return None; + } + + let text = message + .content + .iter() + .filter_map(|content| content.filter_for_audience(Role::User)) + .filter_map(|content| content.as_text().map(|text| text.to_string())) + .collect::>() + .join("\n"); + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.is_empty() { + return None; + } + + let mut chars = normalized.chars(); + let mut result: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + let end = result.trim_end().len(); + result.truncate(end); + result.push('…'); + } + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::GooseMode; + use crate::conversation::message::{MessageContent, MessageMetadata}; + use crate::session::session_manager::{ + SessionListFilters, SessionListPageQuery, SessionManager, SessionType, + }; + use std::path::{Path, PathBuf}; + use tempfile::TempDir; + + async fn snippet_session(sm: &SessionManager) -> String { + sm.create_session( + PathBuf::from("/tmp/snippet"), + "Snippet session".to_string(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap() + .id + } + + async fn listed_snippets(sm: &SessionManager) -> HashMap> { + let types = [SessionType::User]; + sm.list_sessions_paged(SessionListPageQuery { + filters: SessionListFilters { + types: Some(&types), + working_dir: Some(Path::new("/tmp/snippet")), + ..Default::default() + }, + cursor: None, + page_size: 100, + include_last_message_snippet: true, + }) + .await + .unwrap() + .sessions + .into_iter() + .map(|session| (session.id, session.last_message_snippet)) + .collect() + } + + async fn hydrated_snippet_of(sm: &SessionManager, id: &str) -> Option { + let mut snippets = listed_snippets(sm).await; + snippets.remove(id).unwrap() + } + + fn message_at(mut message: Message, created: i64) -> Message { + message.created = created; + message + } + + fn assistant_audience_text(text: &str) -> MessageContent { + use rmcp::model::{AnnotateAble, RawTextContent}; + + MessageContent::Text( + RawTextContent { + text: text.to_string(), + meta: None, + } + .no_annotation() + .with_audience(vec![Role::Assistant]), + ) + } + + #[test] + fn test_message_snippet_collapses_whitespace_and_truncates() { + use rmcp::model::CallToolRequestParams; + + let collapsed = Message::user().with_text(" hello\n\nworld\t again "); + assert_eq!( + message_snippet(&collapsed, 20).as_deref(), + Some("hello world again") + ); + + let exact = Message::user().with_text("one two three four x"); + assert_eq!( + message_snippet(&exact, 20).as_deref(), + Some("one two three four x") + ); + + let long = Message::user().with_text("abcde fghij klmno p qrstuv"); + assert_eq!( + message_snippet(&long, 20).as_deref(), + Some("abcde fghij klmno p…") + ); + + let blob = Message::user().with_text("x".repeat(5000)); + let s = message_snippet(&blob, 20).unwrap(); + assert_eq!(s.chars().count(), 21); + assert!(s.ends_with('…')); + + let tool = + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))); + assert_eq!(message_snippet(&tool, 20), None); + + let thinking = Message::assistant().with_thinking("internal reasoning", "sig"); + assert_eq!(message_snippet(&thinking, 20), None); + + let agent_only = Message::assistant() + .with_text("hidden summary") + .with_metadata(MessageMetadata::agent_only()); + assert_eq!(message_snippet(&agent_only, 20), None); + } + + #[test] + fn test_message_snippet_ignores_assistant_audience_text_blocks() { + let mixed = Message::user() + .with_content(assistant_audience_text("assistant-only preprompt")) + .with_text("visible prompt"); + + assert_eq!( + message_snippet(&mixed, 128).as_deref(), + Some("visible prompt") + ); + + let only_assistant = + Message::user().with_content(assistant_audience_text("assistant-only details")); + + assert_eq!(message_snippet(&only_assistant, 128), None); + } + + #[tokio::test] + async fn test_live_last_message_snippet_reads_text_append() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + assert_eq!(hydrated_snippet_of(&sm, &id).await, None); + + sm.add_message(&id, &Message::user().with_text("hello there world")) + .await + .unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("hello there world") + ); + + let message = Message::user() + .with_content(assistant_audience_text("assistant-only preprompt")) + .with_text("visible prompt"); + sm.add_message(&id, &message).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("visible prompt") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_ignores_tool_messages() { + use rmcp::model::{CallToolRequestParams, CallToolResult, Content}; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message(&id, &Message::user().with_text("real text message")) + .await + .unwrap(); + sm.add_message( + &id, + &Message::assistant() + .with_text("hidden context summary") + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + sm.add_message( + &id, + &Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + ) + .await + .unwrap(); + sm.add_message( + &id, + &Message::user().with_tool_response( + "t1", + Ok(CallToolResult::success(vec![Content::text("done")])), + ), + ) + .await + .unwrap(); + sm.add_message(&id, &Message::assistant().with_thinking("pondering", "sig")) + .await + .unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("real text message") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_reads_replaced_conversation() { + use crate::conversation::Conversation; + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message(&id, &Message::user().with_text("stale original message")) + .await + .unwrap(); + + let conversation = Conversation::new_unvalidated(vec![ + Message::user().with_text("first user prompt"), + Message::assistant().with_text("assistant reply here"), + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + Message::assistant() + .with_text("hidden compacted summary") + .with_metadata(MessageMetadata::agent_only()), + ]); + sm.replace_conversation(&id, &conversation).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("assistant reply here") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_reads_truncated_conversation() { + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + let previous = message_at(Message::user().with_text("previous remaining text"), 1_000); + let tool = message_at( + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + 2_000, + ); + let latest = message_at( + Message::assistant().with_text("latest text to remove"), + 3_000, + ); + let hidden = message_at( + Message::assistant() + .with_text("hidden compacted summary") + .with_metadata(MessageMetadata::agent_only()), + 2_500, + ); + + sm.add_message(&id, &previous).await.unwrap(); + sm.add_message(&id, &tool).await.unwrap(); + sm.add_message(&id, &hidden).await.unwrap(); + sm.add_message(&id, &latest).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("latest text to remove") + ); + + sm.truncate_conversation(&id, 3_000).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("previous remaining text") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippet_null_after_truncate_without_text_messages() { + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + let tool = message_at( + Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + 500, + ); + let text = message_at(Message::user().with_text("only text to remove"), 1_000); + + sm.add_message(&id, &tool).await.unwrap(); + sm.add_message(&id, &text).await.unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("only text to remove") + ); + + sm.truncate_conversation(&id, 1_000).await.unwrap(); + + assert_eq!(hydrated_snippet_of(&sm, &id).await, None); + } + + #[tokio::test] + async fn test_live_last_message_snippets_read_from_recent_messages() { + use rmcp::model::CallToolRequestParams; + + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let visible_id = snippet_session(&sm).await; + + sm.add_message( + &visible_id, + &Message::user().with_text("**raw** _markdown_ subtitle"), + ) + .await + .unwrap(); + for index in 0..3 { + sm.add_message( + &visible_id, + &Message::assistant() + .with_text(format!("hidden summary {index}")) + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + } + let empty_id = snippet_session(&sm).await; + sm.add_message( + &empty_id, + &Message::assistant().with_tool_request("t1", Ok(CallToolRequestParams::new("shell"))), + ) + .await + .unwrap(); + sm.add_message( + &empty_id, + &Message::assistant() + .with_text("hidden only") + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + let by_id = listed_snippets(&sm).await; + + assert_eq!( + by_id + .get(&visible_id) + .and_then(|snippet| snippet.as_deref()), + Some("**raw** _markdown_ subtitle") + ); + assert_eq!(by_id.get(&empty_id), Some(&None)); + } + + #[tokio::test] + async fn test_live_last_message_snippet_skips_unparseable_recent_rows() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message( + &id, + &message_at(Message::user().with_text("older visible text"), 1_000), + ) + .await + .unwrap(); + + let pool = sm.storage().pool().await.unwrap(); + sqlx::query( + r#" + INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp, metadata_json) + VALUES (?, ?, ?, ?, ?, ?) + "#, + ) + .bind("invalid-content") + .bind(&id) + .bind("assistant") + .bind("not valid json") + .bind(2_000_i64) + .bind("{}") + .execute(pool) + .await + .unwrap(); + + assert_eq!( + hydrated_snippet_of(&sm, &id).await.as_deref(), + Some("older visible text") + ); + } + + #[tokio::test] + async fn test_live_last_message_snippets_stays_bounded() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let id = snippet_session(&sm).await; + + sm.add_message(&id, &Message::user().with_text("older visible text")) + .await + .unwrap(); + for index in 0..RECENT_MESSAGE_SNIPPET_SCAN_LIMIT { + sm.add_message( + &id, + &Message::assistant() + .with_text(format!("hidden summary {index}")) + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + } + assert_eq!(hydrated_snippet_of(&sm, &id).await, None); + } +} diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index 00ffa766a1c8..951c533bcd3b 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -2,10 +2,12 @@ mod chat_history_search; mod diagnostics; pub mod extension_data; pub mod import_formats; +mod last_message_snippet; mod legacy; #[cfg(feature = "nostr")] pub mod nostr_share; pub mod session_manager; +mod session_naming; pub use diagnostics::{ config_path, generate_diagnostics, get_system_info, latest_llm_log_path, diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index c216ada06dd1..3ac0b6cfe996 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -2,12 +2,15 @@ use crate::config::paths::Paths; use crate::config::GooseMode; use crate::conversation::message::Message; use crate::conversation::Conversation; -use crate::model::ModelConfig; -use crate::providers::base::{Provider, MSG_COUNT_FOR_SESSION_NAME_GENERATION}; +use crate::providers::base::Provider; use crate::recipe::Recipe; use crate::session::extension_data::ExtensionData; +use crate::session::session_naming::{ + generate_session_name, MSG_COUNT_FOR_SESSION_NAME_GENERATION, +}; use anyhow::Result; use chrono::{DateTime, Utc}; +use goose_providers::model::ModelConfig; use rmcp::model::Role; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; @@ -86,21 +89,8 @@ pub struct Session { pub archived_at: Option>, #[serde(default)] pub project_id: Option, -} - -impl Session { - pub fn display_title(&self) -> Option { - if !self.user_set_name && self.session_type != SessionType::Scheduled { - if let Some(recipe) = &self.recipe { - return Some(recipe.title.clone()); - } - } - if self.name.is_empty() { - None - } else { - Some(self.name.clone()) - } - } + #[serde(default)] + pub last_message_snippet: Option, } pub struct SessionUpdateBuilder<'a> { @@ -314,6 +304,7 @@ pub(crate) struct SessionListPageQuery<'a> { pub(crate) filters: SessionListFilters<'a>, pub(crate) cursor: Option<&'a SessionListCursor>, pub(crate) page_size: usize, + pub(crate) include_last_message_snippet: bool, } #[derive(Debug, Default)] @@ -465,6 +456,26 @@ impl SessionManager { .await } + async fn system_generated_name_update( + &self, + id: &str, + name: String, + ) -> Result { + self.update(id) + .system_generated_name(name.clone()) + .apply() + .await?; + + let session = self.get_session(id, false).await?; + Ok(SessionNameUpdate { + session_id: id.to_string(), + name, + updated_at: session.updated_at, + message_count: session.message_count, + user_set_name: session.user_set_name, + }) + } + pub async fn maybe_update_name( &self, id: &str, @@ -476,6 +487,19 @@ impl SessionManager { return Ok(None); } + if session.session_type == SessionType::Scheduled { + return Ok(None); + } + + if let Some(recipe) = &session.recipe { + let name = recipe.title.trim().to_string(); + if name.is_empty() || session.name == name { + return Ok(None); + } + + return Ok(Some(self.system_generated_name_update(id, name).await?)); + } + let conversation = session .conversation .ok_or_else(|| anyhow::anyhow!("No messages found"))?; @@ -487,20 +511,8 @@ impl SessionManager { .count(); if user_message_count <= MSG_COUNT_FOR_SESSION_NAME_GENERATION { - let name = provider.generate_session_name(id, &conversation).await?; - self.update(id) - .system_generated_name(name.clone()) - .apply() - .await?; - - let session = self.get_session(id, false).await?; - return Ok(Some(SessionNameUpdate { - session_id: id.to_string(), - name, - updated_at: session.updated_at, - message_count: session.message_count, - user_set_name: session.user_set_name, - })); + let name = generate_session_name(provider.as_ref(), id, &conversation).await?; + return Ok(Some(self.system_generated_name_update(id, name).await?)); } Ok(None) } @@ -596,6 +608,7 @@ impl Default for Session { goose_mode: GooseMode::default(), archived_at: None, project_id: None, + last_message_snippet: None, } } } @@ -668,6 +681,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { .unwrap_or_default(), archived_at: row.try_get("archived_at").ok(), project_id: row.try_get("project_id").ok().flatten(), + last_message_snippet: None, }) } } @@ -1678,6 +1692,7 @@ impl SessionStorage { } let page_size = query.page_size; + let include_last_message_snippet = query.include_last_message_snippet; let mut sessions = self .list_sessions_matching(SessionListQuery { filters: query.filters, @@ -1698,6 +1713,10 @@ impl SessionStorage { if has_next_page { sessions.truncate(page_size); } + if include_last_message_snippet { + let pool = self.pool().await?; + super::last_message_snippet::hydrate_last_message_snippets(pool, &mut sessions).await?; + } Ok(SessionListPage { sessions, @@ -2025,10 +2044,69 @@ fn merge_tool_meta( mod tests { use super::*; use crate::conversation::message::{Message, MessageContent}; + use crate::providers::base::MessageStream; + use goose_providers::conversation::token_usage::ProviderUsage; + use goose_providers::errors::ProviderError; + use rmcp::model::Tool; use tempfile::TempDir; use test_case::test_case; const NUM_CONCURRENT_SESSIONS: i32 = 10; + const GENERATED_SESSION_NAME: &str = "Generated session name"; + + struct NamingTestProvider { + model_config: ModelConfig, + } + + #[async_trait::async_trait] + impl Provider for NamingTestProvider { + fn get_name(&self) -> &str { + "naming-test" + } + + async fn stream( + &self, + _model_config: &ModelConfig, + _session_id: &str, + _system: &str, + _messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> std::result::Result { + unimplemented!("session naming calls complete_fast") + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete_fast( + &self, + _session_id: &str, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message::assistant().with_text(GENERATED_SESSION_NAME), + ProviderUsage::new("test".to_string(), Default::default()), + )) + } + } + + fn naming_test_provider() -> Arc { + Arc::new(NamingTestProvider { + model_config: ModelConfig::new("test-model").unwrap(), + }) + } + + fn test_recipe(title: &str) -> Recipe { + Recipe::builder() + .title(title) + .description("Recipe description") + .instructions("Follow the recipe") + .build() + .unwrap() + } async fn create_session_for_list( sm: &SessionManager, @@ -2105,6 +2183,147 @@ mod tests { .unwrap(); } + async fn add_user_message(sm: &SessionManager, session_id: &str) { + sm.add_message(session_id, &Message::user().with_text("hello world")) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_maybe_update_name_updates_eligible_session() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let session = sm + .create_session( + temp_dir.path().to_path_buf(), + "New Chat".to_string(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap(); + + add_user_message(&sm, &session.id).await; + + let update = sm + .maybe_update_name(&session.id, naming_test_provider()) + .await + .unwrap(); + assert_eq!( + update.as_ref().map(|update| update.name.as_str()), + Some(GENERATED_SESSION_NAME) + ); + + let reloaded = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(reloaded.name, GENERATED_SESSION_NAME); + assert!(!reloaded.user_set_name); + } + + #[tokio::test] + async fn test_maybe_update_name_preserves_user_renamed_session() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let session = sm + .create_session( + temp_dir.path().to_path_buf(), + "New Chat".to_string(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap(); + + sm.update(&session.id) + .user_provided_name("Manual title".to_string()) + .apply() + .await + .unwrap(); + add_user_message(&sm, &session.id).await; + + let update = sm + .maybe_update_name(&session.id, naming_test_provider()) + .await + .unwrap(); + assert!(update.is_none()); + + let reloaded = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(reloaded.name, "Manual title"); + assert!(reloaded.user_set_name); + } + + #[tokio::test] + async fn test_maybe_update_name_uses_recipe_title_for_recipe_session() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let session = sm + .create_session( + temp_dir.path().to_path_buf(), + "New Chat".to_string(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap(); + + sm.update(&session.id) + .recipe(Some(test_recipe("Recipe title"))) + .apply() + .await + .unwrap(); + add_user_message(&sm, &session.id).await; + + let update = sm + .maybe_update_name(&session.id, naming_test_provider()) + .await + .unwrap(); + assert_eq!( + update.as_ref().map(|update| update.name.as_str()), + Some("Recipe title") + ); + + let reloaded = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(reloaded.name, "Recipe title"); + assert!(!reloaded.user_set_name); + + let update = sm + .maybe_update_name(&session.id, naming_test_provider()) + .await + .unwrap(); + assert!(update.is_none()); + } + + #[tokio::test] + async fn test_maybe_update_name_preserves_scheduled_session() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + let original_name = "Scheduled job: test-job"; + + let session = sm + .create_session( + temp_dir.path().to_path_buf(), + original_name.to_string(), + SessionType::Scheduled, + GooseMode::default(), + ) + .await + .unwrap(); + + add_user_message(&sm, &session.id).await; + + let update = sm + .maybe_update_name(&session.id, naming_test_provider()) + .await + .unwrap(); + assert!(update.is_none()); + + let reloaded = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(reloaded.name, original_name); + assert!(!reloaded.user_set_name); + } + async fn create_search_session( sm: &SessionManager, name: &str, @@ -2204,6 +2423,7 @@ mod tests { }, cursor, page_size, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2397,6 +2617,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2438,6 +2659,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2472,6 +2694,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2510,6 +2733,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2530,6 +2754,7 @@ mod tests { }, cursor: None, page_size: 10, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2568,6 +2793,7 @@ mod tests { filters: filters.clone(), cursor: None, page_size: 1, + include_last_message_snippet: false, }) .await .unwrap(); @@ -2584,6 +2810,7 @@ mod tests { filters, cursor: cursor.next_cursor.as_ref(), page_size: 1, + include_last_message_snippet: false, }) .await .unwrap(); diff --git a/crates/goose/src/session/session_naming.rs b/crates/goose/src/session/session_naming.rs new file mode 100644 index 000000000000..74321016aaf9 --- /dev/null +++ b/crates/goose/src/session/session_naming.rs @@ -0,0 +1,249 @@ +use std::sync::LazyLock; + +use anyhow::Result; +use goose_providers::conversation::{message::Message, Conversation}; +use regex::Regex; + +use crate::{providers::base::Provider, utils::safe_truncate}; + +pub static MSG_COUNT_FOR_SESSION_NAME_GENERATION: usize = 3; + +fn strip_xml_tags(text: &str) -> String { + static BLOCK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)<([a-zA-Z][a-zA-Z0-9_]*)[^>]*>.*?").unwrap() + }); + static TAG_RE: LazyLock = + LazyLock::new(|| Regex::new(r"]*>").unwrap()); + let pass1 = BLOCK_RE.replace_all(text, ""); + TAG_RE.replace_all(&pass1, "").into_owned() +} + +fn extract_short_title(text: &str) -> String { + let word_count = text.split_whitespace().count(); + if word_count <= 8 { + return text.to_string(); + } + + { + let mut results = Vec::new(); + let mut quote_char: Option = None; + let mut current = String::new(); + let mut prev_char: Option = None; + + for ch in text.chars() { + match quote_char { + None => { + if matches!(ch, '"' | '\'' | '`') { + let after_alnum = prev_char.map(|p| p.is_alphanumeric()).unwrap_or(false); + if !after_alnum { + quote_char = Some(ch); + current.clear(); + } + } + } + Some(q) => { + if ch == q { + let trimmed = current.trim().to_string(); + let wc = trimmed.split_whitespace().count(); + if (2..=8).contains(&wc) { + results.push(trimmed); + } + quote_char = None; + current.clear(); + } else { + current.push(ch); + } + } + } + prev_char = Some(ch); + } + + if let Some(title) = results.last() { + return title.clone(); + } + } + + if let Some(last) = text.lines().rev().find(|l| !l.trim().is_empty()) { + return last.trim().to_string(); + } + + text.to_string() +} + +/// Returns the first 3 user messages as strings for session naming, +/// filtering out assistant-only content (e.g. preprompt blocks). +fn get_initial_user_messages(messages: &Conversation) -> Vec { + messages + .iter() + .filter(|m| m.role == rmcp::model::Role::User) + .take(MSG_COUNT_FOR_SESSION_NAME_GENERATION) + .map(|m| { + m.content + .iter() + .filter_map(|c| c.filter_for_audience(rmcp::model::Role::User)) + .filter_map(|c| c.as_text().map(|s| s.to_string())) + .collect::>() + .join("\n") + }) + .collect() +} + +/// Extracts preprompt context (assistant-audience blocks) from the first user message. +/// These are content blocks visible to the assistant but not the user. +fn get_preprompt_context(messages: &Conversation) -> String { + messages + .iter() + .filter(|m| m.role == rmcp::model::Role::User) + .take(1) + .flat_map(|m| m.content.iter()) + .filter_map(|c| { + // If this block is NOT visible to the user, it's preprompt/assistant-only content + if c.filter_for_audience(rmcp::model::Role::User).is_none() { + c.as_text().map(|s| s.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n") +} + +/// Generate a session name/description based on the conversation history +/// Creates a prompt asking for a concise description in 4 words or less. +pub(crate) async fn generate_session_name( + provider: &dyn Provider, + session_id: &str, + messages: &Conversation, +) -> Result { + let context = get_initial_user_messages(messages); + let preprompt_context = get_preprompt_context(messages); + let system = crate::prompt_template::render_template( + "session_name.md", + &std::collections::HashMap::::new(), + )?; + + use crate::providers::cli_common::{ + SESSION_NAME_BEGIN_MARKER, SESSION_NAME_END_MARKER, SESSION_NAME_SUFFIX, + }; + + let preprompt_section = if preprompt_context.is_empty() { + String::new() + } else { + format!( + "---BEGIN BACKGROUND CONTEXT (for understanding only, do NOT base the title on this)---\n{}\n---END BACKGROUND CONTEXT---\n\n", + preprompt_context + ) + }; + + let user_text = format!( + "{}{}\n{}\n{}\n\n{}", + preprompt_section, + SESSION_NAME_BEGIN_MARKER, + context.join("\n"), + SESSION_NAME_END_MARKER, + SESSION_NAME_SUFFIX, + ); + let message = Message::user().with_text(&user_text); + let result = provider + .complete_fast(session_id, &system, &[message], &[]) + .await?; + + let raw: String = result + .0 + .content + .iter() + .filter_map(|c| c.as_text()) + .collect(); + let description = strip_xml_tags(&raw) + .split_whitespace() + .collect::>() + .join(" "); + + Ok(safe_truncate(&extract_short_title(&description), 100)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_xml_tags() { + assert_eq!(strip_xml_tags("reasoninganswer"), "answer"); + assert_eq!(strip_xml_tags("beforemidafter"), "beforeafter"); + assert_eq!(strip_xml_tags("xyz"), "z"); + assert_eq!(strip_xml_tags("no tags here"), "no tags here"); + assert_eq!(strip_xml_tags("a < b > c"), "a < b > c"); + assert_eq!(strip_xml_tags("überok"), "ok"); + assert_eq!(strip_xml_tags("日本語hello"), "hello"); + assert_eq!(strip_xml_tags(""), ""); + assert_eq!(strip_xml_tags("<>stuff"), "<>stuff"); + // attributes + assert_eq!( + strip_xml_tags(r#"reasoninganswer"#), + "answer" + ); + // self-closing tags + assert_eq!(strip_xml_tags("
self closing"), "self closing"); + // orphan closing tags + assert_eq!(strip_xml_tags("orphan tag"), "orphan tag"); + // multiline content + assert_eq!( + strip_xml_tags("\nline1\nline2\nresult"), + "result" + ); + } + + #[test] + fn test_extract_short_title() { + assert_eq!(extract_short_title("List files"), "List files"); + assert_eq!( + extract_short_title( + r#"blah blah blah blah blah blah blah blah blah "List files in folder""# + ), + "List files in folder" + ); + assert_eq!( + extract_short_title( + "blah blah blah blah blah blah blah blah blah `View current files`" + ), + "View current files" + ); + assert_eq!( + extract_short_title( + r#"stuff stuff stuff stuff stuff stuff stuff stuff "Abc title" "Zzz title""# + ), + "Zzz title" + ); + assert_eq!( + extract_short_title( + "long long long long long long long long long\nList files in folder" + ), + "List files in folder" + ); + assert_eq!( + extract_short_title( + r#"lots of words here and there and more and more "single" final line here"# + ), + "lots of words here and there and more and more \"single\" final line here" + ); + assert_eq!(extract_short_title("Hello world"), "Hello world"); + assert_eq!( + extract_short_title( + r#"1. Analyze the request. 2. The user's message says list files. 3. "List current folder files" fits perfectly. Result: List current folder files"# + ), + "List current folder files" + ); + assert_eq!( + extract_short_title( + r#"the user's phrasing is about listing files and the user's intent is clear. "List folder files" is best"# + ), + "List folder files" + ); + assert_eq!( + extract_short_title( + "lots of reasoning here about what to call it\nList current folder files" + ), + "List current folder files" + ); + } +} diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 1645b0d03316..07da20c6ba4a 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -109,15 +109,20 @@ fn loaded_skill_context(skill: &SourceEntry, content: &str) -> String { if !skill.supporting_files.is_empty() { let skill_dir = Path::new(&skill.path); output.push_str(&format!( - "\n## Supporting Files\n\nSkill directory: {}\n\n", + "\n## Supporting Files\n\nSkill directory: {}\n\n\ + Relative paths in this skill resolve from the skill directory. \ + The shell tool runs in the session working directory, so use the \ + resolved path below or `cd` into the skill directory before running \ + supporting scripts.\n\n", skill.path )); for file in &skill.supporting_files { if let Ok(relative) = Path::new(file).strip_prefix(skill_dir) { let rel_str = relative.to_string_lossy().replace('\\', "/"); + let resolved_path = Path::new(file).to_string_lossy().replace('\\', "/"); output.push_str(&format!( - "- {} → load_skill(name: \"{}/{}\")\n", - rel_str, skill.name, rel_str + "- {} → {} (load_skill(name: \"{}/{}\"))\n", + rel_str, resolved_path, skill.name, rel_str )); } } @@ -531,4 +536,21 @@ mod tests { assert!(rendered.contains("# Loaded Skill: test-skill (skill)")); assert!(rendered.contains("## Content\n\nReview the code carefully.")); } + + #[test] + fn loaded_skill_context_shows_resolved_paths_for_supporting_files() { + let skill_dir = std::env::temp_dir().join("goose-test-skill"); + let script_path = skill_dir.join("scripts").join("my-tool.exe"); + let mut skill = skill_with_content("Run scripts/my-tool.exe."); + skill.path = skill_dir.to_string_lossy().into_owned(); + skill.supporting_files = vec![script_path.to_string_lossy().into_owned()]; + + let rendered = loaded_skill_context_with_args(&skill, None).unwrap(); + let resolved_path = script_path.to_string_lossy().replace('\\', "/"); + + assert!(rendered.contains("Relative paths in this skill resolve from the skill directory")); + assert!(rendered.contains("scripts/my-tool.exe")); + assert!(rendered.contains(&resolved_path)); + assert!(rendered.contains("load_skill(name: \"test-skill/scripts/my-tool.exe\")")); + } } diff --git a/crates/goose/tests/acp_common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs index d2af23d07497..94536a283dcf 100644 --- a/crates/goose/tests/acp_common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use std::time::Duration; const SHELL_TEST_CONTENT: &str = "test-shell-content-98765"; +const TURN_CONTEXT_CLOSE: &str = r#"
\n"#; const OPENAI_SESSION_NAME_RESPONSE: &str = r#"data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1766229303,"model":"gpt-5-nano","choices":[{"index":0,"delta":{"content":"Generated Test Title"},"finish_reason":null}]} @@ -41,7 +42,7 @@ async fn new_basic_session(config: TestConnectionConfig) -> Basic let expected_session_id = C::expected_session_id(); let openai = OpenAiFixture::new( vec![( - r#"
\nwhat is 1+1""#.into(), + format!("{TURN_CONTEXT_CLOSE}what is 1+1"), include_str!("../acp_test_data/openai_basic.txt"), )], expected_session_id.clone(), @@ -99,7 +100,7 @@ pub async fn run_session_name_update_notification() { let openai = OpenAiFixture::new( vec![ ( - r#"
\nwhat should we call this conversation?""#.into(), + format!("{TURN_CONTEXT_CLOSE}what should we call this conversation?"), include_str!("../acp_test_data/openai_basic.txt"), ), ( @@ -1140,7 +1141,7 @@ pub async fn run_prompt_basic() { let expected_session_id = C::expected_session_id(); let openai = OpenAiFixture::new( vec![( - r#"
\nwhat is 1+1""#.into(), + format!("{TURN_CONTEXT_CLOSE}what is 1+1"), include_str!("../acp_test_data/openai_basic.txt"), )], expected_session_id.clone(), @@ -1168,7 +1169,7 @@ pub async fn run_prompt_codemode() { let openai = OpenAiFixture::new( vec![ ( - format!(r#"\n{prompt}""#), + format!("{TURN_CONTEXT_CLOSE}{prompt}"), include_str!("../acp_test_data/openai_builtin_search.txt"), ), ( @@ -1215,8 +1216,9 @@ pub async fn run_prompt_image() { let openai = OpenAiFixture::new( vec![ ( - r#"\nUse the get_image tool and describe what you see in its result.""# - .into(), + format!( + "{TURN_CONTEXT_CLOSE}Use the get_image tool and describe what you see in its result." + ), include_str!("../acp_test_data/openai_image_tool_call.txt"), ), ( @@ -1291,7 +1293,7 @@ pub async fn run_prompt_mcp() { let openai = OpenAiFixture::new( vec![ ( - r#"\nUse the get_code tool and output only its result.""#.into(), + format!("{TURN_CONTEXT_CLOSE}Use the get_code tool and output only its result."), include_str!("../acp_test_data/openai_tool_call.txt"), ), ( diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 5b8a336b9f67..0453a3c163ac 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -11,9 +11,9 @@ use common_tests::fixtures::{ TestConnectionConfig, }; use goose::acp::server::AcpProviderFactory; -use goose::model::ModelConfig; use goose::providers::base::{MessageStream, Provider}; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use goose_test_support::{EnforceSessionId, IgnoreSessionId}; use serial_test::serial; use std::path::PathBuf; diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index fd5677da7572..d300cfc17db8 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -199,9 +199,10 @@ pub async fn spawn_acp_server_in_process( move |_provider_name, model_config, _extensions, _working_dir| { let base_url = base_url.clone(); Box::pin(async move { - let api_client = ApiClient::new( + let api_client = ApiClient::new_with_tls( base_url, ApiAuthMethod::BearerToken("test-key".to_string()), + None, ) .unwrap(); let provider: Arc = diff --git a/crates/goose/tests/acp_fixtures/provider.rs b/crates/goose/tests/acp_fixtures/provider.rs index 4767125b27ca..c11dc9247fcd 100644 --- a/crates/goose/tests/acp_fixtures/provider.rs +++ b/crates/goose/tests/acp_fixtures/provider.rs @@ -12,10 +12,10 @@ use futures::StreamExt; use goose::acp::{AcpProvider, AcpProviderConfig}; use goose::config::{GooseMode, PermissionManager}; use goose::conversation::message::{ActionRequiredData, Message, MessageContent}; -use goose::model::ModelConfig; use goose::permission::permission_confirmation::PrincipalType; use goose::permission::{Permission, PermissionConfirmation}; use goose::providers::base::Provider; +use goose_providers::model::ModelConfig; use goose_test_support::{ExpectedSessionId, IgnoreSessionId, TEST_MODEL}; use std::collections::{HashMap, HashSet}; use std::str::FromStr; diff --git a/crates/goose/tests/acp_fixtures/server.rs b/crates/goose/tests/acp_fixtures/server.rs index f7a5eec96ba8..1cfe46cfcdc0 100644 --- a/crates/goose/tests/acp_fixtures/server.rs +++ b/crates/goose/tests/acp_fixtures/server.rs @@ -273,7 +273,7 @@ impl Connection for AcpServerConnection { .connect_with(transport, { let cx_holder = cx_holder_clone; async move |cx: ConnectionTo| { - let _resp = cx + let resp = cx .send_request( InitializeRequest::new(ProtocolVersion::LATEST) .client_capabilities( @@ -285,6 +285,11 @@ impl Connection for AcpServerConnection { .block_task() .await .unwrap(); + assert_eq!( + resp.agent_info.as_ref().map(|info| info.name.as_str()), + Some("goose"), + "initialize response must identify the agent" + ); *cx_holder.lock().unwrap() = Some(cx.clone()); let _ = ready_tx.send(()); diff --git a/crates/goose/tests/acp_fork_session_test.rs b/crates/goose/tests/acp_fork_session_test.rs new file mode 100644 index 000000000000..ea4e7f6cfbe3 --- /dev/null +++ b/crates/goose/tests/acp_fork_session_test.rs @@ -0,0 +1,129 @@ +#[allow(dead_code)] +#[path = "acp_common_tests/mod.rs"] +mod common_tests; + +use agent_client_protocol::schema::{ForkSessionRequest, ForkSessionResponse, SessionId}; +use common_tests::fixtures::server::AcpServerConnection; +use common_tests::fixtures::{run_test, Connection, OpenAiFixture, TestConnectionConfig}; +use goose::config::GooseMode; +use goose::conversation::message::{Message, MessageContent}; +use goose::session::{SessionManager, SessionType}; +use std::path::Path; + +async fn new_connection(data_root: &Path) -> AcpServerConnection { + let openai = OpenAiFixture::new( + vec![], + ::expected_session_id(), + ) + .await; + ::new( + TestConnectionConfig { + data_root: data_root.to_path_buf(), + ..Default::default() + }, + openai, + ) + .await +} + +async fn fork_session_request( + conn: &AcpServerConnection, + request: ForkSessionRequest, +) -> anyhow::Result { + conn.cx() + .send_request(request) + .block_task() + .await + .map_err(Into::into) +} + +async fn seed_session_with_messages( + session_manager: &SessionManager, + cwd: &Path, + messages: &[(&str, i64)], +) -> goose::session::Session { + let session = session_manager + .create_session( + cwd.to_path_buf(), + "Fork before".to_string(), + SessionType::Acp, + GooseMode::default(), + ) + .await + .unwrap(); + + for (text, created) in messages { + let mut message = Message::user().with_text(*text); + message.created = *created; + session_manager + .add_message(&session.id, &message) + .await + .unwrap(); + } + + session +} + +async fn session_texts(session_manager: &SessionManager, session_id: &str) -> Vec { + session_manager + .get_session(session_id, true) + .await + .unwrap() + .conversation + .unwrap() + .messages() + .iter() + .flat_map(|message| { + message.content.iter().filter_map(|content| match content { + MessageContent::Text(text) => Some(text.text.clone()), + _ => None, + }) + }) + .collect() +} + +fn conversation_before_meta(timestamp: i64) -> serde_json::Map { + let mut meta = serde_json::Map::new(); + meta.insert( + "conversationBefore".to_string(), + serde_json::Value::Number(timestamp.into()), + ); + meta +} + +#[test] +fn fork_session_conversation_before_matches_rest_cutoff() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = tempfile::tempdir().unwrap(); + let session_manager = SessionManager::new(data_root.path().to_path_buf()); + let session = seed_session_with_messages( + &session_manager, + cwd.path(), + &[ + ("first", 1_718_000_000), + ("second", 1_718_000_060), + ("third", 1_718_000_120), + ], + ) + .await; + let conn = new_connection(data_root.path()).await; + + let response = fork_session_request( + &conn, + ForkSessionRequest::new(SessionId::new(session.id.clone()), cwd.path()) + .meta(conversation_before_meta(1_718_000_120)), + ) + .await + .unwrap(); + + assert_eq!( + session_texts(&session_manager, response.session_id.0.as_ref()).await, + vec!["first", "second"] + ); + assert_eq!( + session_texts(&session_manager, &session.id).await, + vec!["first", "second", "third"] + ); + }); +} diff --git a/crates/goose/tests/acp_secret_cache_invalidation_test.rs b/crates/goose/tests/acp_secret_cache_invalidation_test.rs index 850cd06a336c..bce30e4a6473 100644 --- a/crates/goose/tests/acp_secret_cache_invalidation_test.rs +++ b/crates/goose/tests/acp_secret_cache_invalidation_test.rs @@ -6,11 +6,11 @@ use common_tests::fixtures::server::AcpServerConnection; use common_tests::fixtures::{run_test, send_custom, Connection, TestConnectionConfig}; use goose::config::paths::Paths; use goose::config::{Config, ConfigError}; -use goose::model::ModelConfig; use goose::providers::base::{MessageStream, Provider}; use goose::providers::inventory::ProviderInventoryService; use goose::session::session_manager::SessionStorage; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use goose_test_support::EnforceSessionId; use serial_test::serial; use std::sync::Arc; diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 2407d2062b9e..5f3b084cfdb6 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -3,7 +3,7 @@ mod common_tests; use agent_client_protocol::schema::{ ListSessionsRequest, ListSessionsResponse, SessionConfigKind, SessionConfigOptionCategory, - SessionConfigOptionValue, SetSessionConfigOptionRequest, + SessionConfigOptionValue, SessionInfo, SetSessionConfigOptionRequest, }; use agent_client_protocol::ErrorCode; use common_tests::fixtures::server::AcpServerConnection; @@ -23,7 +23,7 @@ use common_tests::{ run_shell_terminal_false, run_shell_terminal_true, }; use goose::config::GooseMode; -use goose::conversation::message::Message; +use goose::conversation::message::{Message, MessageMetadata}; use goose::custom_requests::{GetSessionInfoRequest, GetSessionInfoResponse}; use goose::session::{SessionManager, SessionType}; use std::path::Path; @@ -116,6 +116,25 @@ fn assert_invalid_params(error: anyhow::Error) { assert_eq!(acp_error.code, ErrorCode::InvalidParams); } +fn include_last_message_snippet_meta( + value: serde_json::Value, +) -> serde_json::Map { + let mut goose = serde_json::Map::new(); + goose.insert("includeLastMessageSnippet".to_string(), value); + + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose)); + meta +} + +fn last_message_snippet(session: &SessionInfo) -> Option<&str> { + session + .meta + .as_ref() + .and_then(|meta| meta.get("lastMessageSnippet")) + .and_then(serde_json::Value::as_str) +} + #[test] fn test_config_mcp() { run_test(async { run_config_mcp::().await }); @@ -131,6 +150,79 @@ fn test_list_sessions() { run_test(async { run_list_sessions::().await }); } +#[test] +fn test_list_sessions_emits_computed_snippet() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let cwd = Path::new("/tmp/acp-session-list-snippet"); + let session_manager = SessionManager::new(data_root.path().to_path_buf()); + let session = session_manager + .create_session( + cwd.to_path_buf(), + "Live subtitle".to_string(), + SessionType::Acp, + GooseMode::default(), + ) + .await + .unwrap(); + session_manager + .add_message( + &session.id, + &Message::user().with_text("**raw** _markdown_ subtitle"), + ) + .await + .unwrap(); + session_manager + .add_message( + &session.id, + &Message::assistant() + .with_text("hidden newer text") + .with_metadata(MessageMetadata::agent_only()), + ) + .await + .unwrap(); + + let conn = new_connection(data_root.path()).await; + let response = list_sessions_request( + &conn, + ListSessionsRequest::new() + .meta(include_last_message_snippet_meta(serde_json::Value::Null)), + ) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!(last_message_snippet(&response.sessions[0]), None); + + let response = list_sessions_request( + &conn, + ListSessionsRequest::new().meta(include_last_message_snippet_meta( + serde_json::Value::Bool(false), + )), + ) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!(last_message_snippet(&response.sessions[0]), None); + + let response = list_sessions_request( + &conn, + ListSessionsRequest::new().meta(include_last_message_snippet_meta( + serde_json::Value::Bool(true), + )), + ) + .await + .unwrap(); + + assert_eq!(response.sessions.len(), 1); + assert_eq!( + last_message_snippet(&response.sessions[0]), + Some("**raw** _markdown_ subtitle") + ); + }); +} + #[test] fn test_list_sessions_pagination() { run_test(async { @@ -142,15 +234,24 @@ fn test_list_sessions_pagination() { .await .unwrap(); assert_eq!(first.sessions.len(), 50); + assert!(first + .sessions + .iter() + .all(|session| last_message_snippet(session).is_none())); let second = list_sessions_request( &conn, - ListSessionsRequest::new().cursor(first.next_cursor.clone().unwrap()), + ListSessionsRequest::new() + .cursor(first.next_cursor.clone().unwrap()) + .meta(include_last_message_snippet_meta(serde_json::Value::Bool( + true, + ))), ) .await .unwrap(); assert_eq!(second.sessions.len(), 1); assert!(second.next_cursor.is_none()); + assert_eq!(last_message_snippet(&second.sessions[0]), Some("hello")); let second_id = &second.sessions[0].session_id; assert!(first @@ -296,6 +397,16 @@ fn test_list_sessions_invalid_params() { .await .unwrap_err(); assert_invalid_params(error); + + let error = list_sessions_request( + &conn, + ListSessionsRequest::new().meta(include_last_message_snippet_meta( + serde_json::Value::String("true".to_string()), + )), + ) + .await + .unwrap_err(); + assert_invalid_params(error); }); } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 0fa4da3b0e11..25e5c196d691 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -342,13 +342,13 @@ mod tests { use goose::agents::SessionConfig; use goose::config::GooseMode; use goose::conversation::message::{Message, MessageContent}; - use goose::model::ModelConfig; use goose::providers::base::{ stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata, }; use goose::session::session_manager::SessionType; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; use rmcp::model::{CallToolRequestParams, Tool}; use rmcp::object; use std::path::PathBuf; @@ -381,6 +381,7 @@ mod tests { fn from_env( _model: ModelConfig, _extensions: Vec, + _tls_config: Option, ) -> futures::future::BoxFuture<'static, anyhow::Result> { Box::pin(async { Ok(Self::new()) }) } @@ -466,6 +467,7 @@ mod tests { responses.push(response); } Ok(AgentEvent::McpNotification(_)) => {} + Ok(AgentEvent::Usage(_)) => {} Ok(AgentEvent::HistoryReplaced(_updated_conversation)) => { // We should update the conversation here, but we're not reading it } @@ -503,13 +505,13 @@ mod tests { use goose::config::base::Config; use goose::config::GooseMode; use goose::conversation::message::Message; - use goose::model::ModelConfig; use goose::providers::base::{ stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata, }; use goose::session::session_manager::SessionType; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; use rmcp::model::{AnnotateAble, CallToolRequestParams, CallToolResult, RawContent, Tool}; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -550,6 +552,7 @@ mod tests { fn from_env( _model: ModelConfig, _extensions: Vec, + _tls_config: Option, ) -> futures::future::BoxFuture<'static, anyhow::Result> { Box::pin(async { Ok(Self::new()) }) } @@ -856,12 +859,12 @@ mod tests { use goose::config::permission::PermissionManager; use goose::config::GooseMode; use goose::conversation::message::Message; - use goose::model::ModelConfig; use goose::providers::base::{MessageStream, Provider, ProviderDef, ProviderMetadata}; use goose::session::session_manager::SessionType; use goose::session::SessionManager; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; use rmcp::model::{CallToolRequestParams, Role, Tool}; use rmcp::object; use std::path::PathBuf; @@ -902,6 +905,7 @@ mod tests { fn from_env( _model: ModelConfig, _extensions: Vec, + _tls_config: Option, ) -> futures::future::BoxFuture<'static, anyhow::Result> { unimplemented!() } @@ -1127,7 +1131,6 @@ mod tests { use goose::config::permission::PermissionManager; use goose::config::GooseMode; use goose::conversation::message::Message; - use goose::model::ModelConfig; use goose::providers::base::{ stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata, }; @@ -1135,6 +1138,7 @@ mod tests { use goose::session::SessionManager; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; use rmcp::model::Tool; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; @@ -1172,6 +1176,7 @@ mod tests { fn from_env( _model: ModelConfig, _extensions: Vec, + _tls_config: Option, ) -> futures::future::BoxFuture<'static, anyhow::Result> { Box::pin(async { Ok(Self::new()) }) } @@ -1386,6 +1391,105 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_setting_goal_via_reply_starts_a_turn() -> Result<()> { + let temp_dir = TempDir::new()?; + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let agent = create_agent_with_session_naming_disabled(session_manager.clone()); + let provider = Arc::new(GoalTextProvider::new()); + + let session = session_manager + .create_session( + PathBuf::default(), + "goal-start-turn".to_string(), + SessionType::Hidden, + GooseMode::default(), + ) + .await?; + agent.update_provider(provider.clone(), &session.id).await?; + + let session_config = SessionConfig { + id: session.id.clone(), + schedule_id: None, + max_turns: Some(10), + retry_config: None, + }; + + let reply_stream = agent + .reply( + Message::user().with_text("/goal make all tests pass"), + session_config, + None, + ) + .await?; + tokio::pin!(reply_stream); + + let mut messages = Vec::new(); + while let Some(event) = reply_stream.next().await { + if let Ok(AgentEvent::Message(msg)) = event { + messages.push(msg); + } + } + + // The provider must be invoked: setting a goal kicks off a turn + // (the goal-checking loop then runs and clears the goal once met). + assert!( + provider.call_count.load(Ordering::SeqCst) >= 1, + "Setting a goal should start an agent turn" + ); + + // The user still sees the confirmation. + assert!( + messages + .iter() + .any(|m| m.as_concat_text().contains("Goal set")), + "Goal confirmation should be surfaced to the user" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_querying_goal_via_reply_does_not_start_a_turn() -> Result<()> { + let temp_dir = TempDir::new()?; + let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); + let agent = create_agent_with_session_naming_disabled(session_manager.clone()); + let provider = Arc::new(GoalTextProvider::new()); + + let session = session_manager + .create_session( + PathBuf::default(), + "goal-query-no-turn".to_string(), + SessionType::Hidden, + GooseMode::default(), + ) + .await?; + agent.update_provider(provider.clone(), &session.id).await?; + + let session_config = SessionConfig { + id: session.id.clone(), + schedule_id: None, + max_turns: Some(10), + retry_config: None, + }; + + let reply_stream = agent + .reply(Message::user().with_text("/goal"), session_config, None) + .await?; + tokio::pin!(reply_stream); + while let Some(event) = reply_stream.next().await { + let _ = event?; + } + + assert_eq!( + provider.call_count.load(Ordering::SeqCst), + 0, + "Querying the goal should not start an agent turn" + ); + + Ok(()) + } } mod cumulative_token_tests { @@ -1395,12 +1499,12 @@ mod tests { use goose::config::permission::PermissionManager; use goose::config::GooseMode; use goose::conversation::message::Message; - use goose::model::ModelConfig; use goose::providers::base::{stream_from_single_message, MessageStream, Provider}; use goose::session::session_manager::SessionType; use goose::session::SessionManager; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; + use goose_providers::model::ModelConfig; use rmcp::model::Tool; use std::path::PathBuf; use std::sync::Arc; diff --git a/crates/goose/tests/compaction.rs b/crates/goose/tests/compaction.rs index a2d58aadd294..ad9dd39b1880 100644 --- a/crates/goose/tests/compaction.rs +++ b/crates/goose/tests/compaction.rs @@ -5,7 +5,6 @@ use goose::agents::{Agent, AgentEvent, SessionConfig}; use goose::config::GooseMode; use goose::conversation::message::{Message, MessageContent}; use goose::conversation::Conversation; -use goose::model::ModelConfig; use goose::providers::base::{ stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata, }; @@ -13,6 +12,7 @@ use goose::session::session_manager::SessionType; use goose::session::Session; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; +use goose_providers::model::ModelConfig; use rmcp::model::Tool; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -199,6 +199,7 @@ impl ProviderDef for MockCompactionProvider { fn from_env( _model: ModelConfig, _extensions: Vec, + _tls_config: Option, ) -> futures::future::BoxFuture<'static, anyhow::Result> { Box::pin(async { Ok(Self::new()) }) } diff --git a/crates/goose/tests/local_inference_integration.rs b/crates/goose/tests/local_inference_integration.rs index 971a76709287..d52ab0c4bfc9 100644 --- a/crates/goose/tests/local_inference_integration.rs +++ b/crates/goose/tests/local_inference_integration.rs @@ -16,8 +16,8 @@ use base64::prelude::*; use futures::StreamExt; use goose::conversation::message::Message; -use goose::model::ModelConfig; use goose::providers::create; +use goose_providers::model::ModelConfig; const DEFAULT_TEST_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M"; @@ -28,7 +28,7 @@ fn test_model() -> String { #[tokio::test] #[ignore] async fn test_local_inference_stream_produces_output() { - let model_config = ModelConfig::new(&test_model()).expect("valid model config"); + let model_config = ModelConfig::new(test_model()).expect("valid model config"); let provider = create("local", model_config.clone(), Vec::new()) .await .expect("provider creation should succeed"); @@ -70,7 +70,7 @@ async fn test_local_inference_stream_produces_output() { #[tokio::test] #[ignore] async fn test_local_inference_large_prompt() { - let model_config = ModelConfig::new(&test_model()) + let model_config = ModelConfig::new(test_model()) .expect("valid model config") .with_max_tokens(Some(20)); let provider = create("local", model_config.clone(), Vec::new()) @@ -182,7 +182,7 @@ async fn test_local_inference_vision_produces_output() { #[tokio::test] #[ignore] async fn test_local_inference_vision_text_only_model_graceful() { - let model_config = ModelConfig::new(&test_model()).expect("valid model config"); + let model_config = ModelConfig::new(test_model()).expect("valid model config"); let provider = create("local", model_config.clone(), Vec::new()) .await .expect("provider creation should succeed"); diff --git a/crates/goose/tests/local_inference_perf.rs b/crates/goose/tests/local_inference_perf.rs index fc3502fe06ea..0fb4603dd5f9 100644 --- a/crates/goose/tests/local_inference_perf.rs +++ b/crates/goose/tests/local_inference_perf.rs @@ -11,8 +11,8 @@ //! TEST_MODEL="bartowski/Qwen_Qwen3-32B-GGUF:Q4_K_M" cargo test -p goose --test local_inference_perf -- --ignored --nocapture use goose::conversation::message::Message; -use goose::model::ModelConfig; use goose::providers::create; +use goose_providers::model::ModelConfig; use std::time::Instant; const DEFAULT_TEST_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M"; @@ -24,7 +24,7 @@ fn test_model() -> String { #[tokio::test] #[ignore] async fn test_local_inference_cold_vs_warm() { - let model_config = ModelConfig::new(&test_model()) + let model_config = ModelConfig::new(test_model()) .expect("valid model config") .with_max_tokens(Some(20)); let provider = create("local", model_config.clone(), Vec::new()) diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index 859383d7b461..77ff78625072 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken; use goose::agents::extension::{Envs, ExtensionConfig}; use goose::agents::extension_manager::{ExtensionManager, ExtensionManagerCapabilities}; use goose::agents::GoosePlatform; -use goose::model::ModelConfig; +use goose_providers::model::ModelConfig; use test_case::test_case; @@ -61,6 +61,7 @@ impl ProviderDef for MockProvider { fn from_env( model: ModelConfig, _extensions: Vec, + _tls_config: Option, ) -> futures::future::BoxFuture<'static, anyhow::Result> { Box::pin(async move { Ok(Self::new(model)) }) } @@ -246,6 +247,7 @@ async fn test_replayed_session( envs, env_keys: vec![], timeout: Some(30), + cwd: None, bundled: Some(false), available_tools: vec![], }; diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index 24f49d8aab4c..b215c7d3ce65 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -289,7 +289,7 @@ impl ProviderFixture { async fn tool_roundtrip( &self, prompt: &str, - model_config: Option, + model_config: Option, ) -> Result { let tools = self .agent @@ -444,9 +444,9 @@ impl ProviderFixture { async fn test_image_content_support(&self) -> Result<()> { let image_config = match &self.image_model { - Some(model) => { - Some(goose::model::ModelConfig::new(model)?.with_canonical_limits(&self.name)) - } + Some(model) => Some( + goose_providers::model::ModelConfig::new(model)?.with_canonical_limits(&self.name), + ), None => None, }; let response = self @@ -467,7 +467,8 @@ impl ProviderFixture { async fn test_model_switch(&self) -> Result<()> { let default = &self.provider.get_model_config().model_name; let alt = self.model_switch_name.as_deref().unwrap(); - let alt_config = goose::model::ModelConfig::new(alt)?.with_canonical_limits(&self.name); + let alt_config = + goose_providers::model::ModelConfig::new(alt)?.with_canonical_limits(&self.name); let message = Message::user().with_text("Just say hello!"); let (response, _) = self diff --git a/crates/goose/tests/session_id_propagation_test.rs b/crates/goose/tests/session_id_propagation_test.rs index c8c069f154a6..2d4f54e6d61a 100644 --- a/crates/goose/tests/session_id_propagation_test.rs +++ b/crates/goose/tests/session_id_propagation_test.rs @@ -1,9 +1,9 @@ use goose::conversation::message::Message; -use goose::model::ModelConfig; use goose::providers::api_client::{ApiClient, AuthMethod}; use goose::providers::base::Provider; use goose::providers::openai::OpenAiProvider; use goose::session_context::SESSION_ID_HEADER; +use goose_providers::model::ModelConfig; use serde_json::json; use std::sync::Arc; use std::sync::Mutex; @@ -36,9 +36,10 @@ impl HeaderCapture { } fn create_test_provider(mock_server_url: &str) -> Box { - let api_client = ApiClient::new( + let api_client = ApiClient::new_with_tls( mock_server_url.to_string(), AuthMethod::BearerToken("test-key".to_string()), + None, ) .unwrap(); let model = ModelConfig::new_or_fail("gpt-5-nano"); diff --git a/crates/goose/tests/tetrate_streaming.rs b/crates/goose/tests/tetrate_streaming.rs index 04e7370c7313..111586a41c15 100644 --- a/crates/goose/tests/tetrate_streaming.rs +++ b/crates/goose/tests/tetrate_streaming.rs @@ -1,9 +1,9 @@ use anyhow::Result; use futures::StreamExt; use goose::conversation::message::{Message, MessageContent}; -use goose::model::ModelConfig; use goose::providers::base::Provider; use goose::providers::tetrate::TetrateProvider; +use goose_providers::model::ModelConfig; use rmcp::model::Tool; use rmcp::object; use serial_test::serial; @@ -17,7 +17,7 @@ mod tetrate_streaming_tests { // Create a test provider with the default model let model_config = ModelConfig::new("claude-3-5-sonnet-latest")?.with_canonical_limits("tetrate"); - TetrateProvider::from_env(model_config).await + TetrateProvider::from_env(model_config, None).await } #[tokio::test] @@ -248,7 +248,7 @@ mod tetrate_streaming_tests { let model_config = ModelConfig::new("claude-3-5-sonnet-latest")?.with_canonical_limits("tetrate"); - let provider = TetrateProvider::from_env(model_config).await?; + let provider = TetrateProvider::from_env(model_config, None).await?; let messages = vec![Message::user().with_text("Hello")]; let model_config = provider.get_model_config(); diff --git a/documentation/blog/2026-04-07-goose-moves-to-aaif/index.md b/documentation/blog/2026-04-07-goose-moves-to-aaif/index.md index 144e3190a70e..31387f4f4fa0 100644 --- a/documentation/blog/2026-04-07-goose-moves-to-aaif/index.md +++ b/documentation/blog/2026-04-07-goose-moves-to-aaif/index.md @@ -3,7 +3,11 @@ title: "goose has a new home - the Agentic AI Foundation (AAIF)" description: "goose has moved to the Agentic AI Foundation (aaif) at the Linux Foundation" authors: - mic +featured: true +image: /img/blog/goose-aaif-foundation.png --- +![goose moves to the Agentic AI Foundation](/img/blog/goose-aaif-foundation.png) + Block has donated goose to the [Agentic AI Foundation (AAIF)](https://aaif.io/) at the Linux Foundation, alongside Anthropic's Model Context Protocol (MCP) and OpenAI's AGENTS.md. You can read the [full announcement here](https://aaif.io/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation-aaif-anchored-by-new-project-contributions-including-model-context-protocol-mcp-goose-and-agents-md/). While nothing changes with goose's dedication to its mission and community, here's what's changed; diff --git a/documentation/blog/2026-04-08-goose-acp-and-new-tui/index.md b/documentation/blog/2026-04-08-goose-acp-and-new-tui/index.md index ef0d54f313e7..d1e6fd703b9a 100644 --- a/documentation/blog/2026-04-08-goose-acp-and-new-tui/index.md +++ b/documentation/blog/2026-04-08-goose-acp-and-new-tui/index.md @@ -3,7 +3,6 @@ title: "goose 2.0 beta - new architecture and clients" description: "We're shipping a new TUI, rewriting the desktop app in Tauri, and unifying everything under ACP." authors: - alexhancock -featured: true image: /img/blog/goose-2-blog-cover.jpg --- diff --git a/documentation/blog/2026-06-17-self-improving-agents-need-humans/index.md b/documentation/blog/2026-06-17-self-improving-agents-need-humans/index.md new file mode 100644 index 000000000000..72d0bd4e9964 --- /dev/null +++ b/documentation/blog/2026-06-17-self-improving-agents-need-humans/index.md @@ -0,0 +1,37 @@ +--- +title: "Self-Improving Agents Still Need Humans" +description: "Benchmarks are most useful when they become bug reports. Here's how the goose team uses Terminal-bench failures, Harbor, and human judgment to improve real agent behavior." +authors: + - douwe +image: /img/blog/self-improving-agents-need-humans.png +--- + +![A human engineer reviews an AI agent feedback loop across benchmark dashboards and terminal logs](/img/blog/self-improving-agents-need-humans.png) + +[Goodhart's law](https://en.wikipedia.org/wiki/Goodhart%27s_law) is the benchmarker's curse: when a measure becomes a target, it stops being a good measure. Coding-agent benchmarks are almost designed to trigger it. The tasks are public, the result is one number, and the leaderboard inevitably fills up with harnesses that are, often without meaning to be, overfit to the benchmark. + +That does not make the go-to standard [Terminal-bench](https://www.tbench.ai/) useless, but it does change how the goose team uses it. The leaderboard is a noisy measure of general agent ability. The signal is a pattern of failures: places where goose keeps getting stuck or where goose fails and another harness succeeds. + +That is also why we usually benchmark with [Sonnet](https://www.anthropic.com/claude/sonnet) rather than the strongest model available. We are not trying to get the largest possible number. We want enough failures left on the table to see what support the agent is missing. + + + +Self-improving agents are all the rage, but the version we trust requires humans for now. The loop is to run the benchmark, have [goose](https://github.com/aaif-goose/goose) compare a task where one harness succeeded and another failed, and ask it to explain the difference in concrete terms. A human then looks across a few of those failures, decides what the general lesson is, and asks goose to implement that broader improvement. + +The human step keeps the loop from collapsing into benchmark tricks. Without it, self-improving agents might choose to just write one [Skill](https://agentskills.io/) per task; agents are just as lazy as humans. With it, we can hopefully turn a failure on one task into a capability that should help on tasks we have not seen yet. + +The tooling for this lives in [`evals/harbor`](https://github.com/aaif-goose/goose/pull/9637) in the goose repo. The main entry point is `./evals/harbor/cmd.py`, a little Python command with subcommands for `run`, `list`, `show`, `task`, `compare`, and `pull`. It wraps [Harbor](https://github.com/harbor-framework/harbor) around a particular goose binary, model, and set of extensions, then leaves a directory full of per-task JSON and logs. + +This is a place where Python works really well. `cmd.py` is a [PEP 723](https://peps.python.org/pep-0723/) [`uv`](https://docs.astral.sh/uv/) script, so the dependencies sit at the top of the file. There is no packaging ceremony, no new repo, and no remembering which virtualenv was blessed. You ask an agent to add a subcommand, instrument a value, or make the output table less terrible, and it just does it. The script becomes both the control surface and the documentation of the experiment. This is where vibe coding shines: small tools that express what needs to be done and where it really doesn't matter if they are coded elegantly. + +The actual benchmark runs happen on a remote Linux machine, because a full run is slow, parallel, and Docker-heavy work. But the analysis does not have to stay there. `cmd.py pull tbench@...:/path/to/goose` rsyncs the run directories back down and then the local tool can list runs, inspect one task, or compare two jobs. That matters more than it sounds. If every question requires SSHing into the benchmark box and spelunking through logs by hand, you ask fewer questions. + +The comparison recipes in [PR #9637](https://github.com/aaif-goose/goose/pull/9637) implement the step where goose looks at the result. They read two runs for the same task, the agent trajectories, the verifier output, and the reference solution. The useful output explains the mechanism: "A noticed the image, B never opened it" or "A stopped after the correct file existed, B kept rewriting it until it broke." + +Here are two findings from recent work. First, goose would sometimes keep exploring after it had enough information to finish. It would be near the answer, but instead of concluding, it would keep poking and eventually run out of turns, failing the benchmark. The same thing can happen in a normal conversation: if goose does not know how far it is into the exchange, it has no real reason to stop exploring and steer toward a solution. [PR #9636](https://github.com/aaif-goose/goose/pull/9636) added turn count awareness to MoIM, the context goose injects to keep the model oriented. So now goose knows when it is time to call it a day. + +Second, goose had lost the ability to read images from disk. Originally, when you dragged an image into a goose conversation, we inserted the path to that image and gave the model a tool to read it. That was clunky, so we replaced it with proper image loading in the conversation. In the process, we accidentally deleted the disk image tool too. In the benchmark this showed up as goose using [PIL](https://pillow.readthedocs.io/) to inspect an image instead of using the visual abilities of the model. The comparison recipe made the failure obvious because the successful run looked at the image and the failing run worked around it. + +Thanks to the loop, we found two real weaknesses in goose and fixed them. The benchmark improved, which is nice, but more importantly, both fixes should make goose better in normal use: less likely to drift when it should finish and able again to look at an image file sitting on disk. + +That is when a benchmark is useful: when it stops being a leaderboard and starts being a bug report. diff --git a/documentation/blog/authors.yml b/documentation/blog/authors.yml index 517f10457e93..01ae59acb508 100644 --- a/documentation/blog/authors.yml +++ b/documentation/blog/authors.yml @@ -76,6 +76,17 @@ damien: linkedin: damienrj github: damienrj +douwe: + name: Douwe Osinga + title: Software Engineer + image_url: /img/authors/douwe-osinga.png + url: https://douwe.com + page: true + socials: + linkedin: douweosinga + github: DOsinga + x: dosinga + dean: name: Dean Wyatte title: Principal Machine Learning Engineer @@ -244,4 +255,3 @@ codefromthecrypt: github: codefromthecrypt x: adrianfcole linkedin: adrianfcole - diff --git a/documentation/docs/getting-started/installation.md b/documentation/docs/getting-started/installation.md index 4ee3465ffef6..7b0bddcd23ee 100644 --- a/documentation/docs/getting-started/installation.md +++ b/documentation/docs/getting-started/installation.md @@ -35,7 +35,7 @@ import { PanelLeft } from 'lucide-react';

Option 2: Install via Homebrew

Homebrew downloads the [same app](https://github.com/Homebrew/homebrew-cask/blob/master/Casks/b/block-goose.rb) but can take care of updates too. ```bash - brew install --cask block-goose + brew install --cask block-goose ``` ---
diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 0eba35b6eef1..e6fa240dd064 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -27,10 +27,11 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [Anthropic](https://www.anthropic.com/) | Offers Claude, an advanced AI model for natural language tasks. | `ANTHROPIC_API_KEY`, `ANTHROPIC_HOST` (optional) | | [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) | Run local models with Atomic Chat's OpenAI-compatible server. **Because this provider runs locally, you must first [download a model](#local-llms).** | None required. Connects to local server at `localhost:1337` by default. | | [Avian](https://avian.io/) | Cost-effective inference API with DeepSeek, Kimi, GLM, and MiniMax models. OpenAI-compatible with streaming and function calling support. | `AVIAN_API_KEY`, `AVIAN_HOST` (optional) | -| [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/) | Access Azure-hosted OpenAI models, including GPT-4 and GPT-3.5. Supports both API key and Azure credential chain authentication. | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY` (optional) | +| [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/) | Access Azure-hosted OpenAI models, including GPT-4 and GPT-3.5. Supports API key, Entra ID bearer token, and Azure credential chain authentication. | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY` (optional), `AZURE_OPENAI_AD_TOKEN` (optional) | | [ChatGPT Codex](https://chatgpt.com/codex) | Access GPT-5 Codex models optimized for code generation and understanding. **Requires a ChatGPT Plus/Pro subscription.** | No manual key. Uses browser-based OAuth authentication for both CLI and Desktop. | | [Databricks](https://www.databricks.com/) | Unified data analytics and AI platform for building and deploying models. | `DATABRICKS_HOST`, `DATABRICKS_TOKEN` | | [Docker Model Runner](https://docs.docker.com/ai/model-runner/) | Local models running in Docker Desktop or Docker CE with OpenAI-compatible API endpoints. **Because this provider runs locally, you must first [download a model](#local-llms).** | `OPENAI_HOST`, `OPENAI_BASE_PATH` | +| [EmpirioLabs](https://empiriolabs.ai/) | Frontier open and proprietary chat models (Qwen, DeepSeek, GLM, Kimi, MiniMax) through one OpenAI-compatible API with streaming. Catalog available at `https://api.empiriolabs.ai/v1/models`. | `EMPIRIOLABS_API_KEY` | | [FuturMix](https://futurmix.ai/) | Unified AI gateway providing access to models from Anthropic, Google, OpenAI, and DeepSeek through an OpenAI-compatible API. | `FUTURMIX_API_KEY` | | [Gemini](https://ai.google.dev/gemini-api/docs) | Advanced LLMs by Google with multimodal capabilities (text, images). Gemini 3 models support configurable [thinking levels](#gemini-3-thinking-levels). | `GOOGLE_API_KEY`, `GEMINI3_THINKING_LEVEL` (optional) | | [GCP Vertex AI](https://cloud.google.com/vertex-ai) | Google Cloud's Vertex AI platform, supporting Gemini and Claude models. **Credentials must be [configured in advance](https://cloud.google.com/vertex-ai/docs/authentication).** Filters for allowed models by organization policy (if configured). | `GCP_PROJECT_ID`, `GCP_LOCATION` and optionally `GCP_MAX_RATE_LIMIT_RETRIES` (5), `GCP_MAX_OVERLOADED_RETRIES` (5), `GCP_INITIAL_RETRY_INTERVAL_MS` (5000), `GCP_BACKOFF_MULTIPLIER` (2.0), `GCP_MAX_RETRY_INTERVAL_MS` (320_000). | @@ -88,11 +89,11 @@ To configure your chosen provider, see available options, or select a model, vis **First-time users:** - + On the welcome screen the first time you open goose, you have these options: - + - + 1. Choose `Quick Setup with API Key`. @@ -108,34 +109,34 @@ To configure your chosen provider, see available options, or select a model, vis 4. When you return to goose Desktop, you're ready to begin your first session. - We recommend new users start with Agent Router by Tetrate. Tetrate provides access to multiple AI models with built-in rate limiting and automatic failover. + We recommend new users start with Agent Router by Tetrate. Tetrate provides access to multiple AI models with built-in rate limiting and automatic failover. :::info Free Credits Offer You'll receive $10 in free credits the first time you automatically authenticate with Tetrate through goose. This offer is available to both new and existing Tetrate users. ::: - 1. Choose `Agent Router by Tetrate`. + 1. Choose `Agent Router by Tetrate`. 2. goose will open a browser window for you to authenticate with Tetrate, or create a new account if you don't have one already. 3. When you return to goose Desktop, you're ready to begin your first session. - 1. Choose `Automatic setup with OpenRouter`. + 1. Choose `Automatic setup with OpenRouter`. 2. goose will open a browser window for you to authenticate with OpenRouter, or create a new account if you don't have one already. 3. When you return to the goose Desktop, you're ready to begin your first session. - 1. If you have a specific provider you want to use with goose, and an API key from that provider, choose `Other Providers`. - 2. Find the provider of your choice and click its `Configure` button. If you don't see your provider in the list, click `Add Custom Provider` at the bottom of the window to [configure a custom provider](#configure-custom-provider). + 1. If you have a specific provider you want to use with goose, and an API key from that provider, choose `Other Providers`. + 2. Find the provider of your choice and click its `Configure` button. If you don't see your provider in the list, click `Add Custom Provider` at the bottom of the window to [configure a custom provider](#configure-custom-provider). 3. Depending on your provider, you'll need to input your API Key, API Host, or other optional [parameters](#available-providers). Click the `Submit` button to authenticate and begin your first session. :::info Ollama Model Detection For Ollama users, all locally installed models display automatically in the model selection dropdown. ::: - + - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar 2. Click the `Settings` button on the sidebar 3. Click the `Models` tab @@ -163,7 +164,7 @@ To configure your chosen provider, see available options, or select a model, vis 4. Click `Reset Provider and Model` to clear your current settings and return to the welcome screen - 1. In your terminal, run the following command: + 1. In your terminal, run the following command: ```sh goose configure @@ -172,59 +173,59 @@ To configure your chosen provider, see available options, or select a model, vis 2. Select `Configure Providers` from the menu and press `Enter`. ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? // highlight-start │ ● Configure Providers (Change provider or update credentials) // highlight-end - │ ○ Custom Providers - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Custom Providers + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Choose a model provider and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list. ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Amazon Bedrock - │ ○ Amazon SageMaker TGI + │ ○ Amazon Bedrock + │ ○ Amazon SageMaker TGI // highlight-start │ ● Anthropic (Claude and other models from Anthropic) // highlight-end - │ ○ Azure OpenAI + │ ○ Azure OpenAI │ ○ Claude Code CLI │ ○ ... - └ + └ ``` 4. Enter your API key (and any other configuration details) when prompted. ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Anthropic + │ Anthropic │ ◆ Provider Anthropic requires ANTHROPIC_API_KEY, please enter a value // highlight-start │ ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ // highlight-end - └ + └ ``` - + If you're just changing models, skip any prompts to update the provider configuration. - 5. Enter your desired `ANTHROPIC_HOST` or press `Enter` to use the default. + 5. Enter your desired `ANTHROPIC_HOST` or press `Enter` to use the default. ``` ◆ Provider Anthropic requires ANTHROPIC_HOST, please enter a value @@ -236,7 +237,7 @@ To configure your chosen provider, see available options, or select a model, vis - Select the model from a list - Search for the model by name - Enter the model name directly - + ``` │ ◇ Model fetch complete @@ -249,7 +250,7 @@ To configure your chosen provider, see available options, or select a model, vis ◒ Checking your configuration... └ Configuration saved successfully ``` - + This change takes effect the next time you start a session. :::note @@ -381,7 +382,7 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T 4. Click `Configure providers` 5. Click `Add Custom Provider` at the bottom of the window 6. Fill in the provider details: - - **Provider Type**: + - **Provider Type**: - `OpenAI Compatible` (most common) - `Anthropic Compatible` - `Ollama Compatible` @@ -400,7 +401,7 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T - 1. In your terminal, run the following command: + 1. In your terminal, run the following command: ```sh goose configure @@ -409,38 +410,38 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T 2. Select `Custom Providers`. Use the arrow keys (↑/↓) to move through the options. ```sh - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ○ Configure Providers // highlight-start │ ● Custom Providers (Add custom provider with compatible API) // highlight-end - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Select `Add A Custom Provider` ```sh - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Custom Providers + │ Custom Providers │ ◆ What would you like to do? // highlight-start │ ● Add A Custom Provider (Add a new OpenAI/Anthropic/Ollama compatible Provider) // highlight-end │ ○ Remove Custom Provider - └ + └ ``` 4. Follow the prompts to enter the provider details: - - **API Type**: + - **API Type**: - `OpenAI Compatible` (most common) - `Anthropic Compatible` - `Ollama Compatible` @@ -519,8 +520,8 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T - - 1. In your terminal, run the following command: + + 1. In your terminal, run the following command: ```sh goose configure @@ -529,40 +530,40 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T 2. Select `Configure Providers` from the menu and press `Enter`. ```sh - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? // highlight-start │ ● Configure Providers (Change provider or update credentials) // highlight-end - │ ○ Custom Providers - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Custom Providers + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Select the custom provider you want to update and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list. ```sh - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Amazon Bedrock - │ ○ Amazon SageMaker TGI + │ ○ Amazon Bedrock + │ ○ Amazon SageMaker TGI │ ○ Anthropic - │ ○ Azure OpenAI - │ ○ Claude Code CLI + │ ○ Azure OpenAI + │ ○ Claude Code CLI // highlight-start │ ● Corporate API (Custom Corporate API provider) // highlight-end - │ ○ Cursor Agent + │ ○ Cursor Agent │ ○ ... - └ + └ ``` 4. Follow the prompts to update the fields. @@ -594,8 +595,8 @@ Your changes are available in your next goose session. - - 1. In your terminal, run the following command: + + 1. In your terminal, run the following command: ```sh goose configure @@ -604,34 +605,34 @@ Your changes are available in your next goose session. 2. Select `Custom Providers`. Use the arrow keys (↑/↓) to move through the options. ```sh - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ○ Configure Providers // highlight-start │ ● Custom Providers (Add custom provider with compatible API) // highlight-end - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Select `Remove Custom Provider`. ```sh - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Custom Providers + │ Custom Providers │ ◆ What would you like to do? - │ ○ Add A Custom Provider + │ ○ Add A Custom Provider // highlight-start │ ● Remove Custom Provider (Remove an existing custom provider) // highlight-end - └ + └ ``` 4. Select the custom provider you want to remove. @@ -654,7 +655,7 @@ Your changes are available in your next goose session. ## Using goose for Free -goose is a free and open source AI agent that you can start using right away, but not all supported [LLM Providers][providers] provide a free tier. +goose is a free and open source AI agent that you can start using right away, but not all supported [LLM Providers][providers] provide a free tier. Below, we outline a couple of free options and how to get started with them. @@ -668,7 +669,7 @@ Groq provides free access to open source (open weight) models with high-speed in Groq offers several open source models that support tool calling, including: - **moonshotai/kimi-k2-instruct-0905** - Mixture-of-Experts model with 1 trillion parameters, optimized for agentic intelligence and tool use -- **qwen/qwen3-32b** - 32.8 billion parameter model with advanced reasoning and multilingual capabilities +- **qwen/qwen3-32b** - 32.8 billion parameter model with advanced reasoning and multilingual capabilities - **llama-3.3-70b-versatile** - Meta's Llama 3.3 model for versatile applications - **llama-3.1-8b-instant** - Meta's Llama 3.1 model for fast inference @@ -678,7 +679,7 @@ To set up Groq with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -690,7 +691,7 @@ To set up Groq with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -701,6 +702,47 @@ To set up Groq with goose, follow these steps: +### EmpirioLabs +[EmpirioLabs](https://empiriolabs.ai/) provides access to frontier open and proprietary chat models through a single OpenAI-compatible API with streaming. To use EmpirioLabs with goose, you need an API key from [EmpirioLabs](https://platform.empiriolabs.ai/dashboard/api-keys). + +EmpirioLabs offers models that support tool calling, including: +- **qwen3-7-plus** - Qwen3.7 Plus with a 1M context window +- **qwen3-7-max** - Qwen3.7 Max with a 1M context window +- **deepseek-v4-pro** - DeepSeek V4 Pro with a 1M context window +- **deepseek-v4-flash** - DeepSeek V4 Flash with a 1M context window +- **glm-5-1** - GLM-5.1 with a 202K context window +- **kimi-k2-7-code** - Kimi K2.7 Code with a 256K context window +- **minimax-m3** - MiniMax M3 with a 524K context window + +The full live catalog is available at `https://api.empiriolabs.ai/v1/models`. For the complete list of EmpirioLabs models configured in goose, see [empiriolabs.json](https://github.com/aaif-goose/goose/blob/main/crates/goose/src/providers/declarative/empiriolabs.json). For more details, see the [EmpirioLabs documentation](https://docs.empiriolabs.ai). + +To set up EmpirioLabs with goose, follow these steps: + + + + **To update your LLM provider and API key:** + + 1. Click the button in the top-left to open the sidebar. + 2. Click the `Settings` button on the sidebar. + 3. Click the `Models` tab. + 4. Click `Configure Providers` + 5. Choose `EmpirioLabs` as provider from the list. + 6. Click `Configure`, enter your API key, and click `Submit`. + 7. Select the EmpirioLabs model of your choice. + + + + 1. Run: + ```sh + goose configure + ``` + 2. Select `Configure Providers` from the menu. + 3. Follow the prompts to choose `EmpirioLabs` as the provider. + 4. Enter your API key when prompted. + 5. Select the EmpirioLabs model of your choice. + + + ### FuturMix [FuturMix](https://futurmix.ai/) is a unified AI gateway providing access to models from Anthropic, Google, OpenAI, and DeepSeek through an OpenAI-compatible API. To use FuturMix with goose, you need an API key from [FuturMix](https://futurmix.ai/). @@ -717,7 +759,7 @@ To set up FuturMix with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -729,7 +771,7 @@ To set up FuturMix with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -756,7 +798,7 @@ To set up Novita AI with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -768,7 +810,7 @@ To set up Novita AI with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -823,7 +865,7 @@ To set up Google Gemini with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -834,7 +876,7 @@ To set up Google Gemini with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -854,7 +896,7 @@ To set up Google Gemini with goose, follow these steps: │ ◇ Provider Google Gemini requires GOOGLE_API_KEY, please enter a value │▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ - │ + │ ◇ Enter a model from that provider: │ gemini-2.0-flash-exp │ @@ -977,14 +1019,14 @@ Here are some local providers we support: - The native `DeepSeek-r1` model doesn't support tool calling, however, we have a [custom model](https://ollama.com/michaelneale/deepseek-r1-goose) you can use with goose. + The native `DeepSeek-r1` model doesn't support tool calling, however, we have a [custom model](https://ollama.com/michaelneale/deepseek-r1-goose) you can use with goose. :::warning Note that this is a 70B model size and requires a powerful device to run smoothly. ::: - 1. [Download Ollama](https://ollama.com/download). + 1. [Download Ollama](https://ollama.com/download). 2. In a terminal window, run the following command to install the custom DeepSeek-r1 model: ```sh @@ -1000,44 +1042,44 @@ Here are some local providers we support: 4. Choose to `Configure Providers` ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ● Configure Providers (Change provider or update credentials) - │ ○ Toggle Extensions - │ ○ Add Extension - └ + │ ○ Toggle Extensions + │ ○ Add Extension + └ ``` 5. Choose `Ollama` as the model provider ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Anthropic - │ ○ Databricks - │ ○ Google Gemini - │ ○ Groq + │ ○ Anthropic + │ ○ Databricks + │ ○ Google Gemini + │ ○ Groq │ ● Ollama (Local open source models) - │ ○ OpenAI - │ ○ OpenRouter - └ + │ ○ OpenAI + │ ○ OpenRouter + └ ``` 6. Enter the host where your model is running ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◆ Provider Ollama requires OLLAMA_HOST, please enter a value │ http://localhost:11434 @@ -1047,17 +1089,17 @@ Here are some local providers we support: 7. Enter the installed model from above ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◇ Provider Ollama requires OLLAMA_HOST, please enter a value - │ http://localhost:11434 - │ + │ http://localhost:11434 + │ ◇ Enter a model from that provider: │ michaelneale/deepseek-r1-goose │ @@ -1067,7 +1109,7 @@ Here are some local providers we support: ``` - 1. [Download Ollama](https://ollama.com/download). + 1. [Download Ollama](https://ollama.com/download). 2. In a terminal, run any [model supporting tool-calling](https://ollama.com/search?c=tools) Example: @@ -1085,32 +1127,32 @@ Here are some local providers we support: 4. Choose to `Configure Providers` ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ● Configure Providers (Change provider or update credentials) - │ ○ Toggle Extensions - │ ○ Add Extension - └ + │ ○ Toggle Extensions + │ ○ Add Extension + └ ``` 5. Choose `Ollama` as the model provider ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Anthropic - │ ○ Databricks - │ ○ Google Gemini - │ ○ Groq + │ ○ Anthropic + │ ○ Databricks + │ ○ Google Gemini + │ ○ Groq │ ● Ollama (Local open source models) - │ ○ OpenAI - │ ○ OpenRouter - └ + │ ○ OpenAI + │ ○ OpenRouter + └ ``` 6. Enter the host where your model is running @@ -1123,13 +1165,13 @@ Here are some local providers we support: ::: ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◆ Provider Ollama requires OLLAMA_HOST, please enter a value │ http://localhost:11434 @@ -1140,13 +1182,13 @@ Here are some local providers we support: 7. Enter the model you have running ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◇ Provider Ollama requires OLLAMA_HOST, please enter a value │ http://localhost:11434 @@ -1162,7 +1204,7 @@ Here are some local providers we support: :::tip Context Length If you notice that goose is having trouble using extensions or is ignoring [.goosehints](/docs/guides/context-engineering/using-goosehints), it is likely that the model's default context length of 4096 tokens is too low. Set the `OLLAMA_CONTEXT_LENGTH` environment variable to a [higher value](https://github.com/ollama/ollama/blob/main/docs/faq.mdx#how-can-i-specify-the-context-window-size). ::: - + @@ -1275,7 +1317,7 @@ Here are some local providers we support: docker model pull hf.co/unsloth/gemma-3n-e4b-it-gguf:q6_k ``` - 4. Configure goose to use Docker Model Runner, using the OpenAI API compatible endpoint: + 4. Configure goose to use Docker Model Runner, using the OpenAI API compatible endpoint: ```sh goose configure @@ -1284,16 +1326,16 @@ Here are some local providers we support: 5. Choose to `Configure Providers` ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ● Configure Providers (Change provider or update credentials) - │ ○ Toggle Extensions - │ ○ Add Extension - └ + │ ○ Toggle Extensions + │ ○ Add Extension + └ ``` - 6. Choose `OpenAI` as the model provider: + 6. Choose `OpenAI` as the model provider: ``` ┌ goose-configure @@ -1309,7 +1351,7 @@ Here are some local providers we support: │ ○ OpenRouter ``` - 7. Configure Docker Model Runner endpoint as the `OPENAI_HOST`: + 7. Configure Docker Model Runner endpoint as the `OPENAI_HOST`: ``` ┌ goose-configure @@ -1325,10 +1367,10 @@ Here are some local providers we support: └ ``` - The default value for the host-side port Docker Model Runner is 12434, so the `OPENAI_HOST` value could be: - `http://localhost:12434`. + The default value for the host-side port Docker Model Runner is 12434, so the `OPENAI_HOST` value could be: + `http://localhost:12434`. - 8. Configure the base path: + 8. Configure the base path: ``` ◆ Provider OpenAI requires OPENAI_BASE_PATH, please enter a value @@ -1345,7 +1387,7 @@ Here are some local providers we support: ◇ Enter a model from that provider: │ gpt-4o │ - ◒ Checking your configuration... + ◒ Checking your configuration... └ Configuration saved successfully ``` @@ -1363,12 +1405,15 @@ GitHub Copilot uses a device flow for authentication, so no API keys are require 4. Paste the code to authorize the application 5. When you return to goose, GitHub Copilot will be available as a provider in both CLI and Desktop. -## Azure OpenAI Credential Chain +## Azure OpenAI Authentication + +goose supports three authentication methods for Azure OpenAI: -goose supports two authentication methods for Azure OpenAI: +1. **Entra ID Bearer Token** - Uses a pre-acquired Microsoft Entra access token from `AZURE_OPENAI_AD_TOKEN`, sent as `Authorization: Bearer `. goose skips Azure CLI and token acquisition entirely, which suits enterprise deployments where only short-lived tokens are exposed to the runtime (e.g. obtained via `az account get-access-token --resource https://cognitiveservices.azure.com --query accessToken --output tsv`) +2. **API Key Authentication** - Uses the `AZURE_OPENAI_API_KEY` for direct authentication +3. **Azure Credential Chain** - Uses Azure CLI credentials automatically without requiring an API key -1. **API Key Authentication** - Uses the `AZURE_OPENAI_API_KEY` for direct authentication -2. **Azure Credential Chain** - Uses Azure CLI credentials automatically without requiring an API key +When more than one is configured, `AZURE_OPENAI_AD_TOKEN` takes precedence over `AZURE_OPENAI_API_KEY`, which takes precedence over the credential chain. To use the Azure Credential Chain: - Ensure you're logged in with `az login` @@ -1398,12 +1443,12 @@ When thinking is enabled, you can view the model's reasoning process. See [Viewi When selecting a Gemini 3 model, a "Thinking Level" dropdown appears automatically. Select your preference and the setting persists across sessions. - + **Interactive configuration:** - + When you run `goose configure` and select a Gemini 3 model, you'll be prompted to choose a thinking level: - + ``` ◆ Select thinking level for Gemini 3: │ ● Low - Better latency, lighter reasoning @@ -1414,7 +1459,7 @@ When thinking is enabled, you can view the model's reasoning process. See [Viewi :::info Priority Order The thinking level is determined in this order (highest to lowest priority): -1. `request_params.thinking_level` in model configuration (via `GOOSE_PREDEFINED_MODELS`) +1. `request_params.thinking_level` in model configuration 2. `GEMINI3_THINKING_LEVEL` environment variable 3. Default value: `low` ::: @@ -1434,16 +1479,16 @@ Some models expose their internal reasoning or "chain of thought" as part of the Reasoning output appears automatically in a collapsible **"Show reasoning"** toggle above the model's response. Click it to expand and view the model's thought process. - + Reasoning output is **hidden by default** in the CLI. To display it, set the `GOOSE_CLI_SHOW_THINKING` environment variable: - + ```bash export GOOSE_CLI_SHOW_THINKING=1 ``` - + When enabled, reasoning appears under a "Thinking:" header in dimmed text before the model's main response. - + :::note This requires stdout to be a terminal (reasoning output won't appear when piping output to a file or another command). ::: diff --git a/documentation/docs/guides/context-engineering/using-goosehints.md b/documentation/docs/guides/context-engineering/using-goosehints.md index cf53fb9b1df4..5880da58957d 100644 --- a/documentation/docs/guides/context-engineering/using-goosehints.md +++ b/documentation/docs/guides/context-engineering/using-goosehints.md @@ -6,7 +6,7 @@ sidebar_label: Using goosehints import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -import { PanelLeft } from 'lucide-react'; +import { Menu } from 'lucide-react'; `.goosehints` is a text file used to provide additional context about your project and improve the communication with goose. The use of `.goosehints` ensures that goose understands your requirements better and can execute tasks more effectively. @@ -51,7 +51,7 @@ You can use other agent rule files with goose by using the [`CONTEXT_FILE_NAMES` #### Local hints file 1. Click the directory path at the bottom of the app and open the directory where you want to create the file - 2. Click the button in the top-left to open the sidebar + 2. Click the hamburger button in the top-left to open the sidebar 3. Click `Settings` in the sidebar 4. Click `Chat` 5. Scroll down to the `Project Hints (.goosehints)` section and click `Configure` diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 2583445d3fa7..9e7a06ca12b3 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -60,80 +60,6 @@ export GOOSE_PROVIDER__HOST="https://api.anthropic.com" export GOOSE_PROVIDER__API_KEY="your-api-key-here" ``` -### Custom Model Definitions - -Define custom model configurations with provider-specific parameters and context limits. This is useful for enabling provider beta features (like extended context windows) or configuring models with specific settings. - -| Variable | Purpose | Values | Default | -|----------|---------|---------|---------| -| `GOOSE_PREDEFINED_MODELS` | Define custom model configurations | JSON array of model objects | None | - -**Model Configuration Fields:** - -| Field | Required | Type | Description | -|-------|----------|------|-------------| -| `id` | No | number | Optional numeric identifier | -| `name` | Yes | string | Model name used to reference this configuration | -| `provider` | Yes | string | Provider name (e.g., "databricks", "openai", "anthropic") | -| `alias` | No | string | Display name for the model | -| `subtext` | No | string | Additional descriptive text | -| `context_limit` | No | number | Override the default context window size in tokens | -| `request_params` | No | object | Provider-specific parameters included in API requests | - -:::info -The `id`, `alias`, and `subtext` fields are currently not used. -::: - -When a custom model's `context_limit` is specified, it takes precedence over pattern-matching but can still be overridden by explicit environment variables like [`GOOSE_CONTEXT_LIMIT`](#model-context-limit-overrides). - -**Examples** - -```bash -# Enable Anthropic's 1M context window with beta header -export GOOSE_PREDEFINED_MODELS='[ - { - "id": 1, - "name": "claude-sonnet-4-1m", - "provider": "anthropic", - "alias": "Claude Sonnet 4 (1M context)", - "subtext": "Anthropic", - "context_limit": 1000000, - "request_params": { - "anthropic_beta": ["context-1m-2025-08-07"] - } - } -]' - -# Define multiple custom models -export GOOSE_PREDEFINED_MODELS='[ - { - "id": 1, - "name": "gpt-4-custom", - "provider": "openai", - "alias": "GPT-4 (200k)", - "context_limit": 200000 - }, - { - "id": 2, - "name": "internal-model", - "provider": "databricks", - "alias": "Internal Model (500k)", - "context_limit": 500000 - } -]' - -# Gemini 3 with high thinking level -export GOOSE_PREDEFINED_MODELS='[ - { - "name": "gemini-3-pro", - "provider": "google", - "request_params": {"thinking_level": "high"} - } -]' -``` - -Custom context limits and request parameters are applied when the model is used. Custom context limits are displayed in goose CLI's [token usage indicator](/docs/guides/sessions/smart-context-management#token-usage). - ### Claude Thinking Configuration These variables control Claude's reasoning behavior. Supported on Anthropic and Databricks providers. diff --git a/documentation/docs/mcp/ophis-mcp.md b/documentation/docs/mcp/ophis-mcp.md new file mode 100644 index 000000000000..ad1290322ccd --- /dev/null +++ b/documentation/docs/mcp/ophis-mcp.md @@ -0,0 +1,83 @@ +--- +title: Ophis Extension +description: Add Ophis as a goose Extension to swap tokens with natural-language intents +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions'; +import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller'; + +This tutorial covers how to add [Ophis](https://ophis.fi) as a goose extension so goose can turn natural-language swap requests into executable orders across supported chains. + +:::tip Quick Install + + + [Launch the installer](goose://extension?type=streamable_http&url=https%3A%2F%2Fmcp.ophis.fi%2Fmcp&id=ophis&name=Ophis&description=Natural-language%20intent%20DEX%20aggregator%20with%20a%20keyless%20MCP%20server%20for%20AI%20agents) + + + Use `goose configure` to add a `Remote Extension (Streamable HTTP)` extension type with: + + **Endpoint URL** + ``` + https://mcp.ophis.fi/mcp + ``` + + +::: + +## What is Ophis? + +Ophis is an intent-based DEX aggregator with a natural-language layer and a keyless MCP server for AI agents. It is non-custodial, gasless, and MEV-protected, and returns swap surplus to the trader. Ophis is a fork of CoW Protocol. It deploys its own settlement contract on Optimism and routes through CoW Protocol on the other supported chains: Ethereum, Base, Arbitrum, Polygon, BNB Chain, Gnosis, Avalanche, Linea, Plasma, and Ink. + +The MCP server is keyless and requires no API key or environment variables. It exposes six tools: `parse_intent`, `get_quote`, `build_order`, `submit_order`, `lookup_tier`, and `list_chains`. Trades are signed by the user's own wallet, so the server never holds keys or funds. + +## Configuration + + + + + + + + + + +## Example Usage + +Once Ophis is configured, you can describe a swap in plain language and ask goose to prepare an order. Here are some examples: + +**Parse an intent** +``` +Parse this swap intent: "swap 100 USDC for ETH on Base". +``` + +**Get a quote** +``` +Get a quote to swap 0.5 WETH for USDC on Arbitrum. +``` + +**List supported chains** +``` +Which chains does Ophis support? +``` + +The server returns a quote and an unsigned order. The actual trade is signed by your own wallet, so no keys or funds are ever shared with the extension. + +## Resources + +- Website: [ophis.fi](https://ophis.fi) +- App: [swap.ophis.fi](https://swap.ophis.fi) +- Docs: [docs.ophis.fi](https://docs.ophis.fi) +- Source: [github.com/ophis-fi/ophis](https://github.com/ophis-fi/ophis) diff --git a/documentation/docs/mcp/scholar-sidekick-mcp.md b/documentation/docs/mcp/scholar-sidekick-mcp.md new file mode 100644 index 000000000000..197dcc8a3147 --- /dev/null +++ b/documentation/docs/mcp/scholar-sidekick-mcp.md @@ -0,0 +1,121 @@ +--- +title: Scholar Sidekick Extension +description: Add Scholar Sidekick MCP Server as a goose Extension +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller'; +import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions'; + +This tutorial covers how to add the [Scholar Sidekick MCP Server](https://github.com/mlava/scholar-sidekick-mcp) as a goose extension to resolve, format, export, and verify academic citations from any scholarly identifier — DOI, PMID, PMCID, ISBN, ISSN, arXiv ID, ADS bibcode, or WHO IRIS URL — plus retraction (Crossref + Retraction Watch) and open-access (Unpaywall) checks. + +:::tip Quick Install + + + [Launch the installer](goose://extension?cmd=npx&arg=-y&arg=scholar-sidekick-mcp%40latest&id=scholar-sidekick&name=Scholar%20Sidekick&description=Resolve%2C%20format%2C%20export%2C%20and%20verify%20academic%20citations%20plus%20retraction%20and%20open-access%20checks&timeout=300) + + + **Command** + ```sh + npx -y scholar-sidekick-mcp@latest + ``` + + + **No API key required** — the server works anonymously on a free, rate-limited tier. Optionally set `SCHOLAR_API_KEY` (a free `ssk_` key from [scholar-sidekick.com/account](https://scholar-sidekick.com/account)) for higher limits, or `RAPIDAPI_KEY` for paid tiers. See [Optional: higher rate limits](#optional-higher-rate-limits). +::: + +## Configuration + +:::info +You'll need [Node.js](https://nodejs.org/) installed (the command uses `npx`). **No API key is required** — Scholar Sidekick works anonymously on a free, rate-limited tier. For higher limits, add a free first-party key (`SCHOLAR_API_KEY`, an `ssk_` key from [scholar-sidekick.com/account](https://scholar-sidekick.com/account)); for paid/managed tiers, add a [RapidAPI key](https://rapidapi.com/scholar-sidekick-scholar-sidekick-api/api/scholar-sidekick) (`RAPIDAPI_KEY`). See [Optional: higher rate limits](#optional-higher-rate-limits) below. +::: + + + + + + + + + + +## Optional: higher rate limits + +Scholar Sidekick runs **without any key** on a free, rate-limited tier — fine for normal interactive use. To raise your limits, add one environment variable to the extension (goose Desktop: extension settings → Environment Variables; CLI: `goose configure` → the extension's env): + +- **`SCHOLAR_API_KEY`** — a **free** first-party key (prefixed `ssk_`). Create one at [scholar-sidekick.com/account](https://scholar-sidekick.com/account). Sent as `Authorization: Bearer`; raises your rate limit and unlocks the verifier's optional LLM screen. +- **`RAPIDAPI_KEY`** — for paid/managed tiers via the [RapidAPI gateway](https://rapidapi.com/scholar-sidekick-scholar-sidekick-api/api/scholar-sidekick). When set, calls route through RapidAPI instead of the anonymous/first-party endpoint. + +Neither is required, and you never need both — if both are set, RapidAPI takes precedence. + +## What You Can Do + +Scholar Sidekick exposes six tools that turn any scholarly identifier into clean citations, exports, and integrity checks. Five built-in citation styles (Vancouver, AMA, APA, IEEE, CSE) plus the full CSL catalogue of 10,000+ styles. + +### Format a citation from any identifier + +Paste a DOI, PMID, PMCID, ISBN, ISSN, arXiv ID, ADS bibcode, or WHO IRIS URL and get a formatted reference in your chosen style. Detection is automatic — pass identifiers verbatim (no need to strip `PMID:`, `arXiv:`, or `https://doi.org/` prefixes). + +**Prompt:** + +``` +Format 10.1056/NEJMoa2033700 as a Vancouver-style citation. +``` + +### Export a bibliography for your reference manager + +Pass one or more identifiers and get a ready-to-import file in BibTeX, RIS, EndNote XML, RefWorks, NBIB, Zotero RDF, CSV, or CSL-JSON. Drop straight into Zotero, Mendeley, EndNote, JabRef, or Citavi. + +**Prompt:** + +``` +Take these three DOIs and export them as BibTeX: +10.1056/NEJMoa2033700 +10.1038/s41586-020-2649-2 +10.1016/S0140-6736(20)32661-1 +``` + +### Check whether a paper has been retracted + +Cross-references Crossref and Retraction Watch for retractions, corrections, and expressions of concern. Returns status, reason, and date. + +**Prompt:** + +``` +Has 10.1016/S0140-6736(97)11096-0 been retracted? +``` + +### Find an open-access copy + +Looks up Unpaywall to surface the best legal free version of a paper — repository copy, publisher OA, or preprint — with the licence and version (accepted vs published). + +**Prompt:** + +``` +Is there a free open-access copy of 10.1371/journal.pone.0173664? +``` + +### Verify whether a citation is real + +Cross-checks a *claimed* citation (title, optional authors/year) against the metadata actually resolved from its identifier. Catches the dominant LLM-fabrication pattern — a real, resolvable DOI paired with an invented title and authors — documented by Topaz et al. (Lancet, 2026). Use this when an AI-generated bibliography "looks plausible but…". + +**Prompt:** + +``` +Is this citation real? "A Unified Theory of Everything", Smith J, Nature, 2010, 10.1038/nphys1170 +``` diff --git a/documentation/static/img/authors/douwe-osinga.png b/documentation/static/img/authors/douwe-osinga.png new file mode 100644 index 000000000000..4796b9be5890 Binary files /dev/null and b/documentation/static/img/authors/douwe-osinga.png differ diff --git a/documentation/static/img/blog/goose-aaif-foundation.png b/documentation/static/img/blog/goose-aaif-foundation.png new file mode 100644 index 000000000000..4713ed71e062 Binary files /dev/null and b/documentation/static/img/blog/goose-aaif-foundation.png differ diff --git a/documentation/static/img/blog/self-improving-agents-need-humans.png b/documentation/static/img/blog/self-improving-agents-need-humans.png new file mode 100644 index 000000000000..d8755d6ee29a Binary files /dev/null and b/documentation/static/img/blog/self-improving-agents-need-humans.png differ diff --git a/documentation/static/servers.json b/documentation/static/servers.json index 8c4de0011ddf..189ada2d15da 100644 --- a/documentation/static/servers.json +++ b/documentation/static/servers.json @@ -567,6 +567,18 @@ } ] }, + { + "id": "ophis", + "name": "Ophis", + "description": "Natural-language intent DEX aggregator with a keyless MCP server for AI agents", + "type": "streamable-http", + "url": "https://mcp.ophis.fi/mcp", + "link": "https://github.com/ophis-fi/ophis", + "installation_notes": "Streamable HTTP extension. No API key or environment variables required.", + "is_builtin": false, + "endorsed": false, + "environmentVariables": [] + }, { "id": "pdf_read", "name": "PDF Reader", @@ -852,14 +864,19 @@ "description": "Resolve, format, export, and verify academic citations (DOI, PMID, ISBN, arXiv) plus retraction and open-access checks", "command": "npx -y scholar-sidekick-mcp@latest", "link": "https://github.com/mlava/scholar-sidekick-mcp", - "installation_notes": "Requires a free RapidAPI key. Get one at https://rapidapi.com/scholar-sidekick-scholar-sidekick-api/api/scholar-sidekick and set RAPIDAPI_KEY.", + "installation_notes": "No API key required — works anonymously on a free, rate-limited tier. Optionally set SCHOLAR_API_KEY (a free first-party ssk_ key from https://scholar-sidekick.com/account) for higher limits, or RAPIDAPI_KEY for paid tiers via RapidAPI.", "is_builtin": false, "endorsed": false, "environmentVariables": [ + { + "name": "SCHOLAR_API_KEY", + "description": "Optional first-party API key (ssk_) from https://scholar-sidekick.com/account; raises rate limits. Sent as Authorization: Bearer.", + "required": false + }, { "name": "RAPIDAPI_KEY", - "description": "RapidAPI key for the Scholar Sidekick API (free tier available)", - "required": true + "description": "Optional RapidAPI key for paid/managed tiers; routes calls through the RapidAPI gateway.", + "required": false } ] } diff --git a/evals/harbor/.agents/skills/compare_tasks/SKILL.md b/evals/harbor/.agents/skills/compare_tasks/SKILL.md index 15e186d95dcb..49877caf775a 100644 --- a/evals/harbor/.agents/skills/compare_tasks/SKILL.md +++ b/evals/harbor/.agents/skills/compare_tasks/SKILL.md @@ -40,12 +40,23 @@ jq '{task_name, trial_name}' "$TRIAL_A_DIR/result.json" ### 2. Headline facts -Pull these fields from each trial's `result.json`. The actual shape (harbor -0.8 `TrialResult`): +The fastest path is to let `cmd.py task` do it for you — it already prints +status, reward, duration, tokens, turns, cost, error class, and the tail of +the verifier stdout: + +```bash +./evals/harbor/cmd.py task "$RUN_A" "$TASK" +./evals/harbor/cmd.py task "$RUN_B" "$TASK" +``` + +Only drop to raw `jq` against `result.json` if you need a field `cmd.py task` +doesn't print. The actual shape (harbor 0.8 `TrialResult`): ```bash jq '{ - reward: (.verifier_result.rewards.reward // null), + reward: (.verifier_result.rewards.reward + // (.verifier_result.rewards | to_entries | .[0].value) + // null), rewards_all: .verifier_result.rewards, duration_seconds: ((.finished_at | fromdateiso8601) - (.started_at | fromdateiso8601)), input_tokens: .agent_result.n_input_tokens, @@ -57,6 +68,10 @@ jq '{ }' "$TRIAL_A_DIR/result.json" ``` +The `reward` fallback mirrors `reporter.trial_reward`: if the verifier +didn't use the conventional `reward` key, take the first value in the +`rewards` map. + Derive status from those: - `pass` if `reward >= 1.0` @@ -72,31 +87,62 @@ timed out during teardown, or it timed out after writing the correct answer). If we got points, count them. See `reporter.trial_status` for the canonical rule. -Several `agent_result` fields are commonly `null` for older `GooseBinaryAgent` -runs (notably `n_cache_tokens`, `n_output_tokens`, `cost_usd`). Don't treat -that as a failure — just omit those facts from the comparison if missing on -either side. The reporter has fallbacks that read goose's `complete` event -from `agent/goose.txt`; you don't normally need to replicate them here. +Several `agent_result` fields can be `null` depending on the harness +(notably `n_cache_tokens`, `n_output_tokens`, `cost_usd` on some goose +runs). Don't treat that as a failure — just omit those facts from the +comparison if missing on either side. `cmd.py task` already applies +harbor's fallbacks (reading goose's `complete` event from `agent/goose.txt` +when the structured field is null), so its numbers are the right ones to +report. ### 3. Read the task spec The task definitions are NOT in the harbor Python package. They are plain -text files on disk, in harbor's dataset cache. Do not run `find /` or +text files on disk, in harbor's task cache. Do not run `find /` or `pip show harbor` — that is the wrong direction. -Find the task directory (works on Linux and macOS): +Harbor caches under `~/.cache/harbor/` on every platform (it uses +`Path("~/.cache/harbor").expanduser()` unconditionally — there is no +`~/Library/Caches/harbor` on macOS, despite what you might expect). + +The on-disk layout for package-backed tasks (the common case — everything +in `terminal-bench/terminal-bench-2` lands here) is: + +``` +~/.cache/harbor/tasks/packages//// +``` + +Note: no dataset name in the path. Tasks are keyed by org + task name + +content digest, not by which dataset pulled them. The `` segment +changes when the task is republished, so discover the dir rather than +hardcoding: ```bash -TASK_DIR=$( - ls -d ~/.cache/harbor/datasets/terminal-bench__terminal-bench-2__*/tasks/"$TASK"/ 2>/dev/null \ - || ls -d ~/Library/Caches/harbor/datasets/terminal-bench__terminal-bench-2__*/tasks/"$TASK"/ 2>/dev/null -) +TASK_DIR=$(ls -d ~/.cache/harbor/tasks/packages/terminal-bench/"$TASK"/*/ 2>/dev/null | head -1) echo "$TASK_DIR" ls "$TASK_DIR" ``` -If both lookups return empty, the dataset hasn't been downloaded yet — bail -out and report that, rather than guessing. +If that's empty, the task could be from a different org or a git source — +broaden the search. `find` returns the parent (one level above the +digest), so descend one more level. Guard against `$PARENT` being empty, +otherwise the glob expands to `/*/` and matches the filesystem root: + +```bash +PARENT=$(find ~/.cache/harbor/tasks -type d -name "$TASK" 2>/dev/null | head -1) +if [ -n "$PARENT" ]; then + TASK_DIR=$(ls -d "$PARENT"/*/ 2>/dev/null | head -1) +fi +``` + +If both lookups come up empty, the task hasn't been downloaded on this +machine — bail out and report that, rather than guessing. (Runs sync via +`cmd.py pull` but the task cache does not, so a machine that only inspects +results may never have the spec locally.) + +`~/.cache/harbor/datasets/` exists too but holds dataset-level metadata, +not the per-task `instruction.md` / `tests/` / `solution/` files — not +what you want here. Inside, you care about three files: @@ -115,11 +161,12 @@ Two sources, prefer the first when present: - `$TRIAL_DIR/agent/trajectory.json` — harbor's ATIF format, one entry per agent step. `jq '.steps[] | {step_id, source, message, tool_calls: [.tool_calls[]?.function_name]}'` - gives a compact view. Recent goose runs (after the populate_context_post_run - fix) have this; older `GooseBinaryAgent` runs may not. + gives a compact view. Most current runs have it; some older harness + versions may not. - `$TRIAL_DIR/agent/.txt` — raw stream-json or log. The filename - matches the harness: `goose.txt`, `pi.txt`, `opencode.txt`, - `claude-code.txt`. `ls "$TRIAL_DIR/agent/"` to find it. + matches the harness (commonly `goose.txt` or `pi.txt`; other harnesses + use their own name). Don't guess — run `ls "$TRIAL_DIR/agent/"` and use + whatever `.txt` file is there. Skim, don't quote in full. For each agent identify: @@ -170,10 +217,11 @@ Output markdown with these sections in order: ## Tools you'll need +- `./evals/harbor/cmd.py task ` for the headline numbers - `ls -d` to discover the `__` trial directories -- `jq` for `result.json` +- `jq` for any `result.json` field `cmd.py task` doesn't print - file reads against `$TRIAL_DIR/agent/` and `$TRIAL_DIR/verifier/` -- file reads against the dataset cache (`~/.cache/harbor/datasets/...`) +- `find ~/.cache/harbor/tasks` to locate the task spec No Python imports, no `harbor` package required. Everything you need is on disk as JSON / text files. diff --git a/evals/harbor/recipes/analyze_bench_failure.yaml b/evals/harbor/recipes/analyze_bench_failure.yaml new file mode 100644 index 000000000000..803ced0f9cc2 --- /dev/null +++ b/evals/harbor/recipes/analyze_bench_failure.yaml @@ -0,0 +1,188 @@ +version: 1.0.0 +title: analyze a single harbor benchmark failure +description: compare one task across two runs, theorize why the target failed, propose what could change +author: + contact: douwe@block.xyz + +parameters: + - key: target + input_type: string + requirement: required + description: "the run we want to improve (typically a goose run)" + - key: reference + input_type: string + requirement: required + description: "the run that succeeded on this task" + - key: task + input_type: string + requirement: required + description: "bare task name, e.g. extract-elf (not terminal-bench/extract-elf)" + +extensions: + - type: builtin + name: developer + display_name: Developer + timeout: 600 + bundled: true + description: Core tool for file operations, shell commands, and code analysis + +instructions: analyze why goose (the target run) failed a task that the reference run passed, and suggest what might change in goose to fix it + +prompt: | + you are analyzing a single harbor benchmark task where the reference run + succeeded and the target run (typically goose) failed. the goal is to + form a theory about *why* target failed and suggest what we could change + in goose to fix it. this is analysis, not implementation — no code + changes, no worktrees. + + target run (the one that failed): {{ target }} + reference run (the one that passed): {{ reference }} + task: {{ task }} + + this recipe assumes it is launched from the root of the goose repo + (the current working directory contains `evals/harbor/`). all paths + below are relative to that. + + ## step 1: headline facts + + cmd.py task prints status, reward, duration, tokens, turns, cost, error, + and a tail of the verifier output. start there for both runs: + + ``` + ./evals/harbor/cmd.py task {{ reference }} {{ task }} + ./evals/harbor/cmd.py task {{ target }} {{ task }} + ``` + + ## step 2: find the trial directories + + harbor 0.8 names trial dirs `__`. discover them + from disk — don't guess the suffix: + + ``` + TARGET_DIR=$(ls -d evals/harbor/runs/{{ target }}/{{ task }}__*/ 2>/dev/null | head -1) + REF_DIR=$(ls -d evals/harbor/runs/{{ reference }}/{{ task }}__*/ 2>/dev/null | head -1) + echo "target: $TARGET_DIR" + echo "ref: $REF_DIR" + ``` + + if either is empty the run didn't include this task — stop and report. + + ## step 3: read the task spec + + the task definition lives in harbor's task cache. package-backed tasks + (the common case, including all of terminal-bench-2) land under + `~/.cache/harbor/tasks/packages////`. the digest is + per task version and changes when the task is republished, so discover + the directory rather than guessing: + + ``` + TASK_DIR=$(ls -d ~/.cache/harbor/tasks/packages/terminal-bench/{{ task }}/*/ 2>/dev/null | head -1) + echo "$TASK_DIR" + ls "$TASK_DIR" + ``` + + if that's empty, fall back to a broader search in case the task came + from a git source or a different org. note that `find` returns the + parent (one level above the digest), so descend one more level. guard + against `$PARENT` being empty — otherwise the glob expands to `/*/` and + matches the filesystem root: + + ``` + PARENT=$(find ~/.cache/harbor/tasks -type d -name "{{ task }}" 2>/dev/null | head -1) + if [ -n "$PARENT" ]; then + TASK_DIR=$(ls -d "$PARENT"/*/ 2>/dev/null | head -1) + fi + ``` + + if both come up empty the task isn't cached locally — say so and continue + with what you can learn from the trial dirs alone (the verifier stdout + often reveals what was being checked). + + read these three when present: + + - `instruction.md` — what the agent was asked to do + - `tests/test_outputs.py` or `run-tests.sh` — what the verifier checks + - `solution/solution.sh` — the reference correct answer + + when describing a failure later, **quote the assertion that failed** + rather than paraphrasing — paraphrase is where wrong conclusions sneak in. + + ## step 4: read each agent's trajectory + + two sources per trial, prefer the first: + + - `$TRIAL_DIR/agent/trajectory.json` — harbor's ATIF format, one entry + per agent step. compact view: + `jq '.steps[] | {step_id, source, message, tool_calls: [.tool_calls[]?.function_name]}' "$TRIAL_DIR/agent/trajectory.json"` + - `$TRIAL_DIR/agent/.txt` — raw log. filename varies by harness + (commonly `goose.txt` or `pi.txt`). don't guess; run + `ls "$TRIAL_DIR/agent/"` and use whatever .txt is there. + + for each side identify: + + - the approach the agent took + - the final artifacts it left in the container (files created / modified) + - for the target (the failure), the failure mode — pick one: + - misread the spec (wrong assumption about input/output) + - right approach, shallow bug (off-by-one, wrong encoding, wrong path) + - ran out of clock — but note whether it was making real progress or + thrashing. a thrashing timeout is really a logic failure. + - diverged into an unproductive thread (debugging a non-issue) + - the verifier expected something the spec didn't telegraph + + ## step 5: read the verifier output + + `$TRIAL_DIR/verifier/test-stdout.txt` is usually the most diagnostic + file — it shows exactly which assertion failed and what the agent's + output looked like at that point. + + ``` + tail -80 "$TARGET_DIR/verifier/test-stdout.txt" + ``` + + ## step 6: look at goose source for a theory + + the target is (typically) goose. once you have a failure mode, dig into + the goose source (the current working directory) to see if there's + something there that could plausibly be improved. relevant places + depending on what you saw: + + - `crates/goose/src/agents/` — agent loop, tool-call handling, + context management + - `crates/goose/src/providers/` — provider-specific quirks (prompt + shape, streaming, tool-call format) + - `crates/goose-mcp/src/developer/` — the developer extension, where + most shell/file tools live + - `crates/goose/src/prompts/` and any system-prompt strings — what + we're telling the model about how to behave + - `crates/goose-cli/src/` — cli-side behavior (less likely to matter + for bench) + + use `rg` to search; don't grep the world. if the reference run used a + different harness (e.g. pi, opencode, claude-code), think about what + that harness does differently — sometimes it's just a prompt difference, + sometimes it's a tool-shape difference, sometimes it's a timeout or + retry policy. + + ## step 7: write up the analysis + + produce markdown with these sections: + + - **task** — one-line restatement of what the task wanted + - **outcome** — reference vs target headline (status, reward, duration, + turns) and which assertion the target failed on (quote it) + - **what reference did** — 2–4 sentences on the winning approach + - **what target did** — 2–4 sentences on the losing approach, with the + failure mode named + - **theory** — why target failed in mechanism terms, not vibes. "the + developer extension's text_editor truncates files >2MB and the task + output was 3MB" beats "goose got confused". + - **what we might change in goose** — concrete, but open-ended. could be + a prompt tweak, a tool behavior change, a default config, a new + capability, or "this is a one-off task quirk and not worth chasing". + cite the source files you looked at. it's fine to list more than one + candidate, and fine to say "not sure, would want to look at more + failures with this shape first". + + stop there. no code changes, no PRs, no issues filed. the user will + triage the suggestions across all the tabs once everything has run. diff --git a/evals/harbor/recipes/compare_bench_run.yaml b/evals/harbor/recipes/compare_bench_run.yaml new file mode 100644 index 000000000000..e8f4bb509e83 --- /dev/null +++ b/evals/harbor/recipes/compare_bench_run.yaml @@ -0,0 +1,98 @@ +version: 1.0.0 +title: compare harbor benchmark runs +description: find tasks where one run succeeded but another failed, and fan out per-task analysis +author: + contact: douwe@block.xyz + +parameters: + - key: target + input_type: string + requirement: required + description: "the run we want to improve (typically a goose run)" + - key: reference + input_type: string + requirement: required + description: "the run to learn from (the one that did better)" + +extensions: + - type: builtin + name: developer + display_name: Developer + timeout: 600 + bundled: true + description: Core tool for file operations, shell commands, and code analysis + +instructions: orchestrate per-task analysis of a benchmark regression + +prompt: | + you are comparing two harbor benchmark runs to find tasks where the + `reference` run passed but the `target` run did not, then fanning out + one agent per such task to analyze why. + + target run (the one we want to improve): {{ target }} + reference run (the one to learn from): {{ reference }} + + this recipe assumes it is launched from the root of the goose repo + (i.e. the current working directory contains `evals/harbor/`). all paths + below are relative to that. + + step 1: locate the runs. + + confirm both run directories exist: + + ``` + ls evals/harbor/runs/{{ target }}/ + ls evals/harbor/runs/{{ reference }}/ + ``` + + if either is missing, stop and tell the user. + + step 2: find the divergent tasks. + + use cmd.py compare with -v to get the per-task breakdown: + + ``` + ./evals/harbor/cmd.py compare {{ reference }} {{ target }} -v + ``` + + note the argument order: we pass `reference` as A and `target` as B, so + the "Only A solved" section is exactly the list we want — tasks the + reference passed and the target did not. + + do NOT filter out timeouts. a timeout often masks a real failure — the + agent kept going down a wrong path until the clock ran out. the per-task + analyzer will call out timeouts that are genuinely "right approach, ran + out of clock" versus ones that are really logic failures wearing a + timeout costume. + + step 3: show the user the list and ask for confirmation. + + print the list of tasks that will be analyzed, with the count, and ask + for explicit agreement before launching. mention that each task will get + its own iTerm tab running `analyze_bench_failure.yaml`. + + step 4: on agreement, launch one tab per task. + + for each task in the list, run (substituting `$PWD` and ``): + + ``` + REPO=$PWD + osascript <" + end tell + end tell + APPLESCRIPT + ``` + + the `cd $REPO` matters: new iTerm tabs open in the user's home directory + by default, but the analyzer recipe expects to run from the repo root. + + put a 1 second sleep between launches, otherwise keystrokes may go to + the wrong tab. use the bare task name (e.g. `extract-elf`), not the + qualified form (`terminal-bench/extract-elf`) — the analyzer recipe and + cmd.py both expect the bare name. + + after launching, print a one-line summary of how many tabs you opened. diff --git a/evals/open-model-gym/Justfile b/evals/open-model-gym/Justfile index 6b08e5169b61..09ccdf62d3bf 100644 --- a/evals/open-model-gym/Justfile +++ b/evals/open-model-gym/Justfile @@ -7,6 +7,14 @@ default: run run: _install cd suite && npm run test +# Full run with artifacts isolated under ~/.goose/gym-runs/ (keeps the repo clean) +run-clean: _install + #!/usr/bin/env bash + set -euo pipefail + export GYM_OUTPUT_DIR="$HOME/.goose/gym-runs/$(date +%Y%d%m%H%M%S)" + echo "Artifacts → $GYM_OUTPUT_DIR" + cd suite && npm run test + # Quick test - file-editing + everyday-app-automation, single run each (no repetition) test: _install cd suite && npx tsx src/runner.ts --scenario=file-editing,everyday-app-automation --run-count=1 @@ -19,9 +27,12 @@ scenario name: _install agent name: _install cd suite && npx tsx src/runner.ts --agent={{name}} -# Open report in browser +# Open report in browser (honors GYM_OUTPUT_DIR if set) report: - open report.html + #!/usr/bin/env bash + set -euo pipefail + dir="${GYM_OUTPUT_DIR:-.}" + open "${dir/#\~/$HOME}/report.html" # Install all dependencies install: diff --git a/evals/open-model-gym/README.md b/evals/open-model-gym/README.md index c2e6c11df50e..7aa8899a7f2b 100644 --- a/evals/open-model-gym/README.md +++ b/evals/open-model-gym/README.md @@ -267,6 +267,7 @@ Each tool returns realistic mock data. Tool calls are logged to `tool-calls.log` | Command | Description | |---------|-------------| | `just run` | Full test run (3 reps each, worst kept) | +| `just run-clean` | Full run with artifacts isolated under `~/.goose/gym-runs/` | | `just test` | Quick run (1 rep each) | | `just scenario ` | Run specific scenario | | `just agent ` | Run specific agent | @@ -283,9 +284,40 @@ npx tsx src/runner.ts --run-count=5 # Don't auto-open browser npx tsx src/runner.ts --no-open + +# Redirect all run artifacts outside the repo (see Output below) +npx tsx src/runner.ts --output-dir=~/.goose/gym-runs/latest + +# Raise the per-agent timeout (seconds) for slow local models on heavy +# scenarios. Default 300s; also settable via GYM_AGENT_TIMEOUT. +npx tsx src/runner.ts --agent-timeout=1200 ``` ## Output - `report.html` — Live-updating HTML matrix showing pass/fail status, duration, and validation details - `logs/` — Full agent output logs for each run + +By default these (plus the cache, scratch workdir, and isolated agent config +roots `.goose-root/` / `.opencode-root/` / `.pi-root/`) are written inside the +gym directory. They're gitignored, but still pile up in your checkout — awkward +if you want to run the bench regularly or from a worktree. + +To keep the repo clean, redirect **all** run artifacts to a single base +directory with the `GYM_OUTPUT_DIR` env var (or the `--output-dir=` flag). +`config.yaml` and `scenarios/` are still read from the repo. + +```bash +# Everything lands under a timestamped dir outside the repo (YYYYDDMMHHMMSS) +GYM_OUTPUT_DIR=~/.goose/gym-runs/$(date +%Y%d%m%H%M%S) just run + +# Convenience recipe that does the timestamping for you +just run-clean + +# View the report from a redirected run +GYM_OUTPUT_DIR=~/.goose/gym-runs/20261406101500 just report +``` + +> Note: the run cache lives under the output dir too, so a fresh timestamped +> dir means a fresh (cold) cache. Point `GYM_OUTPUT_DIR` at a stable directory +> if you want cache reuse across runs. diff --git a/evals/open-model-gym/mcp-harness/package-lock.json b/evals/open-model-gym/mcp-harness/package-lock.json index 845f45748a2d..b8dfaf41edf4 100644 --- a/evals/open-model-gym/mcp-harness/package-lock.json +++ b/evals/open-model-gym/mcp-harness/package-lock.json @@ -582,9 +582,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/evals/open-model-gym/suite/package-lock.json b/evals/open-model-gym/suite/package-lock.json index e26ebac39292..90443a050db8 100644 --- a/evals/open-model-gym/suite/package-lock.json +++ b/evals/open-model-gym/suite/package-lock.json @@ -13,14 +13,14 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "tsx": "^4.19.0", + "tsx": "^4.22.4", "typescript": "^5.5.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -35,9 +35,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -52,9 +52,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -69,9 +69,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -86,9 +86,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -103,9 +103,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -120,9 +120,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -154,9 +154,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -171,9 +171,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -188,9 +188,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -205,9 +205,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -222,9 +222,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -239,9 +239,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -256,9 +256,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -273,9 +273,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -290,9 +290,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -307,9 +307,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -324,9 +324,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -341,9 +341,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -358,9 +358,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -375,9 +375,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -392,9 +392,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -409,9 +409,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -426,9 +426,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -443,9 +443,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -576,9 +576,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -589,32 +589,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/foreground-child": { @@ -648,19 +648,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-tsconfig": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", - "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -778,16 +765,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -918,14 +895,13 @@ } }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" diff --git a/evals/open-model-gym/suite/package.json b/evals/open-model-gym/suite/package.json index a01fdbbecbbb..f6771dc4c448 100644 --- a/evals/open-model-gym/suite/package.json +++ b/evals/open-model-gym/suite/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "tsx": "^4.19.0", + "tsx": "^4.22.4", "typescript": "^5.5.0" } } diff --git a/evals/open-model-gym/suite/src/runner.ts b/evals/open-model-gym/suite/src/runner.ts index dc291fee8c3a..62ebff6f9b5d 100644 --- a/evals/open-model-gym/suite/src/runner.ts +++ b/evals/open-model-gym/suite/src/runner.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node import { mkdirSync, writeFileSync, rmSync, readdirSync, existsSync, copyFileSync } from "node:fs"; -import { join, basename, dirname } from "node:path"; +import { join, basename, dirname, resolve } from "node:path"; import { homedir } from "node:os"; -import { execSync } from "node:child_process"; +import { execSync, execFileSync } from "node:child_process"; import { parse, stringify } from "yaml"; import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; @@ -66,6 +66,7 @@ interface CacheInputs { runnerHash: string; binaryHash: string; mcpHarnessHash: string; + timeoutMs: number; } interface CacheEntry { @@ -87,11 +88,61 @@ interface CacheIndex { entries: Record; } +// ============================================================================= +// Output directory resolution +// ============================================================================= +// All run artifacts (cache, isolated agent config roots, scratch workdir, logs, +// and the HTML report) live under a single base directory. By default this is +// the in-repo gym directory, so existing behavior is unchanged. Set the +// GYM_OUTPUT_DIR env var or pass --output-dir= to redirect everything +// outside the repo and keep your checkout clean, e.g.: +// +// GYM_OUTPUT_DIR=~/.goose/gym-runs/$(date +%Y%d%m%H%M%S) just run +// +// config.yaml and scenarios/ are inputs and always read from the repo. + +const SUITE_DIR = join(import.meta.dirname, ".."); // .../open-model-gym/suite +const GYM_DIR = join(import.meta.dirname, "../.."); // .../open-model-gym + +function expandHome(p: string): string { + return p === "~" || p.startsWith("~/") ? join(homedir(), p.slice(1)) : p; +} + +// Resolved output base, or null to fall back to the legacy in-repo locations. +const OUTPUT_DIR: string | null = (() => { + const flag = process.argv + .find((a) => a.startsWith("--output-dir=")) + ?.split("=")[1]; + const base = flag ?? process.env.GYM_OUTPUT_DIR; + // Resolve to an absolute path: runners exec with cwd set to the workdir, so a + // relative base would make prompt/log paths resolve against the wrong dir. + return base ? resolve(expandHome(base)) : null; +})(); + +// Resolve an artifact path under OUTPUT_DIR when set, else its legacy anchor. +function artifactPath(name: string, legacyAnchor: string): string { + return join(OUTPUT_DIR ?? legacyAnchor, name); +} + +// Agent timeout +// ============================================================================= +// Per-invocation timeout for an agent run, in milliseconds. Larger local models +// can exceed the old fixed 5-minute cap on heavier scenarios and get killed +// mid-task (recorded as a failure rather than a timeout). Override the cap with +// GYM_AGENT_TIMEOUT (seconds) or --agent-timeout=; default 300s. +const AGENT_TIMEOUT_MS = (() => { + const flag = process.argv + .find((a) => a.startsWith("--agent-timeout=")) + ?.split("=")[1]; + const secs = parseInt(flag ?? process.env.GYM_AGENT_TIMEOUT ?? "", 10); + return (Number.isFinite(secs) && secs > 0 ? secs : 300) * 1000; +})(); + // ============================================================================= // Cache Utilities // ============================================================================= -const CACHE_DIR = join(import.meta.dirname, "../.cache"); +const CACHE_DIR = artifactPath(".cache", SUITE_DIR); const CACHE_INDEX_PATH = join(CACHE_DIR, "index.json"); const CACHE_LOGS_DIR = join(CACHE_DIR, "logs"); const CACHE_VERSION = 1; @@ -180,10 +231,15 @@ function computeCacheKey(pair: TestPair, binaryHashes: Map, mcpH runnerHash, binaryHash, mcpHarnessHash, + timeoutMs: AGENT_TIMEOUT_MS, }; - // Combine all into single key - const key = sha256(scenarioHash + modelKey + runnerHash + binaryHash + mcpHarnessHash); + // Combine all into single key. The timeout is included so a result cached + // under a short timeout (e.g. a 300s ETIMEDOUT) isn't reused when the run is + // retried with a larger GYM_AGENT_TIMEOUT. + const key = sha256( + scenarioHash + modelKey + runnerHash + binaryHash + mcpHarnessHash + AGENT_TIMEOUT_MS, + ); return { key, inputs }; } @@ -296,7 +352,7 @@ const PLATFORM_EXTENSIONS = new Set([ ]); // Isolated goose config directory -const GOOSE_ROOT = join(import.meta.dirname, "../.goose-root"); +const GOOSE_ROOT = artifactPath(".goose-root", SUITE_DIR); const GOOSE_CONFIG_DIR = join(GOOSE_ROOT, "config"); function generateGooseConfig(model: ModelConfig, runner: RunnerConfig): object { @@ -385,7 +441,7 @@ async function runGooseAgent( GOOSE_PATH_ROOT: GOOSE_ROOT, MCP_HARNESS_LOG: join(workdir, "tool-calls.log"), }, - timeout: 5 * 60 * 1000, + timeout: AGENT_TIMEOUT_MS, encoding: "utf-8", }); @@ -397,7 +453,7 @@ async function runGooseAgent( // ============================================================================= // Isolated opencode config directory -const OPENCODE_ROOT = join(import.meta.dirname, "../.opencode-root"); +const OPENCODE_ROOT = artifactPath(".opencode-root", SUITE_DIR); function generateOpenCodeConfig(model: ModelConfig, runner: RunnerConfig, workdir: string): object { const mcp: Record = {}; @@ -479,7 +535,7 @@ async function runOpenCodeAgent( XDG_CONFIG_HOME: OPENCODE_ROOT, XDG_DATA_HOME: OPENCODE_ROOT, }, - timeout: 5 * 60 * 1000, + timeout: AGENT_TIMEOUT_MS, encoding: "utf-8", shell: "/bin/bash", }); @@ -496,7 +552,7 @@ async function runOpenCodeAgent( // MCP support via pi-mcp-adapter: `pi install npm:pi-mcp-adapter` // Isolated Pi config directory (like Goose/OpenCode) -const PI_CONFIG_DIR = join(import.meta.dirname, "../.pi-root"); +const PI_CONFIG_DIR = artifactPath(".pi-root", SUITE_DIR); // User's real Pi config (for copying auth.json) const PI_USER_CONFIG = join(homedir(), ".pi", "agent"); @@ -644,7 +700,7 @@ async function runPiAgent( PI_CODING_AGENT_DIR: PI_CONFIG_DIR, // Use isolated config dir MCP_HARNESS_LOG: join(workdir, "tool-calls.log"), }, - timeout: 5 * 60 * 1000, + timeout: AGENT_TIMEOUT_MS, encoding: "utf-8", shell: "/bin/bash", }); @@ -979,11 +1035,15 @@ function generateHtmlReport( ): void { const { isRunning = false, allPairs = [] } = options; - // Read and embed gym.png as base64 - const rootDir = join(outputPath, ".."); + // Read and embed gym.png as base64. Prefer one sitting next to the report + // (legacy in-repo layout); otherwise fall back to the copy in the source tree + // so the image still embeds when output is redirected via GYM_OUTPUT_DIR. let gymBase64 = ""; try { - const gymPath = join(rootDir, "gym.png"); + const adjacent = join(outputPath, "..", "gym.png"); + const gymPath = existsSync(adjacent) + ? adjacent + : join(import.meta.dirname, "gym.png"); gymBase64 = readFileSync(gymPath).toString("base64"); } catch (e) { // gym.png not found, will use external reference @@ -1323,6 +1383,7 @@ function generateHtmlReport( `; + mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, html); console.log(`\n📊 Report saved to: ${outputPath}`); } @@ -1361,12 +1422,11 @@ async function main() { return; } - const rootDir = join(import.meta.dirname, "../.."); - const configPath = join(rootDir, "config.yaml"); + const configPath = join(GYM_DIR, "config.yaml"); const scenariosDir = join(import.meta.dirname, "../scenarios"); - const workdir = join(import.meta.dirname, "../.workdir"); - const logsDir = join(rootDir, "logs"); - const reportPath = join(rootDir, "report.html"); + const workdir = artifactPath(".workdir", SUITE_DIR); + const logsDir = artifactPath("logs", GYM_DIR); + const reportPath = artifactPath("report.html", GYM_DIR); const config = loadConfig(configPath); let scenarios = loadAllScenarios(scenariosDir); @@ -1423,6 +1483,7 @@ async function main() { } const mcpHarnessHash = getMcpHarnessHash(); + console.log(`Output: ${OUTPUT_DIR ?? GYM_DIR}${OUTPUT_DIR ? "" : " (in-repo; set GYM_OUTPUT_DIR to redirect)"}`); console.log(`Models: ${config.models.map((m) => m.name).join(", ")}`); console.log(`Runners: ${config.runners.map((r) => r.name).join(", ")}`); console.log(`Running ${pairs.length} test pairs (${RUN_COUNT}x each, worst result kept)`); @@ -1455,7 +1516,7 @@ async function main() { if (!browserOpened) { generateHtmlReport(results, reportPath, { isRunning: true, allPairs: pairs }); if (!noOpen) { - execSync(`open "${reportPath}"`); + execFileSync("open", [reportPath]); } browserOpened = true; } @@ -1490,10 +1551,10 @@ async function main() { } generateHtmlReport(results, reportPath, { isRunning: false, allPairs: pairs }); - + // If everything was cached, open browser now with final report if (!browserOpened && !noOpen) { - execSync(`open "${reportPath}"`); + execFileSync("open", [reportPath]); } printResults(results); diff --git a/scripts/provider-error-proxy/pyproject.toml b/scripts/provider-error-proxy/pyproject.toml index efceb04c8bf3..51c36c01bf7c 100644 --- a/scripts/provider-error-proxy/pyproject.toml +++ b/scripts/provider-error-proxy/pyproject.toml @@ -4,6 +4,6 @@ version = "0.1.0" description = "HTTP proxy for simulating provider errors in Goose" requires-python = ">=3.11" dependencies = [ - "aiohttp>=3.9.0", + "aiohttp>=3.14.0", "brotli>=1.1.0", ] diff --git a/scripts/provider-error-proxy/uv.lock b/scripts/provider-error-proxy/uv.lock index 362b60dec416..f72b9a46d8d1 100644 --- a/scripts/provider-error-proxy/uv.lock +++ b/scripts/provider-error-proxy/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.4" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -22,95 +22,111 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, - { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, - { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, - { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, - { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, - { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, - { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, - { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, - { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, - { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, - { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, - { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, - { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, - { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, - { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, - { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, - { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, - { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, - { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, - { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, - { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, - { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, - { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, - { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, - { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, - { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, - { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, - { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, - { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, - { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, - { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, - { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, - { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, - { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, - { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, - { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, - { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, - { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, - { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, - { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, - { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, - { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, + { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" }, + { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" }, + { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" }, + { url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" }, + { url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" }, + { url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" }, + { url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" }, + { url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" }, + { url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" }, + { url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" }, + { url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" }, + { url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" }, + { url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" }, + { url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" }, + { url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" }, + { url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" }, + { url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" }, + { url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" }, + { url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" }, ] [[package]] @@ -498,7 +514,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "aiohttp", specifier = ">=3.14.0" }, { name = "brotli", specifier = ">=1.1.0" }, ] diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 87968e311688..5e6aa8cacde3 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -68,6 +68,7 @@ module.exports = [ CustomEvent: 'readonly', HTMLElement: 'readonly', HTMLInputElement: 'readonly', + HTMLSelectElement: 'readonly', HTMLTextAreaElement: 'readonly', HTMLButtonElement: 'readonly', HTMLDivElement: 'readonly', diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index b0c2b69c1838..7f0db8ae4db4 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -114,7 +114,6 @@ module.exports = { icon: 'src/images/icon.png', prefix: '/opt', ...(isLinuxVulkanBuild ? { requires: ['vulkan-loader'] } : {}), - fpm: ['--rpm-rpmbuild-define', '_build_id_links none'], }, }, }, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index bf8bb14a95d5..93466b85c765 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.37.0" + "version": "1.38.0" }, "paths": { "/action-required/tool-confirmation": { @@ -3918,6 +3918,9 @@ "actionType" ], "properties": { + "action": { + "type": "string" + }, "actionType": { "type": "string", "enum": [ @@ -4754,9 +4757,19 @@ "spec" ], "properties": { + "backend_id": { + "type": "string", + "description": "Optional backend id for callers selecting a concrete variant row.", + "nullable": true + }, "spec": { "type": "string", - "description": "Model spec like \"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M\"" + "description": "Model spec/download id like \"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M\" or \"google/gemma-4-31B-it\"" + }, + "variant_id": { + "type": "string", + "description": "Optional backend-specific variant id, such as a GGUF quantization or MLX dtype.", + "nullable": true } } }, @@ -4978,6 +4991,10 @@ "cmd": { "type": "string" }, + "cwd": { + "type": "string", + "nullable": true + }, "description": { "type": "string" }, @@ -5508,6 +5525,77 @@ }, "repo_id": { "type": "string" + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HfModelVariant" + } + } + } + }, + "HfModelVariant": { + "type": "object", + "required": [ + "variant_id", + "label", + "backend_id", + "format", + "model_id", + "download_id", + "size_bytes", + "description", + "quality_rank" + ], + "properties": { + "backend_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "download_id": { + "type": "string" + }, + "download_url": { + "type": "string", + "nullable": true + }, + "filename": { + "type": "string", + "nullable": true + }, + "format": { + "type": "string" + }, + "label": { + "type": "string" + }, + "model_id": { + "type": "string" + }, + "quality_rank": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "sharded": { + "type": "boolean" + }, + "size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "supported": { + "type": "boolean" + }, + "unsupported_reason": { + "type": "string", + "nullable": true + }, + "variant_id": { + "type": "string" } } }, @@ -6583,6 +6671,11 @@ "ModelSettings": { "type": "object", "properties": { + "backend_id": { + "type": "string", + "description": "Backend implementation to use for this model. Defaults to llama.cpp.", + "nullable": true + }, "chat_template": { "$ref": "#/components/schemas/ChatTemplate" }, @@ -6592,6 +6685,10 @@ "nullable": true, "minimum": 0 }, + "draft_model": { + "type": "string", + "nullable": true + }, "enable_thinking": { "type": "boolean" }, @@ -7459,7 +7556,8 @@ "required": [ "variants", "available_memory_bytes", - "downloaded_quants" + "downloaded_quants", + "downloaded_variants" ], "properties": { "available_memory_bytes": { @@ -7473,6 +7571,12 @@ "type": "string" } }, + "downloaded_variants": { + "type": "array", + "items": { + "type": "string" + } + }, "recommended_index": { "type": "integer", "nullable": true, @@ -7481,7 +7585,7 @@ "variants": { "type": "array", "items": { - "$ref": "#/components/schemas/HfQuantVariant" + "$ref": "#/components/schemas/HfModelVariant" } } } @@ -7979,6 +8083,10 @@ "format": "int32", "nullable": true }, + "last_message_snippet": { + "type": "string", + "nullable": true + }, "message_count": { "type": "integer", "minimum": 0 diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 34f6f21f34da..b3f46e42a462 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -1,7 +1,7 @@ { "name": "goose-app", "productName": "ApeMind Agent", - "version": "1.37.0", + "version": "1.38.0", "description": "ApeMind Agent Desktop", "engines": { "node": "^24.10.0", @@ -44,6 +44,7 @@ "i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --ignore '**/*.d.ts' --out-file src/i18n/messages/en.json --flatten && pnpm run i18n:compile", "i18n:check": "node scripts/i18n-check.js && node scripts/i18n-validate-locale.js", "i18n:compile": "node scripts/i18n-compile.js", + "i18n:validate-es": "node scripts/i18n-validate-locale.js es", "i18n:validate-ja": "node scripts/i18n-validate-locale.js ja", "i18n:validate-locale": "node scripts/i18n-validate-locale.js" }, diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index 1e150509cdc3..501e34fd9449 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -274,6 +274,27 @@ describe('App Component - Brand New State', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + it('should navigate home when the main process emits new-chat', async () => { + mockElectron.getConfig.mockReturnValue({ + GOOSE_DEFAULT_PROVIDER: 'openai', + GOOSE_DEFAULT_MODEL: 'gpt-4', + GOOSE_ALLOWLIST_WARNING: false, + }); + + render(, { wrapper: IntlTestWrapper }); + + await waitFor(() => { + expect(mockElectron.reactReady).toHaveBeenCalled(); + }); + + const newChatHandler = mockElectron.on.mock.calls.find(([channel]) => channel === 'new-chat')?.[1]; + expect(newChatHandler).toBeDefined(); + + newChatHandler?.({} as any); + + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + it('should seed recipe sessions with the recipe prompt when no initial message is provided', () => { expect( resolveSessionInitialMessage( diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 098ac5aaf2bb..f22db8e4274f 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -26,6 +26,7 @@ import { UserInput } from './types/message'; interface PairRouteState { resumeSessionId?: string; initialMessage?: UserInput; + noAutoSubmit?: boolean; } import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; @@ -84,8 +85,11 @@ const PairRouteWrapper = ({ activeSessions: Array<{ sessionId: string; initialMessage?: UserInput; + noAutoSubmit?: boolean; }>; - setActiveSessions: (sessions: Array<{ sessionId: string; initialMessage?: UserInput }>) => void; + setActiveSessions: ( + sessions: Array<{ sessionId: string; initialMessage?: UserInput; noAutoSubmit?: boolean }> + ) => void; }) => { const { extensionsList } = useConfig(); const location = useLocation(); @@ -98,6 +102,7 @@ const PairRouteWrapper = ({ const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; const recipeIdFromConfig = window.appConfig?.get('recipeId') as string | undefined; const initialMessage = routeState.initialMessage; + const noAutoSubmit = routeState.noAutoSubmit; // Create session if we have an initialMessage, recipeDeeplink, or recipeId but no sessionId useEffect(() => { @@ -122,6 +127,7 @@ const PairRouteWrapper = ({ detail: { sessionId: newSession.id, initialMessage: sessionInitialMessage, + noAutoSubmit, }, }) ); @@ -162,11 +168,12 @@ const PairRouteWrapper = ({ detail: { sessionId: resumeSessionId, initialMessage: initialMessage, + noAutoSubmit, }, }) ); } - }, [resumeSessionId, activeSessions, initialMessage]); + }, [resumeSessionId, activeSessions, initialMessage, noAutoSubmit]); return null; }; @@ -358,15 +365,16 @@ export function AppInner() { const MAX_ACTIVE_SESSIONS = 10; const [activeSessions, setActiveSessions] = useState< - Array<{ sessionId: string; initialMessage?: UserInput }> + Array<{ sessionId: string; initialMessage?: UserInput; noAutoSubmit?: boolean }> >([]); useEffect(() => { const handleAddActiveSession = (event: Event) => { - const { sessionId, initialMessage } = ( + const { sessionId, initialMessage, noAutoSubmit } = ( event as CustomEvent<{ sessionId: string; initialMessage?: UserInput; + noAutoSubmit?: boolean; }> ).detail; @@ -380,7 +388,7 @@ export function AppInner() { } // New session - add to end with LRU eviction if needed - const newSession = { sessionId, initialMessage }; + const newSession = { sessionId, initialMessage, noAutoSubmit }; const updated = [...prev, newSession]; if (updated.length > MAX_ACTIVE_SESSIONS) { return updated.slice(updated.length - MAX_ACTIVE_SESSIONS); @@ -402,11 +410,21 @@ export function AppInner() { }); }; + const handleSessionDeleted = (event: Event) => { + const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail; + + setActiveSessions((prev) => { + return prev.filter((session) => session.sessionId !== sessionId); + }); + }; + window.addEventListener(AppEvents.ADD_ACTIVE_SESSION, handleAddActiveSession); window.addEventListener(AppEvents.CLEAR_INITIAL_MESSAGE, handleClearInitialMessage); + window.addEventListener(AppEvents.SESSION_DELETED, handleSessionDeleted); return () => { window.removeEventListener(AppEvents.ADD_ACTIVE_SESSION, handleAddActiveSession); window.removeEventListener(AppEvents.CLEAR_INITIAL_MESSAGE, handleClearInitialMessage); + window.removeEventListener(AppEvents.SESSION_DELETED, handleSessionDeleted); }; }, []); @@ -575,12 +593,12 @@ export function AppInner() { useEffect(() => { const handleNewChat = (_event: IpcRendererEvent, ..._args: unknown[]) => { - window.dispatchEvent(new CustomEvent(AppEvents.TRIGGER_NEW_CHAT)); + navigate('/'); }; window.electron.on('new-chat', handleNewChat); return () => window.electron.off('new-chat', handleNewChat); - }, []); + }, [navigate]); useEffect(() => { const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { @@ -601,12 +619,14 @@ export function AppInner() { useEffect(() => { const handleSetInitialMessage = async (_event: IpcRendererEvent, ...args: unknown[]) => { const initialMessage = args[0] as string; + const options = (args[1] as { noAutoSubmit?: boolean } | undefined) || {}; if (initialMessage && !isProcessingRef.current) { isProcessingRef.current = true; navigate('/pair', { state: { initialMessage: { msg: initialMessage, images: [] }, + noAutoSubmit: options.noAutoSubmit, }, }); setTimeout(() => { diff --git a/ui/desktop/src/__tests__/sessions.test.ts b/ui/desktop/src/__tests__/sessions.test.ts index 97e7daca254e..e865e2019065 100644 --- a/ui/desktop/src/__tests__/sessions.test.ts +++ b/ui/desktop/src/__tests__/sessions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { shouldShowNewChatTitle } from '../sessions'; -import { getSessionDisplayName, prependUnique } from '../hooks/useNavigationSessions'; +import { getSessionDisplayName, shouldShowNewChatTitle } from '../sessions'; +import { prependUnique } from '../hooks/useNavigationSessions'; import type { Session } from '../api'; import type { SessionListItem } from '../acp/sessions'; diff --git a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts new file mode 100644 index 000000000000..3575ffa6c72a --- /dev/null +++ b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts @@ -0,0 +1,134 @@ +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '../../api'; +import { AppEvents } from '../../constants/events'; +import { ChatState } from '../../types/chatState'; +import { handleAcpSessionNotification } from '../chatNotifications'; +import type { AcpChatSessionSnapshot } from '../chatSessionStore'; +import { acpChatSessionStore } from '../chatSessionStore'; + +vi.mock('../../acpChatFeatureFlag', () => ({ + USE_ACP_CHAT: true, +})); + +vi.mock('../chatSessionStore', () => ({ + acpChatSessionStore: { + getSnapshot: vi.fn(), + applyAcpSessionNotification: vi.fn(), + }, +})); + +const SESSION_ID = 'session-1'; + +function sessionInfoUpdate(title: string): SessionNotification { + return { + sessionId: SESSION_ID, + update: { + sessionUpdate: 'session_info_update', + title, + }, + }; +} + +function sessionWithName(name: string): Session { + return { + id: SESSION_ID, + name, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + working_dir: '/tmp', + message_count: 0, + extension_data: {}, + source: 'test', + } as Session; +} + +function snapshotWithName(name: string): AcpChatSessionSnapshot { + return { + session: sessionWithName(name), + messages: [], + tokenState: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + }, + notifications: [], + chatState: ChatState.Idle, + sessionLoadError: undefined, + activePromptAttemptId: null, + }; +} + +function snapshotWithoutSession(): AcpChatSessionSnapshot { + return { + session: undefined, + messages: [], + tokenState: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + }, + notifications: [], + chatState: ChatState.Idle, + sessionLoadError: undefined, + activePromptAttemptId: null, + }; +} + +describe('handleAcpSessionNotification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('dispatches SESSION_RENAMED when a session info notification changes the name', async () => { + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Old name')); + vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + snapshotWithName('New name') + ); + + await handleAcpSessionNotification(sessionInfoUpdate('New name')); + + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: AppEvents.SESSION_RENAMED, + detail: { sessionId: SESSION_ID, newName: 'New name' }, + }) + ); + }); + + it('does not dispatch SESSION_RENAMED when the name is unchanged', async () => { + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Same name')); + vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + snapshotWithName('Same name') + ); + + await handleAcpSessionNotification(sessionInfoUpdate('Same name')); + + expect(dispatchEvent).not.toHaveBeenCalled(); + }); + + it('dispatches SESSION_RENAMED from the notification title when the session is not loaded', async () => { + const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithoutSession()); + vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + snapshotWithoutSession() + ); + + await handleAcpSessionNotification(sessionInfoUpdate('Generated name')); + + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: AppEvents.SESSION_RENAMED, + detail: { sessionId: SESSION_ID, newName: 'Generated name' }, + }) + ); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts new file mode 100644 index 000000000000..ef0f923420e1 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -0,0 +1,292 @@ +import type { + CreateElicitationRequest, + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Message, Session } from '../../api'; +import { ChatState } from '../../types/chatState'; +import { createAcpChatSessionStore, type AcpChatSessionStore } from '../chatSessionStore'; + +function message(id: string, text: string): Message { + return { + id, + role: 'user', + created: 123, + content: [{ type: 'text', text }], + metadata: { userVisible: true, agentVisible: true }, + }; +} + +function session(id: string, conversation: Message[] = []): Session { + return { + id, + name: `Session ${id}`, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + working_dir: '/tmp', + message_count: conversation.length, + extension_data: {}, + source: 'test', + conversation, + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + accumulated_input_tokens: 4, + accumulated_output_tokens: 5, + accumulated_total_tokens: 9, + } as Session; +} + +function permissionRequest(sessionId: string, toolCallId = 'tool-1'): RequestPermissionRequest { + return { + sessionId, + options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }], + toolCall: { + toolCallId, + title: 'Edit file', + rawInput: { path: 'README.md' }, + content: [ + { + type: 'content', + content: { type: 'text', text: 'Allow editing README.md?' }, + }, + ], + _meta: { + goose: { + toolCall: { + toolName: 'edit_file', + }, + }, + }, + }, + }; +} + +function elicitationRequest(sessionId: string): { + id: string; + sessionId: string; + request: CreateElicitationRequest & { + mode: 'form'; + sessionId: string; + }; +} { + return { + id: 'acp_elicitation_1', + sessionId, + request: { + mode: 'form', + sessionId, + message: 'Choose a project', + requestedSchema: { + type: 'object', + properties: { + project: { + type: 'string', + }, + }, + }, + }, + }; +} + +function toolProgressNotification(sessionId: string): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + _meta: { + toolNotification: { + type: 'progress', + params: { + progressToken: 'scan-repo', + progress: 3, + }, + }, + }, + }, + }; +} + +describe('acpChatSessionStore', () => { + let store: AcpChatSessionStore; + + beforeEach(() => { + store = createAcpChatSessionStore(); + }); + + it('stores loaded session messages and token state', () => { + const initialMessage = message('message-1', 'Hello'); + + const snapshot = store.setLoadedSession('session-1', session('session-1', [initialMessage])); + + expect(snapshot.session?.id).toBe('session-1'); + expect(snapshot.messages).toEqual([initialMessage]); + expect(snapshot.tokenState).toMatchObject({ + inputTokens: 1, + outputTokens: 2, + totalTokens: 3, + accumulatedInputTokens: 4, + accumulatedOutputTokens: 5, + accumulatedTotalTokens: 9, + }); + expect(snapshot.chatState).toBe(ChatState.Idle); + expect(snapshot.sessionLoadError).toBeUndefined(); + }); + + it('keeps multiple session snapshots isolated', () => { + store.setMessages('session-1', [message('message-1', 'One')]); + store.setMessages('session-2', [message('message-2', 'Two')]); + + expect(store.getSnapshot('session-1')?.messages[0].id).toBe('message-1'); + expect(store.getSnapshot('session-2')?.messages[0].id).toBe('message-2'); + }); + + it('deletes session snapshots', () => { + store.setMessages('session-1', [message('message-1', 'One')]); + + store.deleteSnapshot('session-1'); + + expect(store.getSnapshot('session-1')).toBeUndefined(); + }); + + it('notifies only listeners for the updated session', () => { + const sessionOneListener = vi.fn(); + const sessionTwoListener = vi.fn(); + + store.subscribe('session-1', sessionOneListener); + store.subscribe('session-2', sessionTwoListener); + + store.setChatState('session-1', ChatState.Streaming); + + expect(sessionOneListener).toHaveBeenCalledTimes(1); + expect(sessionOneListener).toHaveBeenCalledWith( + expect.objectContaining({ chatState: ChatState.Streaming }) + ); + expect(sessionTwoListener).not.toHaveBeenCalled(); + }); + + it('stops notifying after unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = store.subscribe('session-1', listener); + + unsubscribe(); + store.setChatState('session-1', ChatState.Streaming); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('ignores stale prompt attempts and leaves the current attempt active', () => { + store.startPromptAttempt('session-1', 'attempt-a'); + store.startPromptAttempt('session-1', 'attempt-b'); + + expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-a', 'late error')).toBe(false); + + expect(store.getSnapshot('session-1')).toMatchObject({ + activePromptAttemptId: 'attempt-b', + chatState: ChatState.Streaming, + sessionLoadError: undefined, + }); + + expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-b')).toBe(true); + expect(store.getSnapshot('session-1')).toMatchObject({ + activePromptAttemptId: null, + chatState: ChatState.Idle, + }); + }); + + it('keeps loaded sessions streaming when a prompt attempt is active', () => { + store.startPromptAttempt('session-1', 'attempt-1'); + + const snapshot = store.setLoadedSession('session-1', session('session-1')); + + expect(snapshot.activePromptAttemptId).toBe('attempt-1'); + expect(snapshot.chatState).toBe(ChatState.Streaming); + }); + + it('stores ACP tool notifications and clears them for a new prompt attempt', () => { + const snapshot = store.applyAcpSessionNotification(toolProgressNotification('session-1')); + + expect(snapshot.notifications).toHaveLength(1); + expect(snapshot.notifications[0]).toMatchObject({ + type: 'Notification', + request_id: 'tool-1', + message: { + method: 'notifications/progress', + params: { + progressToken: 'scan-repo', + progress: 3, + }, + }, + }); + + const nextSnapshot = store.startPromptAttempt('session-1', 'attempt-1'); + + expect(nextSnapshot.notifications).toEqual([]); + }); + + it('applies permission requests as waiting action-required messages', () => { + const snapshot = store.applyPermissionRequest(permissionRequest('session-1', 'tool-1')); + + expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput); + expect(snapshot.messages).toHaveLength(1); + expect(snapshot.messages[0].role).toBe('assistant'); + expect(snapshot.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'toolConfirmation', + id: 'tool-1', + }, + }); + }); + + it('applies elicitation requests as waiting action-required messages', () => { + const snapshot = store.applyElicitationRequest(elicitationRequest('session-1')); + + expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput); + expect(snapshot.messages).toHaveLength(1); + expect(snapshot.messages[0].role).toBe('assistant'); + expect(snapshot.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'acp_elicitation_1', + message: 'Choose a project', + }, + }); + }); + + it('stores submitted elicitation status', () => { + store.applyElicitationRequest(elicitationRequest('session-1')); + + const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'submitted'); + + expect(snapshot?.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'acp_elicitation_1', + isSubmitted: true, + isCancelled: false, + }, + }); + }); + + it('stores cancelled elicitation status', () => { + store.applyElicitationRequest(elicitationRequest('session-1')); + + const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'cancelled'); + + expect(snapshot?.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'acp_elicitation_1', + isSubmitted: false, + isCancelled: true, + }, + }); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts new file mode 100644 index 000000000000..64e1787794a7 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts @@ -0,0 +1,161 @@ +import type { CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ACP_ELICITATION_TIMEOUT_SECONDS, + cancelAcpElicitationRequestsForSession, + requestAcpElicitation, + resolveAcpElicitationRequest, +} from '../elicitationRequests'; +import { acpChatSessionStore } from '../chatSessionStore'; + +vi.mock('../../acpChatFeatureFlag', () => ({ + USE_ACP_CHAT: true, +})); + +vi.mock('../chatSessionStore', () => ({ + acpChatSessionStore: { + applyElicitationRequest: vi.fn(), + setElicitationStatus: vi.fn(), + }, +})); + +const TEST_SESSION_IDS = ['session-1', 'session-2']; + +function formRequest(sessionId: string): CreateElicitationRequest { + return { + mode: 'form', + sessionId, + message: 'Choose a project', + requestedSchema: { + type: 'object', + properties: { + project: { + type: 'string', + }, + }, + required: ['project'], + }, + }; +} + +async function expectStillPending(promise: Promise): Promise { + let settled = false; + promise.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + + await Promise.resolve(); + + expect(settled).toBe(false); +} + +describe('ACP elicitation requests', () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpElicitationRequestsForSession(sessionId); + } + }); + + afterEach(() => { + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpElicitationRequestsForSession(sessionId); + } + }); + + it('keeps form requests pending until explicit resolve', async () => { + const response = requestAcpElicitation(formRequest('session-1')); + + await expectStillPending(response); + + const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock.calls[0][0]; + + expect(appliedRequest.id).toMatch(/^acp_elicitation_/); + expect(appliedRequest.sessionId).toBe('session-1'); + expect(appliedRequest.request.message).toBe('Choose a project'); + + expect( + resolveAcpElicitationRequest('session-1', appliedRequest.id, { + project: 'goose', + }) + ).toBe(true); + expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + 'session-1', + appliedRequest.id, + 'submitted' + ); + + await expect(response).resolves.toEqual({ + action: 'accept', + content: { + project: 'goose', + }, + }); + }); + + it('cancels unsupported requests', async () => { + await expect( + requestAcpElicitation({ + mode: 'url', + requestId: 'request-1', + elicitationId: 'elicitation-1', + message: 'Open this page', + url: 'https://example.com', + }) + ).resolves.toEqual({ action: 'cancel' }); + }); + + it('cancels only pending requests for the requested session', async () => { + const sessionOneResponse = requestAcpElicitation(formRequest('session-1')); + const sessionTwoResponse = requestAcpElicitation(formRequest('session-2')); + + const applyElicitationRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest); + const sessionOneRequest = applyElicitationRequest.mock.calls[0][0]; + const sessionTwoRequest = applyElicitationRequest.mock.calls[1][0]; + + cancelAcpElicitationRequestsForSession('session-1'); + + expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + 'session-1', + sessionOneRequest.id, + 'cancelled' + ); + await expect(sessionOneResponse).resolves.toEqual({ action: 'cancel' }); + await expectStillPending(sessionTwoResponse); + + expect(resolveAcpElicitationRequest('session-2', sessionTwoRequest.id, {})).toBe(true); + await expect(sessionTwoResponse).resolves.toEqual({ + action: 'accept', + content: {}, + }); + expect(resolveAcpElicitationRequest('session-1', sessionOneRequest.id, {})).toBe(false); + }); + + it('cancels pending requests when they expire', async () => { + vi.useFakeTimers(); + try { + const response = requestAcpElicitation(formRequest('session-1')); + const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock + .calls[0][0]; + + await expectStillPending(response); + + await vi.advanceTimersByTimeAsync(ACP_ELICITATION_TIMEOUT_SECONDS * 1000); + + expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + 'session-1', + appliedRequest.id, + 'cancelled' + ); + await expect(response).resolves.toEqual({ action: 'cancel' }); + expect(resolveAcpElicitationRequest('session-1', appliedRequest.id, {})).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/ui/desktop/src/acp/__tests__/errors.test.ts b/ui/desktop/src/acp/__tests__/errors.test.ts new file mode 100644 index 000000000000..31c9b3b42fb3 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/errors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { parseAcpCreditsExhaustedError } from '../errors'; + +describe('parseAcpCreditsExhaustedError', () => { + it('parses structured ACP credits exhausted errors', () => { + expect( + parseAcpCreditsExhaustedError({ + code: -32603, + message: 'Please add credits to your account, then resend your message to continue.', + data: { + reason: 'credits_exhausted', + url: 'https://router.tetrate.ai/billing', + }, + }) + ).toEqual({ + message: 'Please add credits to your account, then resend your message to continue.', + url: 'https://router.tetrate.ai/billing', + }); + }); + + it('parses wrapped JSON-RPC errors', () => { + expect( + parseAcpCreditsExhaustedError({ + error: { + code: -32603, + message: 'Add credits to continue.', + data: { + reason: 'credits_exhausted', + }, + }, + }) + ).toEqual({ + message: 'Add credits to continue.', + }); + }); + + it('ignores non-credits-exhausted errors', () => { + expect( + parseAcpCreditsExhaustedError({ + code: -32603, + message: 'Something failed.', + data: { + reason: 'provider_error', + }, + }) + ).toBeNull(); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/permissionRequests.test.ts b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts new file mode 100644 index 000000000000..b2a4856dbefb --- /dev/null +++ b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts @@ -0,0 +1,126 @@ +import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + cancelAcpPermissionRequestsForSession, + requestAcpPermission, + resolveAcpPermissionRequest, +} from '../permissionRequests'; + +vi.mock('../../acpChatFeatureFlag', () => ({ + USE_ACP_CHAT: true, +})); + +vi.mock('../chatSessionStore', () => ({ + acpChatSessionStore: { + applyPermissionRequest: vi.fn(), + }, +})); + +function permissionRequest(sessionId: string, toolCallId: string): RequestPermissionRequest { + return { + sessionId, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Deny once', kind: 'reject_once' }, + ], + toolCall: { + toolCallId, + title: 'Read file', + rawInput: { path: 'README.md' }, + content: [ + { + type: 'content', + content: { type: 'text', text: 'Allow reading README.md?' }, + }, + ], + }, + }; +} + +const TEST_SESSION_IDS = ['session-1', 'session-2']; + +async function expectStillPending(promise: Promise): Promise { + let settled = false; + promise.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + + await Promise.resolve(); + + expect(settled).toBe(false); +} + +describe('ACP permission requests', () => { + beforeEach(() => { + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpPermissionRequestsForSession(sessionId); + } + }); + + afterEach(() => { + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpPermissionRequestsForSession(sessionId); + } + }); + + it('keeps permission requests pending until explicit resolve', async () => { + const response = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + + await expectStillPending(response); + + expect(resolveAcpPermissionRequest('session-1', 'tool-1', 'allow_once')).toBe(true); + await expect(response).resolves.toEqual({ + outcome: { + outcome: 'selected', + optionId: 'allow-once', + }, + }); + }); + + it('cancels only pending requests for the requested session', async () => { + const sessionOneResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + const sessionTwoResponse = requestAcpPermission(permissionRequest('session-2', 'tool-2')); + + cancelAcpPermissionRequestsForSession('session-1'); + + await expect(sessionOneResponse).resolves.toEqual({ + outcome: { + outcome: 'cancelled', + }, + }); + await expectStillPending(sessionTwoResponse); + + expect(resolveAcpPermissionRequest('session-2', 'tool-2', 'deny_once')).toBe(true); + await expect(sessionTwoResponse).resolves.toEqual({ + outcome: { + outcome: 'selected', + optionId: 'reject-once', + }, + }); + }); + + it('cancels an older duplicate request for the same session and tool call', async () => { + const firstResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + const secondResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + + await expect(firstResponse).resolves.toEqual({ + outcome: { + outcome: 'cancelled', + }, + }); + await expectStillPending(secondResponse); + + expect(resolveAcpPermissionRequest('session-1', 'tool-1', 'allow_once')).toBe(true); + await expect(secondResponse).resolves.toEqual({ + outcome: { + outcome: 'selected', + optionId: 'allow-once', + }, + }); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/prompt.test.ts b/ui/desktop/src/acp/__tests__/prompt.test.ts new file mode 100644 index 000000000000..b758737ef2e6 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/prompt.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { Message } from '../../api'; +import { messageToAcpPromptContent } from '../prompt'; + +describe('messageToAcpPromptContent', () => { + it('converts text and image content into ACP prompt blocks', () => { + const message: Message = { + id: 'message-1', + role: 'user', + created: 123, + content: [ + { type: 'text', text: 'Describe this' }, + { type: 'image', data: 'abc123', mimeType: 'image/png' }, + ], + metadata: { userVisible: true, agentVisible: true }, + }; + + expect(messageToAcpPromptContent(message)).toEqual([ + { type: 'text', text: 'Describe this' }, + { type: 'image', data: 'abc123', mimeType: 'image/png' }, + ]); + }); + + it('omits empty text content and unsupported content blocks', () => { + const message: Message = { + id: 'message-1', + role: 'user', + created: 123, + content: [ + { type: 'text', text: ' ' }, + { + type: 'toolResponse', + id: 'tool-1', + toolResult: { status: 'success', value: [] }, + }, + ], + metadata: { userVisible: true, agentVisible: true }, + } as Message; + + expect(messageToAcpPromptContent(message)).toEqual([]); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts new file mode 100644 index 000000000000..9a26e45b88ed --- /dev/null +++ b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts @@ -0,0 +1,500 @@ +import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; +import { describe, expect, it } from 'vitest'; +import type { Message, MessageContent } from '../../api'; +import type { NotificationEvent } from '../../types/message'; +import { + createAcpSessionNotificationAdapter, + type AcpChatStateChange, +} from '../sessionNotificationAdapter'; + +const SESSION_ID = 'session-1'; + +function acpUpdate(update: SessionNotification['update']): SessionNotification { + return { + sessionId: SESSION_ID, + update, + }; +} + +function gooseUpdate( + update: GooseSessionNotification_unstable['update'] +): GooseSessionNotification_unstable { + return { + sessionId: SESSION_ID, + update, + }; +} + +function agentText(text: string): SessionNotification { + return acpUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }); +} + +function userText(text: string): SessionNotification { + return acpUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + }); +} + +function agentThought(text: string): SessionNotification { + return acpUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text }, + }); +} + +function agentImage(data: string, mimeType: string): SessionNotification { + return acpUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'image', data, mimeType }, + }); +} + +function expectOnlyMessagesChange(chatStateChanges: AcpChatStateChange[]): Message[] { + expect(chatStateChanges).toHaveLength(1); + + const [chatStateChange] = chatStateChanges; + expect(chatStateChange.type).toBe('messages'); + + if (chatStateChange.type !== 'messages') { + throw new Error('expected messages state change'); + } + + return chatStateChange.messages; +} + +function expectOnlyNotificationChange( + chatStateChanges: AcpChatStateChange[] +): NotificationEvent { + expect(chatStateChanges).toHaveLength(1); + + const [chatStateChange] = chatStateChanges; + expect(chatStateChange.type).toBe('notification'); + + if (chatStateChange.type !== 'notification') { + throw new Error('expected notification state change'); + } + + return chatStateChange.notification; +} + +function firstContent(message: Message): MessageContent { + const content = message.content[0]; + expect(content).toBeDefined(); + return content; +} + +describe('createAcpSessionNotificationAdapter', () => { + describe('apply', () => { + describe('message chunks', () => { + it('maps and merges text chunks by role', () => { + const adapter = createAcpSessionNotificationAdapter(); + + adapter.apply(agentText('Hello ')); + + const secondChunkStateChanges = adapter.apply(agentText('world')); + let messages = expectOnlyMessagesChange(secondChunkStateChanges); + + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('assistant'); + expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'Hello world' }); + + const userTextStateChanges = adapter.apply(userText('Question')); + messages = expectOnlyMessagesChange(userTextStateChanges); + + expect(messages).toHaveLength(2); + expect(messages[1].role).toBe('user'); + expect(firstContent(messages[1])).toMatchObject({ type: 'text', text: 'Question' }); + }); + + it('appends repeated adjacent text deltas', () => { + const adapter = createAcpSessionNotificationAdapter(); + + adapter.apply(agentText('Hel')); + const messages = expectOnlyMessagesChange(adapter.apply(agentText('l'))); + + expect(messages).toHaveLength(1); + expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'Hell' }); + }); + + it('maps image and thinking chunks to existing message content shapes', () => { + const imageAdapter = createAcpSessionNotificationAdapter(); + + const imageStateChanges = imageAdapter.apply(agentImage('base64-image', 'image/png')); + const imageMessages = expectOnlyMessagesChange(imageStateChanges); + + expect(firstContent(imageMessages[0])).toMatchObject({ + type: 'image', + data: 'base64-image', + mimeType: 'image/png', + }); + + const thoughtAdapter = createAcpSessionNotificationAdapter(); + thoughtAdapter.apply(agentThought('Thinking ')); + + const thoughtStateChanges = thoughtAdapter.apply(agentThought('more')); + const thoughtMessages = expectOnlyMessagesChange(thoughtStateChanges); + + expect(thoughtMessages).toHaveLength(1); + expect(firstContent(thoughtMessages[0])).toMatchObject({ + type: 'thinking', + thinking: 'Thinking more', + signature: '', + }); + }); + }); + + describe('tools', () => { + it('maps tool calls and successful responses, including MCP app metadata', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const toolCallStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call', + toolCallId: 'tool-1', + title: 'Read file', + kind: 'read', + status: 'in_progress', + rawInput: { path: 'README.md' }, + locations: [{ path: 'README.md', line: 1 }], + _meta: { + goose: { + toolCall: { + extensionName: 'developer', + toolName: 'read_file', + }, + }, + }, + }) + ); + let messages = expectOnlyMessagesChange(toolCallStateChanges); + + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('assistant'); + expect(firstContent(messages[0])).toMatchObject({ + type: 'toolRequest', + id: 'tool-1', + toolCall: { + status: 'success', + value: { + name: 'read_file', + arguments: { path: 'README.md' }, + }, + }, + metadata: { + title: 'Read file', + status: 'in_progress', + extensionName: 'developer', + kind: 'read', + locations: [{ path: 'README.md', line: 1 }], + }, + }); + + const toolResponseStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'completed', + rawOutput: 'raw result', + content: [ + { + type: 'content', + content: { type: 'text', text: 'rendered result' }, + }, + ], + _meta: { + goose: { + mcpApp: { + resourceUri: 'ui://app/resource', + extensionName: 'developer', + toolName: 'read_file', + }, + }, + }, + }) + ); + messages = expectOnlyMessagesChange(toolResponseStateChanges); + + expect(messages).toHaveLength(2); + expect(messages[1].role).toBe('user'); + expect(firstContent(messages[1])).toMatchObject({ + type: 'toolResponse', + id: 'tool-1', + toolResult: { + status: 'success', + value: { + content: [{ type: 'text', text: 'rendered result' }], + isError: false, + _meta: { + ui: { resourceUri: 'ui://app/resource' }, + extensionName: 'developer', + toolName: 'read_file', + }, + }, + }, + metadata: { + status: 'completed', + rawOutput: 'raw result', + }, + }); + }); + + it('maps failed tool responses to error results', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const failedToolStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'failed', + title: 'Read file', + rawOutput: 'permission denied', + }) + ); + const messages = expectOnlyMessagesChange(failedToolStateChanges); + + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('user'); + expect(firstContent(messages[0])).toMatchObject({ + type: 'toolResponse', + id: 'tool-1', + toolResult: { + status: 'error', + error: 'permission denied', + }, + metadata: { + title: 'Read file', + status: 'failed', + rawOutput: 'permission denied', + }, + }); + }); + + it('uses failed tool response text content when raw output is absent', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const failedToolStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'failed', + title: 'Read file', + content: [ + { + type: 'content', + content: { type: 'text', text: 'file not found' }, + }, + ], + }) + ); + const messages = expectOnlyMessagesChange(failedToolStateChanges); + + expect(firstContent(messages[0])).toMatchObject({ + type: 'toolResponse', + id: 'tool-1', + toolResult: { + status: 'error', + error: 'file not found', + }, + }); + }); + + it('maps in-progress tool message notifications', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const notificationStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + _meta: { + toolNotification: { + type: 'message', + params: { + level: 'info', + logger: 'subagent:session-1', + data: { + text: 'Running search...', + }, + }, + }, + }, + }) + ); + const notification = expectOnlyNotificationChange(notificationStateChanges); + + expect(notification).toMatchObject({ + type: 'Notification', + request_id: 'tool-1', + message: { + method: 'notifications/message', + params: { + level: 'info', + logger: 'subagent:session-1', + data: { + text: 'Running search...', + }, + }, + }, + }); + }); + + it('maps in-progress tool progress notifications', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const notificationStateChanges = adapter.apply( + acpUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + _meta: { + toolNotification: { + type: 'progress', + params: { + progressToken: 'scan-repo', + progress: 3, + total: 10, + message: 'Scanned 3 of 10 directories', + }, + }, + }, + }) + ); + const notification = expectOnlyNotificationChange(notificationStateChanges); + + expect(notification).toMatchObject({ + type: 'Notification', + request_id: 'tool-1', + message: { + method: 'notifications/progress', + params: { + progressToken: 'scan-repo', + progress: 3, + total: 10, + message: 'Scanned 3 of 10 directories', + }, + }, + }); + }); + }); + }); + + describe('applyGoose', () => { + it('maps usage updates into token state', () => { + const adapter = createAcpSessionNotificationAdapter(); + + expect( + adapter.applyGoose( + gooseUpdate({ + sessionUpdate: 'usage_update', + used: 42, + contextLimit: 200, + accumulatedInputTokens: 10, + accumulatedOutputTokens: 15, + accumulatedCost: 0.12, + }) + ) + ).toEqual([ + { + type: 'tokenState', + tokenState: { + totalTokens: 42, + accumulatedInputTokens: 10, + accumulatedOutputTokens: 15, + accumulatedTotalTokens: 25, + accumulatedCost: 0.12, + }, + }, + ]); + }); + + it('maps status messages and keeps later id-less chunks separate', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const noticeStateChanges = adapter.applyGoose( + gooseUpdate({ + sessionUpdate: 'status_message', + status: { type: 'notice', message: 'Checking files' }, + }) + ); + let messages = expectOnlyMessagesChange(noticeStateChanges); + + expect(messages).toHaveLength(1); + expect(messages[0].metadata).toMatchObject({ userVisible: true, agentVisible: false }); + expect(firstContent(messages[0])).toMatchObject({ + type: 'systemNotification', + notificationType: 'inlineMessage', + msg: 'Checking files', + }); + + const textStateChanges = adapter.apply(agentText('Result')); + messages = expectOnlyMessagesChange(textStateChanges); + + expect(messages).toHaveLength(2); + expect(firstContent(messages[1])).toMatchObject({ type: 'text', text: 'Result' }); + + const progressStateChanges = adapter.applyGoose( + gooseUpdate({ + sessionUpdate: 'status_message', + status: { type: 'progress', message: 'Still working' }, + }) + ); + messages = expectOnlyMessagesChange(progressStateChanges); + + expect(firstContent(messages[2])).toMatchObject({ + type: 'systemNotification', + notificationType: 'thinkingMessage', + msg: 'Still working', + }); + }); + }); + + describe('applyPermissionRequest', () => { + it('maps permission requests to action-required tool confirmations', () => { + const adapter = createAcpSessionNotificationAdapter(); + + const request: RequestPermissionRequest = { + sessionId: SESSION_ID, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + toolCall: { + toolCallId: 'tool-1', + title: 'Edit file', + rawInput: { path: 'README.md' }, + content: [ + { + type: 'content', + content: { type: 'text', text: 'Allow editing README.md?' }, + }, + ], + _meta: { + goose: { + toolCall: { + toolName: 'edit_file', + }, + }, + }, + }, + }; + + const permissionStateChanges = adapter.applyPermissionRequest(request); + const messages = expectOnlyMessagesChange(permissionStateChanges); + + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('assistant'); + expect(firstContent(messages[0])).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'toolConfirmation', + id: 'tool-1', + toolName: 'edit_file', + arguments: { path: 'README.md' }, + prompt: 'Allow editing README.md?', + }, + }); + }); + }); +}); diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts index 59caba3b0f4d..1622ee772ca5 100644 --- a/ui/desktop/src/acp/acpConnection.ts +++ b/ui/desktop/src/acp/acpConnection.ts @@ -1,25 +1,27 @@ import { DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, GooseClient, - type Client, + type GooseClientCallbacks, } from '@aaif/goose-sdk'; import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'; import packageJson from '../../package.json'; +import { + handleAcpGooseSessionNotification, + handleAcpSessionNotification, +} from './chatNotifications'; import { createWebSocketStream } from './createWebSocketStream'; +import { requestAcpElicitation } from './elicitationRequests'; +import { requestAcpPermission } from './permissionRequests'; let clientPromise: Promise | null = null; let resolvedClient: GooseClient | null = null; -function createClientCallbacks(): () => Client { +function createClientCallbacks(): () => GooseClientCallbacks { return () => ({ - requestPermission: async () => { - return { - outcome: { - outcome: 'cancelled', - }, - }; - }, - sessionUpdate: async () => {}, + requestPermission: requestAcpPermission, + unstable_createElicitation: requestAcpElicitation, + sessionUpdate: handleAcpSessionNotification, + unstable_sessionUpdate: handleAcpGooseSessionNotification, }); } @@ -47,9 +49,11 @@ async function initializeConnection(): Promise { await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { + elicitation: { form: {} }, _meta: { goose: { mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + customNotifications: true, }, }, }, diff --git a/ui/desktop/src/acp/adapter/elicitations.ts b/ui/desktop/src/acp/adapter/elicitations.ts new file mode 100644 index 000000000000..7c3cfa140873 --- /dev/null +++ b/ui/desktop/src/acp/adapter/elicitations.ts @@ -0,0 +1,89 @@ +import type { Message } from '../../api'; +import type { AcpElicitationRequest } from '../elicitationRequests'; +import { + type AcpChatStateChange, + type AdapterState, + DEFAULT_VISIBLE_MESSAGE_METADATA, + messagesChange, +} from './shared'; + +export type ElicitationStatus = 'submitted' | 'cancelled'; + +export function applyElicitationRequest( + state: AdapterState, + request: AcpElicitationRequest +): AcpChatStateChange[] { + if (hasExistingElicitation(state, request.id)) { + return messagesChange(state); + } + + state.messages.push({ + id: request.id, + role: 'assistant', + created: Math.floor(Date.now() / 1000), + content: [ + { + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: request.id, + message: request.request.message, + requested_schema: request.request.requestedSchema, + }, + }, + ], + metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + }); + + return messagesChange(state); +} + +export function applyElicitationStatus( + state: AdapterState, + elicitationId: string, + status: ElicitationStatus +): AcpChatStateChange[] { + const statusData = { + isSubmitted: status === 'submitted', + isCancelled: status === 'cancelled', + }; + let changed = false; + + state.messages = state.messages.map((message) => { + let messageChanged = false; + const content = message.content.map((content) => { + if ( + content.type !== 'actionRequired' || + content.data.actionType !== 'elicitation' || + content.data.id !== elicitationId + ) { + return content; + } + + messageChanged = true; + changed = true; + return { + ...content, + data: { + ...content.data, + ...statusData, + }, + }; + }); + + return messageChanged ? { ...message, content } : message; + }); + + return changed ? messagesChange(state) : []; +} + +function hasExistingElicitation(state: AdapterState, elicitationId: string): boolean { + return state.messages.some((message: Message) => + message.content.some( + (content) => + content.type === 'actionRequired' && + content.data.actionType === 'elicitation' && + content.data.id === elicitationId + ) + ); +} diff --git a/ui/desktop/src/acp/adapter/gooseSessionNotifications.ts b/ui/desktop/src/acp/adapter/gooseSessionNotifications.ts new file mode 100644 index 000000000000..197b4f8a50cc --- /dev/null +++ b/ui/desktop/src/acp/adapter/gooseSessionNotifications.ts @@ -0,0 +1,58 @@ +import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import { type AcpChatStateChange, type AdapterState, messagesChange } from './shared'; + +export function applyGooseSessionNotification( + state: AdapterState, + notification: GooseSessionNotification_unstable +): AcpChatStateChange[] { + const update = notification.update; + + switch (update.sessionUpdate) { + case 'usage_update': + return [ + { + type: 'tokenState', + tokenState: { + totalTokens: update.used, + accumulatedInputTokens: update.accumulatedInputTokens, + accumulatedOutputTokens: update.accumulatedOutputTokens, + accumulatedTotalTokens: update.accumulatedInputTokens + update.accumulatedOutputTokens, + ...(update.accumulatedCost !== undefined + ? { accumulatedCost: update.accumulatedCost } + : {}), + }, + }, + ]; + case 'status_message': + return applyStatusMessage(state, notification.sessionId, update); + default: + return []; + } +} + +function applyStatusMessage( + state: AdapterState, + sessionId: string, + update: Extract +): AcpChatStateChange[] { + const notificationType = update.status.type === 'notice' ? 'inlineMessage' : 'thinkingMessage'; + + state.messages.push({ + id: `acp_status_${sessionId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + role: 'assistant', + created: Math.floor(Date.now() / 1000), + content: [ + { + type: 'systemNotification', + notificationType, + msg: update.status.message, + }, + ], + metadata: { + userVisible: true, + agentVisible: false, + }, + }); + + return messagesChange(state); +} diff --git a/ui/desktop/src/acp/adapter/messages.ts b/ui/desktop/src/acp/adapter/messages.ts new file mode 100644 index 000000000000..1b3b3caf0ce0 --- /dev/null +++ b/ui/desktop/src/acp/adapter/messages.ts @@ -0,0 +1,150 @@ +import type { + ContentBlock as AcpContentBlock, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import type { Message, MessageContent } from '../../api'; +import { + type AcpChatStateChange, + type AdapterState, + DEFAULT_VISIBLE_MESSAGE_METADATA, + getGooseMessageMeta, + messagesChange, +} from './shared'; + +export function applyContentChunk( + state: AdapterState, + role: Message['role'], + update: Extract< + SessionNotification['update'], + { sessionUpdate: 'user_message_chunk' | 'agent_message_chunk' } + > +): AcpChatStateChange[] { + const content = messageContentFromAcpContentBlock(update.content); + if (!content) { + return []; + } + + const gooseMeta = getGooseMessageMeta(update); + const messageId = update.messageId ?? gooseMeta.messageId; + const existing = findMessageForChunk(state, role, messageId, gooseMeta.created); + + if (existing) { + const lastContent = existing.content[existing.content.length - 1]; + if (lastContent?.type === 'text' && content.type === 'text') { + lastContent.text += content.text; + } else if (content.type === 'image' && hasImageContent(existing, content)) { + return messagesChange(state); + } else { + existing.content.push(content); + } + } else { + state.messages.push({ + ...(messageId ? { id: messageId } : {}), + role, + created: gooseMeta.created ?? Math.floor(Date.now() / 1000), + content: [content], + metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + }); + } + + return messagesChange(state); +} + +export function applyThoughtChunk( + state: AdapterState, + update: Extract +): AcpChatStateChange[] { + if (update.content.type !== 'text') { + return []; + } + + const gooseMeta = getGooseMessageMeta(update); + const messageId = update.messageId ?? gooseMeta.messageId; + const existing = findMessageForChunk(state, 'assistant', messageId, gooseMeta.created); + + if (existing) { + const lastContent = existing.content[existing.content.length - 1]; + if (lastContent?.type === 'thinking') { + lastContent.thinking += update.content.text; + } else { + existing.content.push({ type: 'thinking', thinking: update.content.text, signature: '' }); + } + } else { + state.messages.push({ + ...(messageId ? { id: messageId } : {}), + role: 'assistant', + created: gooseMeta.created ?? Math.floor(Date.now() / 1000), + content: [{ type: 'thinking', thinking: update.content.text, signature: '' }], + metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + }); + } + + return messagesChange(state); +} + +function messageContentFromAcpContentBlock(content: AcpContentBlock): MessageContent | undefined { + switch (content.type) { + case 'text': + return { + type: 'text', + text: content.text, + ...(content._meta ? { _meta: content._meta } : {}), + ...(content.annotations ? { annotations: content.annotations } : {}), + }; + case 'image': + return { + type: 'image', + data: content.data, + mimeType: content.mimeType, + ...(content._meta ? { _meta: content._meta } : {}), + ...(content.annotations ? { annotations: content.annotations } : {}), + }; + default: + return undefined; + } +} + +export function findMessageForChunk( + state: AdapterState, + role: Message['role'], + messageId: string | undefined, + created: number | undefined +): Message | undefined { + if (!messageId) { + return lastMergeableMessageWithRole(state, role); + } + + const existing = state.messages.find( + (message) => message.id === messageId && message.role === role + ); + if (existing) { + return existing; + } + + const pending = lastMergeableMessageWithRole(state, role); + if (pending && !pending.id) { + pending.id = messageId; + pending.created = created ?? pending.created; + return pending; + } + + return undefined; +} + +function lastMergeableMessageWithRole( + state: AdapterState, + role: Message['role'] +): Message | undefined { + const lastMessage = state.messages[state.messages.length - 1]; + if (lastMessage?.role !== role || lastMessage.metadata.agentVisible === false) { + return undefined; + } + return lastMessage; +} + +function hasImageContent(message: Message, image: Extract) { + return message.content.some( + (content) => + content.type === 'image' && content.data === image.data && content.mimeType === image.mimeType + ); +} diff --git a/ui/desktop/src/acp/adapter/permissions.ts b/ui/desktop/src/acp/adapter/permissions.ts new file mode 100644 index 000000000000..e99212038ff1 --- /dev/null +++ b/ui/desktop/src/acp/adapter/permissions.ts @@ -0,0 +1,61 @@ +import type { RequestPermissionRequest } from '@agentclientprotocol/sdk'; +import { + type AcpChatStateChange, + type AdapterState, + DEFAULT_VISIBLE_MESSAGE_METADATA, + messagesChange, + rawInputToArguments, + toolIdentity, +} from './shared'; + +export function applyPermissionRequest( + state: AdapterState, + request: RequestPermissionRequest +): AcpChatStateChange[] { + const toolCallId = request.toolCall.toolCallId; + const existing = state.messages.some((message) => + message.content.some( + (content) => + content.type === 'actionRequired' && + content.data.actionType === 'toolConfirmation' && + content.data.id === toolCallId + ) + ); + if (existing) { + return messagesChange(state); + } + + const identity = toolIdentity(request.toolCall); + const prompt = permissionPrompt(request); + + state.messages.push({ + id: `acp_permission_${toolCallId}`, + role: 'assistant', + created: Math.floor(Date.now() / 1000), + content: [ + { + type: 'actionRequired', + data: { + actionType: 'toolConfirmation', + id: toolCallId, + toolName: identity.toolName ?? request.toolCall.title ?? toolCallId, + arguments: rawInputToArguments(request.toolCall.rawInput), + ...(prompt ? { prompt } : {}), + }, + }, + ], + metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + }); + + return messagesChange(state); +} + +function permissionPrompt(request: RequestPermissionRequest): string | undefined { + for (const content of request.toolCall.content ?? []) { + if (content.type === 'content' && content.content.type === 'text') { + return content.content.text; + } + } + + return undefined; +} diff --git a/ui/desktop/src/acp/adapter/shared.ts b/ui/desktop/src/acp/adapter/shared.ts new file mode 100644 index 000000000000..49e0444a8f57 --- /dev/null +++ b/ui/desktop/src/acp/adapter/shared.ts @@ -0,0 +1,81 @@ +import type { ToolCall, ToolCallUpdate } from '@agentclientprotocol/sdk'; +import type { Message, TokenState } from '../../api'; +import type { NotificationEvent } from '../../types/message'; + +export type AcpChatStateChange = + | { type: 'messages'; messages: Message[] } + | { type: 'tokenState'; tokenState: Partial } + | { type: 'sessionInfo'; name?: string } + | { type: 'notification'; notification: NotificationEvent }; + +export interface AdapterState { + messages: Message[]; +} + +export interface GooseMessageMeta { + messageId?: string; + created?: number; +} + +export interface ToolIdentity { + toolName?: string; + extensionName?: string; +} + +export const DEFAULT_VISIBLE_MESSAGE_METADATA: Message['metadata'] = { + userVisible: true, + agentVisible: true, +}; + +export function messagesChange(state: AdapterState): AcpChatStateChange[] { + return [{ type: 'messages', messages: state.messages.map(cloneMessage) }]; +} + +export function cloneMessage(message: Message): Message { + return { + ...message, + content: message.content.map((content) => ({ ...content })), + metadata: { ...message.metadata }, + }; +} + +export function getGooseMessageMeta(update: { _meta?: unknown }): GooseMessageMeta { + if (!isRecord(update._meta)) { + return {}; + } + + const goose = update._meta.goose; + if (!isRecord(goose)) { + return {}; + } + + return { + created: typeof goose.created === 'number' ? goose.created : undefined, + messageId: typeof goose.messageId === 'string' ? goose.messageId : undefined, + }; +} + +export function rawInputToArguments(rawInput: unknown): Record { + return isRecord(rawInput) ? rawInput : {}; +} + +export function toolIdentity(update: ToolCall | ToolCallUpdate): ToolIdentity { + if (!isRecord(update._meta)) { + return {}; + } + + const goose = update._meta.goose; + if (!isRecord(goose) || !isRecord(goose.toolCall)) { + return {}; + } + + return { + toolName: typeof goose.toolCall.toolName === 'string' ? goose.toolCall.toolName : undefined, + extensionName: + typeof goose.toolCall.extensionName === 'string' ? goose.toolCall.extensionName : undefined, + }; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/ui/desktop/src/acp/adapter/toolNotifications.ts b/ui/desktop/src/acp/adapter/toolNotifications.ts new file mode 100644 index 000000000000..9bd5a70424d3 --- /dev/null +++ b/ui/desktop/src/acp/adapter/toolNotifications.ts @@ -0,0 +1,110 @@ +import type { ToolCallUpdate } from '@agentclientprotocol/sdk'; +import type { NotificationEvent } from '../../types/message'; +import type { AcpChatStateChange } from './shared'; +import { isRecord } from './shared'; + +type ToolNotification = + | { + type: 'message'; + params: LoggingMessageNotificationParams; + } + | { + type: 'progress'; + params: ProgressNotificationParams; + }; + +type LoggingMessageNotificationParams = { + level: string; + logger?: string; + data: unknown; +}; + +type ProgressNotificationParams = { + progressToken: string | number; + progress: number; + total?: number; + message?: string; +}; + +export function toolNotificationChange( + update: ToolCallUpdate +): Extract | undefined { + const toolNotification = parseToolNotification(update._meta); + if (!toolNotification) { + return undefined; + } + + return { + type: 'notification', + notification: toNotificationEvent(update.toolCallId, toolNotification), + }; +} + +function parseToolNotification(meta: unknown): ToolNotification | undefined { + if (!isRecord(meta)) { + return undefined; + } + + const toolNotification = meta.toolNotification; + if (!isRecord(toolNotification)) { + return undefined; + } + + if (toolNotification.type === 'message') { + const params = parseLoggingMessageParams(toolNotification.params); + return params ? { type: 'message', params } : undefined; + } + + if (toolNotification.type === 'progress') { + const params = parseProgressParams(toolNotification.params); + return params ? { type: 'progress', params } : undefined; + } + + return undefined; +} + +function parseLoggingMessageParams(value: unknown): LoggingMessageNotificationParams | undefined { + if (!isRecord(value) || typeof value.level !== 'string' || !('data' in value)) { + return undefined; + } + + return { + level: value.level, + ...(typeof value.logger === 'string' ? { logger: value.logger } : {}), + data: value.data, + }; +} + +function parseProgressParams(value: unknown): ProgressNotificationParams | undefined { + if ( + !isRecord(value) || + (typeof value.progressToken !== 'string' && typeof value.progressToken !== 'number') || + typeof value.progress !== 'number' + ) { + return undefined; + } + + return { + progressToken: value.progressToken, + progress: value.progress, + ...(typeof value.total === 'number' ? { total: value.total } : {}), + ...(typeof value.message === 'string' ? { message: value.message } : {}), + }; +} + +function toNotificationEvent( + toolCallId: string, + toolNotification: ToolNotification +): NotificationEvent { + return { + type: 'Notification', + request_id: toolCallId, + message: { + method: + toolNotification.type === 'message' + ? 'notifications/message' + : 'notifications/progress', + params: toolNotification.params, + }, + }; +} diff --git a/ui/desktop/src/acp/adapter/tools.ts b/ui/desktop/src/acp/adapter/tools.ts new file mode 100644 index 000000000000..5bbcb74d7243 --- /dev/null +++ b/ui/desktop/src/acp/adapter/tools.ts @@ -0,0 +1,328 @@ +import type { + ContentBlock as AcpContentBlock, + ToolCall, + ToolCallUpdate, +} from '@agentclientprotocol/sdk'; +import type { CallToolResponse, ContentBlock as ApiContentBlock, Message } from '../../api'; +import { findMessageForChunk } from './messages'; +import { toolNotificationChange } from './toolNotifications'; +import { + type AcpChatStateChange, + type AdapterState, + DEFAULT_VISIBLE_MESSAGE_METADATA, + type GooseMessageMeta, + getGooseMessageMeta, + isRecord, + messagesChange, + rawInputToArguments, + toolIdentity, + type ToolIdentity, +} from './shared'; + +export function applyToolCall(state: AdapterState, update: ToolCall): AcpChatStateChange[] { + const gooseMeta = getGooseMessageMeta(update); + const message = getOrCreateAssistantMessageForUpdate(state, gooseMeta); + + if ( + message.content.some( + (content) => content.type === 'toolRequest' && content.id === update.toolCallId + ) + ) { + return messagesChange(state); + } + + const identity = toolIdentity(update); + const metadata = toolRequestMetadata(update, identity); + + message.content.push({ + type: 'toolRequest', + id: update.toolCallId, + toolCall: { + status: 'success', + value: { + name: identity.toolName ?? update.title, + arguments: rawInputToArguments(update.rawInput), + }, + }, + ...(metadata ? { metadata } : {}), + ...(update._meta ? { _meta: update._meta } : {}), + }); + + return messagesChange(state); +} + +export function applyToolCallUpdate( + state: AdapterState, + update: ToolCallUpdate +): AcpChatStateChange[] { + if (update.status !== 'completed' && update.status !== 'failed') { + const notificationChange = toolNotificationChange(update); + return notificationChange ? [notificationChange] : []; + } + + if (hasToolResponse(state, update.toolCallId)) { + return messagesChange(state); + } + + const gooseMeta = getGooseMessageMeta(update); + const message = getOrCreateToolResponseMessageForUpdate(state, gooseMeta); + const identity = toolIdentity(update); + const metadata = toolResponseMetadata(update, identity); + + message.content.push({ + type: 'toolResponse', + id: update.toolCallId, + toolResult: + update.status === 'failed' + ? { status: 'error', error: toolError(update) } + : { status: 'success', value: toolResultValue(update, mcpAppMetadata(update)) }, + ...(metadata ? { metadata } : {}), + }); + + return messagesChange(state); +} + +function getOrCreateAssistantMessageForUpdate( + state: AdapterState, + gooseMeta: GooseMessageMeta +): Message { + const existing = findMessageForChunk(state, 'assistant', gooseMeta.messageId, gooseMeta.created); + if (existing) { + return existing; + } + + const message: Message = { + ...(gooseMeta.messageId ? { id: gooseMeta.messageId } : {}), + role: 'assistant', + created: gooseMeta.created ?? Math.floor(Date.now() / 1000), + content: [], + metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + }; + state.messages.push(message); + return message; +} + +function getOrCreateToolResponseMessageForUpdate( + state: AdapterState, + gooseMeta: GooseMessageMeta +): Message { + if (gooseMeta.messageId) { + const existing = state.messages.find( + (message) => message.id === gooseMeta.messageId && message.role === 'user' + ); + if (existing) { + return existing; + } + } + + const message: Message = { + ...(gooseMeta.messageId ? { id: gooseMeta.messageId } : {}), + role: 'user', + created: gooseMeta.created ?? Math.floor(Date.now() / 1000), + content: [], + metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + }; + state.messages.push(message); + return message; +} + +function hasToolResponse(state: AdapterState, toolCallId: string): boolean { + return state.messages.some((message) => + message.content.some((content) => content.type === 'toolResponse' && content.id === toolCallId) + ); +} + +function toolRequestMetadata( + update: ToolCall, + identity: ToolIdentity +): Record | undefined { + return baseToolMetadata(update, identity); +} + +function toolResponseMetadata( + update: ToolCallUpdate, + identity: ToolIdentity +): Record | undefined { + const metadata = baseToolMetadata(update, identity) ?? {}; + if (update.rawOutput !== undefined) { + metadata.rawOutput = update.rawOutput; + } + if (update.content) { + metadata.content = update.content; + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +function baseToolMetadata( + update: ToolCall | ToolCallUpdate, + identity: ToolIdentity +): Record | undefined { + const metadata: Record = {}; + + if (update.title) { + metadata.title = update.title; + } + if (update.status) { + metadata.status = update.status; + } + if (identity.extensionName) { + metadata.extensionName = identity.extensionName; + } + if (update.kind) { + metadata.kind = update.kind; + } + if (update.locations) { + metadata.locations = update.locations; + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +function toolResultValue( + update: ToolCallUpdate, + mcpAppMeta: DesktopMcpAppMeta | undefined +): CallToolResponse { + return { + content: toolResultContent(update), + isError: false, + ...(mcpAppMeta ? { _meta: mcpAppMeta } : {}), + }; +} + +function toolResultContent(update: ToolCallUpdate): ApiContentBlock[] { + const content: ApiContentBlock[] = []; + + for (const item of update.content ?? []) { + if (item.type !== 'content') { + continue; + } + + const block = apiContentBlockFromAcpContentBlock(item.content); + if (block) { + content.push(block); + } + } + + if (content.length > 0) { + return content; + } + + if (typeof update.rawOutput === 'string') { + return [{ type: 'text', text: update.rawOutput }]; + } + + return []; +} + +function apiContentBlockFromAcpContentBlock(content: AcpContentBlock): ApiContentBlock | undefined { + switch (content.type) { + case 'text': + return { + type: 'text', + text: content.text, + ...(content._meta ? { _meta: content._meta } : {}), + }; + case 'image': + return { + type: 'image', + data: content.data, + mimeType: content.mimeType, + ...(content._meta ? { _meta: content._meta } : {}), + }; + case 'audio': + return { + type: 'audio', + data: content.data, + mimeType: content.mimeType, + }; + case 'resource_link': + return { + type: 'resource_link', + uri: content.uri, + name: content.name, + ...(content.description ? { description: content.description } : {}), + ...(content.mimeType ? { mimeType: content.mimeType } : {}), + ...(content.size !== undefined && content.size !== null ? { size: content.size } : {}), + ...(content.title ? { title: content.title } : {}), + ...(content._meta ? { _meta: content._meta } : {}), + }; + case 'resource': + return { + type: 'resource', + resource: apiResourceContentsFromAcpResource(content.resource), + ...(content._meta ? { _meta: content._meta } : {}), + }; + default: + return undefined; + } +} + +function apiResourceContentsFromAcpResource( + resource: Extract['resource'] +): Extract['resource'] { + if ('text' in resource) { + return { + uri: resource.uri, + text: resource.text, + ...(resource.mimeType ? { mimeType: resource.mimeType } : {}), + ...(resource._meta ? { _meta: resource._meta } : {}), + }; + } + + return { + uri: resource.uri, + blob: resource.blob, + ...(resource.mimeType ? { mimeType: resource.mimeType } : {}), + ...(resource._meta ? { _meta: resource._meta } : {}), + }; +} + +function toolError(update: ToolCallUpdate): string { + if (typeof update.rawOutput === 'string' && update.rawOutput.trim()) { + return update.rawOutput; + } + + const contentText = toolResultContent(update) + .flatMap((content) => (content.type === 'text' ? [content.text] : [])) + .filter((text) => text.trim().length > 0) + .join('\n'); + if (contentText) { + return contentText; + } + + return update.title ?? 'Tool call failed'; +} + +interface DesktopMcpAppMeta extends Record { + ui: { + resourceUri: string; + }; + extensionName?: string; + toolName?: string; +} + +function mcpAppMetadata(update: ToolCallUpdate): DesktopMcpAppMeta | undefined { + if (!isRecord(update._meta)) { + return undefined; + } + + const goose = update._meta.goose; + if (!isRecord(goose) || !isRecord(goose.mcpApp)) { + return undefined; + } + + const resourceUri = goose.mcpApp.resourceUri; + if (typeof resourceUri !== 'string') { + return undefined; + } + + return { + ui: { + resourceUri, + }, + extensionName: + typeof goose.mcpApp.extensionName === 'string' ? goose.mcpApp.extensionName : undefined, + toolName: typeof goose.mcpApp.toolName === 'string' ? goose.mcpApp.toolName : undefined, + }; +} diff --git a/ui/desktop/src/acp/chatNotifications.ts b/ui/desktop/src/acp/chatNotifications.ts new file mode 100644 index 000000000000..b70e8c02df9f --- /dev/null +++ b/ui/desktop/src/acp/chatNotifications.ts @@ -0,0 +1,36 @@ +import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { AppEvents } from '../constants/events'; +import { acpChatSessionStore } from './chatSessionStore'; + +export function handleAcpSessionNotification(notification: SessionNotification): Promise { + if (USE_ACP_CHAT) { + const sessionNameBeforeNotification = acpChatSessionStore.getSnapshot( + notification.sessionId + )?.session?.name; + const updatedName = + notification.update.sessionUpdate === 'session_info_update' + ? notification.update.title + : undefined; + acpChatSessionStore.applyAcpSessionNotification(notification); + + if (updatedName && updatedName !== sessionNameBeforeNotification) { + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_RENAMED, { + detail: { sessionId: notification.sessionId, newName: updatedName }, + }) + ); + } + } + return Promise.resolve(); +} + +export function handleAcpGooseSessionNotification( + notification: GooseSessionNotification_unstable +): Promise { + if (USE_ACP_CHAT) { + acpChatSessionStore.applyAcpGooseSessionNotification(notification); + } + return Promise.resolve(); +} diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts new file mode 100644 index 000000000000..93ef805d2e45 --- /dev/null +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -0,0 +1,361 @@ +import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; +import type { Message, Session, TokenState } from '../api'; +import { ChatState } from '../types/chatState'; +import type { NotificationEvent } from '../types/message'; +import { + createAcpSessionNotificationAdapter, + type AcpChatStateChange, + type AcpSessionNotificationAdapter, +} from './sessionNotificationAdapter'; +import type { ElicitationStatus } from './adapter/elicitations'; +import { cloneMessage } from './adapter/shared'; +import type { AcpElicitationRequest } from './elicitationRequests'; + +export interface AcpChatSessionSnapshot { + session: Session | undefined; + messages: Message[]; + tokenState: TokenState; + notifications: NotificationEvent[]; + chatState: ChatState; + sessionLoadError: string | undefined; + activePromptAttemptId: string | null; +} + +type SnapshotListener = (snapshot: AcpChatSessionSnapshot) => void; + +interface StoreEntry extends AcpChatSessionSnapshot { + adapter: AcpSessionNotificationAdapter; +} + +const initialTokenState: TokenState = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, +}; + +export interface AcpChatSessionStore { + getSnapshot(sessionId: string): AcpChatSessionSnapshot | undefined; + subscribe(sessionId: string, listener: (snapshot: AcpChatSessionSnapshot) => void): () => void; + deleteSnapshot(sessionId: string): void; + setLoadedSession( + sessionId: string, + session: Session, + tokenState?: TokenState + ): AcpChatSessionSnapshot; + setSessionMetadata(sessionId: string, session: Session | undefined): AcpChatSessionSnapshot; + setMessages(sessionId: string, messages: Message[]): AcpChatSessionSnapshot; + setTokenState(sessionId: string, tokenState: TokenState): AcpChatSessionSnapshot; + setChatState(sessionId: string, chatState: ChatState): AcpChatSessionSnapshot; + setSessionLoadError( + sessionId: string, + sessionLoadError: string | undefined + ): AcpChatSessionSnapshot; + startPromptAttempt(sessionId: string, promptAttemptId: string): AcpChatSessionSnapshot; + finishPromptAttemptIfCurrent(sessionId: string, promptAttemptId: string, error?: string): boolean; + clearActivePromptAttempt(sessionId: string): AcpChatSessionSnapshot | undefined; + isCurrentPromptAttempt(sessionId: string, promptAttemptId: string): boolean; + applyAcpSessionNotification(notification: SessionNotification): AcpChatSessionSnapshot; + applyAcpGooseSessionNotification( + notification: GooseSessionNotification_unstable + ): AcpChatSessionSnapshot; + applyPermissionRequest(request: RequestPermissionRequest): AcpChatSessionSnapshot; + applyElicitationRequest(request: AcpElicitationRequest): AcpChatSessionSnapshot; + setElicitationStatus( + sessionId: string, + elicitationId: string, + status: ElicitationStatus + ): AcpChatSessionSnapshot | undefined; +} + +export function createAcpChatSessionStore(): AcpChatSessionStore { + const sessionsById = new Map(); + const listenersBySessionId = new Map>(); + + const getSnapshot: AcpChatSessionStore['getSnapshot'] = (sessionId) => { + const entry = sessionsById.get(sessionId); + return entry ? snapshotFromEntry(entry) : undefined; + }; + + const subscribe: AcpChatSessionStore['subscribe'] = (sessionId, listener) => { + const listeners = listenersBySessionId.get(sessionId) ?? new Set(); + listeners.add(listener); + listenersBySessionId.set(sessionId, listeners); + + let subscribed = true; + return () => { + if (!subscribed) { + return; + } + + subscribed = false; + const currentListeners = listenersBySessionId.get(sessionId); + if (!currentListeners) { + return; + } + + currentListeners.delete(listener); + if (currentListeners.size === 0) { + listenersBySessionId.delete(sessionId); + } + }; + }; + + const deleteSnapshot: AcpChatSessionStore['deleteSnapshot'] = (sessionId) => { + sessionsById.delete(sessionId); + }; + + const getOrCreateEntry = (sessionId: string): StoreEntry => { + const existing = sessionsById.get(sessionId); + if (existing) { + return existing; + } + + const entry: StoreEntry = { + session: undefined, + messages: [], + tokenState: { ...initialTokenState }, + notifications: [], + chatState: ChatState.Idle, + sessionLoadError: undefined, + activePromptAttemptId: null, + adapter: createAcpSessionNotificationAdapter(), + }; + sessionsById.set(sessionId, entry); + return entry; + }; + + const notify = (sessionId: string, entry: StoreEntry): AcpChatSessionSnapshot => { + const snapshot = snapshotFromEntry(entry); + const listeners = listenersBySessionId.get(sessionId); + if (listeners) { + for (const listener of listeners) { + listener(snapshot); + } + } + return snapshot; + }; + + const setLoadedSession: AcpChatSessionStore['setLoadedSession'] = ( + sessionId, + session, + tokenState = tokenStateFromSession(session) + ) => { + const entry = getOrCreateEntry(sessionId); + entry.session = session; + entry.messages = cloneMessages(session.conversation ?? []); + entry.tokenState = { ...tokenState }; + entry.chatState = entry.activePromptAttemptId ? ChatState.Streaming : ChatState.Idle; + entry.sessionLoadError = undefined; + entry.adapter = createAcpSessionNotificationAdapter(entry.messages); + return notify(sessionId, entry); + }; + + const setSessionMetadata: AcpChatSessionStore['setSessionMetadata'] = (sessionId, session) => { + const entry = getOrCreateEntry(sessionId); + entry.session = session; + return notify(sessionId, entry); + }; + + const setMessages: AcpChatSessionStore['setMessages'] = (sessionId, messages) => { + const entry = getOrCreateEntry(sessionId); + entry.messages = cloneMessages(messages); + entry.adapter = createAcpSessionNotificationAdapter(entry.messages); + return notify(sessionId, entry); + }; + + const setTokenState: AcpChatSessionStore['setTokenState'] = (sessionId, tokenState) => { + const entry = getOrCreateEntry(sessionId); + entry.tokenState = { ...tokenState }; + return notify(sessionId, entry); + }; + + const setChatState: AcpChatSessionStore['setChatState'] = (sessionId, chatState) => { + const entry = getOrCreateEntry(sessionId); + entry.chatState = chatState; + return notify(sessionId, entry); + }; + + const setSessionLoadError: AcpChatSessionStore['setSessionLoadError'] = ( + sessionId, + sessionLoadError + ) => { + const entry = getOrCreateEntry(sessionId); + entry.sessionLoadError = sessionLoadError; + return notify(sessionId, entry); + }; + + const startPromptAttempt: AcpChatSessionStore['startPromptAttempt'] = ( + sessionId, + promptAttemptId + ) => { + const entry = getOrCreateEntry(sessionId); + entry.activePromptAttemptId = promptAttemptId; + entry.chatState = ChatState.Streaming; + entry.sessionLoadError = undefined; + entry.notifications = []; + return notify(sessionId, entry); + }; + + const finishPromptAttemptIfCurrent: AcpChatSessionStore['finishPromptAttemptIfCurrent'] = ( + sessionId, + promptAttemptId, + error + ) => { + const entry = sessionsById.get(sessionId); + if (!entry || entry.activePromptAttemptId !== promptAttemptId) { + return false; + } + + entry.activePromptAttemptId = null; + entry.chatState = ChatState.Idle; + entry.sessionLoadError = error; + notify(sessionId, entry); + return true; + }; + + const clearActivePromptAttempt: AcpChatSessionStore['clearActivePromptAttempt'] = (sessionId) => { + const entry = sessionsById.get(sessionId); + if (!entry) { + return undefined; + } + + entry.activePromptAttemptId = null; + entry.chatState = ChatState.Idle; + return notify(sessionId, entry); + }; + + const isCurrentPromptAttempt: AcpChatSessionStore['isCurrentPromptAttempt'] = ( + sessionId, + promptAttemptId + ) => sessionsById.get(sessionId)?.activePromptAttemptId === promptAttemptId; + + const applyAcpSessionNotification: AcpChatSessionStore['applyAcpSessionNotification'] = ( + notification + ) => { + const entry = getOrCreateEntry(notification.sessionId); + const changes = entry.adapter.apply(notification); + applyChatStateChanges(entry, changes); + return notify(notification.sessionId, entry); + }; + + const applyAcpGooseSessionNotification: AcpChatSessionStore['applyAcpGooseSessionNotification'] = + (notification) => { + const entry = getOrCreateEntry(notification.sessionId); + const changes = entry.adapter.applyGoose(notification); + applyChatStateChanges(entry, changes); + return notify(notification.sessionId, entry); + }; + + const applyPermissionRequest: AcpChatSessionStore['applyPermissionRequest'] = (request) => { + const entry = getOrCreateEntry(request.sessionId); + const changes = entry.adapter.applyPermissionRequest(request); + applyChatStateChanges(entry, changes); + entry.chatState = ChatState.WaitingForUserInput; + return notify(request.sessionId, entry); + }; + + const applyElicitationRequest: AcpChatSessionStore['applyElicitationRequest'] = (request) => { + const entry = getOrCreateEntry(request.sessionId); + const changes = entry.adapter.applyElicitationRequest(request); + applyChatStateChanges(entry, changes); + entry.chatState = ChatState.WaitingForUserInput; + return notify(request.sessionId, entry); + }; + + const setElicitationStatus: AcpChatSessionStore['setElicitationStatus'] = ( + sessionId, + elicitationId, + status + ) => { + const entry = sessionsById.get(sessionId); + if (!entry) { + return undefined; + } + + const changes = entry.adapter.applyElicitationStatus(elicitationId, status); + if (changes.length === 0) { + return snapshotFromEntry(entry); + } + + applyChatStateChanges(entry, changes); + return notify(sessionId, entry); + }; + + return { + getSnapshot, + subscribe, + deleteSnapshot, + setLoadedSession, + setSessionMetadata, + setMessages, + setTokenState, + setChatState, + setSessionLoadError, + startPromptAttempt, + finishPromptAttemptIfCurrent, + clearActivePromptAttempt, + isCurrentPromptAttempt, + applyAcpSessionNotification, + applyAcpGooseSessionNotification, + applyPermissionRequest, + applyElicitationRequest, + setElicitationStatus, + }; +} + +export const acpChatSessionStore = createAcpChatSessionStore(); + +export function tokenStateFromSession(session: Session | undefined): TokenState { + return { + inputTokens: session?.input_tokens ?? 0, + outputTokens: session?.output_tokens ?? 0, + totalTokens: session?.total_tokens ?? 0, + accumulatedInputTokens: session?.accumulated_input_tokens ?? 0, + accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0, + accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0, + ...(session?.accumulated_cost !== undefined + ? { accumulatedCost: session.accumulated_cost } + : {}), + }; +} + +function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]): void { + for (const change of changes) { + switch (change.type) { + case 'messages': + entry.messages = cloneMessages(change.messages); + break; + case 'tokenState': + entry.tokenState = { ...entry.tokenState, ...change.tokenState }; + break; + case 'sessionInfo': + if (change.name && entry.session) { + entry.session = { ...entry.session, name: change.name }; + } + break; + case 'notification': + entry.notifications = [...entry.notifications, change.notification]; + break; + } + } +} + +function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot { + return { + session: entry.session, + messages: cloneMessages(entry.messages), + tokenState: { ...entry.tokenState }, + notifications: [...entry.notifications], + chatState: entry.chatState, + sessionLoadError: entry.sessionLoadError, + activePromptAttemptId: entry.activePromptAttemptId, + }; +} + +function cloneMessages(messages: Message[]): Message[] { + return messages.map(cloneMessage); +} diff --git a/ui/desktop/src/acp/elicitationRequests.ts b/ui/desktop/src/acp/elicitationRequests.ts new file mode 100644 index 000000000000..775f6fc613d7 --- /dev/null +++ b/ui/desktop/src/acp/elicitationRequests.ts @@ -0,0 +1,115 @@ +import type { + CreateElicitationRequest, + CreateElicitationResponse, + ElicitationContentValue, + ElicitationSchema, +} from '@agentclientprotocol/sdk'; +import { v7 as uuidv7 } from 'uuid'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { acpChatSessionStore } from './chatSessionStore'; + +type SessionScopedFormElicitationRequest = CreateElicitationRequest & { + mode: 'form'; + sessionId: string; + requestedSchema: ElicitationSchema; +}; + +export interface AcpElicitationRequest { + id: string; + sessionId: string; + request: SessionScopedFormElicitationRequest; +} + +interface PendingElicitationRequest { + request: AcpElicitationRequest; + resolve: (response: CreateElicitationResponse) => void; + timeoutId: ReturnType; +} + +const pendingRequests = new Map(); +export const ACP_ELICITATION_TIMEOUT_SECONDS = 300; + +export async function requestAcpElicitation( + request: CreateElicitationRequest +): Promise { + if (!USE_ACP_CHAT || !isSessionScopedFormElicitation(request)) { + return cancelledElicitationResponse(); + } + + const elicitationRequest: AcpElicitationRequest = { + id: `acp_elicitation_${uuidv7()}`, + sessionId: request.sessionId, + request, + }; + const key = elicitationRequestKey(elicitationRequest.sessionId, elicitationRequest.id); + + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + const pending = pendingRequests.get(key); + if (!pending) { + return; + } + + pendingRequests.delete(key); + acpChatSessionStore.setElicitationStatus( + elicitationRequest.sessionId, + elicitationRequest.id, + 'cancelled' + ); + pending.resolve(cancelledElicitationResponse()); + }, ACP_ELICITATION_TIMEOUT_SECONDS * 1000); + + pendingRequests.set(key, { request: elicitationRequest, resolve, timeoutId }); + acpChatSessionStore.applyElicitationRequest(elicitationRequest); + }); +} + +export function resolveAcpElicitationRequest( + sessionId: string, + elicitationId: string, + userData: Record +): boolean { + const key = elicitationRequestKey(sessionId, elicitationId); + const pending = pendingRequests.get(key); + if (!pending) { + return false; + } + + pendingRequests.delete(key); + clearTimeout(pending.timeoutId); + acpChatSessionStore.setElicitationStatus(sessionId, elicitationId, 'submitted'); + pending.resolve(acceptedElicitationResponse(userData)); + return true; +} + +export function cancelAcpElicitationRequestsForSession(sessionId: string): void { + for (const [key, pending] of pendingRequests) { + if (pending.request.sessionId === sessionId) { + pendingRequests.delete(key); + clearTimeout(pending.timeoutId); + acpChatSessionStore.setElicitationStatus(sessionId, pending.request.id, 'cancelled'); + pending.resolve(cancelledElicitationResponse()); + } + } +} + +function isSessionScopedFormElicitation( + request: CreateElicitationRequest +): request is SessionScopedFormElicitationRequest { + return request.mode === 'form' && 'sessionId' in request && typeof request.sessionId === 'string'; +} + +function acceptedElicitationResponse(userData: Record): CreateElicitationResponse { + return { + action: 'accept', + content: userData as Record, + }; +} + +function cancelledElicitationResponse(): CreateElicitationResponse { + return { action: 'cancel' }; +} + +function elicitationRequestKey(sessionId: string, elicitationId: string): string { + return `${sessionId}\u0000${elicitationId}`; +} diff --git a/ui/desktop/src/acp/errors.ts b/ui/desktop/src/acp/errors.ts new file mode 100644 index 000000000000..aec28b2ba8aa --- /dev/null +++ b/ui/desktop/src/acp/errors.ts @@ -0,0 +1,45 @@ +export interface AcpCreditsExhaustedError { + message: string; + url?: string; +} + +const CREDITS_EXHAUSTED_REASON = 'credits_exhausted'; + +export function parseAcpCreditsExhaustedError(error: unknown): AcpCreditsExhaustedError | null { + const jsonRpcError = asAcpJsonRpcError(error); + if (jsonRpcError?.data?.reason !== CREDITS_EXHAUSTED_REASON) { + return null; + } + + const url = typeof jsonRpcError.data.url === 'string' ? jsonRpcError.data.url : undefined; + + return { + message: jsonRpcError.message, + ...(url ? { url } : {}), + }; +} + +interface AcpJsonRpcError { + message: string; + data: Record; +} + +function asAcpJsonRpcError(error: unknown): AcpJsonRpcError | null { + if (!isRecord(error)) { + return null; + } + + const candidate = isRecord(error.error) ? error.error : error; + if (typeof candidate.message !== 'string' || !isRecord(candidate.data)) { + return null; + } + + return { + message: candidate.message, + data: candidate.data, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/ui/desktop/src/acp/permissionRequests.ts b/ui/desktop/src/acp/permissionRequests.ts new file mode 100644 index 000000000000..28b852e2fc5e --- /dev/null +++ b/ui/desktop/src/acp/permissionRequests.ts @@ -0,0 +1,115 @@ +import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import type { Permission } from '../api'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { acpChatSessionStore } from './chatSessionStore'; + +interface PendingPermissionRequest { + request: RequestPermissionRequest; + resolve: (response: RequestPermissionResponse) => void; +} + +const pendingRequests = new Map(); + +export async function requestAcpPermission( + request: RequestPermissionRequest +): Promise { + const key = permissionRequestKey(request.sessionId, request.toolCall.toolCallId); + const previous = pendingRequests.get(key); + if (previous) { + previous.resolve(cancelledPermissionResponse()); + } + + if (!USE_ACP_CHAT) { + return cancelledPermissionResponse(); + } + + return new Promise((resolve) => { + pendingRequests.set(key, { request, resolve }); + acpChatSessionStore.applyPermissionRequest(request); + }); +} + +export function resolveAcpPermissionRequest( + sessionId: string, + toolCallId: string, + action: Permission +): boolean { + const key = permissionRequestKey(sessionId, toolCallId); + const pending = pendingRequests.get(key); + if (!pending) { + return false; + } + + pendingRequests.delete(key); + pending.resolve(permissionResponseForAction(pending.request, action)); + return true; +} + +export function cancelAcpPermissionRequestsForSession(sessionId: string): void { + for (const [key, pending] of pendingRequests) { + if (pending.request.sessionId === sessionId) { + pendingRequests.delete(key); + pending.resolve(cancelledPermissionResponse()); + } + } +} + +function permissionResponseForAction( + request: RequestPermissionRequest, + action: Permission +): RequestPermissionResponse { + if (action === 'cancel') { + return cancelledPermissionResponse(); + } + + const optionId = permissionOptionIdForAction(request, action); + if (!optionId) { + return cancelledPermissionResponse(); + } + + return { + outcome: { + outcome: 'selected', + optionId, + }, + }; +} + +function permissionOptionIdForAction( + request: RequestPermissionRequest, + action: Permission +): string | undefined { + const kind = permissionOptionKindForAction(action); + if (!kind) { + return undefined; + } + + return request.options.find((candidate) => candidate.kind === kind)?.optionId; +} + +function permissionOptionKindForAction(action: Permission) { + switch (action) { + case 'allow_once': + return 'allow_once'; + case 'always_allow': + return 'allow_always'; + case 'deny_once': + return 'reject_once'; + case 'always_deny': + return 'reject_always'; + case 'cancel': + return undefined; + } +} + +function cancelledPermissionResponse(): RequestPermissionResponse { + return { + outcome: { + outcome: 'cancelled', + }, + }; +} + +function permissionRequestKey(sessionId: string, toolCallId: string): string { + return `${sessionId}\u0000${toolCallId}`; +} diff --git a/ui/desktop/src/acp/prompt.ts b/ui/desktop/src/acp/prompt.ts new file mode 100644 index 000000000000..780fba242267 --- /dev/null +++ b/ui/desktop/src/acp/prompt.ts @@ -0,0 +1,45 @@ +import type { ContentBlock, PromptResponse } from '@agentclientprotocol/sdk'; +import type { Message } from '../api'; +import { getAcpClient } from './acpConnection'; + +export async function acpPromptSession( + sessionId: string, + message: Message +): Promise { + const client = await getAcpClient(); + return client.prompt({ + sessionId, + prompt: messageToAcpPromptContent(message), + }); +} + +export async function acpCancelPrompt(sessionId: string): Promise { + const client = await getAcpClient(); + await client.cancel({ sessionId }); +} + +export function messageToAcpPromptContent(message: Message): ContentBlock[] { + const prompt: ContentBlock[] = []; + + for (const content of message.content) { + switch (content.type) { + case 'text': + if (content.text.trim()) { + prompt.push({ + type: 'text', + text: content.text, + }); + } + break; + case 'image': + prompt.push({ + type: 'image', + data: content.data, + mimeType: content.mimeType, + }); + break; + } + } + + return prompt; +} diff --git a/ui/desktop/src/acp/sessionNotificationAdapter.ts b/ui/desktop/src/acp/sessionNotificationAdapter.ts new file mode 100644 index 000000000000..3548a5084d4e --- /dev/null +++ b/ui/desktop/src/acp/sessionNotificationAdapter.ts @@ -0,0 +1,85 @@ +import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; +import type { Message } from '../api'; +import { + applyElicitationRequest as applyElicitationRequestToState, + applyElicitationStatus as applyElicitationStatusToState, + type ElicitationStatus, +} from './adapter/elicitations'; +import { applyGooseSessionNotification } from './adapter/gooseSessionNotifications'; +import { applyContentChunk, applyThoughtChunk } from './adapter/messages'; +import { applyPermissionRequest as applyPermissionRequestToState } from './adapter/permissions'; +import { type AcpChatStateChange, type AdapterState, cloneMessage } from './adapter/shared'; +import { applyToolCall, applyToolCallUpdate } from './adapter/tools'; +import type { AcpElicitationRequest } from './elicitationRequests'; + +export type { AcpChatStateChange } from './adapter/shared'; + +export interface AcpSessionNotificationAdapter { + apply(notification: SessionNotification): AcpChatStateChange[]; + applyGoose(notification: GooseSessionNotification_unstable): AcpChatStateChange[]; + applyPermissionRequest(request: RequestPermissionRequest): AcpChatStateChange[]; + applyElicitationRequest(request: AcpElicitationRequest): AcpChatStateChange[]; + applyElicitationStatus(elicitationId: string, status: ElicitationStatus): AcpChatStateChange[]; + getMessages(): Message[]; +} + +export function createAcpSessionNotificationAdapter( + initialMessages: Message[] = [] +): AcpSessionNotificationAdapter { + const state: AdapterState = { + messages: initialMessages.map(cloneMessage), + }; + + return { + apply(notification) { + return applyAcpSessionNotification(state, notification); + }, + applyGoose(notification) { + return applyGooseSessionNotification(state, notification); + }, + applyPermissionRequest(request) { + return applyPermissionRequestToState(state, request); + }, + applyElicitationRequest(request) { + return applyElicitationRequestToState(state, request); + }, + applyElicitationStatus(elicitationId, status) { + return applyElicitationStatusToState(state, elicitationId, status); + }, + getMessages() { + return state.messages.map(cloneMessage); + }, + }; +} + +function applyAcpSessionNotification( + state: AdapterState, + notification: SessionNotification +): AcpChatStateChange[] { + const update = notification.update; + + switch (update.sessionUpdate) { + case 'user_message_chunk': + return applyContentChunk(state, 'user', update); + case 'agent_message_chunk': + return applyContentChunk(state, 'assistant', update); + case 'agent_thought_chunk': + return applyThoughtChunk(state, update); + case 'tool_call': + return applyToolCall(state, update); + case 'tool_call_update': + return applyToolCallUpdate(state, update); + case 'session_info_update': + return [ + { + type: 'sessionInfo', + ...(update.title ? { name: update.title } : {}), + }, + ]; + case 'usage_update': + return []; + default: + return []; + } +} diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index d48ce1b5966d..2a945a113b0f 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -1,4 +1,8 @@ -import type { ForkSessionRequest, ListSessionsRequest, SessionInfo } from '@agentclientprotocol/sdk'; +import type { + ForkSessionRequest, + ListSessionsRequest, + SessionInfo, +} from '@agentclientprotocol/sdk'; import { getAcpClient } from './acpConnection'; import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; @@ -99,10 +103,27 @@ export async function acpRenameSession(sessionId: string, title: string): Promis await client.goose.sessionRename_unstable({ sessionId, title }); } -export async function acpForkSession(sessionId: string, cwd: string): Promise { +export async function acpTruncateSessionConversation( + sessionId: string, + truncateFrom: number +): Promise { const client = await getAcpClient(); + await client.goose.sessionConversationTruncate_unstable({ sessionId, truncateFrom }); +} + +export async function acpForkSession( + sessionId: string, + conversationBefore?: number +): Promise { + const client = await getAcpClient(); + const sessionInfo = await client.goose.sessionInfo_unstable({ sessionId }); + const { cwd } = sessionInfo.session; const request: ForkSessionRequest = { sessionId, cwd }; - await client.unstable_forkSession(request); + if (conversationBefore !== undefined) { + request._meta = { conversationBefore }; + } + const response = await client.unstable_forkSession(request); + return String(response.sessionId); } export async function acpExportSession(sessionId: string): Promise { diff --git a/ui/desktop/src/acpChatFeatureFlag.ts b/ui/desktop/src/acpChatFeatureFlag.ts new file mode 100644 index 000000000000..34ebb17537a1 --- /dev/null +++ b/ui/desktop/src/acpChatFeatureFlag.ts @@ -0,0 +1 @@ +export const USE_ACP_CHAT = false; diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index c96616752f2a..3eedb831d6ae 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { addExtension, agentAddExtension, agentRemoveExtension, callTool, cancelDownload, cancelLocalModelDownload, checkProvider, cleanupProviderCache, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadHfModel, downloadModel, encodeRecipe, exportApp, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getFeatures, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSlashCommands, getTools, getTunnelStatus, importApp, importSessionNostr, inspectRunningJob, killRunningJob, listApps, listBuiltinChatTemplates, listLocalModels, listModels, listProviderSecrets, listRecipes, listSchedules, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, shareSessionNostr, startAgent, startNanogptSetup, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, syncFeaturedModels, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, CallToolData, CallToolError, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CancelRequest, ChatRequest, ChatTemplate, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FeaturesResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponse, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, GooseMode, HfGgufFile, HfModelInfo, HfQuantVariant, Icon, IconTheme, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrRequest, ImportSessionNostrResponse, ImportSessionNostrResponses, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponse, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrRequest, ShareSessionNostrResponse, ShareSessionNostrResponse2, ShareSessionNostrResponses, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponse, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolCallingMode, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, CallToolData, CallToolError, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CancelRequest, ChatRequest, ChatTemplate, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FeaturesResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponse, GetFeaturesResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, GooseMode, HfGgufFile, HfModelInfo, HfModelVariant, HfQuantVariant, Icon, IconTheme, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionNostrData, ImportSessionNostrErrors, ImportSessionNostrRequest, ImportSessionNostrResponse, ImportSessionNostrResponses, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListBuiltinChatTemplatesData, ListBuiltinChatTemplatesResponse, ListBuiltinChatTemplatesResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, ShareSessionNostrData, ShareSessionNostrErrors, ShareSessionNostrRequest, ShareSessionNostrResponse, ShareSessionNostrResponse2, ShareSessionNostrResponses, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartNanogptSetupData, StartNanogptSetupResponse, StartNanogptSetupResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SyncFeaturedModelsData, SyncFeaturedModelsResponses, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolCallingMode, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index ca49c97ee6de..1aeb787229cb 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -20,6 +20,7 @@ export type ActionRequiredData = { message: string; requested_schema: unknown; } | { + action?: string; actionType: 'elicitationResponse'; id: string; user_data: unknown; @@ -292,9 +293,17 @@ export type DictationProviderStatus = { export type DownloadModelRequest = { /** - * Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" + * Optional backend id for callers selecting a concrete variant row. + */ + backend_id?: string | null; + /** + * Model spec/download id like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" or "google/gemma-4-31B-it" */ spec: string; + /** + * Optional backend-specific variant id, such as a GGUF quantization or MLX dtype. + */ + variant_id?: string | null; }; export type DownloadProgress = { @@ -383,6 +392,7 @@ export type ExtensionConfig = { available_tools?: Array; bundled?: boolean | null; cmd: string; + cwd?: string | null; description: string; env_keys?: Array; envs?: Envs; @@ -558,6 +568,24 @@ export type HfModelInfo = { gguf_files: Array; model_name: string; repo_id: string; + variants?: Array; +}; + +export type HfModelVariant = { + backend_id: string; + description: string; + download_id: string; + download_url?: string | null; + filename?: string | null; + format: string; + label: string; + model_id: string; + quality_rank: number; + sharded?: boolean; + size_bytes: number; + supported?: boolean; + unsupported_reason?: string | null; + variant_id: string; }; /** @@ -875,8 +903,13 @@ export type ModelInfoResponse = { }; export type ModelSettings = { + /** + * Backend implementation to use for this model. Defaults to llama.cpp. + */ + backend_id?: string | null; chat_template?: ChatTemplate; context_size?: number | null; + draft_model?: string | null; enable_thinking?: boolean; flash_attention?: boolean | null; frequency_penalty?: number; @@ -1185,8 +1218,9 @@ export type RemoveExtensionRequest = { export type RepoVariantsResponse = { available_memory_bytes: number; downloaded_quants: Array; + downloaded_variants: Array; recommended_index?: number | null; - variants: Array; + variants: Array; }; export type ResourceContents = { @@ -1340,6 +1374,7 @@ export type Session = { goose_mode?: GooseMode; id: string; input_tokens?: number | null; + last_message_snippet?: string | null; message_count: number; model_config?: ModelConfig | null; name: string; diff --git a/ui/desktop/src/bin/jbang b/ui/desktop/src/bin/jbang index 5456939a1a0f..c06b64276345 100755 --- a/ui/desktop/src/bin/jbang +++ b/ui/desktop/src/bin/jbang @@ -29,6 +29,34 @@ else RESOLVED_GOOSE_CONFIG_DIR="${HOME}/.config/goose" fi MCP_HERMIT_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/mcp-hermit" +mkdir -p "${RESOLVED_GOOSE_CONFIG_DIR}" +HERMIT_SETUP_LOCK_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/.mcp-hermit-setup.lock" +HERMIT_SETUP_LOCK_TIMEOUT=300 +HERMIT_SETUP_LOCK_STARTED_AT=$(date +%s) +while ! mkdir "${HERMIT_SETUP_LOCK_DIR}" 2>/dev/null; do + if [ $(( $(date +%s) - HERMIT_SETUP_LOCK_STARTED_AT )) -ge "${HERMIT_SETUP_LOCK_TIMEOUT}" ]; then + log "Timed out waiting for ${HERMIT_SETUP_LOCK_DIR}; removing stale lock." + rm -rf "${HERMIT_SETUP_LOCK_DIR}" + HERMIT_SETUP_LOCK_STARTED_AT=$(date +%s) + fi + sleep 0.1 +done +trap 'rm -rf "${HERMIT_SETUP_LOCK_DIR}"; log "An error occurred. Exiting with status $?."' ERR +trap 'rm -rf "${HERMIT_SETUP_LOCK_DIR}"' EXIT + +# One-time cleanup for existing Linux users to fix locking issues +CLEANUP_MARKER="${RESOLVED_GOOSE_CONFIG_DIR}/.mcp-hermit-cleanup-v1" +if [[ "$(uname -s)" == "Linux" ]] && [ ! -f "${CLEANUP_MARKER}" ]; then + log "Performing one-time cleanup of old mcp-hermit directory to fix locking issues." + if [ -d "${MCP_HERMIT_DIR}" ]; then + STALE_MCP_HERMIT_DIR="${MCP_HERMIT_DIR}.stale.$$" + mv "${MCP_HERMIT_DIR}" "${STALE_MCP_HERMIT_DIR}" + rm -rf "${STALE_MCP_HERMIT_DIR}" + log "Removed old mcp-hermit directory." + fi + touch "${CLEANUP_MARKER}" + log "Cleanup completed. Marker file created." +fi # Ensure mcp-hermit/bin exists log "Creating directory ${MCP_HERMIT_DIR}/bin if it does not exist." @@ -56,9 +84,41 @@ export HERMIT_STATE_DIR="${MCP_HERMIT_DIR}/cache" export PATH="${MCP_HERMIT_DIR}/bin:${PATH}" log "Updated PATH to include ${MCP_HERMIT_DIR}/bin." -# Initialize hermit -log "Initializing hermit." -hermit init >> "$LOG_FILE" +# Check if hermit environment is already initialized (only run init on first setup) +if [ ! -f "bin/activate-hermit" ]; then + log "Hermit environment not yet initialized. Setting up hermit." + + # Fix hermit self-update lock issues on Linux by using temp binary for init only + if [[ "$(uname -s)" == "Linux" ]]; then + log "Creating temp dir with bin subdirectory for hermit copy to avoid self-update locks." + HERMIT_TMP_DIR="/tmp/hermit_tmp_$$/bin" + mkdir -p "${HERMIT_TMP_DIR}" + cp "${MCP_HERMIT_DIR}/bin/hermit" "${HERMIT_TMP_DIR}/hermit" + chmod +x "${HERMIT_TMP_DIR}/hermit" + HERMIT_ORIGINAL_PATH="${PATH}" + export PATH="${HERMIT_TMP_DIR}:${PATH}" + HERMIT_CLEANUP_DIR="/tmp/hermit_tmp_$$" + fi + + # Initialize hermit + log "Initializing hermit." + hermit init >> "$LOG_FILE" + + # Clean up temp dir if it was created + if [[ -n "${HERMIT_CLEANUP_DIR:-}" ]]; then + log "Cleaning up temporary hermit binary directory." + export PATH="${HERMIT_ORIGINAL_PATH}" + rm -rf "${HERMIT_CLEANUP_DIR}" + fi +else + log "Hermit environment already initialized. Skipping init." +fi + +# Activate the environment with output redirected to log +if [[ "$(uname -s)" == "Linux" ]]; then + log "Activating hermit environment." + { . "bin/activate-hermit"; } >> "$LOG_FILE" 2>&1 +fi # Install OpenJDK using hermit log "Installing OpenJDK with hermit." @@ -77,6 +137,10 @@ log "hermit: $(which hermit)" log "java: $(which java)" log "jbang: $(which jbang)" +rm -rf "${HERMIT_SETUP_LOCK_DIR}" +trap 'log "An error occurred. Exiting with status $?."' ERR +trap - EXIT + # Check for custom registry settings log "Checking for GOOSE_JBANG_REGISTRY environment variable for custom jbang registry setup..." if [ -n "${GOOSE_JBANG_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_JBANG_REGISTRY" > /dev/null; then diff --git a/ui/desktop/src/bin/node-setup-common.sh b/ui/desktop/src/bin/node-setup-common.sh index 748dd63d4961..9a3a29bc0c26 100755 --- a/ui/desktop/src/bin/node-setup-common.sh +++ b/ui/desktop/src/bin/node-setup-common.sh @@ -32,16 +32,31 @@ else RESOLVED_GOOSE_CONFIG_DIR="${HOME}/.config/goose" fi MCP_HERMIT_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/mcp-hermit" +mkdir -p "${RESOLVED_GOOSE_CONFIG_DIR}" +HERMIT_SETUP_LOCK_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/.mcp-hermit-setup.lock" +HERMIT_SETUP_LOCK_TIMEOUT=300 +HERMIT_SETUP_LOCK_STARTED_AT=$(date +%s) +while ! mkdir "${HERMIT_SETUP_LOCK_DIR}" 2>/dev/null; do + if [ $(( $(date +%s) - HERMIT_SETUP_LOCK_STARTED_AT )) -ge "${HERMIT_SETUP_LOCK_TIMEOUT}" ]; then + log "Timed out waiting for ${HERMIT_SETUP_LOCK_DIR}; removing stale lock." + rm -rf "${HERMIT_SETUP_LOCK_DIR}" + HERMIT_SETUP_LOCK_STARTED_AT=$(date +%s) + fi + sleep 0.1 +done +trap 'rm -rf "${HERMIT_SETUP_LOCK_DIR}"; log "An error occurred. Exiting with status $?."' ERR +trap 'rm -rf "${HERMIT_SETUP_LOCK_DIR}"' EXIT # One-time cleanup for existing Linux users to fix locking issues CLEANUP_MARKER="${RESOLVED_GOOSE_CONFIG_DIR}/.mcp-hermit-cleanup-v1" if [[ "$(uname -s)" == "Linux" ]] && [ ! -f "${CLEANUP_MARKER}" ]; then log "Performing one-time cleanup of old mcp-hermit directory to fix locking issues." if [ -d "${MCP_HERMIT_DIR}" ]; then - rm -rf "${MCP_HERMIT_DIR}" + STALE_MCP_HERMIT_DIR="${MCP_HERMIT_DIR}.stale.$$" + mv "${MCP_HERMIT_DIR}" "${STALE_MCP_HERMIT_DIR}" + rm -rf "${STALE_MCP_HERMIT_DIR}" log "Removed old mcp-hermit directory." fi - mkdir -p "${RESOLVED_GOOSE_CONFIG_DIR}" touch "${CLEANUP_MARKER}" log "Cleanup completed. Marker file created." fi @@ -91,6 +106,7 @@ if [ ! -f "bin/activate-hermit" ]; then mkdir -p "${HERMIT_TMP_DIR}" cp "${MCP_HERMIT_DIR}/bin/hermit" "${HERMIT_TMP_DIR}/hermit" chmod +x "${HERMIT_TMP_DIR}/hermit" + HERMIT_ORIGINAL_PATH="${PATH}" export PATH="${HERMIT_TMP_DIR}:${PATH}" HERMIT_CLEANUP_DIR="/tmp/hermit_tmp_$$" fi @@ -102,6 +118,7 @@ if [ ! -f "bin/activate-hermit" ]; then # Clean up temp dir if it was created if [[ -n "${HERMIT_CLEANUP_DIR:-}" ]]; then log "Cleaning up temporary hermit binary directory." + export PATH="${HERMIT_ORIGINAL_PATH}" rm -rf "${HERMIT_CLEANUP_DIR}" fi else @@ -124,6 +141,10 @@ log "hermit: $(which hermit)" log "node: $(which node)" log "npx: $(which npx)" +rm -rf "${HERMIT_SETUP_LOCK_DIR}" +trap 'log "An error occurred. Exiting with status $?."' ERR +trap - EXIT + log "Checking for GOOSE_NPM_REGISTRY and GOOSE_NPM_CERT environment variables for custom npm registry setup..." # Check if GOOSE_NPM_REGISTRY is set and accessible diff --git a/ui/desktop/src/bin/uvx b/ui/desktop/src/bin/uvx index 369adadaa91e..db6e0c067eaf 100755 --- a/ui/desktop/src/bin/uvx +++ b/ui/desktop/src/bin/uvx @@ -29,6 +29,34 @@ else RESOLVED_GOOSE_CONFIG_DIR="${HOME}/.config/goose" fi MCP_HERMIT_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/mcp-hermit" +mkdir -p "${RESOLVED_GOOSE_CONFIG_DIR}" +HERMIT_SETUP_LOCK_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/.mcp-hermit-setup.lock" +HERMIT_SETUP_LOCK_TIMEOUT=300 +HERMIT_SETUP_LOCK_STARTED_AT=$(date +%s) +while ! mkdir "${HERMIT_SETUP_LOCK_DIR}" 2>/dev/null; do + if [ $(( $(date +%s) - HERMIT_SETUP_LOCK_STARTED_AT )) -ge "${HERMIT_SETUP_LOCK_TIMEOUT}" ]; then + log "Timed out waiting for ${HERMIT_SETUP_LOCK_DIR}; removing stale lock." + rm -rf "${HERMIT_SETUP_LOCK_DIR}" + HERMIT_SETUP_LOCK_STARTED_AT=$(date +%s) + fi + sleep 0.1 +done +trap 'rm -rf "${HERMIT_SETUP_LOCK_DIR}"; log "An error occurred. Exiting with status $?."' ERR +trap 'rm -rf "${HERMIT_SETUP_LOCK_DIR}"' EXIT + +# One-time cleanup for existing Linux users to fix locking issues +CLEANUP_MARKER="${RESOLVED_GOOSE_CONFIG_DIR}/.mcp-hermit-cleanup-v1" +if [[ "$(uname -s)" == "Linux" ]] && [ ! -f "${CLEANUP_MARKER}" ]; then + log "Performing one-time cleanup of old mcp-hermit directory to fix locking issues." + if [ -d "${MCP_HERMIT_DIR}" ]; then + STALE_MCP_HERMIT_DIR="${MCP_HERMIT_DIR}.stale.$$" + mv "${MCP_HERMIT_DIR}" "${STALE_MCP_HERMIT_DIR}" + rm -rf "${STALE_MCP_HERMIT_DIR}" + log "Removed old mcp-hermit directory." + fi + touch "${CLEANUP_MARKER}" + log "Cleanup completed. Marker file created." +fi # Ensure mcp-hermit/bin exists log "Creating directory ${MCP_HERMIT_DIR}/bin if it does not exist." @@ -62,9 +90,41 @@ log "Updated PATH to include ${MCP_HERMIT_DIR}/bin." log "Checking for hermit in PATH." which hermit >> "$LOG_FILE" -# Initialize hermit -log "Initializing hermit." -hermit init >> "$LOG_FILE" +# Check if hermit environment is already initialized (only run init on first setup) +if [ ! -f "bin/activate-hermit" ]; then + log "Hermit environment not yet initialized. Setting up hermit." + + # Fix hermit self-update lock issues on Linux by using temp binary for init only + if [[ "$(uname -s)" == "Linux" ]]; then + log "Creating temp dir with bin subdirectory for hermit copy to avoid self-update locks." + HERMIT_TMP_DIR="/tmp/hermit_tmp_$$/bin" + mkdir -p "${HERMIT_TMP_DIR}" + cp "${MCP_HERMIT_DIR}/bin/hermit" "${HERMIT_TMP_DIR}/hermit" + chmod +x "${HERMIT_TMP_DIR}/hermit" + HERMIT_ORIGINAL_PATH="${PATH}" + export PATH="${HERMIT_TMP_DIR}:${PATH}" + HERMIT_CLEANUP_DIR="/tmp/hermit_tmp_$$" + fi + + # Initialize hermit + log "Initializing hermit." + hermit init >> "$LOG_FILE" + + # Clean up temp dir if it was created + if [[ -n "${HERMIT_CLEANUP_DIR:-}" ]]; then + log "Cleaning up temporary hermit binary directory." + export PATH="${HERMIT_ORIGINAL_PATH}" + rm -rf "${HERMIT_CLEANUP_DIR}" + fi +else + log "Hermit environment already initialized. Skipping init." +fi + +# Activate the environment with output redirected to log +if [[ "$(uname -s)" == "Linux" ]]; then + log "Activating hermit environment." + { . "bin/activate-hermit"; } >> "$LOG_FILE" 2>&1 +fi # Initialize python >= 3.10 log "hermit install python 3.10" @@ -80,6 +140,10 @@ log "hermit: $(which hermit)" log "uv: $(which uv)" log "uvx: $(which uvx)" +rm -rf "${HERMIT_SETUP_LOCK_DIR}" +trap 'log "An error occurred. Exiting with status $?."' ERR +trap - EXIT + log "Checking for GOOSE_UV_REGISTRY environment variable for custom python/pip/UV registry setup..." # Check if GOOSE_UV_REGISTRY is set and accessible diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index be7df73d2e33..dec6d86be877 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -16,7 +16,7 @@ import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; import { useNavigationContextSafe } from './Layout/NavigationContext'; import { cn } from '../utils'; -import { useChatStream } from '../hooks/useChatStream'; +import { useChatSession } from '../hooks/useChatSession'; import { useNavigation } from '../hooks/useNavigation'; import { RecipeHeader } from './RecipeHeader'; import { RecipeWarningModal } from './ui/RecipeWarningModal'; @@ -33,6 +33,7 @@ import { Recipe } from '../recipe'; import { useAutoSubmit } from '../hooks/useAutoSubmit'; import EnvironmentBadge from './GooseSidebar/EnvironmentBadge'; import apeCloudLogo from '../images/logo.png'; +import SessionActionsHeader from './SessionActionsHeader'; const RECIPE_TRUST_WARNINGS_ENABLED = false; @@ -71,6 +72,7 @@ interface BaseChatProps { sessionId: string; isActiveSession: boolean; initialMessage?: UserInput; + noAutoSubmit?: boolean; } export default function BaseChat({ @@ -80,6 +82,7 @@ export default function BaseChat({ customMainLayoutProps = {}, sessionId, initialMessage, + noAutoSubmit, isActiveSession, }: BaseChatProps) { const intl = useIntl(); @@ -95,7 +98,7 @@ export default function BaseChat({ const navContext = useNavigationContextSafe(); const setView = useNavigation(); const isNavCollapsed = !navContext?.isNavExpanded; - const contentClassName = cn('pr-1 pb-10 pt-10', (isMobile || isNavCollapsed) && 'pt-14'); + const contentClassName = cn('pr-1 pb-10 pt-12', (isMobile || isNavCollapsed) && 'pt-16'); const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); const onStreamFinish = useCallback(() => {}, []); const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false); @@ -105,6 +108,7 @@ export default function BaseChat({ messages, chatState, setChatState, + updateSession, handleSubmit, submitElicitationResponse, stopStreaming, @@ -112,8 +116,9 @@ export default function BaseChat({ setRecipeUserParams, tokenState, notifications: toolCallNotifications, + pauseQueueOnStop, onMessageUpdate, - } = useChatStream({ + } = useChatSession({ sessionId, onStreamFinish, }); @@ -131,11 +136,16 @@ export default function BaseChat({ return initialMessage; }, [initialMessage, recipe?.prompt, session?.user_recipe_values]); + // noAutoSubmit only suppresses auto-submitting the initial prompt of a fresh session + // (goose://new-session?prompt=...). Once the conversation has messages, later flows + // such as forks or resumes should auto-submit normally. + const suppressInitialAutoSubmit = noAutoSubmit && messages.length === 0; const canAutoSubmit = - session?.session_type === 'scheduled' || - !recipe || - !RECIPE_TRUST_WARNINGS_ENABLED || - hasNotAcceptedRecipe === false; + !suppressInitialAutoSubmit && + (session?.session_type === 'scheduled' || + !recipe || + !RECIPE_TRUST_WARNINGS_ENABLED || + hasNotAcceptedRecipe === false); useAutoSubmit({ sessionId, @@ -369,7 +379,10 @@ export default function BaseChat({ : recipe.prompt; } - const initialPrompt = recipePrompt; + const initialPrompt = + noAutoSubmit && messages.length === 0 && resolvedInitialMessage?.msg + ? resolvedInitialMessage.msg + : recipePrompt; if (sessionLoadError) { return ( @@ -428,6 +441,8 @@ export default function BaseChat({
+ + 0 && !session?.user_recipe_values && session?.session_type !== 'scheduled' && ( - setView('chat')} - initialValues={ - (window.appConfig?.get('recipeParameters') as Record | undefined) || - undefined - } - /> - )} + setView('chat')} + initialValues={ + (window.appConfig?.get('recipeParameters') as Record | undefined) || + undefined + } + /> + )} void; onStop?: () => void; + pauseQueueOnStop?: boolean; commandHistory?: string[]; initialValue?: string; droppedFiles?: DroppedFile[]; @@ -207,6 +204,7 @@ export default function ChatInput({ chatState = ChatState.Idle, setChatState, onStop, + pauseQueueOnStop = false, commandHistory = [], initialValue = '', droppedFiles = [], @@ -1376,6 +1374,13 @@ export default function ChatInput({ if (onStop) onStop(); }; + const handleStop = () => { + if (pauseQueueOnStop && queuedMessages.length > 0) { + pauseRemainingQueue(); + } + if (onStop) onStop(); + }; + const handleResumeQueue = () => { queuePausedRef.current = false; setLastInterruption(null); @@ -1586,10 +1591,7 @@ export default function ChatInput({ extensions, diagnostics, attach, mic, send. When the bar is narrow (e.g. on a small window), the secondary controls drop out so the model selector + send button always stay visible. */} -
+
{/* Left: model selector */}
@@ -1744,7 +1746,7 @@ export default function ChatInput({ {isLoading && !hasSubmittableContent ? (
); diff --git a/ui/desktop/src/components/ChatSessionsContainer.tsx b/ui/desktop/src/components/ChatSessionsContainer.tsx index 833e1beeedd3..b911539a62b8 100644 --- a/ui/desktop/src/components/ChatSessionsContainer.tsx +++ b/ui/desktop/src/components/ChatSessionsContainer.tsx @@ -8,6 +8,7 @@ interface ChatSessionsContainerProps { activeSessions: Array<{ sessionId: string; initialMessage?: UserInput; + noAutoSubmit?: boolean; }>; } @@ -51,6 +52,7 @@ export default function ChatSessionsContainer({ setChat={setChat} sessionId={session.sessionId} initialMessage={session.initialMessage} + noAutoSubmit={session.noAutoSubmit} suppressEmptyState={false} isActiveSession={isVisible} /> diff --git a/ui/desktop/src/components/ElicitationRequest.test.tsx b/ui/desktop/src/components/ElicitationRequest.test.tsx new file mode 100644 index 000000000000..3479e54e1f66 --- /dev/null +++ b/ui/desktop/src/components/ElicitationRequest.test.tsx @@ -0,0 +1,82 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import type { ActionRequired } from '../api'; +import { IntlTestWrapper } from '../i18n/test-utils'; +import ElicitationRequest from './ElicitationRequest'; + +const actionRequiredContent = { + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'elicitation-1', + message: 'Need more information', + requested_schema: { + type: 'object', + properties: {}, + }, + }, +} as ActionRequired & { type: 'actionRequired' }; + +type SubmitElicitationResponse = ( + elicitationId: string, + userData: Record +) => Promise; + +function renderElicitationRequest(onSubmit: SubmitElicitationResponse) { + return render( + , + { wrapper: IntlTestWrapper } + ); +} + +describe('ElicitationRequest', () => { + it('shows submitted state when the response is accepted', async () => { + const onSubmit = vi.fn().mockResolvedValue(true); + + renderElicitationRequest(onSubmit); + + await userEvent.click(screen.getByRole('button', { name: 'Accept' })); + + expect(onSubmit).toHaveBeenCalledWith('elicitation-1', {}); + expect(await screen.findByText('Information submitted')).toBeInTheDocument(); + }); + + it('shows submitted state while the response is pending', async () => { + let resolveSubmission: (value: boolean) => void = () => {}; + const submission = new Promise((resolve) => { + resolveSubmission = resolve; + }); + const onSubmit = vi.fn().mockReturnValue(submission); + + renderElicitationRequest(onSubmit); + + await userEvent.click(screen.getByRole('button', { name: 'Accept' })); + + expect(screen.getByText('Information submitted')).toBeInTheDocument(); + + await act(async () => { + resolveSubmission(true); + await submission; + }); + }); + + it('keeps the request actionable when no ACP request is pending', async () => { + const onSubmit = vi.fn().mockResolvedValue(false); + + renderElicitationRequest(onSubmit); + + await userEvent.click(screen.getByRole('button', { name: 'Accept' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'This request is no longer active. The extension will need to ask again.' + ); + expect(screen.queryByText('Information submitted')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Accept' })).toBeEnabled(); + }); +}); diff --git a/ui/desktop/src/components/ElicitationRequest.tsx b/ui/desktop/src/components/ElicitationRequest.tsx index 8e131f3a8f18..9d0c24b984d5 100644 --- a/ui/desktop/src/components/ElicitationRequest.tsx +++ b/ui/desktop/src/components/ElicitationRequest.tsx @@ -34,6 +34,10 @@ const i18n = defineMessages({ id: 'elicitationRequest.waitingForResponse', defaultMessage: 'Waiting for your response ({timeRemaining} remaining)', }, + submitError: { + id: 'elicitationRequest.submitError', + defaultMessage: 'This request is no longer active. The extension will need to ask again.', + }, }); const ELICITATION_TIMEOUT_SECONDS = 300; @@ -42,7 +46,7 @@ interface ElicitationRequestProps { isCancelledMessage: boolean; isClicked: boolean; actionRequiredContent: ActionRequired & { type: 'actionRequired' }; - onSubmit: (elicitationId: string, userData: Record) => void; + onSubmit: (elicitationId: string, userData: Record) => Promise; } function formatTime(seconds: number): string { @@ -59,9 +63,18 @@ export default function ElicitationRequest({ }: ElicitationRequestProps) { const intl = useIntl(); const [submitted, setSubmitted] = useState(isClicked); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(); const [timeRemaining, setTimeRemaining] = useState(ELICITATION_TIMEOUT_SECONDS); const startTimeRef = useRef(Date.now()); + useEffect(() => { + if (isClicked) { + setSubmitted(true); + setSubmitError(undefined); + } + }, [isClicked]); + useEffect(() => { if (submitted || isCancelledMessage || isClicked) return; @@ -87,14 +100,31 @@ export default function ElicitationRequest({ const schema = (requested_schema ?? {}) as JsonSchema; const hasSchemaFields = Boolean(schema.properties && Object.keys(schema.properties).length > 0); - const handleSubmit = (formData: Record) => { + const submitResponse = async (formData: Record) => { setSubmitted(true); - onSubmit(elicitationId, formData); + setIsSubmitting(true); + setSubmitError(undefined); + try { + const didSubmit = await onSubmit(elicitationId, formData); + if (!didSubmit) { + setSubmitted(false); + setSubmitError(intl.formatMessage(i18n.submitError)); + } + } catch (error) { + console.error('Error submitting elicitation response:', error); + setSubmitted(false); + setSubmitError(intl.formatMessage(i18n.submitError)); + } finally { + setIsSubmitting(false); + } + }; + + const handleSubmit = (formData: Record) => { + void submitResponse(formData); }; const handleAccept = () => { - setSubmitted(true); - onSubmit(elicitationId, {}); + void submitResponse({}); }; if (isCancelledMessage) { @@ -165,14 +195,20 @@ export default function ElicitationRequest({ schema={schema} onSubmit={handleSubmit} submitLabel={intl.formatMessage(i18n.submit)} + disabled={isSubmitting} /> ) : (
-
)} + {submitError && ( +
+ {submitError} +
+ )}
diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index ee0a433aafd6..c0d512016bcf 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -34,7 +34,7 @@ interface GooseMessageProps { submitElicitationResponse?: ( elicitationId: string, userData: Record - ) => Promise; + ) => Promise; } export default function GooseMessage({ @@ -75,6 +75,13 @@ export default function GooseMessage({ ); const hasToolConfirmation = toolConfirmationContent !== undefined; const hasElicitation = elicitationContent !== undefined; + const elicitationData = + elicitationContent?.data.actionType === 'elicitation' + ? (elicitationContent.data as typeof elicitationContent.data & { + isSubmitted?: boolean; + isCancelled?: boolean; + }) + : undefined; const toolConfirmationShownInline = useMemo(() => { if (!toolConfirmationContent) return false; @@ -203,8 +210,8 @@ export default function GooseMessage({ {hasElicitation && submitElicitationResponse && ( diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index e28a47eb2274..9d7be7017f87 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { IpcRendererEvent } from 'electron'; import { Outlet, useLocation } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { Menu } from 'lucide-react'; +import { Menu, PanelLeft } from 'lucide-react'; import { defineMessages, useIntl } from '../../i18n'; import { Button } from '../ui/button'; import ChatSessionsContainer from '../ChatSessionsContainer'; @@ -18,12 +18,17 @@ const i18n = defineMessages({ id: 'appLayout.openNavigation', defaultMessage: 'Open navigation', }, + collapseNavigation: { + id: 'appLayout.collapseNavigation', + defaultMessage: 'Collapse navigation', + }, }); interface AppLayoutContentProps { activeSessions: Array<{ sessionId: string; initialMessage?: UserInput; + noAutoSubmit?: boolean; }>; } @@ -59,28 +64,28 @@ const AppLayoutContent: React.FC = ({ activeSessions }) = const needsTrafficLightInset = safeIsMacOS && !isFullScreen; const headerPadding = needsTrafficLightInset ? 'pl-[96px]' : 'pl-4'; - const headerTop = needsTrafficLightInset ? 'top-[15px]' : 'top-[11px]'; + const headerTop = needsTrafficLightInset ? 'top-[14px]' : 'top-[11px]'; + const navToggleTitle = intl.formatMessage( + isNavExpanded ? i18n.collapseNavigation : i18n.openNavigation + ); return (
- {/* Floating menu toggle — only when sidebar is collapsed. When expanded, - the sidebar's own header has the collapse button. */} - {!isNavExpanded && ( -
+ -
- )} + {isNavExpanded ? : } + +
{/* Main content with navigation. Shared white canvas; the sidebar is a rounded outlined card floating on it with breathing room. */} @@ -116,6 +121,7 @@ interface AppLayoutProps { activeSessions: Array<{ sessionId: string; initialMessage?: UserInput; + noAutoSubmit?: boolean; }>; } diff --git a/ui/desktop/src/components/Layout/NavigationPanel.tsx b/ui/desktop/src/components/Layout/NavigationPanel.tsx index b2cc8d13b045..6c92868fe4d9 100644 --- a/ui/desktop/src/components/Layout/NavigationPanel.tsx +++ b/ui/desktop/src/components/Layout/NavigationPanel.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { ChevronDown, ChevronRight, PanelLeft } from 'lucide-react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { motion } from 'framer-motion'; import { useNavigationContext } from './NavigationContext'; import { useConfig } from '../ConfigContext'; @@ -12,11 +12,9 @@ import { type NavItem, } from '../../hooks/useNavigationItems'; import { AppEvents } from '../../constants/events'; -import { Goose } from '../icons/Goose'; import { InlineEditText } from '../common/InlineEditText'; import { SessionIndicators } from '../SessionIndicators'; -import { updateSessionName } from '../../api'; -import type { SessionListItem } from '../../acp/sessions'; +import { acpRenameSession, type SessionListItem } from '../../acp/sessions'; import { cn } from '../../utils'; import { defineMessages, useIntl } from '../../i18n'; @@ -40,10 +38,6 @@ const i18n = defineMessages({ id: 'navigationPanel.untitledSession', defaultMessage: 'Untitled session', }, - collapseSidebar: { - id: 'navigationPanel.collapseSidebar', - defaultMessage: 'Collapse sidebar', - }, }); const navItemClass = (active: boolean) => @@ -102,10 +96,12 @@ const SessionRow: React.FC = ({ session, active, status, onClic { - await updateSessionName({ - path: { session_id: session.id }, - body: { name: newName }, - }); + await acpRenameSession(session.id, newName); + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_RENAMED, { + detail: { sessionId: session.id, newName, userInitiated: true }, + }) + ); onRenamed(); }} placeholder={intl.formatMessage(i18n.untitledSession)} @@ -123,7 +119,7 @@ const SessionRow: React.FC = ({ session, active, status, onClic export const Navigation: React.FC<{ className?: string }> = ({ className }) => { const intl = useIntl(); - const { isNavExpanded, setIsNavExpanded } = useNavigationContext(); + const { isNavExpanded } = useNavigationContext(); const location = useLocation(); const { extensionsList } = useConfig(); @@ -138,13 +134,8 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => { const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]); - const { - recentSessions, - activeSessionId, - fetchSessions, - handleNavClick, - handleSessionClick, - } = useNavigationSessions(); + const { recentSessions, activeSessionId, fetchSessions, handleNavClick, handleSessionClick } = + useNavigationSessions(); const [sessionStatuses, setSessionStatuses] = useState>(new Map()); @@ -200,25 +191,12 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} - className={cn( - 'bg-background-primary outline-none flex flex-col h-full', - className - )} + className={cn('bg-background-primary outline-none flex flex-col h-full', className)} > - {/* Header: logo + collapse button. Top padding clears the macOS traffic lights. */} -
- - -
+
{/* Nav items */} -
+
{visibleItems.map((item) => ( = ({ }) => { const intl = useIntl(); const navigate = useNavigate(); + const fieldIdPrefix = useId(); + const fieldId = (key: string): string => `${fieldIdPrefix}-${key}`; const [inputValues, setInputValues] = useState>({}); const [validationErrors, setValidationErrors] = useState>({}); const [showCancelOptions, setShowCancelOptions] = useState(false); - // Pre-fill the form with default values from the recipe and initialValues from deeplink useEffect(() => { const defaultValues: Record = {}; parameters.forEach((param) => { @@ -92,10 +93,8 @@ const ParameterInputModal: React.FC = ({ }; const handleSubmit = (): void => { - // Clear previous validation errors setValidationErrors({}); - // Check if all *required* parameters are filled const requiredParams: Parameter[] = parameters.filter((p) => p.requirement === 'required'); const errors: Record = {}; @@ -115,7 +114,6 @@ const ParameterInputModal: React.FC = ({ }; const handleCancel = (): void => { - // Always show cancel options if recipe has any parameters (required or optional) const hasAnyParams = parameters.length > 0; if (hasAnyParams) { @@ -136,7 +134,6 @@ const ParameterInputModal: React.FC = ({ return (
{showCancelOptions ? ( - // Cancel options modal

{intl.formatMessage(i18n.cancelRecipeSetup)} @@ -162,7 +159,6 @@ const ParameterInputModal: React.FC = ({

) : ( - // Main parameter form

@@ -173,16 +169,19 @@ const ParameterInputModal: React.FC = ({
{parameters.map((param) => (
-

-
+
-
+
= [ + { value: 'system', message: 'languageSystem' }, + { value: 'en', message: 'languageEnglish' }, + { value: 'es', message: 'languageSpanish' }, + { value: 'hi', message: 'languageHindi' }, + { value: 'ja', message: 'languageJapanese' }, + { value: 'ko', message: 'languageKorean' }, + { value: 'ru', message: 'languageRussian' }, + { value: 'tr', message: 'languageTurkish' }, + { value: 'zh-CN', message: 'languageChineseSimplified' }, +]; + interface AppSettingsSectionProps { scrollToSection?: string; } @@ -135,6 +172,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false); const [showPricing, setShowPricing] = useState(true); + const [language, setLanguage] = useState('system'); const [isDarkMode, setIsDarkMode] = useState(false); const updateSectionRef = useRef(null); const shouldShowUpdates = !window.appConfig.get('GOOSE_VERSION'); @@ -161,6 +199,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti useEffect(() => { window.electron.getSetting('showPricing').then(setShowPricing); + window.electron.getSetting('language').then((value) => setLanguage(value ?? 'system')); }, []); useEffect(() => { @@ -256,7 +295,25 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti window.dispatchEvent(new CustomEvent('showPricingChanged')); }; + const handleLanguageChange = async (value: string) => { + const nextLanguage = LANGUAGE_OPTIONS.find((option) => option.value === value)?.value; + if (!nextLanguage || nextLanguage === language) { + return; + } + + setLanguage(nextLanguage); + try { + await window.electron.setSetting('language', nextLanguage); + window.electron.reloadApp(); + } catch (error) { + console.error('Failed to update language setting:', error); + setLanguage(language); + } + }; + const intl = useIntl(); + const selectedLanguage = + LANGUAGE_OPTIONS.find((option) => option.value === language) ?? LANGUAGE_OPTIONS[0]; return (
@@ -404,6 +461,29 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti + + + {intl.formatMessage(i18n.languageTitle)} + {intl.formatMessage(i18n.languageDesc)} + + + + + {intl.formatMessage(i18n[selectedLanguage.message])} + + + + + {LANGUAGE_OPTIONS.map((option) => ( + + {intl.formatMessage(i18n[option.message])} + + ))} + + + + + diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.test.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.test.tsx index a18ebbe2feb2..954eefa9222a 100644 --- a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.test.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.test.tsx @@ -256,7 +256,7 @@ describe('ExtensionModal', () => { expect(submittedData.headers).toEqual([ { key: 'Authorization', value: 'Bearer abc123', isEdited: true }, ]); - }); + }, 10000); describe('pending env var capture (fix for #8969)', () => { beforeEach(() => { @@ -350,7 +350,7 @@ describe('ExtensionModal', () => { }), ]) ); - }); + }, 10000); it('does not capture a pending env var when only the key is filled', async () => { const user = userEvent.setup(); diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index 9cb15f25f6e4..40aff0b5b3eb 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -154,9 +154,9 @@ export function getSubtitle(config: ExtensionConfig) { } case 'sse': case 'streamable_http': { - const prefix = `${config.type.toUpperCase().replace('_', ' ')} extension`; + const label = config.type === 'sse' ? 'SSE' : 'HTTP'; return { - description: `${prefix}${config.description ? ': ' + config.description : ''}`, + description: config.description ? `${label}: ${config.description}` : `${label} extension`, command: config.uri || null, }; } diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index ab886aa173e8..5fd4cd0892f5 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { nameToKey, getDefaultFormData, @@ -346,6 +346,36 @@ describe('Extension Utils', () => { ])('splits %j correctly', (input, expected) => { expect(splitCmdAndArgs(input)).toEqual(expected); }); + + describe('on Windows', () => { + beforeEach(() => { + vi.stubGlobal('window', { electron: { platform: 'win32' } }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('preserves backslashes in a Windows path', () => { + expect(splitCmdAndArgs('C:\\Users\\name\\path\\to\\extension.js')).toEqual({ + cmd: 'C:\\Users\\name\\path\\to\\extension.js', + args: [], + }); + }); + + it('preserves backslashes in cmd and args', () => { + expect(splitCmdAndArgs('node C:\\Users\\name\\ext.js')).toEqual({ + cmd: 'node', + args: ['C:\\Users\\name\\ext.js'], + }); + }); + + it('handles a quoted Windows path containing spaces', () => { + expect(splitCmdAndArgs('"C:\\Program Files\\app\\ext.js"')).toEqual({ + cmd: 'C:\\Program Files\\app\\ext.js', + args: [], + }); + }); + }); }); describe('extractCommand', () => { diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index 00e29c1fa506..12172610295e 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -168,13 +168,23 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon } } +function isWindowsPlatform(): boolean { + return typeof window !== 'undefined' && window.electron?.platform === 'win32'; +} + export function splitCmdAndArgs(str: string): { cmd: string; args: string[] } { const trimmed = str.trim(); if (!trimmed) { return { cmd: '', args: [] }; } - const parsed = parseShellQuote(trimmed); + // shell-quote treats `\` as a POSIX escape character, so a Windows path like + // `C:\Users\name\ext.js` would lose its backslashes and become `C:Usersnameext.js`. + // Doubling backslashes on Windows lets them survive parsing (shell-quote unescapes + // `\\` back to `\`), while still honoring quotes for paths containing spaces. + const toParse = isWindowsPlatform() ? trimmed.replace(/\\/g, '\\\\') : trimmed; + + const parsed = parseShellQuote(toParse); const words = parsed.filter((item): item is string => typeof item === 'string').map(String); const cmd = words[0] || ''; diff --git a/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx b/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx index 04da134438a3..e37f4f26068a 100644 --- a/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx +++ b/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx @@ -14,8 +14,9 @@ import { searchHfModels, getRepoFiles, downloadHfModel, + type DownloadModelRequest, type HfModelInfo, - type HfQuantVariant, + type HfModelVariant, } from '../../../api'; import { defineMessages, useIntl } from '../../../i18n'; @@ -26,7 +27,7 @@ const i18n = defineMessages({ }, searchPlaceholder: { id: 'huggingFaceModelSearch.searchPlaceholder', - defaultMessage: 'Search for GGUF models...', + defaultMessage: 'Search for local models...', }, loadingVariants: { id: 'huggingFaceModelSearch.loadingVariants', @@ -54,7 +55,7 @@ const i18n = defineMessages({ }, noGgufModels: { id: 'huggingFaceModelSearch.noGgufModels', - defaultMessage: 'No GGUF models found for this query.', + defaultMessage: 'No compatible local models found for this query.', }, searchError: { id: 'huggingFaceModelSearch.searchError', @@ -85,14 +86,15 @@ const formatDownloads = (n: number): string => { }; interface RepoData { - variants: HfQuantVariant[]; + variants: HfModelVariant[]; recommendedIndex: number | null; availableMemoryBytes: number; downloadedQuants: Set; + downloadedVariants: Set; } interface Props { - onDownloadStarted: (modelId: string) => void; + onDownloadStarted: (modelId: string, request: DownloadModelRequest) => void; /** Model IDs (repo:quant) with an active download in progress */ activeDownloadIds?: Set; /** Model IDs (repo:quant) confirmed downloaded on disk */ @@ -129,7 +131,7 @@ export const HuggingFaceModelSearch = ({ query: { q, limit: 20 }, }); if (response.data) { - // Pre-fetch variants for all results and filter out repos with no suitable quantizations + // Pre-fetch variants for all results and filter out repos with no compatible local variants const modelsWithVariants = await Promise.all( response.data.map(async (model) => { try { @@ -148,10 +150,11 @@ export const HuggingFaceModelSearch = ({ const validResults = modelsWithVariants.filter(Boolean) as { model: HfModelInfo; data: { - variants: HfQuantVariant[]; + variants: HfModelVariant[]; recommended_index?: number | null; available_memory_bytes: number; downloaded_quants: string[]; + downloaded_variants: string[]; }; }[]; @@ -164,6 +167,7 @@ export const HuggingFaceModelSearch = ({ recommendedIndex: r.data.recommended_index ?? null, availableMemoryBytes: r.data.available_memory_bytes, downloadedQuants: new Set(r.data.downloaded_quants), + downloadedVariants: new Set(r.data.downloaded_variants), }; } return next; @@ -217,6 +221,7 @@ export const HuggingFaceModelSearch = ({ recommendedIndex: response.data!.recommended_index ?? null, availableMemoryBytes: response.data!.available_memory_bytes, downloadedQuants: new Set(response.data!.downloaded_quants), + downloadedVariants: new Set(response.data!.downloaded_variants), }, })); } @@ -232,22 +237,27 @@ export const HuggingFaceModelSearch = ({ } }; - const startDownload = async (repoId: string, quantization: string) => { - const spec = `${repoId}:${quantization}`; - setDownloading((prev) => new Set(prev).add(spec)); + const startDownload = async (repoId: string, variant: HfModelVariant) => { + const downloadKey = variant.download_id; + const request: DownloadModelRequest = { + spec: repoId, + backend_id: variant.backend_id, + variant_id: variant.variant_id, + }; + setDownloading((prev) => new Set(prev).add(downloadKey)); try { const response = await downloadHfModel({ - body: { spec }, + body: request, }); if (response.data) { - onDownloadStarted(response.data); + onDownloadStarted(response.data, request); } } catch (e) { console.error('Download failed:', e); } finally { setDownloading((prev) => { const next = new Set(prev); - next.delete(spec); + next.delete(downloadKey); return next; }); } @@ -285,6 +295,7 @@ export const HuggingFaceModelSearch = ({ const recommendedIndex = data?.recommendedIndex ?? null; const availableMemory = data?.availableMemoryBytes ?? 0; const downloadedQuants = data?.downloadedQuants ?? new Set(); + const downloadedVariants = data?.downloadedVariants ?? new Set(); return (
@@ -320,20 +331,21 @@ export const HuggingFaceModelSearch = ({
)} {variants.map((variant, idx) => { - const dlKey = `${model.repo_id}:${variant.quantization}`; - const isStarting = downloading.has(dlKey); + const isStarting = downloading.has(variant.download_id); const isRecommended = idx === recommendedIndex; - const modelId = `${model.repo_id}:${variant.quantization}`; + const modelId = variant.model_id; const isActiveDownload = activeDownloadIds?.has(modelId) ?? false; const isDownloaded = downloadedModelIds ? downloadedModelIds.has(modelId) - : downloadedQuants.has(variant.quantization); + : downloadedVariants.has(modelId) || + downloadedQuants.has(variant.variant_id); const tooLarge = availableMemory > 0 && variant.size_bytes > availableMemory * 0.85; + const isSupported = variant.supported ?? true; return (
+ + {variant.format} + - {variant.quantization} + {variant.label} {formatBytes(variant.size_bytes)} @@ -360,6 +375,12 @@ export const HuggingFaceModelSearch = ({ {variant.description && ( {variant.description} )} + {!isSupported && variant.unsupported_reason && ( + + + {variant.unsupported_reason} + + )} {tooLarge && !isDownloaded && ( @@ -384,8 +405,8 @@ export const HuggingFaceModelSearch = ({ + +
+
+ )} + {progress.status === 'cancelled' && ( +
+

+ {progress.error || intl.formatMessage(i18n.downloadCancelled)} +

+
+ + +
+
)}
); @@ -543,7 +647,7 @@ export const LocalInferenceSettings = () => {
m.status.state === 'Downloaded').map((m) => m.id)) } diff --git a/ui/desktop/src/constants/events.ts b/ui/desktop/src/constants/events.ts index 69f26aeb2c23..026d00d02cdd 100644 --- a/ui/desktop/src/constants/events.ts +++ b/ui/desktop/src/constants/events.ts @@ -11,7 +11,6 @@ export enum AppEvents { SESSION_STATUS_UPDATE = 'session-status-update', ADD_ACTIVE_SESSION = 'add-active-session', CLEAR_INITIAL_MESSAGE = 'clear-initial-message', - TRIGGER_NEW_CHAT = 'trigger-new-chat', MESSAGE_STREAM_FINISHED = 'message-stream-finished', SCROLL_CHAT_TO_BOTTOM = 'scroll-chat-to-bottom', HIDE_ALERT_POPOVER = 'hide-alert-popover', diff --git a/ui/desktop/src/hooks/useAcpChatSession.ts b/ui/desktop/src/hooks/useAcpChatSession.ts new file mode 100644 index 000000000000..eef729bbada9 --- /dev/null +++ b/ui/desktop/src/hooks/useAcpChatSession.ts @@ -0,0 +1,618 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; +import { defineMessages, useIntl } from '../i18n'; +import { v7 as uuidv7 } from 'uuid'; +import { AppEvents } from '../constants/events'; +import { ChatState } from '../types/chatState'; + +import { + Message, + resumeAgent, + Session, + TokenState, + updateFromSession, + updateSessionUserRecipeValues, + listApps, +} from '../api'; + +import { createUserMessage, NotificationEvent, UserInput } from '../types/message'; +import { errorMessage } from '../utils/conversionUtils'; +import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; +import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes'; +import { cancelAcpPermissionRequestsForSession } from '../acp/permissionRequests'; +import { + cancelAcpElicitationRequestsForSession, + resolveAcpElicitationRequest, +} from '../acp/elicitationRequests'; +import { parseAcpCreditsExhaustedError, type AcpCreditsExhaustedError } from '../acp/errors'; +import { acpCancelPrompt, acpPromptSession } from '../acp/prompt'; +import { acpForkSession, acpTruncateSessionConversation } from '../acp/sessions'; +import { acpChatSessionStore, type AcpChatSessionSnapshot } from '../acp/chatSessionStore'; + +interface StreamState { + messages: Message[]; + session: Session | undefined; + chatState: ChatState; + sessionLoadError: string | undefined; + tokenState: TokenState; + notifications: NotificationEvent[]; +} + +type StreamAction = + | { type: 'SET_MESSAGES'; payload: Message[] } + | { type: 'SET_SESSION'; payload: Session | undefined } + | { type: 'SET_CHAT_STATE'; payload: ChatState } + | { type: 'SET_SESSION_LOAD_ERROR'; payload: string | undefined } + | { type: 'SET_TOKEN_STATE'; payload: TokenState } + | { type: 'SYNC_FROM_ACP_STORE'; payload: AcpChatSessionSnapshot } + | { type: 'RESET_FOR_NEW_SESSION' } + | { type: 'START_STREAMING' } + | { type: 'STREAM_ERROR'; payload: string } + | { type: 'STREAM_FINISH'; payload?: string }; + +const initialTokenState: TokenState = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, +}; + +const initialState: StreamState = { + messages: [], + session: undefined, + chatState: ChatState.Idle, + sessionLoadError: undefined, + tokenState: initialTokenState, + notifications: [], +}; + +function streamReducer(state: StreamState, action: StreamAction): StreamState { + switch (action.type) { + case 'SET_MESSAGES': + return { ...state, messages: action.payload }; + + case 'SET_SESSION': + return { ...state, session: action.payload }; + + case 'SET_CHAT_STATE': + return { ...state, chatState: action.payload }; + + case 'SET_SESSION_LOAD_ERROR': + return { ...state, sessionLoadError: action.payload }; + + case 'SET_TOKEN_STATE': + return { ...state, tokenState: action.payload }; + + case 'SYNC_FROM_ACP_STORE': + return { + ...state, + session: action.payload.session, + messages: action.payload.messages, + tokenState: action.payload.tokenState, + notifications: action.payload.notifications, + chatState: action.payload.chatState, + sessionLoadError: action.payload.sessionLoadError, + }; + + case 'RESET_FOR_NEW_SESSION': + return { + ...state, + messages: [], + session: undefined, + sessionLoadError: undefined, + notifications: [], + chatState: ChatState.LoadingConversation, + }; + + case 'START_STREAMING': + return { + ...state, + chatState: ChatState.Streaming, + notifications: [], + }; + + case 'STREAM_ERROR': + return { + ...state, + sessionLoadError: action.payload, + chatState: ChatState.Idle, + }; + + case 'STREAM_FINISH': + return { + ...state, + sessionLoadError: action.payload, + chatState: ChatState.Idle, + }; + + default: + return state; + } +} + +function isClearCommand(message: string): boolean { + return message.trim() === '/clear'; +} + +function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Message { + return { + id: uuidv7(), + role: 'assistant', + created: Math.floor(Date.now() / 1000), + content: [ + { + type: 'systemNotification', + notificationType: 'creditsExhausted', + msg: error.message, + ...(error.url ? { data: { top_up_url: error.url } } : {}), + }, + ], + metadata: { userVisible: true, agentVisible: false }, + }; +} + +const i18n = defineMessages({ + notificationTitle: { + id: 'chat.notification.taskComplete.title', + defaultMessage: 'Goose finished the task.', + }, + notificationBody: { + id: 'chat.notification.taskComplete.body', + defaultMessage: 'Click here to bring Goose back into focus.', + }, +}); + +export function useAcpChatSession({ + sessionId, + onStreamFinish, + onSessionLoaded, +}: UseChatSessionParams): UseChatSessionResult { + const intl = useIntl(); + const [state, dispatch] = useReducer(streamReducer, initialState); + + // Ref to access latest state in callbacks (avoids stale closures) + const stateRef = useRef(state); + stateRef.current = state; + + useEffect(() => { + if (!sessionId) { + return; + } + + const snapshot = acpChatSessionStore.getSnapshot(sessionId); + if (snapshot) { + dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: snapshot }); + } + + return acpChatSessionStore.subscribe(sessionId, (nextSnapshot) => { + dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: nextSnapshot }); + }); + }, [sessionId]); + + useEffect(() => { + const handleSessionRenamed = (event: Event) => { + const { sessionId: renamedSessionId, newName, userInitiated } = ( + event as CustomEvent<{ sessionId: string; newName: string; userInitiated?: boolean }> + ).detail; + + if (renamedSessionId !== sessionId) { + return; + } + + const currentSession = stateRef.current.session; + if (!currentSession || (currentSession.name === newName && !userInitiated)) { + return; + } + + const updatedSession = { + ...currentSession, + name: newName, + ...(userInitiated && { user_set_name: true }), + }; + acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); + dispatch({ type: 'SET_SESSION', payload: updatedSession }); + }; + + window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); + return () => window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); + }, [sessionId]); + + const onFinish = useCallback( + async (error?: string): Promise => { + acpChatSessionStore.setSessionLoadError(sessionId, error); + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); + dispatch({ type: 'STREAM_FINISH', payload: error }); + + if (!error) { + try { + const [notificationsEnabled, anyWindowFocused] = await Promise.all([ + window.electron.getSetting('enableNotifications'), + window.electron.isAnyWindowFocused(), + ]); + if (notificationsEnabled === true && !anyWindowFocused) { + window.electron.showNotification({ + title: intl.formatMessage(i18n.notificationTitle), + body: intl.formatMessage(i18n.notificationBody), + }); + } + } catch (notifyError) { + console.warn('Failed to show task completion notification:', notifyError); + } + } + + const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/); + if (isNewSession) { + window.dispatchEvent(new CustomEvent(AppEvents.MESSAGE_STREAM_FINISHED)); + } + + onStreamFinish(); + }, + [intl, onStreamFinish, sessionId] + ); + + const submitToAcpSession = useCallback( + async (targetSessionId: string, userMessage: Message) => { + const promptAttemptId = uuidv7(); + acpChatSessionStore.startPromptAttempt(targetSessionId, promptAttemptId); + + try { + await acpPromptSession(targetSessionId, userMessage); + if (acpChatSessionStore.finishPromptAttemptIfCurrent(targetSessionId, promptAttemptId)) { + onFinish(); + } + } catch (error) { + const creditsExhaustedError = parseAcpCreditsExhaustedError(error); + if (creditsExhaustedError) { + if (!acpChatSessionStore.isCurrentPromptAttempt(targetSessionId, promptAttemptId)) { + return; + } + + const messages = [ + ...stateRef.current.messages, + createAcpCreditsExhaustedMessage(creditsExhaustedError), + ]; + acpChatSessionStore.setMessages(targetSessionId, messages); + dispatch({ + type: 'SET_MESSAGES', + payload: messages, + }); + if (acpChatSessionStore.finishPromptAttemptIfCurrent(targetSessionId, promptAttemptId)) { + onFinish(); + } + return; + } + + const submitError = 'Submit error: ' + errorMessage(error); + if ( + acpChatSessionStore.finishPromptAttemptIfCurrent( + targetSessionId, + promptAttemptId, + submitError + ) + ) { + onFinish(submitError); + } + } + }, + [onFinish] + ); + + // Load session on mount or sessionId change + useEffect(() => { + if (!sessionId) return; + + const cached = acpChatSessionStore.getSnapshot(sessionId); + if (cached?.session) { + dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: cached }); + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) + ); + onSessionLoaded?.(); + return; + } + + dispatch({ type: 'RESET_FOR_NEW_SESSION' }); + + let cancelled = false; + + (async () => { + try { + const response = await resumeAgent({ + body: { + session_id: sessionId, + load_model_and_extensions: true, + }, + throwOnError: true, + }); + + if (cancelled) { + return; + } + + const resumeData = response.data; + const loadedSession = resumeData?.session; + const extensionResults = resumeData?.extension_results; + + showExtensionLoadResults(extensionResults); + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) + ); + + if (loadedSession) { + const snapshot = acpChatSessionStore.setLoadedSession(sessionId, loadedSession); + dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: snapshot }); + } + + listApps({ + throwOnError: true, + query: { session_id: sessionId }, + }).catch((err) => { + console.warn('Failed to populate apps cache:', err); + }); + + onSessionLoaded?.(); + } catch (error) { + if (cancelled) return; + + const loadError = errorMessage(error); + acpChatSessionStore.setSessionLoadError(sessionId, loadError); + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); + dispatch({ type: 'STREAM_ERROR', payload: loadError }); + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionId, onSessionLoaded]); + + const handleSubmit = useCallback( + async (input: UserInput) => { + const { msg: userMessage, images } = input; + const currentState = stateRef.current; + + if ( + !currentState.session || + currentState.chatState === ChatState.LoadingConversation || + currentState.chatState === ChatState.Streaming || + currentState.chatState === ChatState.Thinking || + currentState.chatState === ChatState.Compacting + ) { + return; + } + + const hasExistingMessages = currentState.messages.length > 0; + const hasNewMessage = userMessage.trim().length > 0 || images.length > 0; + const clearsConversation = hasNewMessage && isClearCommand(userMessage); + + if (!hasNewMessage && !hasExistingMessages) { + return; + } + + // Emit session-created event for first message in a new session + if (!hasExistingMessages && hasNewMessage) { + window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); + } + + const newMessage = hasNewMessage + ? createUserMessage(userMessage, images) + : currentState.messages[currentState.messages.length - 1]; + const currentMessages = clearsConversation + ? [] + : hasNewMessage + ? [...currentState.messages, newMessage] + : [...currentState.messages]; + + if (clearsConversation || hasNewMessage) { + acpChatSessionStore.setMessages(sessionId, currentMessages); + dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); + } + + acpChatSessionStore.setChatState(sessionId, ChatState.Streaming); + dispatch({ type: 'START_STREAMING' }); + + await submitToAcpSession(sessionId, newMessage); + }, + [sessionId, submitToAcpSession] + ); + + const submitElicitationResponse = useCallback( + async (elicitationId: string, userData: Record) => { + const currentState = stateRef.current; + + if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) { + return false; + } + + if (!resolveAcpElicitationRequest(sessionId, elicitationId, userData)) { + console.error('No pending ACP elicitation request found', { sessionId, elicitationId }); + return false; + } + + return true; + }, + [sessionId] + ); + + const setRecipeUserParams = useCallback( + async (user_recipe_values: Record) => { + const currentState = stateRef.current; + + if (currentState.session) { + await updateSessionUserRecipeValues({ + path: { + session_id: sessionId, + }, + body: { + userRecipeValues: user_recipe_values, + }, + throwOnError: true, + }); + const updatedSession = { + ...currentState.session, + user_recipe_values, + }; + acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); + dispatch({ type: 'SET_SESSION', payload: updatedSession }); + } else { + acpChatSessionStore.setSessionLoadError( + sessionId, + "can't call setRecipeParams without a session" + ); + dispatch({ + type: 'SET_SESSION_LOAD_ERROR', + payload: "can't call setRecipeParams without a session", + }); + } + }, + [sessionId] + ); + + useEffect(() => { + if (state.session) { + updateFromSession({ + body: { + session_id: state.session.id, + }, + throwOnError: true, + }); + } + }, [state.session]); + + const stopStreaming = useCallback(() => { + const storedPromptAttemptId = acpChatSessionStore.getSnapshot(sessionId)?.activePromptAttemptId; + const hasStoredAcpPrompt = + storedPromptAttemptId !== null && storedPromptAttemptId !== undefined; + + if (hasStoredAcpPrompt) { + acpChatSessionStore.clearActivePromptAttempt(sessionId); + cancelAcpPermissionRequestsForSession(sessionId); + cancelAcpElicitationRequestsForSession(sessionId); + acpCancelPrompt(sessionId).catch((e) => { + console.warn('Failed to cancel ACP prompt:', e); + }); + } + + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); + dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); + }, [sessionId]); + + const onMessageUpdate = useCallback( + async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => { + const currentState = stateRef.current; + + acpChatSessionStore.setChatState(sessionId, ChatState.Thinking); + dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking }); + + try { + const message = currentState.messages.find((m) => m.id === messageId); + + if (!message) { + throw new Error(`Message with id ${messageId} not found in current messages`); + } + + if (editType === 'fork') { + const targetSessionId = await acpForkSession(sessionId, message.created); + + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); + dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); + const event = new CustomEvent(AppEvents.SESSION_FORKED, { + detail: { + newSessionId: targetSessionId, + shouldStartAgent: true, + editedMessage: newContent, + }, + }); + window.dispatchEvent(event); + window.electron.logInfo(`Dispatched session-forked event for session ${targetSessionId}`); + } else { + await acpTruncateSessionConversation(sessionId, message.created); + + const truncatedMessages = currentState.messages.filter( + (m) => m.created < message.created + ); + const updatedUserMessage = createUserMessage(newContent); + + for (const content of message.content) { + if (content.type === 'image') { + updatedUserMessage.content.push(content); + } + } + + const messagesForUI = [...truncatedMessages, updatedUserMessage]; + acpChatSessionStore.setMessages(sessionId, messagesForUI); + acpChatSessionStore.setChatState(sessionId, ChatState.Streaming); + dispatch({ type: 'SET_MESSAGES', payload: messagesForUI }); + dispatch({ type: 'START_STREAMING' }); + + await submitToAcpSession(sessionId, updatedUserMessage); + } + } catch (error) { + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); + dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); + const errorMsg = errorMessage(error); + console.error('Failed to edit message:', error); + const { toastError } = await import('../toasts'); + toastError({ + title: 'Failed to edit message', + msg: errorMsg, + }); + } + }, + [sessionId, submitToAcpSession] + ); + + const setChatState = useCallback( + (newState: ChatState) => { + acpChatSessionStore.setChatState(sessionId, newState); + dispatch({ type: 'SET_CHAT_STATE', payload: newState }); + }, + [sessionId] + ); + + const updateSession = useCallback( + (updater: (session: Session) => Session) => { + const cached = acpChatSessionStore.getSnapshot(sessionId); + const currentSession = stateRef.current.session ?? cached?.session; + if (!currentSession) return; + + const nextSession = updater(currentSession); + acpChatSessionStore.setSessionMetadata(sessionId, nextSession); + dispatch({ type: 'SET_SESSION', payload: nextSession }); + }, + [sessionId] + ); + + const cached = acpChatSessionStore.getSnapshot(sessionId); + const maybe_cached_messages = state.session ? state.messages : cached?.messages || []; + const maybe_cached_session = state.session ?? cached?.session; + + const notificationsMap = useMemo(() => { + return state.notifications.reduce((map, notification) => { + const key = notification.request_id; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(notification); + return map; + }, new Map()); + }, [state.notifications]); + + return { + sessionLoadError: state.sessionLoadError, + messages: maybe_cached_messages, + session: maybe_cached_session, + chatState: state.chatState, + setChatState, + updateSession, + handleSubmit, + submitElicitationResponse, + stopStreaming, + setRecipeUserParams, + tokenState: state.tokenState, + notifications: notificationsMap, + pauseQueueOnStop: true, + onMessageUpdate, + }; +} diff --git a/ui/desktop/src/hooks/useChatSession.ts b/ui/desktop/src/hooks/useChatSession.ts new file mode 100644 index 000000000000..f1f8ba1ca4a5 --- /dev/null +++ b/ui/desktop/src/hooks/useChatSession.ts @@ -0,0 +1,8 @@ +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { useAcpChatSession } from './useAcpChatSession'; +import { useChatStream } from './useChatStream'; +import type { UseChatSessionHook } from './useChatSessionTypes'; + +export const useChatSession: UseChatSessionHook = USE_ACP_CHAT + ? useAcpChatSession + : useChatStream; diff --git a/ui/desktop/src/hooks/useChatSessionTypes.ts b/ui/desktop/src/hooks/useChatSessionTypes.ts new file mode 100644 index 000000000000..15822a2b3ff9 --- /dev/null +++ b/ui/desktop/src/hooks/useChatSessionTypes.ts @@ -0,0 +1,35 @@ +import type { Message, Session, TokenState } from '../api'; +import type { ChatState } from '../types/chatState'; +import type { NotificationEvent, UserInput } from '../types/message'; + +export interface UseChatSessionParams { + sessionId: string; + onStreamFinish: () => void; + onSessionLoaded?: () => void; +} + +export interface UseChatSessionResult { + session?: Session; + messages: Message[]; + chatState: ChatState; + setChatState: (state: ChatState) => void; + updateSession: (updater: (session: Session) => Session) => void; + handleSubmit: (input: UserInput) => Promise; + submitElicitationResponse: ( + elicitationId: string, + userData: Record + ) => Promise; + setRecipeUserParams: (values: Record) => Promise; + stopStreaming: () => void; + sessionLoadError?: string; + tokenState: TokenState; + notifications: Map; + pauseQueueOnStop: boolean; + onMessageUpdate: ( + messageId: string, + newContent: string, + editType?: 'fork' | 'edit' + ) => Promise; +} + +export type UseChatSessionHook = (params: UseChatSessionParams) => UseChatSessionResult; diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index f67390a76606..6116cf4d6951 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -29,35 +29,12 @@ import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; import { maybeHandlePlatformEvent } from '../utils/platform_events'; import { useSessionEvents, type SessionEvent } from './useSessionEvents'; +import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes'; const resultsCache = new Map(); -interface UseChatStreamProps { - sessionId: string; - onStreamFinish: () => void; - onSessionLoaded?: () => void; -} - -interface UseChatStreamReturn { - session?: Session; - messages: Message[]; - chatState: ChatState; - setChatState: (state: ChatState) => void; - handleSubmit: (input: UserInput) => Promise; - submitElicitationResponse: ( - elicitationId: string, - userData: Record - ) => Promise; - setRecipeUserParams: (values: Record) => Promise; - stopStreaming: () => void; - sessionLoadError?: string; - tokenState: TokenState; - notifications: Map; - onMessageUpdate: ( - messageId: string, - newContent: string, - editType?: 'fork' | 'edit' - ) => Promise; +export function clearSessionCache(sessionId: string): void { + resultsCache.delete(sessionId); } interface StreamState { @@ -413,7 +390,7 @@ export function useChatStream({ sessionId, onStreamFinish, onSessionLoaded, -}: UseChatStreamProps): UseChatStreamReturn { +}: UseChatSessionParams): UseChatSessionResult { const intl = useIntl(); const [state, dispatch] = useReducer(streamReducer, initialState); @@ -499,10 +476,16 @@ export function useChatStream({ throwOnError: true, }); if (response.data?.name) { + const nextMessageCount = + response.data.message_count ?? currentState.session?.message_count; dispatch({ type: 'SET_SESSION', payload: currentState.session - ? { ...currentState.session, name: response.data.name } + ? { + ...currentState.session, + name: response.data.name, + ...(nextMessageCount !== undefined && { message_count: nextMessageCount }), + } : undefined, }); window.dispatchEvent( @@ -896,21 +879,32 @@ export function useChatStream({ }); const currentState = stateRef.current; const currentName = currentState.session?.name; + const currentMessageCount = currentState.session?.message_count; const newName = response.data?.name; + const newMessageCount = response.data?.message_count; + const hasNameChange = Boolean(newName && newName !== currentName); + const hasMessageCountChange = + newMessageCount !== undefined && newMessageCount !== currentMessageCount; - if (newName && newName !== currentName) { + if (newName && (hasNameChange || hasMessageCountChange)) { dispatch({ type: 'SET_SESSION', payload: currentState.session - ? { ...currentState.session, name: newName } + ? { + ...currentState.session, + name: newName, + ...(newMessageCount !== undefined && { message_count: newMessageCount }), + } : undefined, }); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId, newName }, - }) - ); - return; + if (hasNameChange) { + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_RENAMED, { + detail: { sessionId, newName }, + }) + ); + return; + } } } catch { // Silently continue polling @@ -952,7 +946,7 @@ export function useChatStream({ const currentState = stateRef.current; if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) { - return; + return true; } // An elicitation response unblocks an in-flight tool call on the original @@ -970,8 +964,10 @@ export function useChatStream({ }, throwOnError: true, }); + return true; } catch (error) { onFinish('Submit error: ' + errorMessage(error)); + return true; } }, [sessionId, onFinish] @@ -1137,6 +1133,24 @@ export function useChatStream({ dispatch({ type: 'SET_CHAT_STATE', payload: newState }); }, []); + const updateSession = useCallback( + (updater: (session: Session) => Session) => { + const cached = resultsCache.get(sessionId); + const currentSession = stateRef.current.session ?? cached?.session; + if (!currentSession) return; + + const nextSession = updater(currentSession); + dispatch({ type: 'SET_SESSION', payload: nextSession }); + resultsCache.set(sessionId, { + session: nextSession, + messages: stateRef.current.messages.length + ? stateRef.current.messages + : cached?.messages || [], + }); + }, + [sessionId] + ); + const cached = resultsCache.get(sessionId); const maybe_cached_messages = state.session ? state.messages : cached?.messages || []; const maybe_cached_session = state.session ?? cached?.session; @@ -1158,12 +1172,14 @@ export function useChatStream({ session: maybe_cached_session, chatState: state.chatState, setChatState, + updateSession, handleSubmit, submitElicitationResponse, stopStreaming, setRecipeUserParams, tokenState: state.tokenState, notifications: notificationsMap, + pauseQueueOnStop: false, onMessageUpdate, }; } diff --git a/ui/desktop/src/hooks/useNavigationSessions.ts b/ui/desktop/src/hooks/useNavigationSessions.ts index 23441d16d3a3..6977cefa8868 100644 --- a/ui/desktop/src/hooks/useNavigationSessions.ts +++ b/ui/desktop/src/hooks/useNavigationSessions.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { getSession } from '../api'; import { useChatContext } from '../contexts/ChatContext'; -import { shouldShowNewChatTitle } from '../sessions'; +import { getSessionDisplayName } from '../sessions'; import { AppEvents } from '../constants/events'; import type { Session } from '../api'; import { acpListRecentSessions, type SessionListItem } from '../acp/sessions'; @@ -146,11 +146,16 @@ export function useNavigationSessions() { }; const handleSessionRenamed = (event: Event) => { - const { sessionId, newName } = (event as CustomEvent<{ sessionId: string; newName: string }>) - .detail; + const { sessionId, newName, userInitiated } = ( + event as CustomEvent<{ sessionId: string; newName: string; userInitiated?: boolean }> + ).detail; setRecentSessions((prev) => - prev.map((session) => (session.id === sessionId ? { ...session, name: newName } : session)) + prev.map((session) => + session.id === sessionId + ? { ...session, name: newName, ...(userInitiated && { user_set_name: true }) } + : session + ) ); }; @@ -195,16 +200,3 @@ export function useNavigationSessions() { handleSessionClick, }; } - -export function getSessionDisplayName(session: Session): string { - if (session.user_set_name) { - return session.name; - } - if (session.recipe?.title) { - return session.recipe.title; - } - if (shouldShowNewChatTitle(session)) { - return 'New Chat'; - } - return session.name; -} diff --git a/ui/desktop/src/i18n/i18n.test.ts b/ui/desktop/src/i18n/i18n.test.ts index ec1deaf9b9d3..9159abb2d943 100644 --- a/ui/desktop/src/i18n/i18n.test.ts +++ b/ui/desktop/src/i18n/i18n.test.ts @@ -73,6 +73,22 @@ describe('getLocale', () => { expect(getLocale()).toEqual({ locale: 'tr', messageLocale: 'tr' }); }); + it('supports Korean from navigator.languages', () => { + vi.stubGlobal('navigator', { languages: ['ko-KR'] }); + expect(getLocale()).toEqual({ locale: 'ko-KR', messageLocale: 'ko' }); + }); + + it('supports explicit Korean locale', () => { + mockAppConfig({ GOOSE_LOCALE: 'ko' }); + vi.stubGlobal('navigator', { languages: ['xx-XX'] }); + expect(getLocale()).toEqual({ locale: 'ko', messageLocale: 'ko' }); + }); + + it('supports POSIX-style Korean locale from GOOSE_LOCALE', () => { + mockAppConfig({ GOOSE_LOCALE: 'ko_KR' }); + vi.stubGlobal('navigator', { languages: ['xx-XX'] }); + expect(getLocale()).toEqual({ locale: 'ko-KR', messageLocale: 'ko' }); + }); it('supports Japanese from navigator.languages', () => { vi.stubGlobal('navigator', { languages: ['ja-JP'] }); @@ -96,6 +112,17 @@ describe('getLocale', () => { expect(getLocale()).toEqual({ locale: 'hi', messageLocale: 'hi' }); }); + it('supports Spanish from navigator.languages', () => { + vi.stubGlobal('navigator', { languages: ['es-ES'] }); + expect(getLocale()).toEqual({ locale: 'es-ES', messageLocale: 'es' }); + }); + + it('supports explicit Spanish locale', () => { + mockAppConfig({ GOOSE_LOCALE: 'es' }); + vi.stubGlobal('navigator', { languages: ['xx-XX'] }); + expect(getLocale()).toEqual({ locale: 'es', messageLocale: 'es' }); + }); + it('falls back to base language when locale tag is invalid BCP 47', () => { // "en-" is not a valid BCP 47 tag and would cause RangeError in Intl APIs mockAppConfig({ GOOSE_LOCALE: 'en-' }); diff --git a/ui/desktop/src/i18n/index.ts b/ui/desktop/src/i18n/index.ts index 662b4b3eb11c..198cc350ce1b 100644 --- a/ui/desktop/src/i18n/index.ts +++ b/ui/desktop/src/i18n/index.ts @@ -2,7 +2,7 @@ * Locale detection and message loading for the i18n system. * * Locale resolution order: - * 1. GOOSE_LOCALE config value (set via environment variable, passed through appConfig) + * 1. GOOSE_LOCALE config value (manual setting or environment variable, passed through appConfig) * 2. navigator.languages (full accept-language list from OS/browser) * 3. "en" (fallback) * @@ -15,7 +15,9 @@ export { defineMessages, useIntl } from 'react-intl'; /** The set of locales that have translation catalogs. */ -const SUPPORTED_LOCALES = new Set(['en', 'hi', 'ru', 'tr', 'zh-CN', 'ja']); +export const SUPPORTED_LOCALES = ['en', 'es', 'hi', 'ja', 'ko', 'ru', 'tr', 'zh-CN'] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; +const SUPPORTED_LOCALE_SET = new Set(SUPPORTED_LOCALES); /** * Map Simplified Chinese aliases (zh, zh-Hans*, zh-SG, zh-MY) to "zh-CN". @@ -61,12 +63,12 @@ export function getLocale(): { locale: string; messageLocale: string } { const tag = resolveChineseAlias(normalized); // Exact match first - if (SUPPORTED_LOCALES.has(tag)) return { locale: tag, messageLocale: tag }; + if (SUPPORTED_LOCALE_SET.has(tag)) return { locale: tag, messageLocale: tag }; // Try base language (e.g. "pt-BR" → "pt") for the catalog, but keep the // full regional tag for formatting so date/number output respects the region. const base = tag.split('-')[0]; - if (SUPPORTED_LOCALES.has(base)) { + if (SUPPORTED_LOCALE_SET.has(base)) { // Validate the full tag is a well-formed BCP 47 locale before using it // for formatting. Invalid tags (e.g. "en-") would cause RangeError in // Intl APIs, so fall back to the base language in that case. diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index e7d5a56968fd..a87fa4f46e47 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -11,6 +11,9 @@ "announcementModal.gotIt": { "defaultMessage": "Got it!" }, + "appLayout.collapseNavigation": { + "defaultMessage": "Collapse navigation" + }, "appLayout.openNavigation": { "defaultMessage": "Open navigation" }, @@ -992,6 +995,9 @@ "elicitationRequest.submit": { "defaultMessage": "Submit" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "Information submitted" }, @@ -1488,7 +1494,7 @@ "defaultMessage": "Loading variants..." }, "huggingFaceModelSearch.noGgufModels": { - "defaultMessage": "No GGUF models found for this query." + "defaultMessage": "No compatible local models found for this query." }, "huggingFaceModelSearch.recommended": { "defaultMessage": "Recommended" @@ -1506,7 +1512,7 @@ "defaultMessage": "Search returned no data." }, "huggingFaceModelSearch.searchPlaceholder": { - "defaultMessage": "Search for GGUF models..." + "defaultMessage": "Search for local models..." }, "huggingFaceModelSearch.tooLarge": { "defaultMessage": "May not fit in memory ({size} model, {available} available)" @@ -1845,11 +1851,17 @@ "defaultMessage": "Delete this model? You can re-download it later." }, "localInferenceSettings.description": { - "defaultMessage": "Download and manage local LLM models for inference without API keys. Search HuggingFace for any GGUF model or use the featured picks below." + "defaultMessage": "Download and manage local LLM models for inference without API keys. Search HuggingFace for GGUF or MLX models, or use the featured picks below." + }, + "localInferenceSettings.dismiss": { + "defaultMessage": "Dismiss" }, "localInferenceSettings.download": { "defaultMessage": "Download" }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "Download cancelled" + }, "localInferenceSettings.downloadFailed": { "defaultMessage": "Download failed" }, @@ -1883,6 +1895,9 @@ "localInferenceSettings.remaining": { "defaultMessage": "{time} remaining" }, + "localInferenceSettings.retry": { + "defaultMessage": "Retry" + }, "localInferenceSettings.showAllFeatured": { "defaultMessage": "Show all featured ({count} more)" }, @@ -2447,9 +2462,6 @@ "navigationPanel.chats": { "defaultMessage": "Chats" }, - "navigationPanel.collapseSidebar": { - "defaultMessage": "Collapse sidebar" - }, "navigationPanel.noChats": { "defaultMessage": "No recent chats" }, @@ -3737,6 +3749,72 @@ "securityToggle.warpNotice": { "defaultMessage": "Command injection detection works best when connected to WARP (required to reach the classification service)." }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "Session actions" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "Cancel" + }, + "sessionActionsHeader.close": { + "defaultMessage": "Close" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "Session JSON copied" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "Text copied" + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "Copy JSON" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "Copy text" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "Failed to duplicate session: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "Duplicate session" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "Session duplicated" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "Text value" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "Failed to load session JSON: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "Session JSON" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "Loading JSON..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "Failed to rename session: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "Enter session name" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "Rename session" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "Rename Session" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "Session renamed" + }, + "sessionActionsHeader.save": { + "defaultMessage": "Save" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "Saving..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "View session JSON" + }, "sessionHistory.cancel": { "defaultMessage": "Cancel" }, @@ -4037,6 +4115,39 @@ "settings.help.title": { "defaultMessage": "Help & feedback" }, + "settings.language.description": { + "defaultMessage": "Choose the display language for goose" + }, + "settings.language.english": { + "defaultMessage": "English" + }, + "settings.language.hindi": { + "defaultMessage": "Hindi" + }, + "settings.language.japanese": { + "defaultMessage": "Japanese" + }, + "settings.language.korean": { + "defaultMessage": "Korean" + }, + "settings.language.russian": { + "defaultMessage": "Russian" + }, + "settings.language.spanish": { + "defaultMessage": "Spanish" + }, + "settings.language.systemDefault": { + "defaultMessage": "System Default" + }, + "settings.language.title": { + "defaultMessage": "Language" + }, + "settings.language.turkish": { + "defaultMessage": "Turkish" + }, + "settings.language.zhCN": { + "defaultMessage": "Chinese (Simplified)" + }, "settings.menuBarIcon.description": { "defaultMessage": "Show ApeMind Agent in the menu bar" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json new file mode 100644 index 000000000000..deb87b37c440 --- /dev/null +++ b/ui/desktop/src/i18n/messages/es.json @@ -0,0 +1,4862 @@ +{ + "alertBox.autoCompactAt": { + "defaultMessage": "Compactar automáticamente al" + }, + "alertBox.compactNow": { + "defaultMessage": "Compactar ahora" + }, + "alertBox.failedToSaveThreshold": { + "defaultMessage": "No se pudo guardar el umbral: {error}" + }, + "announcementModal.gotIt": { + "defaultMessage": "¡Entendido!" + }, + "appLayout.collapseNavigation": { + "defaultMessage": "Contraer navegación" + }, + "appLayout.openNavigation": { + "defaultMessage": "Abrir navegación" + }, + "appsView.customApp": { + "defaultMessage": "App personalizada" + }, + "appsView.description": { + "defaultMessage": "Aplicaciones de tus servidores MCP y Apps creadas por goose. Puedes pedirle que cree nuevas apps desde la interfaz de chat y aparecerán aquí." + }, + "appsView.errorLoading": { + "defaultMessage": "Error al cargar las apps: {error}" + }, + "appsView.importApp": { + "defaultMessage": "Importar App" + }, + "appsView.launch": { + "defaultMessage": "Abrir" + }, + "appsView.loading": { + "defaultMessage": "Cargando apps..." + }, + "appsView.noAppsDescription": { + "defaultMessage": "Abre un chat y pídele a goose la app que quieras tener. Puede crearte una y aparecerá aquí. O si alguien compartió una app, puedes importarla con el botón de arriba." + }, + "appsView.noAppsTitle": { + "defaultMessage": "No hay apps disponibles" + }, + "appsView.retry": { + "defaultMessage": "Reintentar" + }, + "appsView.title": { + "defaultMessage": "Apps" + }, + "authSettings.activeProviderWarning": { + "defaultMessage": "Este es el proveedor activo. Las nuevas solicitudes pueden fallar hasta que configures otra credencial." + }, + "authSettings.cancel": { + "defaultMessage": "Cancelar" + }, + "authSettings.delete": { + "defaultMessage": "Eliminar" + }, + "authSettings.deleteCredential": { + "defaultMessage": "Eliminar credencial" + }, + "authSettings.deleteMessage": { + "defaultMessage": "¿Eliminar la credencial {name} de {provider}?" + }, + "authSettings.deleteTitle": { + "defaultMessage": "Eliminar credencial" + }, + "authSettings.deleted": { + "defaultMessage": "Credencial eliminada" + }, + "authSettings.description": { + "defaultMessage": "Gestiona las credenciales de proveedores que goose guarda localmente." + }, + "authSettings.empty": { + "defaultMessage": "No se encontraron credenciales de proveedores guardadas localmente." + }, + "authSettings.expiresAt": { + "defaultMessage": "Vence {date}" + }, + "authSettings.failedToConfigure": { + "defaultMessage": "No se pudo configurar la credencial: {error}" + }, + "authSettings.failedToDelete": { + "defaultMessage": "No se pudo eliminar la credencial: {error}" + }, + "authSettings.failedToLoad": { + "defaultMessage": "No se pudieron cargar las credenciales de proveedores" + }, + "authSettings.loading": { + "defaultMessage": "Cargando credenciales..." + }, + "authSettings.reauthorize": { + "defaultMessage": "Volver a autorizar" + }, + "authSettings.signIn": { + "defaultMessage": "Iniciar sesión" + }, + "authSettings.signedIn": { + "defaultMessage": "Credencial configurada" + }, + "authSettings.storageProviderCache": { + "defaultMessage": "Caché del proveedor" + }, + "authSettings.storageSecretStore": { + "defaultMessage": "Almacén de secretos" + }, + "authSettings.title": { + "defaultMessage": "Credenciales de proveedores" + }, + "backButton.back": { + "defaultMessage": "Atrás" + }, + "baseChat.failedToLoadSession": { + "defaultMessage": "No se pudo cargar la sesión" + }, + "baseChat.goHome": { + "defaultMessage": "Ir al inicio" + }, + "baseChat.noSession": { + "defaultMessage": "Sin sesión" + }, + "baseChat.recipeCreatedMessage": { + "defaultMessage": "\"{title}\" se guardó y está lista para usar." + }, + "baseChat.recipeCreatedTitle": { + "defaultMessage": "¡Receta creada con éxito!" + }, + "bottomMenuExtensionSelection.extensionToggleError": { + "defaultMessage": "Error al activar/desactivar la extensión" + }, + "bottomMenuExtensionSelection.extensionUpdated": { + "defaultMessage": "Extensión actualizada" + }, + "bottomMenuExtensionSelection.extensionWillBeDisabled": { + "defaultMessage": "{name} se desactivará en los chats nuevos" + }, + "bottomMenuExtensionSelection.extensionWillBeEnabled": { + "defaultMessage": "{name} se activará en los chats nuevos" + }, + "bottomMenuExtensionSelection.extensionsForNewChats": { + "defaultMessage": "Extensiones para chats nuevos" + }, + "bottomMenuExtensionSelection.extensionsForThisSession": { + "defaultMessage": "Extensiones para esta sesión de chat" + }, + "bottomMenuExtensionSelection.manageExtensions": { + "defaultMessage": "administrar extensiones" + }, + "bottomMenuExtensionSelection.noActiveSession": { + "defaultMessage": "No se encontró ninguna sesión activa. Inicia primero una sesión de chat." + }, + "bottomMenuExtensionSelection.noExtensionsAvailable": { + "defaultMessage": "no hay extensiones disponibles" + }, + "bottomMenuExtensionSelection.noExtensionsFound": { + "defaultMessage": "no se encontraron extensiones" + }, + "bottomMenuExtensionSelection.searchExtensions": { + "defaultMessage": "buscar extensiones..." + }, + "cardButtons.configure": { + "defaultMessage": "Configurar" + }, + "cardButtons.launch": { + "defaultMessage": "Abrir" + }, + "chat.notification.taskComplete.body": { + "defaultMessage": "Haz clic aquí para volver a poner el foco en Goose." + }, + "chat.notification.taskComplete.title": { + "defaultMessage": "Goose terminó la tarea." + }, + "chatInput.contextWindow": { + "defaultMessage": "Ventana de contexto" + }, + "chatInput.createRecipeFromSession": { + "defaultMessage": "Crear Receta desde la sesión" + }, + "chatInput.dictationError": { + "defaultMessage": "Error de dictado" + }, + "chatInput.failedToReadImage": { + "defaultMessage": "No se pudo leer el archivo de imagen" + }, + "chatInput.navigationShortcut": { + "defaultMessage": "{prefix}↑/{prefix}↓ para navegar entre mensajes" + }, + "chatInput.processingDroppedFiles": { + "defaultMessage": "Procesando archivos soltados..." + }, + "chatInput.recording": { + "defaultMessage": "Grabando..." + }, + "chatInput.removeFile": { + "defaultMessage": "Quitar archivo" + }, + "chatInput.removeImage": { + "defaultMessage": "Quitar imagen" + }, + "chatInput.restartingSession": { + "defaultMessage": "Reiniciando sesión..." + }, + "chatInput.send": { + "defaultMessage": "Enviar" + }, + "chatInput.tooManyTools": { + "defaultMessage": "Demasiadas herramientas pueden degradar el rendimiento. Cantidad de herramientas: {toolCount} (recomendado: {recommended})" + }, + "chatInput.transcribing": { + "defaultMessage": "Transcribiendo..." + }, + "chatInput.typeMessage": { + "defaultMessage": "Escribe un mensaje para enviar" + }, + "chatInput.unknownType": { + "defaultMessage": "Tipo desconocido" + }, + "chatInput.viewEditRecipe": { + "defaultMessage": "Ver/Editar Receta" + }, + "chatInput.viewExtensions": { + "defaultMessage": "Ver extensiones" + }, + "chatInput.waitingForImages": { + "defaultMessage": "Esperando a que se guarden las imágenes..." + }, + "chatSettings.modeDescription": { + "defaultMessage": "Configura cómo interactúa Goose con las herramientas y las extensiones" + }, + "chatSettings.modeTitle": { + "defaultMessage": "Modo" + }, + "chatSettings.responseStylesDescription": { + "defaultMessage": "Elige cómo debe formatear y dar estilo Goose a sus respuestas" + }, + "chatSettings.responseStylesTitle": { + "defaultMessage": "Estilos de respuesta" + }, + "configSettings.configReset": { + "defaultMessage": "Configuración restablecida" + }, + "configSettings.configResetMsg": { + "defaultMessage": "Se revirtieron todos los cambios" + }, + "configSettings.configUpdated": { + "defaultMessage": "Configuración actualizada" + }, + "configSettings.configUpdatedMsg": { + "defaultMessage": "Se guardó \"{name}\" con éxito" + }, + "configSettings.configurationEditor": { + "defaultMessage": "Editor de configuración" + }, + "configSettings.description": { + "defaultMessage": "Edita los ajustes de configuración de goose" + }, + "configSettings.descriptionWithProvider": { + "defaultMessage": "Edita los ajustes de configuración de goose (ajustes actuales de {provider})" + }, + "configSettings.done": { + "defaultMessage": "Listo" + }, + "configSettings.editConfiguration": { + "defaultMessage": "Editar configuración" + }, + "configSettings.enterValue": { + "defaultMessage": "Ingresa {name}" + }, + "configSettings.noSettings": { + "defaultMessage": "No se encontraron ajustes de configuración." + }, + "configSettings.resetChanges": { + "defaultMessage": "Restablecer cambios" + }, + "configSettings.saveFailed": { + "defaultMessage": "No se pudo guardar" + }, + "configSettings.saveFailedMsg": { + "defaultMessage": "No se pudo guardar \"{name}\"" + }, + "configSettings.saving": { + "defaultMessage": "Guardando..." + }, + "configSettings.title": { + "defaultMessage": "Configuración" + }, + "configureApproveMode.cancel": { + "defaultMessage": "Cancelar" + }, + "configureApproveMode.description": { + "defaultMessage": "La aprobación se puede aplicar a todas las solicitudes de herramientas o determinar qué acciones pueden requerir integración" + }, + "configureApproveMode.manualApproval": { + "defaultMessage": "Aprobación manual" + }, + "configureApproveMode.manualApprovalDescription": { + "defaultMessage": "Todas las herramientas, extensiones y modificaciones de archivos requerirán aprobación humana" + }, + "configureApproveMode.save": { + "defaultMessage": "Guardar" + }, + "configureApproveMode.saving": { + "defaultMessage": "Guardando..." + }, + "configureApproveMode.smartApproval": { + "defaultMessage": "Aprobación inteligente" + }, + "configureApproveMode.smartApprovalDescription": { + "defaultMessage": "Determina de forma inteligente qué acciones necesitan aprobación según el nivel de riesgo" + }, + "configureApproveMode.title": { + "defaultMessage": "Configurar modo de aprobación" + }, + "confirmationModal.defaultCancel": { + "defaultMessage": "No" + }, + "confirmationModal.defaultConfirm": { + "defaultMessage": "Sí" + }, + "confirmationModal.processing": { + "defaultMessage": "Procesando..." + }, + "conversationLimitsDropdown.conversationLimits": { + "defaultMessage": "Límites de conversación" + }, + "conversationLimitsDropdown.maxTurns": { + "defaultMessage": "Turnos máximos" + }, + "conversationLimitsDropdown.maxTurnsDescription": { + "defaultMessage": "Turnos máximos del agente antes de que Goose pida la intervención del usuario" + }, + "costTracker.costUnavailable": { + "defaultMessage": "Datos de costo no disponibles para {model} ({inputTokens} de entrada, {outputTokens} tokens de salida)" + }, + "costTracker.inputOutputTooltip": { + "defaultMessage": "Entrada: {inputTokens} tokens ({inputCost}) | Salida: {outputTokens} tokens ({outputCost})" + }, + "costTracker.pricingUnavailable": { + "defaultMessage": "Datos de precios no disponibles para {model}" + }, + "costTracker.totalSessionCost": { + "defaultMessage": "Costo total de la sesión: {cost}" + }, + "createEditRecipe.clickToGenerateDeeplink": { + "defaultMessage": "Haz clic para generar el deeplink" + }, + "createEditRecipe.close": { + "defaultMessage": "Cerrar" + }, + "createEditRecipe.copied": { + "defaultMessage": "¡Copiado!" + }, + "createEditRecipe.copy": { + "defaultMessage": "Copiar" + }, + "createEditRecipe.copyLinkDescription": { + "defaultMessage": "Copia este enlace para compartirlo con amigos o pégalo directamente en Chrome para abrirlo" + }, + "createEditRecipe.createRecipeTitle": { + "defaultMessage": "Crear Receta" + }, + "createEditRecipe.createSubtitle": { + "defaultMessage": "Crea una nueva receta para definir el comportamiento y las capacidades del agente en sesiones de chat reutilizables." + }, + "createEditRecipe.editSubtitle": { + "defaultMessage": "Puedes editar la receta de abajo para cambiar el comportamiento del agente en una nueva sesión." + }, + "createEditRecipe.generatingDeeplink": { + "defaultMessage": "Generando deeplink..." + }, + "createEditRecipe.learnMore": { + "defaultMessage": "Más información" + }, + "createEditRecipe.recipeSavedAndLaunchedMsg": { + "defaultMessage": "Receta guardada y abierta con éxito" + }, + "createEditRecipe.recipeSavedMsg": { + "defaultMessage": "Receta guardada con éxito" + }, + "createEditRecipe.saveAndRunFailed": { + "defaultMessage": "No se pudo guardar y ejecutar" + }, + "createEditRecipe.saveAndRunFailedMsg": { + "defaultMessage": "No se pudo guardar y ejecutar la receta: {error}" + }, + "createEditRecipe.saveAndRunRecipe": { + "defaultMessage": "Guardar y ejecutar Receta" + }, + "createEditRecipe.saveFailed": { + "defaultMessage": "No se pudo guardar" + }, + "createEditRecipe.saveFailedMsg": { + "defaultMessage": "No se pudo guardar la receta: {error}" + }, + "createEditRecipe.saveRecipe": { + "defaultMessage": "Guardar Receta" + }, + "createEditRecipe.saving": { + "defaultMessage": "Guardando..." + }, + "createEditRecipe.validationFailed": { + "defaultMessage": "La validación falló" + }, + "createEditRecipe.validationMsg": { + "defaultMessage": "Completa todos los campos obligatorios y asegúrate de que el Schema JSON sea válido." + }, + "createEditRecipe.viewEditRecipeTitle": { + "defaultMessage": "Ver/editar receta" + }, + "createRecipeFromSession.analyzingTitle": { + "defaultMessage": "Analizando tu conversación" + }, + "createRecipeFromSession.cancel": { + "defaultMessage": "Cancelar" + }, + "createRecipeFromSession.createAndRunRecipe": { + "defaultMessage": "Crear y ejecutar Receta" + }, + "createRecipeFromSession.createRecipe": { + "defaultMessage": "Crear Receta" + }, + "createRecipeFromSession.creating": { + "defaultMessage": "Creando..." + }, + "createRecipeFromSession.extractingInsights": { + "defaultMessage": "Extrayendo ideas de tu chat" + }, + "createRecipeFromSession.failedToCreateDefaultMsg": { + "defaultMessage": "Ocurrió un error inesperado al crear la receta. Inténtalo de nuevo." + }, + "createRecipeFromSession.failedToCreateTitle": { + "defaultMessage": "No se pudo crear la receta" + }, + "createRecipeFromSession.stageComplete": { + "defaultMessage": "¡Completado!" + }, + "createRecipeFromSession.stageExtracting": { + "defaultMessage": "Extrayendo los temas principales..." + }, + "createRecipeFromSession.stageFinalizing": { + "defaultMessage": "Finalizando los detalles..." + }, + "createRecipeFromSession.stageGenerating": { + "defaultMessage": "Generando la estructura de la receta..." + }, + "createRecipeFromSession.stageIdentifying": { + "defaultMessage": "Identificando patrones clave..." + }, + "createRecipeFromSession.stageReading": { + "defaultMessage": "Leyendo tu conversación..." + }, + "createRecipeFromSession.subtitle": { + "defaultMessage": "Crea una receta reutilizable a partir de tu conversación actual." + }, + "createRecipeFromSession.title": { + "defaultMessage": "Crear Receta desde la sesión" + }, + "createSubRecipeInline.cancel": { + "defaultMessage": "Cancelar" + }, + "createSubRecipeInline.closeModal": { + "defaultMessage": "Cerrar la ventana de crear subreceta" + }, + "createSubRecipeInline.createAndAdd": { + "defaultMessage": "Crear y agregar Subreceta" + }, + "createSubRecipeInline.createdSuccess": { + "defaultMessage": "Subreceta creada con éxito" + }, + "createSubRecipeInline.creating": { + "defaultMessage": "Creando..." + }, + "createSubRecipeInline.duplicateName": { + "defaultMessage": "Nombre duplicado" + }, + "createSubRecipeInline.duplicateNameMsg": { + "defaultMessage": "Ya existe una subreceta llamada \"{name}\". Usa un nombre único." + }, + "createSubRecipeInline.instructionsLabel": { + "defaultMessage": "Instrucciones" + }, + "createSubRecipeInline.instructionsPlaceholder": { + "defaultMessage": "Instrucciones para la IA cuando se llame a esta subreceta..." + }, + "createSubRecipeInline.nameHint": { + "defaultMessage": "Identificador único que se usa para generar el nombre de la herramienta" + }, + "createSubRecipeInline.nameLabel": { + "defaultMessage": "Nombre" + }, + "createSubRecipeInline.namePlaceholder": { + "defaultMessage": "ej., security_scan" + }, + "createSubRecipeInline.preconfiguredValues": { + "defaultMessage": "Valores preconfigurados" + }, + "createSubRecipeInline.preconfiguredValuesHint": { + "defaultMessage": "Valores de parámetros opcionales que siempre se pasan a la subreceta" + }, + "createSubRecipeInline.recipeDescriptionLabel": { + "defaultMessage": "Descripción de la receta" + }, + "createSubRecipeInline.recipeDescriptionPlaceholder": { + "defaultMessage": "Qué hace esta receta cuando se ejecuta" + }, + "createSubRecipeInline.recipeTitleLabel": { + "defaultMessage": "Título de la receta" + }, + "createSubRecipeInline.recipeTitlePlaceholder": { + "defaultMessage": "ej., Herramienta de análisis de seguridad" + }, + "createSubRecipeInline.saveFailed": { + "defaultMessage": "No se pudo guardar" + }, + "createSubRecipeInline.saveFailedMsg": { + "defaultMessage": "No se pudo guardar la subreceta: {error}" + }, + "createSubRecipeInline.sequentialHint": { + "defaultMessage": "(Fuerza la ejecución secuencial de varias instancias)" + }, + "createSubRecipeInline.sequentialLabel": { + "defaultMessage": "Secuencial cuando se repite" + }, + "createSubRecipeInline.subtitle": { + "defaultMessage": "Crea una receta simple para usarla como herramienta invocable en tu receta principal" + }, + "createSubRecipeInline.title": { + "defaultMessage": "Crear nueva Subreceta" + }, + "createSubRecipeInline.toolDescriptionLabel": { + "defaultMessage": "Descripción de la herramienta" + }, + "createSubRecipeInline.toolDescriptionPlaceholder": { + "defaultMessage": "Descripción opcional que se muestra cuando esto se llama como herramienta" + }, + "createSubRecipeInline.validationFailed": { + "defaultMessage": "La validación falló" + }, + "createSubRecipeInline.validationMsg": { + "defaultMessage": "El nombre, el título, la descripción de la receta y las instrucciones son obligatorios." + }, + "creditsExhaustedNotification.addCredits": { + "defaultMessage": "Agregar créditos" + }, + "creditsExhaustedNotification.insufficientCredits": { + "defaultMessage": "Créditos insuficientes" + }, + "cronPicker.april": { + "defaultMessage": "Abril" + }, + "cronPicker.at": { + "defaultMessage": "a las" + }, + "cronPicker.atMinute": { + "defaultMessage": "en el minuto" + }, + "cronPicker.atSecond": { + "defaultMessage": "en el segundo" + }, + "cronPicker.august": { + "defaultMessage": "Agosto" + }, + "cronPicker.cronExpression": { + "defaultMessage": "Expresión cron" + }, + "cronPicker.custom": { + "defaultMessage": "Cron personalizado" + }, + "cronPicker.day": { + "defaultMessage": "Día" + }, + "cronPicker.december": { + "defaultMessage": "Diciembre" + }, + "cronPicker.emptyCronError": { + "defaultMessage": "La expresión cron no puede estar vacía" + }, + "cronPicker.every": { + "defaultMessage": "Cada" + }, + "cronPicker.february": { + "defaultMessage": "Febrero" + }, + "cronPicker.friday": { + "defaultMessage": "Viernes" + }, + "cronPicker.hour": { + "defaultMessage": "Hora" + }, + "cronPicker.inMonth": { + "defaultMessage": "en" + }, + "cronPicker.invalidDayOfMonth": { + "defaultMessage": "El día debe estar entre 1 y {max}" + }, + "cronPicker.january": { + "defaultMessage": "Enero" + }, + "cronPicker.july": { + "defaultMessage": "Julio" + }, + "cronPicker.june": { + "defaultMessage": "Junio" + }, + "cronPicker.march": { + "defaultMessage": "Marzo" + }, + "cronPicker.may": { + "defaultMessage": "Mayo" + }, + "cronPicker.minute": { + "defaultMessage": "Minuto" + }, + "cronPicker.mode": { + "defaultMessage": "Modo" + }, + "cronPicker.monday": { + "defaultMessage": "Lunes" + }, + "cronPicker.month": { + "defaultMessage": "Mes" + }, + "cronPicker.november": { + "defaultMessage": "Noviembre" + }, + "cronPicker.october": { + "defaultMessage": "Octubre" + }, + "cronPicker.on": { + "defaultMessage": "el" + }, + "cronPicker.onDay": { + "defaultMessage": "el día" + }, + "cronPicker.quarter": { + "defaultMessage": "Trimestre" + }, + "cronPicker.saturday": { + "defaultMessage": "Sábado" + }, + "cronPicker.september": { + "defaultMessage": "Septiembre" + }, + "cronPicker.startingMonth": { + "defaultMessage": "mes de inicio" + }, + "cronPicker.sunday": { + "defaultMessage": "Domingo" + }, + "cronPicker.thursday": { + "defaultMessage": "Jueves" + }, + "cronPicker.tuesday": { + "defaultMessage": "Martes" + }, + "cronPicker.wednesday": { + "defaultMessage": "Miércoles" + }, + "cronPicker.week": { + "defaultMessage": "Semana" + }, + "cronPicker.year": { + "defaultMessage": "Año" + }, + "customProviderForm.add": { + "defaultMessage": "Agregar" + }, + "customProviderForm.anthropicCompatible": { + "defaultMessage": "Compatible con Anthropic" + }, + "customProviderForm.apiBasePath": { + "defaultMessage": "Ruta base de la API (opcional)" + }, + "customProviderForm.apiBasePathHint": { + "defaultMessage": "Sobrescribe la ruta predeterminada de la API. Déjala en blanco para usar la ruta predeterminada del proveedor." + }, + "customProviderForm.apiBasePathPlaceholder": { + "defaultMessage": "ej., v1/chat/completions o project_id/v1" + }, + "customProviderForm.apiKey": { + "defaultMessage": "API Key" + }, + "customProviderForm.apiKeyPlaceholderExisting": { + "defaultMessage": "Déjala en blanco para mantener la key existente" + }, + "customProviderForm.apiKeyPlaceholderNew": { + "defaultMessage": "Tu API key" + }, + "customProviderForm.apiKeyRequired": { + "defaultMessage": "La API key es obligatoria" + }, + "customProviderForm.apiUrl": { + "defaultMessage": "URL de la API" + }, + "customProviderForm.apiUrlPlaceholder": { + "defaultMessage": "https://api.example.com" + }, + "customProviderForm.apiUrlRequired": { + "defaultMessage": "La URL de la API es obligatoria" + }, + "customProviderForm.attachments": { + "defaultMessage": "Adjuntos" + }, + "customProviderForm.authHint": { + "defaultMessage": "Los LLM locales como Ollama normalmente no requieren una API key." + }, + "customProviderForm.authentication": { + "defaultMessage": "Autenticación" + }, + "customProviderForm.availableModels": { + "defaultMessage": "Modelos disponibles (separados por comas)" + }, + "customProviderForm.back": { + "defaultMessage": "← Atrás" + }, + "customProviderForm.cancel": { + "defaultMessage": "Cancelar" + }, + "customProviderForm.cannotDeleteActive": { + "defaultMessage": "No puedes eliminar este proveedor mientras está en uso. Cambia primero a un modelo diferente." + }, + "customProviderForm.chooseSetup": { + "defaultMessage": "Elige cómo quieres configurar tu proveedor." + }, + "customProviderForm.clear": { + "defaultMessage": "Limpiar" + }, + "customProviderForm.configureManually": { + "defaultMessage": "Configurar manualmente" + }, + "customProviderForm.configureManuallyDesc": { + "defaultMessage": "Ingresa tú mismo todos los datos del proveedor" + }, + "customProviderForm.confirmDelete": { + "defaultMessage": "Confirmar eliminación" + }, + "customProviderForm.createProvider": { + "defaultMessage": "Crear proveedor" + }, + "customProviderForm.customHeaders": { + "defaultMessage": "Headers personalizados" + }, + "customProviderForm.customHeadersHint": { + "defaultMessage": "Agrega headers HTTP personalizados para incluirlos en las solicitudes al proveedor. Haz clic en el botón \"+\" para agregarlos después de completar ambos campos." + }, + "customProviderForm.deleteConfirmation": { + "defaultMessage": "¿Seguro que quieres eliminar este proveedor personalizado? Esto eliminará de forma permanente el proveedor y su API key almacenada. Esta acción no se puede deshacer." + }, + "customProviderForm.deleteProvider": { + "defaultMessage": "Eliminar proveedor" + }, + "customProviderForm.displayName": { + "defaultMessage": "Nombre visible" + }, + "customProviderForm.displayNamePlaceholder": { + "defaultMessage": "El nombre de tu proveedor" + }, + "customProviderForm.displayNameRequired": { + "defaultMessage": "El nombre visible es obligatorio" + }, + "customProviderForm.docs": { + "defaultMessage": "Documentación" + }, + "customProviderForm.headerBothRequired": { + "defaultMessage": "Se deben ingresar el nombre y el valor del header" + }, + "customProviderForm.headerDuplicate": { + "defaultMessage": "Ya existe un header con este nombre" + }, + "customProviderForm.headerNamePlaceholder": { + "defaultMessage": "Nombre del header" + }, + "customProviderForm.headerNoSpaces": { + "defaultMessage": "El nombre del header no puede contener espacios" + }, + "customProviderForm.modelsPlaceholder": { + "defaultMessage": "model-a, model-b, model-c" + }, + "customProviderForm.modelsRequired": { + "defaultMessage": "Se requiere al menos un modelo" + }, + "customProviderForm.ollamaCompatible": { + "defaultMessage": "Compatible con Ollama" + }, + "customProviderForm.openaiCompatible": { + "defaultMessage": "Compatible con OpenAI" + }, + "customProviderForm.providerType": { + "defaultMessage": "Tipo de proveedor" + }, + "customProviderForm.reasoning": { + "defaultMessage": "Razonamiento" + }, + "customProviderForm.requiresApiKey": { + "defaultMessage": "Este proveedor requiere una API key" + }, + "customProviderForm.startFromTemplate": { + "defaultMessage": "Empezar desde una plantilla de proveedor" + }, + "customProviderForm.startFromTemplateDesc": { + "defaultMessage": "Elige un proveedor conocido y completaremos la configuración automáticamente" + }, + "customProviderForm.submitError": { + "defaultMessage": "No se pudo guardar el proveedor. Revisa tu configuración e inténtalo de nuevo." + }, + "customProviderForm.supportsStreaming": { + "defaultMessage": "El proveedor admite respuestas en streaming" + }, + "customProviderForm.toolCalling": { + "defaultMessage": "Llamada a herramientas" + }, + "customProviderForm.updateProvider": { + "defaultMessage": "Actualizar proveedor" + }, + "customProviderForm.usingTemplate": { + "defaultMessage": "Usando la plantilla: {name}" + }, + "customProviderForm.valuePlaceholder": { + "defaultMessage": "Valor" + }, + "defaultCardButtons.configureSettings": { + "defaultMessage": "Configurar los ajustes de {name}" + }, + "defaultCardButtons.deleteSettings": { + "defaultMessage": "Eliminar los ajustes de {name}" + }, + "defaultCardButtons.editSettings": { + "defaultMessage": "Editar los ajustes de {name}" + }, + "defaultCardButtons.getStarted": { + "defaultMessage": "¡Empieza con goose!" + }, + "defaultProviderSetupForm.apiHostLabel": { + "defaultMessage": "Host de la API" + }, + "defaultProviderSetupForm.apiHostPlaceholder": { + "defaultMessage": "https://api.example.com" + }, + "defaultProviderSetupForm.apiKeyLabel": { + "defaultMessage": "API Key" + }, + "defaultProviderSetupForm.apiKeyPlaceholder": { + "defaultMessage": "Tu API key" + }, + "defaultProviderSetupForm.hideOptions": { + "defaultMessage": "Ocultar {count} opciones" + }, + "defaultProviderSetupForm.loadingConfig": { + "defaultMessage": "Cargando valores de configuración..." + }, + "defaultProviderSetupForm.modelsLabel": { + "defaultMessage": "Modelos" + }, + "defaultProviderSetupForm.modelsPlaceholder": { + "defaultMessage": "modelo-a, modelo-b" + }, + "defaultProviderSetupForm.noConfigParameters": { + "defaultMessage": "No hay parámetros de configuración para este proveedor." + }, + "defaultProviderSetupForm.showOptions": { + "defaultMessage": "Mostrar {count} opciones" + }, + "diagnosticsModal.attachHint": { + "defaultMessage": "Si reportas un error, considera adjuntarle el informe de diagnóstico." + }, + "diagnosticsModal.cancel": { + "defaultMessage": "Cancelar" + }, + "diagnosticsModal.configSettings": { + "defaultMessage": "Ajustes de configuración" + }, + "diagnosticsModal.description": { + "defaultMessage": "Puedes descargar un archivo zip de diagnóstico para compartir con el equipo, o reportar un error directamente en GitHub con los detalles de tu sistema ya rellenados. Un informe de diagnóstico contiene lo siguiente:" + }, + "diagnosticsModal.diagnosticsErrorMsg": { + "defaultMessage": "No se pudo descargar el diagnóstico" + }, + "diagnosticsModal.diagnosticsErrorTitle": { + "defaultMessage": "Error de diagnóstico" + }, + "diagnosticsModal.download": { + "defaultMessage": "Descargar" + }, + "diagnosticsModal.downloading": { + "defaultMessage": "Descargando..." + }, + "diagnosticsModal.fileBug": { + "defaultMessage": "Reportar error en GitHub" + }, + "diagnosticsModal.logFiles": { + "defaultMessage": "Archivos de registro recientes" + }, + "diagnosticsModal.opening": { + "defaultMessage": "Abriendo..." + }, + "diagnosticsModal.reportProblem": { + "defaultMessage": "Reportar un problema" + }, + "diagnosticsModal.sensitiveWarning": { + "defaultMessage": "Si tu sesión contiene información sensible, no compartas el archivo de diagnóstico públicamente." + }, + "diagnosticsModal.sessionMessages": { + "defaultMessage": "Los mensajes de tu sesión actual" + }, + "diagnosticsModal.systemInfo": { + "defaultMessage": "Información básica del sistema" + }, + "diagnosticsModal.systemInfoErrorMsg": { + "defaultMessage": "No se pudo obtener la información del sistema" + }, + "diagnosticsModal.systemInfoErrorTitle": { + "defaultMessage": "Error" + }, + "dialog.close": { + "defaultMessage": "Cerrar" + }, + "dictationSettings.addApiKey": { + "defaultMessage": "Añadir API Key" + }, + "dictationSettings.apiKey": { + "defaultMessage": "API Key" + }, + "dictationSettings.cancel": { + "defaultMessage": "Cancelar" + }, + "dictationSettings.chooseVoiceConversion": { + "defaultMessage": "Elige cómo se convierte la voz a texto" + }, + "dictationSettings.configureApiKey": { + "defaultMessage": "Configura la API key en {settingsPath}" + }, + "dictationSettings.configured": { + "defaultMessage": "(Configurado)" + }, + "dictationSettings.configuredIn": { + "defaultMessage": "✓ Configurado en {settingsPath}" + }, + "dictationSettings.disabled": { + "defaultMessage": "Deshabilitado" + }, + "dictationSettings.enterApiKey": { + "defaultMessage": "Introduce tu API key" + }, + "dictationSettings.notConfigured": { + "defaultMessage": "(no configurado)" + }, + "dictationSettings.removeApiKey": { + "defaultMessage": "Quitar API Key" + }, + "dictationSettings.requiredForTranscription": { + "defaultMessage": "Requerido para la transcripción" + }, + "dictationSettings.save": { + "defaultMessage": "Guardar" + }, + "dictationSettings.updateApiKey": { + "defaultMessage": "Actualizar API Key" + }, + "dictationSettings.voiceDictationProvider": { + "defaultMessage": "Proveedor de dictado por voz" + }, + "dirSwitcher.chooseDirectory": { + "defaultMessage": "Elegir directorio…" + }, + "dirSwitcher.currentDirectory": { + "defaultMessage": "Directorio actual" + }, + "dirSwitcher.failedToUpdateWorkingDir": { + "defaultMessage": "No se pudo actualizar el directorio de trabajo" + }, + "dirSwitcher.gitWorktrees": { + "defaultMessage": "Worktrees de Git" + }, + "dirSwitcher.noWorktreesFound": { + "defaultMessage": "No se encontraron worktrees" + }, + "dirSwitcher.openInFinder": { + "defaultMessage": "Abrir en el gestor de archivos" + }, + "dirSwitcher.recentDirectories": { + "defaultMessage": "Directorios recientes" + }, + "elicitationRequest.accept": { + "defaultMessage": "Aceptar" + }, + "elicitationRequest.cancelled": { + "defaultMessage": "La solicitud de información fue cancelada." + }, + "elicitationRequest.defaultMessage": { + "defaultMessage": "Goose necesita algo de información de tu parte." + }, + "elicitationRequest.expired": { + "defaultMessage": "Esta solicitud ha expirado. La extensión tendrá que preguntar de nuevo." + }, + "elicitationRequest.submit": { + "defaultMessage": "Enviar" + }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, + "elicitationRequest.submitted": { + "defaultMessage": "Información enviada" + }, + "elicitationRequest.waitingForResponse": { + "defaultMessage": "Esperando tu respuesta ({timeRemaining} restantes)" + }, + "envVarsSection.add": { + "defaultMessage": "Añadir" + }, + "envVarsSection.bothRequired": { + "defaultMessage": "Debes introducir tanto el nombre como el valor de la variable" + }, + "envVarsSection.envVarsDescription": { + "defaultMessage": "Añade pares clave-valor para variables de entorno. Haz clic en el botón \"+\" para añadir después de rellenar ambos campos. Para valores secretos existentes, haz clic en el botón de editar para modificarlos." + }, + "envVarsSection.environmentVariables": { + "defaultMessage": "Variables de entorno" + }, + "envVarsSection.noSpaces": { + "defaultMessage": "El nombre de la variable no puede contener espacios" + }, + "envVarsSection.value": { + "defaultMessage": "Valor" + }, + "envVarsSection.variableName": { + "defaultMessage": "Nombre de la variable" + }, + "environmentBadge.dev": { + "defaultMessage": "Dev" + }, + "errorBoundary.errorGeneric": { + "defaultMessage": "Ocurrió un error." + }, + "errorBoundary.errorWithVersion": { + "defaultMessage": "Ocurrió un error en Goose v{version}." + }, + "errorBoundary.heading": { + "defaultMessage": "¡Honk!" + }, + "errorBoundary.reload": { + "defaultMessage": "Recargar" + }, + "extensionConfigFields.commandLabel": { + "defaultMessage": "Comando" + }, + "extensionConfigFields.commandPlaceholder": { + "defaultMessage": "p. ej. npx -y @modelcontextprotocol/my-extension [filepath]" + }, + "extensionConfigFields.commandRequired": { + "defaultMessage": "El comando es obligatorio" + }, + "extensionConfigFields.endpointLabel": { + "defaultMessage": "Endpoint" + }, + "extensionConfigFields.endpointPlaceholder": { + "defaultMessage": "Introduce la URL del endpoint..." + }, + "extensionConfigFields.endpointRequired": { + "defaultMessage": "La URL del endpoint es obligatoria" + }, + "extensionInfoFields.descriptionLabel": { + "defaultMessage": "Descripción" + }, + "extensionInfoFields.descriptionPlaceholder": { + "defaultMessage": "Descripción opcional..." + }, + "extensionInfoFields.extensionName": { + "defaultMessage": "Nombre de la extensión" + }, + "extensionInfoFields.extensionNamePlaceholder": { + "defaultMessage": "Introduce el nombre de la extensión..." + }, + "extensionInfoFields.nameRequired": { + "defaultMessage": "El nombre es obligatorio" + }, + "extensionInfoFields.typeHttp": { + "defaultMessage": "HTTP" + }, + "extensionInfoFields.typeLabel": { + "defaultMessage": "Tipo" + }, + "extensionInfoFields.typeSseUnsupported": { + "defaultMessage": "SSE (no compatible)" + }, + "extensionInfoFields.typeStandardIo": { + "defaultMessage": "Standard IO (STDIO)" + }, + "extensionInfoFields.typeStdio": { + "defaultMessage": "STDIO" + }, + "extensionInfoFields.typeStreamableHttp": { + "defaultMessage": "Streamable HTTP" + }, + "extensionInstallModal.alreadyInstalledMessage": { + "defaultMessage": "La extensión ''{name}'' ya se instaló correctamente. Inicia una nueva sesión de chat para usarla." + }, + "extensionInstallModal.alreadyInstalledTitle": { + "defaultMessage": "La extensión ''{name}'' ya está instalada" + }, + "extensionInstallModal.blockedMessage": { + "defaultMessage": "Este comando de extensión no está en la lista permitida y su instalación está bloqueada. Extensión: {name} Comando: {command} Contacta a tu administrador para solicitar la aprobación de esta extensión." + }, + "extensionInstallModal.blockedTitle": { + "defaultMessage": "Instalación de extensión bloqueada" + }, + "extensionInstallModal.cancel": { + "defaultMessage": "Cancelar" + }, + "extensionInstallModal.installAnyway": { + "defaultMessage": "Instalar de todos modos" + }, + "extensionInstallModal.installing": { + "defaultMessage": "Instalando..." + }, + "extensionInstallModal.no": { + "defaultMessage": "No" + }, + "extensionInstallModal.ok": { + "defaultMessage": "OK" + }, + "extensionInstallModal.trustedMessage": { + "defaultMessage": "¿Seguro que quieres instalar la extensión {name}? Comando: {command}" + }, + "extensionInstallModal.trustedTitle": { + "defaultMessage": "Confirmar instalación de la extensión" + }, + "extensionInstallModal.unknownCommand": { + "defaultMessage": "Comando desconocido" + }, + "extensionInstallModal.untrustedMessageWithCommand": { + "defaultMessage": "{securityMessage} Extensión: {name} Comando: {command} Contacta a tu administrador si no estás seguro de esto." + }, + "extensionInstallModal.untrustedMessageWithUrl": { + "defaultMessage": "{securityMessage} Extensión: {name} URL: {url} Contacta a tu administrador si no estás seguro de esto." + }, + "extensionInstallModal.untrustedSecurityMessage": { + "defaultMessage": "Este comando de extensión no está en la lista permitida y podrá acceder a tus conversaciones y ofrecer funcionalidad adicional. Instalar extensiones de fuentes no confiables puede suponer riesgos de seguridad." + }, + "extensionInstallModal.untrustedTitle": { + "defaultMessage": "¿Instalar extensión no confiable?" + }, + "extensionInstallModal.yes": { + "defaultMessage": "Sí" + }, + "extensionItem.configureExtension": { + "defaultMessage": "Configurar la extensión {name}" + }, + "extensionItem.toggleExtension": { + "defaultMessage": "Activar o desactivar la extensión {name}" + }, + "extensionList.availableExtensions": { + "defaultMessage": "Extensiones disponibles ({count})" + }, + "extensionList.builtInExtension": { + "defaultMessage": "Extensión integrada" + }, + "extensionList.defaultExtensions": { + "defaultMessage": "Extensiones por defecto ({count})" + }, + "extensionList.noExtensions": { + "defaultMessage": "No hay extensiones disponibles" + }, + "extensionModal.cancel": { + "defaultMessage": "Cancelar" + }, + "extensionModal.closeWithoutSaving": { + "defaultMessage": "Cerrar sin guardar" + }, + "extensionModal.confirmRemoval": { + "defaultMessage": "Confirmar eliminación" + }, + "extensionModal.deleteDescription": { + "defaultMessage": "Esto eliminará permanentemente esta extensión y todos sus ajustes." + }, + "extensionModal.deleteExtensionTitle": { + "defaultMessage": "Eliminar la extensión \"{name}\"" + }, + "extensionModal.installationNotes": { + "defaultMessage": "Notas de instalación" + }, + "extensionModal.removeExtension": { + "defaultMessage": "Quitar extensión" + }, + "extensionModal.unsavedChangesMessage": { + "defaultMessage": "Tienes cambios sin guardar en la configuración de la extensión. ¿Seguro que quieres cerrar sin guardar?" + }, + "extensionModal.unsavedChangesTitle": { + "defaultMessage": "Cambios sin guardar" + }, + "extensionTimeoutField.timeoutLabel": { + "defaultMessage": "Tiempo de espera" + }, + "extensionsSection.addCustomExtension": { + "defaultMessage": "Añadir extensión personalizada" + }, + "extensionsSection.addExtension": { + "defaultMessage": "Añadir extensión" + }, + "extensionsSection.saveChanges": { + "defaultMessage": "Guardar cambios" + }, + "extensionsSection.updateExtension": { + "defaultMessage": "Actualizar extensión" + }, + "extensionsView.addCustomExtension": { + "defaultMessage": "Añadir extensión personalizada" + }, + "extensionsView.addExtension": { + "defaultMessage": "Añadir extensión" + }, + "extensionsView.defaultNote": { + "defaultMessage": "Las extensiones habilitadas aquí se usan por defecto en los chats nuevos. También puedes activar extensiones durante el chat." + }, + "extensionsView.description": { + "defaultMessage": "Estas extensiones usan el Model Context Protocol (MCP). Pueden ampliar las capacidades de Goose mediante tres componentes principales: Prompts, Recursos y Herramientas. {searchShortcut} para buscar." + }, + "extensionsView.heading": { + "defaultMessage": "Extensiones" + }, + "extensionsView.searchPlaceholder": { + "defaultMessage": "Buscar extensiones..." + }, + "externalBackendSection.certFingerprint": { + "defaultMessage": "Huella del certificado (opcional)" + }, + "externalBackendSection.certFingerprintHelp": { + "defaultMessage": "Fija una huella de certificado TLS específica. Si se omite, el certificado se confía en el primer uso (TOFU)." + }, + "externalBackendSection.certFingerprintPlaceholder": { + "defaultMessage": "AA:BB:CC:... o sha256/base64" + }, + "externalBackendSection.description": { + "defaultMessage": "Por defecto goose lanza un servidor por ti; usa esto para conectarte a un servidor goose externo" + }, + "externalBackendSection.restartNote": { + "defaultMessage": "Los cambios requieren reiniciar Goose para que surtan efecto. Las nuevas ventanas de chat se conectarán al servidor externo." + }, + "externalBackendSection.secretKey": { + "defaultMessage": "Secret Key" + }, + "externalBackendSection.secretKeyHelp": { + "defaultMessage": "La secret key configurada en el servidor goosed (GOOSE_SERVER__SECRET_KEY)" + }, + "externalBackendSection.secretKeyPlaceholder": { + "defaultMessage": "Introduce la secret key del servidor" + }, + "externalBackendSection.serverUrl": { + "defaultMessage": "URL del servidor" + }, + "externalBackendSection.title": { + "defaultMessage": "Goose Server" + }, + "externalBackendSection.urlFormatError": { + "defaultMessage": "Formato de URL inválido" + }, + "externalBackendSection.urlProtocolError": { + "defaultMessage": "La URL debe usar el protocolo http o https" + }, + "externalBackendSection.useExternalServer": { + "defaultMessage": "Usar servidor externo" + }, + "externalBackendSection.useExternalServerDescription": { + "defaultMessage": "Conéctate a un servidor goose que se ejecuta en otro lugar (requiere reiniciar la app)" + }, + "freeOptionCards.chooseOption": { + "defaultMessage": "Elige una opción para empezar." + }, + "freeOptionCards.freeAndPrivate": { + "defaultMessage": "Gratis y privado" + }, + "freeOptionCards.localModelDescription": { + "defaultMessage": "Descarga un modelo y ejecútalo por completo en tu máquina. Sin API keys, sin cuentas." + }, + "freeOptionCards.localModelTitle": { + "defaultMessage": "Usar un modelo local" + }, + "freeOptionCards.nanogptDescription": { + "defaultMessage": "Regístrate para recibir 60M de tokens gratis durante 7 días." + }, + "freeOptionCards.nanogptTitle": { + "defaultMessage": "NanoGPT" + }, + "freeOptionCards.retry": { + "defaultMessage": "Reintentar" + }, + "freeOptionCards.tetrateDescription": { + "defaultMessage": "Accede a múltiples modelos de IA con configuración automática. Regístrate para recibir $10 de crédito." + }, + "freeOptionCards.tetrateTitle": { + "defaultMessage": "Agent Router by Tetrate" + }, + "freeOptionCards.unexpectedError": { + "defaultMessage": "Ocurrió un error inesperado durante la configuración." + }, + "gatewaySettings.botFatherInstructions": { + "defaultMessage": "Abre @BotFather en tu teléfono, envía /newbot y sigue las indicaciones para nombrar tu bot. BotFather te responderá con un token de API — pégalo abajo." + }, + "gatewaySettings.close": { + "defaultMessage": "Cerrar" + }, + "gatewaySettings.expiresIn": { + "defaultMessage": "Expira en {time}" + }, + "gatewaySettings.failedToGeneratePairingCode": { + "defaultMessage": "No se pudo generar el código de emparejamiento" + }, + "gatewaySettings.failedToRemove": { + "defaultMessage": "No se pudo quitar" + }, + "gatewaySettings.failedToStart": { + "defaultMessage": "No se pudo iniciar" + }, + "gatewaySettings.failedToStop": { + "defaultMessage": "No se pudo detener" + }, + "gatewaySettings.failedToUnpairUser": { + "defaultMessage": "No se pudo desemparejar al usuario" + }, + "gatewaySettings.loading": { + "defaultMessage": "Cargando..." + }, + "gatewaySettings.pairDevice": { + "defaultMessage": "Emparejar dispositivo" + }, + "gatewaySettings.pairedUsers": { + "defaultMessage": "Usuarios emparejados" + }, + "gatewaySettings.pairingCode": { + "defaultMessage": "Código de emparejamiento" + }, + "gatewaySettings.pasteBotToken": { + "defaultMessage": "Pega aquí el token del bot" + }, + "gatewaySettings.remove": { + "defaultMessage": "Quitar" + }, + "gatewaySettings.running": { + "defaultMessage": "En ejecución" + }, + "gatewaySettings.sendCodeToPair": { + "defaultMessage": "Envía este código a tu bot de {gatewayType} para emparejar." + }, + "gatewaySettings.start": { + "defaultMessage": "Iniciar" + }, + "gatewaySettings.stop": { + "defaultMessage": "Detener" + }, + "gatewaySettings.stopped": { + "defaultMessage": "Detenido" + }, + "gatewaySettings.telegram": { + "defaultMessage": "Telegram" + }, + "goosehintsModal.close": { + "defaultMessage": "Cerrar" + }, + "goosehintsModal.developer": { + "defaultMessage": "Developer" + }, + "goosehintsModal.dialogDescription": { + "defaultMessage": "Proporciona contexto adicional sobre tu proyecto para mejorar la comunicación con Goose" + }, + "goosehintsModal.dialogTitle": { + "defaultMessage": "Configurar pistas del proyecto (.goosehints)" + }, + "goosehintsModal.errorReading": { + "defaultMessage": "Error al leer el archivo .goosehints: {error}" + }, + "goosehintsModal.failedToAccess": { + "defaultMessage": "No se pudo acceder al archivo .goosehints" + }, + "goosehintsModal.failedToSave": { + "defaultMessage": "No se pudo guardar el archivo .goosehints" + }, + "goosehintsModal.fileCreating": { + "defaultMessage": "Creando un nuevo archivo .goosehints en: {filePath}" + }, + "goosehintsModal.fileFound": { + "defaultMessage": "Archivo .goosehints encontrado en: {filePath}" + }, + "goosehintsModal.helpText1": { + "defaultMessage": ".goosehints es un archivo de texto usado para proporcionar contexto adicional sobre tu proyecto y mejorar la comunicación con Goose." + }, + "goosehintsModal.helpText2": { + "defaultMessage": "Asegúrate de que la extensión {bold} esté habilitada en la página de extensiones. Esta extensión es necesaria para usar .goosehints. Tendrás que reiniciar tu sesión para que las actualizaciones de .goosehints surtan efecto." + }, + "goosehintsModal.helpText3": { + "defaultMessage": "Consulta {link} para más información." + }, + "goosehintsModal.helpTextLink": { + "defaultMessage": "usar .goosehints" + }, + "goosehintsModal.placeholder": { + "defaultMessage": "Introduce aquí las pistas del proyecto..." + }, + "goosehintsModal.save": { + "defaultMessage": "Guardar" + }, + "goosehintsModal.savedSuccessfully": { + "defaultMessage": "Guardado correctamente" + }, + "goosehintsModal.saving": { + "defaultMessage": "Guardando..." + }, + "goosehintsSection.configure": { + "defaultMessage": "Configurar" + }, + "goosehintsSection.description": { + "defaultMessage": "Configura el archivo .goosehints de tu proyecto para proporcionar contexto adicional a Goose" + }, + "goosehintsSection.title": { + "defaultMessage": "Pistas del proyecto (.goosehints)" + }, + "groupedExtensionLoadingToast.askGoose": { + "defaultMessage": "Pregunta a goose" + }, + "groupedExtensionLoadingToast.collapseDetails": { + "defaultMessage": "Contraer detalles" + }, + "groupedExtensionLoadingToast.copied": { + "defaultMessage": "¡Copiado!" + }, + "groupedExtensionLoadingToast.copyError": { + "defaultMessage": "Error al copiar" + }, + "groupedExtensionLoadingToast.expandDetails": { + "defaultMessage": "Expandir detalles" + }, + "groupedExtensionLoadingToast.failedToAddExtension": { + "defaultMessage": "No se pudo añadir la extensión" + }, + "groupedExtensionLoadingToast.failedToLoad": { + "defaultMessage": "{count,plural,one{# extensión no se pudo cargar} other{# extensiones no se pudieron cargar}}" + }, + "groupedExtensionLoadingToast.loadingExtensions": { + "defaultMessage": "{count,plural,one{Cargando # extensión...} other{Cargando # extensiones...}}" + }, + "groupedExtensionLoadingToast.partiallyLoaded": { + "defaultMessage": "{totalCount,plural,one{Cargada {successCount}/# extensión} other{Cargadas {successCount}/# extensiones}}" + }, + "groupedExtensionLoadingToast.showDetails": { + "defaultMessage": "Mostrar detalles" + }, + "groupedExtensionLoadingToast.showLess": { + "defaultMessage": "Mostrar menos" + }, + "groupedExtensionLoadingToast.successfullyLoaded": { + "defaultMessage": "{count,plural,one{Se cargó correctamente # extensión} other{Se cargaron correctamente # extensiones}}" + }, + "headersSection.add": { + "defaultMessage": "Añadir" + }, + "headersSection.bothRequired": { + "defaultMessage": "Debes introducir tanto el nombre como el valor del encabezado" + }, + "headersSection.duplicateHeader": { + "defaultMessage": "Ya existe un encabezado con este nombre" + }, + "headersSection.headerName": { + "defaultMessage": "Nombre del encabezado" + }, + "headersSection.headersDescription": { + "defaultMessage": "Añade encabezados HTTP personalizados para incluirlos en las peticiones al servidor MCP. Haz clic en el botón \"+\" para añadir después de rellenar ambos campos." + }, + "headersSection.noSpaces": { + "defaultMessage": "El nombre del encabezado no puede contener espacios" + }, + "headersSection.requestHeaders": { + "defaultMessage": "Encabezados de la petición" + }, + "headersSection.value": { + "defaultMessage": "Valor" + }, + "hub.goodAfternoon": { + "defaultMessage": "Buenas tardes" + }, + "hub.goodEvening": { + "defaultMessage": "Buenas noches" + }, + "hub.goodMorning": { + "defaultMessage": "Buenos días" + }, + "huggingFaceModelSearch.download": { + "defaultMessage": "Descargar" + }, + "huggingFaceModelSearch.downloaded": { + "defaultMessage": "Descargado" + }, + "huggingFaceModelSearch.downloading": { + "defaultMessage": "Descargando…" + }, + "huggingFaceModelSearch.loadingVariants": { + "defaultMessage": "Cargando variantes..." + }, + "huggingFaceModelSearch.noGgufModels": { + "defaultMessage": "No se encontraron modelos GGUF para esta búsqueda." + }, + "huggingFaceModelSearch.recommended": { + "defaultMessage": "Recomendado" + }, + "huggingFaceModelSearch.searchError": { + "defaultMessage": "Error de búsqueda: {details}" + }, + "huggingFaceModelSearch.searchFailed": { + "defaultMessage": "La búsqueda falló. Inténtalo de nuevo." + }, + "huggingFaceModelSearch.searchHuggingFace": { + "defaultMessage": "Buscar en HuggingFace" + }, + "huggingFaceModelSearch.searchNoData": { + "defaultMessage": "La búsqueda no devolvió datos." + }, + "huggingFaceModelSearch.searchPlaceholder": { + "defaultMessage": "Buscar modelos GGUF..." + }, + "huggingFaceModelSearch.tooLarge": { + "defaultMessage": "Puede que no quepa en memoria (modelo de {size}, {available} disponible)" + }, + "huggingFaceSignInPrompt.failedToConfigure": { + "defaultMessage": "No se pudo iniciar sesión en Hugging Face: {error}" + }, + "huggingFaceSignInPrompt.signIn": { + "defaultMessage": "Iniciar sesión" + }, + "huggingFaceSignInPrompt.signedIn": { + "defaultMessage": "Sesión iniciada en Hugging Face" + }, + "huggingFaceSignInPrompt.signingIn": { + "defaultMessage": "Iniciando sesión..." + }, + "huggingFaceSignInPrompt.title": { + "defaultMessage": "Hugging Face" + }, + "imagePreview.altText": { + "defaultMessage": "imagen de goose" + }, + "imagePreview.clickToCollapse": { + "defaultMessage": "Haz clic para contraer" + }, + "imagePreview.clickToExpand": { + "defaultMessage": "Haz clic para expandir" + }, + "imagePreview.unableToLoad": { + "defaultMessage": "No se pudo cargar la imagen" + }, + "importRecipeForm.cancel": { + "defaultMessage": "Cancelar" + }, + "importRecipeForm.deeplinkHint": { + "defaultMessage": "Pega un deeplink de receta que empiece por \"goose://recipe?config=\"" + }, + "importRecipeForm.deeplinkPlaceholder": { + "defaultMessage": "Pega aquí tu deeplink goose://recipe?config=..." + }, + "importRecipeForm.example": { + "defaultMessage": "ejemplo" + }, + "importRecipeForm.expectedRecipeStructure": { + "defaultMessage": "Estructura esperada de la receta" + }, + "importRecipeForm.importRecipeButton": { + "defaultMessage": "Importar receta" + }, + "importRecipeForm.importRecipeTitle": { + "defaultMessage": "Importar receta" + }, + "importRecipeForm.importing": { + "defaultMessage": "Importando..." + }, + "importRecipeForm.or": { + "defaultMessage": "O" + }, + "importRecipeForm.recipeDeeplinkLabel": { + "defaultMessage": "Deeplink de la receta" + }, + "importRecipeForm.recipeFileHint": { + "defaultMessage": "Sube un archivo YAML o JSON que contenga la estructura de la receta" + }, + "importRecipeForm.recipeFileLabel": { + "defaultMessage": "Archivo de receta" + }, + "importRecipeForm.reviewWarning": { + "defaultMessage": "Asegúrate de revisar el contenido de los archivos de receta antes de añadirlos a tu interfaz de goose." + }, + "importRecipeForm.schemaDescription": { + "defaultMessage": "Tu archivo YAML o JSON debe seguir esta estructura. Los campos obligatorios son: title, description y, o bien instructions, o bien prompt." + }, + "inlineEditText.clickToEdit": { + "defaultMessage": "Haz clic para editar" + }, + "inlineEditText.doubleClickToEdit": { + "defaultMessage": "Haz doble clic para editar" + }, + "inlineEditText.enterText": { + "defaultMessage": "Introduce texto" + }, + "inlineEditText.failedToSave": { + "defaultMessage": "No se pudo guardar" + }, + "instructionsEditor.cancel": { + "defaultMessage": "Cancelar" + }, + "instructionsEditor.insertExample": { + "defaultMessage": "Insertar ejemplo" + }, + "instructionsEditor.label": { + "defaultMessage": "Instrucciones" + }, + "instructionsEditor.placeholder": { + "defaultMessage": "Instrucciones detalladas para la IA, ocultas al usuario" + }, + "instructionsEditor.save": { + "defaultMessage": "Guardar instrucciones" + }, + "instructionsEditor.syntaxHelp": { + "defaultMessage": "Usa la sintaxis {code} para definir parámetros que los usuarios pueden rellenar" + }, + "instructionsEditor.title": { + "defaultMessage": "Editor de instrucciones" + }, + "jsonSchemaEditor.cancel": { + "defaultMessage": "Cancelar" + }, + "jsonSchemaEditor.description": { + "defaultMessage": "Define la estructura esperada de la respuesta de la IA usando el formato JSON Schema" + }, + "jsonSchemaEditor.insertExample": { + "defaultMessage": "Insertar ejemplo" + }, + "jsonSchemaEditor.invalidJson": { + "defaultMessage": "Formato JSON inválido" + }, + "jsonSchemaEditor.label": { + "defaultMessage": "JSON Schema de respuesta" + }, + "jsonSchemaEditor.save": { + "defaultMessage": "Guardar Schema" + }, + "jsonSchemaEditor.title": { + "defaultMessage": "Editor de JSON Schema" + }, + "jsonSchemaForm.cancel": { + "defaultMessage": "Cancelar" + }, + "jsonSchemaForm.fieldRequired": { + "defaultMessage": "Este campo es obligatorio" + }, + "jsonSchemaForm.maxLength": { + "defaultMessage": "La longitud máxima es {maxLength}" + }, + "jsonSchemaForm.maxValue": { + "defaultMessage": "El valor máximo es {maximum}" + }, + "jsonSchemaForm.minLength": { + "defaultMessage": "La longitud mínima es {minLength}" + }, + "jsonSchemaForm.minValue": { + "defaultMessage": "El valor mínimo es {minimum}" + }, + "jsonSchemaForm.noFields": { + "defaultMessage": "No hay campos que mostrar" + }, + "jsonSchemaForm.selectPlaceholder": { + "defaultMessage": "Seleccionar..." + }, + "jsonSchemaForm.submit": { + "defaultMessage": "Enviar" + }, + "keyValueEditor.addValue": { + "defaultMessage": "Añadir valor preconfigurado" + }, + "keyValueEditor.defaultKeyPlaceholder": { + "defaultMessage": "Nombre del parámetro..." + }, + "keyValueEditor.defaultValuePlaceholder": { + "defaultMessage": "Valor del parámetro..." + }, + "keyValueEditor.removeValue": { + "defaultMessage": "Quitar valor preconfigurado {key}" + }, + "keyboardShortcuts.alwaysOnTopDescription": { + "defaultMessage": "Alternar ventana siempre visible" + }, + "keyboardShortcuts.alwaysOnTopLabel": { + "defaultMessage": "Siempre visible" + }, + "keyboardShortcuts.cancel": { + "defaultMessage": "Cancelar" + }, + "keyboardShortcuts.categoryApplication": { + "defaultMessage": "Atajos de la aplicación" + }, + "keyboardShortcuts.categoryApplicationDescription": { + "defaultMessage": "Estos atajos funcionan cuando Goose es la aplicación activa" + }, + "keyboardShortcuts.categoryGlobal": { + "defaultMessage": "Atajos globales" + }, + "keyboardShortcuts.categoryGlobalDescription": { + "defaultMessage": "Estos atajos funcionan en todo el sistema, incluso cuando Goose no está enfocado" + }, + "keyboardShortcuts.categorySearch": { + "defaultMessage": "Atajos de búsqueda" + }, + "keyboardShortcuts.categorySearchDescription": { + "defaultMessage": "Estos atajos funcionan al buscar en una conversación" + }, + "keyboardShortcuts.categoryWindow": { + "defaultMessage": "Atajos de ventana" + }, + "keyboardShortcuts.categoryWindowDescription": { + "defaultMessage": "Estos atajos controlan el comportamiento de la ventana" + }, + "keyboardShortcuts.change": { + "defaultMessage": "Cambiar" + }, + "keyboardShortcuts.disabled": { + "defaultMessage": "Desactivado" + }, + "keyboardShortcuts.dismiss": { + "defaultMessage": "Descartar" + }, + "keyboardShortcuts.findDescription": { + "defaultMessage": "Abrir búsqueda en la conversación" + }, + "keyboardShortcuts.findLabel": { + "defaultMessage": "Buscar" + }, + "keyboardShortcuts.findNextDescription": { + "defaultMessage": "Saltar al siguiente resultado de búsqueda" + }, + "keyboardShortcuts.findNextLabel": { + "defaultMessage": "Buscar siguiente" + }, + "keyboardShortcuts.findPreviousDescription": { + "defaultMessage": "Saltar al resultado de búsqueda anterior" + }, + "keyboardShortcuts.findPreviousLabel": { + "defaultMessage": "Buscar anterior" + }, + "keyboardShortcuts.focusWindowDescription": { + "defaultMessage": "Traer al frente la ventana de Goose desde cualquier lugar" + }, + "keyboardShortcuts.focusWindowLabel": { + "defaultMessage": "Enfocar la ventana de Goose" + }, + "keyboardShortcuts.loading": { + "defaultMessage": "Cargando..." + }, + "keyboardShortcuts.newChatDescription": { + "defaultMessage": "Crear un nuevo chat en la ventana actual" + }, + "keyboardShortcuts.newChatLabel": { + "defaultMessage": "Nuevo chat" + }, + "keyboardShortcuts.newChatWindowDescription": { + "defaultMessage": "Abrir una nueva ventana de Goose" + }, + "keyboardShortcuts.newChatWindowLabel": { + "defaultMessage": "Nueva ventana de chat" + }, + "keyboardShortcuts.openDirectoryDescription": { + "defaultMessage": "Abrir el diálogo de selección de directorio" + }, + "keyboardShortcuts.openDirectoryLabel": { + "defaultMessage": "Abrir directorio" + }, + "keyboardShortcuts.quickLauncherDescription": { + "defaultMessage": "Abrir la superposición del lanzador rápido" + }, + "keyboardShortcuts.quickLauncherLabel": { + "defaultMessage": "Lanzador rápido" + }, + "keyboardShortcuts.reassignShortcut": { + "defaultMessage": "Reasignar atajo" + }, + "keyboardShortcuts.resetAllShortcuts": { + "defaultMessage": "Restablecer todos los atajos" + }, + "keyboardShortcuts.resetShortcutsDetail": { + "defaultMessage": "Esto restaurará todos los atajos a su configuración original." + }, + "keyboardShortcuts.resetShortcutsMessage": { + "defaultMessage": "¿Restablecer todos los atajos de teclado a sus valores predeterminados?" + }, + "keyboardShortcuts.resetShortcutsTitle": { + "defaultMessage": "Restablecer atajos de teclado" + }, + "keyboardShortcuts.resetToDefaultsDescription": { + "defaultMessage": "Restaurar todos los atajos de teclado a su configuración original" + }, + "keyboardShortcuts.resetToDefaultsHeading": { + "defaultMessage": "Restablecer a los valores predeterminados" + }, + "keyboardShortcuts.restartDescription": { + "defaultMessage": "Los cambios en los atajos de la aplicación (como Nuevo chat, Ajustes, etc.) requieren reiniciar Goose para que surtan efecto. Los atajos globales (Enfocar ventana, Lanzador rápido) funcionan de inmediato." + }, + "keyboardShortcuts.restartRequired": { + "defaultMessage": "Reinicio necesario" + }, + "keyboardShortcuts.settingsDescription": { + "defaultMessage": "Abrir el panel de ajustes" + }, + "keyboardShortcuts.settingsLabel": { + "defaultMessage": "Ajustes" + }, + "keyboardShortcuts.shortcutConflictSaveDetail": { + "defaultMessage": "Al guardar esto se quitará el atajo de \"{conflictLabel}\" y se asignará a \"{targetLabel}\". ¿Quieres continuar?" + }, + "keyboardShortcuts.shortcutConflictTitle": { + "defaultMessage": "Conflicto de atajo" + }, + "keyboardShortcuts.shortcutConflictToggleDetail": { + "defaultMessage": "Al activar esto se quitará el atajo de \"{conflictLabel}\" y se asignará a \"{targetLabel}\". ¿Quieres continuar?" + }, + "keyboardShortcuts.shortcutConflictToggleMessage": { + "defaultMessage": "El atajo {shortcut} ya está asignado a \"{conflictLabel}\"." + }, + "keyboardShortcuts.toggleNavigationDescription": { + "defaultMessage": "Mostrar u ocultar el menú de navegación" + }, + "keyboardShortcuts.toggleNavigationLabel": { + "defaultMessage": "Alternar navegación" + }, + "launcher.placeholder": { + "defaultMessage": "Pregúntale lo que sea a goose..." + }, + "loadingGoose.compacting": { + "defaultMessage": "goose está compactando la conversación..." + }, + "loadingGoose.idle": { + "defaultMessage": "goose está trabajando en ello…" + }, + "loadingGoose.loadingConversation": { + "defaultMessage": "cargando conversación..." + }, + "loadingGoose.restartingAgent": { + "defaultMessage": "reiniciando sesión..." + }, + "loadingGoose.streaming": { + "defaultMessage": "goose está trabajando en ello…" + }, + "loadingGoose.thinking": { + "defaultMessage": "goose está pensando…" + }, + "loadingGoose.waiting": { + "defaultMessage": "goose está esperando…" + }, + "localInferenceSettings.deleteConfirm": { + "defaultMessage": "¿Eliminar este modelo? Podrás volver a descargarlo más tarde." + }, + "localInferenceSettings.description": { + "defaultMessage": "Descarga y gestiona modelos LLM locales para inferencia sin claves API. Busca cualquier modelo GGUF en HuggingFace o usa las opciones destacadas de abajo." + }, + "localInferenceSettings.dismiss": { + "defaultMessage": "Descartar" + }, + "localInferenceSettings.download": { + "defaultMessage": "Descargar" + }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "Descarga cancelada" + }, + "localInferenceSettings.downloadFailed": { + "defaultMessage": "La descarga falló" + }, + "localInferenceSettings.downloadProgress": { + "defaultMessage": "{downloaded} / {total} ({percent}%)" + }, + "localInferenceSettings.downloadedModels": { + "defaultMessage": "Modelos descargados" + }, + "localInferenceSettings.downloading": { + "defaultMessage": "Descargando" + }, + "localInferenceSettings.featuredModels": { + "defaultMessage": "Modelos destacados" + }, + "localInferenceSettings.huggingFaceSignInNote": { + "defaultMessage": "Inicia sesión para aumentar los límites de uso al buscar y descargar modelos, y para acceder a repositorios de Hugging Face privados o restringidos." + }, + "localInferenceSettings.modelSettings": { + "defaultMessage": "Ajustes del modelo" + }, + "localInferenceSettings.modelSettingsTitle": { + "defaultMessage": "Ajustes del modelo" + }, + "localInferenceSettings.noModels": { + "defaultMessage": "No hay modelos disponibles" + }, + "localInferenceSettings.recommended": { + "defaultMessage": "Recomendado" + }, + "localInferenceSettings.remaining": { + "defaultMessage": "{time} restante" + }, + "localInferenceSettings.retry": { + "defaultMessage": "Reintentar" + }, + "localInferenceSettings.showAllFeatured": { + "defaultMessage": "Mostrar todos los destacados ({count} más)" + }, + "localInferenceSettings.showRecommendedOnly": { + "defaultMessage": "Mostrar solo los recomendados" + }, + "localInferenceSettings.title": { + "defaultMessage": "Modelos de inferencia local" + }, + "localInferenceSettings.vision": { + "defaultMessage": "Visión" + }, + "localInferenceSettings.visionEncoderDownloading": { + "defaultMessage": "Descargando codificador de visión…" + }, + "localInferenceSettings.visionEncoderNotDownloaded": { + "defaultMessage": "Codificador de visión no descargado" + }, + "localModelManager.active": { + "defaultMessage": "Activo" + }, + "localModelManager.deleteConfirm": { + "defaultMessage": "¿Eliminar este modelo? Podrás volver a descargarlo más tarde." + }, + "localModelManager.download": { + "defaultMessage": "Descargar" + }, + "localModelManager.downloaded": { + "defaultMessage": "Descargado" + }, + "localModelManager.gpuAcceleration": { + "defaultMessage": "Admite aceleración por GPU (CUDA para NVIDIA, Metal para Apple Silicon). Las funciones de GPU deben habilitarse al compilar para obtener aceleración por hardware." + }, + "localModelManager.noModels": { + "defaultMessage": "No hay modelos disponibles" + }, + "localModelManager.recommended": { + "defaultMessage": "Recomendado" + }, + "localModelManager.recommendedForHardware": { + "defaultMessage": "Recomendado para tu hardware" + }, + "localModelManager.showAllModels": { + "defaultMessage": "Mostrar todos los modelos ({count} más)" + }, + "localModelManager.showRecommendedOnly": { + "defaultMessage": "Mostrar solo los recomendados" + }, + "localModelPicker.back": { + "defaultMessage": "Atrás" + }, + "localModelPicker.bestForMachine": { + "defaultMessage": "El mejor para tu máquina" + }, + "localModelPicker.cancelDownload": { + "defaultMessage": "Cancelar descarga" + }, + "localModelPicker.checkingModels": { + "defaultMessage": "Comprobando modelos disponibles..." + }, + "localModelPicker.downloadModel": { + "defaultMessage": "Descargar {modelId} ({size})" + }, + "localModelPicker.downloading": { + "defaultMessage": "Descargando {modelId}" + }, + "localModelPicker.failedToLoad": { + "defaultMessage": "No se pudieron cargar los modelos disponibles. Inténtalo de nuevo." + }, + "localModelPicker.failedToStartDownload": { + "defaultMessage": "No se pudo iniciar la descarga. Inténtalo de nuevo." + }, + "localModelPicker.hideOtherSizes": { + "defaultMessage": "Ocultar otros tamaños" + }, + "localModelPicker.localModelsNote": { + "defaultMessage": "Los modelos locales mantienen todo en tu máquina para una privacidad total. El rendimiento y el tamaño de la ventana de contexto pueden variar respecto a los proveedores en la nube según tu hardware y el tamaño del modelo." + }, + "localModelPicker.lostConnection": { + "defaultMessage": "Se perdió la conexión con la descarga. Inténtalo de nuevo." + }, + "localModelPicker.modelNotFound": { + "defaultMessage": "Modelo no encontrado" + }, + "localModelPicker.ready": { + "defaultMessage": "Listo" + }, + "localModelPicker.selectModel": { + "defaultMessage": "Selecciona un modelo" + }, + "localModelPicker.showOtherSizes": { + "defaultMessage": "Mostrar {count} tamaños más" + }, + "localModelPicker.startingDownload": { + "defaultMessage": "Iniciando descarga..." + }, + "localModelPicker.tryAgain": { + "defaultMessage": "Intentar de nuevo" + }, + "localModelPicker.useModel": { + "defaultMessage": "Usar {modelId}" + }, + "markdownContent.cancel": { + "defaultMessage": "Cancelar" + }, + "markdownContent.copyCode": { + "defaultMessage": "Copiar código" + }, + "markdownContent.failedToOpenLink": { + "defaultMessage": "No se pudo abrir el enlace" + }, + "markdownContent.noApplicationFound": { + "defaultMessage": "No se encontró ninguna aplicación para abrir este enlace." + }, + "markdownContent.open": { + "defaultMessage": "Abrir" + }, + "markdownContent.openExternalLink": { + "defaultMessage": "Abrir enlace externo" + }, + "markdownContent.openProtocolLink": { + "defaultMessage": "¿Abrir enlace {protocol}?" + }, + "markdownContent.thisWillOpen": { + "defaultMessage": "Esto abrirá: {href}" + }, + "mcpAppRenderer.appFallbackTitle": { + "defaultMessage": "App" + }, + "mcpAppRenderer.cancelButton": { + "defaultMessage": "Cancelar" + }, + "mcpAppRenderer.close": { + "defaultMessage": "Cerrar" + }, + "mcpAppRenderer.exitFullscreen": { + "defaultMessage": "Salir de pantalla completa" + }, + "mcpAppRenderer.exitFullscreenTitle": { + "defaultMessage": "Salir de pantalla completa (Esc)" + }, + "mcpAppRenderer.failedToInitSandbox": { + "defaultMessage": "No se pudo inicializar el proxy del sandbox" + }, + "mcpAppRenderer.failedToLoadResource": { + "defaultMessage": "No se pudo cargar el recurso" + }, + "mcpAppRenderer.fullscreen": { + "defaultMessage": "Pantalla completa" + }, + "mcpAppRenderer.invalidUrl": { + "defaultMessage": "URL no válida" + }, + "mcpAppRenderer.movePipWindow": { + "defaultMessage": "Mover la ventana de imagen en imagen (usa las teclas de flecha)" + }, + "mcpAppRenderer.openButton": { + "defaultMessage": "Abrir" + }, + "mcpAppRenderer.openExternalLinkTitle": { + "defaultMessage": "Abrir enlace externo" + }, + "mcpAppRenderer.openLinkDetail": { + "defaultMessage": "Esto abrirá: {url}" + }, + "mcpAppRenderer.openProtocolLink": { + "defaultMessage": "¿Abrir enlace {protocol}?" + }, + "mcpAppRenderer.pictureInPicture": { + "defaultMessage": "Imagen en imagen" + }, + "mcpAppRenderer.playingInPip": { + "defaultMessage": "Reproduciendo en imagen en imagen" + }, + "mcpUIResourceRenderer.cancelButton": { + "defaultMessage": "Cancelar" + }, + "mcpUIResourceRenderer.openButton": { + "defaultMessage": "Abrir" + }, + "mcpUIResourceRenderer.openExternalLinkTitle": { + "defaultMessage": "Abrir enlace externo" + }, + "mcpUIResourceRenderer.openLinkDetail": { + "defaultMessage": "Esto abrirá: {url}" + }, + "mcpUIResourceRenderer.openProtocolLink": { + "defaultMessage": "¿Abrir enlace {protocol}?" + }, + "mcpUIResourceRenderer.toastMessageReceived": { + "defaultMessage": "Mensaje recibido para {message}." + }, + "mcpUIResourceRenderer.toastTitle": { + "defaultMessage": "Mensaje {messageType} de MCP-UI" + }, + "mcpUIResourceRenderer.toastUnsupported": { + "defaultMessage": "Mensaje recibido para {message}. Los mensajes {messageType} aún no se admiten, consulta la consola para más detalles." + }, + "mentionPopover.itemsFound": { + "defaultMessage": "{count,plural,one{# elemento encontrado} other{# elementos encontrados}}" + }, + "mentionPopover.loadingCommands": { + "defaultMessage": "Cargando comandos..." + }, + "mentionPopover.noCommandsFound": { + "defaultMessage": "No se encontraron comandos que coincidan con \"{query}\"" + }, + "mentionPopover.noItemsFound": { + "defaultMessage": "No se encontraron elementos que coincidan con \"{query}\"" + }, + "mentionPopover.scanningFiles": { + "defaultMessage": "Escaneando archivos..." + }, + "messageCopyLink.copied": { + "defaultMessage": "¡Copiado!" + }, + "messageCopyLink.copy": { + "defaultMessage": "Copiar" + }, + "messageQueue.cancel": { + "defaultMessage": "Cancelar" + }, + "messageQueue.cannotSendWhileEditing": { + "defaultMessage": "No se puede enviar mientras editas" + }, + "messageQueue.clearAll": { + "defaultMessage": "Borrar todo" + }, + "messageQueue.clickToEdit": { + "defaultMessage": "{content} (Clic para editar)" + }, + "messageQueue.collapseQueue": { + "defaultMessage": "Contraer la cola" + }, + "messageQueue.dragToReorder": { + "defaultMessage": "Arrastra los mensajes para reordenar la prioridad" + }, + "messageQueue.expandQueue": { + "defaultMessage": "Expandir la cola" + }, + "messageQueue.messageCount": { + "defaultMessage": "{count,plural,one{# mensaje {status}} other{# mensajes {status}}}" + }, + "messageQueue.messageQueue": { + "defaultMessage": "Cola de mensajes" + }, + "messageQueue.next": { + "defaultMessage": "Siguiente" + }, + "messageQueue.paused": { + "defaultMessage": "Pausado" + }, + "messageQueue.queuePaused": { + "defaultMessage": "Cola pausada" + }, + "messageQueue.queuePausedCompact": { + "defaultMessage": "Cola pausada: haz clic en \"Enviar\" o añade un nuevo mensaje para reanudar" + }, + "messageQueue.queuePausedExpanded": { + "defaultMessage": "Cola pausada por interrupción. Usa \"Enviar ahora\" o añade un nuevo mensaje para reanudar." + }, + "messageQueue.queued": { + "defaultMessage": "en cola" + }, + "messageQueue.removeFromQueue": { + "defaultMessage": "Quitar este mensaje de la cola" + }, + "messageQueue.save": { + "defaultMessage": "Guardar" + }, + "messageQueue.sendNow": { + "defaultMessage": "Enviar este mensaje ahora" + }, + "messageQueue.stopAndSend": { + "defaultMessage": "Detener el procesamiento actual y enviar este mensaje ahora" + }, + "messageQueue.waiting": { + "defaultMessage": "esperando" + }, + "microphoneSelector.chooseDescription": { + "defaultMessage": "Elige qué micrófono usar para el dictado" + }, + "microphoneSelector.grantAccess": { + "defaultMessage": "Conceder acceso" + }, + "microphoneSelector.grantAccessDescription": { + "defaultMessage": "Concede acceso para ver los micrófonos disponibles" + }, + "microphoneSelector.microphone": { + "defaultMessage": "Micrófono" + }, + "microphoneSelector.microphoneLabel": { + "defaultMessage": "Micrófono {index}" + }, + "microphoneSelector.selectedMicrophone": { + "defaultMessage": "Micrófono seleccionado" + }, + "microphoneSelector.speakToTest": { + "defaultMessage": "Habla para probar tu micrófono ({seconds}s)" + }, + "microphoneSelector.stop": { + "defaultMessage": "Detener" + }, + "microphoneSelector.systemDefault": { + "defaultMessage": "Predeterminado del sistema" + }, + "microphoneSelector.test": { + "defaultMessage": "Probar" + }, + "modeSelectionItem.autonomousDescription": { + "defaultMessage": "Capacidades completas de modificación de archivos: edita, crea y elimina archivos libremente." + }, + "modeSelectionItem.autonomousLabel": { + "defaultMessage": "Autónomo" + }, + "modeSelectionItem.chatOnlyDescription": { + "defaultMessage": "Interactúa con el proveedor seleccionado sin usar herramientas ni extensiones." + }, + "modeSelectionItem.chatOnlyLabel": { + "defaultMessage": "Solo chat" + }, + "modeSelectionItem.manualDescription": { + "defaultMessage": "Todas las herramientas, extensiones y modificaciones de archivos requerirán aprobación humana" + }, + "modeSelectionItem.manualLabel": { + "defaultMessage": "Manual" + }, + "modeSelectionItem.smartDescription": { + "defaultMessage": "Determina de forma inteligente qué acciones necesitan aprobación según el nivel de riesgo" + }, + "modeSelectionItem.smartLabel": { + "defaultMessage": "Inteligente" + }, + "modelAndProviderContext.modelChangeFailed": { + "defaultMessage": "{provider}/{model} falló" + }, + "modelAndProviderContext.modelChangedTitle": { + "defaultMessage": "Modelo cambiado" + }, + "modelAndProviderContext.selectModel": { + "defaultMessage": "Seleccionar modelo" + }, + "modelAndProviderContext.switchModelSuccess": { + "defaultMessage": "Modelos cambiados correctamente: usando {model} de {provider}" + }, + "modelAndProviderContext.unknownProviderMsg": { + "defaultMessage": "Proveedor desconocido en la configuración: revisa tu config.yaml" + }, + "modelAndProviderContext.unknownProviderTitle": { + "defaultMessage": "Búsqueda del nombre del proveedor" + }, + "modelSettingsButtons.configureProviders": { + "defaultMessage": "Configurar proveedores" + }, + "modelSettingsButtons.switchModels": { + "defaultMessage": "Cambiar de modelo" + }, + "modelSettingsPanel.batchSize": { + "defaultMessage": "Tamaño de lote" + }, + "modelSettingsPanel.batchSizeDescription": { + "defaultMessage": "Lote de procesamiento del prompt" + }, + "modelSettingsPanel.builtinChatTemplate": { + "defaultMessage": "Plantilla integrada" + }, + "modelSettingsPanel.builtinChatTemplateDescription": { + "defaultMessage": "Selecciona el nombre de una plantilla integrada de llama.cpp" + }, + "modelSettingsPanel.chatTemplate": { + "defaultMessage": "Plantilla de chat" + }, + "modelSettingsPanel.chatTemplateBuiltin": { + "defaultMessage": "Integrada" + }, + "modelSettingsPanel.chatTemplateCustomInline": { + "defaultMessage": "Personalizada en línea" + }, + "modelSettingsPanel.chatTemplateDescription": { + "defaultMessage": "Usa metadatos GGUF embebidos, una plantilla integrada de llama.cpp o Jinja en línea" + }, + "modelSettingsPanel.chatTemplateEmbedded": { + "defaultMessage": "Embebida" + }, + "modelSettingsPanel.contextAndGeneration": { + "defaultMessage": "Contexto y generación" + }, + "modelSettingsPanel.contextSize": { + "defaultMessage": "Tamaño del contexto" + }, + "modelSettingsPanel.contextSizeDescription": { + "defaultMessage": "Ventana de contexto máxima (0 = valor por defecto del modelo)" + }, + "modelSettingsPanel.customChatTemplate": { + "defaultMessage": "Plantilla de chat personalizada" + }, + "modelSettingsPanel.customChatTemplateDescription": { + "defaultMessage": "Pega el código fuente completo de la plantilla de chat de Jinja" + }, + "modelSettingsPanel.etaLearningRate": { + "defaultMessage": "Eta (tasa de aprendizaje)" + }, + "modelSettingsPanel.flashAttention": { + "defaultMessage": "Flash attention" + }, + "modelSettingsPanel.flashAttentionDescription": { + "defaultMessage": "Activar la optimización de flash attention" + }, + "modelSettingsPanel.frequencyPenalty": { + "defaultMessage": "Penalización por frecuencia" + }, + "modelSettingsPanel.frequencyPenaltyDescription": { + "defaultMessage": "0.0 = desactivado" + }, + "modelSettingsPanel.gpuLayers": { + "defaultMessage": "Capas de GPU" + }, + "modelSettingsPanel.gpuLayersDescription": { + "defaultMessage": "Capas a descargar en la GPU" + }, + "modelSettingsPanel.loadingSettings": { + "defaultMessage": "Cargando ajustes..." + }, + "modelSettingsPanel.lockModelInRam": { + "defaultMessage": "Bloquear el modelo en RAM (mlock)" + }, + "modelSettingsPanel.lockModelInRamDescription": { + "defaultMessage": "Evitar que el modelo se pase al disco (swap)" + }, + "modelSettingsPanel.maxOutputTokens": { + "defaultMessage": "Máximo de tokens de salida" + }, + "modelSettingsPanel.maxOutputTokensDescription": { + "defaultMessage": "Límite de tokens generados" + }, + "modelSettingsPanel.minP": { + "defaultMessage": "Min P" + }, + "modelSettingsPanel.performance": { + "defaultMessage": "Rendimiento" + }, + "modelSettingsPanel.presencePenalty": { + "defaultMessage": "Penalización por presencia" + }, + "modelSettingsPanel.presencePenaltyDescription": { + "defaultMessage": "0.0 = desactivado" + }, + "modelSettingsPanel.repeatPenalty": { + "defaultMessage": "Penalización por repetición" + }, + "modelSettingsPanel.repeatPenaltyDescription": { + "defaultMessage": "1.0 = desactivado" + }, + "modelSettingsPanel.repeatWindow": { + "defaultMessage": "Ventana de repetición" + }, + "modelSettingsPanel.repeatWindowDescription": { + "defaultMessage": "Tokens hacia atrás a considerar" + }, + "modelSettingsPanel.repetitionPenalty": { + "defaultMessage": "Penalización por repetición" + }, + "modelSettingsPanel.reset": { + "defaultMessage": "Restablecer" + }, + "modelSettingsPanel.resetToDefaults": { + "defaultMessage": "Restablecer a los valores predeterminados" + }, + "modelSettingsPanel.samplingStrategy": { + "defaultMessage": "Estrategia de muestreo" + }, + "modelSettingsPanel.saving": { + "defaultMessage": "Guardando..." + }, + "modelSettingsPanel.seed": { + "defaultMessage": "Semilla" + }, + "modelSettingsPanel.tauTargetEntropy": { + "defaultMessage": "Tau (entropía objetivo)" + }, + "modelSettingsPanel.temperature": { + "defaultMessage": "Temperatura" + }, + "modelSettingsPanel.threads": { + "defaultMessage": "Hilos" + }, + "modelSettingsPanel.threadsDescription": { + "defaultMessage": "Hilos de CPU para la generación" + }, + "modelSettingsPanel.toolCalling": { + "defaultMessage": "Llamada a herramientas" + }, + "modelSettingsPanel.toolCallingAuto": { + "defaultMessage": "Auto" + }, + "modelSettingsPanel.toolCallingDescription": { + "defaultMessage": "Elige cómo los modelos locales seleccionan la llamada a herramientas nativa o emulada" + }, + "modelSettingsPanel.toolCallingForceEmulated": { + "defaultMessage": "Forzar emulada" + }, + "modelSettingsPanel.toolCallingForceNative": { + "defaultMessage": "Forzar nativa" + }, + "modelSettingsPanel.topK": { + "defaultMessage": "Top K" + }, + "modelSettingsPanel.topP": { + "defaultMessage": "Top P" + }, + "modelsBottomBar.changeModel": { + "defaultMessage": "Cambiar modelo" + }, + "modelsBottomBar.currentModel": { + "defaultMessage": "Modelo actual" + }, + "modelsBottomBar.loadingModel": { + "defaultMessage": "Cargando modelo..." + }, + "modelsBottomBar.localModelSettings": { + "defaultMessage": "Ajustes del modelo local" + }, + "modelsBottomBar.localModelSettingsTitle": { + "defaultMessage": "Ajustes del modelo local — {modelName}" + }, + "modelsBottomBar.resolvedModel": { + "defaultMessage": "Modelo resuelto" + }, + "modelsBottomBar.selectModel": { + "defaultMessage": "Seleccionar modelo" + }, + "modelsSection.resetDescription": { + "defaultMessage": "Borra el modelo y los ajustes de proveedor seleccionados para empezar de cero" + }, + "modelsSection.resetTitle": { + "defaultMessage": "Restablecer proveedor y modelo" + }, + "navigation.itemApps": { + "defaultMessage": "Apps" + }, + "navigation.itemExtensions": { + "defaultMessage": "Extensiones" + }, + "navigation.itemHome": { + "defaultMessage": "Nuevo chat" + }, + "navigation.itemRecipes": { + "defaultMessage": "Recetas" + }, + "navigation.itemScheduler": { + "defaultMessage": "Programador" + }, + "navigation.itemSessions": { + "defaultMessage": "Historial de sesiones" + }, + "navigation.itemSettings": { + "defaultMessage": "Ajustes" + }, + "navigation.itemSkills": { + "defaultMessage": "Skills" + }, + "navigationPanel.chats": { + "defaultMessage": "Chats" + }, + "navigationPanel.noChats": { + "defaultMessage": "No hay chats recientes" + }, + "navigationPanel.untitledSession": { + "defaultMessage": "Sesión sin título" + }, + "onboardingGuard.checkProviderErrorDescription": { + "defaultMessage": "Puede que el servidor se esté iniciando o no esté disponible temporalmente." + }, + "onboardingGuard.checkProviderErrorTitle": { + "defaultMessage": "No se pudo conectar con el servidor de Goose" + }, + "onboardingGuard.retry": { + "defaultMessage": "Reintentar" + }, + "onboardingGuard.welcomeDescription": { + "defaultMessage": "Tu agente de IA local. Conecta un proveedor de modelos de IA para empezar." + }, + "onboardingGuard.welcomeTitle": { + "defaultMessage": "Te damos la bienvenida a goose" + }, + "onboardingSuccess.allSet": { + "defaultMessage": "Todo está listo para que empieces a usar goose." + }, + "onboardingSuccess.connectedTo": { + "defaultMessage": "Conectado a {providerName}" + }, + "onboardingSuccess.getStarted": { + "defaultMessage": "Empezar" + }, + "onboardingSuccess.learnMore": { + "defaultMessage": "Más información" + }, + "onboardingSuccess.localModelReady": { + "defaultMessage": "Modelo local listo" + }, + "onboardingSuccess.privacyDescription": { + "defaultMessage": "Los datos de uso anónimos ayudan a mejorar goose. Nunca recopilamos tus conversaciones, código ni datos personales." + }, + "onboardingSuccess.privacyTitle": { + "defaultMessage": "Privacidad" + }, + "onboardingSuccess.shareUsageData": { + "defaultMessage": "Compartir datos de uso anónimos" + }, + "parameterInput.defaultValue": { + "defaultMessage": "Valor por defecto" + }, + "parameterInput.defaultValuePlaceholder": { + "defaultMessage": "Introduce el valor por defecto" + }, + "parameterInput.deleteParameter": { + "defaultMessage": "Eliminar parámetro: {key}" + }, + "parameterInput.description": { + "defaultMessage": "descripción" + }, + "parameterInput.descriptionHelp": { + "defaultMessage": "Este es el mensaje que verá el usuario final." + }, + "parameterInput.descriptionPlaceholder": { + "defaultMessage": "Ej.: \"Introduce el nombre del nuevo componente\"" + }, + "parameterInput.inputType": { + "defaultMessage": "Tipo de entrada" + }, + "parameterInput.optional": { + "defaultMessage": "Opcional" + }, + "parameterInput.optionsHelp": { + "defaultMessage": "Introduce cada opción en una línea nueva. Se mostrarán como opciones de un menú desplegable." + }, + "parameterInput.optionsLabel": { + "defaultMessage": "Opciones (una por línea)" + }, + "parameterInput.optionsPlaceholder": { + "defaultMessage": "Opción 1 Opción 2 Opción 3" + }, + "parameterInput.required": { + "defaultMessage": "Obligatorio" + }, + "parameterInput.requirement": { + "defaultMessage": "Requisito" + }, + "parameterInput.typeBoolean": { + "defaultMessage": "Booleano" + }, + "parameterInput.typeNumber": { + "defaultMessage": "Número" + }, + "parameterInput.typeSelect": { + "defaultMessage": "Selección" + }, + "parameterInput.typeString": { + "defaultMessage": "Cadena" + }, + "parameterInput.unused": { + "defaultMessage": "Sin usar" + }, + "parameterInput.unusedWarningTitle": { + "defaultMessage": "Este parámetro no se usa en las instrucciones ni en el prompt. Estará disponible para entrada manual, pero puede que no sea necesario." + }, + "parameterInputModal.backToForm": { + "defaultMessage": "Volver al formulario de parámetros" + }, + "parameterInputModal.cancel": { + "defaultMessage": "Cancelar" + }, + "parameterInputModal.cancelRecipeSetup": { + "defaultMessage": "Cancelar configuración de la receta" + }, + "parameterInputModal.enterValue": { + "defaultMessage": "Introduce el valor para {key}..." + }, + "parameterInputModal.false": { + "defaultMessage": "Falso" + }, + "parameterInputModal.recipeParameters": { + "defaultMessage": "Parámetros de la receta" + }, + "parameterInputModal.select": { + "defaultMessage": "Seleccionar..." + }, + "parameterInputModal.selectOption": { + "defaultMessage": "Selecciona una opción..." + }, + "parameterInputModal.startNewChat": { + "defaultMessage": "Iniciar nuevo chat (sin receta)" + }, + "parameterInputModal.startRecipe": { + "defaultMessage": "Iniciar receta" + }, + "parameterInputModal.true": { + "defaultMessage": "Verdadero" + }, + "parameterInputModal.whatToDo": { + "defaultMessage": "¿Qué te gustaría hacer?" + }, + "permissionModal.alwaysAllow": { + "defaultMessage": "Permitir siempre" + }, + "permissionModal.askBefore": { + "defaultMessage": "Preguntar antes" + }, + "permissionModal.cancel": { + "defaultMessage": "Cancelar" + }, + "permissionModal.close": { + "defaultMessage": "Cerrar" + }, + "permissionModal.failedToLoadTools": { + "defaultMessage": "No se pudieron cargar las herramientas" + }, + "permissionModal.failedToLoadToolsDescription": { + "defaultMessage": "No se pudieron cargar las herramientas de esta extensión. Puede que la extensión no esté cargada en la sesión actual." + }, + "permissionModal.neverAllow": { + "defaultMessage": "No permitir nunca" + }, + "permissionModal.noActiveSession": { + "defaultMessage": "No hay sesión activa" + }, + "permissionModal.noActiveSessionDescription": { + "defaultMessage": "Inicia primero una sesión de chat para configurar los permisos de herramientas de esta extensión. Los permisos de herramientas se cargan desde las extensiones de la sesión activa." + }, + "permissionModal.noToolsAvailable": { + "defaultMessage": "No hay herramientas disponibles para esta extensión." + }, + "permissionModal.saveChanges": { + "defaultMessage": "Guardar cambios" + }, + "permissionRulesModal.description": { + "defaultMessage": "Configura los permisos de herramientas de las extensiones para controlar cómo interactúan con tu sistema." + }, + "permissionRulesModal.extensionRules": { + "defaultMessage": "Reglas de extensiones" + }, + "permissionRulesModal.title": { + "defaultMessage": "Reglas de permisos" + }, + "permissionSetting.extensionRules": { + "defaultMessage": "Reglas de extensiones" + }, + "permissionSetting.permissionRules": { + "defaultMessage": "Reglas de permisos" + }, + "permissionSetting.permissionRulesDescription": { + "defaultMessage": "Instrucciones ocultas que se pasarán al proveedor para ayudar a dirigir y añadir contexto a tus respuestas." + }, + "privacyInfoModal.collectErrors": { + "defaultMessage": "Tipos de error (ej.: \"rate_limit\", \"auth\" - sin detalles)" + }, + "privacyInfoModal.collectExtensions": { + "defaultMessage": "Extensiones y recuentos de uso de herramientas (solo nombres)" + }, + "privacyInfoModal.collectOs": { + "defaultMessage": "Sistema operativo, versión y arquitectura" + }, + "privacyInfoModal.collectProvider": { + "defaultMessage": "Proveedor y modelo usados" + }, + "privacyInfoModal.collectSession": { + "defaultMessage": "Métricas de sesión (duración, número de interacciones, uso de tokens)" + }, + "privacyInfoModal.collectVersion": { + "defaultMessage": "Versión de goose y método de instalación" + }, + "privacyInfoModal.description": { + "defaultMessage": "Los datos de uso anónimos nos ayudan a entender cómo se usa goose e identificar áreas de mejora." + }, + "privacyInfoModal.neverCollect": { + "defaultMessage": "Nunca recopilamos tus conversaciones, código, argumentos de herramientas, mensajes de error ni ningún dato personal. Puedes cambiar este ajuste en cualquier momento en Ajustes." + }, + "privacyInfoModal.title": { + "defaultMessage": "Detalles de privacidad" + }, + "privacyInfoModal.whatWeCollect": { + "defaultMessage": "Lo que recopilamos:" + }, + "progressiveMessageList.loadingMessages": { + "defaultMessage": "Cargando mensajes... ({renderedCount}/{totalCount})" + }, + "progressiveMessageList.modelChanged": { + "defaultMessage": "Modelo cambiado: {previousModel} → {currentModel}" + }, + "progressiveMessageList.searchHint": { + "defaultMessage": "Pulsa Cmd/Ctrl+F para cargar todos los mensajes de inmediato y buscar" + }, + "promptsSettings.allPromptsReset": { + "defaultMessage": "Todos los prompts se restablecieron a sus valores por defecto" + }, + "promptsSettings.backToList": { + "defaultMessage": "Volver a la lista" + }, + "promptsSettings.confirmReplaceWithDefault": { + "defaultMessage": "¿Reemplazar el contenido actual con el predeterminado? Tus cambios se perderán." + }, + "promptsSettings.confirmResetAll": { + "defaultMessage": "¿Seguro que quieres restablecer todos los prompts a sus valores por defecto? Esto no se puede deshacer." + }, + "promptsSettings.confirmResetOne": { + "defaultMessage": "¿Seguro que quieres restablecer este prompt a su valor por defecto? Esto no se puede deshacer." + }, + "promptsSettings.confirmUnsavedBack": { + "defaultMessage": "Tienes cambios sin guardar. ¿Seguro que quieres volver?" + }, + "promptsSettings.customized": { + "defaultMessage": "Personalizado" + }, + "promptsSettings.edit": { + "defaultMessage": "Editar" + }, + "promptsSettings.editPromptTitle": { + "defaultMessage": "Editar: {name}" + }, + "promptsSettings.editingLabel": { + "defaultMessage": "Editando: {name}" + }, + "promptsSettings.enterPromptContent": { + "defaultMessage": "Introduce el contenido del prompt..." + }, + "promptsSettings.failedToLoadPrompt": { + "defaultMessage": "No se pudo cargar el prompt" + }, + "promptsSettings.failedToLoadPrompts": { + "defaultMessage": "No se pudieron cargar los prompts" + }, + "promptsSettings.failedToResetPrompt": { + "defaultMessage": "No se pudo restablecer el prompt" + }, + "promptsSettings.failedToResetPrompts": { + "defaultMessage": "No se pudieron restablecer los prompts" + }, + "promptsSettings.failedToSavePrompt": { + "defaultMessage": "No se pudo guardar el prompt" + }, + "promptsSettings.promptEditingDescription": { + "defaultMessage": "Personaliza los prompts que definen el comportamiento de goose en distintos contextos. Estos prompts usan la sintaxis de plantillas Jinja2. Ten cuidado al modificar las variables de plantilla, ya que los cambios incorrectos pueden romper la funcionalidad. Comparte cualquier mejora con la comunidad." + }, + "promptsSettings.promptEditingTitle": { + "defaultMessage": "Edición de prompts" + }, + "promptsSettings.promptResetToDefault": { + "defaultMessage": "Prompt restablecido a su valor por defecto" + }, + "promptsSettings.promptSaved": { + "defaultMessage": "Prompt guardado" + }, + "promptsSettings.resetAll": { + "defaultMessage": "Restablecer todo" + }, + "promptsSettings.resetToDefault": { + "defaultMessage": "Restablecer al valor por defecto" + }, + "promptsSettings.restoreDefault": { + "defaultMessage": "Restaurar valor por defecto" + }, + "promptsSettings.save": { + "defaultMessage": "Guardar" + }, + "promptsSettings.templateTip": { + "defaultMessage": "Las variables de plantilla como {extensionsExample} o {forExample} se reemplazan por valores reales en tiempo de ejecución. Ten cuidado de no quitar las variables obligatorias." + }, + "promptsSettings.unsavedChanges": { + "defaultMessage": "Tienes cambios sin guardar" + }, + "providerCard.noMetadata": { + "defaultMessage": "Error de ProviderCard: no se proporcionaron metadatos" + }, + "providerCard.unknownProvider": { + "defaultMessage": "Proveedor desconocido" + }, + "providerCatalogPicker.anthropicCompatible": { + "defaultMessage": "Compatible con Anthropic" + }, + "providerCatalogPicker.apiFormat": { + "defaultMessage": "Formato de API" + }, + "providerCatalogPicker.cancel": { + "defaultMessage": "Cancelar" + }, + "providerCatalogPicker.chooseProvider": { + "defaultMessage": "Elegir proveedor" + }, + "providerCatalogPicker.errorPrefix": { + "defaultMessage": "Error: {error}" + }, + "providerCatalogPicker.loadingProviders": { + "defaultMessage": "Cargando proveedores..." + }, + "providerCatalogPicker.modelsAvailable": { + "defaultMessage": "{count} modelos disponibles" + }, + "providerCatalogPicker.noProvidersAvailable": { + "defaultMessage": "No hay proveedores disponibles" + }, + "providerCatalogPicker.noProvidersFound": { + "defaultMessage": "No se encontraron proveedores para \"{query}\"" + }, + "providerCatalogPicker.openaiCompatible": { + "defaultMessage": "Compatible con OpenAI" + }, + "providerCatalogPicker.requiresEnvVar": { + "defaultMessage": "• Requiere {envVar}" + }, + "providerCatalogPicker.searchProviders": { + "defaultMessage": "Buscar proveedores..." + }, + "providerCatalogPicker.selectFormatDescription": { + "defaultMessage": "Selecciona un formato de API y un proveedor. Rellenaremos la configuración por ti automáticamente." + }, + "providerConfigForm.browserWindowOpen": { + "defaultMessage": "Se abrirá una ventana del navegador para que completes el inicio de sesión." + }, + "providerConfigForm.configuring": { + "defaultMessage": "Configurando..." + }, + "providerConfigForm.continue": { + "defaultMessage": "Continuar" + }, + "providerConfigForm.deviceCodeFlowHint": { + "defaultMessage": "Se abrirá una ventana del navegador y el código de verificación se copiará en tu portapapeles. Pégalo en el navegador para completar el inicio de sesión." + }, + "providerConfigForm.noApiKey": { + "defaultMessage": "¿No tienes una clave de API?" + }, + "providerConfigForm.signInWith": { + "defaultMessage": "Iniciar sesión con {providerName}" + }, + "providerConfigForm.signingIn": { + "defaultMessage": "Iniciando sesión..." + }, + "providerConfigurationModal.addApiKeyDescription": { + "defaultMessage": "Añade tu(s) clave(s) de API de este proveedor para integrarlo en goose" + }, + "providerConfigurationModal.browserWindowHint": { + "defaultMessage": "Se abrirá una ventana del navegador para que completes el inicio de sesión." + }, + "providerConfigurationModal.cancel": { + "defaultMessage": "Cancelar" + }, + "providerConfigurationModal.cannotDeleteActive": { + "defaultMessage": "No puedes eliminar este proveedor mientras está en uso. Cambia primero a un modelo diferente." + }, + "providerConfigurationModal.checkConfigAgain": { + "defaultMessage": "Vuelve a comprobar tu configuración para usar este proveedor." + }, + "providerConfigurationModal.close": { + "defaultMessage": "Cerrar" + }, + "providerConfigurationModal.configureHeader": { + "defaultMessage": "Configurar {providerName}" + }, + "providerConfigurationModal.deleteConfigHeader": { + "defaultMessage": "Eliminar la configuración de {providerName}" + }, + "providerConfigurationModal.deleteConfirmation": { + "defaultMessage": "Esto eliminará permanentemente la configuración actual del proveedor." + }, + "providerConfigurationModal.deviceCodeFlowHint": { + "defaultMessage": "Se abrirá una ventana del navegador y el código de verificación se copiará en tu portapapeles. Pégalo en el navegador para completar el inicio de sesión." + }, + "providerConfigurationModal.errorCheckingConfig": { + "defaultMessage": "Hubo un error al comprobar la configuración de este proveedor." + }, + "providerConfigurationModal.errorTitle": { + "defaultMessage": "Error" + }, + "providerConfigurationModal.externalSetupIntro": { + "defaultMessage": "Este proveedor se configura fuera de goose. Sigue estos pasos:" + }, + "providerConfigurationModal.goBack": { + "defaultMessage": "Volver" + }, + "providerConfigurationModal.huggingFaceOAuthDescription": { + "defaultMessage": "Inicia sesión para usar los Inference Providers de Hugging Face sin introducir manualmente un token de API." + }, + "providerConfigurationModal.oauthLoginFailed": { + "defaultMessage": "Falló el inicio de sesión con OAuth: {error}" + }, + "providerConfigurationModal.oauthSignInDescription": { + "defaultMessage": "Inicia sesión con tu cuenta de {providerName} para usar este proveedor" + }, + "providerConfigurationModal.parameterRequired": { + "defaultMessage": "{paramName} es obligatorio" + }, + "providerConfigurationModal.removeConfiguration": { + "defaultMessage": "Quitar configuración" + }, + "providerConfigurationModal.seeDocumentation": { + "defaultMessage": "Consulta la documentación para más detalles." + }, + "providerConfigurationModal.signInWith": { + "defaultMessage": "Iniciar sesión con {providerName}" + }, + "providerConfigurationModal.signingIn": { + "defaultMessage": "Iniciando sesión..." + }, + "providerGrid.addProvider": { + "defaultMessage": "Añadir proveedor" + }, + "providerGrid.addProviderTitle": { + "defaultMessage": "Añadir proveedor" + }, + "providerGrid.chooseModel": { + "defaultMessage": "Elegir modelo" + }, + "providerGrid.configureProvider": { + "defaultMessage": "Configurar proveedor" + }, + "providerGrid.editProvider": { + "defaultMessage": "Editar proveedor" + }, + "providerGrid.fromTemplateOrManual": { + "defaultMessage": "Desde plantilla o configuración manual" + }, + "providerLogo.alt": { + "defaultMessage": "Logotipo de {providerName}" + }, + "providerSelector.addCustomProvider": { + "defaultMessage": "Añadir un proveedor personalizado" + }, + "providerSelector.addCustomProviderTitle": { + "defaultMessage": "Añadir proveedor personalizado" + }, + "providerSelector.connectProvider": { + "defaultMessage": "Conectar a un proveedor" + }, + "providerSelector.connectProviderDescription": { + "defaultMessage": "Conecta OpenAI, Anthropic, Google, etc." + }, + "providerSelector.freeLocalDescription": { + "defaultMessage": "Usa un modelo local o un proveedor con créditos gratis" + }, + "providerSelector.selectProvider": { + "defaultMessage": "Selecciona un proveedor" + }, + "providerSelector.useFreeLocal": { + "defaultMessage": "Usar proveedores gratuitos/locales" + }, + "providerSettings.configurationSettings": { + "defaultMessage": "Ajustes de configuración del proveedor" + }, + "providerSettings.loadingProviders": { + "defaultMessage": "Cargando proveedores..." + }, + "providerSettings.onboardingDescription": { + "defaultMessage": "Selecciona un proveedor de modelos de IA para empezar con goose. Necesitarás usar claves de API generadas por cada proveedor, que se cifrarán y se almacenarán localmente. Puedes cambiar de proveedor en cualquier momento en los ajustes." + }, + "providerSettings.otherProviders": { + "defaultMessage": "Otros proveedores" + }, + "providerSetupActions.cancel": { + "defaultMessage": "Cancelar" + }, + "providerSetupActions.cannotDeleteActive": { + "defaultMessage": "No puedes eliminar {providerName} mientras está en uso. Cambia a un modelo diferente antes de eliminar este proveedor." + }, + "providerSetupActions.confirmDelete": { + "defaultMessage": "Confirmar eliminación" + }, + "providerSetupActions.confirmDeleteMessage": { + "defaultMessage": "¿Seguro que quieres eliminar los parámetros de configuración de {providerName}? Esta acción no se puede deshacer." + }, + "providerSetupActions.deleteProvider": { + "defaultMessage": "Eliminar proveedor" + }, + "providerSetupActions.enableProvider": { + "defaultMessage": "Activar proveedor" + }, + "providerSetupActions.ok": { + "defaultMessage": "Aceptar" + }, + "providerSetupActions.submit": { + "defaultMessage": "Enviar" + }, + "recipeActivityEditor.activitiesDescription": { + "defaultMessage": "Los prompts principales y los botones de actividad que se mostrarán en la ventana de chat de la receta." + }, + "recipeActivityEditor.activitiesLabel": { + "defaultMessage": "Actividades" + }, + "recipeActivityEditor.activityButtonsDescription": { + "defaultMessage": "Botones en los que se puede hacer clic que aparecerán debajo del mensaje para ayudar a los usuarios a interactuar con tu receta." + }, + "recipeActivityEditor.activityButtonsLabel": { + "defaultMessage": "Botones de actividad" + }, + "recipeActivityEditor.addActivity": { + "defaultMessage": "Añadir actividad" + }, + "recipeActivityEditor.addNewActivityPlaceholder": { + "defaultMessage": "Añadir nueva actividad..." + }, + "recipeActivityEditor.messageDescription": { + "defaultMessage": "Un mensaje con formato que aparecerá en la parte superior de la receta. Admite formato markdown." + }, + "recipeActivityEditor.messageLabel": { + "defaultMessage": "Mensaje" + }, + "recipeActivityEditor.messagePlaceholder": { + "defaultMessage": "Introduce un mensaje de introducción de cara al usuario para tu receta (admite **negrita**, *cursiva*, `code`, etc.)" + }, + "recipeExtensionSelector.description": { + "defaultMessage": "Selecciona qué extensiones deben estar disponibles al ejecutar esta receta. Déjalo vacío para usar las extensiones por defecto." + }, + "recipeExtensionSelector.extensionsSelected": { + "defaultMessage": "{count,plural,one{# extensión seleccionada} other{# extensiones seleccionadas}}" + }, + "recipeExtensionSelector.label": { + "defaultMessage": "Extensiones (opcional)" + }, + "recipeExtensionSelector.noExtensionsAvailable": { + "defaultMessage": "No hay extensiones disponibles" + }, + "recipeExtensionSelector.noExtensionsFound": { + "defaultMessage": "No se encontraron extensiones" + }, + "recipeExtensionSelector.searchPlaceholder": { + "defaultMessage": "Buscar extensiones..." + }, + "recipeFormFields.addParameter": { + "defaultMessage": "Añadir parámetro" + }, + "recipeFormFields.advancedOptions": { + "defaultMessage": "Opciones avanzadas" + }, + "recipeFormFields.advancedOptionsHint": { + "defaultMessage": "Actividades, parámetros, modelo, extensiones, schema de respuesta, subrecetas" + }, + "recipeFormFields.descriptionLabel": { + "defaultMessage": "Descripción" + }, + "recipeFormFields.descriptionPlaceholder": { + "defaultMessage": "Breve descripción de lo que hace esta receta" + }, + "recipeFormFields.enterValueFor": { + "defaultMessage": "Introduce el valor para {key}" + }, + "recipeFormFields.initialPrompt": { + "defaultMessage": "Prompt inicial" + }, + "recipeFormFields.instructionsLabel": { + "defaultMessage": "Instrucciones" + }, + "recipeFormFields.instructionsPlaceholder": { + "defaultMessage": "Instrucciones detalladas para la IA, ocultas al usuario" + }, + "recipeFormFields.openEditor": { + "defaultMessage": "Abrir editor" + }, + "recipeFormFields.parameterNamePlaceholder": { + "defaultMessage": "Introduce el nombre del parámetro..." + }, + "recipeFormFields.parametersDescription": { + "defaultMessage": "Los parámetros se detectarán automáticamente a partir de la sintaxis '{{parameter_name}}' en las instrucciones/prompt/actividades, o puedes añadirlos manualmente a continuación." + }, + "recipeFormFields.parametersLabel": { + "defaultMessage": "Parámetros" + }, + "recipeFormFields.promptOptionalHint": { + "defaultMessage": "(Opcional: se requieren las instrucciones o el prompt)" + }, + "recipeFormFields.promptPlaceholder": { + "defaultMessage": "Prompt rellenado previamente cuando se inicia la receta" + }, + "recipeFormFields.responseJsonSchema": { + "defaultMessage": "Schema JSON de respuesta" + }, + "recipeFormFields.responseJsonSchemaDescription": { + "defaultMessage": "Define la estructura esperada de la respuesta de la IA con el formato JSON Schema" + }, + "recipeFormFields.templateVarHint": { + "defaultMessage": "Usa '{{parameter_name}}' para definir parámetros que se puedan rellenar al ejecutar la receta." + }, + "recipeFormFields.titleLabel": { + "defaultMessage": "Título" + }, + "recipeFormFields.titlePlaceholder": { + "defaultMessage": "Título de la receta" + }, + "recipeHeader.recipeLabel": { + "defaultMessage": "Receta" + }, + "recipeModelSelector.backToModelList": { + "defaultMessage": "Volver a la lista de modelos" + }, + "recipeModelSelector.enterCustomModel": { + "defaultMessage": "Introduce un nombre de modelo personalizado" + }, + "recipeModelSelector.enterModelNotListed": { + "defaultMessage": "Introduce un modelo que no esté en la lista..." + }, + "recipeModelSelector.fetchError": { + "defaultMessage": "No se pudieron obtener los modelos. Vuelve a intentarlo más tarde." + }, + "recipeModelSelector.loadingModels": { + "defaultMessage": "Cargando modelos…" + }, + "recipeModelSelector.modelHint": { + "defaultMessage": "Déjalo vacío para usar el modelo por defecto del proveedor seleccionado" + }, + "recipeModelSelector.modelLabel": { + "defaultMessage": "Modelo (opcional)" + }, + "recipeModelSelector.providerHint": { + "defaultMessage": "Déjalo vacío para usar el proveedor por defecto configurado en los ajustes" + }, + "recipeModelSelector.providerLabel": { + "defaultMessage": "Proveedor (opcional)" + }, + "recipeModelSelector.selectModel": { + "defaultMessage": "Selecciona un modelo" + }, + "recipeModelSelector.selectProvider": { + "defaultMessage": "Selecciona un proveedor" + }, + "recipeModelSelector.useDefaultProvider": { + "defaultMessage": "Usar el proveedor por defecto" + }, + "recipeNameField.defaultLabel": { + "defaultMessage": "Nombre de la receta" + }, + "recipeNameField.formatHint": { + "defaultMessage": "Se formateará automáticamente (minúsculas, guiones en lugar de espacios)" + }, + "recipeWarningModal.cancel": { + "defaultMessage": "Cancelar" + }, + "recipeWarningModal.descriptionLabel": { + "defaultMessage": "Descripción:" + }, + "recipeWarningModal.firstTimeDescription": { + "defaultMessage": "Estás a punto de ejecutar una receta que no has ejecutado antes." + }, + "recipeWarningModal.hiddenCharsWarning": { + "defaultMessage": "Esta receta contiene caracteres ocultos que se ignorarán por tu seguridad, ya que podrían usarse con fines maliciosos." + }, + "recipeWarningModal.instructionsLabel": { + "defaultMessage": "Instrucciones:" + }, + "recipeWarningModal.newRecipeWarningTitle": { + "defaultMessage": "⚠️ Advertencia de receta nueva" + }, + "recipeWarningModal.recipePreview": { + "defaultMessage": "Vista previa de la receta:" + }, + "recipeWarningModal.securityWarningTitle": { + "defaultMessage": "⚠️ Advertencia de seguridad" + }, + "recipeWarningModal.titleLabel": { + "defaultMessage": "Título:" + }, + "recipeWarningModal.trustAndExecute": { + "defaultMessage": "Confiar y ejecutar" + }, + "recipeWarningModal.trustSource": { + "defaultMessage": "Continúa solo si confías en el origen de esta receta." + }, + "recipesView.addSchedule": { + "defaultMessage": "Añadir programación" + }, + "recipesView.addSlashCommand": { + "defaultMessage": "Añadir comando de barra" + }, + "recipesView.adjustSearchTerms": { + "defaultMessage": "Prueba a ajustar tus términos de búsqueda" + }, + "recipesView.allFiles": { + "defaultMessage": "Todos los archivos" + }, + "recipesView.cancel": { + "defaultMessage": "Cancelar" + }, + "recipesView.copyDeeplink": { + "defaultMessage": "Copiar deeplink" + }, + "recipesView.copyDeeplinkFailedMsg": { + "defaultMessage": "No se pudo copiar el deeplink al portapapeles" + }, + "recipesView.copyFailedTitle": { + "defaultMessage": "Error al copiar" + }, + "recipesView.copyYaml": { + "defaultMessage": "Copiar YAML" + }, + "recipesView.copyYamlFailedMsg": { + "defaultMessage": "No se pudo copiar el YAML de la receta al portapapeles" + }, + "recipesView.createRecipe": { + "defaultMessage": "Crear receta" + }, + "recipesView.deeplinkCopiedMsg": { + "defaultMessage": "El deeplink de la receta se copió al portapapeles" + }, + "recipesView.deeplinkCopiedTitle": { + "defaultMessage": "Deeplink copiado" + }, + "recipesView.deleteRecipe": { + "defaultMessage": "Eliminar receta" + }, + "recipesView.deleteRecipeConfirm": { + "defaultMessage": "¿Seguro que quieres eliminar \"{title}\"?" + }, + "recipesView.deleteRecipeDetail": { + "defaultMessage": "Se eliminará el archivo de la receta." + }, + "recipesView.deleteRecipeTitle": { + "defaultMessage": "Eliminar receta" + }, + "recipesView.editRecipe": { + "defaultMessage": "Editar receta" + }, + "recipesView.editSchedule": { + "defaultMessage": "Editar programación" + }, + "recipesView.editSlashCommand": { + "defaultMessage": "Editar comando de barra" + }, + "recipesView.errorLoadingRecipes": { + "defaultMessage": "Error al cargar las recetas" + }, + "recipesView.exportFailedMsg": { + "defaultMessage": "No se pudo exportar la receta al archivo" + }, + "recipesView.exportFailedTitle": { + "defaultMessage": "Falló la exportación" + }, + "recipesView.exportRecipeDialogTitle": { + "defaultMessage": "Exportar receta" + }, + "recipesView.exportToFile": { + "defaultMessage": "Exportar a archivo" + }, + "recipesView.noMatchingRecipes": { + "defaultMessage": "No se encontraron recetas que coincidan" + }, + "recipesView.noSavedRecipes": { + "defaultMessage": "No hay recetas guardadas" + }, + "recipesView.noSavedRecipesDescription": { + "defaultMessage": "Las recetas guardadas desde los chats aparecerán aquí." + }, + "recipesView.openInNewWindow": { + "defaultMessage": "Abrir en una ventana nueva" + }, + "recipesView.recipeDeletedSuccess": { + "defaultMessage": "Receta eliminada correctamente" + }, + "recipesView.recipeExportedMsg": { + "defaultMessage": "Receta guardada en {filePath}" + }, + "recipesView.recipeExportedTitle": { + "defaultMessage": "Receta exportada" + }, + "recipesView.recipesDescription": { + "defaultMessage": "Consulta y gestiona tus recetas guardadas para iniciar nuevas sesiones rápidamente con configuraciones predefinidas. {shortcut} para buscar." + }, + "recipesView.recipesTitle": { + "defaultMessage": "Recetas" + }, + "recipesView.remove": { + "defaultMessage": "Quitar" + }, + "recipesView.removeSchedule": { + "defaultMessage": "Quitar programación" + }, + "recipesView.runs": { + "defaultMessage": "Se ejecuta {schedule}" + }, + "recipesView.save": { + "defaultMessage": "Guardar" + }, + "recipesView.scheduleDialogTitle": { + "defaultMessage": "{action} programación" + }, + "recipesView.scheduleRemovedMsg": { + "defaultMessage": "La receta ya no se ejecutará automáticamente" + }, + "recipesView.scheduleRemovedTitle": { + "defaultMessage": "Programación eliminada" + }, + "recipesView.scheduleSavedMsg": { + "defaultMessage": "La receta se ejecutará {schedule}" + }, + "recipesView.scheduleSavedTitle": { + "defaultMessage": "Programación guardada" + }, + "recipesView.searchRecipesPlaceholder": { + "defaultMessage": "Buscar recetas..." + }, + "recipesView.shareRecipe": { + "defaultMessage": "Compartir receta" + }, + "recipesView.slashCommandDescription": { + "defaultMessage": "Define un comando de barra para ejecutar esta receta rápidamente desde cualquier chat" + }, + "recipesView.slashCommandPlaceholder": { + "defaultMessage": "nombre-del-comando" + }, + "recipesView.slashCommandRemovedMsg": { + "defaultMessage": "Se ha quitado el comando de barra de la receta" + }, + "recipesView.slashCommandRemovedTitle": { + "defaultMessage": "Comando de barra eliminado" + }, + "recipesView.slashCommandSavedMsg": { + "defaultMessage": "Usa /{command} para ejecutar esta receta" + }, + "recipesView.slashCommandSavedTitle": { + "defaultMessage": "Comando de barra guardado" + }, + "recipesView.slashCommandTitle": { + "defaultMessage": "Comando de barra" + }, + "recipesView.slashCommandUsageHint": { + "defaultMessage": "Usa /{command} en cualquier chat para ejecutar esta receta" + }, + "recipesView.tryAgain": { + "defaultMessage": "Reintentar" + }, + "recipesView.useRecipe": { + "defaultMessage": "Usar receta" + }, + "recipesView.yamlCopiedMsg": { + "defaultMessage": "El YAML de la receta se ha copiado al portapapeles" + }, + "recipesView.yamlCopiedTitle": { + "defaultMessage": "YAML copiado" + }, + "recipesView.yamlFiles": { + "defaultMessage": "Archivos YAML" + }, + "resetProviderSection.resetButton": { + "defaultMessage": "Restablecer proveedor y modelo" + }, + "resetProviderSection.resetDescription": { + "defaultMessage": "Esto borrará los ajustes de modelo y proveedor que seleccionaste. Si no hay valores predeterminados disponibles, te llevaremos a la pantalla de bienvenida para configurarlos de nuevo." + }, + "responseStyle.conciseDescription": { + "defaultMessage": "Las llamadas a herramientas aparecen cerradas por defecto y solo muestran la herramienta usada" + }, + "responseStyle.conciseLabel": { + "defaultMessage": "Conciso" + }, + "responseStyle.detailedDescription": { + "defaultMessage": "Las llamadas a herramientas aparecen abiertas por defecto para mostrar los detalles" + }, + "responseStyle.detailedLabel": { + "defaultMessage": "Detallado" + }, + "scheduleDetailView.actions": { + "defaultMessage": "Acciones" + }, + "scheduleDetailView.cannotModifyRunning": { + "defaultMessage": "No se puede activar ni modificar una programación mientras se está ejecutando." + }, + "scheduleDetailView.created": { + "defaultMessage": "Creada: {date}" + }, + "scheduleDetailView.cronExpression": { + "defaultMessage": "Expresión cron:" + }, + "scheduleDetailView.currentSession": { + "defaultMessage": "Sesión actual:" + }, + "scheduleDetailView.currentlyRunning": { + "defaultMessage": "Ejecutándose ahora" + }, + "scheduleDetailView.dir": { + "defaultMessage": "Dir: {path}" + }, + "scheduleDetailView.editSchedule": { + "defaultMessage": "Editar programación" + }, + "scheduleDetailView.errorPrefix": { + "defaultMessage": "Error: {error}" + }, + "scheduleDetailView.failedToLoadSession": { + "defaultMessage": "No se pudo cargar la sesión" + }, + "scheduleDetailView.idLabel": { + "defaultMessage": "ID:" + }, + "scheduleDetailView.inspectJobError": { + "defaultMessage": "Error al inspeccionar el trabajo" + }, + "scheduleDetailView.inspectNoInfo": { + "defaultMessage": "No hay información detallada disponible" + }, + "scheduleDetailView.inspectRunningJob": { + "defaultMessage": "Inspeccionar trabajo en ejecución" + }, + "scheduleDetailView.jobCancelled": { + "defaultMessage": "Trabajo cancelado" + }, + "scheduleDetailView.jobCancelledMsg": { + "defaultMessage": "El trabajo se canceló durante el arranque." + }, + "scheduleDetailView.jobInspection": { + "defaultMessage": "Inspección del trabajo" + }, + "scheduleDetailView.jobKilled": { + "defaultMessage": "Trabajo terminado" + }, + "scheduleDetailView.killJobError": { + "defaultMessage": "Error al terminar el trabajo" + }, + "scheduleDetailView.killRunningJob": { + "defaultMessage": "Terminar trabajo en ejecución" + }, + "scheduleDetailView.lastRun": { + "defaultMessage": "Última ejecución:" + }, + "scheduleDetailView.loadingSchedule": { + "defaultMessage": "Cargando programación..." + }, + "scheduleDetailView.loadingSessions": { + "defaultMessage": "Cargando sesiones..." + }, + "scheduleDetailView.messages": { + "defaultMessage": "Mensajes: {count}" + }, + "scheduleDetailView.newSession": { + "defaultMessage": "Nueva sesión: {sessionId}" + }, + "scheduleDetailView.noScheduleId": { + "defaultMessage": "No se proporcionó ningún ID de programación. Vuelve a la lista de programaciones." + }, + "scheduleDetailView.noSessions": { + "defaultMessage": "No se encontraron sesiones para esta programación." + }, + "scheduleDetailView.pauseSchedule": { + "defaultMessage": "Pausar programación" + }, + "scheduleDetailView.pauseUnpauseError": { + "defaultMessage": "Error al pausar/reanudar" + }, + "scheduleDetailView.paused": { + "defaultMessage": "En pausa" + }, + "scheduleDetailView.pausedMsg": { + "defaultMessage": "En pausa \"{id}\"" + }, + "scheduleDetailView.pausedWarning": { + "defaultMessage": "Esta programación está en pausa y no se ejecutará automáticamente. Usa \"Ejecutar programación ahora\" para activarla manualmente o reanúdala para retomar la ejecución automática." + }, + "scheduleDetailView.processStarted": { + "defaultMessage": "Proceso iniciado:" + }, + "scheduleDetailView.recentSessions": { + "defaultMessage": "Sesiones recientes" + }, + "scheduleDetailView.recipeSource": { + "defaultMessage": "Origen de la receta:" + }, + "scheduleDetailView.runScheduleError": { + "defaultMessage": "Error al ejecutar la programación" + }, + "scheduleDetailView.runScheduleNow": { + "defaultMessage": "Ejecutar programación ahora" + }, + "scheduleDetailView.scheduleDetails": { + "defaultMessage": "Detalles de la programación" + }, + "scheduleDetailView.scheduleInformation": { + "defaultMessage": "Información de la programación" + }, + "scheduleDetailView.scheduleLabel": { + "defaultMessage": "Programación:" + }, + "scheduleDetailView.scheduleNotFound": { + "defaultMessage": "Programación no encontrada" + }, + "scheduleDetailView.scheduleNotFoundError": { + "defaultMessage": "Programación no encontrada" + }, + "scheduleDetailView.schedulePaused": { + "defaultMessage": "Programación en pausa" + }, + "scheduleDetailView.scheduleTriggered": { + "defaultMessage": "Programación activada" + }, + "scheduleDetailView.scheduleUnpaused": { + "defaultMessage": "Programación reanudada" + }, + "scheduleDetailView.scheduleUpdated": { + "defaultMessage": "Programación actualizada" + }, + "scheduleDetailView.sessionId": { + "defaultMessage": "ID de sesión: {id}" + }, + "scheduleDetailView.tokens": { + "defaultMessage": "Tokens: {count}" + }, + "scheduleDetailView.unpauseSchedule": { + "defaultMessage": "Reanudar programación" + }, + "scheduleDetailView.unpausedMsg": { + "defaultMessage": "Reanudada \"{id}\"" + }, + "scheduleDetailView.updateScheduleError": { + "defaultMessage": "Error al actualizar la programación" + }, + "scheduleDetailView.updatedMsg": { + "defaultMessage": "Actualizada \"{id}\"" + }, + "scheduleDetailView.viewingScheduleId": { + "defaultMessage": "Viendo el ID de programación: {id}" + }, + "scheduleModal.browseYaml": { + "defaultMessage": "Buscar archivo YAML..." + }, + "scheduleModal.cancel": { + "defaultMessage": "Cancelar" + }, + "scheduleModal.createNewSchedule": { + "defaultMessage": "Crear nueva programación" + }, + "scheduleModal.createSchedule": { + "defaultMessage": "Crear programación" + }, + "scheduleModal.creating": { + "defaultMessage": "Creando..." + }, + "scheduleModal.deepLink": { + "defaultMessage": "Deep link" + }, + "scheduleModal.deepLinkPlaceholder": { + "defaultMessage": "Pega aquí el enlace goose://recipe..." + }, + "scheduleModal.editSchedule": { + "defaultMessage": "Editar programación" + }, + "scheduleModal.failedParseRecipe": { + "defaultMessage": "No se pudo analizar la receta del archivo." + }, + "scheduleModal.failedReadFile": { + "defaultMessage": "No se pudo leer el archivo seleccionado." + }, + "scheduleModal.invalidDeepLink": { + "defaultMessage": "Deep link no válido. Usa un enlace goose://recipe." + }, + "scheduleModal.invalidFileType": { + "defaultMessage": "Tipo de archivo no válido: selecciona un archivo YAML (.yaml o .yml)" + }, + "scheduleModal.nameLabel": { + "defaultMessage": "Nombre:" + }, + "scheduleModal.namePlaceholder": { + "defaultMessage": "p. ej., resumen-diario" + }, + "scheduleModal.provideValidRecipe": { + "defaultMessage": "Proporciona un origen de receta válido." + }, + "scheduleModal.recipeDescription": { + "defaultMessage": "Descripción: {description}" + }, + "scheduleModal.recipeParsed": { + "defaultMessage": "Receta analizada correctamente" + }, + "scheduleModal.recipeTitle": { + "defaultMessage": "Título: {title}" + }, + "scheduleModal.scheduleIdRequired": { + "defaultMessage": "Se requiere el ID de la programación." + }, + "scheduleModal.scheduleLabel": { + "defaultMessage": "Programación:" + }, + "scheduleModal.selected": { + "defaultMessage": "Seleccionado: {path}" + }, + "scheduleModal.sourceLabel": { + "defaultMessage": "Origen:" + }, + "scheduleModal.updateSchedule": { + "defaultMessage": "Actualizar programación" + }, + "scheduleModal.updating": { + "defaultMessage": "Actualizando..." + }, + "scheduleModal.yaml": { + "defaultMessage": "YAML" + }, + "schedulesView.confirmDelete": { + "defaultMessage": "¿Quitar la programación \"{id}\"? La receta se conservará." + }, + "schedulesView.description": { + "defaultMessage": "Crea y gestiona tareas programadas para ejecutar recetas automáticamente en los horarios que definas." + }, + "schedulesView.edit": { + "defaultMessage": "Editar" + }, + "schedulesView.errorPrefix": { + "defaultMessage": "Error: {error}" + }, + "schedulesView.inspect": { + "defaultMessage": "Inspeccionar" + }, + "schedulesView.inspectError": { + "defaultMessage": "Error al inspeccionar el trabajo" + }, + "schedulesView.inspectNoInfo": { + "defaultMessage": "No hay información detallada disponible para este trabajo" + }, + "schedulesView.jobInspection": { + "defaultMessage": "Inspección del trabajo" + }, + "schedulesView.jobKilled": { + "defaultMessage": "Trabajo terminado" + }, + "schedulesView.kill": { + "defaultMessage": "Terminar" + }, + "schedulesView.killError": { + "defaultMessage": "Error al terminar el trabajo" + }, + "schedulesView.lastRun": { + "defaultMessage": "Última ejecución: {date}" + }, + "schedulesView.noSchedules": { + "defaultMessage": "Aún no hay programaciones" + }, + "schedulesView.pause": { + "defaultMessage": "Pausar" + }, + "schedulesView.pauseError": { + "defaultMessage": "Error al pausar la programación" + }, + "schedulesView.paused": { + "defaultMessage": "En pausa" + }, + "schedulesView.refresh": { + "defaultMessage": "Actualizar" + }, + "schedulesView.refreshing": { + "defaultMessage": "Actualizando..." + }, + "schedulesView.resume": { + "defaultMessage": "Reanudar" + }, + "schedulesView.running": { + "defaultMessage": "En ejecución" + }, + "schedulesView.schedulePaused": { + "defaultMessage": "Programación en pausa" + }, + "schedulesView.schedulePausedMsg": { + "defaultMessage": "Se pausó correctamente la programación \"{id}\"" + }, + "schedulesView.scheduleUnpaused": { + "defaultMessage": "Programación reanudada" + }, + "schedulesView.scheduleUnpausedMsg": { + "defaultMessage": "Se reanudó correctamente la programación \"{id}\"" + }, + "schedulesView.scheduleUpdated": { + "defaultMessage": "Programación actualizada" + }, + "schedulesView.scheduleUpdatedMsg": { + "defaultMessage": "Se actualizó correctamente la programación \"{id}\"" + }, + "schedulesView.scheduler": { + "defaultMessage": "Programador" + }, + "schedulesView.unpauseError": { + "defaultMessage": "Error al reanudar la programación" + }, + "searchBar.caseSensitive": { + "defaultMessage": "Distinguir mayúsculas" + }, + "searchBar.close": { + "defaultMessage": "Cerrar ({shortcut})" + }, + "searchBar.next": { + "defaultMessage": "Siguiente ({shortcut})" + }, + "searchBar.placeholder": { + "defaultMessage": "Buscar en la conversación..." + }, + "searchBar.previous": { + "defaultMessage": "Anterior ({shortcut})" + }, + "secureStorageNotice.defaultMessage": { + "defaultMessage": "Las claves se almacenan de forma segura en el llavero" + }, + "securityToggle.apiTokenDescription": { + "defaultMessage": "Token de autenticación para el servicio de clasificación" + }, + "securityToggle.apiTokenOptional": { + "defaultMessage": "API Token (opcional)" + }, + "securityToggle.classificationEndpoint": { + "defaultMessage": "Endpoint de clasificación" + }, + "securityToggle.classificationEndpointDescription": { + "defaultMessage": "Ingresa la URL completa de tu servicio de clasificación" + }, + "securityToggle.commandClassifierActive": { + "defaultMessage": "Clasificador de comandos activo (configurado automáticamente desde el entorno)" + }, + "securityToggle.commandEndpointDescription": { + "defaultMessage": "Ingresa la URL completa de tu servicio de clasificación de inyección de comandos" + }, + "securityToggle.commandInjectionDescription": { + "defaultMessage": "Usa modelos de ML para detectar comandos de shell maliciosos" + }, + "securityToggle.detectionModel": { + "defaultMessage": "Modelo de detección" + }, + "securityToggle.detectionModelDescription": { + "defaultMessage": "Selecciona qué modelo de ML usar para la detección de inyección de prompt" + }, + "securityToggle.detectionThreshold": { + "defaultMessage": "Umbral de detección" + }, + "securityToggle.enableCommandInjection": { + "defaultMessage": "Activar detección de inyección de comandos con ML" + }, + "securityToggle.enablePromptInjection": { + "defaultMessage": "Activar detección de inyección de prompt" + }, + "securityToggle.enablePromptInjectionMl": { + "defaultMessage": "Activar detección de inyección de prompt con ML" + }, + "securityToggle.mlEndpointDescription": { + "defaultMessage": "Ingresa la URL completa de tu servicio de clasificación con ML (incluido el identificador del modelo)" + }, + "securityToggle.mlTokenDescription": { + "defaultMessage": "Token de autenticación para el servicio de ML (p. ej., token de HuggingFace)" + }, + "securityToggle.overrideNotice": { + "defaultMessage": "Este ajuste lo gestiona tu organización y no se puede cambiar." + }, + "securityToggle.promptInjectionDescription": { + "defaultMessage": "Detecta y previene posibles ataques de inyección de prompt" + }, + "securityToggle.promptInjectionMlDescription": { + "defaultMessage": "Usa modelos de ML para detectar posibles inyecciones de prompt en tu chat" + }, + "securityToggle.thresholdDescription": { + "defaultMessage": "Los valores más altos son más estrictos (0.01 = muy permisivo, 1.0 = máximo estricto)" + }, + "securityToggle.warpNotice": { + "defaultMessage": "La detección de inyección de comandos funciona mejor cuando estás conectado a WARP (necesario para llegar al servicio de clasificación)." + }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "Acciones de la sesión" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "Cancelar" + }, + "sessionActionsHeader.close": { + "defaultMessage": "Cerrar" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "JSON de la sesión copiado" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "Texto copiado" + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "Copiar JSON" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "Copiar texto" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "No se pudo duplicar la sesión: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "Duplicar sesión" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "Sesión duplicada" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "Valor del texto" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "No se pudo cargar el JSON de la sesión: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "JSON de la sesión" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "Cargando JSON..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "No se pudo renombrar la sesión: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "Introduce el nombre de la sesión" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "Renombrar sesión" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "Renombrar sesión" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "Sesión renombrada" + }, + "sessionActionsHeader.save": { + "defaultMessage": "Guardar" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "Guardando..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "Ver el JSON de la sesión" + }, + "sessionHistory.cancel": { + "defaultMessage": "Cancelar" + }, + "sessionHistory.copy": { + "defaultMessage": "Copiar" + }, + "sessionHistory.empty.description": { + "defaultMessage": "Esta sesión no contiene ningún mensaje" + }, + "sessionHistory.empty.title": { + "defaultMessage": "No se encontraron mensajes" + }, + "sessionHistory.error.loading": { + "defaultMessage": "Error al cargar los detalles de la sesión" + }, + "sessionHistory.error.tryAgain": { + "defaultMessage": "Reintentar" + }, + "sessionHistory.loading": { + "defaultMessage": "Cargando detalles de la sesión..." + }, + "sessionHistory.resume": { + "defaultMessage": "Reanudar" + }, + "sessionHistory.searchPlaceholder": { + "defaultMessage": "Buscar en el historial..." + }, + "sessionHistory.share": { + "defaultMessage": "Compartir" + }, + "sessionHistory.shareModal.description": { + "defaultMessage": "Comparte este enlace de sesión para dar a otros una vista de solo lectura de tu chat de goose." + }, + "sessionHistory.shareModal.title": { + "defaultMessage": "Compartir sesión (beta)" + }, + "sessionHistory.shareTooltip": { + "defaultMessage": "Para habilitar el uso compartido de sesiones, ve a Ajustes > Sesión > Compartir sesión." + }, + "sessionHistory.sharing": { + "defaultMessage": "Compartiendo..." + }, + "sessionHistory.toast.copyFailed": { + "defaultMessage": "No se pudo copiar el enlace al portapapeles" + }, + "sessionHistory.toast.launchFailed": { + "defaultMessage": "No se pudo abrir la sesión: {error}" + }, + "sessionHistory.toast.shareFailed": { + "defaultMessage": "No se pudo compartir la sesión: {error}" + }, + "sessionIndicators.error": { + "defaultMessage": "La sesión encontró un error" + }, + "sessionIndicators.newActivity": { + "defaultMessage": "Tiene actividad nueva" + }, + "sessionIndicators.streaming": { + "defaultMessage": "Streaming" + }, + "sessionSharingSection.alreadyConfigured": { + "defaultMessage": "El uso compartido de sesiones ya se configuró" + }, + "sessionSharingSection.baseUrl": { + "defaultMessage": "URL base" + }, + "sessionSharingSection.connectionFailed": { + "defaultMessage": "Falló la conexión." + }, + "sessionSharingSection.connectionSuccess": { + "defaultMessage": "¡Conexión exitosa!" + }, + "sessionSharingSection.connectionTimedOut": { + "defaultMessage": "Se agotó el tiempo de conexión. Puede que el servidor esté lento o inaccesible." + }, + "sessionSharingSection.descriptionConfigured": { + "defaultMessage": "El uso compartido de sesiones está configurado pero es totalmente opcional: tus sesiones solo se comparten cuando haces clic explícitamente en el botón de compartir." + }, + "sessionSharingSection.descriptionDefault": { + "defaultMessage": "Puedes habilitar el uso compartido de sesiones para compartir tus sesiones con otros." + }, + "sessionSharingSection.enableSharing": { + "defaultMessage": "Habilitar el uso compartido de sesiones" + }, + "sessionSharingSection.invalidUrl": { + "defaultMessage": "Formato de URL no válido. Ingresa una URL válida (p. ej., https://example.com/api)." + }, + "sessionSharingSection.serverError": { + "defaultMessage": "Error del servidor: HTTP {status}. Puede que el servidor no esté configurado correctamente." + }, + "sessionSharingSection.testConnection": { + "defaultMessage": "Probar conexión" + }, + "sessionSharingSection.testing": { + "defaultMessage": "Probando..." + }, + "sessionSharingSection.testingConnection": { + "defaultMessage": "Probando la conexión..." + }, + "sessionSharingSection.title": { + "defaultMessage": "Compartir sesión" + }, + "sessionSharingSection.unknownError": { + "defaultMessage": "Ocurrió un error desconocido." + }, + "sessionSharingSection.unreachableServer": { + "defaultMessage": "No se pudo conectar con el servidor. Revisa la URL y tu conexión de red." + }, + "sessionSharingSection.urlPlaceholder": { + "defaultMessage": "https://example.com/api" + }, + "sessionViewComponents.empty.description": { + "defaultMessage": "Esta sesión no contiene ningún mensaje" + }, + "sessionViewComponents.empty.title": { + "defaultMessage": "No se encontraron mensajes" + }, + "sessionViewComponents.error.loading": { + "defaultMessage": "Error al cargar los detalles de la sesión" + }, + "sessionViewComponents.error.tryAgain": { + "defaultMessage": "Reintentar" + }, + "sessionViewComponents.role.assistant": { + "defaultMessage": "Goose" + }, + "sessionViewComponents.role.user": { + "defaultMessage": "Tú" + }, + "sessions.action.delete": { + "defaultMessage": "Eliminar sesión" + }, + "sessions.action.duplicate": { + "defaultMessage": "Duplicar sesión" + }, + "sessions.action.editName": { + "defaultMessage": "Editar nombre de la sesión" + }, + "sessions.action.export": { + "defaultMessage": "Exportar sesión" + }, + "sessions.action.openNewWindow": { + "defaultMessage": "Abrir en una ventana nueva" + }, + "sessions.action.shareNostr": { + "defaultMessage": "Compartir enlace cifrado de Nostr" + }, + "sessions.cancel": { + "defaultMessage": "Cancelar" + }, + "sessions.chatHistory": { + "defaultMessage": "Historial de chats" + }, + "sessions.chatHistoryDesc": { + "defaultMessage": "Consulta y busca tus conversaciones anteriores con Goose. {shortcut} para buscar." + }, + "sessions.close": { + "defaultMessage": "Cerrar" + }, + "sessions.delete.message": { + "defaultMessage": "¿Seguro que quieres eliminar la sesión \"{name}\"? Esta acción no se puede deshacer." + }, + "sessions.delete.title": { + "defaultMessage": "Eliminar sesión" + }, + "sessions.edit.placeholder": { + "defaultMessage": "Ingresa la descripción de la sesión" + }, + "sessions.edit.title": { + "defaultMessage": "Editar descripción de la sesión" + }, + "sessions.empty.description": { + "defaultMessage": "Tu historial de chats aparecerá aquí" + }, + "sessions.empty.title": { + "defaultMessage": "No se encontraron sesiones de chat" + }, + "sessions.error.loading": { + "defaultMessage": "Error al cargar las sesiones" + }, + "sessions.error.tryAgain": { + "defaultMessage": "Reintentar" + }, + "sessions.import": { + "defaultMessage": "Importar sesión" + }, + "sessions.importNostr": { + "defaultMessage": "Importar enlace" + }, + "sessions.importNostr.description": { + "defaultMessage": "Pega un enlace de Goose para compartir por Nostr para obtener, descifrar e importar la sesión." + }, + "sessions.importNostr.placeholder": { + "defaultMessage": "goose://sessions/nostr?nevent=...&key=..." + }, + "sessions.importNostr.title": { + "defaultMessage": "Importar sesión de Nostr" + }, + "sessions.importing": { + "defaultMessage": "Importando..." + }, + "sessions.loadingMore": { + "defaultMessage": "Cargando más sesiones..." + }, + "sessions.save": { + "defaultMessage": "Guardar" + }, + "sessions.saving": { + "defaultMessage": "Guardando..." + }, + "sessions.search.noResults": { + "defaultMessage": "No se encontraron sesiones que coincidan" + }, + "sessions.search.noResultsDesc": { + "defaultMessage": "Prueba a ajustar los términos de búsqueda" + }, + "sessions.searchPlaceholder": { + "defaultMessage": "Buscar en el historial..." + }, + "sessions.shareNostr.description": { + "defaultMessage": "Cualquiera que tenga este enlace puede obtener y descifrar la sesión. Trátalo como un secreto." + }, + "sessions.shareNostr.title": { + "defaultMessage": "Enlace cifrado de Nostr para compartir" + }, + "sessions.toast.copied": { + "defaultMessage": "Copiado al portapapeles" + }, + "sessions.toast.deleteFailed": { + "defaultMessage": "No se pudo eliminar la sesión \"{name}\": {error}" + }, + "sessions.toast.deleted": { + "defaultMessage": "Sesión eliminada correctamente" + }, + "sessions.toast.duplicateFailed": { + "defaultMessage": "No se pudo duplicar la sesión: {error}" + }, + "sessions.toast.duplicated": { + "defaultMessage": "Sesión \"{name}\" duplicada correctamente" + }, + "sessions.toast.exported": { + "defaultMessage": "Sesión exportada correctamente" + }, + "sessions.toast.importFailed": { + "defaultMessage": "No se pudo importar la sesión: {error}" + }, + "sessions.toast.imported": { + "defaultMessage": "Sesión importada correctamente" + }, + "sessions.toast.shareNostr": { + "defaultMessage": "Enlace cifrado de Nostr creado para compartir" + }, + "sessions.toast.shareNostrFailed": { + "defaultMessage": "No se pudo crear el enlace de Nostr para compartir: {error}" + }, + "sessions.toast.updateFailed": { + "defaultMessage": "No se pudo actualizar la descripción de la sesión: {error}" + }, + "sessions.toast.updated": { + "defaultMessage": "Descripción de la sesión actualizada correctamente" + }, + "sessionsView.error.failedToLoad": { + "defaultMessage": "No se pudieron cargar los detalles de la sesión. Inténtalo de nuevo más tarde." + }, + "sessionsView.loading": { + "defaultMessage": "Cargando..." + }, + "settings.appearance.description": { + "defaultMessage": "Configura cómo se ve goose en tu sistema" + }, + "settings.appearance.title": { + "defaultMessage": "Apariencia" + }, + "settings.close": { + "defaultMessage": "Cerrar" + }, + "settings.costTracking.description": { + "defaultMessage": "Muestra los precios del modelo y los costos de uso" + }, + "settings.costTracking.title": { + "defaultMessage": "Seguimiento de costos" + }, + "settings.dockIcon.description": { + "defaultMessage": "Muestra goose en el dock" + }, + "settings.dockIcon.title": { + "defaultMessage": "Icono del dock" + }, + "settings.help.description": { + "defaultMessage": "Ayúdanos a mejorar goose reportando problemas o solicitando nuevas funciones" + }, + "settings.help.reportBug": { + "defaultMessage": "Reportar un error" + }, + "settings.help.requestFeature": { + "defaultMessage": "Solicitar una función" + }, + "settings.help.title": { + "defaultMessage": "Ayuda y comentarios" + }, + "settings.language.description": { + "defaultMessage": "Elige el idioma de la interfaz de goose" + }, + "settings.language.english": { + "defaultMessage": "Inglés" + }, + "settings.language.hindi": { + "defaultMessage": "Hindi" + }, + "settings.language.japanese": { + "defaultMessage": "Japonés" + }, + "settings.language.korean": { + "defaultMessage": "Coreano" + }, + "settings.language.russian": { + "defaultMessage": "Ruso" + }, + "settings.language.spanish": { + "defaultMessage": "Español" + }, + "settings.language.systemDefault": { + "defaultMessage": "Predeterminado del sistema" + }, + "settings.language.title": { + "defaultMessage": "Idioma" + }, + "settings.language.turkish": { + "defaultMessage": "Turco" + }, + "settings.language.zhCN": { + "defaultMessage": "Chino (simplificado)" + }, + "settings.menuBarIcon.description": { + "defaultMessage": "Muestra goose en la barra de menús" + }, + "settings.menuBarIcon.title": { + "defaultMessage": "Icono de la barra de menús" + }, + "settings.notifications.configGuide": { + "defaultMessage": "Guía de configuración" + }, + "settings.notifications.description": { + "defaultMessage": "Las notificaciones las gestiona tu sistema operativo - {link}" + }, + "settings.notifications.modal.macInstructions": { + "defaultMessage": "Para activar las notificaciones en macOS:" + }, + "settings.notifications.modal.macStep1": { + "defaultMessage": "Abre Preferencias del Sistema" + }, + "settings.notifications.modal.macStep2": { + "defaultMessage": "Haz clic en Notificaciones" + }, + "settings.notifications.modal.macStep3": { + "defaultMessage": "Encuentra y selecciona goose en la lista de aplicaciones" + }, + "settings.notifications.modal.macStep4": { + "defaultMessage": "Activa las notificaciones y ajusta la configuración como prefieras" + }, + "settings.notifications.modal.title": { + "defaultMessage": "Cómo activar las notificaciones" + }, + "settings.notifications.modal.winInstructions": { + "defaultMessage": "Para activar las notificaciones en Windows:" + }, + "settings.notifications.modal.winStep1": { + "defaultMessage": "Abre Configuración" + }, + "settings.notifications.modal.winStep2": { + "defaultMessage": "Ve a Sistema > Notificaciones" + }, + "settings.notifications.modal.winStep3": { + "defaultMessage": "Encuentra y selecciona goose en la lista de aplicaciones" + }, + "settings.notifications.modal.winStep4": { + "defaultMessage": "Activa las notificaciones y ajusta la configuración como prefieras" + }, + "settings.notifications.openSettings": { + "defaultMessage": "Abrir configuración" + }, + "settings.notifications.task.description": { + "defaultMessage": "Notifica cuando Goose termina una tarea mientras la ventana está en segundo plano" + }, + "settings.notifications.task.title": { + "defaultMessage": "Notificaciones de tareas completadas" + }, + "settings.notifications.title": { + "defaultMessage": "Notificaciones" + }, + "settings.preventSleep.description": { + "defaultMessage": "Mantén tu computadora despierta mientras goose ejecuta una tarea (la pantalla aún puede bloquearse)" + }, + "settings.preventSleep.title": { + "defaultMessage": "Evitar suspensión" + }, + "settings.theme.description": { + "defaultMessage": "Personaliza la apariencia de goose" + }, + "settings.theme.title": { + "defaultMessage": "Tema" + }, + "settings.updates.description": { + "defaultMessage": "Busca e instala actualizaciones para mantener goose funcionando al máximo" + }, + "settings.updates.title": { + "defaultMessage": "Actualizaciones" + }, + "settings.version.title": { + "defaultMessage": "Versión" + }, + "settingsView.tabApp": { + "defaultMessage": "App" + }, + "settingsView.tabAuth": { + "defaultMessage": "Auth" + }, + "settingsView.tabChat": { + "defaultMessage": "Chat" + }, + "settingsView.tabKeyboard": { + "defaultMessage": "Teclado" + }, + "settingsView.tabLocalInference": { + "defaultMessage": "Inferencia local" + }, + "settingsView.tabModels": { + "defaultMessage": "Modelos" + }, + "settingsView.tabPrompts": { + "defaultMessage": "Prompts" + }, + "settingsView.tabSession": { + "defaultMessage": "Sesión" + }, + "settingsView.title": { + "defaultMessage": "Ajustes" + }, + "sharedSession.loading": { + "defaultMessage": "Cargando detalles de la sesión..." + }, + "sharedSession.title": { + "defaultMessage": "Sesión compartida" + }, + "sheet.close": { + "defaultMessage": "Cerrar" + }, + "shortcutRecorder.cancel": { + "defaultMessage": "Cancelar" + }, + "shortcutRecorder.clickToRecord": { + "defaultMessage": "Haz clic para grabar..." + }, + "shortcutRecorder.conflictWarning": { + "defaultMessage": "Este atajo ya lo usa {label}. Al guardar se reasignará a esta acción." + }, + "shortcutRecorder.pressShortcut": { + "defaultMessage": "Presiona el atajo..." + }, + "shortcutRecorder.save": { + "defaultMessage": "Guardar" + }, + "skillsView.addSkill": { + "defaultMessage": "Agregar Skill" + }, + "skillsView.adjustSearchTerms": { + "defaultMessage": "Intenta ajustar los términos de búsqueda" + }, + "skillsView.comingSoon": { + "defaultMessage": "Próximamente" + }, + "skillsView.errorLoadingSkills": { + "defaultMessage": "Error al cargar las Skills" + }, + "skillsView.noMatchingSkills": { + "defaultMessage": "No se encontraron Skills que coincidan" + }, + "skillsView.noSkillsDescription": { + "defaultMessage": "Las Skills se cargan desde archivos SKILL.md en ~/.config/agents/skills/, .goose/skills/ u otros directorios admitidos." + }, + "skillsView.noSkillsInstalled": { + "defaultMessage": "No hay Skills instaladas" + }, + "skillsView.searchSkillsPlaceholder": { + "defaultMessage": "Buscar Skills..." + }, + "skillsView.skillsDescription": { + "defaultMessage": "Mira las Skills instaladas que amplían las capacidades de Goose. {shortcut} para buscar." + }, + "skillsView.skillsTitle": { + "defaultMessage": "Skills" + }, + "skillsView.tryAgain": { + "defaultMessage": "Intentar de nuevo" + }, + "spellcheckToggle.description": { + "defaultMessage": "Revisa la ortografía en la entrada del chat. Requiere reiniciar para que tenga efecto." + }, + "spellcheckToggle.title": { + "defaultMessage": "Activar corrección ortográfica" + }, + "standaloneAppView.failedToLoad": { + "defaultMessage": "No se pudo cargar la app" + }, + "standaloneAppView.initializing": { + "defaultMessage": "Inicializando la app..." + }, + "standaloneAppView.missingParams": { + "defaultMessage": "Faltan parámetros requeridos" + }, + "stringUtils.configuredProvider": { + "defaultMessage": "El proveedor {name} está configurado" + }, + "stringUtils.ollamaApp": { + "defaultMessage": "App de Ollama" + }, + "stringUtils.ollamaNotConfiguredPrefix": { + "defaultMessage": "Para usarlo, o bien la" + }, + "stringUtils.ollamaNotConfiguredSuffix": { + "defaultMessage": "debe estar instalada en tu máquina y abierta, o debes ingresar un valor para OLLAMA_HOST." + }, + "subRecipeEditor.addExisting": { + "defaultMessage": "Agregar existente" + }, + "subRecipeEditor.createNew": { + "defaultMessage": "Crear nueva subreceta" + }, + "subRecipeEditor.deleteSubrecipe": { + "defaultMessage": "Eliminar subreceta {name}" + }, + "subRecipeEditor.description": { + "defaultMessage": "Las subrecetas son recetas que se pueden invocar como herramientas durante la ejecución. Permiten flujos de trabajo de varios pasos y componentes reutilizables." + }, + "subRecipeEditor.duplicateName": { + "defaultMessage": "Nombre duplicado" + }, + "subRecipeEditor.duplicateNameMsg": { + "defaultMessage": "Ya existe una subreceta llamada \"{name}\". Usa un nombre único." + }, + "subRecipeEditor.editSubrecipe": { + "defaultMessage": "Editar subreceta {name}" + }, + "subRecipeEditor.label": { + "defaultMessage": "Subrecetas" + }, + "subRecipeEditor.preconfiguredValues": { + "defaultMessage": "Valores preconfigurados:" + }, + "subRecipeEditor.sequential": { + "defaultMessage": "Secuencial" + }, + "subRecipeModal.addTitle": { + "defaultMessage": "Agregar subreceta" + }, + "subRecipeModal.apply": { + "defaultMessage": "Aplicar" + }, + "subRecipeModal.browse": { + "defaultMessage": "Explorar" + }, + "subRecipeModal.cancel": { + "defaultMessage": "Cancelar" + }, + "subRecipeModal.closeModal": { + "defaultMessage": "Cerrar la ventana de subreceta" + }, + "subRecipeModal.configureTitle": { + "defaultMessage": "Configurar subreceta" + }, + "subRecipeModal.descriptionLabel": { + "defaultMessage": "Descripción" + }, + "subRecipeModal.descriptionPlaceholder": { + "defaultMessage": "Descripción opcional de lo que hace esta subreceta..." + }, + "subRecipeModal.invalidFile": { + "defaultMessage": "Archivo no válido" + }, + "subRecipeModal.invalidFileMsg": { + "defaultMessage": "Selecciona un archivo YAML (.yaml o .yml)." + }, + "subRecipeModal.nameHint": { + "defaultMessage": "Identificador único usado para generar el nombre de la herramienta" + }, + "subRecipeModal.nameLabel": { + "defaultMessage": "Nombre" + }, + "subRecipeModal.namePlaceholder": { + "defaultMessage": "p. ej., security_scan" + }, + "subRecipeModal.pathHint": { + "defaultMessage": "Explora un archivo de receta existente o ingresa una ruta manualmente" + }, + "subRecipeModal.pathLabel": { + "defaultMessage": "Ruta" + }, + "subRecipeModal.pathPlaceholder": { + "defaultMessage": "p. ej., ./subrecipes/security-analysis.yaml" + }, + "subRecipeModal.preconfiguredValues": { + "defaultMessage": "Valores preconfigurados" + }, + "subRecipeModal.preconfiguredValuesHint": { + "defaultMessage": "Valores de parámetros opcionales que siempre se pasan a la subreceta" + }, + "subRecipeModal.sequentialHint": { + "defaultMessage": "(Fuerza la ejecución secuencial de múltiples instancias de la subreceta)" + }, + "subRecipeModal.sequentialLabel": { + "defaultMessage": "Secuencial cuando se repite" + }, + "subRecipeModal.subtitle": { + "defaultMessage": "Configura una subreceta que se pueda invocar como herramienta durante la ejecución de la receta" + }, + "switchModelModal.backToModelList": { + "defaultMessage": "Volver a la lista de modelos" + }, + "switchModelModal.cancel": { + "defaultMessage": "Cancelar" + }, + "switchModelModal.checkProviderConfig": { + "defaultMessage": "Revisa la configuración de tu proveedor en Ajustes → Proveedores" + }, + "switchModelModal.chooseModel": { + "defaultMessage": "Elige un modelo:" + }, + "switchModelModal.claudeAdaptive": { + "defaultMessage": "Adaptativo - Claude decide cuándo y cuánto pensar" + }, + "switchModelModal.claudeDisabled": { + "defaultMessage": "Desactivado - Sin pensamiento extendido" + }, + "switchModelModal.claudeEffortHigh": { + "defaultMessage": "Alto - Razonamiento profundo (predeterminado)" + }, + "switchModelModal.claudeEffortLow": { + "defaultMessage": "Bajo - Pensamiento mínimo, respuestas más rápidas" + }, + "switchModelModal.claudeEffortMax": { + "defaultMessage": "Máximo - Sin límites en la profundidad del pensamiento" + }, + "switchModelModal.claudeEffortMedium": { + "defaultMessage": "Medio - Pensamiento moderado" + }, + "switchModelModal.claudeEnabled": { + "defaultMessage": "Activado - Presupuesto fijo de tokens para pensar" + }, + "switchModelModal.couldNotContactProvider": { + "defaultMessage": "No se pudo contactar al proveedor" + }, + "switchModelModal.customModelName": { + "defaultMessage": "Nombre de modelo personalizado" + }, + "switchModelModal.description": { + "defaultMessage": "Selecciona un proveedor y un modelo para tus conversaciones." + }, + "switchModelModal.enterModelNotListed": { + "defaultMessage": "Ingresa un modelo que no esté en la lista..." + }, + "switchModelModal.extendedThinking": { + "defaultMessage": "Pensamiento extendido" + }, + "switchModelModal.geminiOnly": { + "defaultMessage": "(Solo modelos Gemini 3)" + }, + "switchModelModal.goToSettings": { + "defaultMessage": "Ir a Ajustes" + }, + "switchModelModal.loadingModels": { + "defaultMessage": "Cargando modelos…" + }, + "switchModelModal.localModelsDescription": { + "defaultMessage": "Para usar inferencia local, primero debes descargar un modelo a tu computadora. Ve a Ajustes → Modelos para gestionar los modelos locales." + }, + "switchModelModal.localModelsTitle": { + "defaultMessage": "Primero hay que descargar los modelos locales" + }, + "switchModelModal.providerPlaceholder": { + "defaultMessage": "Proveedor, escribe para buscar" + }, + "switchModelModal.quickStartGuide": { + "defaultMessage": "Guía de inicio rápido" + }, + "switchModelModal.recommended": { + "defaultMessage": "Recomendado" + }, + "switchModelModal.selectEffortLevel": { + "defaultMessage": "Selecciona el nivel de esfuerzo" + }, + "switchModelModal.selectModel": { + "defaultMessage": "Selecciona un modelo" + }, + "switchModelModal.selectModelButton": { + "defaultMessage": "Seleccionar modelo" + }, + "switchModelModal.selectModelPlaceholder": { + "defaultMessage": "Selecciona un modelo, escribe para buscar" + }, + "switchModelModal.selectOrEnterModel": { + "defaultMessage": "Selecciona o ingresa un modelo" + }, + "switchModelModal.selectProvider": { + "defaultMessage": "Selecciona un proveedor" + }, + "switchModelModal.selectThinkingLevel": { + "defaultMessage": "Selecciona el nivel de pensamiento" + }, + "switchModelModal.selectThinkingMode": { + "defaultMessage": "Selecciona el modo de pensamiento" + }, + "switchModelModal.thinkingBudget": { + "defaultMessage": "Presupuesto de pensamiento (tokens)" + }, + "switchModelModal.thinkingEffort": { + "defaultMessage": "Esfuerzo de pensamiento" + }, + "switchModelModal.thinkingEffortOff": { + "defaultMessage": "Desactivado - Sin pensamiento extendido" + }, + "switchModelModal.thinkingLevel": { + "defaultMessage": "Nivel de pensamiento" + }, + "switchModelModal.thinkingLevelHigh": { + "defaultMessage": "Alto - Razonamiento más profundo, mayor latencia" + }, + "switchModelModal.thinkingLevelLow": { + "defaultMessage": "Bajo - Mejor latencia, razonamiento más ligero" + }, + "switchModelModal.title": { + "defaultMessage": "Cambiar de modelo" + }, + "switchModelModal.typeModelName": { + "defaultMessage": "Escribe aquí el nombre del modelo" + }, + "switchModelModal.useOtherProvider": { + "defaultMessage": "Usar otro proveedor" + }, + "telemetryConsentPrompt.description": { + "defaultMessage": "¿Te gustaría compartir datos de uso anónimos para ayudar a mejorar goose? Nunca recopilamos tus conversaciones, código ni datos personales." + }, + "telemetryConsentPrompt.heading": { + "defaultMessage": "Ayuda a mejorar goose" + }, + "telemetryConsentPrompt.learnMore": { + "defaultMessage": "Más información" + }, + "telemetryConsentPrompt.optIn": { + "defaultMessage": "Sí, compartir datos de uso anónimos" + }, + "telemetryConsentPrompt.optOut": { + "defaultMessage": "No, gracias" + }, + "telemetrySettings.configErrorTitle": { + "defaultMessage": "Error de configuración" + }, + "telemetrySettings.description": { + "defaultMessage": "Controla cómo se usan tus datos" + }, + "telemetrySettings.learnMore": { + "defaultMessage": "Más información" + }, + "telemetrySettings.loadError": { + "defaultMessage": "No se pudieron cargar los ajustes de telemetría." + }, + "telemetrySettings.title": { + "defaultMessage": "Privacidad" + }, + "telemetrySettings.toggleDescription": { + "defaultMessage": "Ayuda a mejorar goose compartiendo estadísticas de uso anónimas." + }, + "telemetrySettings.toggleLabel": { + "defaultMessage": "Datos de uso anónimos" + }, + "telemetrySettings.updateError": { + "defaultMessage": "No se pudieron actualizar los ajustes de telemetría." + }, + "themeSelector.dark": { + "defaultMessage": "Oscuro" + }, + "themeSelector.light": { + "defaultMessage": "Claro" + }, + "themeSelector.system": { + "defaultMessage": "Sistema" + }, + "themeSelector.theme": { + "defaultMessage": "Tema" + }, + "toolApprovalButtons.allowOnce": { + "defaultMessage": "Permitir una vez" + }, + "toolApprovalButtons.allowedOnce": { + "defaultMessage": "Permitido una vez" + }, + "toolApprovalButtons.alwaysAllow": { + "defaultMessage": "Permitir siempre" + }, + "toolApprovalButtons.alwaysAllowed": { + "defaultMessage": "Permitido siempre" + }, + "toolApprovalButtons.cancelled": { + "defaultMessage": "Cancelado" + }, + "toolApprovalButtons.denied": { + "defaultMessage": "Denegado" + }, + "toolApprovalButtons.deniedOnce": { + "defaultMessage": "Denegado una vez" + }, + "toolApprovalButtons.deny": { + "defaultMessage": "Denegar" + }, + "toolCallStatusIndicator.toolStatus": { + "defaultMessage": "Estado de la herramienta: {status}" + }, + "toolCallWithResponse.activityCount": { + "defaultMessage": "Actividad ({count})" + }, + "toolCallWithResponse.code": { + "defaultMessage": "Código" + }, + "toolCallWithResponse.loadingSpinner": { + "defaultMessage": "Indicador de carga" + }, + "toolCallWithResponse.logs": { + "defaultMessage": "Logs" + }, + "toolCallWithResponse.mcpUiExperimental": { + "defaultMessage": "La UI de MCP es experimental y puede cambiar en cualquier momento." + }, + "toolCallWithResponse.output": { + "defaultMessage": "Salida" + }, + "toolCallWithResponse.toolDetails": { + "defaultMessage": "Detalles de la herramienta" + }, + "toolCallWithResponse.toolResultAlt": { + "defaultMessage": "Resultado de la herramienta" + }, + "toolCallWithResponse.viewSubagentSession": { + "defaultMessage": "Ver sesión del subagente" + }, + "toolConfirmation.allowToolCallWithName": { + "defaultMessage": "¿Permitir {toolName}?" + }, + "toolConfirmation.gooseWouldLikeToCallWithName": { + "defaultMessage": "Goose quiere invocar {toolName}. ¿Permitir?" + }, + "tunnelSection.appStoreQrInstructions": { + "defaultMessage": "Escanea este código QR con la cámara de tu iPhone para instalar la app móvil de goose desde la App Store" + }, + "tunnelSection.close": { + "defaultMessage": "Cerrar" + }, + "tunnelSection.connectionDetails": { + "defaultMessage": "Detalles de conexión" + }, + "tunnelSection.downloadIosApp": { + "defaultMessage": "Descargar la app de goose para iOS" + }, + "tunnelSection.failedToLoadStatus": { + "defaultMessage": "No se pudo cargar el estado del túnel" + }, + "tunnelSection.failedToStartTunnel": { + "defaultMessage": "No se pudo iniciar el túnel" + }, + "tunnelSection.failedToStopTunnel": { + "defaultMessage": "No se pudo detener el túnel" + }, + "tunnelSection.getIosApp": { + "defaultMessage": "Obtén la app para iOS" + }, + "tunnelSection.mobileApp": { + "defaultMessage": "App móvil" + }, + "tunnelSection.mobileAppConnection": { + "defaultMessage": "Conexión de la app móvil" + }, + "tunnelSection.openInAppStore": { + "defaultMessage": "Abrir en la App Store" + }, + "tunnelSection.or": { + "defaultMessage": "o" + }, + "tunnelSection.previewDescription": { + "defaultMessage": "Activa el acceso remoto a goose desde dispositivos móviles mediante un túnel seguro." + }, + "tunnelSection.previewFeature": { + "defaultMessage": "Función en vista previa:" + }, + "tunnelSection.qrCodeInstructions": { + "defaultMessage": "Escanea este código QR con la app móvil de goose. No compartas este código con nadie, ya que es para tu acceso personal." + }, + "tunnelSection.retry": { + "defaultMessage": "Reintentar" + }, + "tunnelSection.scanQrCode": { + "defaultMessage": "escanear código QR" + }, + "tunnelSection.secretKey": { + "defaultMessage": "Clave secreta" + }, + "tunnelSection.showQrCode": { + "defaultMessage": "Mostrar código QR" + }, + "tunnelSection.startTunnel": { + "defaultMessage": "Iniciar túnel" + }, + "tunnelSection.starting": { + "defaultMessage": "Iniciando..." + }, + "tunnelSection.statusDisabled": { + "defaultMessage": "El túnel está desactivado" + }, + "tunnelSection.statusError": { + "defaultMessage": "El túnel encontró un error" + }, + "tunnelSection.statusIdle": { + "defaultMessage": "El túnel no está en ejecución" + }, + "tunnelSection.statusRunning": { + "defaultMessage": "El túnel está activo" + }, + "tunnelSection.statusStarting": { + "defaultMessage": "Iniciando el túnel..." + }, + "tunnelSection.stopTunnel": { + "defaultMessage": "Detener túnel" + }, + "tunnelSection.tunnelStatus": { + "defaultMessage": "Estado del túnel" + }, + "tunnelSection.tunnelUrl": { + "defaultMessage": "URL del túnel" + }, + "tunnelSection.url": { + "defaultMessage": "URL:" + }, + "updateSection.autoDownload": { + "defaultMessage": "La actualización se descargará automáticamente en segundo plano." + }, + "updateSection.autoInstallNote": { + "defaultMessage": "La actualización se instalará automáticamente cuando cierres la app." + }, + "updateSection.checkForUpdates": { + "defaultMessage": "Buscar actualizaciones" + }, + "updateSection.checking": { + "defaultMessage": "Buscando actualizaciones..." + }, + "updateSection.currentVersion": { + "defaultMessage": "Versión actual" + }, + "updateSection.downloadReady": { + "defaultMessage": "¡Actualización descargada y lista para instalar!" + }, + "updateSection.downloadingProgress": { + "defaultMessage": "Descargando actualización... {percent}%" + }, + "updateSection.downloadingUpdate": { + "defaultMessage": "Descargando actualización..." + }, + "updateSection.installAndRestart": { + "defaultMessage": "Instalar y reiniciar" + }, + "updateSection.installNowHint": { + "defaultMessage": "O haz clic en \"Instalar y reiniciar\" para actualizar ahora." + }, + "updateSection.latestVersion": { + "defaultMessage": "¡Estás usando la última versión!" + }, + "updateSection.loading": { + "defaultMessage": "Cargando..." + }, + "updateSection.manualInstallNote": { + "defaultMessage": "Después de la descarga, tendrás que instalar la actualización manualmente." + }, + "updateSection.manualInstallRequired": { + "defaultMessage": "Este método de actualización requiere instalación manual." + }, + "updateSection.readyInstallAuto": { + "defaultMessage": "✓ ¡La actualización está lista! Se instalará cuando cierres Goose." + }, + "updateSection.readyInstallManual": { + "defaultMessage": "✓ ¡La actualización está lista! Haz clic en \"Instalar y reiniciar\" para ver las instrucciones de instalación." + }, + "updateSection.upToDate": { + "defaultMessage": "(actualizado)" + }, + "updateSection.updateAvailable": { + "defaultMessage": "¡Actualización disponible!" + }, + "updateSection.versionAvailable": { + "defaultMessage": "→ {version} disponible" + }, + "updateSection.versionIsAvailable": { + "defaultMessage": "La versión {version} está disponible" + }, + "userMessage.cancel": { + "defaultMessage": "Cancelar" + }, + "userMessage.cancelAriaLabel": { + "defaultMessage": "Cancelar edición" + }, + "userMessage.editAriaLabel": { + "defaultMessage": "Editar el contenido del mensaje" + }, + "userMessage.editButton": { + "defaultMessage": "Editar" + }, + "userMessage.editInPlace": { + "defaultMessage": "Editar en el sitio" + }, + "userMessage.editInPlaceAriaLabel": { + "defaultMessage": "Editar el mensaje en el sitio" + }, + "userMessage.editInPlaceDescription": { + "defaultMessage": "Editar en el sitio actualiza esta sesión • Duplicar sesión crea una nueva sesión" + }, + "userMessage.editInPlaceTitle": { + "defaultMessage": "Actualizar el mensaje en esta sesión" + }, + "userMessage.editMessageAriaLabel": { + "defaultMessage": "Editar mensaje: {preview}" + }, + "userMessage.editMessageTitle": { + "defaultMessage": "Editar mensaje" + }, + "userMessage.editPlaceholder": { + "defaultMessage": "Edita tu mensaje..." + }, + "userMessage.emptyError": { + "defaultMessage": "El mensaje no puede estar vacío" + }, + "userMessage.forkSession": { + "defaultMessage": "Duplicar sesión" + }, + "userMessage.forkSessionAriaLabel": { + "defaultMessage": "Duplicar la sesión con el mensaje editado" + }, + "userMessage.forkSessionTitle": { + "defaultMessage": "Crear una nueva sesión con el mensaje editado" + } +} diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 2013ddc6b578..e66697b8fbb8 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -11,6 +11,9 @@ "announcementModal.gotIt": { "defaultMessage": "समझ गया!" }, + "appLayout.collapseNavigation": { + "defaultMessage": "Collapse navigation" + }, "appLayout.openNavigation": { "defaultMessage": "नेविगेशन खोलें" }, @@ -992,6 +995,9 @@ "elicitationRequest.submit": { "defaultMessage": "सबमिट करें" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "जानकारी प्रस्तुत की गई" }, @@ -1847,9 +1853,15 @@ "localInferenceSettings.description": { "defaultMessage": "API कुंजियों के बिना अनुमान के लिए स्थानीय एलएलएम मॉडल डाउनलोड और प्रबंधित करें। किसी भी जीजीयूएफ मॉडल के लिए हगिंगफेस खोजें या नीचे दिए गए चुनिंदा विकल्पों का उपयोग करें।" }, + "localInferenceSettings.dismiss": { + "defaultMessage": "खारिज करें" + }, "localInferenceSettings.download": { "defaultMessage": "डाउनलोड करें" }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "डाउनलोड रद्द किया गया" + }, "localInferenceSettings.downloadFailed": { "defaultMessage": "डाउनलोड विफल रहा" }, @@ -1883,6 +1895,9 @@ "localInferenceSettings.remaining": { "defaultMessage": "{time} शेष" }, + "localInferenceSettings.retry": { + "defaultMessage": "पुनः प्रयास करें" + }, "localInferenceSettings.showAllFeatured": { "defaultMessage": "सभी विशेष रुप से प्रदर्शित दिखाएँ ({count} अधिक)" }, @@ -2447,9 +2462,6 @@ "navigationPanel.chats": { "defaultMessage": "चैट" }, - "navigationPanel.collapseSidebar": { - "defaultMessage": "साइडबार को संक्षिप्त करें" - }, "navigationPanel.noChats": { "defaultMessage": "कोई हालिया चैट नहीं" }, @@ -3737,6 +3749,72 @@ "securityToggle.warpNotice": { "defaultMessage": "WARP से कनेक्ट होने पर कमांड इंजेक्शन डिटेक्शन सबसे अच्छा काम करता है (वर्गीकरण सेवा तक पहुंचने के लिए आवश्यक)।" }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "Session actions" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "Cancel" + }, + "sessionActionsHeader.close": { + "defaultMessage": "Close" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "Session JSON copied" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "Text copied" + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "Copy JSON" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "Copy text" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "Failed to duplicate session: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "Duplicate session" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "Session duplicated" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "Text value" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "Failed to load session JSON: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "Session JSON" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "Loading JSON..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "Failed to rename session: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "Enter session name" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "Rename session" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "Rename Session" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "Session renamed" + }, + "sessionActionsHeader.save": { + "defaultMessage": "Save" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "Saving..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "View session JSON" + }, "sessionHistory.cancel": { "defaultMessage": "रद्द करें" }, @@ -4037,6 +4115,39 @@ "settings.help.title": { "defaultMessage": "सहायता एवं प्रतिक्रिया" }, + "settings.language.description": { + "defaultMessage": "goose के लिए प्रदर्शन भाषा चुनें" + }, + "settings.language.english": { + "defaultMessage": "अंग्रेज़ी" + }, + "settings.language.hindi": { + "defaultMessage": "हिन्दी" + }, + "settings.language.japanese": { + "defaultMessage": "जापानी" + }, + "settings.language.korean": { + "defaultMessage": "कोरियाई" + }, + "settings.language.russian": { + "defaultMessage": "रूसी" + }, + "settings.language.spanish": { + "defaultMessage": "स्पेनिश" + }, + "settings.language.systemDefault": { + "defaultMessage": "सिस्टम डिफ़ॉल्ट" + }, + "settings.language.title": { + "defaultMessage": "भाषा" + }, + "settings.language.turkish": { + "defaultMessage": "तुर्की" + }, + "settings.language.zhCN": { + "defaultMessage": "चीनी (सरलीकृत)" + }, "settings.menuBarIcon.description": { "defaultMessage": "मेनू बार में goose दिखाएँ" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 490e900e8ece..325f9e8386ee 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -11,6 +11,9 @@ "announcementModal.gotIt": { "defaultMessage": "了解しました" }, + "appLayout.collapseNavigation": { + "defaultMessage": "ナビを折りたたむ" + }, "appLayout.openNavigation": { "defaultMessage": "ナビゲーションを開く" }, @@ -992,6 +995,9 @@ "elicitationRequest.submit": { "defaultMessage": "送信" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "情報を送信しました" }, @@ -1847,9 +1853,15 @@ "localInferenceSettings.description": { "defaultMessage": "APIキーなしで推論できるローカルLLMモデルをダウンロード・管理します。Hugging Faceで任意のGGUFモデルを検索するか、以下の注目モデルを使用できます。" }, + "localInferenceSettings.dismiss": { + "defaultMessage": "閉じる" + }, "localInferenceSettings.download": { "defaultMessage": "ダウンロード" }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "ダウンロードがキャンセルされました" + }, "localInferenceSettings.downloadFailed": { "defaultMessage": "ダウンロードに失敗しました" }, @@ -1883,6 +1895,9 @@ "localInferenceSettings.remaining": { "defaultMessage": "残り{time}" }, + "localInferenceSettings.retry": { + "defaultMessage": "再試行" + }, "localInferenceSettings.showAllFeatured": { "defaultMessage": "注目モデルをすべて表示(あと{count}件)" }, @@ -2447,9 +2462,6 @@ "navigationPanel.chats": { "defaultMessage": "チャット" }, - "navigationPanel.collapseSidebar": { - "defaultMessage": "サイドバーを折りたたむ" - }, "navigationPanel.noChats": { "defaultMessage": "最近のチャットはありません" }, @@ -3737,6 +3749,72 @@ "securityToggle.warpNotice": { "defaultMessage": "コマンドインジェクション検出は、WARPに接続していると最も効果的です(分類サービスに到達するために必要です)。" }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "セッションの操作" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "キャンセル" + }, + "sessionActionsHeader.close": { + "defaultMessage": "閉じる" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "セッションJSONをコピーしました" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "テキストをコピーしました" + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "JSONをコピー" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "テキストをコピー" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "セッションを複製できませんでした: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "セッションを複製" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "セッションを複製しました" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "テキスト値" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "セッションJSONを読み込めませんでした: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "セッションJSON" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "JSONを読み込み中..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "セッションの名前を変更できませんでした: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "セッション名を入力" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "セッションの名前を変更" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "セッションの名前を変更" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "セッションの名前を変更しました" + }, + "sessionActionsHeader.save": { + "defaultMessage": "保存" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "保存中..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "セッションJSONを表示" + }, "sessionHistory.cancel": { "defaultMessage": "キャンセル" }, @@ -4037,6 +4115,39 @@ "settings.help.title": { "defaultMessage": "ヘルプとフィードバック" }, + "settings.language.description": { + "defaultMessage": "goose の表示言語を選択" + }, + "settings.language.english": { + "defaultMessage": "英語" + }, + "settings.language.hindi": { + "defaultMessage": "ヒンディー語" + }, + "settings.language.japanese": { + "defaultMessage": "日本語" + }, + "settings.language.korean": { + "defaultMessage": "韓国語" + }, + "settings.language.russian": { + "defaultMessage": "ロシア語" + }, + "settings.language.spanish": { + "defaultMessage": "スペイン語" + }, + "settings.language.systemDefault": { + "defaultMessage": "システムデフォルト" + }, + "settings.language.title": { + "defaultMessage": "言語" + }, + "settings.language.turkish": { + "defaultMessage": "トルコ語" + }, + "settings.language.zhCN": { + "defaultMessage": "中国語(簡体字)" + }, "settings.menuBarIcon.description": { "defaultMessage": "メニューバーにgooseを表示します" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json new file mode 100644 index 000000000000..d71e35b5777b --- /dev/null +++ b/ui/desktop/src/i18n/messages/ko.json @@ -0,0 +1,4862 @@ +{ + "alertBox.autoCompactAt": { + "defaultMessage": "자동 압축 기준" + }, + "alertBox.compactNow": { + "defaultMessage": "지금 압축" + }, + "alertBox.failedToSaveThreshold": { + "defaultMessage": "임계값 저장 실패: {error}" + }, + "announcementModal.gotIt": { + "defaultMessage": "알았어요!" + }, + "appLayout.collapseNavigation": { + "defaultMessage": "탐색 접기" + }, + "appLayout.openNavigation": { + "defaultMessage": "탐색 열기" + }, + "appsView.customApp": { + "defaultMessage": "사용자 지정 앱" + }, + "appsView.description": { + "defaultMessage": "MCP 서버의 애플리케이션과 goose 자체에서 구축한 앱입니다. 채팅 인터페이스를 통해 새 앱을 생성하도록 요청할 수 있으며 여기에 표시됩니다." + }, + "appsView.errorLoading": { + "defaultMessage": "앱 로딩 오류: {error}" + }, + "appsView.importApp": { + "defaultMessage": "앱 가져오기" + }, + "appsView.launch": { + "defaultMessage": "실행" + }, + "appsView.loading": { + "defaultMessage": "앱 로드 중..." + }, + "appsView.noAppsDescription": { + "defaultMessage": "채팅을 열고 원하는 앱을 goose에 요청하세요. goose가 앱을 만들어 주면 여기에 표시됩니다. 또는 누군가가 앱을 공유한 경우 위 버튼을 사용하여 가져올 수 있습니다." + }, + "appsView.noAppsTitle": { + "defaultMessage": "사용 가능한 앱이 없습니다." + }, + "appsView.retry": { + "defaultMessage": "다시 시도" + }, + "appsView.title": { + "defaultMessage": "앱" + }, + "authSettings.activeProviderWarning": { + "defaultMessage": "현재 사용 중인 제공업체입니다. 다른 자격 증명을 설정할 때까지 새 요청이 실패할 수 있습니다." + }, + "authSettings.cancel": { + "defaultMessage": "취소" + }, + "authSettings.delete": { + "defaultMessage": "삭제" + }, + "authSettings.deleteCredential": { + "defaultMessage": "자격 증명 삭제" + }, + "authSettings.deleteMessage": { + "defaultMessage": "{provider}의 {name} 자격 증명을 삭제하시겠습니까?" + }, + "authSettings.deleteTitle": { + "defaultMessage": "자격 증명 삭제" + }, + "authSettings.deleted": { + "defaultMessage": "자격 증명이 삭제되었습니다." + }, + "authSettings.description": { + "defaultMessage": "goose에 로컬로 저장된 제공업체 자격 증명을 관리합니다." + }, + "authSettings.empty": { + "defaultMessage": "로컬에 저장된 제공업체 자격 증명을 찾을 수 없습니다." + }, + "authSettings.expiresAt": { + "defaultMessage": "{date} 만료" + }, + "authSettings.failedToConfigure": { + "defaultMessage": "자격 증명 구성 실패: {error}" + }, + "authSettings.failedToDelete": { + "defaultMessage": "자격 증명 삭제 실패: {error}" + }, + "authSettings.failedToLoad": { + "defaultMessage": "제공업체 자격 증명을 로드하지 못했습니다." + }, + "authSettings.loading": { + "defaultMessage": "자격 증명을 불러오는 중..." + }, + "authSettings.reauthorize": { + "defaultMessage": "재승인" + }, + "authSettings.signIn": { + "defaultMessage": "로그인" + }, + "authSettings.signedIn": { + "defaultMessage": "자격 증명이 설정되었습니다." + }, + "authSettings.storageProviderCache": { + "defaultMessage": "제공업체 캐시" + }, + "authSettings.storageSecretStore": { + "defaultMessage": "비밀 저장소" + }, + "authSettings.title": { + "defaultMessage": "제공업체 자격 증명" + }, + "backButton.back": { + "defaultMessage": "뒤로" + }, + "baseChat.failedToLoadSession": { + "defaultMessage": "세션을 로드하지 못했습니다." + }, + "baseChat.goHome": { + "defaultMessage": "홈으로 이동" + }, + "baseChat.noSession": { + "defaultMessage": "세션 없음" + }, + "baseChat.recipeCreatedMessage": { + "defaultMessage": "\"{title}\"이 저장되어 사용할 수 있습니다." + }, + "baseChat.recipeCreatedTitle": { + "defaultMessage": "레시피가 성공적으로 생성되었습니다!" + }, + "bottomMenuExtensionSelection.extensionToggleError": { + "defaultMessage": "익스텐션 토글 오류" + }, + "bottomMenuExtensionSelection.extensionUpdated": { + "defaultMessage": "익스텐션이 업데이트되었습니다." + }, + "bottomMenuExtensionSelection.extensionWillBeDisabled": { + "defaultMessage": "{name}은 새 채팅에서 비활성화됩니다." + }, + "bottomMenuExtensionSelection.extensionWillBeEnabled": { + "defaultMessage": "{name}은 새 채팅에서 활성화됩니다." + }, + "bottomMenuExtensionSelection.extensionsForNewChats": { + "defaultMessage": "새 채팅에 사용할 익스텐션" + }, + "bottomMenuExtensionSelection.extensionsForThisSession": { + "defaultMessage": "이 채팅 세션의 익스텐션" + }, + "bottomMenuExtensionSelection.manageExtensions": { + "defaultMessage": "익스텐션 관리" + }, + "bottomMenuExtensionSelection.noActiveSession": { + "defaultMessage": "활성 세션을 찾을 수 없습니다. 먼저 채팅 세션을 시작하세요." + }, + "bottomMenuExtensionSelection.noExtensionsAvailable": { + "defaultMessage": "사용 가능한 익스텐션 없음" + }, + "bottomMenuExtensionSelection.noExtensionsFound": { + "defaultMessage": "익스텐션이 없습니다" + }, + "bottomMenuExtensionSelection.searchExtensions": { + "defaultMessage": "익스텐션 검색..." + }, + "cardButtons.configure": { + "defaultMessage": "구성" + }, + "cardButtons.launch": { + "defaultMessage": "실행" + }, + "chat.notification.taskComplete.body": { + "defaultMessage": "Goose에 다시 초점을 맞추려면 여기를 클릭하세요." + }, + "chat.notification.taskComplete.title": { + "defaultMessage": "goose가 작업을 완료했습니다." + }, + "chatInput.contextWindow": { + "defaultMessage": "컨텍스트 창" + }, + "chatInput.createRecipeFromSession": { + "defaultMessage": "세션에서 레시피 생성" + }, + "chatInput.dictationError": { + "defaultMessage": "받아쓰기 오류" + }, + "chatInput.failedToReadImage": { + "defaultMessage": "이미지 파일을 읽지 못했습니다." + }, + "chatInput.navigationShortcut": { + "defaultMessage": "{prefix}↑/{prefix}↓ 메시지 탐색" + }, + "chatInput.processingDroppedFiles": { + "defaultMessage": "끌어다 놓은 파일을 처리하는 중..." + }, + "chatInput.recording": { + "defaultMessage": "녹음..." + }, + "chatInput.removeFile": { + "defaultMessage": "파일 제거" + }, + "chatInput.removeImage": { + "defaultMessage": "이미지 삭제" + }, + "chatInput.restartingSession": { + "defaultMessage": "세션을 다시 시작하는 중..." + }, + "chatInput.send": { + "defaultMessage": "보내기" + }, + "chatInput.tooManyTools": { + "defaultMessage": "도구가 너무 많으면 성능이 저하될 수 있습니다. 도구 수: {toolCount} (권장: {recommended})" + }, + "chatInput.transcribing": { + "defaultMessage": "음성을 텍스트로 변환 중..." + }, + "chatInput.typeMessage": { + "defaultMessage": "보낼 메시지를 입력하세요" + }, + "chatInput.unknownType": { + "defaultMessage": "알 수 없는 유형" + }, + "chatInput.viewEditRecipe": { + "defaultMessage": "레시피 보기/편집" + }, + "chatInput.viewExtensions": { + "defaultMessage": "익스텐션 보기" + }, + "chatInput.waitingForImages": { + "defaultMessage": "이미지 저장을 기다리는 중..." + }, + "chatSettings.modeDescription": { + "defaultMessage": "goose가 도구 및 익스텐션과 상호작용하는 방식을 설정합니다." + }, + "chatSettings.modeTitle": { + "defaultMessage": "모드" + }, + "chatSettings.responseStylesDescription": { + "defaultMessage": "goose가 응답을 구성하고 표시하는 방식을 선택합니다." + }, + "chatSettings.responseStylesTitle": { + "defaultMessage": "응답 스타일" + }, + "configSettings.configReset": { + "defaultMessage": "구성 재설정" + }, + "configSettings.configResetMsg": { + "defaultMessage": "모든 변경사항이 되돌려졌습니다." + }, + "configSettings.configUpdated": { + "defaultMessage": "구성이 업데이트되었습니다." + }, + "configSettings.configUpdatedMsg": { + "defaultMessage": "\"{name}\"을(를) 저장했습니다." + }, + "configSettings.configurationEditor": { + "defaultMessage": "구성 편집기" + }, + "configSettings.description": { + "defaultMessage": "goose 구성 설정을 편집합니다." + }, + "configSettings.descriptionWithProvider": { + "defaultMessage": "goose 구성 설정을 편집합니다. 현재 설정: {provider}" + }, + "configSettings.done": { + "defaultMessage": "완료" + }, + "configSettings.editConfiguration": { + "defaultMessage": "구성 편집" + }, + "configSettings.enterValue": { + "defaultMessage": "{name}을 입력하세요" + }, + "configSettings.noSettings": { + "defaultMessage": "구성 설정을 찾을 수 없습니다." + }, + "configSettings.resetChanges": { + "defaultMessage": "변경 사항 재설정" + }, + "configSettings.saveFailed": { + "defaultMessage": "저장 실패" + }, + "configSettings.saveFailedMsg": { + "defaultMessage": "\"{name}\" 저장에 실패했습니다." + }, + "configSettings.saving": { + "defaultMessage": "저장 중..." + }, + "configSettings.title": { + "defaultMessage": "구성" + }, + "configureApproveMode.cancel": { + "defaultMessage": "취소" + }, + "configureApproveMode.description": { + "defaultMessage": "모든 도구 요청에 승인을 요구하거나, 위험도에 따라 승인 필요 여부를 판단할 수 있습니다." + }, + "configureApproveMode.manualApproval": { + "defaultMessage": "수동 승인" + }, + "configureApproveMode.manualApprovalDescription": { + "defaultMessage": "모든 도구, 익스텐션, 파일 수정에는 사용자 승인이 필요합니다." + }, + "configureApproveMode.save": { + "defaultMessage": "저장" + }, + "configureApproveMode.saving": { + "defaultMessage": "저장 중..." + }, + "configureApproveMode.smartApproval": { + "defaultMessage": "스마트 승인" + }, + "configureApproveMode.smartApprovalDescription": { + "defaultMessage": "위험 수준에 따라 승인이 필요한 작업을 지능적으로 판단합니다." + }, + "configureApproveMode.title": { + "defaultMessage": "승인 모드 구성" + }, + "confirmationModal.defaultCancel": { + "defaultMessage": "아니요" + }, + "confirmationModal.defaultConfirm": { + "defaultMessage": "예" + }, + "confirmationModal.processing": { + "defaultMessage": "처리 중..." + }, + "conversationLimitsDropdown.conversationLimits": { + "defaultMessage": "대화 제한" + }, + "conversationLimitsDropdown.maxTurns": { + "defaultMessage": "최대 턴" + }, + "conversationLimitsDropdown.maxTurnsDescription": { + "defaultMessage": "goose가 사용자 입력을 요청하기 전 최대 에이전트 턴 수" + }, + "costTracker.costUnavailable": { + "defaultMessage": "{model}({inputTokens} 입력, {outputTokens} 출력 토큰)에 대한 비용 데이터는 제공되지 않습니다." + }, + "costTracker.inputOutputTooltip": { + "defaultMessage": "입력: {inputTokens} 토큰({inputCost}) | 출력: {outputTokens} 토큰({outputCost})" + }, + "costTracker.pricingUnavailable": { + "defaultMessage": "{model}에 대한 가격 데이터가 없습니다." + }, + "costTracker.totalSessionCost": { + "defaultMessage": "총 세션 비용: {cost}" + }, + "createEditRecipe.clickToGenerateDeeplink": { + "defaultMessage": "딥링크를 생성하려면 클릭하세요." + }, + "createEditRecipe.close": { + "defaultMessage": "닫기" + }, + "createEditRecipe.copied": { + "defaultMessage": "복사되었습니다!" + }, + "createEditRecipe.copy": { + "defaultMessage": "복사" + }, + "createEditRecipe.copyLinkDescription": { + "defaultMessage": "이 링크를 복사하여 친구와 공유하거나 Chrome에 직접 붙여넣어 엽니다." + }, + "createEditRecipe.createRecipeTitle": { + "defaultMessage": "레시피 생성" + }, + "createEditRecipe.createSubtitle": { + "defaultMessage": "재사용 가능한 채팅 세션에서 에이전트의 동작과 기능을 정의하는 새 레시피를 만듭니다." + }, + "createEditRecipe.editSubtitle": { + "defaultMessage": "아래 레시피를 편집하여 새 세션에서 에이전트의 동작을 변경할 수 있습니다." + }, + "createEditRecipe.generatingDeeplink": { + "defaultMessage": "딥링크 생성 중..." + }, + "createEditRecipe.learnMore": { + "defaultMessage": "자세히 알아보기" + }, + "createEditRecipe.recipeSavedAndLaunchedMsg": { + "defaultMessage": "레시피가 저장되고 성공적으로 실행되었습니다." + }, + "createEditRecipe.recipeSavedMsg": { + "defaultMessage": "레시피가 성공적으로 저장되었습니다." + }, + "createEditRecipe.saveAndRunFailed": { + "defaultMessage": "저장 및 실행 실패" + }, + "createEditRecipe.saveAndRunFailedMsg": { + "defaultMessage": "레시피 저장 및 실행 실패: {error}" + }, + "createEditRecipe.saveAndRunRecipe": { + "defaultMessage": "레시피 저장 및 실행" + }, + "createEditRecipe.saveFailed": { + "defaultMessage": "저장 실패" + }, + "createEditRecipe.saveFailedMsg": { + "defaultMessage": "레시피 저장 실패: {error}" + }, + "createEditRecipe.saveRecipe": { + "defaultMessage": "레시피 저장" + }, + "createEditRecipe.saving": { + "defaultMessage": "저장 중..." + }, + "createEditRecipe.validationFailed": { + "defaultMessage": "검증 실패" + }, + "createEditRecipe.validationMsg": { + "defaultMessage": "필수 필드를 모두 입력하고 JSON 스키마가 유효한지 확인하세요." + }, + "createEditRecipe.viewEditRecipeTitle": { + "defaultMessage": "레시피 보기/수정" + }, + "createRecipeFromSession.analyzingTitle": { + "defaultMessage": "대화 분석 중" + }, + "createRecipeFromSession.cancel": { + "defaultMessage": "취소" + }, + "createRecipeFromSession.createAndRunRecipe": { + "defaultMessage": "레시피 생성 및 실행" + }, + "createRecipeFromSession.createRecipe": { + "defaultMessage": "레시피 생성" + }, + "createRecipeFromSession.creating": { + "defaultMessage": "생성 중..." + }, + "createRecipeFromSession.extractingInsights": { + "defaultMessage": "채팅에서 통찰력 추출" + }, + "createRecipeFromSession.failedToCreateDefaultMsg": { + "defaultMessage": "레시피를 생성하는 중에 예상치 못한 오류가 발생했습니다. 다시 시도해 주세요." + }, + "createRecipeFromSession.failedToCreateTitle": { + "defaultMessage": "레시피를 생성하지 못했습니다." + }, + "createRecipeFromSession.stageComplete": { + "defaultMessage": "완료!" + }, + "createRecipeFromSession.stageExtracting": { + "defaultMessage": "주요 주제 추출 중..." + }, + "createRecipeFromSession.stageFinalizing": { + "defaultMessage": "세부정보를 마무리하는 중..." + }, + "createRecipeFromSession.stageGenerating": { + "defaultMessage": "레시피 구조 생성 중..." + }, + "createRecipeFromSession.stageIdentifying": { + "defaultMessage": "주요 패턴 식별 중..." + }, + "createRecipeFromSession.stageReading": { + "defaultMessage": "대화를 읽는 중..." + }, + "createRecipeFromSession.subtitle": { + "defaultMessage": "현재 대화를 기반으로 재사용 가능한 레시피를 만드세요." + }, + "createRecipeFromSession.title": { + "defaultMessage": "세션에서 레시피 생성" + }, + "createSubRecipeInline.cancel": { + "defaultMessage": "취소" + }, + "createSubRecipeInline.closeModal": { + "defaultMessage": "하위 레시피 생성 모달 닫기" + }, + "createSubRecipeInline.createAndAdd": { + "defaultMessage": "하위 레시피 생성 및 추가" + }, + "createSubRecipeInline.createdSuccess": { + "defaultMessage": "하위 레시피가 성공적으로 생성되었습니다." + }, + "createSubRecipeInline.creating": { + "defaultMessage": "생성 중..." + }, + "createSubRecipeInline.duplicateName": { + "defaultMessage": "중복된 이름" + }, + "createSubRecipeInline.duplicateNameMsg": { + "defaultMessage": "\"{name}\"이라는 하위 레시피가 이미 존재합니다. 고유한 이름을 사용하세요." + }, + "createSubRecipeInline.instructionsLabel": { + "defaultMessage": "지침" + }, + "createSubRecipeInline.instructionsPlaceholder": { + "defaultMessage": "이 하위 레시피가 호출될 때 AI에 제공할 지침..." + }, + "createSubRecipeInline.nameHint": { + "defaultMessage": "도구 이름을 생성하는 데 사용되는 고유 식별자" + }, + "createSubRecipeInline.nameLabel": { + "defaultMessage": "이름" + }, + "createSubRecipeInline.namePlaceholder": { + "defaultMessage": "예: security_scan" + }, + "createSubRecipeInline.preconfiguredValues": { + "defaultMessage": "사전 구성된 값" + }, + "createSubRecipeInline.preconfiguredValuesHint": { + "defaultMessage": "항상 하위 레시피에 전달되는 선택적 매개변수 값" + }, + "createSubRecipeInline.recipeDescriptionLabel": { + "defaultMessage": "레시피 설명" + }, + "createSubRecipeInline.recipeDescriptionPlaceholder": { + "defaultMessage": "이 레시피가 실행될 때 수행되는 작업" + }, + "createSubRecipeInline.recipeTitleLabel": { + "defaultMessage": "레시피 제목" + }, + "createSubRecipeInline.recipeTitlePlaceholder": { + "defaultMessage": "예: 보안 분석 도구" + }, + "createSubRecipeInline.saveFailed": { + "defaultMessage": "저장 실패" + }, + "createSubRecipeInline.saveFailedMsg": { + "defaultMessage": "하위 레시피 저장 실패: {error}" + }, + "createSubRecipeInline.sequentialHint": { + "defaultMessage": "(여러 인스턴스를 순차적으로 강제 실행)" + }, + "createSubRecipeInline.sequentialLabel": { + "defaultMessage": "반복 시 순차 실행" + }, + "createSubRecipeInline.subtitle": { + "defaultMessage": "기본 레시피에서 호출 가능한 도구로 사용할 간단한 레시피를 만듭니다." + }, + "createSubRecipeInline.title": { + "defaultMessage": "새 하위 레시피 생성" + }, + "createSubRecipeInline.toolDescriptionLabel": { + "defaultMessage": "도구 설명" + }, + "createSubRecipeInline.toolDescriptionPlaceholder": { + "defaultMessage": "도구로 호출될 때 표시되는 선택적 설명" + }, + "createSubRecipeInline.validationFailed": { + "defaultMessage": "검증 실패" + }, + "createSubRecipeInline.validationMsg": { + "defaultMessage": "이름, 제목, 레시피 설명, 지침이 필요합니다." + }, + "creditsExhaustedNotification.addCredits": { + "defaultMessage": "크레딧 추가" + }, + "creditsExhaustedNotification.insufficientCredits": { + "defaultMessage": "부족한 크레딧" + }, + "cronPicker.april": { + "defaultMessage": "4월" + }, + "cronPicker.at": { + "defaultMessage": "에" + }, + "cronPicker.atMinute": { + "defaultMessage": "분에" + }, + "cronPicker.atSecond": { + "defaultMessage": "초에" + }, + "cronPicker.august": { + "defaultMessage": "8월" + }, + "cronPicker.cronExpression": { + "defaultMessage": "cron 표현식" + }, + "cronPicker.custom": { + "defaultMessage": "사용자 지정 cron" + }, + "cronPicker.day": { + "defaultMessage": "일" + }, + "cronPicker.december": { + "defaultMessage": "12월" + }, + "cronPicker.emptyCronError": { + "defaultMessage": "크론 표현식은 비워둘 수 없습니다." + }, + "cronPicker.every": { + "defaultMessage": "모든" + }, + "cronPicker.february": { + "defaultMessage": "2월" + }, + "cronPicker.friday": { + "defaultMessage": "금요일" + }, + "cronPicker.hour": { + "defaultMessage": "시간" + }, + "cronPicker.inMonth": { + "defaultMessage": "에" + }, + "cronPicker.invalidDayOfMonth": { + "defaultMessage": "일은 1에서 {max} 사이여야 합니다." + }, + "cronPicker.january": { + "defaultMessage": "1월" + }, + "cronPicker.july": { + "defaultMessage": "7월" + }, + "cronPicker.june": { + "defaultMessage": "6월" + }, + "cronPicker.march": { + "defaultMessage": "3월" + }, + "cronPicker.may": { + "defaultMessage": "5월" + }, + "cronPicker.minute": { + "defaultMessage": "분" + }, + "cronPicker.mode": { + "defaultMessage": "모드" + }, + "cronPicker.monday": { + "defaultMessage": "월요일" + }, + "cronPicker.month": { + "defaultMessage": "월" + }, + "cronPicker.november": { + "defaultMessage": "11월" + }, + "cronPicker.october": { + "defaultMessage": "10월" + }, + "cronPicker.on": { + "defaultMessage": "에" + }, + "cronPicker.onDay": { + "defaultMessage": "일에" + }, + "cronPicker.quarter": { + "defaultMessage": "분기" + }, + "cronPicker.saturday": { + "defaultMessage": "토요일" + }, + "cronPicker.september": { + "defaultMessage": "9월" + }, + "cronPicker.startingMonth": { + "defaultMessage": "시작 월" + }, + "cronPicker.sunday": { + "defaultMessage": "일요일" + }, + "cronPicker.thursday": { + "defaultMessage": "목요일" + }, + "cronPicker.tuesday": { + "defaultMessage": "화요일" + }, + "cronPicker.wednesday": { + "defaultMessage": "수요일" + }, + "cronPicker.week": { + "defaultMessage": "주" + }, + "cronPicker.year": { + "defaultMessage": "연도" + }, + "customProviderForm.add": { + "defaultMessage": "추가" + }, + "customProviderForm.anthropicCompatible": { + "defaultMessage": "Anthropic 호환 가능" + }, + "customProviderForm.apiBasePath": { + "defaultMessage": "API 기본 경로(선택)" + }, + "customProviderForm.apiBasePathHint": { + "defaultMessage": "기본 API 경로를 재정의합니다. 제공업체의 기본 경로를 사용하려면 비워 두세요." + }, + "customProviderForm.apiBasePathPlaceholder": { + "defaultMessage": "예: v1/chat/completions 또는 project_id/v1" + }, + "customProviderForm.apiKey": { + "defaultMessage": "API 키" + }, + "customProviderForm.apiKeyPlaceholderExisting": { + "defaultMessage": "기존 키를 유지하려면 비워 두세요." + }, + "customProviderForm.apiKeyPlaceholderNew": { + "defaultMessage": "API 키" + }, + "customProviderForm.apiKeyRequired": { + "defaultMessage": "API 키가 필요합니다" + }, + "customProviderForm.apiUrl": { + "defaultMessage": "API URL" + }, + "customProviderForm.apiUrlPlaceholder": { + "defaultMessage": "https://api.example.com" + }, + "customProviderForm.apiUrlRequired": { + "defaultMessage": "API URL은 필수입니다" + }, + "customProviderForm.attachments": { + "defaultMessage": "첨부파일" + }, + "customProviderForm.authHint": { + "defaultMessage": "Ollama와 같은 로컬 LLM에는 일반적으로 API 키가 필요하지 않습니다." + }, + "customProviderForm.authentication": { + "defaultMessage": "인증" + }, + "customProviderForm.availableModels": { + "defaultMessage": "사용 가능한 모델(쉼표로 구분)" + }, + "customProviderForm.back": { + "defaultMessage": "← 뒤로" + }, + "customProviderForm.cancel": { + "defaultMessage": "취소" + }, + "customProviderForm.cannotDeleteActive": { + "defaultMessage": "현재 사용 중인 동안에는 이 제공업체를 삭제할 수 없습니다. 먼저 다른 모델로 전환해 보세요." + }, + "customProviderForm.chooseSetup": { + "defaultMessage": "제공업체 설정 방법을 선택하세요." + }, + "customProviderForm.clear": { + "defaultMessage": "지우기" + }, + "customProviderForm.configureManually": { + "defaultMessage": "수동으로 구성" + }, + "customProviderForm.configureManuallyDesc": { + "defaultMessage": "모든 제공업체 세부정보를 직접 입력하세요." + }, + "customProviderForm.confirmDelete": { + "defaultMessage": "삭제 확인" + }, + "customProviderForm.createProvider": { + "defaultMessage": "제공업체 생성" + }, + "customProviderForm.customHeaders": { + "defaultMessage": "사용자 지정 헤더" + }, + "customProviderForm.customHeadersHint": { + "defaultMessage": "제공업체에 보내는 요청에 포함할 사용자 지정 HTTP 헤더를 추가합니다. 두 필드를 모두 채운 뒤 \"+\" 버튼을 클릭해 추가하세요." + }, + "customProviderForm.deleteConfirmation": { + "defaultMessage": "이 사용자 지정 제공업체를 삭제하시겠습니까? 제공업체와 저장된 API 키가 영구적으로 삭제되며, 이 작업은 취소할 수 없습니다." + }, + "customProviderForm.deleteProvider": { + "defaultMessage": "제공업체 삭제" + }, + "customProviderForm.displayName": { + "defaultMessage": "표시 이름" + }, + "customProviderForm.displayNamePlaceholder": { + "defaultMessage": "제공업체 이름" + }, + "customProviderForm.displayNameRequired": { + "defaultMessage": "표시 이름은 필수 항목입니다." + }, + "customProviderForm.docs": { + "defaultMessage": "문서" + }, + "customProviderForm.headerBothRequired": { + "defaultMessage": "헤더 이름과 값을 모두 입력해야 합니다." + }, + "customProviderForm.headerDuplicate": { + "defaultMessage": "이 이름을 가진 헤더가 이미 존재합니다." + }, + "customProviderForm.headerNamePlaceholder": { + "defaultMessage": "헤더 이름" + }, + "customProviderForm.headerNoSpaces": { + "defaultMessage": "헤더 이름에는 공백이 포함될 수 없습니다." + }, + "customProviderForm.modelsPlaceholder": { + "defaultMessage": "모델-a, 모델-b, 모델-c" + }, + "customProviderForm.modelsRequired": { + "defaultMessage": "모델이 1개 이상 필요합니다." + }, + "customProviderForm.ollamaCompatible": { + "defaultMessage": "Ollama 호환 가능" + }, + "customProviderForm.openaiCompatible": { + "defaultMessage": "OpenAI 호환 가능" + }, + "customProviderForm.providerType": { + "defaultMessage": "제공업체 유형" + }, + "customProviderForm.reasoning": { + "defaultMessage": "추론" + }, + "customProviderForm.requiresApiKey": { + "defaultMessage": "이 제공업체에는 API 키가 필요합니다" + }, + "customProviderForm.startFromTemplate": { + "defaultMessage": "제공업체 템플릿에서 시작" + }, + "customProviderForm.startFromTemplateDesc": { + "defaultMessage": "알려진 제공업체를 선택하면 구성이 자동으로 채워집니다." + }, + "customProviderForm.submitError": { + "defaultMessage": "제공업체를 저장하지 못했습니다. 설정을 확인하고 다시 시도하세요." + }, + "customProviderForm.supportsStreaming": { + "defaultMessage": "제공업체는 스트리밍 응답을 지원합니다." + }, + "customProviderForm.toolCalling": { + "defaultMessage": "도구 호출" + }, + "customProviderForm.updateProvider": { + "defaultMessage": "제공업체 업데이트" + }, + "customProviderForm.usingTemplate": { + "defaultMessage": "템플릿 사용: {name}" + }, + "customProviderForm.valuePlaceholder": { + "defaultMessage": "값" + }, + "defaultCardButtons.configureSettings": { + "defaultMessage": "{name} 설정 구성" + }, + "defaultCardButtons.deleteSettings": { + "defaultMessage": "{name} 설정 삭제" + }, + "defaultCardButtons.editSettings": { + "defaultMessage": "{name} 설정 편집" + }, + "defaultCardButtons.getStarted": { + "defaultMessage": "goose 시작하기!" + }, + "defaultProviderSetupForm.apiHostLabel": { + "defaultMessage": "API 호스트" + }, + "defaultProviderSetupForm.apiHostPlaceholder": { + "defaultMessage": "https://api.example.com" + }, + "defaultProviderSetupForm.apiKeyLabel": { + "defaultMessage": "API 키" + }, + "defaultProviderSetupForm.apiKeyPlaceholder": { + "defaultMessage": "API 키" + }, + "defaultProviderSetupForm.hideOptions": { + "defaultMessage": "{count} 옵션 숨기기" + }, + "defaultProviderSetupForm.loadingConfig": { + "defaultMessage": "구성 값 로드 중..." + }, + "defaultProviderSetupForm.modelsLabel": { + "defaultMessage": "모델" + }, + "defaultProviderSetupForm.modelsPlaceholder": { + "defaultMessage": "모델-a, 모델-b" + }, + "defaultProviderSetupForm.noConfigParameters": { + "defaultMessage": "이 제공업체에 대한 구성 매개변수가 없습니다." + }, + "defaultProviderSetupForm.showOptions": { + "defaultMessage": "{count} 옵션 표시" + }, + "diagnosticsModal.attachHint": { + "defaultMessage": "버그를 신고하는 경우 진단 보고서를 첨부해 보세요." + }, + "diagnosticsModal.cancel": { + "defaultMessage": "취소" + }, + "diagnosticsModal.configSettings": { + "defaultMessage": "구성 설정" + }, + "diagnosticsModal.description": { + "defaultMessage": "진단 zip 파일을 다운로드하여 팀과 공유하거나 시스템 세부 정보가 미리 입력된 상태로 GitHub에 직접 버그를 신고할 수 있습니다. 진단 보고서에는 다음이 포함됩니다." + }, + "diagnosticsModal.diagnosticsErrorMsg": { + "defaultMessage": "진단을 다운로드하지 못했습니다." + }, + "diagnosticsModal.diagnosticsErrorTitle": { + "defaultMessage": "진단 오류" + }, + "diagnosticsModal.download": { + "defaultMessage": "다운로드" + }, + "diagnosticsModal.downloading": { + "defaultMessage": "다운로드 중..." + }, + "diagnosticsModal.fileBug": { + "defaultMessage": "GitHub에 버그 신고" + }, + "diagnosticsModal.logFiles": { + "defaultMessage": "최근 로그 파일" + }, + "diagnosticsModal.opening": { + "defaultMessage": "열기..." + }, + "diagnosticsModal.reportProblem": { + "defaultMessage": "문제 신고" + }, + "diagnosticsModal.sensitiveWarning": { + "defaultMessage": "세션에 민감한 정보가 포함되어 있는 경우 진단 파일을 공개적으로 공유하지 마십시오." + }, + "diagnosticsModal.sessionMessages": { + "defaultMessage": "현재 세션 메시지" + }, + "diagnosticsModal.systemInfo": { + "defaultMessage": "기본 시스템 정보" + }, + "diagnosticsModal.systemInfoErrorMsg": { + "defaultMessage": "시스템 정보를 가져오지 못했습니다." + }, + "diagnosticsModal.systemInfoErrorTitle": { + "defaultMessage": "오류" + }, + "dialog.close": { + "defaultMessage": "닫기" + }, + "dictationSettings.addApiKey": { + "defaultMessage": "API 키 추가" + }, + "dictationSettings.apiKey": { + "defaultMessage": "API 키" + }, + "dictationSettings.cancel": { + "defaultMessage": "취소" + }, + "dictationSettings.chooseVoiceConversion": { + "defaultMessage": "음성을 텍스트로 변환하는 방식을 선택합니다." + }, + "dictationSettings.configureApiKey": { + "defaultMessage": "{settingsPath}에서 API 키를 설정합니다." + }, + "dictationSettings.configured": { + "defaultMessage": "(설정됨)" + }, + "dictationSettings.configuredIn": { + "defaultMessage": "✓ {settingsPath}에 설정됨" + }, + "dictationSettings.disabled": { + "defaultMessage": "사용 안 함" + }, + "dictationSettings.enterApiKey": { + "defaultMessage": "API 키를 입력하세요" + }, + "dictationSettings.notConfigured": { + "defaultMessage": "(설정되지 않음)" + }, + "dictationSettings.removeApiKey": { + "defaultMessage": "API 키 제거" + }, + "dictationSettings.requiredForTranscription": { + "defaultMessage": "텍스트 변환에 필요" + }, + "dictationSettings.save": { + "defaultMessage": "저장" + }, + "dictationSettings.updateApiKey": { + "defaultMessage": "API 키 업데이트" + }, + "dictationSettings.voiceDictationProvider": { + "defaultMessage": "음성 받아쓰기 제공업체" + }, + "dirSwitcher.chooseDirectory": { + "defaultMessage": "디렉터리 선택…" + }, + "dirSwitcher.currentDirectory": { + "defaultMessage": "현재 디렉터리" + }, + "dirSwitcher.failedToUpdateWorkingDir": { + "defaultMessage": "작업 디렉터리를 업데이트하지 못했습니다." + }, + "dirSwitcher.gitWorktrees": { + "defaultMessage": "Git 워크트리" + }, + "dirSwitcher.noWorktreesFound": { + "defaultMessage": "작업 트리를 찾을 수 없습니다." + }, + "dirSwitcher.openInFinder": { + "defaultMessage": "파일 관리자에서 열기" + }, + "dirSwitcher.recentDirectories": { + "defaultMessage": "최근 디렉터리" + }, + "elicitationRequest.accept": { + "defaultMessage": "수락" + }, + "elicitationRequest.cancelled": { + "defaultMessage": "정보 요청이 취소되었습니다." + }, + "elicitationRequest.defaultMessage": { + "defaultMessage": "Goose에 추가 정보가 필요합니다." + }, + "elicitationRequest.expired": { + "defaultMessage": "이 요청은 만료되었습니다. 익스텐션에서 다시 질문해야 합니다." + }, + "elicitationRequest.submit": { + "defaultMessage": "제출" + }, + "elicitationRequest.submitError": { + "defaultMessage": "이 요청은 더 이상 활성 상태가 아닙니다. 익스텐션에서 다시 요청해야 합니다." + }, + "elicitationRequest.submitted": { + "defaultMessage": "제출된 정보" + }, + "elicitationRequest.waitingForResponse": { + "defaultMessage": "답변을 기다리는 중입니다. ({timeRemaining} 남음)" + }, + "envVarsSection.add": { + "defaultMessage": "추가" + }, + "envVarsSection.bothRequired": { + "defaultMessage": "변수 이름과 값을 모두 입력해야 합니다." + }, + "envVarsSection.envVarsDescription": { + "defaultMessage": "환경 변수에 대한 키-값 쌍을 추가합니다. 두 필드를 모두 채운 후 \"+\" 버튼을 클릭하여 추가하세요. 기존 비밀 값의 경우 편집 버튼을 클릭하여 수정하세요." + }, + "envVarsSection.environmentVariables": { + "defaultMessage": "환경 변수" + }, + "envVarsSection.noSpaces": { + "defaultMessage": "변수 이름에는 공백이 포함될 수 없습니다." + }, + "envVarsSection.value": { + "defaultMessage": "값" + }, + "envVarsSection.variableName": { + "defaultMessage": "변수 이름" + }, + "environmentBadge.dev": { + "defaultMessage": "Dev" + }, + "errorBoundary.errorGeneric": { + "defaultMessage": "오류가 발생했습니다." + }, + "errorBoundary.errorWithVersion": { + "defaultMessage": "Goose v{version}에서 오류가 발생했습니다." + }, + "errorBoundary.heading": { + "defaultMessage": "꽥!" + }, + "errorBoundary.reload": { + "defaultMessage": "새로고침" + }, + "extensionConfigFields.commandLabel": { + "defaultMessage": "명령" + }, + "extensionConfigFields.commandPlaceholder": { + "defaultMessage": "예: npx -y @modelcontextprotocol/my-extension [filepath]" + }, + "extensionConfigFields.commandRequired": { + "defaultMessage": "명령이 필요합니다" + }, + "extensionConfigFields.endpointLabel": { + "defaultMessage": "엔드포인트" + }, + "extensionConfigFields.endpointPlaceholder": { + "defaultMessage": "엔드포인트 URL 입력..." + }, + "extensionConfigFields.endpointRequired": { + "defaultMessage": "엔드포인트 URL이 필요합니다" + }, + "extensionInfoFields.descriptionLabel": { + "defaultMessage": "설명" + }, + "extensionInfoFields.descriptionPlaceholder": { + "defaultMessage": "선택적 설명..." + }, + "extensionInfoFields.extensionName": { + "defaultMessage": "익스텐션 이름" + }, + "extensionInfoFields.extensionNamePlaceholder": { + "defaultMessage": "익스텐션 이름을 입력하세요..." + }, + "extensionInfoFields.nameRequired": { + "defaultMessage": "이름은 필수입니다" + }, + "extensionInfoFields.typeHttp": { + "defaultMessage": "HTTP" + }, + "extensionInfoFields.typeLabel": { + "defaultMessage": "유형" + }, + "extensionInfoFields.typeSseUnsupported": { + "defaultMessage": "SSE (미지원)" + }, + "extensionInfoFields.typeStandardIo": { + "defaultMessage": "표준 IO(STDIO)" + }, + "extensionInfoFields.typeStdio": { + "defaultMessage": "STDIO" + }, + "extensionInfoFields.typeStreamableHttp": { + "defaultMessage": "스트리밍 가능 HTTP" + }, + "extensionInstallModal.alreadyInstalledMessage": { + "defaultMessage": "''{name}'' 익스텐션이 이미 설치되었습니다. 사용하려면 새 채팅 세션을 시작하세요." + }, + "extensionInstallModal.alreadyInstalledTitle": { + "defaultMessage": "''{name}'' 익스텐션이 이미 설치되어 있습니다." + }, + "extensionInstallModal.blockedMessage": { + "defaultMessage": "이 익스텐션 명령은 허용 목록에 없으며 설치가 차단됩니다. 익스텐션: {name} 명령: {command} 이 익스텐션에 대한 승인을 요청하려면 관리자에게 문의하세요." + }, + "extensionInstallModal.blockedTitle": { + "defaultMessage": "익스텐션 설치가 차단되었습니다." + }, + "extensionInstallModal.cancel": { + "defaultMessage": "취소" + }, + "extensionInstallModal.installAnyway": { + "defaultMessage": "그래도 설치" + }, + "extensionInstallModal.installing": { + "defaultMessage": "설치 중..." + }, + "extensionInstallModal.no": { + "defaultMessage": "아니요" + }, + "extensionInstallModal.ok": { + "defaultMessage": "좋아요" + }, + "extensionInstallModal.trustedMessage": { + "defaultMessage": "{name} 익스텐션을 설치하시겠습니까? 명령: {command}" + }, + "extensionInstallModal.trustedTitle": { + "defaultMessage": "익스텐션 설치 확인" + }, + "extensionInstallModal.unknownCommand": { + "defaultMessage": "알 수 없는 명령" + }, + "extensionInstallModal.untrustedMessageWithCommand": { + "defaultMessage": "{securityMessage} 익스텐션: {name} 명령: {command} 이 내용에 대해 확실하지 않은 경우 관리자에게 문의하세요." + }, + "extensionInstallModal.untrustedMessageWithUrl": { + "defaultMessage": "{securityMessage} 익스텐션: {name} URL: {url} 이에 대해 확실하지 않은 경우 관리자에게 문의하세요." + }, + "extensionInstallModal.untrustedSecurityMessage": { + "defaultMessage": "이 익스텐션 명령은 허용 목록에 없으며 대화에 액세스하고 추가 기능을 제공할 수 있습니다. 신뢰할 수 없는 소스의 익스텐션을 설치하면 보안 위험이 발생할 수 있습니다." + }, + "extensionInstallModal.untrustedTitle": { + "defaultMessage": "신뢰할 수 없는 익스텐션을 설치하시겠습니까?" + }, + "extensionInstallModal.yes": { + "defaultMessage": "예" + }, + "extensionItem.configureExtension": { + "defaultMessage": "{name} 익스텐션 구성" + }, + "extensionItem.toggleExtension": { + "defaultMessage": "{name} 익스텐션 켜기/끄기" + }, + "extensionList.availableExtensions": { + "defaultMessage": "사용 가능한 익스텐션({count})" + }, + "extensionList.builtInExtension": { + "defaultMessage": "내장 익스텐션" + }, + "extensionList.defaultExtensions": { + "defaultMessage": "기본 익스텐션({count})" + }, + "extensionList.noExtensions": { + "defaultMessage": "사용 가능한 익스텐션이 없습니다." + }, + "extensionModal.cancel": { + "defaultMessage": "취소" + }, + "extensionModal.closeWithoutSaving": { + "defaultMessage": "저장하지 않고 닫기" + }, + "extensionModal.confirmRemoval": { + "defaultMessage": "제거 확인" + }, + "extensionModal.deleteDescription": { + "defaultMessage": "이 익스텐션과 모든 설정이 영구적으로 삭제됩니다." + }, + "extensionModal.deleteExtensionTitle": { + "defaultMessage": "익스텐션 \"{name}\" 삭제" + }, + "extensionModal.installationNotes": { + "defaultMessage": "설치 참고 사항" + }, + "extensionModal.removeExtension": { + "defaultMessage": "익스텐션 제거" + }, + "extensionModal.unsavedChangesMessage": { + "defaultMessage": "익스텐션 구성에 저장되지 않은 변경사항이 있습니다. 저장하지 않고 닫으시겠습니까?" + }, + "extensionModal.unsavedChangesTitle": { + "defaultMessage": "저장되지 않은 변경사항" + }, + "extensionTimeoutField.timeoutLabel": { + "defaultMessage": "시간 초과" + }, + "extensionsSection.addCustomExtension": { + "defaultMessage": "사용자 지정 익스텐션 추가" + }, + "extensionsSection.addExtension": { + "defaultMessage": "익스텐션 추가" + }, + "extensionsSection.saveChanges": { + "defaultMessage": "변경 사항 저장" + }, + "extensionsSection.updateExtension": { + "defaultMessage": "익스텐션 업데이트" + }, + "extensionsView.addCustomExtension": { + "defaultMessage": "사용자 지정 익스텐션 추가" + }, + "extensionsView.addExtension": { + "defaultMessage": "익스텐션 추가" + }, + "extensionsView.defaultNote": { + "defaultMessage": "여기에서 켠 익스텐션은 새 채팅의 기본값으로 사용됩니다. 채팅 중에도 익스텐션을 켜거나 끌 수 있습니다." + }, + "extensionsView.description": { + "defaultMessage": "이 익스텐션은 Model Context Protocol(MCP)을 사용합니다. 프롬프트, 리소스, 도구의 세 가지 주요 구성 요소로 goose의 기능을 확장합니다. {searchShortcut}로 검색할 수 있습니다." + }, + "extensionsView.heading": { + "defaultMessage": "익스텐션" + }, + "extensionsView.searchPlaceholder": { + "defaultMessage": "익스텐션 검색..." + }, + "externalBackendSection.certFingerprint": { + "defaultMessage": "인증서 지문(선택 사항)" + }, + "externalBackendSection.certFingerprintHelp": { + "defaultMessage": "특정 TLS 인증서 지문을 고정하세요. 생략할 경우 최초 사용 시 인증서를 신뢰합니다(TOFU)." + }, + "externalBackendSection.certFingerprintPlaceholder": { + "defaultMessage": "AA:BB:CC:... 또는 sha256/base64" + }, + "externalBackendSection.description": { + "defaultMessage": "기본적으로 goose가 서버를 자동으로 실행합니다. 외부 goose 서버에 연결하려면 이 옵션을 사용하세요." + }, + "externalBackendSection.restartNote": { + "defaultMessage": "변경 사항을 적용하려면 goose를 다시 시작해야 합니다. 새 채팅 창은 외부 서버에 연결됩니다." + }, + "externalBackendSection.secretKey": { + "defaultMessage": "비밀 키" + }, + "externalBackendSection.secretKeyHelp": { + "defaultMessage": "goosed 서버에 설정된 비밀 키 (GOOSE_SERVER__SECRET_KEY)" + }, + "externalBackendSection.secretKeyPlaceholder": { + "defaultMessage": "서버의 비밀 키를 입력하세요" + }, + "externalBackendSection.serverUrl": { + "defaultMessage": "서버 URL" + }, + "externalBackendSection.title": { + "defaultMessage": "goose 서버" + }, + "externalBackendSection.urlFormatError": { + "defaultMessage": "잘못된 URL 형식입니다." + }, + "externalBackendSection.urlProtocolError": { + "defaultMessage": "URL은 반드시 http 또는 https 프로토콜을 사용해야 합니다." + }, + "externalBackendSection.useExternalServer": { + "defaultMessage": "외부 서버 사용" + }, + "externalBackendSection.useExternalServerDescription": { + "defaultMessage": "다른 곳에서 실행 중인 goose 서버에 연결합니다. 앱을 다시 시작해야 합니다." + }, + "freeOptionCards.chooseOption": { + "defaultMessage": "시작하려면 옵션을 선택하세요." + }, + "freeOptionCards.freeAndPrivate": { + "defaultMessage": "무료 및 비공개" + }, + "freeOptionCards.localModelDescription": { + "defaultMessage": "모델을 다운로드하고 컴퓨터에서 완전히 실행하세요. API 키도 없고 계정도 없습니다." + }, + "freeOptionCards.localModelTitle": { + "defaultMessage": "로컬 모델 사용" + }, + "freeOptionCards.nanogptDescription": { + "defaultMessage": "7일 동안 6천만 개의 무료 토큰을 받으려면 등록하세요." + }, + "freeOptionCards.nanogptTitle": { + "defaultMessage": "나노GPT" + }, + "freeOptionCards.retry": { + "defaultMessage": "다시 시도" + }, + "freeOptionCards.tetrateDescription": { + "defaultMessage": "자동 설정을 통해 여러 AI 모델에 액세스하세요. $10 크레딧을 받으려면 등록하세요." + }, + "freeOptionCards.tetrateTitle": { + "defaultMessage": "Tetrate의 에이전트 라우터" + }, + "freeOptionCards.unexpectedError": { + "defaultMessage": "설정 중에 예상치 못한 오류가 발생했습니다." + }, + "gatewaySettings.botFatherInstructions": { + "defaultMessage": "휴대폰에서 @BotFather를 열고 /newbot을 보낸 뒤, 안내에 따라 봇 이름을 지정하세요. BotFather가 API 토큰을 보내면 아래에 붙여넣으세요." + }, + "gatewaySettings.close": { + "defaultMessage": "닫기" + }, + "gatewaySettings.expiresIn": { + "defaultMessage": "{time} 후 만료" + }, + "gatewaySettings.failedToGeneratePairingCode": { + "defaultMessage": "페어링 코드를 생성하지 못했습니다." + }, + "gatewaySettings.failedToRemove": { + "defaultMessage": "제거하지 못했습니다." + }, + "gatewaySettings.failedToStart": { + "defaultMessage": "시작하지 못했습니다." + }, + "gatewaySettings.failedToStop": { + "defaultMessage": "중지하지 못했습니다." + }, + "gatewaySettings.failedToUnpairUser": { + "defaultMessage": "사용자 페어링을 해제하지 못했습니다." + }, + "gatewaySettings.loading": { + "defaultMessage": "로드 중..." + }, + "gatewaySettings.pairDevice": { + "defaultMessage": "기기 페어링" + }, + "gatewaySettings.pairedUsers": { + "defaultMessage": "페어링된 사용자" + }, + "gatewaySettings.pairingCode": { + "defaultMessage": "페어링 코드" + }, + "gatewaySettings.pasteBotToken": { + "defaultMessage": "봇 토큰을 여기에 붙여넣으세요" + }, + "gatewaySettings.remove": { + "defaultMessage": "제거" + }, + "gatewaySettings.running": { + "defaultMessage": "실행 중" + }, + "gatewaySettings.sendCodeToPair": { + "defaultMessage": "페어링하려면 이 코드를 {gatewayType} 봇에 보내세요." + }, + "gatewaySettings.start": { + "defaultMessage": "시작" + }, + "gatewaySettings.stop": { + "defaultMessage": "중지" + }, + "gatewaySettings.stopped": { + "defaultMessage": "중지됨" + }, + "gatewaySettings.telegram": { + "defaultMessage": "Telegram" + }, + "goosehintsModal.close": { + "defaultMessage": "닫기" + }, + "goosehintsModal.developer": { + "defaultMessage": "개발자" + }, + "goosehintsModal.dialogDescription": { + "defaultMessage": "goose와의 커뮤니케이션을 개선할 수 있도록 프로젝트에 대한 추가 컨텍스트를 제공하세요." + }, + "goosehintsModal.dialogTitle": { + "defaultMessage": "프로젝트 힌트 구성(.goosehints)" + }, + "goosehintsModal.errorReading": { + "defaultMessage": ".goosehints 파일 읽기 오류: {error}" + }, + "goosehintsModal.failedToAccess": { + "defaultMessage": ".goosehints 파일에 액세스하지 못했습니다." + }, + "goosehintsModal.failedToSave": { + "defaultMessage": ".goosehints 파일을 저장하지 못했습니다." + }, + "goosehintsModal.fileCreating": { + "defaultMessage": "새 .goosehints 파일 생성: {filePath}" + }, + "goosehintsModal.fileFound": { + "defaultMessage": ".goosehints 파일 위치: {filePath}" + }, + "goosehintsModal.helpText1": { + "defaultMessage": ".goosehints는 프로젝트에 대한 추가 컨텍스트를 제공하고 goose와의 커뮤니케이션을 개선하는 데 사용되는 텍스트 파일입니다." + }, + "goosehintsModal.helpText2": { + "defaultMessage": "익스텐션 페이지에서 {bold} 익스텐션이 활성화되어 있는지 확인하세요. .goosehints를 사용하려면 이 익스텐션이 필요합니다. .goosehints 업데이트를 적용하려면 세션을 다시 시작해야 합니다." + }, + "goosehintsModal.helpText3": { + "defaultMessage": "자세한 내용은 {link}를 참조하세요." + }, + "goosehintsModal.helpTextLink": { + "defaultMessage": ".goosehints 사용" + }, + "goosehintsModal.placeholder": { + "defaultMessage": "여기에 프로젝트 힌트를 입력하세요..." + }, + "goosehintsModal.save": { + "defaultMessage": "저장" + }, + "goosehintsModal.savedSuccessfully": { + "defaultMessage": "성공적으로 저장되었습니다" + }, + "goosehintsModal.saving": { + "defaultMessage": "저장 중..." + }, + "goosehintsSection.configure": { + "defaultMessage": "설정" + }, + "goosehintsSection.description": { + "defaultMessage": "프로젝트의 .goosehints 파일을 설정하여 goose에 추가 컨텍스트를 제공합니다." + }, + "goosehintsSection.title": { + "defaultMessage": "프로젝트 힌트 (.goosehints)" + }, + "groupedExtensionLoadingToast.askGoose": { + "defaultMessage": "goose에게 묻기" + }, + "groupedExtensionLoadingToast.collapseDetails": { + "defaultMessage": "세부정보 접기" + }, + "groupedExtensionLoadingToast.copied": { + "defaultMessage": "복사되었습니다!" + }, + "groupedExtensionLoadingToast.copyError": { + "defaultMessage": "복사 오류" + }, + "groupedExtensionLoadingToast.expandDetails": { + "defaultMessage": "세부정보 펼치기" + }, + "groupedExtensionLoadingToast.failedToAddExtension": { + "defaultMessage": "익스텐션을 추가하지 못했습니다." + }, + "groupedExtensionLoadingToast.failedToLoad": { + "defaultMessage": "{count,plural,one{#개의 익스텐션을 로드하지 못했습니다} other{#개의 익스텐션을 로드하지 못했습니다}}" + }, + "groupedExtensionLoadingToast.loadingExtensions": { + "defaultMessage": "{count,plural,one{#개의 익스텐션을 로드하는 중...} other{#개의 익스텐션을 로드하는 중...}}" + }, + "groupedExtensionLoadingToast.partiallyLoaded": { + "defaultMessage": "{totalCount,plural,one{익스텐션 {successCount}/#개 로드됨} other{익스텐션 {successCount}/#개 로드됨}}" + }, + "groupedExtensionLoadingToast.showDetails": { + "defaultMessage": "세부정보 표시" + }, + "groupedExtensionLoadingToast.showLess": { + "defaultMessage": "간략히 보기" + }, + "groupedExtensionLoadingToast.successfullyLoaded": { + "defaultMessage": "{count,plural,one{#개의 익스텐션을 성공적으로 로드했습니다} other{#개의 익스텐션을 성공적으로 로드했습니다}}" + }, + "headersSection.add": { + "defaultMessage": "추가" + }, + "headersSection.bothRequired": { + "defaultMessage": "헤더 이름과 값을 모두 입력해야 합니다." + }, + "headersSection.duplicateHeader": { + "defaultMessage": "이 이름을 가진 헤더가 이미 존재합니다." + }, + "headersSection.headerName": { + "defaultMessage": "헤더 이름" + }, + "headersSection.headersDescription": { + "defaultMessage": "MCP 서버로 보내는 요청에 포함할 사용자 지정 HTTP 헤더를 추가합니다. 두 필드를 모두 채운 뒤 \"+\" 버튼을 클릭해 추가하세요." + }, + "headersSection.noSpaces": { + "defaultMessage": "헤더 이름에는 공백이 포함될 수 없습니다." + }, + "headersSection.requestHeaders": { + "defaultMessage": "요청 헤더" + }, + "headersSection.value": { + "defaultMessage": "값" + }, + "hub.goodAfternoon": { + "defaultMessage": "좋은 오후예요" + }, + "hub.goodEvening": { + "defaultMessage": "좋은 저녁이에요" + }, + "hub.goodMorning": { + "defaultMessage": "좋은 아침이에요" + }, + "huggingFaceModelSearch.download": { + "defaultMessage": "다운로드" + }, + "huggingFaceModelSearch.downloaded": { + "defaultMessage": "다운로드됨" + }, + "huggingFaceModelSearch.downloading": { + "defaultMessage": "다운로드 중…" + }, + "huggingFaceModelSearch.loadingVariants": { + "defaultMessage": "변형 로드 중..." + }, + "huggingFaceModelSearch.noGgufModels": { + "defaultMessage": "이 검색어와 호환되는 로컬 모델을 찾을 수 없습니다." + }, + "huggingFaceModelSearch.recommended": { + "defaultMessage": "추천" + }, + "huggingFaceModelSearch.searchError": { + "defaultMessage": "검색 오류: {details}" + }, + "huggingFaceModelSearch.searchFailed": { + "defaultMessage": "검색에 실패했습니다. 다시 시도해 주세요." + }, + "huggingFaceModelSearch.searchHuggingFace": { + "defaultMessage": "Hugging Face 검색" + }, + "huggingFaceModelSearch.searchNoData": { + "defaultMessage": "검색 결과 데이터가 없습니다." + }, + "huggingFaceModelSearch.searchPlaceholder": { + "defaultMessage": "로컬 모델 검색..." + }, + "huggingFaceModelSearch.tooLarge": { + "defaultMessage": "메모리에 맞지 않을 수 있습니다. ({size} 모델, {available} 사용 가능)" + }, + "huggingFaceSignInPrompt.failedToConfigure": { + "defaultMessage": "Hugging Face에 로그인하지 못했습니다: {error}" + }, + "huggingFaceSignInPrompt.signIn": { + "defaultMessage": "로그인" + }, + "huggingFaceSignInPrompt.signedIn": { + "defaultMessage": "Hugging Face 로그인됨" + }, + "huggingFaceSignInPrompt.signingIn": { + "defaultMessage": "로그인 중..." + }, + "huggingFaceSignInPrompt.title": { + "defaultMessage": "Hugging Face" + }, + "imagePreview.altText": { + "defaultMessage": "goose 이미지" + }, + "imagePreview.clickToCollapse": { + "defaultMessage": "접으려면 클릭하세요." + }, + "imagePreview.clickToExpand": { + "defaultMessage": "펼치려면 클릭하세요." + }, + "imagePreview.unableToLoad": { + "defaultMessage": "이미지를 로드할 수 없습니다." + }, + "importRecipeForm.cancel": { + "defaultMessage": "취소" + }, + "importRecipeForm.deeplinkHint": { + "defaultMessage": "\"goose://recipe?config=\"로 시작하는 레시피 딥링크를 붙여넣으세요." + }, + "importRecipeForm.deeplinkPlaceholder": { + "defaultMessage": "goose://recipe?config=... 딥링크를 여기에 붙여넣으세요." + }, + "importRecipeForm.example": { + "defaultMessage": "예" + }, + "importRecipeForm.expectedRecipeStructure": { + "defaultMessage": "예상 레시피 구조" + }, + "importRecipeForm.importRecipeButton": { + "defaultMessage": "레시피 가져오기" + }, + "importRecipeForm.importRecipeTitle": { + "defaultMessage": "레시피 가져오기" + }, + "importRecipeForm.importing": { + "defaultMessage": "가져오는 중..." + }, + "importRecipeForm.or": { + "defaultMessage": "또는" + }, + "importRecipeForm.recipeDeeplinkLabel": { + "defaultMessage": "레시피 딥링크" + }, + "importRecipeForm.recipeFileHint": { + "defaultMessage": "레시피 구조가 포함된 YAML 또는 JSON 파일을 업로드하세요." + }, + "importRecipeForm.recipeFileLabel": { + "defaultMessage": "레시피 파일" + }, + "importRecipeForm.reviewWarning": { + "defaultMessage": "goose 인터페이스에 레시피 파일을 추가하기 전에 레시피 파일의 내용을 검토하세요." + }, + "importRecipeForm.schemaDescription": { + "defaultMessage": "YAML 또는 JSON 파일은 이 구조를 따라야 합니다. 필수 필드는 제목, 설명, 지침 또는 프롬프트입니다." + }, + "inlineEditText.clickToEdit": { + "defaultMessage": "편집하려면 클릭하세요" + }, + "inlineEditText.doubleClickToEdit": { + "defaultMessage": "편집하려면 두 번 클릭하세요." + }, + "inlineEditText.enterText": { + "defaultMessage": "텍스트를 입력하세요" + }, + "inlineEditText.failedToSave": { + "defaultMessage": "저장하지 못했습니다." + }, + "instructionsEditor.cancel": { + "defaultMessage": "취소" + }, + "instructionsEditor.insertExample": { + "defaultMessage": "예시 삽입" + }, + "instructionsEditor.label": { + "defaultMessage": "지침" + }, + "instructionsEditor.placeholder": { + "defaultMessage": "사용자에게 숨겨진 AI에 대한 자세한 지침" + }, + "instructionsEditor.save": { + "defaultMessage": "지침 저장" + }, + "instructionsEditor.syntaxHelp": { + "defaultMessage": "{code} 구문을 사용하여 사용자가 입력할 수 있는 매개변수를 정의하세요." + }, + "instructionsEditor.title": { + "defaultMessage": "지침 편집기" + }, + "jsonSchemaEditor.cancel": { + "defaultMessage": "취소" + }, + "jsonSchemaEditor.description": { + "defaultMessage": "JSON Schema 형식을 이용하여 예상되는 AI의 응답 구조를 정의합니다." + }, + "jsonSchemaEditor.insertExample": { + "defaultMessage": "예시 삽입" + }, + "jsonSchemaEditor.invalidJson": { + "defaultMessage": "잘못된 JSON 형식입니다." + }, + "jsonSchemaEditor.label": { + "defaultMessage": "응답 JSON 스키마" + }, + "jsonSchemaEditor.save": { + "defaultMessage": "스키마 저장" + }, + "jsonSchemaEditor.title": { + "defaultMessage": "JSON 스키마 편집기" + }, + "jsonSchemaForm.cancel": { + "defaultMessage": "취소" + }, + "jsonSchemaForm.fieldRequired": { + "defaultMessage": "이 필드는 필수입니다" + }, + "jsonSchemaForm.maxLength": { + "defaultMessage": "최대 길이는 {maxLength}입니다" + }, + "jsonSchemaForm.maxValue": { + "defaultMessage": "최대값은 {maximum}입니다" + }, + "jsonSchemaForm.minLength": { + "defaultMessage": "최소 길이는 {minLength}입니다" + }, + "jsonSchemaForm.minValue": { + "defaultMessage": "최소값은 {minimum}입니다" + }, + "jsonSchemaForm.noFields": { + "defaultMessage": "표시할 필드가 없습니다." + }, + "jsonSchemaForm.selectPlaceholder": { + "defaultMessage": "선택..." + }, + "jsonSchemaForm.submit": { + "defaultMessage": "제출" + }, + "keyValueEditor.addValue": { + "defaultMessage": "사전 구성된 값 추가" + }, + "keyValueEditor.defaultKeyPlaceholder": { + "defaultMessage": "매개변수 이름..." + }, + "keyValueEditor.defaultValuePlaceholder": { + "defaultMessage": "매개변수 값..." + }, + "keyValueEditor.removeValue": { + "defaultMessage": "사전 구성된 값 제거: {key}" + }, + "keyboardShortcuts.alwaysOnTopDescription": { + "defaultMessage": "창을 항상 위에 표시할지 전환합니다." + }, + "keyboardShortcuts.alwaysOnTopLabel": { + "defaultMessage": "항상 위에 표시" + }, + "keyboardShortcuts.cancel": { + "defaultMessage": "취소" + }, + "keyboardShortcuts.categoryApplication": { + "defaultMessage": "앱 단축키" + }, + "keyboardShortcuts.categoryApplicationDescription": { + "defaultMessage": "Goose가 활성 앱일 때 작동하는 단축키입니다." + }, + "keyboardShortcuts.categoryGlobal": { + "defaultMessage": "전역 단축키" + }, + "keyboardShortcuts.categoryGlobalDescription": { + "defaultMessage": "Goose가 활성화되어 있지 않아도 시스템 전체에서 작동하는 단축키입니다." + }, + "keyboardShortcuts.categorySearch": { + "defaultMessage": "검색 단축키" + }, + "keyboardShortcuts.categorySearchDescription": { + "defaultMessage": "대화에서 검색할 때 작동하는 단축키입니다." + }, + "keyboardShortcuts.categoryWindow": { + "defaultMessage": "창 단축키" + }, + "keyboardShortcuts.categoryWindowDescription": { + "defaultMessage": "창 동작을 제어하는 단축키입니다." + }, + "keyboardShortcuts.change": { + "defaultMessage": "변경" + }, + "keyboardShortcuts.disabled": { + "defaultMessage": "사용 안 함" + }, + "keyboardShortcuts.dismiss": { + "defaultMessage": "닫기" + }, + "keyboardShortcuts.findDescription": { + "defaultMessage": "대화 내 검색을 엽니다." + }, + "keyboardShortcuts.findLabel": { + "defaultMessage": "찾기" + }, + "keyboardShortcuts.findNextDescription": { + "defaultMessage": "다음 검색 결과로 이동합니다." + }, + "keyboardShortcuts.findNextLabel": { + "defaultMessage": "다음 찾기" + }, + "keyboardShortcuts.findPreviousDescription": { + "defaultMessage": "이전 검색 결과로 이동합니다." + }, + "keyboardShortcuts.findPreviousLabel": { + "defaultMessage": "이전 찾기" + }, + "keyboardShortcuts.focusWindowDescription": { + "defaultMessage": "어디서든 Goose 창을 앞으로 가져옵니다." + }, + "keyboardShortcuts.focusWindowLabel": { + "defaultMessage": "Goose 창 포커스" + }, + "keyboardShortcuts.loading": { + "defaultMessage": "로드 중..." + }, + "keyboardShortcuts.newChatDescription": { + "defaultMessage": "현재 창에서 새 채팅을 만듭니다." + }, + "keyboardShortcuts.newChatLabel": { + "defaultMessage": "새 채팅" + }, + "keyboardShortcuts.newChatWindowDescription": { + "defaultMessage": "새 Goose 창을 엽니다." + }, + "keyboardShortcuts.newChatWindowLabel": { + "defaultMessage": "새 채팅 창" + }, + "keyboardShortcuts.openDirectoryDescription": { + "defaultMessage": "디렉터리 선택 대화상자를 엽니다." + }, + "keyboardShortcuts.openDirectoryLabel": { + "defaultMessage": "디렉터리 열기" + }, + "keyboardShortcuts.quickLauncherDescription": { + "defaultMessage": "빠른 실행기 오버레이를 엽니다." + }, + "keyboardShortcuts.quickLauncherLabel": { + "defaultMessage": "빠른 실행기" + }, + "keyboardShortcuts.reassignShortcut": { + "defaultMessage": "단축키 재지정" + }, + "keyboardShortcuts.resetAllShortcuts": { + "defaultMessage": "모든 단축키 재설정" + }, + "keyboardShortcuts.resetShortcutsDetail": { + "defaultMessage": "모든 단축키를 원래 설정으로 복원합니다." + }, + "keyboardShortcuts.resetShortcutsMessage": { + "defaultMessage": "모든 키보드 단축키를 기본값으로 재설정하시겠습니까?" + }, + "keyboardShortcuts.resetShortcutsTitle": { + "defaultMessage": "키보드 단축키 재설정" + }, + "keyboardShortcuts.resetToDefaultsDescription": { + "defaultMessage": "모든 키보드 단축키를 원래 설정으로 되돌립니다." + }, + "keyboardShortcuts.resetToDefaultsHeading": { + "defaultMessage": "기본값으로 재설정" + }, + "keyboardShortcuts.restartDescription": { + "defaultMessage": "애플리케이션 단축키(예: 새 채팅, 설정 등)의 변경 사항을 적용하려면 goose를 다시 시작해야 합니다. 전역 단축키(창 포커스, 빠른 실행기)는 즉시 적용됩니다." + }, + "keyboardShortcuts.restartRequired": { + "defaultMessage": "다시 시작 필요" + }, + "keyboardShortcuts.settingsDescription": { + "defaultMessage": "설정 패널을 엽니다." + }, + "keyboardShortcuts.settingsLabel": { + "defaultMessage": "설정" + }, + "keyboardShortcuts.shortcutConflictSaveDetail": { + "defaultMessage": "이를 저장하면 \"{conflictLabel}\"에서 단축키가 제거되고 \"{targetLabel}\"에 할당됩니다. 계속하시겠습니까?" + }, + "keyboardShortcuts.shortcutConflictTitle": { + "defaultMessage": "단축키 충돌" + }, + "keyboardShortcuts.shortcutConflictToggleDetail": { + "defaultMessage": "이 기능을 활성화하면 \"{conflictLabel}\"에서 단축키가 제거되고 \"{targetLabel}\"에 할당됩니다. 계속하시겠습니까?" + }, + "keyboardShortcuts.shortcutConflictToggleMessage": { + "defaultMessage": "단축키 {shortcut}은 이미 \"{conflictLabel}\"에 할당되어 있습니다." + }, + "keyboardShortcuts.toggleNavigationDescription": { + "defaultMessage": "탐색 메뉴를 표시하거나 숨깁니다." + }, + "keyboardShortcuts.toggleNavigationLabel": { + "defaultMessage": "탐색 메뉴 전환" + }, + "launcher.placeholder": { + "defaultMessage": "goose에게 무엇이든 물어보세요..." + }, + "loadingGoose.compacting": { + "defaultMessage": "goose가 대화를 압축하고 있습니다..." + }, + "loadingGoose.idle": { + "defaultMessage": "goose가 작업 중…" + }, + "loadingGoose.loadingConversation": { + "defaultMessage": "대화 로드 중..." + }, + "loadingGoose.restartingAgent": { + "defaultMessage": "세션 다시 시작 중..." + }, + "loadingGoose.streaming": { + "defaultMessage": "goose가 작업 중…" + }, + "loadingGoose.thinking": { + "defaultMessage": "goose가 생각 중…" + }, + "loadingGoose.waiting": { + "defaultMessage": "goose가 기다리고 있습니다…" + }, + "localInferenceSettings.deleteConfirm": { + "defaultMessage": "이 모델을 삭제하시겠습니까? 나중에 다시 다운로드할 수 있습니다." + }, + "localInferenceSettings.description": { + "defaultMessage": "API 키 없이 추론에 사용할 로컬 LLM 모델을 다운로드하고 관리하세요. Hugging Face에서 GGUF 또는 MLX 모델을 검색하거나 아래 추천 모델을 사용하세요." + }, + "localInferenceSettings.dismiss": { + "defaultMessage": "닫기" + }, + "localInferenceSettings.download": { + "defaultMessage": "다운로드" + }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "다운로드가 취소되었습니다." + }, + "localInferenceSettings.downloadFailed": { + "defaultMessage": "다운로드 실패" + }, + "localInferenceSettings.downloadProgress": { + "defaultMessage": "{downloaded} / {total} ({percent}%)" + }, + "localInferenceSettings.downloadedModels": { + "defaultMessage": "다운로드한 모델" + }, + "localInferenceSettings.downloading": { + "defaultMessage": "다운로드 중" + }, + "localInferenceSettings.featuredModels": { + "defaultMessage": "주요 모델" + }, + "localInferenceSettings.huggingFaceSignInNote": { + "defaultMessage": "모델 검색 및 다운로드 시 속도 제한 한도를 높이고 비공개 또는 접근 제한된 Hugging Face 리포지토리에 액세스하려면 로그인하세요." + }, + "localInferenceSettings.modelSettings": { + "defaultMessage": "모델 설정" + }, + "localInferenceSettings.modelSettingsTitle": { + "defaultMessage": "모델 설정" + }, + "localInferenceSettings.noModels": { + "defaultMessage": "사용 가능한 모델이 없습니다." + }, + "localInferenceSettings.recommended": { + "defaultMessage": "추천" + }, + "localInferenceSettings.remaining": { + "defaultMessage": "{time} 남음" + }, + "localInferenceSettings.retry": { + "defaultMessage": "다시 시도" + }, + "localInferenceSettings.showAllFeatured": { + "defaultMessage": "모든 추천 항목 표시({count}개 이상)" + }, + "localInferenceSettings.showRecommendedOnly": { + "defaultMessage": "추천만 표시" + }, + "localInferenceSettings.title": { + "defaultMessage": "로컬 추론 모델" + }, + "localInferenceSettings.vision": { + "defaultMessage": "비전" + }, + "localInferenceSettings.visionEncoderDownloading": { + "defaultMessage": "Vision 인코더 다운로드 중…" + }, + "localInferenceSettings.visionEncoderNotDownloaded": { + "defaultMessage": "비전 인코더가 다운로드되지 않았습니다." + }, + "localModelManager.active": { + "defaultMessage": "활성" + }, + "localModelManager.deleteConfirm": { + "defaultMessage": "이 모델을 삭제하시겠습니까? 나중에 다시 다운로드할 수 있습니다." + }, + "localModelManager.download": { + "defaultMessage": "다운로드" + }, + "localModelManager.downloaded": { + "defaultMessage": "다운로드됨" + }, + "localModelManager.gpuAcceleration": { + "defaultMessage": "GPU 가속을 지원합니다(NVIDIA의 경우 CUDA, Apple Silicon의 경우 Metal). GPU 하드웨어 가속을 위해서는 빌드 시 기능을 활성화해야 합니다." + }, + "localModelManager.noModels": { + "defaultMessage": "사용 가능한 모델이 없습니다." + }, + "localModelManager.recommended": { + "defaultMessage": "추천" + }, + "localModelManager.recommendedForHardware": { + "defaultMessage": "하드웨어에 권장됨" + }, + "localModelManager.showAllModels": { + "defaultMessage": "모든 모델 보기({count}개 더 보기)" + }, + "localModelManager.showRecommendedOnly": { + "defaultMessage": "추천만 표시" + }, + "localModelPicker.back": { + "defaultMessage": "뒤로" + }, + "localModelPicker.bestForMachine": { + "defaultMessage": "이 기기에 가장 적합" + }, + "localModelPicker.cancelDownload": { + "defaultMessage": "다운로드 취소" + }, + "localModelPicker.checkingModels": { + "defaultMessage": "사용 가능한 모델을 확인하는 중..." + }, + "localModelPicker.downloadModel": { + "defaultMessage": "{modelId} 다운로드 ({size})" + }, + "localModelPicker.downloading": { + "defaultMessage": "{modelId} 다운로드 중" + }, + "localModelPicker.failedToLoad": { + "defaultMessage": "사용 가능한 모델을 로드하지 못했습니다. 다시 시도해 주세요." + }, + "localModelPicker.failedToStartDownload": { + "defaultMessage": "다운로드를 시작하지 못했습니다. 다시 시도해 주세요." + }, + "localModelPicker.hideOtherSizes": { + "defaultMessage": "다른 사이즈 숨기기" + }, + "localModelPicker.localModelsNote": { + "defaultMessage": "로컬 모델은 완전한 개인 정보 보호를 위해 모든 것을 컴퓨터에 보관합니다. 성능 및 컨텍스트 창 크기는 하드웨어 및 모델 크기에 따라 클라우드 제공업체와 다를 수 있습니다." + }, + "localModelPicker.lostConnection": { + "defaultMessage": "다운로드 연결이 끊겼습니다. 다시 시도해 주세요." + }, + "localModelPicker.modelNotFound": { + "defaultMessage": "모델을 찾을 수 없습니다" + }, + "localModelPicker.ready": { + "defaultMessage": "준비됨" + }, + "localModelPicker.selectModel": { + "defaultMessage": "모델을 선택하세요" + }, + "localModelPicker.showOtherSizes": { + "defaultMessage": "다른 사이즈 {count}개 보기" + }, + "localModelPicker.startingDownload": { + "defaultMessage": "다운로드 시작 중..." + }, + "localModelPicker.tryAgain": { + "defaultMessage": "다시 시도" + }, + "localModelPicker.useModel": { + "defaultMessage": "{modelId} 사용" + }, + "markdownContent.cancel": { + "defaultMessage": "취소" + }, + "markdownContent.copyCode": { + "defaultMessage": "코드 복사" + }, + "markdownContent.failedToOpenLink": { + "defaultMessage": "링크를 열지 못했습니다." + }, + "markdownContent.noApplicationFound": { + "defaultMessage": "이 링크를 열 수 있는 애플리케이션을 찾을 수 없습니다." + }, + "markdownContent.open": { + "defaultMessage": "열기" + }, + "markdownContent.openExternalLink": { + "defaultMessage": "외부 링크 열기" + }, + "markdownContent.openProtocolLink": { + "defaultMessage": "{protocol} 링크를 열까요?" + }, + "markdownContent.thisWillOpen": { + "defaultMessage": "열립니다: {href}" + }, + "mcpAppRenderer.appFallbackTitle": { + "defaultMessage": "앱" + }, + "mcpAppRenderer.cancelButton": { + "defaultMessage": "취소" + }, + "mcpAppRenderer.close": { + "defaultMessage": "닫기" + }, + "mcpAppRenderer.exitFullscreen": { + "defaultMessage": "전체 화면 종료" + }, + "mcpAppRenderer.exitFullscreenTitle": { + "defaultMessage": "전체 화면 종료(Esc)" + }, + "mcpAppRenderer.failedToInitSandbox": { + "defaultMessage": "샌드박스 프록시를 초기화하지 못했습니다." + }, + "mcpAppRenderer.failedToLoadResource": { + "defaultMessage": "리소스를 로드하지 못했습니다." + }, + "mcpAppRenderer.fullscreen": { + "defaultMessage": "전체 화면" + }, + "mcpAppRenderer.invalidUrl": { + "defaultMessage": "유효하지 않은 URL" + }, + "mcpAppRenderer.movePipWindow": { + "defaultMessage": "Picture-in-Picture 창 이동(화살표 키 사용)" + }, + "mcpAppRenderer.openButton": { + "defaultMessage": "열기" + }, + "mcpAppRenderer.openExternalLinkTitle": { + "defaultMessage": "외부 링크 열기" + }, + "mcpAppRenderer.openLinkDetail": { + "defaultMessage": "열립니다: {url}" + }, + "mcpAppRenderer.openProtocolLink": { + "defaultMessage": "{protocol} 링크를 열까요?" + }, + "mcpAppRenderer.pictureInPicture": { + "defaultMessage": "PIP(Picture-in-Picture)" + }, + "mcpAppRenderer.playingInPip": { + "defaultMessage": "PIP(Picture-in-Picture)에서 재생 중" + }, + "mcpUIResourceRenderer.cancelButton": { + "defaultMessage": "취소" + }, + "mcpUIResourceRenderer.openButton": { + "defaultMessage": "열기" + }, + "mcpUIResourceRenderer.openExternalLinkTitle": { + "defaultMessage": "외부 링크 열기" + }, + "mcpUIResourceRenderer.openLinkDetail": { + "defaultMessage": "열립니다: {url}" + }, + "mcpUIResourceRenderer.openProtocolLink": { + "defaultMessage": "{protocol} 링크를 열까요?" + }, + "mcpUIResourceRenderer.toastMessageReceived": { + "defaultMessage": "{message}에 대한 메시지가 수신되었습니다." + }, + "mcpUIResourceRenderer.toastTitle": { + "defaultMessage": "MCP-UI {messageType} 메시지" + }, + "mcpUIResourceRenderer.toastUnsupported": { + "defaultMessage": "{message}에 대한 메시지가 수신되었습니다. {messageType} 메시지는 아직 지원되지 않습니다. 자세한 내용은 콘솔을 참조하세요." + }, + "mentionPopover.itemsFound": { + "defaultMessage": "{count,plural,one{#개 항목 발견} other{#개 항목 발견}}" + }, + "mentionPopover.loadingCommands": { + "defaultMessage": "명령 로드 중..." + }, + "mentionPopover.noCommandsFound": { + "defaultMessage": "\"{query}\"과 일치하는 명령을 찾을 수 없습니다." + }, + "mentionPopover.noItemsFound": { + "defaultMessage": "\"{query}\"과(와) 일치하는 항목이 없습니다." + }, + "mentionPopover.scanningFiles": { + "defaultMessage": "파일을 스캔하는 중..." + }, + "messageCopyLink.copied": { + "defaultMessage": "복사되었습니다!" + }, + "messageCopyLink.copy": { + "defaultMessage": "복사" + }, + "messageQueue.cancel": { + "defaultMessage": "취소" + }, + "messageQueue.cannotSendWhileEditing": { + "defaultMessage": "수정 중에는 보낼 수 없습니다." + }, + "messageQueue.clearAll": { + "defaultMessage": "모두 지우기" + }, + "messageQueue.clickToEdit": { + "defaultMessage": "{content} (수정하려면 클릭하세요)" + }, + "messageQueue.collapseQueue": { + "defaultMessage": "대기열 축소" + }, + "messageQueue.dragToReorder": { + "defaultMessage": "메시지를 드래그하여 우선순위를 재정렬하세요" + }, + "messageQueue.expandQueue": { + "defaultMessage": "대기열 확장" + }, + "messageQueue.messageCount": { + "defaultMessage": "{count,plural,one{#개 메시지 {status}} other{#개 메시지 {status}}}" + }, + "messageQueue.messageQueue": { + "defaultMessage": "메시지 대기열" + }, + "messageQueue.next": { + "defaultMessage": "다음" + }, + "messageQueue.paused": { + "defaultMessage": "일시중지됨" + }, + "messageQueue.queuePaused": { + "defaultMessage": "대기열이 일시중지되었습니다." + }, + "messageQueue.queuePausedCompact": { + "defaultMessage": "대기열이 일시중지되었습니다. 재개하려면 '보내기'를 클릭하거나 새 메시지를 추가하세요." + }, + "messageQueue.queuePausedExpanded": { + "defaultMessage": "중단으로 인해 대기열이 일시중지되었습니다. 재개하려면 \"지금 보내기\"를 사용하거나 새 메시지를 추가하세요." + }, + "messageQueue.queued": { + "defaultMessage": "대기 중" + }, + "messageQueue.removeFromQueue": { + "defaultMessage": "대기열에서 이 메시지 제거" + }, + "messageQueue.save": { + "defaultMessage": "저장" + }, + "messageQueue.sendNow": { + "defaultMessage": "지금 이 메시지 보내기" + }, + "messageQueue.stopAndSend": { + "defaultMessage": "현재 처리를 중지하고 이 메시지 지금 보내기" + }, + "messageQueue.waiting": { + "defaultMessage": "대기 중" + }, + "microphoneSelector.chooseDescription": { + "defaultMessage": "받아쓰기에 사용할 마이크 선택" + }, + "microphoneSelector.grantAccess": { + "defaultMessage": "액세스 권한 부여" + }, + "microphoneSelector.grantAccessDescription": { + "defaultMessage": "사용 가능한 마이크를 보려면 액세스 권한을 부여하세요." + }, + "microphoneSelector.microphone": { + "defaultMessage": "마이크" + }, + "microphoneSelector.microphoneLabel": { + "defaultMessage": "마이크 {index}" + }, + "microphoneSelector.selectedMicrophone": { + "defaultMessage": "선택한 마이크" + }, + "microphoneSelector.speakToTest": { + "defaultMessage": "말해서 마이크를 테스트해 보세요. ({seconds}s)" + }, + "microphoneSelector.stop": { + "defaultMessage": "중지" + }, + "microphoneSelector.systemDefault": { + "defaultMessage": "시스템 기본값" + }, + "microphoneSelector.test": { + "defaultMessage": "테스트" + }, + "modeSelectionItem.autonomousDescription": { + "defaultMessage": "파일을 자유롭게 편집, 생성, 삭제할 수 있습니다." + }, + "modeSelectionItem.autonomousLabel": { + "defaultMessage": "자율" + }, + "modeSelectionItem.chatOnlyDescription": { + "defaultMessage": "도구나 익스텐션을 사용하지 않고 선택한 제공업체와 대화합니다." + }, + "modeSelectionItem.chatOnlyLabel": { + "defaultMessage": "채팅 전용" + }, + "modeSelectionItem.manualDescription": { + "defaultMessage": "모든 도구, 익스텐션, 파일 수정에는 사용자 승인이 필요합니다." + }, + "modeSelectionItem.manualLabel": { + "defaultMessage": "수동" + }, + "modeSelectionItem.smartDescription": { + "defaultMessage": "위험 수준에 따라 승인이 필요한 작업을 지능적으로 판단합니다." + }, + "modeSelectionItem.smartLabel": { + "defaultMessage": "스마트" + }, + "modelAndProviderContext.modelChangeFailed": { + "defaultMessage": "{provider}/{model} 실패" + }, + "modelAndProviderContext.modelChangedTitle": { + "defaultMessage": "모델 변경됨" + }, + "modelAndProviderContext.selectModel": { + "defaultMessage": "모델 선택" + }, + "modelAndProviderContext.switchModelSuccess": { + "defaultMessage": "모델이 전환되었습니다 — {provider}의 {model} 사용 중" + }, + "modelAndProviderContext.unknownProviderMsg": { + "defaultMessage": "구성에 알 수 없는 제공업체가 있습니다 -- config.yaml을 확인하세요" + }, + "modelAndProviderContext.unknownProviderTitle": { + "defaultMessage": "제공업체 이름 조회" + }, + "modelSettingsButtons.configureProviders": { + "defaultMessage": "제공업체 설정" + }, + "modelSettingsButtons.switchModels": { + "defaultMessage": "모델 전환" + }, + "modelSettingsPanel.batchSize": { + "defaultMessage": "배치 크기" + }, + "modelSettingsPanel.batchSizeDescription": { + "defaultMessage": "프롬프트 처리 배치" + }, + "modelSettingsPanel.builtinChatTemplate": { + "defaultMessage": "내장 템플릿" + }, + "modelSettingsPanel.builtinChatTemplateDescription": { + "defaultMessage": "llama.cpp 내장 템플릿 이름을 선택하세요." + }, + "modelSettingsPanel.chatTemplate": { + "defaultMessage": "채팅 템플릿" + }, + "modelSettingsPanel.chatTemplateBuiltin": { + "defaultMessage": "내장" + }, + "modelSettingsPanel.chatTemplateCustomInline": { + "defaultMessage": "사용자 지정 인라인" + }, + "modelSettingsPanel.chatTemplateDescription": { + "defaultMessage": "내장된 GGUF 메타데이터, llama.cpp 내장 템플릿 또는 인라인 Jinja를 사용하세요." + }, + "modelSettingsPanel.chatTemplateEmbedded": { + "defaultMessage": "임베디드" + }, + "modelSettingsPanel.contextAndGeneration": { + "defaultMessage": "컨텍스트 및 생성" + }, + "modelSettingsPanel.contextSize": { + "defaultMessage": "컨텍스트 크기" + }, + "modelSettingsPanel.contextSizeDescription": { + "defaultMessage": "최대 컨텍스트 창(0 = 모델 기본값)" + }, + "modelSettingsPanel.customChatTemplate": { + "defaultMessage": "사용자 지정 채팅 템플릿" + }, + "modelSettingsPanel.customChatTemplateDescription": { + "defaultMessage": "전체 Jinja 채팅 템플릿 소스를 붙여넣으세요." + }, + "modelSettingsPanel.etaLearningRate": { + "defaultMessage": "Eta(학습률)" + }, + "modelSettingsPanel.flashAttention": { + "defaultMessage": "Flash Attention" + }, + "modelSettingsPanel.flashAttentionDescription": { + "defaultMessage": "Flash Attention 최적화 켜기" + }, + "modelSettingsPanel.frequencyPenalty": { + "defaultMessage": "Frequency Penalty" + }, + "modelSettingsPanel.frequencyPenaltyDescription": { + "defaultMessage": "0.0 = 꺼짐" + }, + "modelSettingsPanel.gpuLayers": { + "defaultMessage": "GPU 레이어" + }, + "modelSettingsPanel.gpuLayersDescription": { + "defaultMessage": "GPU로 오프로드할 레이어" + }, + "modelSettingsPanel.loadingSettings": { + "defaultMessage": "설정 로드 중..." + }, + "modelSettingsPanel.lockModelInRam": { + "defaultMessage": "모델을 RAM에 고정(mlock)" + }, + "modelSettingsPanel.lockModelInRamDescription": { + "defaultMessage": "모델이 디스크로 스왑되는 것을 방지" + }, + "modelSettingsPanel.maxOutputTokens": { + "defaultMessage": "최대 출력 토큰" + }, + "modelSettingsPanel.maxOutputTokensDescription": { + "defaultMessage": "생성할 토큰 수 제한" + }, + "modelSettingsPanel.minP": { + "defaultMessage": "Min P" + }, + "modelSettingsPanel.performance": { + "defaultMessage": "성능" + }, + "modelSettingsPanel.presencePenalty": { + "defaultMessage": "Presence Penalty" + }, + "modelSettingsPanel.presencePenaltyDescription": { + "defaultMessage": "0.0 = 꺼짐" + }, + "modelSettingsPanel.repeatPenalty": { + "defaultMessage": "Repeat Penalty" + }, + "modelSettingsPanel.repeatPenaltyDescription": { + "defaultMessage": "1.0 = 꺼짐" + }, + "modelSettingsPanel.repeatWindow": { + "defaultMessage": "Repeat Window" + }, + "modelSettingsPanel.repeatWindowDescription": { + "defaultMessage": "되돌아볼 토큰" + }, + "modelSettingsPanel.repetitionPenalty": { + "defaultMessage": "Repetition Penalty" + }, + "modelSettingsPanel.reset": { + "defaultMessage": "재설정" + }, + "modelSettingsPanel.resetToDefaults": { + "defaultMessage": "기본값으로 재설정" + }, + "modelSettingsPanel.samplingStrategy": { + "defaultMessage": "샘플링 전략" + }, + "modelSettingsPanel.saving": { + "defaultMessage": "저장 중..." + }, + "modelSettingsPanel.seed": { + "defaultMessage": "Seed" + }, + "modelSettingsPanel.tauTargetEntropy": { + "defaultMessage": "Tau(목표 엔트로피)" + }, + "modelSettingsPanel.temperature": { + "defaultMessage": "Temperature" + }, + "modelSettingsPanel.threads": { + "defaultMessage": "스레드" + }, + "modelSettingsPanel.threadsDescription": { + "defaultMessage": "생성에 사용할 CPU 스레드" + }, + "modelSettingsPanel.toolCalling": { + "defaultMessage": "도구 호출" + }, + "modelSettingsPanel.toolCallingAuto": { + "defaultMessage": "자동" + }, + "modelSettingsPanel.toolCallingDescription": { + "defaultMessage": "로컬 모델이 네이티브 도구 호출과 에뮬레이션된 도구 호출 중 어떤 방식을 사용할지 선택하세요." + }, + "modelSettingsPanel.toolCallingForceEmulated": { + "defaultMessage": "에뮬레이션 강제" + }, + "modelSettingsPanel.toolCallingForceNative": { + "defaultMessage": "네이티브 강제" + }, + "modelSettingsPanel.topK": { + "defaultMessage": "Top K" + }, + "modelSettingsPanel.topP": { + "defaultMessage": "Top P" + }, + "modelsBottomBar.changeModel": { + "defaultMessage": "모델 변경" + }, + "modelsBottomBar.currentModel": { + "defaultMessage": "현재 모델" + }, + "modelsBottomBar.loadingModel": { + "defaultMessage": "모델 로드 중..." + }, + "modelsBottomBar.localModelSettings": { + "defaultMessage": "로컬 모델 설정" + }, + "modelsBottomBar.localModelSettingsTitle": { + "defaultMessage": "로컬 모델 설정 — {modelName}" + }, + "modelsBottomBar.resolvedModel": { + "defaultMessage": "확인된 모델" + }, + "modelsBottomBar.selectModel": { + "defaultMessage": "모델 선택" + }, + "modelsSection.resetDescription": { + "defaultMessage": "선택한 모델 및 제공업체 설정을 지우고 새로 시작합니다." + }, + "modelsSection.resetTitle": { + "defaultMessage": "제공업체 및 모델 초기화" + }, + "navigation.itemApps": { + "defaultMessage": "앱" + }, + "navigation.itemExtensions": { + "defaultMessage": "익스텐션" + }, + "navigation.itemHome": { + "defaultMessage": "새 채팅" + }, + "navigation.itemRecipes": { + "defaultMessage": "레시피" + }, + "navigation.itemScheduler": { + "defaultMessage": "스케줄러" + }, + "navigation.itemSessions": { + "defaultMessage": "채팅 기록" + }, + "navigation.itemSettings": { + "defaultMessage": "설정" + }, + "navigation.itemSkills": { + "defaultMessage": "스킬" + }, + "navigationPanel.chats": { + "defaultMessage": "채팅" + }, + "navigationPanel.noChats": { + "defaultMessage": "최근 채팅 없음" + }, + "navigationPanel.untitledSession": { + "defaultMessage": "제목 없는 세션" + }, + "onboardingGuard.checkProviderErrorDescription": { + "defaultMessage": "서버가 시작 중이거나 일시적으로 사용할 수 없습니다." + }, + "onboardingGuard.checkProviderErrorTitle": { + "defaultMessage": "Goose 서버에 접속할 수 없습니다" + }, + "onboardingGuard.retry": { + "defaultMessage": "다시 시도" + }, + "onboardingGuard.welcomeDescription": { + "defaultMessage": "로컬 AI 에이전트입니다. 시작하려면 AI 모델 제공업체를 연결하세요." + }, + "onboardingGuard.welcomeTitle": { + "defaultMessage": "goose에 오신 것을 환영합니다" + }, + "onboardingSuccess.allSet": { + "defaultMessage": "goose를 사용할 준비가 모두 완료되었습니다." + }, + "onboardingSuccess.connectedTo": { + "defaultMessage": "{providerName}에 연결됨" + }, + "onboardingSuccess.getStarted": { + "defaultMessage": "시작하기" + }, + "onboardingSuccess.learnMore": { + "defaultMessage": "자세히 알아보기" + }, + "onboardingSuccess.localModelReady": { + "defaultMessage": "로컬 모델 준비됨" + }, + "onboardingSuccess.privacyDescription": { + "defaultMessage": "익명의 사용 데이터는 goose 개선에 도움이 됩니다. 대화, 코드 또는 개인 데이터는 수집하지 않습니다." + }, + "onboardingSuccess.privacyTitle": { + "defaultMessage": "개인정보 보호" + }, + "onboardingSuccess.shareUsageData": { + "defaultMessage": "익명의 사용 데이터 공유" + }, + "parameterInput.defaultValue": { + "defaultMessage": "기본값" + }, + "parameterInput.defaultValuePlaceholder": { + "defaultMessage": "기본값을 입력하세요" + }, + "parameterInput.deleteParameter": { + "defaultMessage": "매개변수 삭제: {key}" + }, + "parameterInput.description": { + "defaultMessage": "설명" + }, + "parameterInput.descriptionHelp": { + "defaultMessage": "최종 사용자에게 표시되는 메시지입니다." + }, + "parameterInput.descriptionPlaceholder": { + "defaultMessage": "예: \"새 구성요소의 이름을 입력하세요.\"" + }, + "parameterInput.inputType": { + "defaultMessage": "입력 유형" + }, + "parameterInput.optional": { + "defaultMessage": "선택 사항" + }, + "parameterInput.optionsHelp": { + "defaultMessage": "각 옵션을 새 줄에 입력하세요. 드롭다운 선택지로 표시됩니다." + }, + "parameterInput.optionsLabel": { + "defaultMessage": "옵션(한 줄에 하나씩)" + }, + "parameterInput.optionsPlaceholder": { + "defaultMessage": "옵션 1 옵션 2 옵션 3" + }, + "parameterInput.required": { + "defaultMessage": "필수" + }, + "parameterInput.requirement": { + "defaultMessage": "요구 사항" + }, + "parameterInput.typeBoolean": { + "defaultMessage": "부울" + }, + "parameterInput.typeNumber": { + "defaultMessage": "숫자" + }, + "parameterInput.typeSelect": { + "defaultMessage": "선택" + }, + "parameterInput.typeString": { + "defaultMessage": "문자열" + }, + "parameterInput.unused": { + "defaultMessage": "미사용" + }, + "parameterInput.unusedWarningTitle": { + "defaultMessage": "이 매개변수는 지침이나 프롬프트에서 사용되지 않습니다. 수동 입력에 사용할 수 있지만 필요하지 않을 수도 있습니다." + }, + "parameterInputModal.backToForm": { + "defaultMessage": "매개변수 양식으로 돌아가기" + }, + "parameterInputModal.cancel": { + "defaultMessage": "취소" + }, + "parameterInputModal.cancelRecipeSetup": { + "defaultMessage": "레시피 설정 취소" + }, + "parameterInputModal.enterValue": { + "defaultMessage": "{key}에 대한 값을 입력하세요..." + }, + "parameterInputModal.false": { + "defaultMessage": "거짓" + }, + "parameterInputModal.recipeParameters": { + "defaultMessage": "레시피 매개변수" + }, + "parameterInputModal.select": { + "defaultMessage": "선택..." + }, + "parameterInputModal.selectOption": { + "defaultMessage": "옵션을 선택하세요..." + }, + "parameterInputModal.startNewChat": { + "defaultMessage": "새 채팅 시작(레시피 없음)" + }, + "parameterInputModal.startRecipe": { + "defaultMessage": "레시피 시작" + }, + "parameterInputModal.true": { + "defaultMessage": "참" + }, + "parameterInputModal.whatToDo": { + "defaultMessage": "무엇을 하고 싶나요?" + }, + "permissionModal.alwaysAllow": { + "defaultMessage": "항상 허용" + }, + "permissionModal.askBefore": { + "defaultMessage": "먼저 묻기" + }, + "permissionModal.cancel": { + "defaultMessage": "취소" + }, + "permissionModal.close": { + "defaultMessage": "닫기" + }, + "permissionModal.failedToLoadTools": { + "defaultMessage": "도구를 로드하지 못했습니다." + }, + "permissionModal.failedToLoadToolsDescription": { + "defaultMessage": "이 익스텐션의 도구를 로드할 수 없습니다. 현재 세션에 익스텐션이 로드되지 않았을 수 있습니다." + }, + "permissionModal.neverAllow": { + "defaultMessage": "절대 허용하지 않음" + }, + "permissionModal.noActiveSession": { + "defaultMessage": "활성 세션 없음" + }, + "permissionModal.noActiveSessionDescription": { + "defaultMessage": "이 익스텐션에 대한 도구 권한을 구성하려면 먼저 채팅 세션을 시작하세요. 도구 권한은 활성 세션의 익스텐션에서 로드됩니다." + }, + "permissionModal.noToolsAvailable": { + "defaultMessage": "이 익스텐션에 사용할 수 있는 도구가 없습니다." + }, + "permissionModal.saveChanges": { + "defaultMessage": "변경 사항 저장" + }, + "permissionRulesModal.description": { + "defaultMessage": "익스텐션이 시스템과 상호 작용하는 방식을 제어하려면 도구 권한을 구성하세요." + }, + "permissionRulesModal.extensionRules": { + "defaultMessage": "익스텐션 규칙" + }, + "permissionRulesModal.title": { + "defaultMessage": "권한 규칙" + }, + "permissionSetting.extensionRules": { + "defaultMessage": "익스텐션 규칙" + }, + "permissionSetting.permissionRules": { + "defaultMessage": "권한 규칙" + }, + "permissionSetting.permissionRulesDescription": { + "defaultMessage": "응답에 컨텍스트를 추가하고 지시하는 데 도움이 되도록 제공업체에게 전달되는 숨겨진 지침입니다." + }, + "privacyInfoModal.collectErrors": { + "defaultMessage": "오류 유형(예: 'rate_limit', 'auth' - 세부정보 없음)" + }, + "privacyInfoModal.collectExtensions": { + "defaultMessage": "익스텐션 및 도구 사용 횟수(이름만)" + }, + "privacyInfoModal.collectOs": { + "defaultMessage": "운영 체제, 버전 및 아키텍처" + }, + "privacyInfoModal.collectProvider": { + "defaultMessage": "사용된 제공업체 및 모델" + }, + "privacyInfoModal.collectSession": { + "defaultMessage": "세션 지표(기간, 상호 작용 횟수, 토큰 사용량)" + }, + "privacyInfoModal.collectVersion": { + "defaultMessage": "goose 버전 및 설치 방법" + }, + "privacyInfoModal.description": { + "defaultMessage": "익명의 사용 데이터는 goose가 어떻게 사용되는지 이해하고 개선이 필요한 부분을 파악하는 데 도움이 됩니다." + }, + "privacyInfoModal.neverCollect": { + "defaultMessage": "대화, 코드, 도구 인수, 오류 메시지 또는 개인 데이터는 수집하지 않습니다. 언제든지 설정에서 이 설정을 변경할 수 있습니다." + }, + "privacyInfoModal.title": { + "defaultMessage": "개인정보 보호 세부정보" + }, + "privacyInfoModal.whatWeCollect": { + "defaultMessage": "수집하는 항목:" + }, + "progressiveMessageList.loadingMessages": { + "defaultMessage": "메시지 로드 중... ({renderedCount}/{totalCount})" + }, + "progressiveMessageList.modelChanged": { + "defaultMessage": "모델 변경 : {previousModel} → {currentModel}" + }, + "progressiveMessageList.searchHint": { + "defaultMessage": "검색을 위해 모든 메시지를 즉시 로드하려면 Cmd/Ctrl+F를 누르세요." + }, + "promptsSettings.allPromptsReset": { + "defaultMessage": "모든 프롬프트가 기본값으로 재설정됩니다." + }, + "promptsSettings.backToList": { + "defaultMessage": "목록으로 돌아가기" + }, + "promptsSettings.confirmReplaceWithDefault": { + "defaultMessage": "현재 콘텐츠를 기본값으로 바꾸시겠습니까? 변경사항이 손실됩니다." + }, + "promptsSettings.confirmResetAll": { + "defaultMessage": "모든 프롬프트를 기본값으로 재설정하시겠습니까? 이 작업은 취소할 수 없습니다." + }, + "promptsSettings.confirmResetOne": { + "defaultMessage": "이 프롬프트를 기본값으로 재설정하시겠습니까? 이 작업은 취소할 수 없습니다." + }, + "promptsSettings.confirmUnsavedBack": { + "defaultMessage": "저장되지 않은 변경사항이 있습니다. 정말 돌아가시겠어요?" + }, + "promptsSettings.customized": { + "defaultMessage": "사용자 지정됨" + }, + "promptsSettings.edit": { + "defaultMessage": "편집" + }, + "promptsSettings.editPromptTitle": { + "defaultMessage": "편집: {name}" + }, + "promptsSettings.editingLabel": { + "defaultMessage": "편집: {name}" + }, + "promptsSettings.enterPromptContent": { + "defaultMessage": "프롬프트 내용을 입력하세요..." + }, + "promptsSettings.failedToLoadPrompt": { + "defaultMessage": "프롬프트를 로드하지 못했습니다." + }, + "promptsSettings.failedToLoadPrompts": { + "defaultMessage": "프롬프트를 로드하지 못했습니다." + }, + "promptsSettings.failedToResetPrompt": { + "defaultMessage": "프롬프트를 재설정하지 못했습니다." + }, + "promptsSettings.failedToResetPrompts": { + "defaultMessage": "프롬프트를 재설정하지 못했습니다." + }, + "promptsSettings.failedToSavePrompt": { + "defaultMessage": "프롬프트를 저장하지 못했습니다." + }, + "promptsSettings.promptEditingDescription": { + "defaultMessage": "다양한 상황에서 goose의 동작을 정의하는 프롬프트를 사용자 지정하세요. 이 프롬프트는 Jinja2 템플릿 구문을 사용합니다. 잘못된 변경으로 인해 기능이 중단될 수 있으므로 템플릿 변수를 수정할 때는 주의하세요. 개선 사항을 커뮤니티와 공유해 주세요." + }, + "promptsSettings.promptEditingTitle": { + "defaultMessage": "프롬프트 편집" + }, + "promptsSettings.promptResetToDefault": { + "defaultMessage": "기본값으로 프롬프트 재설정" + }, + "promptsSettings.promptSaved": { + "defaultMessage": "프롬프트가 저장되었습니다" + }, + "promptsSettings.resetAll": { + "defaultMessage": "모두 재설정" + }, + "promptsSettings.resetToDefault": { + "defaultMessage": "기본값으로 재설정" + }, + "promptsSettings.restoreDefault": { + "defaultMessage": "기본값 복원" + }, + "promptsSettings.save": { + "defaultMessage": "저장" + }, + "promptsSettings.templateTip": { + "defaultMessage": "{extensionsExample} 또는 {forExample}과 같은 템플릿 변수는 런타임 시 실제 값으로 대체됩니다. 필수 변수를 제거하지 않도록 주의하세요." + }, + "promptsSettings.unsavedChanges": { + "defaultMessage": "저장되지 않은 변경사항이 있습니다." + }, + "providerCard.noMetadata": { + "defaultMessage": "ProviderCard 오류: 메타데이터가 제공되지 않았습니다." + }, + "providerCard.unknownProvider": { + "defaultMessage": "알 수 없는 제공업체" + }, + "providerCatalogPicker.anthropicCompatible": { + "defaultMessage": "Anthropic 호환 가능" + }, + "providerCatalogPicker.apiFormat": { + "defaultMessage": "API 형식" + }, + "providerCatalogPicker.cancel": { + "defaultMessage": "취소" + }, + "providerCatalogPicker.chooseProvider": { + "defaultMessage": "제공업체 선택" + }, + "providerCatalogPicker.errorPrefix": { + "defaultMessage": "오류: {error}" + }, + "providerCatalogPicker.loadingProviders": { + "defaultMessage": "제공업체 로드 중..." + }, + "providerCatalogPicker.modelsAvailable": { + "defaultMessage": "{count}개 모델 사용 가능" + }, + "providerCatalogPicker.noProvidersAvailable": { + "defaultMessage": "이용 가능한 제공업체가 없습니다." + }, + "providerCatalogPicker.noProvidersFound": { + "defaultMessage": "\"{query}\"에 대한 제공업체를 찾을 수 없습니다." + }, + "providerCatalogPicker.openaiCompatible": { + "defaultMessage": "OpenAI 호환 가능" + }, + "providerCatalogPicker.requiresEnvVar": { + "defaultMessage": "• {envVar} 필요" + }, + "providerCatalogPicker.searchProviders": { + "defaultMessage": "제공업체 검색..." + }, + "providerCatalogPicker.selectFormatDescription": { + "defaultMessage": "API 형식과 제공업체를 선택하세요. 구성이 자동으로 채워집니다." + }, + "providerConfigForm.browserWindowOpen": { + "defaultMessage": "로그인을 완료할 수 있는 브라우저 창이 열립니다." + }, + "providerConfigForm.configuring": { + "defaultMessage": "구성 중..." + }, + "providerConfigForm.continue": { + "defaultMessage": "계속" + }, + "providerConfigForm.deviceCodeFlowHint": { + "defaultMessage": "브라우저 창이 열리고 인증 코드가 클립보드에 복사됩니다. 로그인을 완료하려면 브라우저에 붙여넣으세요." + }, + "providerConfigForm.noApiKey": { + "defaultMessage": "API 키가 없나요?" + }, + "providerConfigForm.signInWith": { + "defaultMessage": "{providerName}으로 로그인" + }, + "providerConfigForm.signingIn": { + "defaultMessage": "로그인 중..." + }, + "providerConfigurationModal.addApiKeyDescription": { + "defaultMessage": "이 제공업체를 goose에 연동하려면 API 키를 추가하세요." + }, + "providerConfigurationModal.browserWindowHint": { + "defaultMessage": "로그인을 완료할 수 있는 브라우저 창이 열립니다." + }, + "providerConfigurationModal.cancel": { + "defaultMessage": "취소" + }, + "providerConfigurationModal.cannotDeleteActive": { + "defaultMessage": "현재 사용 중인 동안에는 이 제공업체를 삭제할 수 없습니다. 먼저 다른 모델로 전환해 보세요." + }, + "providerConfigurationModal.checkConfigAgain": { + "defaultMessage": "이 제공업체를 사용하려면 구성을 다시 확인하세요." + }, + "providerConfigurationModal.close": { + "defaultMessage": "닫기" + }, + "providerConfigurationModal.configureHeader": { + "defaultMessage": "{providerName} 설정" + }, + "providerConfigurationModal.deleteConfigHeader": { + "defaultMessage": "{providerName}에 대한 구성 삭제" + }, + "providerConfigurationModal.deleteConfirmation": { + "defaultMessage": "현재 제공업체 구성이 영구적으로 삭제됩니다." + }, + "providerConfigurationModal.deviceCodeFlowHint": { + "defaultMessage": "브라우저 창이 열리고 인증 코드가 클립보드에 복사됩니다. 로그인을 완료하려면 브라우저에 붙여넣으세요." + }, + "providerConfigurationModal.errorCheckingConfig": { + "defaultMessage": "이 제공업체 구성을 확인하는 동안 오류가 발생했습니다." + }, + "providerConfigurationModal.errorTitle": { + "defaultMessage": "오류" + }, + "providerConfigurationModal.externalSetupIntro": { + "defaultMessage": "이 제공업체는 goose 외부에 구성되어 있습니다. 다음 단계를 따르세요." + }, + "providerConfigurationModal.goBack": { + "defaultMessage": "돌아가기" + }, + "providerConfigurationModal.huggingFaceOAuthDescription": { + "defaultMessage": "API 토큰을 수동으로 입력하지 않고도 Hugging Face 추론 제공업체를 사용하려면 로그인하세요." + }, + "providerConfigurationModal.oauthLoginFailed": { + "defaultMessage": "OAuth 로그인 실패: {error}" + }, + "providerConfigurationModal.oauthSignInDescription": { + "defaultMessage": "이 제공업체를 이용하려면 {providerName} 계정으로 로그인하세요." + }, + "providerConfigurationModal.parameterRequired": { + "defaultMessage": "{paramName}은 필수입니다" + }, + "providerConfigurationModal.removeConfiguration": { + "defaultMessage": "구성 제거" + }, + "providerConfigurationModal.seeDocumentation": { + "defaultMessage": "자세한 내용은 문서를 참조하세요." + }, + "providerConfigurationModal.signInWith": { + "defaultMessage": "{providerName}으로 로그인" + }, + "providerConfigurationModal.signingIn": { + "defaultMessage": "로그인 중..." + }, + "providerGrid.addProvider": { + "defaultMessage": "제공업체 추가" + }, + "providerGrid.addProviderTitle": { + "defaultMessage": "제공업체 추가" + }, + "providerGrid.chooseModel": { + "defaultMessage": "모델 선택" + }, + "providerGrid.configureProvider": { + "defaultMessage": "제공업체 구성" + }, + "providerGrid.editProvider": { + "defaultMessage": "제공업체 편집" + }, + "providerGrid.fromTemplateOrManual": { + "defaultMessage": "템플릿 또는 수동 설정으로 시작" + }, + "providerLogo.alt": { + "defaultMessage": "{providerName} 로고" + }, + "providerSelector.addCustomProvider": { + "defaultMessage": "사용자 지정 제공업체 추가" + }, + "providerSelector.addCustomProviderTitle": { + "defaultMessage": "사용자 지정 제공업체 추가" + }, + "providerSelector.connectProvider": { + "defaultMessage": "제공업체에 연결" + }, + "providerSelector.connectProviderDescription": { + "defaultMessage": "OpenAI, Anthropic, Google 등을 연결합니다." + }, + "providerSelector.freeLocalDescription": { + "defaultMessage": "무료 크레딧이 있는 로컬 모델이나 제공업체를 사용하세요." + }, + "providerSelector.selectProvider": { + "defaultMessage": "제공업체 선택" + }, + "providerSelector.useFreeLocal": { + "defaultMessage": "무료/로컬 제공업체 이용" + }, + "providerSettings.configurationSettings": { + "defaultMessage": "제공업체 구성 설정" + }, + "providerSettings.loadingProviders": { + "defaultMessage": "제공업체 로드 중..." + }, + "providerSettings.onboardingDescription": { + "defaultMessage": "goose를 시작하려면 AI 모델 제공업체를 선택하세요. 각 제공업체가 생성한 API 키를 사용해야 하며 암호화되어 로컬에 저장됩니다. 설정에서 언제든지 제공업체를 변경할 수 있습니다." + }, + "providerSettings.otherProviders": { + "defaultMessage": "기타 제공업체" + }, + "providerSetupActions.cancel": { + "defaultMessage": "취소" + }, + "providerSetupActions.cannotDeleteActive": { + "defaultMessage": "현재 사용 중인 {providerName}은 삭제할 수 없습니다. 이 제공업체를 삭제하기 전에 다른 모델로 전환하세요." + }, + "providerSetupActions.confirmDelete": { + "defaultMessage": "삭제 확인" + }, + "providerSetupActions.confirmDeleteMessage": { + "defaultMessage": "{providerName}의 구성 매개변수를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다." + }, + "providerSetupActions.deleteProvider": { + "defaultMessage": "제공업체 삭제" + }, + "providerSetupActions.enableProvider": { + "defaultMessage": "제공업체 활성화" + }, + "providerSetupActions.ok": { + "defaultMessage": "좋아요" + }, + "providerSetupActions.submit": { + "defaultMessage": "제출" + }, + "recipeActivityEditor.activitiesDescription": { + "defaultMessage": "레시피 채팅 창에 표시되는 상단 프롬프트와 활동 버튼입니다." + }, + "recipeActivityEditor.activitiesLabel": { + "defaultMessage": "활동" + }, + "recipeActivityEditor.activityButtonsDescription": { + "defaultMessage": "사용자가 레시피와 상호작용하는 데 도움이 되도록 메시지 아래에 표시되는 클릭 가능한 버튼입니다." + }, + "recipeActivityEditor.activityButtonsLabel": { + "defaultMessage": "활동 버튼" + }, + "recipeActivityEditor.addActivity": { + "defaultMessage": "활동 추가" + }, + "recipeActivityEditor.addNewActivityPlaceholder": { + "defaultMessage": "새 활동 추가..." + }, + "recipeActivityEditor.messageDescription": { + "defaultMessage": "레시피 상단에 표시되는 서식이 적용된 메시지입니다. 마크다운 형식을 지원합니다." + }, + "recipeActivityEditor.messageLabel": { + "defaultMessage": "메시지" + }, + "recipeActivityEditor.messagePlaceholder": { + "defaultMessage": "사용자에게 표시되는 레시피 소개 메시지를 입력하세요(**굵게**, *기울임꼴*, `code` 등 지원)." + }, + "recipeExtensionSelector.description": { + "defaultMessage": "이 레시피를 실행할 때 사용할 수 있는 익스텐션을 선택하세요. 기본 익스텐션을 사용하려면 비워 두세요." + }, + "recipeExtensionSelector.extensionsSelected": { + "defaultMessage": "{count,plural,one{#개의 익스텐션 선택됨} other{#개의 익스텐션 선택됨}}" + }, + "recipeExtensionSelector.label": { + "defaultMessage": "익스텐션(선택사항)" + }, + "recipeExtensionSelector.noExtensionsAvailable": { + "defaultMessage": "사용 가능한 익스텐션이 없습니다." + }, + "recipeExtensionSelector.noExtensionsFound": { + "defaultMessage": "익스텐션이 없습니다." + }, + "recipeExtensionSelector.searchPlaceholder": { + "defaultMessage": "익스텐션 검색..." + }, + "recipeFormFields.addParameter": { + "defaultMessage": "매개변수 추가" + }, + "recipeFormFields.advancedOptions": { + "defaultMessage": "고급 옵션" + }, + "recipeFormFields.advancedOptionsHint": { + "defaultMessage": "활동, 매개변수, 모델, 익스텐션, 응답 스키마, 하위 레시피" + }, + "recipeFormFields.descriptionLabel": { + "defaultMessage": "설명" + }, + "recipeFormFields.descriptionPlaceholder": { + "defaultMessage": "이 레시피의 기능에 대한 간략한 설명" + }, + "recipeFormFields.enterValueFor": { + "defaultMessage": "{key}에 대한 값을 입력하세요." + }, + "recipeFormFields.initialPrompt": { + "defaultMessage": "초기 프롬프트" + }, + "recipeFormFields.instructionsLabel": { + "defaultMessage": "지침" + }, + "recipeFormFields.instructionsPlaceholder": { + "defaultMessage": "사용자에게 숨겨진 AI에 대한 자세한 지침" + }, + "recipeFormFields.openEditor": { + "defaultMessage": "편집기 열기" + }, + "recipeFormFields.parameterNamePlaceholder": { + "defaultMessage": "매개변수 이름을 입력하세요..." + }, + "recipeFormFields.parametersDescription": { + "defaultMessage": "매개변수는 지침/프롬프트/활동의 '{{parameter_name}}' 구문에서 자동으로 감지되거나 아래에서 수동으로 추가할 수 있습니다." + }, + "recipeFormFields.parametersLabel": { + "defaultMessage": "매개변수" + }, + "recipeFormFields.promptOptionalHint": { + "defaultMessage": "(선택사항 - 지침 또는 프롬프트가 필요함)" + }, + "recipeFormFields.promptPlaceholder": { + "defaultMessage": "레시피 시작 시 미리 채워질 프롬프트" + }, + "recipeFormFields.responseJsonSchema": { + "defaultMessage": "응답 JSON 스키마" + }, + "recipeFormFields.responseJsonSchemaDescription": { + "defaultMessage": "JSON Schema 형식을 이용하여 예상되는 AI의 응답 구조를 정의합니다." + }, + "recipeFormFields.templateVarHint": { + "defaultMessage": "레시피 실행 시 입력할 수 있는 매개변수를 정의하려면 '{{parameter_name}}'을 사용하세요." + }, + "recipeFormFields.titleLabel": { + "defaultMessage": "제목" + }, + "recipeFormFields.titlePlaceholder": { + "defaultMessage": "레시피 제목" + }, + "recipeHeader.recipeLabel": { + "defaultMessage": "레시피" + }, + "recipeModelSelector.backToModelList": { + "defaultMessage": "모델 목록으로 돌아가기" + }, + "recipeModelSelector.enterCustomModel": { + "defaultMessage": "모델 이름을 입력하세요." + }, + "recipeModelSelector.enterModelNotListed": { + "defaultMessage": "목록에 없는 모델을 입력하세요..." + }, + "recipeModelSelector.fetchError": { + "defaultMessage": "모델을 가져오지 못했습니다. 나중에 다시 시도해 주세요." + }, + "recipeModelSelector.loadingModels": { + "defaultMessage": "모델 로드 중…" + }, + "recipeModelSelector.modelHint": { + "defaultMessage": "선택한 제공업체의 기본 모델을 사용하려면 비워 두세요." + }, + "recipeModelSelector.modelLabel": { + "defaultMessage": "모델(선택사항)" + }, + "recipeModelSelector.providerHint": { + "defaultMessage": "설정에 구성된 기본 제공업체를 사용하려면 비워 두세요." + }, + "recipeModelSelector.providerLabel": { + "defaultMessage": "제공업체(선택사항)" + }, + "recipeModelSelector.selectModel": { + "defaultMessage": "모델을 선택하세요" + }, + "recipeModelSelector.selectProvider": { + "defaultMessage": "제공업체 선택" + }, + "recipeModelSelector.useDefaultProvider": { + "defaultMessage": "기본 제공업체 사용" + }, + "recipeNameField.defaultLabel": { + "defaultMessage": "레시피 이름" + }, + "recipeNameField.formatHint": { + "defaultMessage": "자동으로 형식이 지정됩니다(소문자, 공백은 대시로 변환)." + }, + "recipeWarningModal.cancel": { + "defaultMessage": "취소" + }, + "recipeWarningModal.descriptionLabel": { + "defaultMessage": "설명:" + }, + "recipeWarningModal.firstTimeDescription": { + "defaultMessage": "이전에 실행한 적이 없는 레시피를 실행하려고 합니다." + }, + "recipeWarningModal.hiddenCharsWarning": { + "defaultMessage": "이 레시피에는 악의적인 목적으로 사용될 수 있으므로 안전을 위해 무시되는 숨겨진 문자가 포함되어 있습니다." + }, + "recipeWarningModal.instructionsLabel": { + "defaultMessage": "지침:" + }, + "recipeWarningModal.newRecipeWarningTitle": { + "defaultMessage": "⚠️ 새로운 레시피 경고" + }, + "recipeWarningModal.recipePreview": { + "defaultMessage": "레시피 미리보기:" + }, + "recipeWarningModal.securityWarningTitle": { + "defaultMessage": "⚠️ 보안 경고" + }, + "recipeWarningModal.titleLabel": { + "defaultMessage": "제목:" + }, + "recipeWarningModal.trustAndExecute": { + "defaultMessage": "신뢰하고 실행" + }, + "recipeWarningModal.trustSource": { + "defaultMessage": "이 레시피의 출처를 신뢰하는 경우에만 진행하세요." + }, + "recipesView.addSchedule": { + "defaultMessage": "일정 추가" + }, + "recipesView.addSlashCommand": { + "defaultMessage": "슬래시 명령 추가" + }, + "recipesView.adjustSearchTerms": { + "defaultMessage": "검색어를 조정해 보세요." + }, + "recipesView.allFiles": { + "defaultMessage": "모든 파일" + }, + "recipesView.cancel": { + "defaultMessage": "취소" + }, + "recipesView.copyDeeplink": { + "defaultMessage": "딥링크 복사" + }, + "recipesView.copyDeeplinkFailedMsg": { + "defaultMessage": "딥링크를 클립보드에 복사하지 못했습니다." + }, + "recipesView.copyFailedTitle": { + "defaultMessage": "복사 실패" + }, + "recipesView.copyYaml": { + "defaultMessage": "YAML 복사" + }, + "recipesView.copyYamlFailedMsg": { + "defaultMessage": "레시피 YAML을 클립보드에 복사하지 못했습니다." + }, + "recipesView.createRecipe": { + "defaultMessage": "레시피 생성" + }, + "recipesView.deeplinkCopiedMsg": { + "defaultMessage": "레시피 딥링크가 클립보드에 복사되었습니다." + }, + "recipesView.deeplinkCopiedTitle": { + "defaultMessage": "딥링크 복사됨" + }, + "recipesView.deleteRecipe": { + "defaultMessage": "레시피 삭제" + }, + "recipesView.deleteRecipeConfirm": { + "defaultMessage": "\"{title}\"을(를) 삭제하시겠습니까?" + }, + "recipesView.deleteRecipeDetail": { + "defaultMessage": "레시피 파일이 삭제됩니다." + }, + "recipesView.deleteRecipeTitle": { + "defaultMessage": "레시피 삭제" + }, + "recipesView.editRecipe": { + "defaultMessage": "레시피 편집" + }, + "recipesView.editSchedule": { + "defaultMessage": "일정 수정" + }, + "recipesView.editSlashCommand": { + "defaultMessage": "슬래시 명령 편집" + }, + "recipesView.errorLoadingRecipes": { + "defaultMessage": "레시피를 로드하는 중 오류가 발생했습니다." + }, + "recipesView.exportFailedMsg": { + "defaultMessage": "레시피를 파일로 내보내지 못했습니다." + }, + "recipesView.exportFailedTitle": { + "defaultMessage": "내보내기 실패" + }, + "recipesView.exportRecipeDialogTitle": { + "defaultMessage": "레시피 내보내기" + }, + "recipesView.exportToFile": { + "defaultMessage": "파일로 내보내기" + }, + "recipesView.noMatchingRecipes": { + "defaultMessage": "일치하는 레시피가 없습니다." + }, + "recipesView.noSavedRecipes": { + "defaultMessage": "저장된 레시피가 없습니다." + }, + "recipesView.noSavedRecipesDescription": { + "defaultMessage": "채팅에서 저장된 레시피가 여기에 표시됩니다." + }, + "recipesView.openInNewWindow": { + "defaultMessage": "새 창에서 열기" + }, + "recipesView.recipeDeletedSuccess": { + "defaultMessage": "레시피가 삭제되었습니다." + }, + "recipesView.recipeExportedMsg": { + "defaultMessage": "{filePath}에 레시피가 저장되었습니다." + }, + "recipesView.recipeExportedTitle": { + "defaultMessage": "레시피를 내보냈습니다." + }, + "recipesView.recipesDescription": { + "defaultMessage": "저장된 레시피를 확인·관리하고, 미리 정의된 구성으로 새 세션을 빠르게 시작하세요. {shortcut}로 검색할 수 있습니다." + }, + "recipesView.recipesTitle": { + "defaultMessage": "레시피" + }, + "recipesView.remove": { + "defaultMessage": "제거" + }, + "recipesView.removeSchedule": { + "defaultMessage": "일정 삭제" + }, + "recipesView.runs": { + "defaultMessage": "{schedule} 실행" + }, + "recipesView.save": { + "defaultMessage": "저장" + }, + "recipesView.scheduleDialogTitle": { + "defaultMessage": "{action} 일정" + }, + "recipesView.scheduleRemovedMsg": { + "defaultMessage": "레시피가 더 이상 자동으로 실행되지 않습니다." + }, + "recipesView.scheduleRemovedTitle": { + "defaultMessage": "일정이 삭제되었습니다." + }, + "recipesView.scheduleSavedMsg": { + "defaultMessage": "레시피가 {schedule} 실행됩니다." + }, + "recipesView.scheduleSavedTitle": { + "defaultMessage": "일정이 저장되었습니다." + }, + "recipesView.searchRecipesPlaceholder": { + "defaultMessage": "레시피 검색..." + }, + "recipesView.shareRecipe": { + "defaultMessage": "레시피 공유" + }, + "recipesView.slashCommandDescription": { + "defaultMessage": "모든 채팅에서 이 레시피를 빠르게 실행하려면 슬래시 명령을 설정하세요." + }, + "recipesView.slashCommandPlaceholder": { + "defaultMessage": "명령 이름" + }, + "recipesView.slashCommandRemovedMsg": { + "defaultMessage": "레시피 슬래시 명령이 제거되었습니다." + }, + "recipesView.slashCommandRemovedTitle": { + "defaultMessage": "슬래시 명령이 제거되었습니다." + }, + "recipesView.slashCommandSavedMsg": { + "defaultMessage": "이 레시피 실행: /{command}" + }, + "recipesView.slashCommandSavedTitle": { + "defaultMessage": "슬래시 명령이 저장되었습니다." + }, + "recipesView.slashCommandTitle": { + "defaultMessage": "슬래시 명령" + }, + "recipesView.slashCommandUsageHint": { + "defaultMessage": "채팅에서 /{command}로 이 레시피 실행" + }, + "recipesView.tryAgain": { + "defaultMessage": "다시 시도" + }, + "recipesView.useRecipe": { + "defaultMessage": "레시피 사용" + }, + "recipesView.yamlCopiedMsg": { + "defaultMessage": "레시피 YAML이 클립보드에 복사되었습니다" + }, + "recipesView.yamlCopiedTitle": { + "defaultMessage": "YAML 복사됨" + }, + "recipesView.yamlFiles": { + "defaultMessage": "YAML 파일" + }, + "resetProviderSection.resetButton": { + "defaultMessage": "제공업체 및 모델 초기화" + }, + "resetProviderSection.resetDescription": { + "defaultMessage": "선택한 모델 및 제공업체 설정이 지워집니다. 사용 가능한 기본값이 없으면, 다시 설정할 수 있도록 시작 화면으로 이동합니다." + }, + "responseStyle.conciseDescription": { + "defaultMessage": "도구 호출은 기본적으로 접혀 있으며 사용된 도구만 표시됩니다." + }, + "responseStyle.conciseLabel": { + "defaultMessage": "간결하게" + }, + "responseStyle.detailedDescription": { + "defaultMessage": "도구 호출은 기본적으로 펼쳐져 세부 정보가 표시됩니다." + }, + "responseStyle.detailedLabel": { + "defaultMessage": "자세히" + }, + "scheduleDetailView.actions": { + "defaultMessage": "작업" + }, + "scheduleDetailView.cannotModifyRunning": { + "defaultMessage": "이미 실행 중인 일정은 트리거하거나 수정할 수 없습니다." + }, + "scheduleDetailView.created": { + "defaultMessage": "생성일: {date}" + }, + "scheduleDetailView.cronExpression": { + "defaultMessage": "cron 표현식:" + }, + "scheduleDetailView.currentSession": { + "defaultMessage": "현재 세션:" + }, + "scheduleDetailView.currentlyRunning": { + "defaultMessage": "현재 실행 중" + }, + "scheduleDetailView.dir": { + "defaultMessage": "디렉터리: {path}" + }, + "scheduleDetailView.editSchedule": { + "defaultMessage": "일정 편집" + }, + "scheduleDetailView.errorPrefix": { + "defaultMessage": "오류: {error}" + }, + "scheduleDetailView.failedToLoadSession": { + "defaultMessage": "세션을 로드하지 못했습니다." + }, + "scheduleDetailView.idLabel": { + "defaultMessage": "ID:" + }, + "scheduleDetailView.inspectJobError": { + "defaultMessage": "작업 검사 오류" + }, + "scheduleDetailView.inspectNoInfo": { + "defaultMessage": "자세한 정보가 없습니다." + }, + "scheduleDetailView.inspectRunningJob": { + "defaultMessage": "실행 중인 작업 검사" + }, + "scheduleDetailView.jobCancelled": { + "defaultMessage": "작업이 취소되었습니다." + }, + "scheduleDetailView.jobCancelledMsg": { + "defaultMessage": "시작하는 동안 작업이 취소되었습니다." + }, + "scheduleDetailView.jobInspection": { + "defaultMessage": "작업 검사" + }, + "scheduleDetailView.jobKilled": { + "defaultMessage": "작업 종료됨" + }, + "scheduleDetailView.killJobError": { + "defaultMessage": "작업 종료 오류" + }, + "scheduleDetailView.killRunningJob": { + "defaultMessage": "실행 중인 작업 종료" + }, + "scheduleDetailView.lastRun": { + "defaultMessage": "마지막 실행:" + }, + "scheduleDetailView.loadingSchedule": { + "defaultMessage": "일정 로드 중..." + }, + "scheduleDetailView.loadingSessions": { + "defaultMessage": "세션 로드 중..." + }, + "scheduleDetailView.messages": { + "defaultMessage": "메시지: {count}" + }, + "scheduleDetailView.newSession": { + "defaultMessage": "새 세션: {sessionId}" + }, + "scheduleDetailView.noScheduleId": { + "defaultMessage": "제공된 일정 ID가 없습니다. 일정 목록으로 돌아갑니다." + }, + "scheduleDetailView.noSessions": { + "defaultMessage": "이 일정에 대한 세션을 찾을 수 없습니다." + }, + "scheduleDetailView.pauseSchedule": { + "defaultMessage": "일정 일시 중지" + }, + "scheduleDetailView.pauseUnpauseError": { + "defaultMessage": "일시중지/일시중지 해제 오류" + }, + "scheduleDetailView.paused": { + "defaultMessage": "일시중지됨" + }, + "scheduleDetailView.pausedMsg": { + "defaultMessage": "\"{id}\" 일시 중지됨" + }, + "scheduleDetailView.pausedWarning": { + "defaultMessage": "이 일정은 일시 중지되어 자동으로 실행되지 않습니다. 수동으로 실행하려면 \"지금 일정 실행\"을 사용하거나, 자동 실행을 재개하려면 일시 중지를 해제하세요." + }, + "scheduleDetailView.processStarted": { + "defaultMessage": "프로세스 시작:" + }, + "scheduleDetailView.recentSessions": { + "defaultMessage": "최근 세션" + }, + "scheduleDetailView.recipeSource": { + "defaultMessage": "레시피 소스:" + }, + "scheduleDetailView.runScheduleError": { + "defaultMessage": "일정 실행 오류" + }, + "scheduleDetailView.runScheduleNow": { + "defaultMessage": "지금 일정 실행" + }, + "scheduleDetailView.scheduleDetails": { + "defaultMessage": "일정 세부정보" + }, + "scheduleDetailView.scheduleInformation": { + "defaultMessage": "일정 정보" + }, + "scheduleDetailView.scheduleLabel": { + "defaultMessage": "일정:" + }, + "scheduleDetailView.scheduleNotFound": { + "defaultMessage": "일정을 찾을 수 없습니다" + }, + "scheduleDetailView.scheduleNotFoundError": { + "defaultMessage": "일정을 찾을 수 없습니다" + }, + "scheduleDetailView.schedulePaused": { + "defaultMessage": "일정이 일시 중지됨" + }, + "scheduleDetailView.scheduleTriggered": { + "defaultMessage": "일정이 실행됨" + }, + "scheduleDetailView.scheduleUnpaused": { + "defaultMessage": "일정 일시중지 해제됨" + }, + "scheduleDetailView.scheduleUpdated": { + "defaultMessage": "일정이 업데이트되었습니다" + }, + "scheduleDetailView.sessionId": { + "defaultMessage": "세션 ID: {id}" + }, + "scheduleDetailView.tokens": { + "defaultMessage": "토큰: {count}" + }, + "scheduleDetailView.unpauseSchedule": { + "defaultMessage": "일정 일시중지 해제" + }, + "scheduleDetailView.unpausedMsg": { + "defaultMessage": "\"{id}\" 일시중지 해제됨" + }, + "scheduleDetailView.updateScheduleError": { + "defaultMessage": "일정 업데이트 오류" + }, + "scheduleDetailView.updatedMsg": { + "defaultMessage": "\"{id}\" 업데이트됨" + }, + "scheduleDetailView.viewingScheduleId": { + "defaultMessage": "일정 ID 보기: {id}" + }, + "scheduleModal.browseYaml": { + "defaultMessage": "YAML 파일 찾아보기..." + }, + "scheduleModal.cancel": { + "defaultMessage": "취소" + }, + "scheduleModal.createNewSchedule": { + "defaultMessage": "새 일정 생성" + }, + "scheduleModal.createSchedule": { + "defaultMessage": "일정 생성" + }, + "scheduleModal.creating": { + "defaultMessage": "생성 중..." + }, + "scheduleModal.deepLink": { + "defaultMessage": "딥링크" + }, + "scheduleModal.deepLinkPlaceholder": { + "defaultMessage": "goose://recipe 링크를 여기에 붙여넣으세요..." + }, + "scheduleModal.editSchedule": { + "defaultMessage": "일정 편집" + }, + "scheduleModal.failedParseRecipe": { + "defaultMessage": "파일에서 레시피를 구문 분석하지 못했습니다." + }, + "scheduleModal.failedReadFile": { + "defaultMessage": "선택한 파일을 읽지 못했습니다." + }, + "scheduleModal.invalidDeepLink": { + "defaultMessage": "딥링크가 잘못되었습니다. goose://recipe 링크를 이용해 주세요." + }, + "scheduleModal.invalidFileType": { + "defaultMessage": "잘못된 파일 형식: YAML 파일(.yaml 또는 .yml)을 선택하세요." + }, + "scheduleModal.nameLabel": { + "defaultMessage": "이름:" + }, + "scheduleModal.namePlaceholder": { + "defaultMessage": "예: 일일 요약 작업" + }, + "scheduleModal.provideValidRecipe": { + "defaultMessage": "유효한 레시피 소스를 입력하세요." + }, + "scheduleModal.recipeDescription": { + "defaultMessage": "설명: {description}" + }, + "scheduleModal.recipeParsed": { + "defaultMessage": "레시피가 성공적으로 파싱되었습니다." + }, + "scheduleModal.recipeTitle": { + "defaultMessage": "제목: {title}" + }, + "scheduleModal.scheduleIdRequired": { + "defaultMessage": "일정 ID가 필요합니다." + }, + "scheduleModal.scheduleLabel": { + "defaultMessage": "일정:" + }, + "scheduleModal.selected": { + "defaultMessage": "선택됨: {path}" + }, + "scheduleModal.sourceLabel": { + "defaultMessage": "소스:" + }, + "scheduleModal.updateSchedule": { + "defaultMessage": "일정 업데이트" + }, + "scheduleModal.updating": { + "defaultMessage": "업데이트 중..." + }, + "scheduleModal.yaml": { + "defaultMessage": "YAML" + }, + "schedulesView.confirmDelete": { + "defaultMessage": "일정 \"{id}\"을(를) 삭제하시겠습니까? 레시피는 유지됩니다." + }, + "schedulesView.description": { + "defaultMessage": "지정된 시간에 자동으로 레시피를 실행하는 예약된 작업을 생성하고 관리하세요." + }, + "schedulesView.edit": { + "defaultMessage": "편집" + }, + "schedulesView.errorPrefix": { + "defaultMessage": "오류: {error}" + }, + "schedulesView.inspect": { + "defaultMessage": "검사" + }, + "schedulesView.inspectError": { + "defaultMessage": "작업 오류 검사" + }, + "schedulesView.inspectNoInfo": { + "defaultMessage": "이 작업에 대한 자세한 정보가 없습니다." + }, + "schedulesView.jobInspection": { + "defaultMessage": "작업 검사" + }, + "schedulesView.jobKilled": { + "defaultMessage": "작업 종료됨" + }, + "schedulesView.kill": { + "defaultMessage": "종료" + }, + "schedulesView.killError": { + "defaultMessage": "작업 종료 오류" + }, + "schedulesView.lastRun": { + "defaultMessage": "마지막 실행: {date}" + }, + "schedulesView.noSchedules": { + "defaultMessage": "아직 일정이 없습니다" + }, + "schedulesView.pause": { + "defaultMessage": "일시 중지" + }, + "schedulesView.pauseError": { + "defaultMessage": "일정 일시 중지 오류" + }, + "schedulesView.paused": { + "defaultMessage": "일시중지됨" + }, + "schedulesView.refresh": { + "defaultMessage": "새로고침" + }, + "schedulesView.refreshing": { + "defaultMessage": "새로고침 중..." + }, + "schedulesView.resume": { + "defaultMessage": "재개" + }, + "schedulesView.running": { + "defaultMessage": "실행 중" + }, + "schedulesView.schedulePaused": { + "defaultMessage": "일정이 일시 중지됨" + }, + "schedulesView.schedulePausedMsg": { + "defaultMessage": "일정 \"{id}\"을(를) 일시중지했습니다." + }, + "schedulesView.scheduleUnpaused": { + "defaultMessage": "일정 일시중지 해제됨" + }, + "schedulesView.scheduleUnpausedMsg": { + "defaultMessage": "일정 \"{id}\"의 일시중지를 해제했습니다." + }, + "schedulesView.scheduleUpdated": { + "defaultMessage": "일정이 업데이트되었습니다" + }, + "schedulesView.scheduleUpdatedMsg": { + "defaultMessage": "\"{id}\" 일정이 업데이트되었습니다." + }, + "schedulesView.scheduler": { + "defaultMessage": "스케줄러" + }, + "schedulesView.unpauseError": { + "defaultMessage": "일시중지 해제 일정 오류" + }, + "searchBar.caseSensitive": { + "defaultMessage": "대소문자 구분" + }, + "searchBar.close": { + "defaultMessage": "닫기({shortcut})" + }, + "searchBar.next": { + "defaultMessage": "다음({shortcut})" + }, + "searchBar.placeholder": { + "defaultMessage": "대화 검색..." + }, + "searchBar.previous": { + "defaultMessage": "이전 ({shortcut})" + }, + "secureStorageNotice.defaultMessage": { + "defaultMessage": "키는 키체인에 안전하게 저장됩니다." + }, + "securityToggle.apiTokenDescription": { + "defaultMessage": "분류 서비스에 대한 인증 토큰" + }, + "securityToggle.apiTokenOptional": { + "defaultMessage": "API 토큰(선택)" + }, + "securityToggle.classificationEndpoint": { + "defaultMessage": "분류 엔드포인트" + }, + "securityToggle.classificationEndpointDescription": { + "defaultMessage": "분류 서비스의 전체 URL을 입력하세요." + }, + "securityToggle.commandClassifierActive": { + "defaultMessage": "명령 분류기 활성화됨(환경에서 자동 설정)" + }, + "securityToggle.commandEndpointDescription": { + "defaultMessage": "명령 인젝션 분류 서비스의 전체 URL을 입력하세요." + }, + "securityToggle.commandInjectionDescription": { + "defaultMessage": "ML 모델을 사용하여 악성 셸 명령 감지" + }, + "securityToggle.detectionModel": { + "defaultMessage": "탐지 모델" + }, + "securityToggle.detectionModelDescription": { + "defaultMessage": "프롬프트 인젝션 감지에 사용할 ML 모델을 선택하세요." + }, + "securityToggle.detectionThreshold": { + "defaultMessage": "감지 임계값" + }, + "securityToggle.enableCommandInjection": { + "defaultMessage": "명령 인젝션 ML 감지 켜기" + }, + "securityToggle.enablePromptInjection": { + "defaultMessage": "프롬프트 인젝션 감지 켜기" + }, + "securityToggle.enablePromptInjectionMl": { + "defaultMessage": "프롬프트 인젝션 ML 감지 켜기" + }, + "securityToggle.mlEndpointDescription": { + "defaultMessage": "ML 분류 서비스의 전체 URL을 입력하세요(모델 식별자 포함)." + }, + "securityToggle.mlTokenDescription": { + "defaultMessage": "ML 서비스에 대한 인증 토큰(예: HuggingFace 토큰)" + }, + "securityToggle.overrideNotice": { + "defaultMessage": "이 설정은 조직에서 관리하며 변경할 수 없습니다." + }, + "securityToggle.promptInjectionDescription": { + "defaultMessage": "잠재적인 프롬프트 인젝션 공격을 감지하고 방지합니다." + }, + "securityToggle.promptInjectionMlDescription": { + "defaultMessage": "ML 모델을 사용하여 채팅에서 잠재적인 프롬프트 인젝션을 감지합니다." + }, + "securityToggle.thresholdDescription": { + "defaultMessage": "값이 높을수록 더 엄격합니다(0.01 = 매우 관대함, 1.0 = 가장 엄격)." + }, + "securityToggle.warpNotice": { + "defaultMessage": "명령 인젝션 감지는 WARP에 연결했을 때 가장 잘 작동합니다. 분류 서비스에 연결하려면 WARP가 필요합니다." + }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "세션 작업" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "취소" + }, + "sessionActionsHeader.close": { + "defaultMessage": "닫기" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "세션 JSON 복사됨" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "텍스트가 복사되었습니다." + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "JSON 복사" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "텍스트 복사" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "세션 복제 실패: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "세션 복제" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "세션이 복제되었습니다" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "텍스트 값" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "세션 JSON을 불러오지 못했습니다: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "세션 JSON" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "JSON 불러오는 중..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "세션 이름 변경 실패: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "세션 이름 입력" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "세션 이름 변경" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "세션 이름 변경" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "세션 이름이 변경되었습니다" + }, + "sessionActionsHeader.save": { + "defaultMessage": "저장" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "저장 중..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "세션 JSON 보기" + }, + "sessionHistory.cancel": { + "defaultMessage": "취소" + }, + "sessionHistory.copy": { + "defaultMessage": "복사" + }, + "sessionHistory.empty.description": { + "defaultMessage": "이 세션에는 메시지가 없습니다." + }, + "sessionHistory.empty.title": { + "defaultMessage": "메시지를 찾을 수 없습니다." + }, + "sessionHistory.error.loading": { + "defaultMessage": "세션 세부정보를 로드하는 중 오류가 발생했습니다." + }, + "sessionHistory.error.tryAgain": { + "defaultMessage": "다시 시도" + }, + "sessionHistory.loading": { + "defaultMessage": "세션 세부정보 로드 중..." + }, + "sessionHistory.resume": { + "defaultMessage": "재개" + }, + "sessionHistory.searchPlaceholder": { + "defaultMessage": "기록 검색..." + }, + "sessionHistory.share": { + "defaultMessage": "공유" + }, + "sessionHistory.shareModal.description": { + "defaultMessage": "이 세션 링크를 공유하여 다른 사람이 goose 채팅을 읽기 전용으로 볼 수 있게 하세요." + }, + "sessionHistory.shareModal.title": { + "defaultMessage": "공유 세션(베타)" + }, + "sessionHistory.shareTooltip": { + "defaultMessage": "세션 공유를 활성화하려면 설정 > 세션 > 세션 공유로 이동하세요." + }, + "sessionHistory.sharing": { + "defaultMessage": "공유 중..." + }, + "sessionHistory.toast.copyFailed": { + "defaultMessage": "링크를 클립보드에 복사하지 못했습니다." + }, + "sessionHistory.toast.launchFailed": { + "defaultMessage": "세션을 시작할 수 없습니다: {error}" + }, + "sessionHistory.toast.shareFailed": { + "defaultMessage": "세션 공유 실패: {error}" + }, + "sessionIndicators.error": { + "defaultMessage": "세션에 오류가 발생했습니다." + }, + "sessionIndicators.newActivity": { + "defaultMessage": "새로운 활동 있음" + }, + "sessionIndicators.streaming": { + "defaultMessage": "스트리밍" + }, + "sessionSharingSection.alreadyConfigured": { + "defaultMessage": "세션 공유가 이미 구성되었습니다." + }, + "sessionSharingSection.baseUrl": { + "defaultMessage": "기본 URL" + }, + "sessionSharingSection.connectionFailed": { + "defaultMessage": "연결에 실패했습니다." + }, + "sessionSharingSection.connectionSuccess": { + "defaultMessage": "연결 성공!" + }, + "sessionSharingSection.connectionTimedOut": { + "defaultMessage": "연결 시간이 초과되었습니다. 서버가 느리거나 연결이 불가능할 수 있습니다." + }, + "sessionSharingSection.descriptionConfigured": { + "defaultMessage": "세션 공유가 설정되었지만, 세션은 공유 버튼을 직접 클릭할 때만 공유됩니다." + }, + "sessionSharingSection.descriptionDefault": { + "defaultMessage": "세션 공유를 켜면 내 세션을 다른 사람과 공유할 수 있습니다." + }, + "sessionSharingSection.enableSharing": { + "defaultMessage": "세션 공유 켜기" + }, + "sessionSharingSection.invalidUrl": { + "defaultMessage": "URL 형식이 잘못되었습니다. 올바른 URL을 입력하세요(예: https://example.com/api)." + }, + "sessionSharingSection.serverError": { + "defaultMessage": "서버 오류: HTTP {status}. 서버가 올바르게 구성되지 않았을 수 있습니다." + }, + "sessionSharingSection.testConnection": { + "defaultMessage": "연결 테스트" + }, + "sessionSharingSection.testing": { + "defaultMessage": "테스트 중..." + }, + "sessionSharingSection.testingConnection": { + "defaultMessage": "연결 테스트 중..." + }, + "sessionSharingSection.title": { + "defaultMessage": "세션 공유" + }, + "sessionSharingSection.unknownError": { + "defaultMessage": "알 수 없는 오류가 발생했습니다." + }, + "sessionSharingSection.unreachableServer": { + "defaultMessage": "서버에 연결할 수 없습니다. URL과 네트워크 연결을 확인해주세요." + }, + "sessionSharingSection.urlPlaceholder": { + "defaultMessage": "https://example.com/api" + }, + "sessionViewComponents.empty.description": { + "defaultMessage": "이 세션에는 메시지가 없습니다." + }, + "sessionViewComponents.empty.title": { + "defaultMessage": "메시지를 찾을 수 없습니다." + }, + "sessionViewComponents.error.loading": { + "defaultMessage": "세션 세부정보를 로드하는 중 오류가 발생했습니다." + }, + "sessionViewComponents.error.tryAgain": { + "defaultMessage": "다시 시도" + }, + "sessionViewComponents.role.assistant": { + "defaultMessage": "Goose" + }, + "sessionViewComponents.role.user": { + "defaultMessage": "나" + }, + "sessions.action.delete": { + "defaultMessage": "세션 삭제" + }, + "sessions.action.duplicate": { + "defaultMessage": "세션 복제" + }, + "sessions.action.editName": { + "defaultMessage": "세션 이름 편집" + }, + "sessions.action.export": { + "defaultMessage": "세션 내보내기" + }, + "sessions.action.openNewWindow": { + "defaultMessage": "새 창에서 열기" + }, + "sessions.action.shareNostr": { + "defaultMessage": "암호화된 Nostr 링크 공유" + }, + "sessions.cancel": { + "defaultMessage": "취소" + }, + "sessions.chatHistory": { + "defaultMessage": "채팅 기록" + }, + "sessions.chatHistoryDesc": { + "defaultMessage": "goose와 나눈 이전 대화를 확인하고 검색하세요. {shortcut}로 검색할 수 있습니다." + }, + "sessions.close": { + "defaultMessage": "닫기" + }, + "sessions.delete.message": { + "defaultMessage": "\"{name}\" 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다." + }, + "sessions.delete.title": { + "defaultMessage": "세션 삭제" + }, + "sessions.edit.placeholder": { + "defaultMessage": "세션 설명 입력" + }, + "sessions.edit.title": { + "defaultMessage": "세션 설명 편집" + }, + "sessions.empty.description": { + "defaultMessage": "채팅 기록이 여기에 표시됩니다" + }, + "sessions.empty.title": { + "defaultMessage": "채팅 세션을 찾을 수 없습니다." + }, + "sessions.error.loading": { + "defaultMessage": "세션을 로드하는 중 오류가 발생했습니다." + }, + "sessions.error.tryAgain": { + "defaultMessage": "다시 시도" + }, + "sessions.import": { + "defaultMessage": "세션 가져오기" + }, + "sessions.importNostr": { + "defaultMessage": "링크 가져오기" + }, + "sessions.importNostr.description": { + "defaultMessage": "Goose Nostr 공유 링크를 붙여넣어 세션을 가져오고 복호화한 뒤 불러오세요." + }, + "sessions.importNostr.placeholder": { + "defaultMessage": "goose://sessions/nostr?nevent=...&key=..." + }, + "sessions.importNostr.title": { + "defaultMessage": "Nostr 세션 가져오기" + }, + "sessions.importing": { + "defaultMessage": "가져오는 중..." + }, + "sessions.loadingMore": { + "defaultMessage": "추가 세션 로드 중..." + }, + "sessions.save": { + "defaultMessage": "저장" + }, + "sessions.saving": { + "defaultMessage": "저장 중..." + }, + "sessions.search.noResults": { + "defaultMessage": "일치하는 세션이 없습니다." + }, + "sessions.search.noResultsDesc": { + "defaultMessage": "검색어를 조정해 보세요." + }, + "sessions.searchPlaceholder": { + "defaultMessage": "기록 검색..." + }, + "sessions.shareNostr.description": { + "defaultMessage": "이 링크가 있는 사람은 누구나 세션을 가져오고 복호화할 수 있습니다. 비밀처럼 다루세요." + }, + "sessions.shareNostr.title": { + "defaultMessage": "암호화된 Nostr 공유 링크" + }, + "sessions.toast.copied": { + "defaultMessage": "클립보드에 복사됨" + }, + "sessions.toast.deleteFailed": { + "defaultMessage": "\"{name}\" 세션 삭제 실패: {error}" + }, + "sessions.toast.deleted": { + "defaultMessage": "세션이 삭제되었습니다." + }, + "sessions.toast.duplicateFailed": { + "defaultMessage": "세션 복제 실패: {error}" + }, + "sessions.toast.duplicated": { + "defaultMessage": "세션 \"{name}\"이(가) 성공적으로 복제되었습니다." + }, + "sessions.toast.exported": { + "defaultMessage": "세션을 성공적으로 내보냈습니다." + }, + "sessions.toast.importFailed": { + "defaultMessage": "세션 가져오기 실패: {error}" + }, + "sessions.toast.imported": { + "defaultMessage": "세션을 성공적으로 가져왔습니다." + }, + "sessions.toast.shareNostr": { + "defaultMessage": "암호화된 Nostr 공유 링크가 생성되었습니다." + }, + "sessions.toast.shareNostrFailed": { + "defaultMessage": "Nostr 공유 링크 생성 실패: {error}" + }, + "sessions.toast.updateFailed": { + "defaultMessage": "세션 설명 업데이트 실패: {error}" + }, + "sessions.toast.updated": { + "defaultMessage": "세션 설명이 업데이트되었습니다." + }, + "sessionsView.error.failedToLoad": { + "defaultMessage": "세션 세부정보를 로드하지 못했습니다. 나중에 다시 시도해 주세요." + }, + "sessionsView.loading": { + "defaultMessage": "로드 중..." + }, + "settings.appearance.description": { + "defaultMessage": "시스템에서 goose가 표시되는 방식을 설정합니다." + }, + "settings.appearance.title": { + "defaultMessage": "모양" + }, + "settings.close": { + "defaultMessage": "닫기" + }, + "settings.costTracking.description": { + "defaultMessage": "모델 가격 및 사용 비용을 표시합니다." + }, + "settings.costTracking.title": { + "defaultMessage": "비용 추적" + }, + "settings.dockIcon.description": { + "defaultMessage": "Dock에 goose를 표시합니다." + }, + "settings.dockIcon.title": { + "defaultMessage": "Dock 아이콘" + }, + "settings.help.description": { + "defaultMessage": "문제를 신고하거나 새 기능을 요청하여 goose 개선에 도움을 주세요." + }, + "settings.help.reportBug": { + "defaultMessage": "버그 신고" + }, + "settings.help.requestFeature": { + "defaultMessage": "기능 요청" + }, + "settings.help.title": { + "defaultMessage": "도움말 및 피드백" + }, + "settings.language.description": { + "defaultMessage": "goose의 표시 언어를 선택하세요" + }, + "settings.language.english": { + "defaultMessage": "영어" + }, + "settings.language.hindi": { + "defaultMessage": "힌디어" + }, + "settings.language.japanese": { + "defaultMessage": "일본어" + }, + "settings.language.korean": { + "defaultMessage": "한국어" + }, + "settings.language.russian": { + "defaultMessage": "러시아어" + }, + "settings.language.spanish": { + "defaultMessage": "스페인어" + }, + "settings.language.systemDefault": { + "defaultMessage": "시스템 기본값" + }, + "settings.language.title": { + "defaultMessage": "언어" + }, + "settings.language.turkish": { + "defaultMessage": "터키어" + }, + "settings.language.zhCN": { + "defaultMessage": "중국어(간체)" + }, + "settings.menuBarIcon.description": { + "defaultMessage": "메뉴 막대에 goose를 표시합니다." + }, + "settings.menuBarIcon.title": { + "defaultMessage": "메뉴 막대 아이콘" + }, + "settings.notifications.configGuide": { + "defaultMessage": "구성 가이드" + }, + "settings.notifications.description": { + "defaultMessage": "알림은 운영체제에서 관리됩니다 — {link}" + }, + "settings.notifications.modal.macInstructions": { + "defaultMessage": "macOS에서 알림을 켜려면:" + }, + "settings.notifications.modal.macStep1": { + "defaultMessage": "시스템 환경설정 열기" + }, + "settings.notifications.modal.macStep2": { + "defaultMessage": "알림을 클릭하세요" + }, + "settings.notifications.modal.macStep3": { + "defaultMessage": "애플리케이션 목록에서 goose를 찾아 선택하세요" + }, + "settings.notifications.modal.macStep4": { + "defaultMessage": "원하는 대로 알림을 활성화하고 설정을 조정합니다." + }, + "settings.notifications.modal.title": { + "defaultMessage": "알림 활성화 방법" + }, + "settings.notifications.modal.winInstructions": { + "defaultMessage": "Windows에서 알림을 켜려면:" + }, + "settings.notifications.modal.winStep1": { + "defaultMessage": "설정 열기" + }, + "settings.notifications.modal.winStep2": { + "defaultMessage": "시스템 > 알림으로 이동" + }, + "settings.notifications.modal.winStep3": { + "defaultMessage": "애플리케이션 목록에서 goose를 찾아 선택하세요" + }, + "settings.notifications.modal.winStep4": { + "defaultMessage": "알림을 켜고 원하는 대로 설정을 조정하세요." + }, + "settings.notifications.openSettings": { + "defaultMessage": "설정 열기" + }, + "settings.notifications.task.description": { + "defaultMessage": "창이 백그라운드에 있을 때 goose가 작업을 완료하면 알림을 보냅니다." + }, + "settings.notifications.task.title": { + "defaultMessage": "작업 완료 알림" + }, + "settings.notifications.title": { + "defaultMessage": "알림" + }, + "settings.preventSleep.description": { + "defaultMessage": "goose가 작업을 실행하는 동안 컴퓨터가 잠자기 상태로 전환되지 않도록 합니다. 화면은 잠길 수 있습니다." + }, + "settings.preventSleep.title": { + "defaultMessage": "잠자기 방지" + }, + "settings.theme.description": { + "defaultMessage": "goose의 모양과 느낌을 사용자화합니다." + }, + "settings.theme.title": { + "defaultMessage": "테마" + }, + "settings.updates.description": { + "defaultMessage": "goose가 최상의 상태로 실행되도록 업데이트를 확인하고 설치합니다." + }, + "settings.updates.title": { + "defaultMessage": "업데이트" + }, + "settings.version.title": { + "defaultMessage": "버전" + }, + "settingsView.tabApp": { + "defaultMessage": "앱" + }, + "settingsView.tabAuth": { + "defaultMessage": "인증" + }, + "settingsView.tabChat": { + "defaultMessage": "채팅" + }, + "settingsView.tabKeyboard": { + "defaultMessage": "키보드" + }, + "settingsView.tabLocalInference": { + "defaultMessage": "로컬 추론" + }, + "settingsView.tabModels": { + "defaultMessage": "모델" + }, + "settingsView.tabPrompts": { + "defaultMessage": "프롬프트" + }, + "settingsView.tabSession": { + "defaultMessage": "세션" + }, + "settingsView.title": { + "defaultMessage": "설정" + }, + "sharedSession.loading": { + "defaultMessage": "세션 세부정보 로드 중..." + }, + "sharedSession.title": { + "defaultMessage": "공유 세션" + }, + "sheet.close": { + "defaultMessage": "닫기" + }, + "shortcutRecorder.cancel": { + "defaultMessage": "취소" + }, + "shortcutRecorder.clickToRecord": { + "defaultMessage": "단축키를 기록하려면 클릭하세요..." + }, + "shortcutRecorder.conflictWarning": { + "defaultMessage": "{label}에서 이미 사용하고 있는 단축키입니다. 저장하면 이 작업에 다시 할당됩니다." + }, + "shortcutRecorder.pressShortcut": { + "defaultMessage": "단축키를 누르세요..." + }, + "shortcutRecorder.save": { + "defaultMessage": "저장" + }, + "skillsView.addSkill": { + "defaultMessage": "스킬 추가" + }, + "skillsView.adjustSearchTerms": { + "defaultMessage": "검색어를 조정해 보세요." + }, + "skillsView.comingSoon": { + "defaultMessage": "곧 출시 예정" + }, + "skillsView.errorLoadingSkills": { + "defaultMessage": "스킬 로딩 오류" + }, + "skillsView.noMatchingSkills": { + "defaultMessage": "일치하는 스킬을 찾을 수 없습니다." + }, + "skillsView.noSkillsDescription": { + "defaultMessage": "스킬은 ~/.config/agents/skills/, .goose/skills/ 또는 기타 지원되는 디렉터리의 SKILL.md 파일에서 로드됩니다." + }, + "skillsView.noSkillsInstalled": { + "defaultMessage": "설치된 스킬 없음" + }, + "skillsView.searchSkillsPlaceholder": { + "defaultMessage": "스킬 검색..." + }, + "skillsView.skillsDescription": { + "defaultMessage": "Goose 기능을 확장하는 설치된 스킬을 확인하세요. {shortcut}로 검색할 수 있습니다." + }, + "skillsView.skillsTitle": { + "defaultMessage": "스킬" + }, + "skillsView.tryAgain": { + "defaultMessage": "다시 시도" + }, + "spellcheckToggle.description": { + "defaultMessage": "채팅 입력창의 맞춤법을 검사합니다. 적용하려면 다시 시작해야 합니다." + }, + "spellcheckToggle.title": { + "defaultMessage": "맞춤법 검사 켜기" + }, + "standaloneAppView.failedToLoad": { + "defaultMessage": "앱을 로드하지 못했습니다." + }, + "standaloneAppView.initializing": { + "defaultMessage": "앱 초기화 중..." + }, + "standaloneAppView.missingParams": { + "defaultMessage": "필수 매개변수가 누락되었습니다." + }, + "stringUtils.configuredProvider": { + "defaultMessage": "{name} 제공업체가 구성되었습니다." + }, + "stringUtils.ollamaApp": { + "defaultMessage": "Ollama 앱" + }, + "stringUtils.ollamaNotConfiguredPrefix": { + "defaultMessage": "사용하려면" + }, + "stringUtils.ollamaNotConfiguredSuffix": { + "defaultMessage": "을(를) 컴퓨터에 설치하고 열어야 합니다. 또는 OLLAMA_HOST 값을 입력해야 합니다." + }, + "subRecipeEditor.addExisting": { + "defaultMessage": "기존 하위 레시피 추가" + }, + "subRecipeEditor.createNew": { + "defaultMessage": "새 하위 레시피 생성" + }, + "subRecipeEditor.deleteSubrecipe": { + "defaultMessage": "{name} 하위 레시피 삭제" + }, + "subRecipeEditor.description": { + "defaultMessage": "하위 레시피는 실행 중 도구로 호출할 수 있는 레시피입니다. 다단계 워크플로와 재사용 가능한 구성 요소를 만들 수 있습니다." + }, + "subRecipeEditor.duplicateName": { + "defaultMessage": "중복된 이름" + }, + "subRecipeEditor.duplicateNameMsg": { + "defaultMessage": "\"{name}\"이라는 하위 레시피가 이미 존재합니다. 고유한 이름을 사용하세요." + }, + "subRecipeEditor.editSubrecipe": { + "defaultMessage": "{name} 하위 레시피 편집" + }, + "subRecipeEditor.label": { + "defaultMessage": "하위 레시피" + }, + "subRecipeEditor.preconfiguredValues": { + "defaultMessage": "사전 구성된 값:" + }, + "subRecipeEditor.sequential": { + "defaultMessage": "순차 실행" + }, + "subRecipeModal.addTitle": { + "defaultMessage": "하위 레시피 추가" + }, + "subRecipeModal.apply": { + "defaultMessage": "적용" + }, + "subRecipeModal.browse": { + "defaultMessage": "찾아보기" + }, + "subRecipeModal.cancel": { + "defaultMessage": "취소" + }, + "subRecipeModal.closeModal": { + "defaultMessage": "하위 레시피 모달 닫기" + }, + "subRecipeModal.configureTitle": { + "defaultMessage": "하위 레시피 설정" + }, + "subRecipeModal.descriptionLabel": { + "defaultMessage": "설명" + }, + "subRecipeModal.descriptionPlaceholder": { + "defaultMessage": "이 하위 레시피의 기능에 대한 선택적 설명..." + }, + "subRecipeModal.invalidFile": { + "defaultMessage": "잘못된 파일" + }, + "subRecipeModal.invalidFileMsg": { + "defaultMessage": "YAML 파일(.yaml 또는 .yml)을 선택하세요." + }, + "subRecipeModal.nameHint": { + "defaultMessage": "도구 이름을 생성하는 데 사용되는 고유 식별자" + }, + "subRecipeModal.nameLabel": { + "defaultMessage": "이름" + }, + "subRecipeModal.namePlaceholder": { + "defaultMessage": "예: security_scan" + }, + "subRecipeModal.pathHint": { + "defaultMessage": "기존 레시피 파일을 찾아보거나 경로를 수동으로 입력하세요." + }, + "subRecipeModal.pathLabel": { + "defaultMessage": "경로" + }, + "subRecipeModal.pathPlaceholder": { + "defaultMessage": "예: ./subrecipes/security-analysis.yaml" + }, + "subRecipeModal.preconfiguredValues": { + "defaultMessage": "사전 구성된 값" + }, + "subRecipeModal.preconfiguredValuesHint": { + "defaultMessage": "항상 하위 레시피에 전달되는 선택적 매개변수 값" + }, + "subRecipeModal.sequentialHint": { + "defaultMessage": "(여러 하위 레시피 인스턴스의 순차 실행을 강제합니다)" + }, + "subRecipeModal.sequentialLabel": { + "defaultMessage": "반복 시 순차 실행" + }, + "subRecipeModal.subtitle": { + "defaultMessage": "레시피 실행 시 도구로 호출할 수 있는 서브레시피를 구성합니다." + }, + "switchModelModal.backToModelList": { + "defaultMessage": "모델 목록으로 돌아가기" + }, + "switchModelModal.cancel": { + "defaultMessage": "취소" + }, + "switchModelModal.checkProviderConfig": { + "defaultMessage": "설정 → 제공업체에서 제공업체 구성을 확인하세요." + }, + "switchModelModal.chooseModel": { + "defaultMessage": "모델을 선택하세요:" + }, + "switchModelModal.claudeAdaptive": { + "defaultMessage": "적응형 — Claude가 언제 얼마나 추론할지 결정합니다." + }, + "switchModelModal.claudeDisabled": { + "defaultMessage": "끔 — 확장 추론 없음" + }, + "switchModelModal.claudeEffortHigh": { + "defaultMessage": "높음 — 심층 추론 (기본값)" + }, + "switchModelModal.claudeEffortLow": { + "defaultMessage": "낮음 — 최소 추론, 가장 빠른 응답" + }, + "switchModelModal.claudeEffortMax": { + "defaultMessage": "최대 — 추론 깊이 제한 없음" + }, + "switchModelModal.claudeEffortMedium": { + "defaultMessage": "중간 — 보통 수준의 추론" + }, + "switchModelModal.claudeEnabled": { + "defaultMessage": "켬 — 추론에 고정 토큰 예산 사용" + }, + "switchModelModal.couldNotContactProvider": { + "defaultMessage": "제공업체에 연결할 수 없습니다." + }, + "switchModelModal.customModelName": { + "defaultMessage": "모델 이름" + }, + "switchModelModal.description": { + "defaultMessage": "대화에 사용할 제공업체 및 모델을 선택하세요." + }, + "switchModelModal.enterModelNotListed": { + "defaultMessage": "목록에 없는 모델을 입력하세요..." + }, + "switchModelModal.extendedThinking": { + "defaultMessage": "확장 추론" + }, + "switchModelModal.geminiOnly": { + "defaultMessage": "(Gemini 3 모델만 해당)" + }, + "switchModelModal.goToSettings": { + "defaultMessage": "설정으로 이동" + }, + "switchModelModal.loadingModels": { + "defaultMessage": "모델 로드 중…" + }, + "switchModelModal.localModelsDescription": { + "defaultMessage": "로컬 추론을 사용하려면 먼저 모델을 컴퓨터에 다운로드해야 합니다. 로컬 모델을 관리하려면 설정 → 모델로 이동하세요." + }, + "switchModelModal.localModelsTitle": { + "defaultMessage": "로컬 모델을 먼저 다운로드해야 합니다." + }, + "switchModelModal.providerPlaceholder": { + "defaultMessage": "제공업체 검색" + }, + "switchModelModal.quickStartGuide": { + "defaultMessage": "빠른 시작 가이드" + }, + "switchModelModal.recommended": { + "defaultMessage": "추천" + }, + "switchModelModal.selectEffortLevel": { + "defaultMessage": "추론 강도 선택" + }, + "switchModelModal.selectModel": { + "defaultMessage": "모델을 선택하세요." + }, + "switchModelModal.selectModelButton": { + "defaultMessage": "모델 선택" + }, + "switchModelModal.selectModelPlaceholder": { + "defaultMessage": "모델 선택 또는 검색" + }, + "switchModelModal.selectOrEnterModel": { + "defaultMessage": "모델을 선택하거나 입력하세요." + }, + "switchModelModal.selectProvider": { + "defaultMessage": "제공업체를 선택하세요." + }, + "switchModelModal.selectThinkingLevel": { + "defaultMessage": "추론 수준 선택" + }, + "switchModelModal.selectThinkingMode": { + "defaultMessage": "추론 모드 선택" + }, + "switchModelModal.thinkingBudget": { + "defaultMessage": "추론 예산(토큰)" + }, + "switchModelModal.thinkingEffort": { + "defaultMessage": "추론 강도" + }, + "switchModelModal.thinkingEffortOff": { + "defaultMessage": "끔 — 확장 추론 없음" + }, + "switchModelModal.thinkingLevel": { + "defaultMessage": "추론 수준" + }, + "switchModelModal.thinkingLevelHigh": { + "defaultMessage": "높음 — 더 깊은 추론, 지연 시간 증가" + }, + "switchModelModal.thinkingLevelLow": { + "defaultMessage": "낮음 — 지연 시간 감소, 가벼운 추론" + }, + "switchModelModal.title": { + "defaultMessage": "모델 전환" + }, + "switchModelModal.typeModelName": { + "defaultMessage": "여기에 모델 이름을 입력하세요." + }, + "switchModelModal.useOtherProvider": { + "defaultMessage": "다른 제공업체 이용" + }, + "telemetryConsentPrompt.description": { + "defaultMessage": "goose 개선을 위해 익명의 사용 데이터를 공유하시겠습니까? 대화, 코드 또는 개인 데이터는 수집하지 않습니다." + }, + "telemetryConsentPrompt.heading": { + "defaultMessage": "goose 개선에 도움을 주세요" + }, + "telemetryConsentPrompt.learnMore": { + "defaultMessage": "자세히 알아보기" + }, + "telemetryConsentPrompt.optIn": { + "defaultMessage": "예, 익명의 사용 데이터를 공유합니다" + }, + "telemetryConsentPrompt.optOut": { + "defaultMessage": "아니요" + }, + "telemetrySettings.configErrorTitle": { + "defaultMessage": "구성 오류" + }, + "telemetrySettings.description": { + "defaultMessage": "데이터 사용 방식을 관리합니다." + }, + "telemetrySettings.learnMore": { + "defaultMessage": "자세히 알아보기" + }, + "telemetrySettings.loadError": { + "defaultMessage": "원격 분석 설정을 로드하지 못했습니다." + }, + "telemetrySettings.title": { + "defaultMessage": "개인정보 보호" + }, + "telemetrySettings.toggleDescription": { + "defaultMessage": "익명 사용 통계를 공유하여 goose 개선에 도움을 주세요." + }, + "telemetrySettings.toggleLabel": { + "defaultMessage": "익명 사용 데이터" + }, + "telemetrySettings.updateError": { + "defaultMessage": "원격 분석 설정을 업데이트하지 못했습니다." + }, + "themeSelector.dark": { + "defaultMessage": "다크" + }, + "themeSelector.light": { + "defaultMessage": "라이트" + }, + "themeSelector.system": { + "defaultMessage": "시스템" + }, + "themeSelector.theme": { + "defaultMessage": "테마" + }, + "toolApprovalButtons.allowOnce": { + "defaultMessage": "한 번만 허용" + }, + "toolApprovalButtons.allowedOnce": { + "defaultMessage": "한 번 허용됨" + }, + "toolApprovalButtons.alwaysAllow": { + "defaultMessage": "항상 허용" + }, + "toolApprovalButtons.alwaysAllowed": { + "defaultMessage": "항상 허용됨" + }, + "toolApprovalButtons.cancelled": { + "defaultMessage": "취소됨" + }, + "toolApprovalButtons.denied": { + "defaultMessage": "거부됨" + }, + "toolApprovalButtons.deniedOnce": { + "defaultMessage": "한 번 거부됨" + }, + "toolApprovalButtons.deny": { + "defaultMessage": "거부" + }, + "toolCallStatusIndicator.toolStatus": { + "defaultMessage": "도구 상태: {status}" + }, + "toolCallWithResponse.activityCount": { + "defaultMessage": "활동({count})" + }, + "toolCallWithResponse.code": { + "defaultMessage": "코드" + }, + "toolCallWithResponse.loadingSpinner": { + "defaultMessage": "로딩 스피너" + }, + "toolCallWithResponse.logs": { + "defaultMessage": "로그" + }, + "toolCallWithResponse.mcpUiExperimental": { + "defaultMessage": "MCP UI는 실험적이며 언제든지 변경될 수 있습니다." + }, + "toolCallWithResponse.output": { + "defaultMessage": "출력" + }, + "toolCallWithResponse.toolDetails": { + "defaultMessage": "도구 세부정보" + }, + "toolCallWithResponse.toolResultAlt": { + "defaultMessage": "도구 결과" + }, + "toolCallWithResponse.viewSubagentSession": { + "defaultMessage": "하위 에이전트 세션 보기" + }, + "toolConfirmation.allowToolCallWithName": { + "defaultMessage": "{toolName}을 허용하시겠습니까?" + }, + "toolConfirmation.gooseWouldLikeToCallWithName": { + "defaultMessage": "goose가 {toolName}을 호출하려고 합니다. 허용하시겠습니까?" + }, + "tunnelSection.appStoreQrInstructions": { + "defaultMessage": "QR 코드를 iPhone 카메라로 스캔하여 App Store에서 goose 모바일 앱을 설치하세요." + }, + "tunnelSection.close": { + "defaultMessage": "닫기" + }, + "tunnelSection.connectionDetails": { + "defaultMessage": "연결 세부정보" + }, + "tunnelSection.downloadIosApp": { + "defaultMessage": "goose iOS 앱 다운로드" + }, + "tunnelSection.failedToLoadStatus": { + "defaultMessage": "터널 상태를 로드하지 못했습니다." + }, + "tunnelSection.failedToStartTunnel": { + "defaultMessage": "터널을 시작하지 못했습니다." + }, + "tunnelSection.failedToStopTunnel": { + "defaultMessage": "터널을 중지하지 못했습니다." + }, + "tunnelSection.getIosApp": { + "defaultMessage": "iOS 앱 받기" + }, + "tunnelSection.mobileApp": { + "defaultMessage": "모바일 앱" + }, + "tunnelSection.mobileAppConnection": { + "defaultMessage": "모바일 앱 연결" + }, + "tunnelSection.openInAppStore": { + "defaultMessage": "앱 스토어에서 열기" + }, + "tunnelSection.or": { + "defaultMessage": "또는" + }, + "tunnelSection.previewDescription": { + "defaultMessage": "보안 터널링을 사용해 모바일 기기에서 goose에 원격으로 접속할 수 있습니다." + }, + "tunnelSection.previewFeature": { + "defaultMessage": "미리 보기 기능:" + }, + "tunnelSection.qrCodeInstructions": { + "defaultMessage": "QR 코드를 goose 모바일 앱으로 스캔하세요. 이 코드는 개인 액세스용이므로 다른 사람과 공유하지 마세요." + }, + "tunnelSection.retry": { + "defaultMessage": "다시 시도" + }, + "tunnelSection.scanQrCode": { + "defaultMessage": "QR 코드 스캔" + }, + "tunnelSection.secretKey": { + "defaultMessage": "비밀 키" + }, + "tunnelSection.showQrCode": { + "defaultMessage": "QR 코드 표시" + }, + "tunnelSection.startTunnel": { + "defaultMessage": "터널 시작" + }, + "tunnelSection.starting": { + "defaultMessage": "시작 중..." + }, + "tunnelSection.statusDisabled": { + "defaultMessage": "터널이 비활성화되었습니다." + }, + "tunnelSection.statusError": { + "defaultMessage": "터널에 오류가 발생했습니다." + }, + "tunnelSection.statusIdle": { + "defaultMessage": "터널이 실행 중이 아닙니다." + }, + "tunnelSection.statusRunning": { + "defaultMessage": "터널이 활성화되었습니다" + }, + "tunnelSection.statusStarting": { + "defaultMessage": "터널 시작 중..." + }, + "tunnelSection.stopTunnel": { + "defaultMessage": "터널 중지" + }, + "tunnelSection.tunnelStatus": { + "defaultMessage": "터널 상태" + }, + "tunnelSection.tunnelUrl": { + "defaultMessage": "터널 URL" + }, + "tunnelSection.url": { + "defaultMessage": "URL:" + }, + "updateSection.autoDownload": { + "defaultMessage": "업데이트는 백그라운드에서 자동으로 다운로드됩니다." + }, + "updateSection.autoInstallNote": { + "defaultMessage": "앱을 종료하면 업데이트가 자동으로 설치됩니다." + }, + "updateSection.checkForUpdates": { + "defaultMessage": "업데이트 확인" + }, + "updateSection.checking": { + "defaultMessage": "업데이트 확인 중..." + }, + "updateSection.currentVersion": { + "defaultMessage": "현재 버전" + }, + "updateSection.downloadReady": { + "defaultMessage": "업데이트가 다운로드되었으며 설치 준비가 완료되었습니다!" + }, + "updateSection.downloadingProgress": { + "defaultMessage": "업데이트 다운로드 중... {percent}%" + }, + "updateSection.downloadingUpdate": { + "defaultMessage": "업데이트 다운로드 중..." + }, + "updateSection.installAndRestart": { + "defaultMessage": "설치 및 다시 시작" + }, + "updateSection.installNowHint": { + "defaultMessage": "또는 지금 업데이트하려면 \"설치 및 다시 시작\"을 클릭하세요." + }, + "updateSection.latestVersion": { + "defaultMessage": "최신 버전을 사용 중입니다!" + }, + "updateSection.loading": { + "defaultMessage": "로드 중..." + }, + "updateSection.manualInstallNote": { + "defaultMessage": "다운로드한 후에는 업데이트를 수동으로 설치해야 합니다." + }, + "updateSection.manualInstallRequired": { + "defaultMessage": "이 업데이트 방법에는 수동 설치가 필요합니다." + }, + "updateSection.readyInstallAuto": { + "defaultMessage": "✓ 업데이트가 준비되었습니다! Goose 종료 시 설치됩니다." + }, + "updateSection.readyInstallManual": { + "defaultMessage": "✓ 업데이트가 준비되었습니다! 설치 지침을 보려면 \"설치 및 다시 시작\"을 클릭하세요." + }, + "updateSection.upToDate": { + "defaultMessage": "최신 상태" + }, + "updateSection.updateAvailable": { + "defaultMessage": "업데이트가 있습니다!" + }, + "updateSection.versionAvailable": { + "defaultMessage": "→ {version} 사용 가능" + }, + "updateSection.versionIsAvailable": { + "defaultMessage": "버전 {version}을 사용할 수 있습니다." + }, + "userMessage.cancel": { + "defaultMessage": "취소" + }, + "userMessage.cancelAriaLabel": { + "defaultMessage": "편집 취소" + }, + "userMessage.editAriaLabel": { + "defaultMessage": "메시지 내용 편집" + }, + "userMessage.editButton": { + "defaultMessage": "편집" + }, + "userMessage.editInPlace": { + "defaultMessage": "바로 편집" + }, + "userMessage.editInPlaceAriaLabel": { + "defaultMessage": "메시지를 바로 편집" + }, + "userMessage.editInPlaceDescription": { + "defaultMessage": "바로 편집은 이 세션을 업데이트합니다 • 세션 포크는 새 세션을 만듭니다" + }, + "userMessage.editInPlaceTitle": { + "defaultMessage": "이 세션의 메시지 업데이트" + }, + "userMessage.editMessageAriaLabel": { + "defaultMessage": "메시지 수정: {preview}" + }, + "userMessage.editMessageTitle": { + "defaultMessage": "메시지 편집" + }, + "userMessage.editPlaceholder": { + "defaultMessage": "메시지를 편집하세요..." + }, + "userMessage.emptyError": { + "defaultMessage": "메시지는 비워둘 수 없습니다." + }, + "userMessage.forkSession": { + "defaultMessage": "세션 포크" + }, + "userMessage.forkSessionAriaLabel": { + "defaultMessage": "편집한 메시지로 세션 포크" + }, + "userMessage.forkSessionTitle": { + "defaultMessage": "편집한 메시지로 새 세션 만들기" + } +} diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index e1b07235918d..8a1e94ec49cf 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -11,6 +11,9 @@ "announcementModal.gotIt": { "defaultMessage": "Понятно!" }, + "appLayout.collapseNavigation": { + "defaultMessage": "Collapse navigation" + }, "appLayout.openNavigation": { "defaultMessage": "Открыть навигацию" }, @@ -992,6 +995,9 @@ "elicitationRequest.submit": { "defaultMessage": "Отправить" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "Информация отправлена" }, @@ -1847,9 +1853,15 @@ "localInferenceSettings.description": { "defaultMessage": "Скачивайте и управляйте локальными LLM-моделями для инференса без API-ключей. Ищите любые GGUF-модели на HuggingFace или используйте подборку ниже." }, + "localInferenceSettings.dismiss": { + "defaultMessage": "Скрыть" + }, "localInferenceSettings.download": { "defaultMessage": "Скачать" }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "Скачивание отменено" + }, "localInferenceSettings.downloadFailed": { "defaultMessage": "Скачивание не удалось" }, @@ -1883,6 +1895,9 @@ "localInferenceSettings.remaining": { "defaultMessage": "осталось {time}" }, + "localInferenceSettings.retry": { + "defaultMessage": "Повторить" + }, "localInferenceSettings.showAllFeatured": { "defaultMessage": "Показать все рекомендуемые (ещё {count})" }, @@ -2447,9 +2462,6 @@ "navigationPanel.chats": { "defaultMessage": "Чаты" }, - "navigationPanel.collapseSidebar": { - "defaultMessage": "Свернуть боковую панель" - }, "navigationPanel.noChats": { "defaultMessage": "Нет недавних чатов" }, @@ -3737,6 +3749,72 @@ "securityToggle.warpNotice": { "defaultMessage": "Обнаружение внедрения команд работает лучше всего при подключении к WARP (требуется для доступа к сервису классификации)." }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "Session actions" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "Cancel" + }, + "sessionActionsHeader.close": { + "defaultMessage": "Close" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "Session JSON copied" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "Text copied" + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "Copy JSON" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "Copy text" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "Failed to duplicate session: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "Duplicate session" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "Session duplicated" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "Text value" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "Failed to load session JSON: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "Session JSON" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "Loading JSON..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "Failed to rename session: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "Enter session name" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "Rename session" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "Rename Session" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "Session renamed" + }, + "sessionActionsHeader.save": { + "defaultMessage": "Save" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "Saving..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "View session JSON" + }, "sessionHistory.cancel": { "defaultMessage": "Отмена" }, @@ -4037,6 +4115,39 @@ "settings.help.title": { "defaultMessage": "Помощь и отзывы" }, + "settings.language.description": { + "defaultMessage": "Выберите язык интерфейса goose" + }, + "settings.language.english": { + "defaultMessage": "Английский" + }, + "settings.language.hindi": { + "defaultMessage": "Хинди" + }, + "settings.language.japanese": { + "defaultMessage": "Японский" + }, + "settings.language.korean": { + "defaultMessage": "Корейский" + }, + "settings.language.russian": { + "defaultMessage": "Русский" + }, + "settings.language.spanish": { + "defaultMessage": "Испанский" + }, + "settings.language.systemDefault": { + "defaultMessage": "Системный язык" + }, + "settings.language.title": { + "defaultMessage": "Язык" + }, + "settings.language.turkish": { + "defaultMessage": "Турецкий" + }, + "settings.language.zhCN": { + "defaultMessage": "Китайский (упрощенный)" + }, "settings.menuBarIcon.description": { "defaultMessage": "Показывать goose в строке меню" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 8eec612221d2..ce2039fea828 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -11,6 +11,9 @@ "announcementModal.gotIt": { "defaultMessage": "Anladım!" }, + "appLayout.collapseNavigation": { + "defaultMessage": "Collapse navigation" + }, "appLayout.openNavigation": { "defaultMessage": "Gezinmeyi aç" }, @@ -992,6 +995,9 @@ "elicitationRequest.submit": { "defaultMessage": "Gönder" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "Bilgiler gönderildi" }, @@ -1847,9 +1853,15 @@ "localInferenceSettings.description": { "defaultMessage": "API anahtarları olmadan çıkarım yapmak için yerel LLM modellerini indirin ve yönetin. Herhangi bir GGUF modeli için HuggingFace'yi arayın veya aşağıdaki öne çıkan seçimleri kullanın." }, + "localInferenceSettings.dismiss": { + "defaultMessage": "Kapat" + }, "localInferenceSettings.download": { "defaultMessage": "İndir" }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "İndirme iptal edildi" + }, "localInferenceSettings.downloadFailed": { "defaultMessage": "İndirme başarısız oldu" }, @@ -1883,6 +1895,9 @@ "localInferenceSettings.remaining": { "defaultMessage": "{time} kaldı" }, + "localInferenceSettings.retry": { + "defaultMessage": "Yeniden dene" + }, "localInferenceSettings.showAllFeatured": { "defaultMessage": "Tüm öne çıkanları göster ({count} daha fazlası)" }, @@ -2447,9 +2462,6 @@ "navigationPanel.chats": { "defaultMessage": "Sohbetler" }, - "navigationPanel.collapseSidebar": { - "defaultMessage": "Kenar çubuğunu daralt" - }, "navigationPanel.noChats": { "defaultMessage": "Son sohbet yok" }, @@ -3737,6 +3749,72 @@ "securityToggle.warpNotice": { "defaultMessage": "Komut enjeksiyonu algılama, WARP bağlantısı olduğunda en iyi şekilde çalışır (sınıflandırma hizmetine erişmek için gereklidir)." }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "Session actions" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "Cancel" + }, + "sessionActionsHeader.close": { + "defaultMessage": "Close" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "Session JSON copied" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "Text copied" + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "Copy JSON" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "Copy text" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "Failed to duplicate session: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "Duplicate session" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "Session duplicated" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "Text value" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "Failed to load session JSON: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "Session JSON" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "Loading JSON..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "Failed to rename session: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "Enter session name" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "Rename session" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "Rename Session" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "Session renamed" + }, + "sessionActionsHeader.save": { + "defaultMessage": "Save" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "Saving..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "View session JSON" + }, "sessionHistory.cancel": { "defaultMessage": "İptal" }, @@ -4037,6 +4115,39 @@ "settings.help.title": { "defaultMessage": "Yardım ve geri bildirim" }, + "settings.language.description": { + "defaultMessage": "goose için görüntüleme dilini seçin" + }, + "settings.language.english": { + "defaultMessage": "İngilizce" + }, + "settings.language.hindi": { + "defaultMessage": "Hintçe" + }, + "settings.language.japanese": { + "defaultMessage": "Japonca" + }, + "settings.language.korean": { + "defaultMessage": "Korece" + }, + "settings.language.russian": { + "defaultMessage": "Rusça" + }, + "settings.language.spanish": { + "defaultMessage": "İspanyolca" + }, + "settings.language.systemDefault": { + "defaultMessage": "Sistem varsayılanı" + }, + "settings.language.title": { + "defaultMessage": "Dil" + }, + "settings.language.turkish": { + "defaultMessage": "Türkçe" + }, + "settings.language.zhCN": { + "defaultMessage": "Çince (Basitleştirilmiş)" + }, "settings.menuBarIcon.description": { "defaultMessage": "Menü çubuğunda goose'yi göster" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 6e808e23cf74..3d7c878cd9ae 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -11,6 +11,9 @@ "announcementModal.gotIt": { "defaultMessage": "知道了!" }, + "appLayout.collapseNavigation": { + "defaultMessage": "Collapse navigation" + }, "appLayout.openNavigation": { "defaultMessage": "展开导航" }, @@ -992,6 +995,9 @@ "elicitationRequest.submit": { "defaultMessage": "提交" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "信息已提交" }, @@ -1847,9 +1853,15 @@ "localInferenceSettings.description": { "defaultMessage": "下载和管理用于本地推理的大模型,无需 API 密钥。在 HuggingFace 搜索任意 GGUF 模型,或使用下方推荐。" }, + "localInferenceSettings.dismiss": { + "defaultMessage": "关闭" + }, "localInferenceSettings.download": { "defaultMessage": "下载" }, + "localInferenceSettings.downloadCancelled": { + "defaultMessage": "下载已取消" + }, "localInferenceSettings.downloadFailed": { "defaultMessage": "下载失败" }, @@ -1883,6 +1895,9 @@ "localInferenceSettings.remaining": { "defaultMessage": "剩余 {time}" }, + "localInferenceSettings.retry": { + "defaultMessage": "重试" + }, "localInferenceSettings.showAllFeatured": { "defaultMessage": "显示全部推荐(还有 {count} 个)" }, @@ -2447,9 +2462,6 @@ "navigationPanel.chats": { "defaultMessage": "聊天" }, - "navigationPanel.collapseSidebar": { - "defaultMessage": "折叠侧边栏" - }, "navigationPanel.noChats": { "defaultMessage": "没有最近的聊天" }, @@ -3737,6 +3749,72 @@ "securityToggle.warpNotice": { "defaultMessage": "连接到 WARP 时,命令注入检测效果最佳(访问分类服务需要 WARP)。" }, + "sessionActionsHeader.actionsLabel": { + "defaultMessage": "Session actions" + }, + "sessionActionsHeader.cancel": { + "defaultMessage": "Cancel" + }, + "sessionActionsHeader.close": { + "defaultMessage": "Close" + }, + "sessionActionsHeader.copiedJson": { + "defaultMessage": "Session JSON copied" + }, + "sessionActionsHeader.copiedText": { + "defaultMessage": "Text copied" + }, + "sessionActionsHeader.copyJson": { + "defaultMessage": "Copy JSON" + }, + "sessionActionsHeader.copyText": { + "defaultMessage": "Copy text" + }, + "sessionActionsHeader.duplicateFailed": { + "defaultMessage": "Failed to duplicate session: {error}" + }, + "sessionActionsHeader.duplicateSession": { + "defaultMessage": "Duplicate session" + }, + "sessionActionsHeader.duplicated": { + "defaultMessage": "Session duplicated" + }, + "sessionActionsHeader.fullTextTitle": { + "defaultMessage": "Text value" + }, + "sessionActionsHeader.jsonFailed": { + "defaultMessage": "Failed to load session JSON: {error}" + }, + "sessionActionsHeader.jsonTitle": { + "defaultMessage": "Session JSON" + }, + "sessionActionsHeader.loadingJson": { + "defaultMessage": "Loading JSON..." + }, + "sessionActionsHeader.renameFailed": { + "defaultMessage": "Failed to rename session: {error}" + }, + "sessionActionsHeader.renamePlaceholder": { + "defaultMessage": "Enter session name" + }, + "sessionActionsHeader.renameSession": { + "defaultMessage": "Rename session" + }, + "sessionActionsHeader.renameTitle": { + "defaultMessage": "Rename Session" + }, + "sessionActionsHeader.renamed": { + "defaultMessage": "Session renamed" + }, + "sessionActionsHeader.save": { + "defaultMessage": "Save" + }, + "sessionActionsHeader.saving": { + "defaultMessage": "Saving..." + }, + "sessionActionsHeader.viewJson": { + "defaultMessage": "View session JSON" + }, "sessionHistory.cancel": { "defaultMessage": "取消" }, @@ -3896,6 +3974,9 @@ "sessions.close": { "defaultMessage": "关闭" }, + "sessions.close": { + "defaultMessage": "关闭" + }, "sessions.delete.message": { "defaultMessage": "确定要删除会话“{name}”吗?此操作无法撤销。" }, @@ -4037,6 +4118,39 @@ "settings.help.title": { "defaultMessage": "帮助与反馈" }, + "settings.language.description": { + "defaultMessage": "选择 goose 的显示语言" + }, + "settings.language.english": { + "defaultMessage": "英语" + }, + "settings.language.hindi": { + "defaultMessage": "印地语" + }, + "settings.language.japanese": { + "defaultMessage": "日语" + }, + "settings.language.korean": { + "defaultMessage": "韩语" + }, + "settings.language.russian": { + "defaultMessage": "俄语" + }, + "settings.language.spanish": { + "defaultMessage": "西班牙语" + }, + "settings.language.systemDefault": { + "defaultMessage": "系统默认" + }, + "settings.language.title": { + "defaultMessage": "语言" + }, + "settings.language.turkish": { + "defaultMessage": "土耳其语" + }, + "settings.language.zhCN": { + "defaultMessage": "简体中文" + }, "settings.menuBarIcon.description": { "defaultMessage": "在菜单栏显示 ApeMind Agent" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 396e36de1ada..cd6aa07a38d7 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -131,13 +131,7 @@ const MENU_TRANSLATIONS_ZH_CN: Record = { }; function detectMenuLocale(): string { - const explicit = process.env.GOOSE_LOCALE; - if (explicit) return explicit; - try { - return app.getSystemLocale() || 'en'; - } catch { - return 'en'; - } + return getConfiguredGooseLocale() ?? 'en'; } function menuT(label: string): string { @@ -175,6 +169,21 @@ function translateMenuLabels(items: MenuItem[]): void { // Settings management const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup'); +const validLanguageSettings = new Set([ + 'system', + 'en', + 'es', + 'hi', + 'ja', + 'ko', + 'ru', + 'tr', + 'zh-CN', +]); + +function isValidLanguageSetting(value: unknown): value is Settings['language'] { + return typeof value === 'string' && validLanguageSettings.has(value as Settings['language']); +} async function seedDefaultRecipes(): Promise { const sourceRoot = app.isPackaged @@ -266,6 +275,23 @@ function updateSettings(modifier: (settings: Settings) => void): void { fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); } +function getConfiguredGooseLocale(): string | undefined { + const language = getSettings().language; + if (isValidLanguageSetting(language) && language !== 'system') { + return language; + } + + if (process.env.GOOSE_LOCALE) { + return process.env.GOOSE_LOCALE; + } + + try { + return app.isReady() ? app.getSystemLocale() || undefined : undefined; + } catch { + return undefined; + } +} + function listGitWorktreeDirs(dir: string): Promise { return new Promise((resolve) => { if (!dir?.trim()) { @@ -369,20 +395,8 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call } }); -// Fill in GOOSE_LOCALE from the OS region locale once Electron is ready. -// Kept separate from the initial appConfig assignment above because -// app.getSystemLocale() is only available after the app.ready event fires. app.whenReady().then(() => { - if (!appConfig.GOOSE_LOCALE) { - try { - const sysLocale = app.getSystemLocale(); - if (sysLocale) { - appConfig.GOOSE_LOCALE = sysLocale; - } - } catch { - // Locale detection is best-effort; renderer will fall back to navigator.language. - } - } + appConfig.GOOSE_LOCALE = getConfiguredGooseLocale(); }); // Main-process net.fetch: pin to the exact cert goosed generated. @@ -436,6 +450,7 @@ if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { // Apply single instance lock on Windows and Linux where it's needed for deep links // macOS uses the 'open-url' event instead let gotTheLock = true; +let openUrlHandledLaunch = false; if (process.platform !== 'darwin') { gotTheLock = app.requestSingleInstanceLock(); @@ -470,7 +485,12 @@ if (process.platform !== 'darwin') { app.whenReady().then(async () => { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; - await createChat(app, { dir: openDir || undefined }); + const prompt = parsedUrl.searchParams.get('prompt') || undefined; + await createChat(app, { + dir: openDir || undefined, + initialMessage: prompt, + initialMessageNoAutoSubmit: prompt !== undefined, + }); }); return; } @@ -485,7 +505,7 @@ if (process.platform !== 'darwin') { } // For non-bot URLs, continue with normal handling - handleProtocolUrl(protocolUrl); + handleProtocolUrl(protocolUrl, parsedUrl); } // Only focus existing windows for non-bot/recipe URLs @@ -503,14 +523,30 @@ if (process.platform !== 'darwin') { // Handle protocol URLs on Windows and Linux startup const protocolUrl = process.argv.find((arg) => arg.startsWith('goose://')); if (protocolUrl) { - app.whenReady().then(() => { - handleProtocolUrl(protocolUrl); + app.whenReady().then(async () => { + let parsedUrl: URL; + try { + parsedUrl = new URL(protocolUrl); + } catch (error) { + log.warn('[Main] Ignoring invalid startup protocol URL:', errorMessage(error)); + return; + } + + openUrlHandledLaunch = true; + try { + await handleProtocolUrl(protocolUrl, parsedUrl); + } catch (error) { + log.error('[Main] Failed to handle startup protocol URL:', errorMessage(error)); + if (BrowserWindow.getAllWindows().length === 0) { + const { dirPath } = parseArgs(); + await createNewWindow(app, dirPath); + } + } }); } } const pendingDeepLinks = new Map(); // windowId -> deep link URL -let openUrlHandledLaunch = false; function getResumeSessionId(parsedUrl: URL): string | null { try { @@ -532,15 +568,19 @@ async function createResumeChatWindow(parsedUrl: URL, dir?: string): Promise 0 ? recentDirs[0] : null; if (parsedUrl.hostname === 'new-session') { - await createChat(app, { dir: openDir || undefined }); + const prompt = parsedUrl.searchParams.get('prompt') || undefined; + await createChat(app, { + dir: openDir || undefined, + initialMessage: prompt, + initialMessageNoAutoSubmit: prompt !== undefined, + }); return; } else if (parsedUrl.hostname === 'resume') { await createResumeChatWindow(parsedUrl, openDir || undefined); @@ -614,7 +654,12 @@ app.on('open-url', async (_event, url) => { if (parsedUrl.hostname === 'new-session') { log.info('[Main] Detected new-session URL, creating new chat window'); openUrlHandledLaunch = true; - await createChat(app, { dir: openDir || undefined }); + const prompt = parsedUrl.searchParams.get('prompt') || undefined; + await createChat(app, { + dir: openDir || undefined, + initialMessage: prompt, + initialMessageNoAutoSubmit: prompt !== undefined, + }); return; } @@ -829,12 +874,63 @@ const windowMap = new Map(); const goosedClients = new Map(); const appWindows = new Map(); +interface GoosedLease { + client: Client; + cleanup: () => Promise; + windowIds: Set; + cleanedUp: boolean; +} + +const goosedLeasesByWindowId = new Map(); + +const cleanupGoosedLease = async (lease: GoosedLease) => { + if (lease.cleanedUp) { + return; + } + + lease.cleanedUp = true; + for (const windowId of lease.windowIds) { + goosedLeasesByWindowId.delete(windowId); + goosedClients.delete(windowId); + } + lease.windowIds.clear(); + + try { + await lease.cleanup(); + } catch (error) { + log.error('Failed to cleanup goosed server:', error); + } +}; + +const attachWindowToGoosedLease = (windowId: number, lease: GoosedLease) => { + lease.windowIds.add(windowId); + goosedLeasesByWindowId.set(windowId, lease); + goosedClients.set(windowId, lease.client); +}; + +const releaseWindowGoosedLease = async (windowId: number) => { + const lease = goosedLeasesByWindowId.get(windowId); + goosedLeasesByWindowId.delete(windowId); + goosedClients.delete(windowId); + + if (!lease) { + return; + } + + lease.windowIds.delete(windowId); + if (lease.windowIds.size === 0) { + await cleanupGoosedLease(lease); + } +}; + const windowPowerSaveBlockers = new Map(); // windowId -> blockerId // Track pending initial messages per window const pendingInitialMessages = new Map(); // windowId -> initialMessage +const pendingInitialMessageNoAutoSubmit = new Set(); // windowIds whose initialMessage should NOT auto-submit interface CreateChatOptions { initialMessage?: string; + initialMessageNoAutoSubmit?: boolean; dir?: string; resumeSessionId?: string; viewType?: string; @@ -847,6 +943,7 @@ interface CreateChatOptions { const createChat = async (app: App, options: CreateChatOptions = {}) => { const { initialMessage, + initialMessageNoAutoSubmit, dir, resumeSessionId, viewType, @@ -898,11 +995,6 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { pinnedCertFingerprint = goosedResult.certFingerprint; } - app.on('will-quit', async () => { - log.info('App quitting, terminating goosed server'); - await goosedResult.cleanup(); - }); - const { baseUrl, workingDir, @@ -944,6 +1036,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { additionalArguments: [ JSON.stringify({ ...appConfig, + GOOSE_LOCALE: getConfiguredGooseLocale(), GOOSE_API_HOST: baseUrl, GOOSE_WORKING_DIR: workingDir, REQUEST_DIR: dir, @@ -984,7 +1077,16 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { }, }) ); - goosedClients.set(mainWindow.id, goosedClient); + const goosedLease: GoosedLease = { + client: goosedClient, + cleanup: goosedResult.cleanup, + windowIds: new Set(), + cleanedUp: false, + }; + attachWindowToGoosedLease(mainWindow.id, goosedLease); + mainWindow.once('closed', () => { + void releaseWindowGoosedLease(mainWindow.id); + }); const serverReady = await checkServerStatus(goosedClient, errorLog, { onEvent: recordStartupEvent, @@ -1198,6 +1300,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { // If we have an initial message, store it to send after React is ready if (initialMessage) { pendingInitialMessages.set(mainWindow.id, initialMessage); + if (initialMessageNoAutoSubmit) { + pendingInitialMessageNoAutoSubmit.add(mainWindow.id); + } } // Set up local keyboard shortcuts that only work when the window is focused @@ -1284,7 +1389,12 @@ const createLauncher = () => { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, - additionalArguments: [JSON.stringify(appConfig)], + additionalArguments: [ + JSON.stringify({ + ...appConfig, + GOOSE_LOCALE: getConfiguredGooseLocale(), + }), + ], partition: 'persist:goose', }, skipTaskbar: true, @@ -1603,9 +1713,11 @@ ipcMain.on('react-ready', (event) => { // Send any pending initial message for this window if (windowId && pendingInitialMessages.has(windowId)) { const initialMessage = pendingInitialMessages.get(windowId)!; + const noAutoSubmit = pendingInitialMessageNoAutoSubmit.has(windowId); log.info('Sending pending initial message to window:', initialMessage); - window.webContents.send('set-initial-message', initialMessage); + window.webContents.send('set-initial-message', initialMessage, { noAutoSubmit }); pendingInitialMessages.delete(windowId); + pendingInitialMessageNoAutoSubmit.delete(windowId); } if (windowId && pendingDeepLinks.has(windowId) && window) { @@ -1674,6 +1786,7 @@ const validSettingKeys: Set = new Set([ 'keyboardShortcuts', 'theme', 'useSystemTheme', + 'language', 'responseStyle', 'showPricing', 'sessionSharing', @@ -1687,11 +1800,20 @@ ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => { return; } + if (key === 'language' && !isValidLanguageSetting(value)) { + console.error(`Invalid language setting rejected: ${String(value)}`); + return; + } + const settings = getSettings(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (settings as any)[key] = value; fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); + if (key === 'language') { + appConfig.GOOSE_LOCALE = getConfiguredGooseLocale(); + } + // Re-register shortcuts if keyboard shortcuts changed if (key === 'keyboardShortcuts') { registerGlobalShortcuts(); @@ -2345,7 +2467,7 @@ async function appMain() { accelerator: shortcuts.newChat, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); - if (focusedWindow) focusedWindow.webContents.send('new-chat'); + if (focusedWindow) focusedWindow.webContents.send('set-view', ''); }, }) ); @@ -2706,6 +2828,10 @@ async function appMain() { event.returnValue = app.getVersion(); }); + ipcMain.on('get-app-locale', (event) => { + event.returnValue = getConfiguredGooseLocale(); + }); + ipcMain.handle('open-directory-in-explorer', async (_event, path: string) => { try { return !!(await shell.openPath(path)); @@ -2723,9 +2849,9 @@ async function appMain() { } const launchingWindowId = launchingWindow.id; - const launchingClient = goosedClients.get(launchingWindowId); - if (!launchingClient) { - throw new Error('No client found for launching window'); + const launchingLease = goosedLeasesByWindowId.get(launchingWindowId); + if (!launchingLease) { + throw new Error('No goosed lease found for launching window'); } const appWindow = new BrowserWindow({ @@ -2743,11 +2869,11 @@ async function appMain() { }, }); - goosedClients.set(appWindow.id, launchingClient); + attachWindowToGoosedLease(appWindow.id, launchingLease); appWindows.set(gooseApp.name, appWindow); - appWindow.on('close', () => { - goosedClients.delete(appWindow.id); + appWindow.on('closed', () => { + void releaseWindowGoosedLease(appWindow.id); appWindows.delete(gooseApp.name); }); @@ -2853,6 +2979,12 @@ app.on('will-quit', async () => { // Stop the mesh child process if we spawned one. mesh.cleanup(); + const goosedLeases = new Set(goosedLeasesByWindowId.values()); + if (goosedLeases.size > 0) { + log.info(`App quitting, terminating ${goosedLeases.size} goosed server(s)`); + await Promise.all([...goosedLeases].map(cleanupGoosedLease)); + } + for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { try { powerSaveBlocker.stop(blockerId); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 364325908079..eca1e0ea0fe3 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -352,9 +352,17 @@ const electronAPI: ElectronAPI = { listGitWorktreeDirs: (dir: string) => ipcRenderer.invoke('list-git-worktree-dirs', dir), }; +function getAppLocale(): unknown { + try { + return ipcRenderer.sendSync('get-app-locale') ?? config.GOOSE_LOCALE; + } catch { + return config.GOOSE_LOCALE; + } +} + const appConfigAPI: AppConfigAPI = { - get: (key: string) => config[key], - getAll: () => config, + get: (key: string) => (key === 'GOOSE_LOCALE' ? getAppLocale() : config[key]), + getAll: () => ({ ...config, GOOSE_LOCALE: getAppLocale() }), }; // Expose the APIs diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index ac787bc6b6c8..22269e964dcf 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,4 +1,5 @@ import { Session, startAgent, ExtensionConfig } from './api'; +import { DEFAULT_CHAT_TITLE } from './contexts/ChatContext'; import type { setViewType } from './hooks/useNavigation'; import { getExtensionConfigsWithOverrides, @@ -9,11 +10,21 @@ import type { FixedExtensionEntry } from './components/ConfigContext'; import { AppEvents } from './constants/events'; import { decodeRecipe, Recipe } from './recipe'; -export function shouldShowNewChatTitle(session: Session): boolean { - if (session.recipe) { - return false; +export function getSessionDisplayName(session: Session): string { + if (session.user_set_name) { + return session.name; + } + if (session.recipe?.title) { + return session.recipe.title; + } + if (shouldShowNewChatTitle(session)) { + return DEFAULT_CHAT_TITLE; } - return !session.user_set_name && session.message_count === 0; + return session.name; +} + +export function shouldShowNewChatTitle(session: Session): boolean { + return !session.user_set_name && session.message_count === 0 && !session.recipe?.title; } export function resumeSession(session: Session, setView: setViewType) { diff --git a/ui/desktop/src/test/setup.ts b/ui/desktop/src/test/setup.ts index bed778d4855b..23ff58cada37 100644 --- a/ui/desktop/src/test/setup.ts +++ b/ui/desktop/src/test/setup.ts @@ -67,6 +67,7 @@ const mockSettings: Record = { }, theme: 'light', useSystemTheme: true, + language: 'system', responseStyle: 'concise', showPricing: true, sessionSharing: { @@ -86,6 +87,7 @@ Object.defineProperty(window, 'electron', { mockSettings[key] = value; return Promise.resolve(); }), + reloadApp: vi.fn(), showMessageBox: vi.fn(() => Promise.resolve({ response: 0 })), getIsFullScreen: vi.fn(() => Promise.resolve(false)), on: vi.fn(), diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 5f50fc6e0790..0f23ac8e193f 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -28,6 +28,8 @@ export interface SessionSharingConfig { baseUrl: string; } +export type LanguageSetting = 'system' | 'en' | 'es' | 'hi' | 'ja' | 'ko' | 'ru' | 'tr' | 'zh-CN'; + export interface Settings { // Desktop app settings showMenuBarIcon: boolean; @@ -42,6 +44,7 @@ export interface Settings { // UI preferences (migrated from localStorage) theme: 'dark' | 'light'; useSystemTheme: boolean; + language: LanguageSetting; responseStyle: string; showPricing: boolean; sessionSharing: SessionSharingConfig; @@ -81,6 +84,7 @@ export const defaultSettings: Settings = { // UI preferences theme: 'light', useSystemTheme: true, + language: 'system', responseStyle: 'concise', showPricing: true, sessionSharing: { diff --git a/ui/desktop/src/vite-env.d.ts b/ui/desktop/src/vite-env.d.ts index 168573f67671..d7c35b6a5252 100644 --- a/ui/desktop/src/vite-env.d.ts +++ b/ui/desktop/src/vite-env.d.ts @@ -61,7 +61,11 @@ declare global { responseStyleChanged: CustomEvent; 'session-created': CustomEvent<{ session?: import('./api').Session }>; 'session-deleted': CustomEvent<{ sessionId: string }>; - 'session-renamed': CustomEvent<{ sessionId: string; newName: string }>; + 'session-renamed': CustomEvent<{ + sessionId: string; + newName: string; + userInitiated?: boolean; + }>; } } diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 72a22590f7df..c9a117b207fd 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -41,7 +41,6 @@ import type { DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, - ElicitationRespondRequest_unstable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, @@ -102,6 +101,7 @@ import type { SetSessionSystemPromptRequest_unstable, SteerSessionRequest_unstable, SteerSessionResponse_unstable, + TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, @@ -576,10 +576,13 @@ export class GooseExtClient { ) as GetSessionInfoResponse_unstable; } - async elicitationRespond_unstable( - params: ElicitationRespondRequest_unstable, + async sessionConversationTruncate_unstable( + params: TruncateSessionConversationRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/elicitation/respond", params); + await this.conn.extMethod( + "_goose/unstable/session/conversation/truncate", + params, + ); } async sessionProjectUpdate_unstable( diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 77d32c437591..555182187ece 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -204,8 +204,8 @@ export const GOOSE_EXT_METHODS = [ responseType: "GetSessionInfoResponse_unstable", }, { - method: "_goose/unstable/elicitation/respond", - requestType: "ElicitationRespondRequest_unstable", + method: "_goose/unstable/session/conversation/truncate", + requestType: "TruncateSessionConversationRequest_unstable", responseType: "EmptyResponse", }, { diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 1216f16fb46b..7a97352d37b1 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -1198,12 +1198,11 @@ export type SessionInfo = { export type SessionId = string; /** - * Submit a response for a pending MCP elicitation in an active session. + * Truncate a session conversation from the given message timestamp onward. */ -export type ElicitationRespondRequest_unstable = { +export type TruncateSessionConversationRequest_unstable = { sessionId: string; - elicitationId: string; - userData?: unknown; + truncateFrom: number; }; /** @@ -1571,9 +1570,7 @@ export type GooseSessionUpdate = ({ sessionUpdate: 'usage_update'; } & SessionUsageUpdate) | ({ sessionUpdate: 'status_message'; -} & StatusMessageUpdate) | ({ - sessionUpdate: 'interaction_update'; -} & InteractionUpdate); +} & StatusMessageUpdate); /** * Streaming context-window usage update for a session. @@ -1602,25 +1599,10 @@ export type StatusMessageUpdate = { status: StatusMessage; }; -export type Interaction = { - id: string; - state: InteractionState; - message?: string | null; - requestedSchema?: unknown; - type: 'elicitation'; -}; - -export type InteractionState = 'pending' | 'submitted'; - -export type InteractionUpdate = { - interaction: Interaction; - _meta?: unknown; -}; - export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | GetSessionInfoRequest_unstable | ElicitationRespondRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { + params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { [key: string]: unknown; } | null; }; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index b98f640e0e5f..54b87f001acb 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -1174,12 +1174,11 @@ export const zGetSessionInfoResponse_unstable = z.object({ }); /** - * Submit a response for a pending MCP elicitation in an active session. + * Truncate a session conversation from the given message timestamp onward. */ -export const zElicitationRespondRequest_unstable = z.object({ +export const zTruncateSessionConversationRequest_unstable = z.object({ sessionId: z.string(), - elicitationId: z.string(), - userData: z.unknown().optional().default(null) + truncateFrom: z.number().int() }); /** @@ -1543,24 +1542,6 @@ export const zStatusMessageUpdate = z.object({ status: zStatusMessage }); -export const zInteractionState = z.enum(['pending', 'submitted']); - -export const zInteraction = z.object({ - id: z.string(), - state: zInteractionState, - message: z.union([ - z.string(), - z.null() - ]).optional(), - requestedSchema: z.unknown().optional(), - type: z.literal('elicitation') -}); - -export const zInteractionUpdate = z.object({ - interaction: zInteraction, - _meta: z.unknown().optional() -}); - /** * Discriminated union of goose-specific session update payloads. * Variant tag matches ACP's convention (`sessionUpdate: ""`). @@ -1575,10 +1556,7 @@ export const zGooseSessionUpdate = z.union([ }).and(zSessionUsageUpdate), z.object({ sessionUpdate: z.literal('status_message') - }).and(zStatusMessageUpdate), - z.object({ - sessionUpdate: z.literal('interaction_update') - }).and(zInteractionUpdate) + }).and(zStatusMessageUpdate) ]); /** @@ -1635,7 +1613,7 @@ export const zExtRequest = z.object({ zExportSessionRequest_unstable, zImportSessionRequest_unstable, zGetSessionInfoRequest_unstable, - zElicitationRespondRequest_unstable, + zTruncateSessionConversationRequest_unstable, zUpdateSessionProjectRequest_unstable, zRenameSessionRequest_unstable, zArchiveSessionRequest_unstable,