mirror of
https://github.com/block/goose.git
synced 2026-07-14 18:39:31 +00:00
Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9081cbd1d7 | ||
|
|
ca9f48afa5 |
560 changed files with 49319 additions and 45869 deletions
1
.claude/skills/code-review
Symbolic link
1
.claude/skills/code-review
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/code-review
|
||||
1
.claude/skills/create-app-e2e-test
Symbolic link
1
.claude/skills/create-app-e2e-test
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/create-app-e2e-test
|
||||
1
.claude/skills/create-pr
Symbolic link
1
.claude/skills/create-pr
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/create-pr
|
||||
1
.claude/skills/edge-case-finder
Symbolic link
1
.claude/skills/edge-case-finder
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/edge-case-finder
|
||||
1
.codex/skills/code-review
Symbolic link
1
.codex/skills/code-review
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/code-review
|
||||
1
.codex/skills/create-app-e2e-test
Symbolic link
1
.codex/skills/create-app-e2e-test
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/create-app-e2e-test
|
||||
1
.codex/skills/create-pr
Symbolic link
1
.codex/skills/create-pr
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/create-pr
|
||||
1
.codex/skills/edge-case-finder
Symbolic link
1
.codex/skills/edge-case-finder
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/edge-case-finder
|
||||
1
.cursor/skills/code-review
Symbolic link
1
.cursor/skills/code-review
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/code-review
|
||||
1
.cursor/skills/create-app-e2e-test
Symbolic link
1
.cursor/skills/create-app-e2e-test
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/create-app-e2e-test
|
||||
1
.cursor/skills/create-pr
Symbolic link
1
.cursor/skills/create-pr
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/create-pr
|
||||
1
.cursor/skills/edge-case-finder
Symbolic link
1
.cursor/skills/edge-case-finder
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/edge-case-finder
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -2,7 +2,7 @@
|
|||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
type: bug
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
|
|
|
|||
7
.github/ISSUE_TEMPLATE/config.yml
vendored
7
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: goose Discord discussion
|
||||
url: https://discord.gg/goose-oss
|
||||
about: Please ask and answer questions here.
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/aaif-goose/goose/security/policy
|
||||
about: Please report security vulnerabilities here.
|
||||
|
|
|
|||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -2,14 +2,11 @@
|
|||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
type: 'feature'
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Before opening a feature request**
|
||||
Please start a discussion in the [goose-eng Discord channel](https://discord.com/channels/1287729918100246654/1514412780504088677) before creating a feature request or beginning implementation. This helps align on direction before anyone spends time building.
|
||||
|
||||
**Please explain the motivation behind the feature request.**
|
||||
Does this feature solve a particular problem you have been experiencing? What opportunities or use cases would be unlocked with this feature?
|
||||
|
||||
|
|
|
|||
99
.github/ISSUE_TEMPLATE/submit-recipe.yml
vendored
Normal file
99
.github/ISSUE_TEMPLATE/submit-recipe.yml
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
name: 🧑🍳 Submit a recipe to the goose cookbook
|
||||
description: Share a reusable goose recipe with the community!
|
||||
title: "[Recipe] <your recipe title here>"
|
||||
labels: ["recipe submission"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing to the goose cookbook! 🍳
|
||||
Recipes are reusable sessions created in goose desktop or CLI and shared with the community to help others vibe code faster.
|
||||
|
||||
📌 **How to Submit**
|
||||
- Create your recipe using goose ("Make recipe from this session")
|
||||
- Fill out the YAML below using the format provided
|
||||
- Paste it into the field and submit the issue — we'll review and add it to the cookbook!
|
||||
|
||||
🪄 **What Happens After?**
|
||||
- If accepted, we'll publish your recipe to the [goose recipes cookbook](https://goose-docs.ai/recipes)
|
||||
- Your GitHub handle will be displayed and linked on the recipe card
|
||||
- If the YAML has any issues, goose will comment with validation errors so you can fix and resubmit.
|
||||
|
||||
🧪 **Pro Tip:** You can test your recipe locally in your terminal with:
|
||||
`goose recipe validate your-recipe.yaml`
|
||||
|
||||
- type: textarea
|
||||
id: recipe-yaml
|
||||
attributes:
|
||||
label: Paste Your Full Recipe YAML Below
|
||||
description: Use the structure below and we'll auto-fill your GitHub handle for `author.contact` after submission.
|
||||
placeholder: |
|
||||
version: "1.0.0"
|
||||
id: clean-up-feature-flag
|
||||
title: Clean Up Feature Flag
|
||||
description: Automatically clean up all references of a fully rolled out feature flag from a codebase and make the new behavior the default.
|
||||
instructions: |
|
||||
Your job is to systematically remove a fully rolled out feature flag and ensure the new behavior is now the default. Use code search tools like ripgrep to identify all references to the flag, clean up definition files, usage sites, tests, and configuration files. Then create a commit and push changes with clear commit messages documenting the flag removal.
|
||||
prompt: |
|
||||
Task: Remove a feature flag that has been fully rolled out, where the feature flag's functionality should become the default behavior.
|
||||
|
||||
Context:
|
||||
Feature flag key: {{ feature_flag_key }}
|
||||
Project: {{ repo_dir }}
|
||||
|
||||
Steps to follow:
|
||||
1. Check out a *new* branch from main or master named using the feature flag key.
|
||||
2. Find the feature flag constant/object that wraps the key.
|
||||
3. Search for all references to the constant/object using ripgrep or equivalent tools.
|
||||
4. Remove all conditional logic and make the new behavior default.
|
||||
5. Remove unused imports, mocks, config, and tests.
|
||||
6. Commit your changes and push the branch.
|
||||
7. Open a GitHub PR.
|
||||
|
||||
Use commit messages like:
|
||||
chore(flag-cleanup): remove <feature_flag_key> flag from codebase
|
||||
|
||||
parameters:
|
||||
- key: feature_flag_key
|
||||
input_type: string
|
||||
requirement: required
|
||||
description: Key of the feature flag
|
||||
|
||||
- key: repo_dir
|
||||
input_type: string
|
||||
requirement: optional
|
||||
default: ./
|
||||
description: Directory of the codebase
|
||||
|
||||
extensions:
|
||||
- type: stdio
|
||||
name: developer
|
||||
cmd: uvx
|
||||
args:
|
||||
- developer-mcp@latest
|
||||
timeout: 300
|
||||
bundled: true
|
||||
description: Access developer tools
|
||||
|
||||
activities:
|
||||
- Remove feature flag definitions
|
||||
- Clean up feature flag usage sites
|
||||
- Update affected tests
|
||||
- Remove flag configurations
|
||||
- Document flag removal
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
🛠 **Recipe Field Tips**
|
||||
- `version` must be "1.0.0" for now
|
||||
- `id` should be lowercase, hyphenated, and unique (e.g. `my-awesome-recipe`)
|
||||
- `title` is the display name of your recipe
|
||||
- `description` should clearly explain what the recipe does
|
||||
- `instructions` are specific steps goose should follow — supports template variables like `{{ variable_name }}`
|
||||
- `prompt` is the first thing goose sees when the recipe is launched
|
||||
- `parameters` should include required or optional inputs — optional ones must have `default`
|
||||
- `extensions` must follow the full format with `type`, `cmd`, `args`, `timeout`, etc.
|
||||
- `activities` describe the main actions the recipe performs
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Test before Release
|
||||
|
||||
1. Approve the workflows for this PR (scroll to bottom) to trigger CI and produce the Desktop bundle for testing.
|
||||
1. Close and reopen this PR to trigger CI and produce the Desktop bundle for testing. 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. Make sure all check workflows pass.
|
||||
3. Install the Desktop bundle from the download links (posted as a comment below when it is ready).
|
||||
4. Complete the goose Release Manual Testing Checklist (posted as a comment below).
|
||||
|
|
|
|||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -40,7 +40,7 @@
|
|||
## Project-Specific Context
|
||||
|
||||
- This is a Rust project using cargo workspaces
|
||||
- Core crates: `goose` (agent logic and ACP server), `goose-cli` (CLI), `goose-mcp` (MCP servers)
|
||||
- Core crates: `goose` (agent logic), `goose-cli` (CLI), `goose-server` (backend), `goose-mcp` (MCP servers)
|
||||
- Error handling: Use `anyhow::Result`, not `unwrap()` in production code
|
||||
- Async runtime: tokio
|
||||
- MCP protocol implementations require extra scrutiny
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
- `cargo fmt --check` - Code formatting (rustfmt)
|
||||
- `cargo test --jobs 2` - All tests
|
||||
- `cargo clippy --all-targets -- -D warnings` - Linting (clippy)
|
||||
- `just check-openapi-schema` - OpenAPI schema validation
|
||||
|
||||
**Desktop app checks:**
|
||||
- `pnpm install --frozen-lockfile` - Fresh dependency install (in `ui/desktop/`)
|
||||
|
|
|
|||
23
.github/workflows/autoclose
vendored
Normal file
23
.github/workflows/autoclose
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 21
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
any-of-labels: "bug,Bug"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.github/workflows/build-notify.yml
vendored
4
.github/workflows/build-notify.yml
vendored
|
|
@ -7,8 +7,6 @@ on:
|
|||
- Release
|
||||
- Canary
|
||||
types: [completed]
|
||||
branches-ignore:
|
||||
- "release/**"
|
||||
|
||||
|
||||
jobs:
|
||||
|
|
@ -36,4 +34,4 @@ jobs:
|
|||
"color": 15158332
|
||||
}]
|
||||
}' \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
14
.github/workflows/bundle-desktop-intel.yml
vendored
14
.github/workflows/bundle-desktop-intel.yml
vendored
|
|
@ -73,11 +73,11 @@ jobs:
|
|||
key: intel-macos-deployment-target-12
|
||||
|
||||
|
||||
- name: Build desktop backend for Intel macOS (x86_64)
|
||||
- name: Build goose-server for Intel macOS (x86_64)
|
||||
run: |
|
||||
source ./bin/activate-hermit
|
||||
rustup target add x86_64-apple-darwin
|
||||
cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin
|
||||
cargo build --release -p goose-server --target x86_64-apple-darwin
|
||||
|
||||
|
||||
|
||||
|
|
@ -95,16 +95,12 @@ jobs:
|
|||
# Check disk space after cleanup
|
||||
df -h
|
||||
|
||||
- name: Copy backend binary into Electron folder
|
||||
- name: Copy binaries into Electron folder
|
||||
run: |
|
||||
mkdir -p ui/desktop/src/bin
|
||||
rm -f ui/desktop/src/bin/goose
|
||||
cp target/x86_64-apple-darwin/release/goose ui/desktop/src/bin/goose
|
||||
chmod +x ui/desktop/src/bin/goose
|
||||
ls -la ui/desktop/src/bin/
|
||||
cp target/x86_64-apple-darwin/release/goosed ui/desktop/src/bin/goosed
|
||||
|
||||
- name: Cache pnpm dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
ui/desktop/node_modules
|
||||
|
|
|
|||
20
.github/workflows/bundle-desktop-linux.yml
vendored
20
.github/workflows/bundle-desktop-linux.yml
vendored
|
|
@ -124,7 +124,7 @@ jobs:
|
|||
with:
|
||||
key: linux-${{ matrix.build-on }}-${{ matrix.variant }}
|
||||
|
||||
- name: Build desktop backend binary
|
||||
- name: Build goosed binary
|
||||
env:
|
||||
RUST_LOG: debug
|
||||
RUST_BACKTRACE: 1
|
||||
|
|
@ -137,25 +137,19 @@ jobs:
|
|||
FEATURE_ARGS=(--features vulkan)
|
||||
fi
|
||||
|
||||
cargo build --release --target ${TARGET} -p goose-cli --bin goose "${FEATURE_ARGS[@]}"
|
||||
cargo build --release --target ${TARGET} -p goose-server "${FEATURE_ARGS[@]}"
|
||||
|
||||
- name: Copy backend binary into Electron folder
|
||||
- name: Copy binaries into Electron folder
|
||||
run: |
|
||||
echo "Copying backend binary to ui/desktop/src/bin/"
|
||||
echo "Copying binaries to ui/desktop/src/bin/"
|
||||
export TARGET="x86_64-unknown-linux-gnu"
|
||||
mkdir -p ui/desktop/src/bin
|
||||
rm -f ui/desktop/src/bin/goose
|
||||
cp target/$TARGET/release/goose ui/desktop/src/bin/
|
||||
chmod +x ui/desktop/src/bin/goose
|
||||
cp target/$TARGET/release/goosed ui/desktop/src/bin/
|
||||
chmod +x ui/desktop/src/bin/goosed
|
||||
ls -la ui/desktop/src/bin/
|
||||
|
||||
- name: Free Rust build artifacts before packaging
|
||||
run: |
|
||||
source ./bin/activate-hermit
|
||||
cargo clean
|
||||
|
||||
- name: Cache pnpm dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
ui/desktop/node_modules
|
||||
|
|
|
|||
38
.github/workflows/bundle-desktop-windows.yml
vendored
38
.github/workflows/bundle-desktop-windows.yml
vendored
|
|
@ -61,7 +61,7 @@ jobs:
|
|||
run: npm install -g pnpm@10.30.3
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
|
|
@ -113,33 +113,28 @@ jobs:
|
|||
env:
|
||||
CUDA_COMPUTE_CAP: ${{ inputs.windows_variant == 'cuda' && '80' || '' }}
|
||||
run: |
|
||||
$isCuda = "${{ inputs.windows_variant }}" -eq "cuda"
|
||||
|
||||
Write-Output "Building Windows ACP backend"
|
||||
if ($isCuda) {
|
||||
cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose --features cuda
|
||||
Write-Output "Building Windows executable..."
|
||||
if ("${{ inputs.windows_variant }}" -eq "cuda") {
|
||||
cargo build --release --target x86_64-pc-windows-msvc -p goose-server --features cuda
|
||||
} else {
|
||||
cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose
|
||||
cargo build --release --target x86_64-pc-windows-msvc -p goose-server
|
||||
}
|
||||
$binaryPath = "./target/x86_64-pc-windows-msvc/release/goose.exe"
|
||||
|
||||
# Verify build succeeded
|
||||
if (-not (Test-Path $binaryPath)) {
|
||||
Write-Error "Windows backend binary not found: $binaryPath"
|
||||
if (-not (Test-Path "./target/x86_64-pc-windows-msvc/release/goosed.exe")) {
|
||||
Write-Error "Windows binary not found."
|
||||
Get-ChildItem ./target/x86_64-pc-windows-msvc/release/ -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output "Windows backend binary found."
|
||||
Get-Item $binaryPath
|
||||
Write-Output "Windows binary found."
|
||||
Get-Item ./target/x86_64-pc-windows-msvc/release/goosed.exe
|
||||
|
||||
- name: Prepare Windows binary
|
||||
shell: bash
|
||||
run: |
|
||||
BACKEND_BINARY="./target/x86_64-pc-windows-msvc/release/goose.exe"
|
||||
|
||||
if [ ! -f "$BACKEND_BINARY" ]; then
|
||||
echo "Windows backend binary not found: $BACKEND_BINARY"
|
||||
if [ ! -f "./target/x86_64-pc-windows-msvc/release/goosed.exe" ]; then
|
||||
echo "Windows binary not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -147,14 +142,13 @@ jobs:
|
|||
rm -rf ./ui/desktop/src/bin
|
||||
mkdir -p ./ui/desktop/src/bin
|
||||
|
||||
echo "Copying Windows backend binary..."
|
||||
cp -f "$BACKEND_BINARY" ./ui/desktop/src/bin/
|
||||
echo "Copying Windows binary..."
|
||||
cp -f ./target/x86_64-pc-windows-msvc/release/goosed.exe ./ui/desktop/src/bin/
|
||||
|
||||
if [ -d "./ui/desktop/src/platform/windows/bin" ]; then
|
||||
echo "Copying Windows platform files..."
|
||||
for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do
|
||||
filename="$(basename "$file")"
|
||||
if [ -f "$file" ] && [ "$filename" != "goose.exe" ]; then
|
||||
if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then
|
||||
cp -f "$file" ./ui/desktop/src/bin/
|
||||
fi
|
||||
done
|
||||
|
|
@ -235,14 +229,14 @@ jobs:
|
|||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
|
||||
files: |
|
||||
${{ github.workspace }}/dist-windows/Goose.exe
|
||||
${{ github.workspace }}/dist-windows/resources/bin/goose.exe
|
||||
${{ github.workspace }}/dist-windows/resources/bin/goosed.exe
|
||||
|
||||
- name: Verify signed executables
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"dist-windows/Goose.exe",
|
||||
"dist-windows/resources/bin/goose.exe"
|
||||
"dist-windows/resources/bin/goosed.exe"
|
||||
)
|
||||
foreach ($file in $files) {
|
||||
Write-Output "Verifying signature: $file"
|
||||
|
|
|
|||
16
.github/workflows/bundle-desktop.yml
vendored
16
.github/workflows/bundle-desktop.yml
vendored
|
|
@ -118,10 +118,8 @@ jobs:
|
|||
key: macos-deployment-target-12
|
||||
|
||||
# Build the project
|
||||
- name: Build desktop backend
|
||||
run: |
|
||||
source ./bin/activate-hermit
|
||||
cargo build --release -p goose-cli --bin goose
|
||||
- name: Build goosed
|
||||
run: source ./bin/activate-hermit && cargo build --release -p goose-server
|
||||
|
||||
# Post-build cleanup to free space
|
||||
- name: Post-build cleanup
|
||||
|
|
@ -136,16 +134,12 @@ jobs:
|
|||
# Check disk space after cleanup
|
||||
df -h
|
||||
|
||||
- name: Copy backend binary into Electron folder
|
||||
- name: Copy binaries into Electron folder
|
||||
run: |
|
||||
mkdir -p ui/desktop/src/bin
|
||||
rm -f ui/desktop/src/bin/goose
|
||||
cp target/release/goose ui/desktop/src/bin/goose
|
||||
chmod +x ui/desktop/src/bin/goose
|
||||
ls -la ui/desktop/src/bin/
|
||||
cp target/release/goosed ui/desktop/src/bin/goosed
|
||||
|
||||
- name: Cache pnpm dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
ui/desktop/node_modules
|
||||
|
|
|
|||
10
.github/workflows/canary.yml
vendored
10
.github/workflows/canary.yml
vendored
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
path: download_cli.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 4) Bundle Desktop App (macOS only)
|
||||
# 4) Bundle Desktop App (macOS only) - builds goosed and Electron app
|
||||
# ------------------------------------------------------------
|
||||
bundle-desktop:
|
||||
needs: [prepare-version]
|
||||
|
|
@ -80,7 +80,7 @@ jobs:
|
|||
signing: false
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 5) Bundle Desktop App (macOS Intel)
|
||||
# 5) Bundle Desktop App (macOS Intel) - builds goosed and Electron app
|
||||
# ------------------------------------------------------------
|
||||
bundle-desktop-intel:
|
||||
needs: [prepare-version]
|
||||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
signing: false
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 6) Bundle Desktop App (Linux)
|
||||
# 6) Bundle Desktop App (Linux) - builds goosed and Electron app
|
||||
# ------------------------------------------------------------
|
||||
bundle-desktop-linux:
|
||||
needs: [prepare-version]
|
||||
|
|
@ -102,7 +102,7 @@ jobs:
|
|||
version: ${{ needs.prepare-version.outputs.version }}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 6) Bundle Desktop App (Windows)
|
||||
# 6) Bundle Desktop App (Windows) - builds goosed and Electron app
|
||||
# ------------------------------------------------------------
|
||||
bundle-desktop-windows:
|
||||
needs: [prepare-version]
|
||||
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
merge-multiple: true
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: |
|
||||
goose-*.tar.bz2
|
||||
|
|
|
|||
2
.github/workflows/cargo-deny.yml
vendored
2
.github/workflows/cargo-deny.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/checkout@v7
|
||||
# https://github.com/EmbarkStudios/cargo-deny-action v2.0.15
|
||||
- uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe
|
||||
with:
|
||||
|
|
|
|||
56
.github/workflows/ci.yml
vendored
56
.github/workflows/ci.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # pin@v3
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
- name: Checkout Code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
|
||||
- name: Run cargo fmt
|
||||
run: cargo fmt --check
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
- name: Checkout Code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
|
|
@ -75,44 +75,6 @@ jobs:
|
|||
env:
|
||||
RUST_MIN_STACK: 8388608
|
||||
|
||||
rust-build-and-test-tls:
|
||||
name: Build and Test TLS Backend (${{ matrix.tls-feature }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tls-feature:
|
||||
- rustls-tls
|
||||
- native-tls
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
key: ${{ matrix.tls-feature }}
|
||||
|
||||
- name: Build and Test with ${{ matrix.tls-feature }}
|
||||
run: |
|
||||
gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar'
|
||||
export CARGO_INCREMENTAL=0
|
||||
cargo test -p goose --no-default-features --features ${{ matrix.tls-feature }},code-mode
|
||||
cargo test -p goose-providers --no-default-features --features ${{ matrix.tls-feature }}
|
||||
cargo test -p goose-cli --no-default-features --features ${{ matrix.tls-feature }},code-mode -- --skip scenario_tests::scenarios::tests
|
||||
cargo test -p goose-cli --no-default-features --features ${{ matrix.tls-feature }},code-mode --jobs 1 scenario_tests::scenarios::tests
|
||||
env:
|
||||
RUST_MIN_STACK: 8388608
|
||||
|
||||
|
||||
rust-build-windows:
|
||||
name: Build Rust Project on Windows
|
||||
|
|
@ -157,7 +119,7 @@ jobs:
|
|||
echo "msrv=$msrv" >> "$GITHUB_OUTPUT"
|
||||
echo "MSRV: $msrv"
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
toolchain: ${{ steps.msrv.outputs.msrv }}
|
||||
|
||||
|
|
@ -183,7 +145,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
|
||||
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
|
||||
|
|
@ -205,7 +167,7 @@ jobs:
|
|||
- name: Checkout Code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
|
|
@ -221,6 +183,12 @@ jobs:
|
|||
cd ui/desktop && pnpm install --frozen-lockfile
|
||||
cd ../sdk && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check OpenAPI Schema is Up-to-Date
|
||||
run: |
|
||||
source ./bin/activate-hermit
|
||||
hermit uninstall rustup
|
||||
just check-openapi-schema
|
||||
|
||||
- name: Check ACP Schema is Up-to-Date
|
||||
run: |
|
||||
source ./bin/activate-hermit
|
||||
|
|
|
|||
2
.github/workflows/create-release-branch.yaml
vendored
2
.github/workflows/create-release-branch.yaml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Extract version and base branch
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: install dependencies
|
||||
|
|
@ -85,7 +85,7 @@ jobs:
|
|||
|
||||
**Please follow these steps:**
|
||||
|
||||
1. Approve workflows for this PR to trigger CI checks.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
node-version: 20
|
||||
|
||||
- name: Cache Node.js modules (documentation)
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ./documentation/node_modules
|
||||
key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }}
|
||||
|
|
|
|||
2
.github/workflows/docs-update-cli-ref.yml
vendored
2
.github/workflows/docs-update-cli-ref.yml
vendored
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
sudo apt-get install -y jq ripgrep
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
|
|
|
|||
292
.github/workflows/docs-update-recipe-ref.yml
vendored
Normal file
292
.github/workflows/docs-update-recipe-ref.yml
vendored
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Automatically updates the Recipe Reference Guide when recipe struct fields,
|
||||
# validation rules, or schema constraints change between releases.
|
||||
#
|
||||
# Triggers: Manual (for testing) or on release (production)
|
||||
# Testing: Use dry_run mode to review outputs without creating PRs
|
||||
# See: documentation/automation/recipe-schema-tracking/TESTING.md
|
||||
|
||||
name: Update Recipe Documentation
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
inputs:
|
||||
old_version:
|
||||
description: 'Previous version (e.g., v1.14.0). Leave empty to auto-detect.'
|
||||
required: false
|
||||
type: string
|
||||
new_version:
|
||||
description: 'New version (e.g., v1.15.0). Leave empty to use HEAD.'
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run mode - generate files but do not create PR'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write # Create branches and commit files
|
||||
pull-requests: write # Create PRs
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
name: Update Recipe Documentation
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for version comparison
|
||||
fetch-tags: true # Fetch all tags so we can checkout version tags
|
||||
|
||||
- name: Fetch upstream tags (for forks)
|
||||
if: github.repository != 'aaif-goose/goose'
|
||||
run: |
|
||||
# Add upstream remote and fetch tags (only needed when testing in forks)
|
||||
git remote add upstream https://github.com/aaif-goose/goose.git || git remote set-url upstream https://github.com/aaif-goose/goose.git
|
||||
git fetch upstream --tags --force
|
||||
echo "✅ Fetched tags from upstream (fork mode)"
|
||||
echo "Total tags available: $(git tag | wc -l)"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq ripgrep
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install goose CLI
|
||||
run: |
|
||||
mkdir -p /home/runner/.local/bin
|
||||
curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh \
|
||||
| CONFIGURE=false GOOSE_BIN_DIR=/home/runner/.local/bin bash
|
||||
echo "/home/runner/.local/bin" >> $GITHUB_PATH
|
||||
goose --version
|
||||
|
||||
- name: Configure goose for CI
|
||||
env:
|
||||
GOOSE_PROVIDER: ${{ vars.GOOSE_PROVIDER || 'openai' }}
|
||||
GOOSE_MODEL: ${{ vars.GOOSE_MODEL || 'gpt-4o' }}
|
||||
run: |
|
||||
mkdir -p ~/.config/goose
|
||||
cat <<EOF > ~/.config/goose/config.yaml
|
||||
GOOSE_PROVIDER: $GOOSE_PROVIDER
|
||||
GOOSE_MODEL: $GOOSE_MODEL
|
||||
keyring: false
|
||||
EOF
|
||||
echo "✅ Created goose config:"
|
||||
cat ~/.config/goose/config.yaml
|
||||
|
||||
- name: Determine versions to compare
|
||||
id: versions
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_OLD_VERSION: ${{ github.event.inputs.old_version }}
|
||||
INPUT_NEW_VERSION: ${{ github.event.inputs.new_version }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
get_previous_release() {
|
||||
gh release list --limit 2 --json tagName --jq '.[].tagName' | sed -n '2p'
|
||||
}
|
||||
|
||||
if [ -n "$INPUT_OLD_VERSION" ]; then
|
||||
OLD_VERSION="$INPUT_OLD_VERSION"
|
||||
else
|
||||
OLD_VERSION=$(get_previous_release)
|
||||
fi
|
||||
|
||||
if [ -n "$INPUT_NEW_VERSION" ]; then
|
||||
NEW_VERSION="$INPUT_NEW_VERSION"
|
||||
elif [ "$EVENT_NAME" = "release" ]; then
|
||||
NEW_VERSION="$RELEASE_TAG"
|
||||
else
|
||||
NEW_VERSION="HEAD" # For testing unreleased changes
|
||||
fi
|
||||
|
||||
if [ -z "$OLD_VERSION" ] || [ -z "$NEW_VERSION" ]; then
|
||||
echo "Error: Could not determine versions to compare"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "OLD_VERSION=$OLD_VERSION" >> $GITHUB_ENV
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
|
||||
|
||||
echo "✅ Comparing $OLD_VERSION → $NEW_VERSION"
|
||||
|
||||
- name: Extract and compare schemas
|
||||
id: extract
|
||||
timeout-minutes: 15
|
||||
working-directory: documentation/automation/recipe-schema-tracking
|
||||
env:
|
||||
GOOSE_REPO: ${{ github.workspace }}
|
||||
run: |
|
||||
set -o pipefail # Ensure pipeline failures are caught
|
||||
|
||||
mkdir -p output
|
||||
./scripts/run-pipeline.sh "$OLD_VERSION" "$NEW_VERSION" 2>&1 | tee output/pipeline.log
|
||||
|
||||
HAS_CHANGES=$(jq -r '.has_changes' output/validation-changes.json)
|
||||
echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$HAS_CHANGES" = "false" ]; then
|
||||
echo "✅ No changes detected"
|
||||
else
|
||||
echo "✅ Changes detected"
|
||||
fi
|
||||
|
||||
- name: Update recipe-reference.md (AI synthesis)
|
||||
if: steps.extract.outputs.has_changes == 'true'
|
||||
timeout-minutes: 10
|
||||
working-directory: documentation/automation/recipe-schema-tracking/output
|
||||
env:
|
||||
RECIPE_REF_PATH: ${{ github.workspace }}/documentation/docs/guides/recipes/recipe-reference.md
|
||||
run: |
|
||||
echo "🔍 Environment diagnostics:"
|
||||
echo " GOOSE_PROVIDER: $GOOSE_PROVIDER"
|
||||
echo " GOOSE_MODEL: $GOOSE_MODEL"
|
||||
echo " OPENAI_API_KEY: ${OPENAI_API_KEY:0:8}..." # Show first 8 chars only
|
||||
echo " RECIPE_REF_PATH: $RECIPE_REF_PATH"
|
||||
echo " HOME: $HOME"
|
||||
echo " PATH: $PATH"
|
||||
echo ""
|
||||
echo "📁 Goose config file:"
|
||||
cat ~/.config/goose/config.yaml || echo "Config file not found!"
|
||||
echo ""
|
||||
echo "📁 Current directory:"
|
||||
pwd
|
||||
ls -la
|
||||
echo ""
|
||||
echo "🤖 Step 1: Running validation changes synthesis..."
|
||||
goose run --recipe ../recipes/synthesize-validation-changes.yaml
|
||||
|
||||
echo ""
|
||||
echo "🤖 Step 2: Applying changes to recipe-reference.md..."
|
||||
goose run --recipe ../recipes/update-recipe-reference.yaml
|
||||
|
||||
- name: Upload automation outputs
|
||||
if: always()
|
||||
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: |
|
||||
documentation/automation/recipe-schema-tracking/output/*.json
|
||||
documentation/automation/recipe-schema-tracking/output/*.md
|
||||
documentation/automation/recipe-schema-tracking/output/*.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Create Pull Request
|
||||
if: |
|
||||
steps.extract.outputs.has_changes == 'true' &&
|
||||
(github.event.inputs.dry_run != 'true' || github.event_name == 'release')
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
branch: docs/auto-recipe-reference-${{ steps.versions.outputs.new_version }}
|
||||
delete-branch: true
|
||||
|
||||
commit-message: |
|
||||
docs: Update recipe reference for ${{ steps.versions.outputs.new_version }}
|
||||
|
||||
Automated update based on schema changes between ${{ steps.versions.outputs.old_version }} and ${{ steps.versions.outputs.new_version }}.
|
||||
|
||||
title: "docs: Update Recipe Reference Guide for ${{ steps.versions.outputs.new_version }}"
|
||||
body: |
|
||||
## Summary
|
||||
|
||||
This PR updates the Recipe Reference Guide based on schema and validation changes detected between **${{ steps.versions.outputs.old_version }}** and **${{ steps.versions.outputs.new_version }}**.
|
||||
|
||||
### Type of Change
|
||||
- [x] Documentation
|
||||
|
||||
### AI Assistance
|
||||
- [x] This PR was created or reviewed with AI assistance
|
||||
|
||||
#### 🤖 Automation Details
|
||||
|
||||
- **Workflow**: `docs-update-recipe-ref.yml`
|
||||
- **Triggered by**: ${{ github.event_name }}
|
||||
- **Previous version**: ${{ steps.versions.outputs.old_version }}
|
||||
- **New version**: ${{ steps.versions.outputs.new_version }}
|
||||
|
||||
#### 📋 Changes Detected
|
||||
|
||||
Review the workflow artifacts for detailed change analysis:
|
||||
- `validation-changes.json` - Structured diff of changes
|
||||
- `validation-changes.md` - Human-readable change documentation
|
||||
- `update-summary.md` - Summary of documentation updates applied
|
||||
|
||||
Download artifacts from the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
### ✅ Review Checklist
|
||||
|
||||
- [ ] Verify all schema changes are accurately documented
|
||||
- [ ] Check that examples are updated correctly
|
||||
- [ ] Ensure validation rules are clearly explained
|
||||
- [ ] Confirm no unintended changes were made
|
||||
- [ ] Do changes require additional updates in this or other recipe topics?
|
||||
|
||||
### 🔗 Related
|
||||
|
||||
- Release: ${{ github.event.release.html_url || 'N/A' }}
|
||||
|
||||
---
|
||||
|
||||
*This PR was automatically generated by the Recipe Documentation Automation workflow.*
|
||||
|
||||
labels: |
|
||||
documentation
|
||||
automated
|
||||
recipe-reference
|
||||
|
||||
- name: Workflow summary
|
||||
if: always()
|
||||
env:
|
||||
OLD_VERSION: ${{ steps.versions.outputs.old_version }}
|
||||
NEW_VERSION: ${{ steps.versions.outputs.new_version }}
|
||||
HAS_CHANGES: ${{ steps.extract.outputs.has_changes }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
run: |
|
||||
echo "## 📊 Recipe Documentation Update Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version Comparison**: $OLD_VERSION → $NEW_VERSION" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Changes Detected**: $HAS_CHANGES" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Dry Run Mode**: $DRY_RUN" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$HAS_CHANGES" = "true" ]; then
|
||||
echo "### ✅ Documentation Updated" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The Recipe Reference Guide has been updated to reflect changes in $NEW_VERSION." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "**Note**: Running in dry-run mode - no PR was created. Review the artifacts to see the generated changes." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "A pull request has been created with the documentation updates." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "### ℹ️ No Changes Needed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No recipe schema or validation changes were detected between $OLD_VERSION and $NEW_VERSION." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Artifacts" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Download the workflow artifacts to review:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Extracted schemas and validation structures" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Change detection results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Human-readable change documentation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Documentation update summary" >> $GITHUB_STEP_SUMMARY
|
||||
2
.github/workflows/goose-issue-solver.yml
vendored
2
.github/workflows/goose-issue-solver.yml
vendored
|
|
@ -30,7 +30,7 @@ env:
|
|||
- Deletion is a feature: ask "can I delete code instead of adding?"
|
||||
- Complete changes: update ALL usages when changing a type. No exceptions.
|
||||
- When changing message types, update ALL provider format functions.
|
||||
- When changing ACP request/response types, run `just generate-acp-types`.
|
||||
- When changing server routes, run `just generate-openapi`.
|
||||
- Your context degrades. The TODO is your memory. Update it after each step.
|
||||
|
||||
Types:
|
||||
|
|
|
|||
4
.github/workflows/goose-release-notes.yml
vendored
4
.github/workflows/goose-release-notes.yml
vendored
|
|
@ -15,8 +15,6 @@ on:
|
|||
- Release
|
||||
types:
|
||||
- completed
|
||||
branches-ignore:
|
||||
- "release/**"
|
||||
|
||||
# Allow manual trigger for testing
|
||||
workflow_dispatch:
|
||||
|
|
@ -78,7 +76,7 @@ jobs:
|
|||
generate-release-notes:
|
||||
name: Generate Release Notes
|
||||
runs-on: ubuntu-latest
|
||||
# For workflow_run: release branch preflight builds are ignored by the trigger.
|
||||
# For workflow_run: only run if Release succeeded and tag is not 'stable'
|
||||
# For workflow_dispatch: only run if tag is not 'stable'
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && inputs.tag != 'stable') ||
|
||||
|
|
|
|||
173
.github/workflows/maven-sdk.yml
vendored
173
.github/workflows/maven-sdk.yml
vendored
|
|
@ -1,173 +0,0 @@
|
|||
name: Maven SDK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish gdk to Maven Central after building"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-native:
|
||||
name: Build native library (${{ matrix.name }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
container: ${{ matrix.container || null }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: darwin-aarch64
|
||||
os: macos-14
|
||||
resource-prefix: darwin-aarch64
|
||||
- name: linux-x86-64
|
||||
os: ubuntu-latest
|
||||
container: quay.io/pypa/manylinux_2_28_x86_64@sha256:441c35fdc6ee809ff9260894f8468ab4fea8c15dc880f8700a3f81b7922c1cda
|
||||
resource-prefix: linux-x86-64
|
||||
- name: win32-x86-64
|
||||
os: windows-latest
|
||||
resource-prefix: win32-x86-64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Build native library
|
||||
shell: bash
|
||||
run: cargo build -p goose-sdk --features uniffi --release
|
||||
|
||||
- name: Stage native library
|
||||
shell: bash
|
||||
run: |
|
||||
case "${{ runner.os }}" in
|
||||
Linux) lib="target/release/libgoose_sdk.so" ;;
|
||||
macOS) lib="target/release/libgoose_sdk.dylib" ;;
|
||||
Windows) lib="target/release/goose_sdk.dll" ;;
|
||||
*) echo "unsupported runner OS: ${{ runner.os }}" >&2; exit 1 ;;
|
||||
esac
|
||||
mkdir -p "maven-native/${{ matrix.resource-prefix }}"
|
||||
cp "$lib" "maven-native/${{ matrix.resource-prefix }}/"
|
||||
|
||||
- name: Upload native library artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: goose-sdk-native-${{ matrix.name }}
|
||||
path: maven-native/**
|
||||
if-no-files-found: error
|
||||
|
||||
package:
|
||||
name: Package Maven artifact
|
||||
needs: build-native
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
||||
|
||||
- name: Download native library artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: goose-sdk-native-*
|
||||
path: native-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate Kotlin bindings
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build -p goose-sdk --features uniffi --release
|
||||
crates/goose-sdk/scripts/prepare-maven-package.sh \
|
||||
target/release/libgoose_sdk.so \
|
||||
linux-x86-64
|
||||
|
||||
- name: Restore downloaded native libraries
|
||||
shell: bash
|
||||
run: cp -R native-artifacts/* crates/goose-sdk/maven/src/main/resources/
|
||||
|
||||
- name: Build Maven publication
|
||||
working-directory: crates/goose-sdk/maven
|
||||
run: gradle --no-daemon publishToMavenLocal
|
||||
|
||||
- name: Stage Maven local artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(grep -m1 '^version =' crates/goose-sdk/Cargo.toml | sed -E 's/version = "([^"]+)"/\1/')
|
||||
mkdir -p maven-artifacts
|
||||
cp -R "$HOME/.m2/repository/io/github/aaif-goose/gdk/$version" maven-artifacts/
|
||||
|
||||
- name: Upload Maven artifact bundle
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: goose-sdk-maven
|
||||
path: maven-artifacts/**
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish to Maven Central
|
||||
needs: package
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish
|
||||
environment: maven-central
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4
|
||||
|
||||
- name: Download native library artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: goose-sdk-native-*
|
||||
path: native-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate Kotlin bindings
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build -p goose-sdk --features uniffi --release
|
||||
crates/goose-sdk/scripts/prepare-maven-package.sh \
|
||||
target/release/libgoose_sdk.so \
|
||||
linux-x86-64
|
||||
|
||||
- name: Restore downloaded native libraries
|
||||
shell: bash
|
||||
run: cp -R native-artifacts/* crates/goose-sdk/maven/src/main/resources/
|
||||
|
||||
- name: Publish to Maven Central
|
||||
working-directory: crates/goose-sdk/maven
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_TOKEN_USERNAME }}
|
||||
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_SIGNING_KEY }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_PASSWORD }}
|
||||
run: gradle --no-daemon publishAndReleaseToMavenCentral
|
||||
2
.github/workflows/patch-release.yaml
vendored
2
.github/workflows/patch-release.yaml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
ref: ${{ inputs.target_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: install dependencies
|
||||
|
|
|
|||
49
.github/workflows/pr-smoke-test.yml
vendored
49
.github/workflows/pr-smoke-test.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Check for code changes
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # pin@v3
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v3
|
||||
id: filter
|
||||
with:
|
||||
base: ${{ github.event.before || github.event.pull_request.base.sha }}
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
|
||||
- name: Build Binary for Smoke Tests
|
||||
run: |
|
||||
cargo build --bin goose
|
||||
cargo build --bin goose --bin goosed
|
||||
|
||||
- name: Upload goose binary
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
|
@ -76,6 +76,13 @@ jobs:
|
|||
path: target/debug/goose
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload goosed binary
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: goosed-binary
|
||||
path: target/debug/goosed
|
||||
retention-days: 1
|
||||
|
||||
smoke-tests:
|
||||
name: Smoke Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -246,3 +253,39 @@ jobs:
|
|||
mkdir -p $HOME/.local/share/goose/sessions
|
||||
mkdir -p $HOME/.config/goose
|
||||
bash scripts/test_compaction.sh
|
||||
|
||||
goosed-integration-tests:
|
||||
name: goose server HTTP integration tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-binary
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
- name: Download Binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: goosed-binary
|
||||
path: target/debug
|
||||
|
||||
- name: Make Binary Executable
|
||||
run: chmod +x target/debug/goosed
|
||||
|
||||
- name: Install Node.js Dependencies
|
||||
run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile
|
||||
working-directory: ui/desktop
|
||||
|
||||
- name: Run Integration Tests
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GOOSED_BINARY: ../../target/debug/goosed
|
||||
GOOSE_PROVIDER: anthropic
|
||||
GOOSE_MODEL: claude-sonnet-4-5-20250929
|
||||
SHELL: /bin/bash
|
||||
SKIP_BUILD: 1
|
||||
run: |
|
||||
echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile
|
||||
source ../../bin/activate-hermit && pnpm run test:integration:goosed
|
||||
working-directory: ui/desktop
|
||||
|
|
|
|||
8
.github/workflows/publish-ask-ai-bot.yml
vendored
8
.github/workflows/publish-ask-ai-bot.yml
vendored
|
|
@ -22,10 +22,10 @@ jobs:
|
|||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/ask-ai-bot
|
||||
tags: |
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # pin@v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # pin@v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: services/ask-ai-bot/Dockerfile
|
||||
|
|
|
|||
10
.github/workflows/publish-docker.yml
vendored
10
.github/workflows/publish-docker.yml
vendored
|
|
@ -26,10 +26,10 @@ jobs:
|
|||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/goose
|
||||
tags: |
|
||||
|
|
@ -54,7 +54,7 @@ jobs:
|
|||
|
||||
- name: Build and push Docker image
|
||||
id: docker-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # pin@v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # pin@v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
|
@ -65,7 +65,7 @@ jobs:
|
|||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Attest Docker image
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository_owner }}/goose
|
||||
subject-digest: ${{ steps.docker-push.outputs.digest }}
|
||||
|
|
|
|||
4
.github/workflows/publish-npm.yml
vendored
4
.github/workflows/publish-npm.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
always-auth: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
with:
|
||||
version: 10.30.3
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ jobs:
|
|||
always-auth: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
with:
|
||||
version: 10.30.3
|
||||
|
||||
|
|
|
|||
118
.github/workflows/python-sdk-wheels.yml
vendored
118
.github/workflows/python-sdk-wheels.yml
vendored
|
|
@ -1,118 +0,0 @@
|
|||
name: Python SDK Wheels
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish wheels to PyPI after building"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build-wheels:
|
||||
name: Build wheel (${{ matrix.name }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
container: ${{ matrix.container || null }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macos-arm64
|
||||
os: macos-14
|
||||
- name: macos-x86_64
|
||||
os: macos-13
|
||||
- name: linux-x86_64
|
||||
os: ubuntu-latest
|
||||
container: quay.io/pypa/manylinux_2_28_x86_64@sha256:441c35fdc6ee809ff9260894f8468ab4fea8c15dc880f8700a3f81b7922c1cda
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Linux tools
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install uv
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Install just
|
||||
if: runner.os != 'Linux'
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
|
||||
- name: Build wheel
|
||||
shell: bash
|
||||
run: just --justfile crates/goose-sdk/justfile python-wheel
|
||||
|
||||
- name: Repair Linux wheel
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
python3 -m pip install auditwheel
|
||||
mkdir -p crates/goose-sdk/python/wheelhouse
|
||||
auditwheel repair --plat manylinux_2_28_x86_64 -w crates/goose-sdk/python/wheelhouse crates/goose-sdk/python/dist/*.whl
|
||||
rm crates/goose-sdk/python/dist/*.whl
|
||||
mv crates/goose-sdk/python/wheelhouse/*.whl crates/goose-sdk/python/dist/
|
||||
|
||||
- name: Smoke test wheel
|
||||
shell: bash
|
||||
run: |
|
||||
python -m venv .venv-wheel-test
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
python_bin=".venv-wheel-test/Scripts/python.exe"
|
||||
else
|
||||
python_bin=".venv-wheel-test/bin/python"
|
||||
fi
|
||||
UV_NO_CONFIG=1 uv pip install --default-index https://pypi.org/simple --python "$python_bin" crates/goose-sdk/python/dist/*.whl
|
||||
"$python_bin" -c 'import goose; print(goose.__name__)'
|
||||
|
||||
- name: Upload wheel artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: goose-sdk-wheel-${{ matrix.name }}
|
||||
path: crates/goose-sdk/python/dist/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish wheels to PyPI
|
||||
needs: build-wheels
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish
|
||||
environment: pypi
|
||||
steps:
|
||||
- name: Download wheel artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: goose-sdk-wheel-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
packages-dir: dist
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
node-version: 20
|
||||
|
||||
- name: Cache Node.js modules (documentation)
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ./documentation/node_modules
|
||||
key: ${{ runner.os }}-documentation-${{ hashFiles('./documentation/package-lock.json') }}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ jobs:
|
|||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Prune Docker caches
|
||||
if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true'
|
||||
|
|
|
|||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -116,7 +116,7 @@ jobs:
|
|||
id-token: write # Required for Sigstore OIDC signing
|
||||
attestations: write # Required for SLSA build provenance attestations
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
|
@ -129,12 +129,12 @@ jobs:
|
|||
|
||||
- name: Attest macOS update manifest
|
||||
if: ${{ env.ENABLE_MAC_NATIVE_AUTO_UPDATE == 'true' }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: latest-mac.yml
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: |
|
||||
goose-*.tar.bz2
|
||||
|
|
|
|||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
steps:
|
||||
# Use the official stale action from GitHub
|
||||
- name: 'Close Stale PRs'
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
# Authentication token with required permissions
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
2
.github/workflows/update-release-pr.yaml
vendored
2
.github/workflows/update-release-pr.yaml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Extract version from branch name
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -84,14 +84,3 @@ result
|
|||
# Goose self-test artifacts
|
||||
gooseselftest/
|
||||
.tasks/
|
||||
|
||||
# Generated goose-sdk Maven bindings and native libraries
|
||||
/crates/goose-sdk/maven/src/main/
|
||||
/crates/goose-sdk/maven/.gradle/
|
||||
/crates/goose-sdk/maven/.kotlin/
|
||||
/crates/goose-sdk/maven/build/
|
||||
|
||||
# Generated goose-sdk Kotlin example build artifacts
|
||||
/crates/goose-sdk/examples/uniffi/kotlin/.gradle/
|
||||
/crates/goose-sdk/examples/uniffi/kotlin/.kotlin/
|
||||
/crates/goose-sdk/examples/uniffi/kotlin/build/
|
||||
|
|
|
|||
12
AGENTS.md
12
AGENTS.md
|
|
@ -14,7 +14,7 @@ cargo build
|
|||
```bash
|
||||
cargo build # debug
|
||||
cargo build --release # release
|
||||
just release-binary # release binary
|
||||
just release-binary # release + openapi
|
||||
```
|
||||
|
||||
### Test
|
||||
|
|
@ -33,8 +33,8 @@ cargo clippy --all-targets -- -D warnings
|
|||
|
||||
### UI
|
||||
```bash
|
||||
just generate-openapi # after server changes
|
||||
just run-ui # start desktop
|
||||
cd ui/desktop && pnpm run typecheck
|
||||
cd ui/desktop && pnpm test # test UI
|
||||
```
|
||||
|
||||
|
|
@ -44,10 +44,12 @@ crates/
|
|||
├── goose # core logic
|
||||
├── goose-acp-macros # ACP proc macros
|
||||
├── goose-cli # CLI entry
|
||||
├── goose-server # backend (binary: goosed)
|
||||
├── goose-mcp # MCP extensions
|
||||
├── goose-test # test utilities
|
||||
└── goose-test-support # test helpers
|
||||
|
||||
evals/open-model-gym/ # benchmarking / evals
|
||||
ui/desktop/ # Electron app
|
||||
```
|
||||
|
||||
|
|
@ -63,6 +65,7 @@ ui/desktop/ # Electron app
|
|||
# 1. cargo build
|
||||
# 2. cargo test -p <crate>
|
||||
# 3. cargo clippy --all-targets -- -D warnings
|
||||
# 4. [if server] just generate-openapi
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
|
@ -72,7 +75,7 @@ ui/desktop/ # Electron app
|
|||
- Error: Use anyhow::Result
|
||||
- Provider: Implement Provider trait see providers/base.rs
|
||||
- MCP: Extensions in crates/goose-mcp/
|
||||
- UI Desktop: Use ACP SDK types or local `src/types/*` types. Do not import generated OpenAPI types/client code from `ui/desktop/src/api`
|
||||
- Server: Changes need just generate-openapi
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
|
@ -104,7 +107,7 @@ remaining space for dynamic text.
|
|||
|
||||
## Never
|
||||
|
||||
- Never: Recreate `ui/desktop/src/api` or add `@hey-api/openapi-ts` to `ui/desktop`
|
||||
- Never: Edit ui/desktop/openapi.json manually
|
||||
- Cargo.toml: For human-authored dependency changes, use `cargo add` instead of manually editing dependency entries unless there is a specific reason not to.
|
||||
- Cargo.toml: Automated dependency bump PRs are exempt; when manual edits are necessary, keep `Cargo.lock` consistent.
|
||||
- Never: Skip cargo fmt
|
||||
|
|
@ -113,5 +116,6 @@ remaining space for dynamic text.
|
|||
|
||||
## Entry Points
|
||||
- CLI: crates/goose-cli/src/main.rs
|
||||
- Server: crates/goose-server/src/main.rs
|
||||
- UI: ui/desktop/src/main.ts
|
||||
- Agent: crates/goose/src/agents/agent.rs
|
||||
|
|
|
|||
|
|
@ -59,7 +59,13 @@ cd goose
|
|||
Build Goose CLI:
|
||||
|
||||
```bash
|
||||
cargo build --release -p goose-cli --bin goose
|
||||
cargo build --release -p goose-cli
|
||||
```
|
||||
|
||||
Build Goose Server:
|
||||
|
||||
```bash
|
||||
cargo build --release -p goose-server
|
||||
```
|
||||
|
||||
This command should give you a list of possible packages in the
|
||||
|
|
@ -74,9 +80,9 @@ cargo test -p
|
|||
cd ui/desktop
|
||||
pnpm install
|
||||
|
||||
# Copy the goose binary to the expected location
|
||||
# Copy the server binary to the expected location
|
||||
mkdir -p src/bin
|
||||
cp ../../target/release/goose src/bin/
|
||||
cp ../../target/release/goosed src/bin/
|
||||
```
|
||||
|
||||
### 4. Build the Application
|
||||
|
|
@ -137,10 +143,10 @@ cd /path/to/goose/ui/desktop/out/goose-linux-x64
|
|||
./goose 2>&1 | grep -v "GLib-GObject" | grep -v "browser_main_loop"
|
||||
```
|
||||
|
||||
#### Goose Binary Not Found
|
||||
If you see "Goose binary not found", ensure you've:
|
||||
1. Built the Rust binary: `cargo build --release -p goose-cli --bin goose`
|
||||
2. Copied it to the right location: `cp ../../target/release/goose src/bin/`
|
||||
#### Server Binary Not Found
|
||||
If you see "Could not find goosed binary", ensure you've:
|
||||
1. Built the Rust backend: `cargo build --release -p goose-server`
|
||||
2. Copied it to the right location: `cp ../../target/release/goosed src/bin/`
|
||||
3. Rebuilt the application: `pnpm run make`
|
||||
|
||||
### Distribution-Specific Notes
|
||||
|
|
@ -171,7 +177,7 @@ Building as Snap packages is not currently supported but may be added in the fut
|
|||
|
||||
For active development:
|
||||
|
||||
1. **Backend changes**: Rebuild with `cargo build --release -p goose-cli --bin goose` and copy the binary
|
||||
1. **Backend changes**: Rebuild with `cargo build --release -p goose-server` and copy the binary
|
||||
2. **Frontend changes**: Use `pnpm run start` for hot reload during development
|
||||
3. **Full rebuild**: Run the complete build process above
|
||||
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
|
||||
# Contributor Covenant 3.0 Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make our community welcoming, safe, and equitable for all.
|
||||
|
||||
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
||||
|
||||
## Encouraged Behaviors
|
||||
|
||||
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language.
|
||||
|
||||
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including:
|
||||
|
||||
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
||||
2. Engaging **kindly and honestly** with others.
|
||||
3. Respecting **different viewpoints** and experiences.
|
||||
4. **Taking responsibility** for our actions and contributions.
|
||||
5. Gracefully giving and accepting **constructive feedback**.
|
||||
6. Committing to **repairing harm** when it occurs.
|
||||
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
||||
|
||||
|
||||
## Restricted Behaviors
|
||||
|
||||
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct.
|
||||
|
||||
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop.
|
||||
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people.
|
||||
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits.
|
||||
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community.
|
||||
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission.
|
||||
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
||||
7. Behaving in other ways that **threaten the well-being** of our community.
|
||||
|
||||
### Other Restrictions
|
||||
|
||||
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions.
|
||||
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
||||
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community.
|
||||
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors.
|
||||
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm.
|
||||
|
||||
When an incident does occur, it is important to report it promptly. To report a possible violation, **Send a message to @goose-admin**
|
||||
|
||||
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution.
|
||||
|
||||
|
||||
## Addressing and Repairing Harm
|
||||
|
||||
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped.
|
||||
|
||||
1) Warning
|
||||
1) Event: A violation involving a single incident or series of incidents.
|
||||
2) Consequence: A private, written warning from the Community Moderators.
|
||||
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations.
|
||||
2) Temporarily Limited Activities
|
||||
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation.
|
||||
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members.
|
||||
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over.
|
||||
3) Temporary Suspension
|
||||
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation.
|
||||
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
|
||||
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
|
||||
4) Permanent Ban
|
||||
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member.
|
||||
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior.
|
||||
3) Repair: There is no possible repair in cases of this severity.
|
||||
|
||||
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community.
|
||||
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
||||
|
||||
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||
|
||||
For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
|
||||
|
||||
|
|
@ -29,17 +29,16 @@ It just means the change was too large for a first contribution. Start with some
|
|||
|
||||
### Issues
|
||||
|
||||
If you spot a bug, please open an issue. This shows the community and the maintainers the direction of your
|
||||
thinking.
|
||||
If you spot a bug or have a concrete proposal for a feature, please open an issue. This shows the community and
|
||||
the maintainers the direction of your thinking.
|
||||
|
||||
For bugs, describe how to reproduce the problem as clearly as possible. If the issue involves an interaction
|
||||
with an LLM, include a diagnostics report if possible.
|
||||
|
||||
### Discussions
|
||||
|
||||
Before opening a feature request or beginning implementation, please start with a discussion in the
|
||||
[goose-eng Discord channel](https://discord.com/channels/1287729918100246654/1514412780504088677). Discussions
|
||||
are a good place to explore design questions, alternatives, and whether something fits the goals of the project.
|
||||
If you have an idea but are not yet sure how it should work, open a discussion instead. Discussions are a good
|
||||
place to explore design questions, alternatives, and whether something fits the goals of the project.
|
||||
|
||||
If a change is large or touches multiple parts of the codebase, please start with a discussion before opening a PR.
|
||||
This helps us align on direction before you spend time implementing something.
|
||||
|
|
@ -185,27 +184,41 @@ cd ui && pnpm install
|
|||
|
||||
See #8757.
|
||||
|
||||
### Regenerating the OpenAPI schema
|
||||
|
||||
The file `ui/desktop/openapi.json` is automatically generated during the build.
|
||||
It is written by the `generate_schema` binary in `crates/goose-server`.
|
||||
To update the spec without starting the UI, run:
|
||||
|
||||
```
|
||||
just generate-openapi
|
||||
```
|
||||
|
||||
This command regenerates `ui/desktop/openapi.json` and then runs the UI's
|
||||
`generate-api` script to rebuild the TypeScript client from that spec.
|
||||
|
||||
API changes should be made in the Rust source under `crates/goose-server/src/`.
|
||||
|
||||
### Debugging
|
||||
|
||||
To debug the external ACP backend, run it from an IDE. The configuration will depend on the IDE. The command to run is:
|
||||
To debug the Goose server, run it from an IDE. The configuration will depend on the IDE. The command to run is:
|
||||
|
||||
```
|
||||
export GOOSE_SERVER__SECRET_KEY=test
|
||||
cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
|
||||
cargo run --package goose-server --bin goosed -- agent # or: `just run-server`
|
||||
```
|
||||
|
||||
The `debug-ui` recipe connects to `http://127.0.0.1:3000` by default. If the
|
||||
backend uses another port, set `GOOSE_PORT` when starting the UI, or set
|
||||
`GOOSE_EXTERNAL_BACKEND_URL` to the backend's HTTP base URL.
|
||||
The server listens on port `3000` by default; this can be changed by setting the
|
||||
`GOOSE_PORT` environment variable.
|
||||
|
||||
Once the backend is running, start a UI and connect it to the backend by running:
|
||||
Once the server is running, start a UI and connect it to the server by running:
|
||||
|
||||
```
|
||||
just debug-ui
|
||||
```
|
||||
|
||||
The UI connects to the backend started in the IDE, allowing breakpoints
|
||||
and stepping through the backend code while interacting with the UI.
|
||||
The UI connects to the server started in the IDE, allowing breakpoints
|
||||
and stepping through the server code while interacting with the UI.
|
||||
|
||||
## Creating a fork
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ goose's architecture is designed for extensibility. Organizations can create "re
|
|||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ goose serve (ACP) │
|
||||
│ ACP HTTP/WebSocket server for custom clients │
|
||||
│ goose-server (goosed) │
|
||||
│ REST API for all goose functionality │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
|
|
@ -49,7 +49,7 @@ goose's architecture is designed for extensibility. Organizations can create "re
|
|||
| Bundle custom MCP extensions | `config.yaml` extensions section, `ui/desktop/src/built-in-extensions.json`, `ui/desktop/src/components/settings/extensions/bundled-extensions.json` | Medium |
|
||||
| Modify system prompts | `crates/goose/src/prompts/` | Low |
|
||||
| Customize desktop branding | `ui/desktop/` (icons, names, colors) | Medium |
|
||||
| Build a new UI (web, mobile) | Integrate with `goose serve` over ACP | High |
|
||||
| Build a new UI (web, mobile) | Integrate with `goose-server` REST API | High |
|
||||
| Create guided workflows | Recipes (YAML-based task definitions) | Low |
|
||||
| Build complex multi-step workflows | Recipes with sub-recipes and subagents | Medium |
|
||||
|
||||
|
|
@ -304,32 +304,40 @@ export GOOSE_BUNDLE_NAME="InsightStream-goose"
|
|||
|
||||
**Goal**: Create an entirely new frontend while leveraging goose's backend.
|
||||
|
||||
goose provides two ACP transport options for building custom clients:
|
||||
goose provides two integration options for building custom UIs:
|
||||
|
||||
### Option 1: ACP HTTP/WebSocket (`goose serve`)
|
||||
### Option 1: REST API (goose-server)
|
||||
|
||||
Use `goose serve` for process-separated integrations such as web apps, desktop shells, and other clients:
|
||||
Use goose-server for HTTP-based integrations (web apps, simple clients):
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve
|
||||
./target/release/goosed
|
||||
|
||||
# ACP endpoint available at http://localhost:3284/acp
|
||||
# API available at http://localhost:3000
|
||||
```
|
||||
|
||||
HTTP clients authenticate with the `X-Secret-Key` header. Browser WebSocket clients use the same secret as a `?token=` query parameter because the browser WebSocket API cannot set custom headers:
|
||||
**Reference the OpenAPI spec** at `ui/desktop/openapi.json` for available endpoints:
|
||||
- Session management
|
||||
- Message streaming
|
||||
- Extension control
|
||||
- Configuration
|
||||
|
||||
```text
|
||||
ws://localhost:3284/acp?token=a-long-random-secret
|
||||
**Key endpoints** for a minimal integration:
|
||||
|
||||
```
|
||||
POST /sessions # Create a new session
|
||||
POST /sessions/{id}/messages # Send a message (streaming response)
|
||||
GET /sessions/{id} # Get session state
|
||||
GET /extensions # List available extensions
|
||||
POST /extensions/{name}/enable # Enable an extension
|
||||
```
|
||||
|
||||
For browser clients served from a non-loopback origin, pass the exact UI origin with `--allowed-origin`. When you pass `--allowed-origin`, it replaces the default loopback origin allowlist, so include every origin the client needs.
|
||||
**Handle streaming responses** - goose uses Server-Sent Events (SSE) for real-time responses.
|
||||
|
||||
For the ACP protocol and client flow, see [Agent Client Protocol clients](documentation/docs/guides/acp-clients.md).
|
||||
### Option 2: Agent Client Protocol (ACP)
|
||||
|
||||
### Option 2: Agent Client Protocol (ACP) over stdio
|
||||
|
||||
For richer local integrations (IDEs and embedded agents), run goose as an ACP agent over stdio.
|
||||
For richer integrations (IDEs, desktop apps, embedded agents), use the **Agent Client Protocol (ACP)**—a standardized JSON-RPC protocol for AI agent communication over stdio or other transports.
|
||||
|
||||
ACP provides:
|
||||
- **Bidirectional communication**: Agents can request permissions, stream updates, and receive cancellations
|
||||
|
|
@ -402,6 +410,11 @@ For the full ACP specification, see the [Agent Client Protocol documentation](ht
|
|||
|
||||
### Technical Details
|
||||
|
||||
**REST API (goose-server)**:
|
||||
- Server implementation: `crates/goose-server/src/routes/`
|
||||
- OpenAPI generation: `just generate-openapi`
|
||||
- API client example: `ui/desktop/src/api/` (generated TypeScript client)
|
||||
|
||||
**ACP**:
|
||||
- ACP server implementation: `crates/goose/src/acp/server.rs`
|
||||
- CLI integration: `crates/goose-cli/src/cli.rs` (Command::Acp)
|
||||
|
|
@ -642,10 +655,10 @@ prompt: |
|
|||
- find_patterns: Find similar features to model after
|
||||
```
|
||||
|
||||
Recipes that define `sub_recipes` get the Summon extension automatically. The AI can invoke sub-recipes through Summon's `delegate` tool:
|
||||
The AI can then invoke these sub-recipes using the `subagent` tool:
|
||||
|
||||
```
|
||||
delegate(source: "find_files", parameters: {"search_term": "authentication"})
|
||||
subagent(subrecipe: "find_files", parameters: {"search_term": "authentication"})
|
||||
```
|
||||
|
||||
### Subagents: Dynamic Task Delegation
|
||||
|
|
@ -665,27 +678,28 @@ prompt: |
|
|||
To complete this task:
|
||||
|
||||
1. Spawn a subagent to analyze the frontend code:
|
||||
delegate(instructions: "Analyze all React components in src/components/ and list their props and state management patterns")
|
||||
subagent(instructions: "Analyze all React components in src/components/
|
||||
and list their props and state management patterns")
|
||||
|
||||
2. Spawn another subagent for the backend:
|
||||
delegate(instructions: "Document all API endpoints in src/api/ including their request/response schemas")
|
||||
subagent(instructions: "Document all API endpoints in src/api/
|
||||
including their request/response schemas")
|
||||
|
||||
3. Synthesize findings from both subagents into a unified report.
|
||||
```
|
||||
|
||||
#### Parallel Subagent Execution
|
||||
|
||||
Use `async: true` to run delegates in parallel, then collect each result with `load(source: "<task_id>")`:
|
||||
Multiple subagent calls in the same message execute in parallel:
|
||||
|
||||
```yaml
|
||||
prompt: |
|
||||
Run these analyses in parallel:
|
||||
Run these analyses in parallel by making all subagent calls at once:
|
||||
|
||||
delegate(instructions: "Count lines of code by language", async: true)
|
||||
delegate(instructions: "Find all TODO comments", async: true)
|
||||
delegate(instructions: "List external dependencies", async: true)
|
||||
subagent(instructions: "Count lines of code by language")
|
||||
subagent(instructions: "Find all TODO comments")
|
||||
subagent(instructions: "List external dependencies")
|
||||
|
||||
Use load(source: "<task_id>") for each returned task id.
|
||||
Then combine the results into a codebase health report.
|
||||
```
|
||||
|
||||
|
|
@ -697,18 +711,22 @@ Customize model, provider, or behavior per subagent:
|
|||
prompt: |
|
||||
Use a faster model for simple tasks:
|
||||
|
||||
delegate(
|
||||
subagent(
|
||||
instructions: "List all files modified in the last week",
|
||||
model: "gpt-4o-mini",
|
||||
max_turns: 3
|
||||
settings: {
|
||||
model: "gpt-4o-mini",
|
||||
max_turns: 3
|
||||
}
|
||||
)
|
||||
|
||||
Use the full model for complex analysis:
|
||||
|
||||
delegate(
|
||||
subagent(
|
||||
instructions: "Review this code for security vulnerabilities",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
temperature: 0.1
|
||||
settings: {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
temperature: 0.1
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -720,7 +738,7 @@ Limit which extensions a subagent can access:
|
|||
prompt: |
|
||||
Create a sandboxed subagent with only file reading capabilities:
|
||||
|
||||
delegate(
|
||||
subagent(
|
||||
instructions: "Analyze the README files in this project",
|
||||
extensions: ["developer"] # Only developer extension, no network access
|
||||
)
|
||||
|
|
@ -801,7 +819,7 @@ prompt: |
|
|||
2. **Parallelize independent tasks** - Multiple subagent calls in one message run concurrently
|
||||
3. **Use `sequential_when_repeated: true`** - For tasks that shouldn't run in parallel (e.g., database migrations)
|
||||
4. **Scope extensions appropriately** - Give subagents only the tools they need
|
||||
5. **Use background delegation for independent work** - Pass `async: true` to `delegate`, then collect results with `load(source: "<task_id>")`
|
||||
5. **Use summary mode (default)** - Subagents return concise summaries; use `summary: false` only when you need full conversation history
|
||||
6. **Handle failures gracefully** - Design workflows to continue even if one subagent fails
|
||||
|
||||
### Technical Details
|
||||
|
|
|
|||
2141
Cargo.lock
generated
2141
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,8 +8,8 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "1.43.0"
|
||||
rust-version = "1.94.1"
|
||||
version = "1.40.0"
|
||||
rust-version = "1.91.1"
|
||||
authors = ["AAIF <ai-oss-tools@block.xyz>"]
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/aaif-goose/goose"
|
||||
|
|
@ -31,8 +31,8 @@ async-trait = { version = "0.1.89", default-features = false }
|
|||
axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "tokio", "query"] }
|
||||
base64 = { version = "0.22.1", default-features = false, features = ["std"] }
|
||||
bytes = { version = "1.10.1", default-features = false, features = ["std"] }
|
||||
candle-core = { version = "0.11", default-features = false }
|
||||
candle-nn = { version = "0.11", default-features = false }
|
||||
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 = "6", default-features = false }
|
||||
|
|
|
|||
59
Justfile
59
Justfile
|
|
@ -13,14 +13,18 @@ check-everything:
|
|||
cargo clippy --all-targets -- -D warnings
|
||||
@echo " → Checking UI code formatting..."
|
||||
cd ui/desktop && pnpm run lint:check
|
||||
@echo " → Validating OpenAPI schema..."
|
||||
./scripts/check-openapi-schema.sh
|
||||
@echo ""
|
||||
@echo "✅ All style checks passed!"
|
||||
|
||||
# Default release command
|
||||
release-binary:
|
||||
@echo "Building release version..."
|
||||
cargo build --release -p goose-cli --bin goose
|
||||
cargo build --release
|
||||
@just copy-binary
|
||||
@echo "Generating OpenAPI schema..."
|
||||
cargo run -p goose-server --bin generate_schema
|
||||
|
||||
# Build Windows executable on a Windows host
|
||||
[unix]
|
||||
|
|
@ -30,7 +34,7 @@ release-windows:
|
|||
|
||||
[windows]
|
||||
release-windows:
|
||||
@powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goose.exe"'
|
||||
@powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-server; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goosed.exe"'
|
||||
|
||||
# Build for Intel Mac
|
||||
release-intel:
|
||||
|
|
@ -39,7 +43,14 @@ release-intel:
|
|||
@just copy-binary-intel
|
||||
|
||||
copy-binary BUILD_MODE="release":
|
||||
@rm -f ./ui/desktop/src/bin/goosed
|
||||
@if [ -f ./target/{{BUILD_MODE}}/goosed ]; then \
|
||||
echo "Copying goosed binary from target/{{BUILD_MODE}}..."; \
|
||||
rm -f ./ui/desktop/src/bin/goosed; \
|
||||
cp -p ./target/{{BUILD_MODE}}/goosed ./ui/desktop/src/bin/; \
|
||||
else \
|
||||
echo "Binary not found in target/{{BUILD_MODE}}"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ -f ./target/{{BUILD_MODE}}/goose ]; then \
|
||||
echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \
|
||||
rm -f ./ui/desktop/src/bin/goose; \
|
||||
|
|
@ -51,7 +62,14 @@ copy-binary BUILD_MODE="release":
|
|||
|
||||
# Copy binary command for Intel build
|
||||
copy-binary-intel:
|
||||
@rm -f ./ui/desktop/src/bin/goosed
|
||||
@if [ -f ./target/x86_64-apple-darwin/release/goosed ]; then \
|
||||
echo "Copying Intel goosed binary to ui/desktop/src/bin with permissions preserved..."; \
|
||||
rm -f ./ui/desktop/src/bin/goosed; \
|
||||
cp -p ./target/x86_64-apple-darwin/release/goosed ./ui/desktop/src/bin/; \
|
||||
else \
|
||||
echo "Intel release binary not found."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ -f ./target/x86_64-apple-darwin/release/goose ]; then \
|
||||
echo "Copying Intel goose CLI binary to ui/desktop/src/bin..."; \
|
||||
rm -f ./ui/desktop/src/bin/goose; \
|
||||
|
|
@ -69,11 +87,10 @@ copy-binary-windows:
|
|||
|
||||
[windows]
|
||||
copy-binary-windows:
|
||||
@powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goose.exe) { \
|
||||
@powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goosed.exe) { \
|
||||
Write-Host "Copying Windows binary to ui/desktop/src/bin..."; \
|
||||
New-Item -ItemType Directory -Force "./ui/desktop/src/bin" | Out-Null; \
|
||||
Remove-Item -Path "./ui/desktop/src/bin/goosed.exe" -Force -ErrorAction SilentlyContinue; \
|
||||
Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goose.exe" -Destination "./ui/desktop/src/bin/" -Force; \
|
||||
Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goosed.exe" -Destination "./ui/desktop/src/bin/" -Force; \
|
||||
} else { \
|
||||
Write-Host "Windows binary not found." -ForegroundColor Red; \
|
||||
exit 1; \
|
||||
|
|
@ -99,7 +116,7 @@ run-ui-only:
|
|||
cd ui/desktop && pnpm install && pnpm run start-gui
|
||||
|
||||
debug-ui:
|
||||
@echo "🚀 Starting goose frontend in external ACP backend mode"
|
||||
@echo "🚀 Starting goose frontend in external backend mode"
|
||||
cd ui/desktop && \
|
||||
export GOOSE_EXTERNAL_BACKEND=true && \
|
||||
export GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" && \
|
||||
|
|
@ -144,8 +161,19 @@ run-docs:
|
|||
|
||||
# Run server
|
||||
run-server:
|
||||
@echo "Running external ACP backend..."
|
||||
GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
|
||||
@echo "Running server..."
|
||||
cargo run -p goose-server --bin goosed agent
|
||||
|
||||
# Check if OpenAPI schema is up-to-date
|
||||
check-openapi-schema: generate-openapi
|
||||
./scripts/check-openapi-schema.sh
|
||||
|
||||
# Generate OpenAPI specification without starting the UI
|
||||
generate-openapi:
|
||||
@echo "Generating OpenAPI schema..."
|
||||
cargo run -p goose-server --bin generate_schema
|
||||
@echo "Generating frontend API..."
|
||||
cd ui/desktop && npx @hey-api/openapi-ts
|
||||
|
||||
# Check if generated ACP schema and TypeScript types are up-to-date
|
||||
check-acp-schema: generate-acp-types
|
||||
|
|
@ -290,6 +318,7 @@ bump-version version:
|
|||
@cd ui/desktop && npm pkg set "version={{ version }}"
|
||||
# update Cargo.lock after bumping versions in Cargo.toml
|
||||
@cargo update --workspace
|
||||
@just set-openapi-version {{ version }}
|
||||
|
||||
# rebuild canonical model registry and mapping report from models.dev
|
||||
build-canonical-models:
|
||||
|
|
@ -304,10 +333,14 @@ prepare-release version:
|
|||
Cargo.lock \
|
||||
ui/desktop/package.json \
|
||||
ui/pnpm-lock.yaml \
|
||||
crates/goose-provider-types/src/canonical/data/canonical_models.json \
|
||||
crates/goose-provider-types/src/canonical/data/provider_metadata.json
|
||||
ui/desktop/openapi.json \
|
||||
crates/goose-providers/src/canonical/data/canonical_models.json \
|
||||
crates/goose-providers/src/canonical/data/provider_metadata.json
|
||||
@git commit --message "chore(release): release version {{ version }}"
|
||||
|
||||
set-openapi-version version:
|
||||
@jq '.info.version |= "{{ version }}"' ui/desktop/openapi.json > ui/desktop/openapi.json.tmp && mv ui/desktop/openapi.json.tmp ui/desktop/openapi.json
|
||||
|
||||
# extract version from Cargo.toml
|
||||
get-tag-version:
|
||||
@uvx --from=toml-cli toml get --toml-path=Cargo.toml "workspace.package.version"
|
||||
|
|
@ -342,6 +375,7 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
|||
### profile = --release or "" for debug
|
||||
### allparam = OR/AND/ANY/NONE --workspace --all-features --all-targets
|
||||
win-bld profile allparam:
|
||||
cargo run {{profile}} -p goose-server --bin generate_schema
|
||||
cargo build {{profile}} {{allparam}}
|
||||
|
||||
### Build just debug
|
||||
|
|
@ -370,7 +404,6 @@ win-app-deps:
|
|||
win-copy-win profile:
|
||||
copy target{{s}}{{profile}}{{s}}*.exe ui{{s}}desktop{{s}}src{{s}}bin
|
||||
copy target{{s}}{{profile}}{{s}}*.dll ui{{s}}desktop{{s}}src{{s}}bin
|
||||
if exist ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe del /f /q ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe
|
||||
|
||||
### "Other" copy {release|debug} files to ui/desktop/src/bin
|
||||
### s = os dependent file separator
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> **🦆 goose has moved!** This project has moved from `block/goose` to the [Agentic AI Foundation (AAIF)](https://aaif.io/) at the Linux Foundation. Some links and references are still being updated — please bear with us during the transition.
|
||||
|
||||
<div align="center">
|
||||
|
||||
# goose
|
||||
|
|
@ -14,12 +16,8 @@ _your native open source AI agent — desktop app, CLI, and API — for code, wo
|
|||
<a href="https://insights.linuxfoundation.org/project/goose"><img src="https://insights.linuxfoundation.org/api/badge/health-score?project=goose"></a>
|
||||
<a href="https://repology.org/project/goose-cli/versions"><img src="https://repology.org/badge/tiny-repos/goose-cli.svg" alt="Packaging status"></a>
|
||||
</p>
|
||||
|
||||
<a href="https://trendshift.io/repositories/25298?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-25298" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25298" alt="aaif-goose%2Fgoose | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
goose is a general-purpose AI agent that runs on your machine. Not just for code — use it for research, writing, automation, data analysis, or anything you need to get done.
|
||||
|
||||
A native desktop app for macOS, Linux, and Windows. A full CLI for terminal workflows. An API to embed it anywhere. Built in Rust for performance and portability.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,134 @@
|
|||
# goose Release Manual Testing Checklist
|
||||
|
||||
Download the release builds from this PR. Once a build is ready, the actions bot will post a comment on this PR
|
||||
with instructions on how to download and sign.
|
||||
## Version: {{VERSION}}
|
||||
|
||||
### Identify the high risk changes in this Release
|
||||
|
||||
## Use the following script to create a risk assessment and testing plan:
|
||||
```
|
||||
./workflow_recipes/release_risk_check/run.sh {{VERSION}}
|
||||
```
|
||||
|
||||
It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes.
|
||||
It will generate an analysis report in `/tmp/release_report_final.md` and perform testing is necessary for high risk pr changes.
|
||||
|
||||
## Run the goose self-test recipe
|
||||
|
||||
goose run --recipe goose-self-test.yaml
|
||||
## Regression Testing
|
||||
|
||||
## Have goose produce a test plan
|
||||
Make a copy of this document for each version and check off as steps are verified.
|
||||
|
||||
Open the release candidate desktop app and have goose produce a test plan by pointing it at this PR. Use a prompt like
|
||||
### Provider Testing
|
||||
|
||||
> Look at the notes in PR <release PR> and the report at `/tmp/release_report_final.md` and investigate potential risks in this release. After familiarizing yourself with the scope of each change, produce a suggested test plan that I should follow before publishing the release.
|
||||
- [ ] Run `cd ui/desktop && pnpm run test:integration:providers` locally from the release branch and verify all providers/models work
|
||||
- [ ] Launch goose, click reset providers, choose databricks and a model
|
||||
|
||||
goose will produce a plan. Follow this plan to finish testing.
|
||||
### Starting Conversations
|
||||
|
||||
Test various ways to start a conversation:
|
||||
|
||||
- [ ] Open home and start a new conversation with "Hello"
|
||||
- [ ] Agent responds
|
||||
- [ ] Token count is updated after agent finishes
|
||||
- [ ] Go to history and see there is a new entry
|
||||
- [ ] Go back to the main screen, start a new conversation from the hub and see that it opens a new conversation
|
||||
- [ ] Open history and click the Hello conversation - verify it loads
|
||||
- [ ] Add a new message to this conversation and see that it is added
|
||||
- [ ] Change the working directory of an existing conversation
|
||||
- [ ] Ask "what is your working directory?"
|
||||
- [ ] Response should match the new directory
|
||||
- [ ] Open a new window, click chat in left side for new chat
|
||||
- [ ] Type "create a tamagotchi game" in the chat input to test developer extension
|
||||
|
||||
### Recipes
|
||||
|
||||
#### Create Recipe from Session
|
||||
|
||||
- [ ] Start a simple chat conversation like "hi"
|
||||
- [ ] Click "create a recipe from this session" in the bottom chat bar
|
||||
- [ ] Recipe title, description and instructions should be filled in with details from the chat
|
||||
- [ ] Add a few activities and params (params unused indicator should update if added to instructions/prompts or activities)
|
||||
- [ ] Can launch create and run recipe - launches in a new window showing as a recipe agent chat with parameters filled in and interact with it
|
||||
- [ ] Recipe should be saved in recipe library
|
||||
|
||||
#### Use Existing Recipe
|
||||
|
||||
- [ ] Pick trip planner from recipe hub (go/gooserecipes)
|
||||
- [ ] See the warning whether to trust this recipe (only on fresh install)
|
||||
- [ ] See the form pop up
|
||||
- [ ] Fill in the form with "Africa" and "14 days"
|
||||
- [ ] Check results are reasonable
|
||||
- [ ] Ask how many days the trip is for - should say 14
|
||||
|
||||
#### Recipe Management
|
||||
|
||||
- [ ] Go to recipe manager and enter a new recipe to generate a joke
|
||||
- [ ] See that it works if you run it
|
||||
- [ ] Edit the recipe by bottom bar and click "View/Edit Recipe"
|
||||
- [ ] Make it generate a limerick instead
|
||||
- [ ] Check that the updated recipe works
|
||||
- [ ] Delete the recipe from the recipe manager
|
||||
- [ ] Verify recipe is actually deleted
|
||||
|
||||
#### Recipe from File
|
||||
|
||||
- [ ] Create a file `~/.config/goose/recipes/test-recipe.yaml` with the following content:
|
||||
|
||||
```yaml
|
||||
recipe:
|
||||
title: test recipe again
|
||||
description: testing recipe again
|
||||
instructions: The value of test_param is {{test_param}}
|
||||
prompt: What is the value of test_param?
|
||||
parameters:
|
||||
- key: test_param
|
||||
input_type: string
|
||||
requirement: required
|
||||
description: Enter value for test_param
|
||||
```
|
||||
|
||||
- [ ] See that it shows up in the list of installed recipes
|
||||
- [ ] Launch the recipe, see that it asks for test_param
|
||||
- [ ] Enter a number, see that it pre-fills the prompt and tells you the value after you hit submit
|
||||
- [ ] Go to hub and enter "what is the value of test_param"
|
||||
- [ ] See a new chat that says it has no idea (recipe is no longer active)
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Manual Extension Addition
|
||||
|
||||
- [ ] Can manually add an extension using random quotes from project
|
||||
- [ ] Add new custom stdio extension with the following command and save:
|
||||
- [ ] `node /ABSOLUTE/PATH/TO/goose/ui/desktop/tests/e2e/basic-mcp.ts` (use your actual project path)
|
||||
- [ ] Should add and can chat to ask for a random quote
|
||||
|
||||
#### Playwright Extension
|
||||
|
||||
- [ ] Install the playwright extension from the extensions hub
|
||||
- [ ] Tell it to open a browser and search on Google for cats
|
||||
- [ ] Verify that the browser opens and navigates
|
||||
|
||||
#### Extension with Environment Variables
|
||||
|
||||
- [ ] Install an extension from deeplink that needs env variables:
|
||||
- [ ] Use: `goose://extension?cmd=npx&arg=-y&arg=%40upstash%2Fcontext7-mcp&id=context7&name=Context7&description=Use%20up-to-date%20code%20and%20docs&env=TEST_ACCESS_TOKEN`
|
||||
- [ ] Extension page should load with env variables modal showing
|
||||
- [ ] Allow form input and saving extension
|
||||
|
||||
### Speech-to-Text (Local Model)
|
||||
|
||||
- [ ] Go to Settings > Chat > Voice dictation provider and select the small model
|
||||
- [ ] Run a quick test that speech-to-text is working (click the mic button, speak, verify transcription)
|
||||
- [ ] Also try OpenAI using your OpenAI key
|
||||
|
||||
### Settings
|
||||
|
||||
- [ ] Settings page loads and all tabs load
|
||||
- [ ] Can change dark mode setting
|
||||
|
||||
### Follow-up Issues
|
||||
|
||||
Link any GitHub issues filed during testing:
|
||||
|
||||
---
|
||||
|
||||
**Tested by:** _____
|
||||
**Date:** _____
|
||||
**Notes:** _____
|
||||
|
|
|
|||
|
|
@ -62,9 +62,8 @@ urlencoding = { workspace = true }
|
|||
clap_complete = { version = "4", default-features = false }
|
||||
comfy-table = { version = "7", default-features = false }
|
||||
sha2 = { workspace = true }
|
||||
sigstore-verify = { version = "0.10", default-features = false, optional = true }
|
||||
sigstore-verify = { version = "0.9", default-features = false, optional = true }
|
||||
axum.workspace = true
|
||||
axum-server = { version = "0.8", default-features = false, optional = true }
|
||||
clap_complete_nushell = { version = "4", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
|
@ -100,18 +99,14 @@ portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel", "tui"]
|
|||
# disables the update command
|
||||
disable-update = []
|
||||
rustls-tls = [
|
||||
"dep:axum-server",
|
||||
"reqwest/rustls",
|
||||
"axum-server/tls-rustls",
|
||||
"sigstore-verify?/rustls",
|
||||
"goose/rustls-tls",
|
||||
"goose-mcp/rustls-tls",
|
||||
"goose-providers/rustls-tls",
|
||||
]
|
||||
native-tls = [
|
||||
"dep:axum-server",
|
||||
"reqwest/native-tls",
|
||||
"axum-server/tls-openssl",
|
||||
"sigstore-verify?/native-tls",
|
||||
"goose/native-tls",
|
||||
"goose-mcp/native-tls",
|
||||
|
|
|
|||
|
|
@ -51,22 +51,6 @@ fn generate_serve_secret_key() -> String {
|
|||
)
|
||||
}
|
||||
|
||||
#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum ServePlatform {
|
||||
#[default]
|
||||
Cli,
|
||||
Desktop,
|
||||
}
|
||||
|
||||
impl From<ServePlatform> for GoosePlatform {
|
||||
fn from(platform: ServePlatform) -> Self {
|
||||
match platform {
|
||||
ServePlatform::Cli => GoosePlatform::GooseCli,
|
||||
ServePlatform::Desktop => GoosePlatform::GooseDesktop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "goose", author, version, display_name = "", about, long_about = None)]
|
||||
pub struct Cli {
|
||||
|
|
@ -408,9 +392,7 @@ async fn get_or_create_session_id(
|
|||
|
||||
let resolved_id = if resume {
|
||||
let Some(id) = identifier else {
|
||||
let sessions = session_manager
|
||||
.list_sessions_by_types(&[SessionType::User])
|
||||
.await?;
|
||||
let sessions = session_manager.list_sessions().await?;
|
||||
let session_id = sessions
|
||||
.first()
|
||||
.map(|s| s.id.clone())
|
||||
|
|
@ -849,18 +831,6 @@ enum Command {
|
|||
#[arg(long, default_value = "3284")]
|
||||
port: u16,
|
||||
|
||||
#[arg(long, help = "Serve ACP over TLS")]
|
||||
tls: bool,
|
||||
|
||||
#[arg(long = "tls-cert-path", value_name = "PATH")]
|
||||
tls_cert_path: Option<String>,
|
||||
|
||||
#[arg(long = "tls-key-path", value_name = "PATH")]
|
||||
tls_key_path: Option<String>,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = ServePlatform::Cli)]
|
||||
platform: ServePlatform,
|
||||
|
||||
#[arg(
|
||||
long = "with-builtin",
|
||||
value_name = "NAME",
|
||||
|
|
@ -870,20 +840,6 @@ enum Command {
|
|||
action = clap::ArgAction::Append
|
||||
)]
|
||||
builtins: Vec<String>,
|
||||
|
||||
#[arg(
|
||||
long = "dangerously-unauthenticated",
|
||||
help = "Start the ACP endpoint without requiring GOOSE_SERVER__SECRET_KEY"
|
||||
)]
|
||||
dangerously_unauthenticated: bool,
|
||||
|
||||
#[arg(
|
||||
long = "allowed-origin",
|
||||
value_name = "ORIGIN",
|
||||
action = clap::ArgAction::Append,
|
||||
help = "Allow an exact Origin value for ACP CORS; may be specified multiple times and replaces the default loopback origins"
|
||||
)]
|
||||
allowed_origins: Vec<String>,
|
||||
},
|
||||
|
||||
/// Start or resume interactive chat sessions
|
||||
|
|
@ -916,15 +872,6 @@ enum Command {
|
|||
)]
|
||||
fork: bool,
|
||||
|
||||
/// Open the session's conversation in $EDITOR before starting
|
||||
#[arg(
|
||||
long,
|
||||
requires = "resume",
|
||||
help = "Edit the session conversation in $EDITOR before starting",
|
||||
long_help = "Open the session's conversation in your editor ($VISUAL / $EDITOR / vi) for modification before resuming. When combined with --fork, creates a new session from the edited result."
|
||||
)]
|
||||
edit: bool,
|
||||
|
||||
/// Show message history when resuming
|
||||
#[arg(
|
||||
long,
|
||||
|
|
@ -1377,21 +1324,7 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
struct ServeCommandArgs {
|
||||
host: String,
|
||||
|
||||
port: u16,
|
||||
tls: bool,
|
||||
tls_cert_path: Option<String>,
|
||||
tls_key_path: Option<String>,
|
||||
platform: ServePlatform,
|
||||
builtins: Vec<String>,
|
||||
dangerously_unauthenticated: bool,
|
||||
allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
|
||||
use axum::http::HeaderValue;
|
||||
async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) -> Result<()> {
|
||||
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
|
||||
use goose::acp::transport::create_router;
|
||||
use goose::config::paths::Paths;
|
||||
|
|
@ -1399,18 +1332,6 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
|
|||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
let ServeCommandArgs {
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
tls_cert_path,
|
||||
tls_key_path,
|
||||
platform,
|
||||
builtins,
|
||||
dangerously_unauthenticated,
|
||||
allowed_origins,
|
||||
} = args;
|
||||
|
||||
let builtins = if builtins.is_empty() {
|
||||
vec!["developer".to_string()]
|
||||
} else {
|
||||
|
|
@ -1433,93 +1354,32 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
|
|||
builtins,
|
||||
data_dir: Paths::data_dir(),
|
||||
config_dir: Paths::config_dir(),
|
||||
goose_platform: platform.into(),
|
||||
goose_platform: GoosePlatform::GooseCli,
|
||||
additional_source_roots,
|
||||
scheduler: None,
|
||||
}));
|
||||
let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV)
|
||||
.ok()
|
||||
.map(|secret| secret.trim().to_string())
|
||||
.filter(|secret| !secret.is_empty());
|
||||
let require_token = env_secret.is_some();
|
||||
if !require_token && !dangerously_unauthenticated {
|
||||
anyhow::bail!(
|
||||
"{GOOSE_SERVER_SECRET_KEY_ENV} must be set to start `goose serve`; pass --dangerously-unauthenticated to run without ACP authentication"
|
||||
);
|
||||
}
|
||||
if dangerously_unauthenticated && !require_token {
|
||||
if !require_token {
|
||||
warn!(
|
||||
"{GOOSE_SERVER_SECRET_KEY_ENV} is not set and --dangerously-unauthenticated was passed; the ACP endpoint will accept unauthenticated connections"
|
||||
"{GOOSE_SERVER_SECRET_KEY_ENV} is not set; the ACP endpoint will accept unauthenticated connections"
|
||||
);
|
||||
}
|
||||
let additional_allowed_origins = allowed_origins
|
||||
.into_iter()
|
||||
.map(|origin| {
|
||||
let origin = origin.trim();
|
||||
if origin.is_empty() || origin == "*" {
|
||||
anyhow::bail!("--allowed-origin must be a non-wildcard Origin value");
|
||||
}
|
||||
HeaderValue::from_str(origin).map_err(|error| {
|
||||
anyhow::anyhow!("invalid --allowed-origin value `{origin}`: {error}")
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key);
|
||||
let router = create_router(
|
||||
server,
|
||||
secret_key,
|
||||
require_token,
|
||||
additional_allowed_origins,
|
||||
);
|
||||
|
||||
let config = Config::global();
|
||||
let tls_cert_path =
|
||||
tls_cert_path.or_else(|| config.get_param::<String>("GOOSE_TLS_CERT_PATH").ok());
|
||||
let tls_key_path =
|
||||
tls_key_path.or_else(|| config.get_param::<String>("GOOSE_TLS_KEY_PATH").ok());
|
||||
let tls = tls
|
||||
|| config.get_param::<bool>("GOOSE_TLS").unwrap_or(false)
|
||||
|| tls_cert_path.is_some()
|
||||
|| tls_key_path.is_some();
|
||||
let router = create_router(server, secret_key, require_token);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
||||
if tls {
|
||||
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
|
||||
{
|
||||
let tls_setup = goose::acp::transport::tls::setup_tls(
|
||||
tls_cert_path.as_deref(),
|
||||
tls_key_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
info!("Starting ACP server on https://{}", addr);
|
||||
info!("Starting ACP server on {}", addr);
|
||||
|
||||
#[cfg(feature = "rustls-tls")]
|
||||
axum_server::bind_rustls(addr, tls_setup.config)
|
||||
.serve(router.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "native-tls")]
|
||||
axum_server::bind_openssl(addr, tls_setup.config)
|
||||
.serve(router.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await?;
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
|
||||
{
|
||||
let _ = (tls_cert_path, tls_key_path);
|
||||
anyhow::bail!(
|
||||
"TLS was requested but no TLS backend is enabled. \
|
||||
Enable the `rustls-tls` or `native-tls` feature."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
info!("Starting ACP server on http://{}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1604,7 +1464,6 @@ async fn handle_interactive_session(
|
|||
identifier: Option<Identifier>,
|
||||
resume: bool,
|
||||
fork: bool,
|
||||
edit: bool,
|
||||
history: bool,
|
||||
session_opts: SessionOptions,
|
||||
extension_opts: ExtensionOptions,
|
||||
|
|
@ -1644,31 +1503,12 @@ async fn handle_interactive_session(
|
|||
let goose_mode = Config::global().get_goose_mode().unwrap_or_default();
|
||||
let mut session_id = get_or_create_session_id(identifier, resume, false, goose_mode).await?;
|
||||
|
||||
if edit || fork {
|
||||
if let Some(ref id) = session_id {
|
||||
if fork {
|
||||
if let Some(id) = session_id {
|
||||
let session_manager = SessionManager::instance();
|
||||
let original = session_manager.get_session(id, true).await?;
|
||||
|
||||
let target_id = if fork {
|
||||
let copied = session_manager
|
||||
.copy_session(id, original.name.clone())
|
||||
.await?;
|
||||
let copied_id = copied.id.clone();
|
||||
session_id = Some(copied.id);
|
||||
copied_id
|
||||
} else {
|
||||
id.clone()
|
||||
};
|
||||
|
||||
if edit {
|
||||
let conversation = original
|
||||
.conversation
|
||||
.ok_or_else(|| anyhow::anyhow!("session has no messages to edit"))?;
|
||||
let edited = crate::session::editor::edit_conversation(&conversation)?;
|
||||
session_manager
|
||||
.replace_conversation(&target_id, &edited)
|
||||
.await?;
|
||||
}
|
||||
let original = session_manager.get_session(&id, false).await?;
|
||||
let copied = session_manager.copy_session(&id, original.name).await?;
|
||||
session_id = Some(copied.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2039,8 +1879,6 @@ 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;
|
||||
|
||||
goose::providers::local_inference::configure_huggingface_auth();
|
||||
|
||||
match command {
|
||||
LocalModelsCommand::Search { query, limit } => {
|
||||
println!("Searching HuggingFace for '{}'...", query);
|
||||
|
|
@ -2232,27 +2070,8 @@ pub async fn cli() -> anyhow::Result<()> {
|
|||
Some(Command::Serve {
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
tls_cert_path,
|
||||
tls_key_path,
|
||||
platform,
|
||||
builtins,
|
||||
dangerously_unauthenticated,
|
||||
allowed_origins,
|
||||
}) => {
|
||||
handle_serve_command(ServeCommandArgs {
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
tls_cert_path,
|
||||
tls_key_path,
|
||||
platform,
|
||||
builtins,
|
||||
dangerously_unauthenticated,
|
||||
allowed_origins,
|
||||
})
|
||||
.await
|
||||
}
|
||||
}) => handle_serve_command(host, port, builtins).await,
|
||||
Some(Command::Session {
|
||||
command: Some(cmd), ..
|
||||
}) => handle_session_subcommand(cmd).await,
|
||||
|
|
@ -2261,7 +2080,6 @@ pub async fn cli() -> anyhow::Result<()> {
|
|||
identifier,
|
||||
resume,
|
||||
fork,
|
||||
edit,
|
||||
history,
|
||||
session_opts,
|
||||
extension_opts,
|
||||
|
|
@ -2270,7 +2088,6 @@ pub async fn cli() -> anyhow::Result<()> {
|
|||
identifier,
|
||||
resume,
|
||||
fork,
|
||||
edit,
|
||||
history,
|
||||
session_opts,
|
||||
extension_opts,
|
||||
|
|
@ -2449,35 +2266,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_command_accepts_dangerously_unauthenticated_flag() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"goose",
|
||||
"serve",
|
||||
"--dangerously-unauthenticated",
|
||||
"--allowed-origin",
|
||||
"app://localhost",
|
||||
"--allowed-origin",
|
||||
"https://app.example",
|
||||
])
|
||||
.expect("parse failed");
|
||||
|
||||
match cli.command {
|
||||
Some(Command::Serve {
|
||||
dangerously_unauthenticated,
|
||||
allowed_origins,
|
||||
..
|
||||
}) => {
|
||||
assert!(dangerously_unauthenticated);
|
||||
assert_eq!(
|
||||
allowed_origins,
|
||||
vec!["app://localhost", "https://app.example"]
|
||||
);
|
||||
}
|
||||
_ => panic!("expected serve command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_command_accepts_options() {
|
||||
let cli = Cli::try_parse_from([
|
||||
|
|
|
|||
|
|
@ -28,22 +28,11 @@ use goose::session::SessionType;
|
|||
use goose_providers::thinking::ThinkingEffort;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::io::IsTerminal;
|
||||
|
||||
// useful for light themes where there is no discernible colour contrast between
|
||||
// cursor-selected and cursor-unselected items.
|
||||
const MULTISELECT_VISIBILITY_HINT: &str = "<";
|
||||
const SHOW_CURSOR: &[u8] = b"\x1b[?25h";
|
||||
|
||||
struct CursorRestoreGuard;
|
||||
|
||||
impl Drop for CursorRestoreGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let _ = stdout.write_all(SHOW_CURSOR);
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_configure() -> anyhow::Result<()> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
|
|
@ -53,7 +42,6 @@ pub async fn handle_configure() -> anyhow::Result<()> {
|
|||
);
|
||||
}
|
||||
|
||||
let _cursor_restore = CursorRestoreGuard;
|
||||
let config = Config::global();
|
||||
|
||||
if !config.exists() {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pub fn handle_project_default() -> Result<()> {
|
|||
}
|
||||
|
||||
// Sort projects by last_accessed (newest first)
|
||||
projects.sort_by_key(|project| std::cmp::Reverse(project.last_accessed));
|
||||
projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
|
||||
|
||||
// Get the most recent project
|
||||
let project = &projects[0];
|
||||
|
|
@ -178,7 +178,7 @@ pub fn handle_projects_interactive() -> Result<()> {
|
|||
}
|
||||
|
||||
// Sort projects by last_accessed (newest first)
|
||||
projects.sort_by_key(|project| std::cmp::Reverse(project.last_accessed));
|
||||
projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
|
||||
|
||||
// Format project paths for display
|
||||
let project_choices: Vec<(String, String)> = projects
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ enum TuiSource {
|
|||
|
||||
fn find_local_script() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
find_local_script_from(&exe)
|
||||
}
|
||||
|
||||
fn find_local_script_from(exe: &Path) -> Option<PathBuf> {
|
||||
let exe_dir = exe.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut dir = Some(exe_dir.to_path_buf());
|
||||
|
|
@ -31,6 +27,13 @@ fn find_local_script_from(exe: &Path) -> Option<PathBuf> {
|
|||
}
|
||||
}
|
||||
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
let candidate = cwd.join(TUI_REL_PATH);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
|
@ -94,34 +97,3 @@ pub fn handle_tui(args: Vec<String>) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn find_local_script_ignores_unrelated_directories() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let executable = temp_dir.path().join("install/bin/goose");
|
||||
let planted_script = temp_dir.path().join("checkout").join(TUI_REL_PATH);
|
||||
fs::create_dir_all(planted_script.parent().unwrap()).expect("create script directory");
|
||||
fs::write(&planted_script, "process.exit(0)\n").expect("write planted script");
|
||||
|
||||
assert_eq!(find_local_script_from(&executable), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_local_script_accepts_executable_ancestor() {
|
||||
let temp_dir = tempfile::tempdir().expect("create temp dir");
|
||||
let executable = temp_dir.path().join("target/debug/goose");
|
||||
let bundled_script = temp_dir.path().join(TUI_REL_PATH);
|
||||
fs::create_dir_all(bundled_script.parent().unwrap()).expect("create script directory");
|
||||
fs::write(&bundled_script, "process.exit(0)\n").expect("write bundled script");
|
||||
|
||||
assert_eq!(
|
||||
find_local_script_from(&executable).as_deref(),
|
||||
Some(bundled_script.as_path())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use reqwest::{
|
||||
header::{HeaderValue, AUTHORIZATION},
|
||||
StatusCode,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sigstore_verify::trust_root::{TrustedRoot, SIGSTORE_PRODUCTION_TRUSTED_ROOT};
|
||||
use sigstore_verify::types::{Bundle, Sha256Hash};
|
||||
|
|
@ -83,32 +79,6 @@ struct AttestationEntry {
|
|||
|
||||
const GITHUB_ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com";
|
||||
|
||||
fn sanitized_token(token: Option<&str>) -> Option<&str> {
|
||||
token.map(str::trim).filter(|tok| !tok.is_empty())
|
||||
}
|
||||
|
||||
fn authorization_header_value(token: &str) -> Option<HeaderValue> {
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).ok()
|
||||
}
|
||||
|
||||
fn github_token() -> Option<String> {
|
||||
env::var("GITHUB_TOKEN")
|
||||
.ok()
|
||||
.and_then(|tok| sanitized_token(Some(&tok)).map(str::to_owned))
|
||||
.or_else(|| {
|
||||
env::var("GH_TOKEN")
|
||||
.ok()
|
||||
.and_then(|tok| sanitized_token(Some(&tok)).map(str::to_owned))
|
||||
})
|
||||
}
|
||||
|
||||
fn should_retry_attestations_without_token(status: StatusCode, token: Option<&str>) -> bool {
|
||||
sanitized_token(token)
|
||||
.and_then(authorization_header_value)
|
||||
.is_some()
|
||||
&& matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
|
||||
}
|
||||
|
||||
async fn fetch_attestations(digest: &str, token: Option<&str>) -> Result<Vec<serde_json::Value>> {
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/aaif-goose/goose/attestations/sha256:{digest}\
|
||||
|
|
@ -116,14 +86,17 @@ async fn fetch_attestations(digest: &str, token: Option<&str>) -> Result<Vec<ser
|
|||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let token = sanitized_token(token);
|
||||
let resp = fetch_attestations_response(&client, &url, token).await?;
|
||||
let mut req = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.header("User-Agent", "goose-cli");
|
||||
|
||||
let resp = if should_retry_attestations_without_token(resp.status(), token) {
|
||||
fetch_attestations_response(&client, &url, None).await?
|
||||
} else {
|
||||
resp
|
||||
};
|
||||
if let Some(tok) = token {
|
||||
req = req.header("Authorization", format!("Bearer {tok}"));
|
||||
}
|
||||
|
||||
let resp = req.send().await.context("Failed to fetch attestations")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!("GitHub attestation API returned HTTP {}", resp.status());
|
||||
|
|
@ -137,24 +110,6 @@ async fn fetch_attestations(digest: &str, token: Option<&str>) -> Result<Vec<ser
|
|||
Ok(body.attestations.into_iter().map(|a| a.bundle).collect())
|
||||
}
|
||||
|
||||
async fn fetch_attestations_response(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<reqwest::Response> {
|
||||
let mut req = client
|
||||
.get(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.header("User-Agent", "goose-cli");
|
||||
|
||||
if let Some(value) = token.and_then(authorization_header_value) {
|
||||
req = req.header(AUTHORIZATION, value);
|
||||
}
|
||||
|
||||
req.send().await.context("Failed to fetch attestations")
|
||||
}
|
||||
|
||||
// Verify a single attestation bundle against the artifact digest and workflow.
|
||||
fn verify_bundle(
|
||||
bundle_json: &serde_json::Value,
|
||||
|
|
@ -187,8 +142,8 @@ fn verify_bundle(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `Ok(())` when the downloaded archive has verified provenance.
|
||||
async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<()> {
|
||||
/// Returns `Ok(true)` verified, `Ok(false)` skipped (soft warning), `Err` hard failure.
|
||||
async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<bool> {
|
||||
let digest = sha256_hex(archive_data);
|
||||
println!("Archive SHA-256: {digest}");
|
||||
|
||||
|
|
@ -197,19 +152,30 @@ async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<()> {
|
|||
_ => "release.yml",
|
||||
};
|
||||
|
||||
let token = github_token();
|
||||
let token = env::var("GITHUB_TOKEN")
|
||||
.ok()
|
||||
.or_else(|| env::var("GH_TOKEN").ok());
|
||||
|
||||
println!("Verifying SLSA provenance via Sigstore...");
|
||||
|
||||
let bundles = fetch_attestations(&digest, token.as_deref())
|
||||
.await
|
||||
.context(
|
||||
"Sigstore provenance check could not complete; refusing to install unverifiable update",
|
||||
)?;
|
||||
|
||||
if bundles.is_empty() {
|
||||
bail!("No Sigstore attestation found for downloaded archive; refusing to install unverifiable update");
|
||||
}
|
||||
let bundles = match fetch_attestations(&digest, token.as_deref()).await {
|
||||
Ok(b) if b.is_empty() => {
|
||||
eprintln!(
|
||||
"Warning: No Sigstore attestation found for this build. \
|
||||
This may be expected for canary or nightly builds."
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Warning: Sigstore provenance check could not complete: {e}\n\
|
||||
This may be expected for releases published before provenance \
|
||||
attestations were enabled."
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let trusted_root = TrustedRoot::from_json(SIGSTORE_PRODUCTION_TRUSTED_ROOT)
|
||||
.context("Failed to load Sigstore trusted root")?;
|
||||
|
|
@ -229,7 +195,7 @@ async fn verify_provenance(archive_data: &[u8], tag: &str) -> Result<()> {
|
|||
) {
|
||||
Ok(()) => {
|
||||
println!("Sigstore provenance verification passed.");
|
||||
return Ok(());
|
||||
return Ok(true);
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
|
|
@ -281,7 +247,7 @@ pub async fn update(canary: bool, reconfigure: bool) -> Result<()> {
|
|||
println!("Downloaded {} bytes.", bytes.len());
|
||||
|
||||
// --- Verify SLSA provenance via Sigstore --------------------------------
|
||||
verify_provenance(&bytes, tag).await?;
|
||||
let provenance_verified = verify_provenance(&bytes, tag).await?;
|
||||
|
||||
// --- Extract to temp dir (hardened against path traversal) --------------
|
||||
let tmp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
|
||||
|
|
@ -308,7 +274,11 @@ pub async fn update(canary: bool, reconfigure: bool) -> Result<()> {
|
|||
#[cfg(target_os = "windows")]
|
||||
copy_dlls(&extracted_binary, ¤t_exe)?;
|
||||
|
||||
println!("goose updated successfully (verified with Sigstore SLSA provenance).");
|
||||
if provenance_verified {
|
||||
println!("goose updated successfully (verified with Sigstore SLSA provenance).");
|
||||
} else {
|
||||
println!("goose updated successfully.");
|
||||
}
|
||||
|
||||
// --- Reconfigure if requested -------------------------------------------
|
||||
if reconfigure {
|
||||
|
|
@ -767,48 +737,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitized_token_trims_blank_values() {
|
||||
assert_eq!(sanitized_token(None), None);
|
||||
assert_eq!(sanitized_token(Some("")), None);
|
||||
assert_eq!(sanitized_token(Some(" ")), None);
|
||||
assert_eq!(sanitized_token(Some(" token\n")), Some("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authorization_header_value_rejects_malformed_tokens() {
|
||||
assert!(authorization_header_value("token").is_some());
|
||||
assert!(authorization_header_value("bad\ntoken").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attestation_lookup_retries_auth_failures_without_token() {
|
||||
assert!(should_retry_attestations_without_token(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Some("token")
|
||||
));
|
||||
assert!(should_retry_attestations_without_token(
|
||||
StatusCode::FORBIDDEN,
|
||||
Some("token")
|
||||
));
|
||||
assert!(!should_retry_attestations_without_token(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Some("token")
|
||||
));
|
||||
assert!(!should_retry_attestations_without_token(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Some("")
|
||||
));
|
||||
assert!(!should_retry_attestations_without_token(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Some("bad\ntoken")
|
||||
));
|
||||
assert!(!should_retry_attestations_without_token(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Path validation and extraction hardening tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -880,11 +808,13 @@ mod tests {
|
|||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_provenance_fails_closed_when_unverifiable() {
|
||||
async fn test_verify_provenance_warns_on_missing_attestation() {
|
||||
let result = verify_provenance(b"not a real archive", "stable").await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"verify_provenance must fail closed when provenance cannot be verified"
|
||||
// Network failures and missing attestations are soft warnings: Ok(false), not hard errors.
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(false),
|
||||
"verify_provenance should return Ok(false) when attestations cannot be fetched"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
#![recursion_limit = "256"]
|
||||
|
||||
#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
|
||||
compile_error!("At least one of `rustls-tls` or `native-tls` features must be enabled");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
#![recursion_limit = "256"]
|
||||
|
||||
use anyhow::Result;
|
||||
use goose_cli::cli::cli;
|
||||
|
||||
|
|
|
|||
|
|
@ -333,10 +333,7 @@ async fn resolve_session_id(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
match session_manager
|
||||
.list_sessions_by_types(&[SessionType::User])
|
||||
.await
|
||||
{
|
||||
match session_manager.list_sessions().await {
|
||||
Ok(sessions) if !sessions.is_empty() => sessions[0].id.clone(),
|
||||
_ => {
|
||||
output::render_error("Cannot resume - no previous sessions found");
|
||||
|
|
@ -698,8 +695,6 @@ fn is_provider_unavailable_error(e: &anyhow::Error) -> bool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use goose::session::SessionManager;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_session_builder_config_creation() {
|
||||
|
|
@ -763,44 +758,6 @@ mod tests {
|
|||
assert!(!config.fork);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_implicit_resume_ignores_newer_scheduled_sessions() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let session_manager = SessionManager::new(temp_dir.path().to_path_buf());
|
||||
let goose_mode = GooseMode::default();
|
||||
|
||||
let user_session = session_manager
|
||||
.create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
"User session".to_string(),
|
||||
SessionType::User,
|
||||
goose_mode,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
session_manager
|
||||
.create_session(
|
||||
temp_dir.path().to_path_buf(),
|
||||
"Scheduled job: test".to_string(),
|
||||
SessionType::Scheduled,
|
||||
goose_mode,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_session_id(
|
||||
&SessionBuilderConfig {
|
||||
resume: true,
|
||||
..SessionBuilderConfig::default()
|
||||
},
|
||||
&session_manager,
|
||||
goose_mode,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resolved, user_session.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_with_ellipsis() {
|
||||
assert_eq!(truncate_with_ellipsis("abc", 5), "abc");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use goose::agents::execute_commands::list_commands;
|
||||
use goose::config::GooseMode;
|
||||
use rustyline::completion::{Completer, FilenameCompleter, Pair};
|
||||
use rustyline::highlight::{CmdKind, Highlighter};
|
||||
|
|
@ -37,8 +36,8 @@ impl GooseCompleter {
|
|||
// Create completion candidates that match the prefix
|
||||
let candidates: Vec<Pair> = cache
|
||||
.prompts
|
||||
.values()
|
||||
.flatten()
|
||||
.iter()
|
||||
.flat_map(|(_, names)| names)
|
||||
.filter(|name| name.starts_with(prefix.trim()))
|
||||
.map(|name| Pair {
|
||||
display: name.clone(),
|
||||
|
|
@ -153,25 +152,23 @@ impl GooseCompleter {
|
|||
|
||||
/// Complete slash commands
|
||||
fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
|
||||
let mut commands = vec![
|
||||
"/exit".to_string(),
|
||||
"/quit".to_string(),
|
||||
"/help".to_string(),
|
||||
"/?".to_string(),
|
||||
"/t".to_string(),
|
||||
"/extension".to_string(),
|
||||
"/builtin".to_string(),
|
||||
"/mode".to_string(),
|
||||
"/model".to_string(),
|
||||
"/recipe".to_string(),
|
||||
// Define available slash commands
|
||||
let commands = [
|
||||
"/exit",
|
||||
"/quit",
|
||||
"/help",
|
||||
"/?",
|
||||
"/t",
|
||||
"/extension",
|
||||
"/builtin",
|
||||
"/prompts",
|
||||
"/prompt",
|
||||
"/mode",
|
||||
"/model",
|
||||
"/recipe",
|
||||
"/skills",
|
||||
"/status",
|
||||
];
|
||||
commands.extend(
|
||||
list_commands()
|
||||
.iter()
|
||||
.map(|command| format!("/{}", command.name)),
|
||||
);
|
||||
commands.sort();
|
||||
commands.dedup();
|
||||
|
||||
// Find commands that match the prefix
|
||||
let matching_commands: Vec<Pair> = commands
|
||||
|
|
@ -581,15 +578,6 @@ mod tests {
|
|||
let (pos, candidates) = completer.complete_slash_commands("/").unwrap();
|
||||
assert_eq!(pos, 0);
|
||||
assert!(candidates.len() > 1);
|
||||
for command in list_commands() {
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.display == format!("/{}", command.name)),
|
||||
"slash completion should list /{}",
|
||||
command.name
|
||||
);
|
||||
}
|
||||
|
||||
// Test no match
|
||||
let (_pos, candidates) = completer.complete_slash_commands("/nonexistent").unwrap();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use goose::config::Config;
|
||||
use goose::conversation::message::Message;
|
||||
use goose::conversation::Conversation;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::Builder;
|
||||
|
|
@ -24,6 +21,9 @@ pub fn resolve_editor_command() -> Option<String> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Inner resolution logic, separated for testability.
|
||||
/// Checks sources in priority order: config, VISUAL, EDITOR.
|
||||
/// Skips empty strings at each level.
|
||||
fn resolve_editor_from_sources(
|
||||
config_editor: Option<&str>,
|
||||
visual: Option<&str>,
|
||||
|
|
@ -37,57 +37,6 @@ fn resolve_editor_from_sources(
|
|||
None
|
||||
}
|
||||
|
||||
/// Resolve the editor command, falling back to vi (or notepad on Windows).
|
||||
pub fn resolve_editor_or_default() -> String {
|
||||
let config = Config::global();
|
||||
let config_editor = config.get_goose_prompt_editor().ok().flatten();
|
||||
let visual = std::env::var("VISUAL").ok();
|
||||
let editor_env = std::env::var("EDITOR").ok();
|
||||
resolve_editor_or_default_from_sources(
|
||||
config_editor.as_deref(),
|
||||
visual.as_deref(),
|
||||
editor_env.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_editor_default() -> String {
|
||||
if cfg!(windows) {
|
||||
"notepad".to_string()
|
||||
} else {
|
||||
"vi".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_editor_or_default_from_sources(
|
||||
config_editor: Option<&str>,
|
||||
visual: Option<&str>,
|
||||
editor_env: Option<&str>,
|
||||
) -> String {
|
||||
resolve_editor_from_sources(config_editor, visual, editor_env)
|
||||
.unwrap_or_else(resolve_editor_default)
|
||||
}
|
||||
|
||||
/// Open a YAML temp file with the user's editor to edit a conversation.
|
||||
/// Returns the edited conversation, or an error if the editor failed or YAML was invalid.
|
||||
pub fn edit_conversation(conversation: &Conversation) -> Result<Conversation> {
|
||||
let yaml = serde_yaml::to_string(conversation.messages())?;
|
||||
|
||||
let mut tmp = NamedTempFile::with_suffix(".yaml")?;
|
||||
tmp.write_all(yaml.as_bytes())?;
|
||||
tmp.flush()?;
|
||||
|
||||
let editor = resolve_editor_or_default();
|
||||
let path = tmp.path().to_path_buf();
|
||||
|
||||
launch_editor(&editor, &path).with_context(|| format!("failed to launch editor '{editor}'"))?;
|
||||
|
||||
let edited = std::fs::read_to_string(&path)?;
|
||||
let messages: Vec<Message> =
|
||||
serde_yaml::from_str(&edited).context("invalid YAML — session unchanged")?;
|
||||
|
||||
Ok(Conversation::new_unvalidated(messages))
|
||||
}
|
||||
|
||||
/// Build the markdown template content for the editor prompt.
|
||||
fn build_template(messages: &[&str], prefill: Option<&str>) -> String {
|
||||
let mut content = String::from("# Goose Prompt Editor\n\n");
|
||||
|
|
@ -135,36 +84,21 @@ impl SymlinkCleanup {
|
|||
|
||||
impl Drop for SymlinkCleanup {
|
||||
fn drop(&mut self) {
|
||||
// Always try to clean up the symlink, ignoring any errors
|
||||
let _ = std::fs::remove_file(&self.symlink_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Split an editor command into program and arguments.
|
||||
///
|
||||
/// Uses shell-word splitting only when the command contains quotes, so values like
|
||||
/// `"/Applications/Sublime Text.app/.../subl" -w` work. Unquoted commands are split on
|
||||
/// whitespace to avoid shlex stripping backslashes from Windows paths like
|
||||
/// `C:\Windows\System32\notepad.exe`.
|
||||
fn split_editor_command(editor_cmd: &str) -> Result<Vec<String>> {
|
||||
if editor_cmd.contains(['"', '\'']) {
|
||||
shlex::split(editor_cmd).ok_or_else(|| {
|
||||
anyhow::anyhow!("Invalid editor command: unmatched quotes in '{editor_cmd}'")
|
||||
})
|
||||
} else {
|
||||
Ok(editor_cmd.split_whitespace().map(String::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch editor and wait for completion
|
||||
fn launch_editor(editor_cmd: &str, file_path: &PathBuf) -> Result<()> {
|
||||
use std::process::Stdio;
|
||||
|
||||
let parts = split_editor_command(editor_cmd)?;
|
||||
let parts: Vec<&str> = editor_cmd.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
return Err(anyhow::anyhow!("Empty editor command"));
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&parts[0]);
|
||||
let mut cmd = Command::new(parts[0]);
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
|
@ -480,64 +414,54 @@ with multiple lines.
|
|||
);
|
||||
}
|
||||
|
||||
// --- resolve_editor_from_sources tests ---
|
||||
|
||||
#[test]
|
||||
fn test_resolve_editor_resolution_priority() {
|
||||
assert_eq!(
|
||||
resolve_editor_from_sources(Some("config-val"), Some("visual-val"), Some("editor-val")),
|
||||
Some("config-val".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_editor_from_sources(Some(""), Some("visual-val"), Some("editor-val")),
|
||||
Some("visual-val".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_editor_from_sources(None, Some(""), Some("editor-val")),
|
||||
Some("editor-val".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(resolve_editor_from_sources(None, None, None), None);
|
||||
assert_eq!(
|
||||
resolve_editor_from_sources(Some(""), Some(""), Some("")),
|
||||
None
|
||||
);
|
||||
|
||||
let default_val = resolve_editor_default();
|
||||
assert_eq!(
|
||||
resolve_editor_or_default_from_sources(None, None, None),
|
||||
default_val
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_editor_or_default_from_sources(Some(""), Some(""), Some("")),
|
||||
default_val
|
||||
);
|
||||
fn test_resolve_editor_returns_config_when_set() {
|
||||
let result = resolve_editor_from_sources(Some("code"), Some("vim"), Some("nano"));
|
||||
assert_eq!(result.as_deref(), Some("code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_editor_command() {
|
||||
assert_eq!(
|
||||
split_editor_command("code --wait").unwrap(),
|
||||
vec!["code", "--wait"]
|
||||
);
|
||||
fn test_resolve_editor_falls_back_to_visual() {
|
||||
let result = resolve_editor_from_sources(None, Some("vim"), Some("nano"));
|
||||
assert_eq!(result.as_deref(), Some("vim"));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
split_editor_command(
|
||||
r#""/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl" -w"#
|
||||
)
|
||||
.unwrap(),
|
||||
vec![
|
||||
"/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl",
|
||||
"-w"
|
||||
]
|
||||
);
|
||||
#[test]
|
||||
fn test_resolve_editor_falls_back_to_editor_env() {
|
||||
let result = resolve_editor_from_sources(None, None, Some("nano"));
|
||||
assert_eq!(result.as_deref(), Some("nano"));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
split_editor_command(r"C:\Windows\System32\notepad.exe").unwrap(),
|
||||
vec![r"C:\Windows\System32\notepad.exe"]
|
||||
);
|
||||
#[test]
|
||||
fn test_resolve_editor_returns_none_when_nothing_set() {
|
||||
let result = resolve_editor_from_sources(None, None, None);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
assert!(split_editor_command(r#"code --wait "unclosed"#).is_err());
|
||||
#[test]
|
||||
fn test_resolve_editor_skips_empty_config() {
|
||||
let result = resolve_editor_from_sources(Some(""), Some("vim"), None);
|
||||
assert_eq!(result.as_deref(), Some("vim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_editor_skips_empty_visual() {
|
||||
let result = resolve_editor_from_sources(None, Some(""), Some("nano"));
|
||||
assert_eq!(result.as_deref(), Some("nano"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_editor_skips_all_empty() {
|
||||
let result = resolve_editor_from_sources(Some(""), Some(""), Some(""));
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_editor_skips_empty_config_and_visual() {
|
||||
let result = resolve_editor_from_sources(Some(""), Some(""), Some("emacs"));
|
||||
assert_eq!(result.as_deref(), Some("emacs"));
|
||||
}
|
||||
|
||||
// --- build_template edge case tests ---
|
||||
|
|
@ -545,7 +469,9 @@ with multiple lines.
|
|||
#[test]
|
||||
fn test_build_template_empty_prefill_string() {
|
||||
let content = build_template(&["## User: Hello"], Some(""));
|
||||
// Empty prefill should not appear in content
|
||||
assert!(content.contains("# Your prompt:\n\n#"));
|
||||
// Should go directly to conversation context
|
||||
assert!(content.contains("# Recent conversation for context"));
|
||||
}
|
||||
|
||||
|
|
@ -572,8 +498,11 @@ with multiple lines.
|
|||
assert!(prefill_pos < context_pos);
|
||||
}
|
||||
|
||||
// --- extract_user_input with prefilled content tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_user_input_with_prefill_kept() {
|
||||
// Simulates a user who opened the editor with prefill and kept it unchanged
|
||||
let content = build_template(&["## User: Hello"], Some("fix the login bug"));
|
||||
let result = extract_user_input(&content);
|
||||
assert_eq!(result, "fix the login bug");
|
||||
|
|
@ -581,6 +510,7 @@ with multiple lines.
|
|||
|
||||
#[test]
|
||||
fn test_extract_user_input_with_prefill_edited() {
|
||||
// Simulates a user who edited the prefill text
|
||||
let mut content = build_template(&["## User: Hello"], Some("fix the login bug"));
|
||||
content = content.replace(
|
||||
"fix the login bug",
|
||||
|
|
@ -592,6 +522,7 @@ with multiple lines.
|
|||
|
||||
#[test]
|
||||
fn test_extract_user_input_prefill_replaced() {
|
||||
// Simulates a user who deleted the prefill and wrote something new
|
||||
let mut content = build_template(&["## User: Hello"], Some("fix the login bug"));
|
||||
content = content.replace("fix the login bug\n", "completely different prompt\n");
|
||||
let result = extract_user_input(&content);
|
||||
|
|
@ -600,6 +531,7 @@ with multiple lines.
|
|||
|
||||
#[test]
|
||||
fn test_extract_user_input_prefill_cleared() {
|
||||
// Simulates a user who deleted the prefill and left nothing
|
||||
let mut content = build_template(&["## User: Hello"], Some("fix the login bug"));
|
||||
content = content.replace("fix the login bug\n", "");
|
||||
let result = extract_user_input(&content);
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ pub fn get_input(
|
|||
rustyline::EventHandler::Conditional(Box::new(CtrlCHandler::new(completion_cache))),
|
||||
);
|
||||
|
||||
let input = match editor.readline("› ") {
|
||||
let input = match editor.readline("> ") {
|
||||
Ok(text) => text,
|
||||
Err(e) => match e {
|
||||
rustyline::error::ReadlineError::Interrupted => return Ok(InputResult::Exit),
|
||||
|
|
@ -401,17 +401,10 @@ fn parse_plan_command(input: String) -> Option<InputResult> {
|
|||
Some(InputResult::Plan(options))
|
||||
}
|
||||
|
||||
fn help_text() -> String {
|
||||
fn print_help() {
|
||||
let newline_key = get_newline_key().to_ascii_uppercase();
|
||||
let modes = GooseMode::VARIANTS.join(", ");
|
||||
let additional_builtin_help = additional_builtin_help();
|
||||
let additional_builtin_help = if additional_builtin_help.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{additional_builtin_help}\n")
|
||||
};
|
||||
|
||||
format!(
|
||||
println!(
|
||||
"Available commands:
|
||||
/exit or /quit - Exit the session
|
||||
/t - Toggle Light/Dark/Ansi theme
|
||||
|
|
@ -432,7 +425,7 @@ fn help_text() -> String {
|
|||
/recipe [filepath] - Generate a recipe from the current conversation and save it to the specified filepath (must end with .yaml).
|
||||
If no filepath is provided, it will be saved to ./recipe.yaml.
|
||||
/compact - Compact the current conversation to reduce context length while preserving key information.
|
||||
{additional_builtin_help}/status - Show session status: model, provider, mode, and token usage.
|
||||
/status - Show session status: model, provider, mode, and token usage.
|
||||
/edit [text] - Open your prompt editor to compose a message. Optionally pre-fill with text.
|
||||
Uses $GOOSE_PROMPT_EDITOR, $VISUAL, or $EDITOR (in that order).
|
||||
/skills - List available skills or enable skills by name (usage: /skills [<name>...])
|
||||
|
|
@ -443,23 +436,7 @@ Navigation:
|
|||
Ctrl+C - Clear current line if text is entered, otherwise exit the session
|
||||
Ctrl+{newline_key} - Add a newline (configurable via GOOSE_CLI_NEWLINE_KEY)
|
||||
Up/Down arrows - Navigate through command history"
|
||||
)
|
||||
}
|
||||
|
||||
fn additional_builtin_help() -> String {
|
||||
const DOCUMENTED_BUILTINS: &[&str] =
|
||||
&["prompts", "prompt", "compact", "clear", "skills", "status"];
|
||||
|
||||
goose::agents::execute_commands::list_commands()
|
||||
.iter()
|
||||
.filter(|command| !DOCUMENTED_BUILTINS.contains(&command.name))
|
||||
.map(|command| format!("/{} - {}", command.name, command.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("{}", help_text());
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract recent messages for editor context
|
||||
|
|
@ -558,19 +535,6 @@ mod tests {
|
|||
assert!(handle_slash_command("/unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_builtin_agent_commands() {
|
||||
let help = help_text();
|
||||
|
||||
for command in goose::agents::execute_commands::list_commands() {
|
||||
assert!(
|
||||
help.contains(&format!("/{}", command.name)),
|
||||
"help output should list /{}",
|
||||
command.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompts_command() {
|
||||
// Test basic prompts command
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
mod builder;
|
||||
mod completion;
|
||||
pub mod editor;
|
||||
mod editor;
|
||||
mod elicitation;
|
||||
mod export;
|
||||
mod input;
|
||||
|
|
@ -53,7 +53,7 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
use tokio;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
|
@ -487,6 +487,10 @@ impl CliSession {
|
|||
|
||||
/// Start an interactive session, optionally with an initial message
|
||||
pub async fn interactive(&mut self, prompt: Option<String>) -> Result<()> {
|
||||
self.agent
|
||||
.emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id)
|
||||
.await;
|
||||
|
||||
let result = self.run_interactive(prompt).await;
|
||||
|
||||
self.agent
|
||||
|
|
@ -1135,6 +1139,9 @@ impl CliSession {
|
|||
|
||||
/// Process a single message and exit
|
||||
pub async fn headless(&mut self, prompt: String) -> Result<()> {
|
||||
self.agent
|
||||
.emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id)
|
||||
.await;
|
||||
let message = Message::user().with_text(&prompt);
|
||||
let result = self
|
||||
.process_message(message, CancellationToken::default(), false)
|
||||
|
|
@ -1326,7 +1333,6 @@ impl CliSession {
|
|||
Some(Ok(AgentEvent::Usage(usage))) => {
|
||||
last_usage = Some(usage);
|
||||
}
|
||||
Some(Ok(AgentEvent::MessageUsage { .. })) => {}
|
||||
Some(Ok(AgentEvent::McpNotification((extension_id, notification)))) => {
|
||||
handle_mcp_notification(
|
||||
&extension_id,
|
||||
|
|
@ -1769,54 +1775,25 @@ fn print_run_stats(
|
|||
usage: Option<&ProviderUsage>,
|
||||
) {
|
||||
let elapsed = run_started.elapsed();
|
||||
let stats = usage.and_then(|usage| usage.stats.as_ref());
|
||||
let generation_elapsed = stats
|
||||
.and_then(|stats| stats.elapsed_ms)
|
||||
.map(Duration::from_millis);
|
||||
let output_tokens = usage
|
||||
.and_then(|usage| usage.usage.output_tokens)
|
||||
.and_then(|tokens| usize::try_from(tokens).ok())
|
||||
.or_else(|| stats.and_then(|stats| stats.output_tokens));
|
||||
.or_else(|| usage.and_then(|usage| usage.stats.as_ref()?.output_tokens));
|
||||
let tokens_per_second = output_tokens.map(|tokens| {
|
||||
let rate_elapsed = generation_elapsed.unwrap_or(elapsed);
|
||||
if rate_elapsed.as_secs_f64() > 0.0 {
|
||||
tokens as f64 / rate_elapsed.as_secs_f64()
|
||||
if elapsed.as_secs_f64() > 0.0 {
|
||||
tokens as f64 / elapsed.as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
});
|
||||
let model_load_ms = stats.and_then(|stats| stats.model_load_ms);
|
||||
let generation_time_to_first_token_ms = stats.and_then(|stats| stats.time_to_first_token_ms);
|
||||
|
||||
eprintln!("\nStats:");
|
||||
if let Some(ms) = model_load_ms {
|
||||
eprintln!(" Model load: {:.2}s", ms as f64 / 1000.0);
|
||||
}
|
||||
if model_load_ms.is_some() {
|
||||
match generation_time_to_first_token_ms {
|
||||
Some(ms) => eprintln!(
|
||||
" Generation time to first token: {:.2}s",
|
||||
ms as f64 / 1000.0
|
||||
),
|
||||
None => eprintln!(" Generation time to first token: unavailable"),
|
||||
}
|
||||
match first_token_at {
|
||||
Some(first) => eprintln!(
|
||||
" End-to-end time to first token: {:.2}s",
|
||||
first.duration_since(run_started).as_secs_f64()
|
||||
),
|
||||
None => eprintln!(" End-to-end time to first token: unavailable"),
|
||||
}
|
||||
} else if let Some(ms) = generation_time_to_first_token_ms {
|
||||
eprintln!(" Time to first token: {:.2}s", ms as f64 / 1000.0);
|
||||
} else {
|
||||
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 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),
|
||||
|
|
@ -1826,7 +1803,10 @@ fn print_run_stats(
|
|||
eprintln!(" Output tokens: {tokens}");
|
||||
}
|
||||
|
||||
if let Some(draft) = stats.and_then(|stats| stats.draft.as_ref()) {
|
||||
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: {}",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use anstream::println;
|
||||
use bat::WrappingMode;
|
||||
use console::{measure_text_width, style, Color, StyledObject, Term};
|
||||
use console::{measure_text_width, style, Color, Term};
|
||||
use goose::config::Config;
|
||||
use goose::conversation::message::{
|
||||
ActionRequiredData, Message, MessageContent, SystemNotificationContent, SystemNotificationType,
|
||||
|
|
@ -16,7 +16,6 @@ use rmcp::model::{CallToolRequestParams, JsonObject, PromptArgument};
|
|||
use serde_json::Value;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
use std::io::{Error, IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
|
@ -27,22 +26,6 @@ pub const DEFAULT_MIN_PRIORITY: f32 = 0.0;
|
|||
pub const DEFAULT_CLI_LIGHT_THEME: &str = "GitHub";
|
||||
pub const DEFAULT_CLI_DARK_THEME: &str = "zenburn";
|
||||
|
||||
fn accent<T: Display>(value: T) -> StyledObject<T> {
|
||||
style(value).cyan()
|
||||
}
|
||||
|
||||
fn success<T: Display>(value: T) -> StyledObject<T> {
|
||||
style(value).green()
|
||||
}
|
||||
|
||||
fn warning<T: Display>(value: T) -> StyledObject<T> {
|
||||
style(value).yellow()
|
||||
}
|
||||
|
||||
fn danger<T: Display>(value: T) -> StyledObject<T> {
|
||||
style(value).red()
|
||||
}
|
||||
|
||||
// Re-export theme for use in main
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Theme {
|
||||
|
|
@ -265,14 +248,13 @@ pub fn render_message(message: &Message, debug: bool) {
|
|||
}
|
||||
MessageContent::SystemNotification(notification) => {
|
||||
match notification.notification_type {
|
||||
SystemNotificationType::ThinkingMessage
|
||||
| SystemNotificationType::ProgressMessage => {
|
||||
SystemNotificationType::ThinkingMessage => {
|
||||
show_thinking();
|
||||
set_thinking_message(¬ification.msg);
|
||||
}
|
||||
SystemNotificationType::InlineMessage => {
|
||||
hide_thinking();
|
||||
println!("\n{} {}", style("·").dim(), ¬ification.msg);
|
||||
println!("\n{}", style(¬ification.msg).yellow());
|
||||
}
|
||||
SystemNotificationType::CreditsExhausted => {
|
||||
render_credits_exhausted_notification(notification);
|
||||
|
|
@ -348,15 +330,14 @@ pub fn render_message_streaming(
|
|||
}
|
||||
MessageContent::SystemNotification(notification) => {
|
||||
match notification.notification_type {
|
||||
SystemNotificationType::ThinkingMessage
|
||||
| SystemNotificationType::ProgressMessage => {
|
||||
SystemNotificationType::ThinkingMessage => {
|
||||
show_thinking();
|
||||
set_thinking_message(¬ification.msg);
|
||||
}
|
||||
SystemNotificationType::InlineMessage => {
|
||||
flush_markdown_buffer(buffer, theme);
|
||||
hide_thinking();
|
||||
println!("\n{} {}", style("·").dim(), ¬ification.msg);
|
||||
println!("\n{}", style(¬ification.msg).yellow());
|
||||
}
|
||||
SystemNotificationType::CreditsExhausted => {
|
||||
flush_markdown_buffer(buffer, theme);
|
||||
|
|
@ -376,7 +357,7 @@ pub fn render_message_streaming(
|
|||
|
||||
fn render_credits_exhausted_notification(notification: &SystemNotificationContent) {
|
||||
hide_thinking();
|
||||
println!("\n{} {}", warning("warning:").bold(), ¬ification.msg);
|
||||
println!("\n{}", style(¬ification.msg).yellow());
|
||||
|
||||
if let Some(url) = notification
|
||||
.data
|
||||
|
|
@ -384,7 +365,10 @@ fn render_credits_exhausted_notification(notification: &SystemNotificationConten
|
|||
.and_then(|d| d.get("top_up_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
println!("{} {}", style("top up:").dim(), accent(url));
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Visit this URL to top up credits: {url}")).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,6 +415,8 @@ pub fn render_text_no_newlines(text: &str, color: Option<Color>, dim: bool) {
|
|||
}
|
||||
if let Some(color) = color {
|
||||
styled_text = styled_text.fg(color);
|
||||
} else {
|
||||
styled_text = styled_text.green();
|
||||
}
|
||||
print!("{}", styled_text);
|
||||
}
|
||||
|
|
@ -438,8 +424,9 @@ pub fn render_text_no_newlines(text: &str, color: Option<Color>, dim: bool) {
|
|||
pub fn render_enter_plan_mode() {
|
||||
println!(
|
||||
"\n{} {}\n",
|
||||
accent("Entering plan mode.").bold(),
|
||||
style("Entering plan mode.").green().bold(),
|
||||
style("You can provide instructions to create a plan and then act on it. To exit early, type /endplan")
|
||||
.green()
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
|
|
@ -447,16 +434,18 @@ pub fn render_enter_plan_mode() {
|
|||
pub fn render_act_on_plan() {
|
||||
println!(
|
||||
"\n{}\n",
|
||||
accent("Exiting plan mode and acting on the above plan").bold(),
|
||||
style("Exiting plan mode and acting on the above plan")
|
||||
.green()
|
||||
.bold(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_exit_plan_mode() {
|
||||
println!("\n{}\n", accent("Exiting plan mode.").bold());
|
||||
println!("\n{}\n", style("Exiting plan mode.").green().bold());
|
||||
}
|
||||
|
||||
pub fn goose_mode_message(text: &str) {
|
||||
println!("\n{} {}", accent("mode:"), text);
|
||||
println!("\n{}", style(text).yellow(),);
|
||||
}
|
||||
|
||||
fn should_show_thinking() -> bool {
|
||||
|
|
@ -592,13 +581,13 @@ fn is_file_tool_name(name: &str) -> bool {
|
|||
}
|
||||
|
||||
pub fn render_error(message: &str) {
|
||||
println!("\n {} {}\n", danger("error:").bold(), message);
|
||||
println!("\n {} {}\n", style("error:").red().bold(), message);
|
||||
}
|
||||
|
||||
pub fn render_prompts(prompts: &HashMap<String, Vec<String>>) {
|
||||
println!();
|
||||
for (extension, prompts) in prompts {
|
||||
println!(" {}", accent(extension));
|
||||
println!(" {}", style(extension).green());
|
||||
for prompt in prompts {
|
||||
println!(" - {}", style(prompt).cyan());
|
||||
}
|
||||
|
|
@ -609,7 +598,7 @@ pub fn render_prompts(prompts: &HashMap<String, Vec<String>>) {
|
|||
pub fn render_prompt_info(info: &PromptInfo) {
|
||||
println!();
|
||||
if let Some(ext) = &info.extension {
|
||||
println!(" {}: {}", accent("Extension"), ext);
|
||||
println!(" {}: {}", style("Extension").green(), ext);
|
||||
}
|
||||
println!(" Prompt: {}", style(&info.name).cyan().bold());
|
||||
if let Some(desc) = &info.description {
|
||||
|
|
@ -625,14 +614,14 @@ fn render_arguments(info: &PromptInfo) {
|
|||
for arg in args {
|
||||
let required = arg.required.unwrap_or(false);
|
||||
let req_str = if required {
|
||||
style("(required)").bold()
|
||||
style("(required)").red()
|
||||
} else {
|
||||
style("(optional)").dim()
|
||||
};
|
||||
|
||||
println!(
|
||||
" {} {} {}",
|
||||
accent(&arg.name),
|
||||
style(&arg.name).yellow(),
|
||||
req_str,
|
||||
arg.description.as_deref().unwrap_or("")
|
||||
);
|
||||
|
|
@ -642,13 +631,21 @@ fn render_arguments(info: &PromptInfo) {
|
|||
|
||||
pub fn render_extension_success(name: &str) {
|
||||
println!();
|
||||
println!(" {} extension `{}`", success("added"), accent(name),);
|
||||
println!(
|
||||
" {} extension `{}`",
|
||||
style("added").green(),
|
||||
style(name).cyan(),
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
pub fn render_extension_error(name: &str, error: &str) {
|
||||
println!();
|
||||
println!(" {} to add extension {}", danger("failed"), danger(name));
|
||||
println!(
|
||||
" {} to add extension {}",
|
||||
style("failed").red(),
|
||||
style(name).red()
|
||||
);
|
||||
println!();
|
||||
println!("{}", style(error).dim());
|
||||
println!();
|
||||
|
|
@ -658,9 +655,9 @@ pub fn render_builtin_success(names: &str) {
|
|||
println!();
|
||||
println!(
|
||||
" {} builtin{}: {}",
|
||||
success("added"),
|
||||
style("added").green(),
|
||||
if names.contains(',') { "s" } else { "" },
|
||||
accent(names)
|
||||
style(names).cyan()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
|
@ -669,9 +666,9 @@ pub fn render_builtin_error(names: &str, error: &str) {
|
|||
println!();
|
||||
println!(
|
||||
" {} to add builtin{}: {}",
|
||||
danger("failed"),
|
||||
style("failed").red(),
|
||||
if names.contains(',') { "s" } else { "" },
|
||||
danger(names)
|
||||
style(names).red()
|
||||
);
|
||||
println!();
|
||||
println!("{}", style(error).dim());
|
||||
|
|
@ -772,7 +769,7 @@ fn render_execute_code_request(call: &CallToolRequestParams, debug: bool) {
|
|||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty());
|
||||
if code.is_some_and(|_| debug) {
|
||||
println!("{}", code.unwrap_or_default());
|
||||
println!("{}", style(code.unwrap_or_default()).green());
|
||||
}
|
||||
|
||||
println!();
|
||||
|
|
|
|||
|
|
@ -524,8 +524,10 @@ impl MarkdownBuffer {
|
|||
}
|
||||
}
|
||||
"]" => {}
|
||||
")" if state.in_link_url => {
|
||||
state.in_link_url = false;
|
||||
")" => {
|
||||
if state.in_link_url {
|
||||
state.in_link_url = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
[package]
|
||||
name = "goose-download-manager"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "time"] }
|
||||
tracing = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
|
|
@ -1,691 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::info;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
fn partial_path_for(destination: &Path) -> PathBuf {
|
||||
destination.with_extension(
|
||||
destination
|
||||
.extension()
|
||||
.map(|e| format!("{}.part", e.to_string_lossy()))
|
||||
.unwrap_or_else(|| "part".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove orphaned `.part` files in the given directory (and one level of subdirectories).
|
||||
/// Preserves `.part` files whose final destination is in `registered_paths` so that
|
||||
/// in-progress shard downloads can resume after a restart.
|
||||
pub fn cleanup_partial_downloads(
|
||||
dir: &Path,
|
||||
registered_paths: &std::collections::HashSet<PathBuf>,
|
||||
) {
|
||||
let should_keep = |part_path: &Path| -> bool {
|
||||
// Derive the final path by stripping the trailing ".part" extension
|
||||
let final_path = part_path.with_extension("");
|
||||
registered_paths.contains(&final_path)
|
||||
};
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "part") && !should_keep(&path) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
if path.is_dir() {
|
||||
if let Ok(sub_entries) = std::fs::read_dir(&path) {
|
||||
for sub in sub_entries.flatten() {
|
||||
let sub_path = sub.path();
|
||||
if sub_path.extension().is_some_and(|e| e == "part")
|
||||
&& !should_keep(&sub_path)
|
||||
{
|
||||
let _ = std::fs::remove_file(&sub_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DownloadProgress {
|
||||
/// Model ID being downloaded
|
||||
pub model_id: String,
|
||||
/// Download status
|
||||
pub status: DownloadStatus,
|
||||
/// Bytes downloaded so far
|
||||
pub bytes_downloaded: u64,
|
||||
/// Total bytes to download
|
||||
pub total_bytes: u64,
|
||||
/// Download progress percentage (0-100)
|
||||
pub progress_percent: f32,
|
||||
/// Download speed in bytes per second
|
||||
pub speed_bps: Option<u64>,
|
||||
/// Estimated time remaining in seconds
|
||||
pub eta_seconds: Option<u64>,
|
||||
/// Error message if failed
|
||||
pub error: Option<String>,
|
||||
/// Whether the background download task has exited
|
||||
#[serde(skip)]
|
||||
pub task_exited: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DownloadStatus {
|
||||
Downloading,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
type DownloadMap = Arc<Mutex<HashMap<String, DownloadProgress>>>;
|
||||
|
||||
pub struct DownloadManager {
|
||||
downloads: DownloadMap,
|
||||
}
|
||||
|
||||
impl Default for DownloadManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DownloadManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
downloads: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_progress(&self, model_id: &str) -> Option<DownloadProgress> {
|
||||
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<DownloadProgress> {
|
||||
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<bool> {
|
||||
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
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock"))?;
|
||||
|
||||
if let Some(progress) = downloads.get_mut(model_id) {
|
||||
progress.status = DownloadStatus::Cancelled;
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("Download not found")
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_model(
|
||||
&self,
|
||||
model_id: String,
|
||||
url: String,
|
||||
destination: PathBuf,
|
||||
on_complete: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> Result<()> {
|
||||
self.download_model_sharded(model_id, vec![(url, destination)], 0, on_complete)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn download_model_with_bearer_token(
|
||||
&self,
|
||||
model_id: String,
|
||||
url: String,
|
||||
destination: PathBuf,
|
||||
bearer_token: Option<String>,
|
||||
on_complete: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> Result<()> {
|
||||
self.download_model_sharded_with_bearer_token(
|
||||
model_id,
|
||||
vec![(url, destination)],
|
||||
0,
|
||||
bearer_token,
|
||||
on_complete,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn download_model_sharded(
|
||||
&self,
|
||||
model_id: String,
|
||||
files: Vec<(String, PathBuf)>,
|
||||
total_size_hint: u64,
|
||||
on_complete: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> Result<()> {
|
||||
self.download_model_sharded_with_bearer_token(
|
||||
model_id,
|
||||
files,
|
||||
total_size_hint,
|
||||
None,
|
||||
on_complete,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn download_model_sharded_with_bearer_token(
|
||||
&self,
|
||||
model_id: String,
|
||||
files: Vec<(String, PathBuf)>,
|
||||
total_size_hint: u64,
|
||||
bearer_token: Option<String>,
|
||||
on_complete: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> Result<()> {
|
||||
info!(model_id = %model_id, file_count = files.len(), "Starting model download");
|
||||
{
|
||||
let mut downloads = self
|
||||
.downloads
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock"))?;
|
||||
|
||||
if let Some(existing) = downloads.get(&model_id) {
|
||||
if existing.status == DownloadStatus::Downloading {
|
||||
anyhow::bail!("Download already in progress");
|
||||
}
|
||||
if existing.status == DownloadStatus::Cancelled && !existing.task_exited {
|
||||
anyhow::bail!(
|
||||
"Download is being cancelled; wait for it to finish before restarting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
downloads.insert(
|
||||
model_id.clone(),
|
||||
DownloadProgress {
|
||||
model_id: model_id.clone(),
|
||||
status: DownloadStatus::Downloading,
|
||||
bytes_downloaded: 0,
|
||||
total_bytes: total_size_hint,
|
||||
progress_percent: 0.0,
|
||||
speed_bps: None,
|
||||
eta_seconds: None,
|
||||
error: None,
|
||||
task_exited: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Create parent directories for all files
|
||||
for (_, dest) in &files {
|
||||
if let Some(parent) = dest.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create directory: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
let downloads = self.downloads.clone();
|
||||
let model_id_clone = model_id.clone();
|
||||
let files_for_cleanup: Vec<PathBuf> = files.iter().map(|(_, d)| d.clone()).collect();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = Self::download_files_sequentially(
|
||||
&files,
|
||||
&downloads,
|
||||
&model_id_clone,
|
||||
bearer_token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(model_id = %model_id_clone, "Download completed successfully");
|
||||
if let Ok(mut downloads) = downloads.lock() {
|
||||
if let Some(progress) = downloads.get_mut(&model_id_clone) {
|
||||
progress.status = DownloadStatus::Completed;
|
||||
progress.progress_percent = 100.0;
|
||||
progress.task_exited = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(callback) = on_complete {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
for dest in &files_for_cleanup {
|
||||
let partial = partial_path_for(dest);
|
||||
let _ = tokio::fs::remove_file(&partial).await;
|
||||
}
|
||||
|
||||
if let Ok(mut downloads) = downloads.lock() {
|
||||
if let Some(progress) = downloads.get_mut(&model_id_clone) {
|
||||
if progress.status != DownloadStatus::Cancelled {
|
||||
progress.status = DownloadStatus::Failed;
|
||||
}
|
||||
progress.error = Some(e.to_string());
|
||||
progress.task_exited = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_RETRIES: u32 = 10;
|
||||
const RETRY_BASE_DELAY: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
const RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
async fn cancellable_sleep(
|
||||
delay: std::time::Duration,
|
||||
downloads: &DownloadMap,
|
||||
model_id: &str,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let check_interval = std::time::Duration::from_millis(500);
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < delay {
|
||||
if Self::is_cancelled(downloads, model_id) {
|
||||
anyhow::bail!("Download cancelled");
|
||||
}
|
||||
let remaining = delay.saturating_sub(start.elapsed());
|
||||
tokio::time::sleep(std::cmp::min(check_interval, remaining)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_cancelled(downloads: &DownloadMap, model_id: &str) -> bool {
|
||||
if let Ok(downloads) = downloads.lock() {
|
||||
if let Some(progress) = downloads.get(model_id) {
|
||||
return progress.status == DownloadStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Download multiple files sequentially, tracking cumulative progress under one model_id.
|
||||
async fn download_files_sequentially(
|
||||
files: &[(String, PathBuf)],
|
||||
downloads: &DownloadMap,
|
||||
model_id: &str,
|
||||
bearer_token: Option<&str>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.read_timeout(std::time::Duration::from_secs(120))
|
||||
.build()?;
|
||||
|
||||
// HEAD each file to get accurate total size. Only replace the hint if
|
||||
// every file returned a size; partial results would underestimate.
|
||||
let mut total: u64 = 0;
|
||||
let mut all_resolved = true;
|
||||
for (url, _) in files {
|
||||
let size = Self::apply_bearer_token(client.head(url), bearer_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.content_length())
|
||||
.unwrap_or(0);
|
||||
if size == 0 {
|
||||
all_resolved = false;
|
||||
}
|
||||
total += size;
|
||||
}
|
||||
if all_resolved && total > 0 {
|
||||
if let Ok(mut dl) = downloads.lock() {
|
||||
if let Some(progress) = dl.get_mut(model_id) {
|
||||
progress.total_bytes = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut cumulative_bytes: u64 = 0;
|
||||
// Account for already-downloaded shards
|
||||
for (_, dest) in files {
|
||||
let partial = partial_path_for(dest);
|
||||
if dest.exists() {
|
||||
if let Ok(meta) = tokio::fs::metadata(dest).await {
|
||||
cumulative_bytes += meta.len();
|
||||
}
|
||||
} else if partial.exists() {
|
||||
if let Ok(meta) = tokio::fs::metadata(&partial).await {
|
||||
cumulative_bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
let bytes_at_start = cumulative_bytes;
|
||||
|
||||
for (url, destination) in files {
|
||||
if Self::is_cancelled(downloads, model_id) {
|
||||
anyhow::bail!("Download cancelled");
|
||||
}
|
||||
|
||||
// Skip already-completed shards
|
||||
if destination.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
Self::download_one_file(
|
||||
&client,
|
||||
url,
|
||||
destination,
|
||||
downloads,
|
||||
model_id,
|
||||
&mut cumulative_bytes,
|
||||
start_time,
|
||||
bytes_at_start,
|
||||
bearer_token,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_one_file(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
destination: &Path,
|
||||
downloads: &DownloadMap,
|
||||
model_id: &str,
|
||||
cumulative_bytes: &mut u64,
|
||||
start_time: std::time::Instant,
|
||||
bytes_at_start: u64,
|
||||
bearer_token: Option<&str>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let partial_path = partial_path_for(destination);
|
||||
let mut retries = 0u32;
|
||||
|
||||
let mut file_bytes: u64 = if partial_path.exists() {
|
||||
tokio::fs::metadata(&partial_path).await?.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Get this file's total size
|
||||
let mut file_total: u64 = Self::apply_bearer_token(client.head(url), bearer_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.content_length())
|
||||
.unwrap_or(0);
|
||||
|
||||
// If partial matches expected size exactly, promote it
|
||||
if file_total > 0 && file_bytes == file_total {
|
||||
tokio::fs::rename(&partial_path, destination).await?;
|
||||
// cumulative_bytes already accounts for this file from the pre-scan
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If partial is oversized or remote changed, discard and re-download
|
||||
if file_total > 0 && file_bytes > file_total {
|
||||
info!(model_id = %model_id, file_bytes, file_total, "Partial file oversized, re-downloading");
|
||||
*cumulative_bytes = cumulative_bytes.saturating_sub(file_bytes);
|
||||
file_bytes = 0;
|
||||
let _ = tokio::fs::remove_file(&partial_path).await;
|
||||
}
|
||||
|
||||
loop {
|
||||
if Self::is_cancelled(downloads, model_id) {
|
||||
let _ = tokio::fs::remove_file(&partial_path).await;
|
||||
anyhow::bail!("Download cancelled");
|
||||
}
|
||||
|
||||
let mut request = Self::apply_bearer_token(client.get(url), bearer_token);
|
||||
if file_bytes > 0 {
|
||||
request = request.header("Range", format!("bytes={}-", file_bytes));
|
||||
}
|
||||
|
||||
let response = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if retries >= Self::MAX_RETRIES {
|
||||
anyhow::bail!("Download failed after {} retries: {}", retries, e);
|
||||
}
|
||||
retries += 1;
|
||||
let delay = std::cmp::min(
|
||||
Self::RETRY_BASE_DELAY * 2u32.saturating_pow(retries - 1),
|
||||
Self::RETRY_MAX_DELAY,
|
||||
);
|
||||
info!(model_id = %model_id, retry = retries, delay_secs = ?delay.as_secs(), error = %e, "Retrying download after connection error");
|
||||
Self::cancellable_sleep(delay, downloads, model_id).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
|
||||
if file_total > 0 && file_bytes == file_total {
|
||||
break;
|
||||
}
|
||||
*cumulative_bytes = cumulative_bytes.saturating_sub(file_bytes);
|
||||
file_bytes = 0;
|
||||
let _ = tokio::fs::remove_file(&partial_path).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !status.is_success() && status != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||
let is_transient = status.is_server_error()
|
||||
|| status == reqwest::StatusCode::REQUEST_TIMEOUT
|
||||
|| status == reqwest::StatusCode::TOO_MANY_REQUESTS;
|
||||
|
||||
if !is_transient || retries >= Self::MAX_RETRIES {
|
||||
anyhow::bail!("Failed to download: HTTP {}", status);
|
||||
}
|
||||
retries += 1;
|
||||
let delay = std::cmp::min(
|
||||
Self::RETRY_BASE_DELAY * 2u32.saturating_pow(retries - 1),
|
||||
Self::RETRY_MAX_DELAY,
|
||||
);
|
||||
info!(model_id = %model_id, retry = retries, http_status = %status, "Retrying download after transient HTTP error");
|
||||
Self::cancellable_sleep(delay, downloads, model_id).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_bytes > 0 && status == reqwest::StatusCode::OK {
|
||||
info!(model_id = %model_id, "Server ignored Range header, restarting file from scratch");
|
||||
// Subtract already-counted partial bytes from cumulative
|
||||
*cumulative_bytes = cumulative_bytes.saturating_sub(file_bytes);
|
||||
file_bytes = 0;
|
||||
let _ = tokio::fs::remove_file(&partial_path).await;
|
||||
}
|
||||
|
||||
// If HEAD didn't return this file's size, learn it from the GET response.
|
||||
// This block only fires once per file (file_total stays non-zero after),
|
||||
// so retries don't double-count. Since download_files_sequentially's HEAD
|
||||
// pass contributed 0 for this file, we add the discovered size to the
|
||||
// shared total so progress/ETA are accurate.
|
||||
if file_total == 0 {
|
||||
let new_file_total = if file_bytes > 0 {
|
||||
response
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.rsplit('/').next())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
} else {
|
||||
response.content_length()
|
||||
};
|
||||
if let Some(t) = new_file_total {
|
||||
file_total = t;
|
||||
if let Ok(mut dl) = downloads.lock() {
|
||||
if let Some(progress) = dl.get_mut(model_id) {
|
||||
progress.total_bytes = progress.total_bytes.saturating_add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&partial_path)
|
||||
.await?;
|
||||
|
||||
let file_len = tokio::fs::metadata(&partial_path).await?.len();
|
||||
if file_len != file_bytes {
|
||||
file.set_len(file_bytes).await?;
|
||||
}
|
||||
|
||||
let mut stream_error = false;
|
||||
let mut resp = response;
|
||||
|
||||
loop {
|
||||
let chunk_result = resp.chunk().await;
|
||||
match chunk_result {
|
||||
Ok(Some(chunk)) => {
|
||||
if Self::is_cancelled(downloads, model_id) {
|
||||
let _ = tokio::fs::remove_file(&partial_path).await;
|
||||
anyhow::bail!("Download cancelled");
|
||||
}
|
||||
|
||||
file.write_all(&chunk).await?;
|
||||
let chunk_len = chunk.len() as u64;
|
||||
file_bytes += chunk_len;
|
||||
*cumulative_bytes += chunk_len;
|
||||
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let bytes_this_session = cumulative_bytes.saturating_sub(bytes_at_start);
|
||||
let speed_bps = if elapsed > 0.0 {
|
||||
Some((bytes_this_session as f64 / elapsed) as u64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let current_total = if let Ok(dl) = downloads.lock() {
|
||||
dl.get(model_id).map(|p| p.total_bytes).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let eta_seconds = if let Some(speed) = speed_bps {
|
||||
if speed > 0 && current_total > 0 {
|
||||
Some(current_total.saturating_sub(*cumulative_bytes) / speed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Ok(mut dl) = downloads.lock() {
|
||||
if let Some(progress) = dl.get_mut(model_id) {
|
||||
progress.bytes_downloaded = *cumulative_bytes;
|
||||
progress.progress_percent = if current_total > 0 {
|
||||
(*cumulative_bytes as f64 / current_total as f64 * 100.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
progress.speed_bps = speed_bps;
|
||||
progress.eta_seconds = eta_seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
info!(model_id = %model_id, bytes = *cumulative_bytes, error = %e, "Download stream interrupted, will retry");
|
||||
stream_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
|
||||
if stream_error {
|
||||
if retries >= Self::MAX_RETRIES {
|
||||
anyhow::bail!(
|
||||
"Download failed after {} retries due to stream interruption",
|
||||
retries
|
||||
);
|
||||
}
|
||||
retries += 1;
|
||||
let delay = std::cmp::min(
|
||||
Self::RETRY_BASE_DELAY * 2u32.saturating_pow(retries - 1),
|
||||
Self::RETRY_MAX_DELAY,
|
||||
);
|
||||
info!(model_id = %model_id, retry = retries, delay_secs = ?delay.as_secs(), "Retrying download with resume");
|
||||
Self::cancellable_sleep(delay, downloads, model_id).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::fs::rename(&partial_path, destination).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_completed(&self, model_id: &str) {
|
||||
if let Ok(mut downloads) = self.downloads.lock() {
|
||||
if let Some(progress) = downloads.get(model_id) {
|
||||
let is_terminal = progress.status == DownloadStatus::Completed
|
||||
|| progress.status == DownloadStatus::Failed
|
||||
|| progress.status == DownloadStatus::Cancelled;
|
||||
if is_terminal && progress.task_exited {
|
||||
downloads.remove(model_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_bearer_token(
|
||||
request: reqwest::RequestBuilder,
|
||||
bearer_token: Option<&str>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if let Some(token) = bearer_token.filter(|token| !token.is_empty()) {
|
||||
request.header("Authorization", format!("Bearer {}", token))
|
||||
} else {
|
||||
request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DOWNLOAD_MANAGER: once_cell::sync::Lazy<DownloadManager> =
|
||||
once_cell::sync::Lazy::new(DownloadManager::new);
|
||||
|
||||
pub fn get_download_manager() -> &'static DownloadManager {
|
||||
&DOWNLOAD_MANAGER
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
[package]
|
||||
name = "goose-local-inference"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
cuda = ["llama-cpp-2/cuda"]
|
||||
vulkan = ["llama-cpp-2/vulkan"]
|
||||
mlx = ["dep:safemlx", "dep:safemlx-lm", "dep:safemlx-lm-utils"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
etcetera = { workspace = true }
|
||||
encoding_rs = { version = "0.8.35", default-features = false }
|
||||
fs2 = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
goose-download-manager = { version = "0.1.0-alpha.0", path = "../goose-download-manager" }
|
||||
goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false }
|
||||
goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types", default-features = false }
|
||||
hf-hub = { version = "1.0.0-rc.1", default-features = false }
|
||||
include_dir = { workspace = true }
|
||||
llama-cpp-2 = { workspace = true }
|
||||
llama-cpp-sys-2 = { workspace = true }
|
||||
minijinja = { version = "2.18", default-features = false, features = ["loader", "multi_template", "serde"] }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
regex = { workspace = true }
|
||||
rmcp = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
utoipa = { workspace = true, features = ["chrono"] }
|
||||
uuid = { workspace = true, features = ["v4", "std"] }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
safemlx = { default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true, version = "0.1.2" }
|
||||
safemlx-lm = { optional = true, version = "0.1.5" }
|
||||
safemlx-lm-utils = { optional = true, version = "0.1.2" }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
llama-cpp-2 = { workspace = true, features = ["sampler", "metal", "mtmd"] }
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub type StringParamResolver = fn(&'static str) -> Result<Option<String>>;
|
||||
pub type BoolParamResolver = fn(&'static str) -> Result<Option<bool>>;
|
||||
|
||||
static STRING_PARAM_RESOLVER: OnceLock<StringParamResolver> = OnceLock::new();
|
||||
static BOOL_PARAM_RESOLVER: OnceLock<BoolParamResolver> = OnceLock::new();
|
||||
|
||||
pub fn set_string_param_resolver(resolve_param: StringParamResolver) {
|
||||
let _ = STRING_PARAM_RESOLVER.set(resolve_param);
|
||||
}
|
||||
|
||||
pub fn set_bool_param_resolver(resolve_param: BoolParamResolver) {
|
||||
let _ = BOOL_PARAM_RESOLVER.set(resolve_param);
|
||||
}
|
||||
|
||||
pub fn string_param(key: &'static str) -> Result<Option<String>> {
|
||||
match STRING_PARAM_RESOLVER.get() {
|
||||
Some(resolve_param) => resolve_param(key),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bool_param(key: &'static str) -> Result<Option<bool>> {
|
||||
match BOOL_PARAM_RESOLVER.get() {
|
||||
Some(resolve_param) => resolve_param(key),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
use crate::{config_resolver, paths::Paths};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub const HUGGINGFACE_TOKEN_SECRET_KEY: &str = "HF_TOKEN";
|
||||
pub const HUGGINGFACE_OAUTH_CACHE_PATH: &str = "huggingface/oauth/tokens.json";
|
||||
|
||||
pub type TokenResolver = fn() -> BoxFuture<'static, Result<Option<String>>>;
|
||||
|
||||
static TOKEN_RESOLVER: OnceLock<TokenResolver> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HuggingFaceTokenData {
|
||||
pub access_token: String,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl HuggingFaceTokenData {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at
|
||||
.is_some_and(|expires_at| expires_at <= Utc::now())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn oauth_cache_path() -> PathBuf {
|
||||
Paths::in_config_dir(HUGGINGFACE_OAUTH_CACHE_PATH)
|
||||
}
|
||||
|
||||
fn load_oauth_token_from_path(path: &Path) -> Option<HuggingFaceTokenData> {
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
pub fn usable_oauth_token() -> Option<String> {
|
||||
let token = load_oauth_token_from_path(&oauth_cache_path())?;
|
||||
(!token.is_expired()).then_some(token.access_token)
|
||||
}
|
||||
|
||||
pub fn hf_token_secret() -> Result<Option<String>> {
|
||||
Ok(config_resolver::string_param(HUGGINGFACE_TOKEN_SECRET_KEY)?
|
||||
.filter(|token| !token.trim().is_empty()))
|
||||
}
|
||||
|
||||
pub fn set_token_resolver(resolve_token: TokenResolver) {
|
||||
let _ = TOKEN_RESOLVER.set(resolve_token);
|
||||
}
|
||||
|
||||
pub async fn resolve_token_async() -> Result<Option<String>> {
|
||||
if let Some(resolve_token) = TOKEN_RESOLVER.get() {
|
||||
return resolve_token().await;
|
||||
}
|
||||
|
||||
if let Some(token) = usable_oauth_token() {
|
||||
return Ok(Some(token));
|
||||
}
|
||||
hf_token_secret()
|
||||
}
|
||||
|
|
@ -1,950 +0,0 @@
|
|||
pub mod config_resolver;
|
||||
pub use goose_download_manager as download_manager;
|
||||
pub mod huggingface_auth;
|
||||
pub mod paths;
|
||||
pub mod prompt_template;
|
||||
pub mod provider_utils;
|
||||
|
||||
mod backend;
|
||||
pub mod hf_models;
|
||||
mod llamacpp;
|
||||
pub mod local_model_registry;
|
||||
pub mod management;
|
||||
mod mlx;
|
||||
pub(crate) mod multimodal;
|
||||
#[cfg(feature = "mlx")]
|
||||
mod native_tool_parsing;
|
||||
pub(crate) mod thinking_output;
|
||||
#[cfg(feature = "mlx")]
|
||||
mod tool_emulation;
|
||||
mod tool_parsing;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_stream::try_stream;
|
||||
use async_trait::async_trait;
|
||||
use backend::{BackendLoadedModel, LocalInferenceBackend};
|
||||
use goose_provider_types::base::{MessageStream, Provider, ProviderDescriptor, ProviderMetadata};
|
||||
use goose_provider_types::conversation::message::{
|
||||
Message, MessageContent, SystemNotificationType,
|
||||
};
|
||||
use goose_provider_types::conversation::token_usage::{ProviderUsage, Usage};
|
||||
use goose_provider_types::errors::ProviderError;
|
||||
use goose_provider_types::images::ImageFormat;
|
||||
use goose_provider_types::model::ModelConfig;
|
||||
use goose_provider_types::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, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use uuid::Uuid;
|
||||
|
||||
type ModelSlotHandle = Arc<ModelSlot>;
|
||||
|
||||
struct ModelSlot {
|
||||
state: Mutex<ModelSlotState>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
enum ModelSlotState {
|
||||
Empty,
|
||||
Loading,
|
||||
Loaded(Box<dyn BackendLoadedModel>),
|
||||
}
|
||||
|
||||
impl ModelSlot {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: Mutex::new(ModelSlotState::Empty),
|
||||
notify: Notify::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct ModelCacheKey {
|
||||
backend_id: &'static str,
|
||||
model_id: String,
|
||||
chat_template: ChatTemplate,
|
||||
}
|
||||
|
||||
impl ModelCacheKey {
|
||||
fn new(
|
||||
backend_id: &'static str,
|
||||
model_id: impl Into<String>,
|
||||
chat_template: ChatTemplate,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend_id,
|
||||
model_id: model_id.into(),
|
||||
chat_template,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InferenceRuntime {
|
||||
models: StdMutex<HashMap<ModelCacheKey, ModelSlotHandle>>,
|
||||
cold_load_lock: Mutex<()>,
|
||||
backends: HashMap<&'static str, Arc<dyn LocalInferenceBackend>>,
|
||||
}
|
||||
|
||||
pub fn builtin_chat_template_names() -> Vec<String> {
|
||||
llamacpp::builtin_chat_template_names()
|
||||
}
|
||||
|
||||
static RUNTIME: StdMutex<Option<Arc<InferenceRuntime>>> = StdMutex::new(None);
|
||||
|
||||
fn current_runtime() -> Option<Arc<InferenceRuntime>> {
|
||||
RUNTIME.lock().expect("runtime lock poisoned").clone()
|
||||
}
|
||||
|
||||
impl InferenceRuntime {
|
||||
pub fn get_or_init() -> Result<Arc<Self>> {
|
||||
let mut guard = RUNTIME.lock().expect("runtime lock poisoned");
|
||||
if let Some(runtime) = guard.as_ref() {
|
||||
return Ok(runtime.clone());
|
||||
}
|
||||
let llamacpp_backend: Arc<dyn LocalInferenceBackend> = Arc::new(LlamaCppBackend::new()?);
|
||||
let mlx_backend: Arc<dyn LocalInferenceBackend> = 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()),
|
||||
cold_load_lock: Mutex::new(()),
|
||||
backends,
|
||||
});
|
||||
*guard = Some(runtime.clone());
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
fn default_backend(&self) -> &dyn LocalInferenceBackend {
|
||||
self.backends
|
||||
.get(LLAMACPP_BACKEND_ID)
|
||||
.expect("default local inference backend registered")
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
fn backend_for_model(
|
||||
&self,
|
||||
resolved: &ResolvedModelPaths,
|
||||
) -> Result<Arc<dyn LocalInferenceBackend>, ProviderError> {
|
||||
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) -> ModelSlotHandle {
|
||||
let mut map = self.models.lock().expect("model cache lock poisoned");
|
||||
map.entry(key)
|
||||
.or_insert_with(|| Arc::new(ModelSlot::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn model_slot(&self, key: &ModelCacheKey) -> Option<ModelSlotHandle> {
|
||||
let map = self.models.lock().expect("model cache lock poisoned");
|
||||
map.get(key).cloned()
|
||||
}
|
||||
|
||||
fn other_model_slots(&self, keep_key: &ModelCacheKey) -> Vec<ModelSlotHandle> {
|
||||
let map = self.models.lock().expect("model cache lock poisoned");
|
||||
map.iter()
|
||||
.filter(|(key, _)| *key != keep_key)
|
||||
.map(|(_, slot)| slot.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_model_loaded(model_name: &str) -> Result<bool, ProviderError> {
|
||||
let resolved = match resolve_model_path(model_name) {
|
||||
Some(resolved) => resolved,
|
||||
None => return Ok(false),
|
||||
};
|
||||
let runtime = InferenceRuntime::get_or_init().map_err(|error| {
|
||||
ProviderError::ExecutionError(format!("Failed to initialize local inference: {error}"))
|
||||
})?;
|
||||
let backend = runtime.backend_for_model(&resolved)?;
|
||||
let key = ModelCacheKey::new(
|
||||
backend.id(),
|
||||
model_name.to_string(),
|
||||
resolved.settings.chat_template,
|
||||
);
|
||||
let Some(slot) = runtime.model_slot(&key) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let state = slot.state.lock().await;
|
||||
Ok(matches!(*state, ModelSlotState::Loaded(_)))
|
||||
}
|
||||
|
||||
pub async fn loaded_model_ids() -> Result<HashSet<String>, ProviderError> {
|
||||
let Some(runtime) = current_runtime() else {
|
||||
return Ok(HashSet::new());
|
||||
};
|
||||
let slots = {
|
||||
let map = runtime.models.lock().expect("model cache lock poisoned");
|
||||
map.iter()
|
||||
.map(|(key, slot)| (key.model_id.clone(), slot.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut loaded = HashSet::new();
|
||||
for (model_id, slot) in slots {
|
||||
if let Ok(state) = slot.state.try_lock() {
|
||||
if matches!(*state, ModelSlotState::Loaded(_)) {
|
||||
loaded.insert(model_id);
|
||||
}
|
||||
} else {
|
||||
loaded.insert(model_id);
|
||||
}
|
||||
}
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
pub async fn evict_model(model_name: &str) -> Result<bool, ProviderError> {
|
||||
let Some(runtime) = current_runtime() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let slots = {
|
||||
let map = runtime.models.lock().expect("model cache lock poisoned");
|
||||
map.iter()
|
||||
.filter(|(key, _)| key.model_id == model_name)
|
||||
.map(|(_, slot)| slot.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut evicted = false;
|
||||
for slot in slots {
|
||||
let mut state = slot.state.lock().await;
|
||||
if matches!(*state, ModelSlotState::Loaded(_)) {
|
||||
*state = ModelSlotState::Empty;
|
||||
evicted = true;
|
||||
slot.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
Ok(evicted)
|
||||
}
|
||||
|
||||
const PROVIDER_NAME: &str = "local";
|
||||
const DEFAULT_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M";
|
||||
|
||||
pub const LOCAL_LLM_MODEL_CONFIG_KEY: &str = "LOCAL_LLM_MODEL";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ResolvedModelPaths {
|
||||
pub model_path: PathBuf,
|
||||
pub context_limit: usize,
|
||||
pub settings: crate::local_model_registry::ModelSettings,
|
||||
pub mmproj_path: Option<PathBuf>,
|
||||
pub backend_id: Option<String>,
|
||||
pub draft_model_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolve_model_local_path(model_id: &str) -> Option<PathBuf> {
|
||||
use crate::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<ResolvedModelPaths> {
|
||||
use crate::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(|| {
|
||||
config_resolver::string_param("GOOSE_LOCAL_DRAFT_MODEL")
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn available_inference_memory_bytes(runtime: &InferenceRuntime) -> u64 {
|
||||
runtime.default_backend().available_memory_bytes()
|
||||
}
|
||||
|
||||
pub fn recommend_local_model(runtime: &InferenceRuntime) -> String {
|
||||
use local_model_registry::{get_registry, is_featured_model, FEATURED_MODELS};
|
||||
|
||||
let available_memory = available_inference_memory_bytes(runtime);
|
||||
|
||||
if let Ok(registry) = get_registry().lock() {
|
||||
let mut models: Vec<_> = registry
|
||||
.list_models()
|
||||
.iter()
|
||||
.filter(|m| is_featured_model(&m.id) && m.size_bytes > 0)
|
||||
.collect();
|
||||
models.sort_by_key(|model| std::cmp::Reverse(model.size_bytes));
|
||||
|
||||
// Return largest that fits in available memory
|
||||
for model in &models {
|
||||
if available_memory >= model.size_bytes {
|
||||
return model.id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing fits, return smallest
|
||||
if let Some(smallest) = models.last() {
|
||||
return smallest.id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first featured model
|
||||
FEATURED_MODELS[0].spec.to_string()
|
||||
}
|
||||
|
||||
fn build_openai_messages_json(
|
||||
system: &str,
|
||||
messages: &[Message],
|
||||
media_marker: Option<&str>,
|
||||
) -> String {
|
||||
use goose_provider_types::formats::openai::format_messages;
|
||||
|
||||
let mut arr: Vec<Value> = 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],
|
||||
media_marker: Option<&str>,
|
||||
) -> String {
|
||||
let mut arr: Vec<Value> = vec![json!({"role": "system", "content": system})];
|
||||
arr.extend(messages.iter().filter_map(|m| {
|
||||
let content = extract_text_content(m);
|
||||
if content.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let role = match m.role {
|
||||
rmcp::model::Role::User => "user",
|
||||
rmcp::model::Role::Assistant => "assistant",
|
||||
};
|
||||
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<Vec<Value>> {
|
||||
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.
|
||||
fn strip_image_parts_from_messages(messages: &mut [Value]) {
|
||||
let mut stripped = false;
|
||||
for msg in messages.iter_mut() {
|
||||
if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||
for part in content.iter_mut() {
|
||||
if part.get("type").and_then(|t| t.as_str()) == Some("image_url") {
|
||||
*part = json!({
|
||||
"type": "text",
|
||||
"text": "[Image attached — image input is not supported with the currently selected model]"
|
||||
});
|
||||
stripped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stripped {
|
||||
tracing::warn!("Stripped image content parts from messages — vision encoder not available for this model");
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a message into plain text for the emulator path's chat history.
|
||||
///
|
||||
/// This is the emulator-path counterpart of [`format_messages`] used by the native
|
||||
/// path. It reconstructs the text-based tool syntax that the emulator prompt teaches
|
||||
/// the model:
|
||||
///
|
||||
/// - `ToolRequest` with a `"command"` argument → `$ command`
|
||||
/// - `ToolRequest` with a `"code"` argument → `` ```execute_typescript\n…\n``` ``
|
||||
/// - `ToolResponse` → `Command output:\n…`
|
||||
///
|
||||
/// Only `developer__shell` and `code_execution__execute_typescript` style tool calls are
|
||||
/// recognized (by argument shape, not tool name). Tool calls from other extensions
|
||||
/// (e.g. custom MCP tools made by a native-tool-calling model earlier in the
|
||||
/// conversation) are silently dropped, since the emulator path has no syntax to
|
||||
/// represent them.
|
||||
fn extract_text_content(msg: &Message) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
for content in &msg.content {
|
||||
match content {
|
||||
MessageContent::Text(text) => {
|
||||
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 {
|
||||
if let Some(cmd) = call
|
||||
.arguments
|
||||
.as_ref()
|
||||
.and_then(|a| a.get("command"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
parts.push(format!("$ {}", cmd));
|
||||
} else if let Some(code) = call
|
||||
.arguments
|
||||
.as_ref()
|
||||
.and_then(|a| a.get("code"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
parts.push(format!("```execute_typescript\n{}\n```", code));
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageContent::ToolResponse(response) => match &response.tool_result {
|
||||
Ok(result) => {
|
||||
let mut output_parts = Vec::new();
|
||||
for content_item in &result.content {
|
||||
if let Some(text_content) = content_item.as_text() {
|
||||
output_parts.push(text_content.text.to_string());
|
||||
}
|
||||
}
|
||||
if !output_parts.is_empty() {
|
||||
parts.push(format!("Command output:\n{}", output_parts.join("\n")));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
parts.push(format!("Command error: {}", e));
|
||||
}
|
||||
},
|
||||
MessageContent::Image(_) => {
|
||||
parts.push(
|
||||
"[Image attached — image input is not supported with the currently selected model]"
|
||||
.to_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("<info-msg>") {
|
||||
output.push_str(before);
|
||||
if let Some((_, after_end)) = after_start.split_once("</info-msg>") {
|
||||
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 Option<Box<dyn RequestLogHandle>>,
|
||||
model_name: String,
|
||||
path_label: &str,
|
||||
prompt_token_count: usize,
|
||||
output_token_count: i32,
|
||||
extra_log_fields: Option<(&str, &str)>,
|
||||
) -> ProviderUsage {
|
||||
let input_tokens = prompt_token_count as i32;
|
||||
let total_tokens = input_tokens + output_token_count;
|
||||
let usage = Usage::new(
|
||||
Some(input_tokens),
|
||||
Some(output_token_count),
|
||||
Some(total_tokens),
|
||||
);
|
||||
let mut log_json = serde_json::json!({
|
||||
"path": path_label,
|
||||
"prompt_tokens": input_tokens,
|
||||
"output_tokens": output_token_count,
|
||||
});
|
||||
if let Some((key, value)) = extra_log_fields {
|
||||
log_json[key] = serde_json::json!(value);
|
||||
}
|
||||
let _ = log.write(&log_json, Some(&usage));
|
||||
ProviderUsage::new(model_name, usage)
|
||||
}
|
||||
|
||||
type StreamSender =
|
||||
tokio::sync::mpsc::Sender<Result<(Option<Message>, Option<ProviderUsage>), ProviderError>>;
|
||||
|
||||
pub struct LocalInferenceProvider {
|
||||
runtime: Arc<InferenceRuntime>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl LocalInferenceProvider {
|
||||
pub async fn from_env() -> Result<Self> {
|
||||
let runtime = InferenceRuntime::get_or_init()?;
|
||||
Ok(Self {
|
||||
runtime,
|
||||
name: PROVIDER_NAME.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDescriptor for LocalInferenceProvider {
|
||||
fn metadata() -> ProviderMetadata
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
use crate::local_model_registry::{get_registry, FEATURED_MODELS};
|
||||
|
||||
let mut known_models: Vec<&str> = FEATURED_MODELS.iter().map(|m| m.spec).collect();
|
||||
|
||||
// Add any registry models not already in the featured list
|
||||
let mut dynamic_models = Vec::new();
|
||||
if let Ok(registry) = get_registry().lock() {
|
||||
for entry in registry.list_models() {
|
||||
if !known_models.contains(&entry.id.as_str()) {
|
||||
dynamic_models.push(entry.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let dynamic_refs: Vec<&str> = dynamic_models.iter().map(|s| s.as_str()).collect();
|
||||
known_models.extend(dynamic_refs);
|
||||
|
||||
ProviderMetadata::new(
|
||||
PROVIDER_NAME,
|
||||
"Local Inference",
|
||||
"Local inference using quantized GGUF models (llama.cpp)",
|
||||
DEFAULT_MODEL,
|
||||
known_models,
|
||||
"https://github.com/utilityai/llama-cpp-rs",
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for LocalInferenceProvider {
|
||||
fn get_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
|
||||
use crate::local_model_registry::get_registry;
|
||||
|
||||
let mut all_models: Vec<String> = Vec::new();
|
||||
|
||||
if let Ok(registry) = get_registry().lock() {
|
||||
for entry in registry.list_models() {
|
||||
all_models.push(entry.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_models)
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
model_config: &ModelConfig,
|
||||
system: &str,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let resolved = resolve_model_path(&model_config.model_name).ok_or_else(|| {
|
||||
ProviderError::ExecutionError(format!("Model not found: {}", model_config.model_name))
|
||||
})?;
|
||||
let backend = self.runtime.backend_for_model(&resolved)?;
|
||||
let model_context_limit = resolved.context_limit;
|
||||
|
||||
// Allow request_params to override thinking
|
||||
let mut model_settings = resolved.settings.clone();
|
||||
if let Some(false) = model_config
|
||||
.request_param::<bool>("enable_thinking")
|
||||
.or_else(|| {
|
||||
config_resolver::bool_param("GOOSE_LOCAL_ENABLE_THINKING")
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
{
|
||||
model_settings.enable_thinking = false;
|
||||
}
|
||||
|
||||
let cache_key = ModelCacheKey::new(
|
||||
backend.id(),
|
||||
model_config.model_name.clone(),
|
||||
model_settings.chat_template.clone(),
|
||||
);
|
||||
let model_slot = self.runtime.get_or_create_model_slot(cache_key.clone());
|
||||
let runtime = self.runtime.clone();
|
||||
|
||||
let cache_key = cache_key.clone();
|
||||
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();
|
||||
let system = system.to_string();
|
||||
let messages = messages.to_vec();
|
||||
let tools = tools.to_vec();
|
||||
let log_payload = serde_json::json!({
|
||||
"system": &system,
|
||||
"messages": messages.iter().map(|m| {
|
||||
serde_json::json!({
|
||||
"role": match m.role { rmcp::model::Role::User => "user", rmcp::model::Role::Assistant => "assistant" },
|
||||
"content": extract_text_content(m),
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"tools": tools.iter().map(|t| &t.name).collect::<Vec<_>>(),
|
||||
"settings": {
|
||||
"tool_calling": settings.tool_calling,
|
||||
"chat_template": settings.chat_template,
|
||||
"context_size": settings.context_size,
|
||||
"sampling": settings.sampling,
|
||||
},
|
||||
});
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<
|
||||
Result<(Option<Message>, Option<ProviderUsage>), ProviderError>,
|
||||
>(32);
|
||||
let mut log = start_log(model_config, &log_payload)?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut model_load_ms = None;
|
||||
|
||||
// Ensure model is loaded — unload any other models first to free memory.
|
||||
loop {
|
||||
let state = model_slot.state.lock().await;
|
||||
match &*state {
|
||||
ModelSlotState::Loaded(_) => break,
|
||||
ModelSlotState::Loading => {
|
||||
let notified = model_slot.notify.notified();
|
||||
drop(state);
|
||||
notified.await;
|
||||
}
|
||||
ModelSlotState::Empty => {
|
||||
drop(state);
|
||||
|
||||
let cold_load_guard = runtime.cold_load_lock.lock().await;
|
||||
let mut state = model_slot.state.lock().await;
|
||||
match &*state {
|
||||
ModelSlotState::Loaded(_) => break,
|
||||
ModelSlotState::Loading => {
|
||||
let notified = model_slot.notify.notified();
|
||||
drop(state);
|
||||
drop(cold_load_guard);
|
||||
notified.await;
|
||||
continue;
|
||||
}
|
||||
ModelSlotState::Empty => {}
|
||||
}
|
||||
*state = ModelSlotState::Loading;
|
||||
drop(state);
|
||||
|
||||
let loading_message = Message::assistant().with_system_notification(
|
||||
SystemNotificationType::ProgressMessage,
|
||||
format!("Loading local model {model_name}..."),
|
||||
);
|
||||
if tx.send(Ok((Some(loading_message), None))).await.is_err() {
|
||||
let mut state = model_slot.state.lock().await;
|
||||
*state = ModelSlotState::Empty;
|
||||
model_slot.notify.notify_waiters();
|
||||
return;
|
||||
}
|
||||
|
||||
let other_model_slots = runtime.other_model_slots(&cache_key);
|
||||
for slot in other_model_slots {
|
||||
let mut other = slot.state.lock().await;
|
||||
if matches!(*other, ModelSlotState::Loaded(_)) {
|
||||
tracing::info!("Unloading previous model to free memory");
|
||||
*other = ModelSlotState::Empty;
|
||||
}
|
||||
}
|
||||
|
||||
let model_id = model_name.clone();
|
||||
let resolved_for_load = resolved_model.clone();
|
||||
let settings_for_load = settings.clone();
|
||||
let backend_for_load = backend.clone();
|
||||
let load_started = std::time::Instant::now();
|
||||
let loaded = match tokio::task::spawn_blocking(move || {
|
||||
backend_for_load.load_model(
|
||||
&model_id,
|
||||
&resolved_for_load,
|
||||
&settings_for_load,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(loaded)) => loaded,
|
||||
Ok(Err(err)) => {
|
||||
let mut state = model_slot.state.lock().await;
|
||||
*state = ModelSlotState::Empty;
|
||||
model_slot.notify.notify_waiters();
|
||||
let _ = log.error(&err);
|
||||
let _ = tx.send(Err(err)).await;
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
let mut state = model_slot.state.lock().await;
|
||||
*state = ModelSlotState::Empty;
|
||||
model_slot.notify.notify_waiters();
|
||||
let err = ProviderError::ExecutionError(err.to_string());
|
||||
let _ = log.error(&err);
|
||||
let _ = tx.send(Err(err)).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let elapsed_ms =
|
||||
u64::try_from(load_started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
model_load_ms = Some(elapsed_ms);
|
||||
tracing::info!(
|
||||
backend = backend.id(),
|
||||
model = %model_name,
|
||||
model_load_ms = elapsed_ms,
|
||||
"Loaded local inference model"
|
||||
);
|
||||
let _ = log.write(
|
||||
&json!({
|
||||
"path": "model_load",
|
||||
"backend": backend.id(),
|
||||
"model": &model_name,
|
||||
"model_load_ms": elapsed_ms,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut state = model_slot.state.lock().await;
|
||||
*state = ModelSlotState::Loaded(loaded);
|
||||
model_slot.notify.notify_waiters();
|
||||
drop(cold_load_guard);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Macro to log errors before sending them through the channel
|
||||
macro_rules! send_err {
|
||||
($err:expr) => {{
|
||||
let err = $err;
|
||||
let msg = match &err {
|
||||
ProviderError::ExecutionError(s) => s.as_str(),
|
||||
ProviderError::ContextLengthExceeded(s) => s.as_str(),
|
||||
_ => "unknown error",
|
||||
};
|
||||
let _ = log.error(msg);
|
||||
let _ = tx.blocking_send(Err(err));
|
||||
return;
|
||||
}};
|
||||
}
|
||||
|
||||
let mut model_guard = model_arc.state.blocking_lock();
|
||||
let loaded = match &mut *model_guard {
|
||||
ModelSlotState::Loaded(loaded) => loaded.as_mut(),
|
||||
ModelSlotState::Empty | ModelSlotState::Loading => {
|
||||
send_err!(ProviderError::ExecutionError(
|
||||
"Model not loaded".to_string()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
|
||||
let request = backend::LocalGenerationRequest {
|
||||
model_name,
|
||||
system: &system,
|
||||
messages: &messages,
|
||||
tools: &tools,
|
||||
settings: &settings,
|
||||
temperature,
|
||||
max_tokens,
|
||||
context_limit,
|
||||
model_load_ms,
|
||||
resolved_model: &resolved_model,
|
||||
draft_model_path: resolved_model.draft_model_path.clone(),
|
||||
message_id: &message_id,
|
||||
tx: &tx,
|
||||
log: &mut log,
|
||||
};
|
||||
|
||||
let result = backend.generate(loaded, request);
|
||||
|
||||
if let Err(err) = result {
|
||||
let msg = match &err {
|
||||
ProviderError::ExecutionError(s) => s.as_str(),
|
||||
ProviderError::ContextLengthExceeded(s) => s.as_str(),
|
||||
_ => "unknown error",
|
||||
};
|
||||
let _ = log.error(msg);
|
||||
let _ = tx.blocking_send(Err(err));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(Box::pin(try_stream! {
|
||||
while let Some(result) = rx.recv().await {
|
||||
let item = result?;
|
||||
yield item;
|
||||
}
|
||||
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[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__>"},
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,860 +0,0 @@
|
|||
use super::hf_models::{
|
||||
self, register_resolved_model, resolve_local_model_selection, resolve_local_model_spec,
|
||||
resolve_model_spec, HfGgufFile, HfModelInfo, HfModelVariant,
|
||||
};
|
||||
use super::local_model_registry::{
|
||||
default_settings_for_model, featured_mmproj_spec, get_registry, model_id_from_repo,
|
||||
ChatTemplate, LocalModelEntry, LocalModelStorage, ModelDownloadStatus, ModelSettings,
|
||||
SamplingConfig, ToolCallingMode, FEATURED_MODELS,
|
||||
};
|
||||
use super::{
|
||||
available_inference_memory_bytes, builtin_chat_template_names, recommend_local_model,
|
||||
InferenceRuntime,
|
||||
};
|
||||
use crate::download_manager::{get_download_manager, DownloadProgress, DownloadStatus};
|
||||
use crate::huggingface_auth;
|
||||
use crate::paths::Paths;
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures::future::join_all;
|
||||
use goose_sdk_types::custom_requests::{
|
||||
LocalInferenceBuiltinChatTemplatesListResponse, LocalInferenceChatTemplate,
|
||||
LocalInferenceDownloadProgressDto, LocalInferenceDownloadState, LocalInferenceHfGgufFileDto,
|
||||
LocalInferenceHfModelInfoDto, LocalInferenceHfModelVariantDto,
|
||||
LocalInferenceHuggingFaceRepoVariantsResponse, LocalInferenceHuggingFaceSearchResponse,
|
||||
LocalInferenceModelDownloadRequest, LocalInferenceModelDownloadResponse,
|
||||
LocalInferenceModelDownloadStatusDto, LocalInferenceModelDto, LocalInferenceModelSettingsDto,
|
||||
LocalInferenceModelSettingsReadResponse, LocalInferenceModelSettingsUpdateResponse,
|
||||
LocalInferenceModelsListResponse, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
static MANAGEMENT_RUNTIME: OnceLock<Arc<InferenceRuntime>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LocalModelSelection {
|
||||
repo_id: String,
|
||||
backend_id: String,
|
||||
variant_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_models() -> Result<LocalInferenceModelsListResponse> {
|
||||
ensure_featured_models_current().await?;
|
||||
|
||||
let runtime = management_runtime()?;
|
||||
let recommended_id = recommend_local_model(&runtime);
|
||||
|
||||
let loaded_model_ids = crate::loaded_model_ids()
|
||||
.await
|
||||
.map_err(|error| anyhow!(error.to_string()))?;
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
let mut models: Vec<LocalInferenceModelDto> = registry
|
||||
.list_models()
|
||||
.iter()
|
||||
.map(|entry| local_model_to_dto(entry, &recommended_id, &loaded_model_ids))
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| {
|
||||
let a_downloaded = a.status.state == LocalInferenceDownloadState::Downloaded;
|
||||
let b_downloaded = b.status.state == LocalInferenceDownloadState::Downloaded;
|
||||
match (b_downloaded, a_downloaded) {
|
||||
(true, false) => std::cmp::Ordering::Greater,
|
||||
(false, true) => std::cmp::Ordering::Less,
|
||||
_ => a.id.cmp(&b.id),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(LocalInferenceModelsListResponse { models })
|
||||
}
|
||||
|
||||
pub async fn search_huggingface_models(
|
||||
query: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<LocalInferenceHuggingFaceSearchResponse> {
|
||||
let limit = limit.unwrap_or(20).min(50);
|
||||
let models = hf_models::search_local_models(&query, limit)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(hf_model_info_to_dto)
|
||||
.collect();
|
||||
Ok(LocalInferenceHuggingFaceSearchResponse { models })
|
||||
}
|
||||
|
||||
pub async fn huggingface_repo_variants(
|
||||
repo_id: String,
|
||||
) -> Result<LocalInferenceHuggingFaceRepoVariantsResponse> {
|
||||
let variants = hf_models::get_repo_local_variants(&repo_id).await?;
|
||||
|
||||
let runtime = management_runtime()?;
|
||||
let available_memory = available_inference_memory_bytes(&runtime);
|
||||
let gguf_variants: Vec<_> = variants
|
||||
.iter()
|
||||
.filter(|variant| variant.backend_id == "llamacpp")
|
||||
.map(|variant| 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, downloaded_variants) = {
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
let models: Vec<_> = registry
|
||||
.list_models()
|
||||
.iter()
|
||||
.filter(|m| m.repo_id == repo_id && m.is_downloaded())
|
||||
.collect();
|
||||
(
|
||||
models.iter().map(|m| m.quantization.clone()).collect(),
|
||||
models.iter().map(|m| m.id.clone()).collect(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(LocalInferenceHuggingFaceRepoVariantsResponse {
|
||||
variants: variants.into_iter().map(hf_model_variant_to_dto).collect(),
|
||||
recommended_index,
|
||||
available_memory_bytes: available_memory,
|
||||
downloaded_quants,
|
||||
downloaded_variants,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn download_model(
|
||||
req: LocalInferenceModelDownloadRequest,
|
||||
) -> Result<LocalInferenceModelDownloadResponse> {
|
||||
let selection = explicit_model_selection(&req)?;
|
||||
let model_id = local_model_id_from_request(&req, selection.as_ref()).await?;
|
||||
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,
|
||||
})?;
|
||||
if !download_reserved {
|
||||
return Ok(LocalInferenceModelDownloadResponse { model_id });
|
||||
}
|
||||
|
||||
if let Err(error) = register_pending_download_model(&model_id, &req, selection.as_ref()) {
|
||||
mark_download_failed(&model_id, &error);
|
||||
return Err(error.context("Failed to register download"));
|
||||
}
|
||||
|
||||
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
|
||||
} 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(LocalInferenceModelDownloadResponse { model_id })
|
||||
}
|
||||
|
||||
pub fn download_progress(model_id: &str) -> Result<Option<LocalInferenceDownloadProgressDto>> {
|
||||
Ok(get_download_manager()
|
||||
.get_progress(&format!("{}-model", model_id))
|
||||
.map(download_progress_to_dto))
|
||||
}
|
||||
|
||||
pub fn cancel_download(model_id: &str) -> Result<()> {
|
||||
let manager = get_download_manager();
|
||||
manager.cancel_download(&format!("{}-model", model_id))?;
|
||||
let _ = manager.cancel_download(&format!("{}-mmproj", model_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_model(model_id: &str) -> Result<()> {
|
||||
let mut registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
if registry.get_model(model_id).is_none() {
|
||||
anyhow::bail!("Model not found");
|
||||
}
|
||||
registry.delete_model(model_id)
|
||||
}
|
||||
|
||||
pub fn model_exists(model_id: &str) -> Result<bool> {
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
Ok(registry.get_model(model_id).is_some())
|
||||
}
|
||||
|
||||
pub async fn evict_model(model_id: &str) -> Result<()> {
|
||||
crate::evict_model(model_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| anyhow!(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_model_settings(model_id: &str) -> Result<LocalInferenceModelSettingsReadResponse> {
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
let settings = registry
|
||||
.get_model_settings(model_id)
|
||||
.ok_or_else(|| anyhow!("Model not found"))?;
|
||||
Ok(LocalInferenceModelSettingsReadResponse {
|
||||
settings: model_settings_to_dto(settings),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_model_settings(
|
||||
model_id: &str,
|
||||
settings: LocalInferenceModelSettingsDto,
|
||||
) -> Result<LocalInferenceModelSettingsUpdateResponse> {
|
||||
let settings = model_settings_from_dto(settings);
|
||||
let mut registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
registry.update_model_settings(model_id, settings.clone())?;
|
||||
Ok(LocalInferenceModelSettingsUpdateResponse {
|
||||
settings: model_settings_to_dto(&settings),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_builtin_chat_templates() -> LocalInferenceBuiltinChatTemplatesListResponse {
|
||||
LocalInferenceBuiltinChatTemplatesListResponse {
|
||||
templates: builtin_chat_template_names(),
|
||||
}
|
||||
}
|
||||
|
||||
fn management_runtime() -> Result<Arc<InferenceRuntime>> {
|
||||
if let Some(runtime) = MANAGEMENT_RUNTIME.get() {
|
||||
return Ok(runtime.clone());
|
||||
}
|
||||
|
||||
let runtime = InferenceRuntime::get_or_init()?;
|
||||
match MANAGEMENT_RUNTIME.set(runtime.clone()) {
|
||||
Ok(()) => Ok(runtime),
|
||||
Err(_) => Ok(MANAGEMENT_RUNTIME
|
||||
.get()
|
||||
.expect("local inference management runtime initialized by another thread")
|
||||
.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_featured_models_current() -> Result<()> {
|
||||
let mut mmproj_downloads_needed: Vec<(String, String, PathBuf)> = Vec::new();
|
||||
|
||||
struct PendingResolve {
|
||||
spec: &'static str,
|
||||
repo_id: String,
|
||||
quantization: String,
|
||||
model_id: String,
|
||||
}
|
||||
let mut to_resolve = Vec::new();
|
||||
|
||||
for featured in FEATURED_MODELS {
|
||||
let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) {
|
||||
Ok(parts) => parts,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let model_id = model_id_from_repo(&repo_id, &quantization);
|
||||
|
||||
{
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
if let Some(existing) = registry.get_model(&model_id) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_resolve.push(PendingResolve {
|
||||
spec: featured.spec,
|
||||
repo_id,
|
||||
quantization,
|
||||
model_id,
|
||||
});
|
||||
}
|
||||
|
||||
let resolved: Vec<(PendingResolve, HfGgufFile)> =
|
||||
join_all(to_resolve.into_iter().map(|pending| async move {
|
||||
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
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
(pending, hf_file)
|
||||
}))
|
||||
.await;
|
||||
|
||||
let entries_to_add: Vec<LocalModelEntry> = resolved
|
||||
.into_iter()
|
||||
.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);
|
||||
LocalModelEntry {
|
||||
id: pending.model_id,
|
||||
repo_id: pending.repo_id,
|
||||
filename: hf_file.filename,
|
||||
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: None,
|
||||
mmproj_source_url: None,
|
||||
mmproj_size_bytes: 0,
|
||||
mmproj_checked: false,
|
||||
shard_files: vec![],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut registry = get_registry()
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Failed to acquire registry lock"))?;
|
||||
|
||||
if !entries_to_add.is_empty() {
|
||||
registry.sync_with_featured(entries_to_add);
|
||||
}
|
||||
|
||||
for model in registry.list_models_mut() {
|
||||
model.enrich_with_featured_mmproj();
|
||||
if model.is_downloaded() {
|
||||
if let Some(mmproj) = featured_mmproj_spec(&model.id) {
|
||||
let path = mmproj.local_path();
|
||||
if !path.exists() {
|
||||
let url = format!(
|
||||
"https://huggingface.co/{}/resolve/main/{}",
|
||||
mmproj.repo, mmproj.filename
|
||||
);
|
||||
mmproj_downloads_needed.push((model.id.clone(), url, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = registry.save();
|
||||
}
|
||||
|
||||
let dm = get_download_manager();
|
||||
let hf_token = huggingface_auth::resolve_token_async().await.ok().flatten();
|
||||
let mut started_paths = std::collections::HashSet::new();
|
||||
for (model_id, url, path) in mmproj_downloads_needed {
|
||||
if !path.exists() && started_paths.insert(path.clone()) {
|
||||
let download_id = format!("{}-mmproj", model_id);
|
||||
let dominated_by_active = dm
|
||||
.get_progress(&download_id)
|
||||
.is_some_and(|p| p.status == DownloadStatus::Downloading);
|
||||
if !dominated_by_active {
|
||||
tracing::info!(model_id = %model_id, "Auto-downloading vision encoder for existing model");
|
||||
if let Err(e) = dm
|
||||
.download_model_with_bearer_token(
|
||||
download_id,
|
||||
url,
|
||||
path,
|
||||
hf_token.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(model_id = %model_id, error = %e, "Failed to start mmproj download");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_model_to_dto(
|
||||
entry: &LocalModelEntry,
|
||||
recommended_id: &str,
|
||||
loaded_model_ids: &HashSet<String>,
|
||||
) -> LocalInferenceModelDto {
|
||||
let vision_capable = entry.settings.vision_capable;
|
||||
LocalInferenceModelDto {
|
||||
id: entry.id.clone(),
|
||||
repo_id: entry.repo_id.clone(),
|
||||
filename: entry.filename.clone(),
|
||||
quantization: entry.quantization.clone(),
|
||||
size_bytes: entry.file_size(),
|
||||
status: model_download_status_to_dto(entry.download_status()),
|
||||
recommended: recommended_id == entry.id,
|
||||
is_loaded: loaded_model_ids.contains(&entry.id),
|
||||
settings: model_settings_to_dto(&entry.settings),
|
||||
vision_capable,
|
||||
mmproj_status: vision_capable
|
||||
.then(|| model_download_status_to_dto(entry.mmproj_download_status())),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_download_status_to_dto(
|
||||
status: ModelDownloadStatus,
|
||||
) -> LocalInferenceModelDownloadStatusDto {
|
||||
match status {
|
||||
ModelDownloadStatus::NotDownloaded => LocalInferenceModelDownloadStatusDto {
|
||||
state: LocalInferenceDownloadState::NotDownloaded,
|
||||
..Default::default()
|
||||
},
|
||||
ModelDownloadStatus::Downloading {
|
||||
progress_percent,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
speed_bps,
|
||||
} => LocalInferenceModelDownloadStatusDto {
|
||||
state: LocalInferenceDownloadState::Downloading,
|
||||
progress_percent: Some(progress_percent),
|
||||
bytes_downloaded: Some(bytes_downloaded),
|
||||
total_bytes: Some(total_bytes),
|
||||
speed_bps: Some(speed_bps),
|
||||
},
|
||||
ModelDownloadStatus::Downloaded => LocalInferenceModelDownloadStatusDto {
|
||||
state: LocalInferenceDownloadState::Downloaded,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn download_progress_to_dto(progress: DownloadProgress) -> LocalInferenceDownloadProgressDto {
|
||||
LocalInferenceDownloadProgressDto {
|
||||
model_id: progress.model_id,
|
||||
status: serde_json::to_value(progress.status)
|
||||
.ok()
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
bytes_downloaded: progress.bytes_downloaded,
|
||||
total_bytes: progress.total_bytes,
|
||||
progress_percent: progress.progress_percent,
|
||||
speed_bps: progress.speed_bps,
|
||||
eta_seconds: progress.eta_seconds,
|
||||
error: progress.error,
|
||||
task_exited: progress.task_exited,
|
||||
}
|
||||
}
|
||||
|
||||
fn hf_model_info_to_dto(model: HfModelInfo) -> LocalInferenceHfModelInfoDto {
|
||||
LocalInferenceHfModelInfoDto {
|
||||
repo_id: model.repo_id,
|
||||
author: model.author,
|
||||
model_name: model.model_name,
|
||||
downloads: model.downloads,
|
||||
gguf_files: model
|
||||
.gguf_files
|
||||
.into_iter()
|
||||
.map(|file| LocalInferenceHfGgufFileDto {
|
||||
filename: file.filename,
|
||||
size_bytes: file.size_bytes,
|
||||
quantization: file.quantization,
|
||||
download_url: file.download_url,
|
||||
})
|
||||
.collect(),
|
||||
variants: model
|
||||
.variants
|
||||
.into_iter()
|
||||
.map(hf_model_variant_to_dto)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hf_model_variant_to_dto(variant: HfModelVariant) -> LocalInferenceHfModelVariantDto {
|
||||
LocalInferenceHfModelVariantDto {
|
||||
variant_id: variant.variant_id,
|
||||
label: variant.label,
|
||||
backend_id: variant.backend_id,
|
||||
format: variant.format,
|
||||
model_id: variant.model_id,
|
||||
download_id: variant.download_id,
|
||||
size_bytes: variant.size_bytes,
|
||||
filename: variant.filename,
|
||||
download_url: variant.download_url,
|
||||
description: variant.description,
|
||||
quality_rank: variant.quality_rank,
|
||||
sharded: variant.sharded,
|
||||
supported: variant.supported,
|
||||
unsupported_reason: variant.unsupported_reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_settings_to_dto(settings: &ModelSettings) -> LocalInferenceModelSettingsDto {
|
||||
LocalInferenceModelSettingsDto {
|
||||
backend_id: settings.backend_id.clone(),
|
||||
context_size: settings.context_size,
|
||||
max_output_tokens: settings.max_output_tokens,
|
||||
draft_model: settings.draft_model.clone(),
|
||||
sampling: sampling_to_dto(&settings.sampling),
|
||||
repeat_penalty: settings.repeat_penalty,
|
||||
repeat_last_n: settings.repeat_last_n,
|
||||
frequency_penalty: settings.frequency_penalty,
|
||||
presence_penalty: settings.presence_penalty,
|
||||
n_batch: settings.n_batch,
|
||||
n_gpu_layers: settings.n_gpu_layers,
|
||||
use_mlock: settings.use_mlock,
|
||||
flash_attention: settings.flash_attention,
|
||||
n_threads: settings.n_threads,
|
||||
tool_calling: tool_calling_to_dto(settings.tool_calling),
|
||||
chat_template: chat_template_to_dto(&settings.chat_template),
|
||||
enable_thinking: settings.enable_thinking,
|
||||
vision_capable: settings.vision_capable,
|
||||
image_token_estimate: settings.image_token_estimate,
|
||||
mmproj_size_bytes: settings.mmproj_size_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_settings_from_dto(settings: LocalInferenceModelSettingsDto) -> ModelSettings {
|
||||
ModelSettings {
|
||||
backend_id: settings.backend_id,
|
||||
context_size: settings.context_size,
|
||||
max_output_tokens: settings.max_output_tokens,
|
||||
draft_model: settings.draft_model,
|
||||
sampling: sampling_from_dto(settings.sampling),
|
||||
repeat_penalty: settings.repeat_penalty,
|
||||
repeat_last_n: settings.repeat_last_n,
|
||||
frequency_penalty: settings.frequency_penalty,
|
||||
presence_penalty: settings.presence_penalty,
|
||||
n_batch: settings.n_batch,
|
||||
n_gpu_layers: settings.n_gpu_layers,
|
||||
use_mlock: settings.use_mlock,
|
||||
flash_attention: settings.flash_attention,
|
||||
n_threads: settings.n_threads,
|
||||
tool_calling: tool_calling_from_dto(settings.tool_calling),
|
||||
chat_template: chat_template_from_dto(settings.chat_template),
|
||||
enable_thinking: settings.enable_thinking,
|
||||
vision_capable: settings.vision_capable,
|
||||
image_token_estimate: settings.image_token_estimate,
|
||||
mmproj_size_bytes: settings.mmproj_size_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
fn sampling_to_dto(sampling: &SamplingConfig) -> LocalInferenceSamplingConfig {
|
||||
match sampling {
|
||||
SamplingConfig::Greedy => LocalInferenceSamplingConfig::Greedy,
|
||||
SamplingConfig::Temperature {
|
||||
temperature,
|
||||
top_k,
|
||||
top_p,
|
||||
min_p,
|
||||
seed,
|
||||
} => LocalInferenceSamplingConfig::Temperature {
|
||||
temperature: *temperature,
|
||||
top_k: *top_k,
|
||||
top_p: *top_p,
|
||||
min_p: *min_p,
|
||||
seed: *seed,
|
||||
},
|
||||
SamplingConfig::MirostatV2 { tau, eta, seed } => LocalInferenceSamplingConfig::MirostatV2 {
|
||||
tau: *tau,
|
||||
eta: *eta,
|
||||
seed: *seed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sampling_from_dto(sampling: LocalInferenceSamplingConfig) -> SamplingConfig {
|
||||
match sampling {
|
||||
LocalInferenceSamplingConfig::Greedy => SamplingConfig::Greedy,
|
||||
LocalInferenceSamplingConfig::Temperature {
|
||||
temperature,
|
||||
top_k,
|
||||
top_p,
|
||||
min_p,
|
||||
seed,
|
||||
} => SamplingConfig::Temperature {
|
||||
temperature,
|
||||
top_k,
|
||||
top_p,
|
||||
min_p,
|
||||
seed,
|
||||
},
|
||||
LocalInferenceSamplingConfig::MirostatV2 { tau, eta, seed } => {
|
||||
SamplingConfig::MirostatV2 { tau, eta, seed }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_calling_to_dto(mode: ToolCallingMode) -> LocalInferenceToolCallingMode {
|
||||
match mode {
|
||||
ToolCallingMode::Auto => LocalInferenceToolCallingMode::Auto,
|
||||
ToolCallingMode::ForceNative => LocalInferenceToolCallingMode::ForceNative,
|
||||
ToolCallingMode::ForceEmulated => LocalInferenceToolCallingMode::ForceEmulated,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_calling_from_dto(mode: LocalInferenceToolCallingMode) -> ToolCallingMode {
|
||||
match mode {
|
||||
LocalInferenceToolCallingMode::Auto => ToolCallingMode::Auto,
|
||||
LocalInferenceToolCallingMode::ForceNative => ToolCallingMode::ForceNative,
|
||||
LocalInferenceToolCallingMode::ForceEmulated => ToolCallingMode::ForceEmulated,
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_template_to_dto(template: &ChatTemplate) -> LocalInferenceChatTemplate {
|
||||
match template {
|
||||
ChatTemplate::Embedded => LocalInferenceChatTemplate::Embedded,
|
||||
ChatTemplate::Builtin { name } => {
|
||||
LocalInferenceChatTemplate::Builtin { name: name.clone() }
|
||||
}
|
||||
ChatTemplate::CustomInline { template } => LocalInferenceChatTemplate::CustomInline {
|
||||
template: template.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_template_from_dto(template: LocalInferenceChatTemplate) -> ChatTemplate {
|
||||
match template {
|
||||
LocalInferenceChatTemplate::Embedded => ChatTemplate::Embedded,
|
||||
LocalInferenceChatTemplate::Builtin { name } => ChatTemplate::Builtin { name },
|
||||
LocalInferenceChatTemplate::CustomInline { template } => {
|
||||
ChatTemplate::CustomInline { template }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_model_selection(
|
||||
req: &LocalInferenceModelDownloadRequest,
|
||||
) -> Result<Option<LocalModelSelection>> {
|
||||
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: &LocalInferenceModelDownloadRequest,
|
||||
selection: Option<&LocalModelSelection>,
|
||||
) -> Result<String> {
|
||||
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!(
|
||||
"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::<Vec<_>>()
|
||||
.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: &LocalInferenceModelDownloadRequest,
|
||||
selection: Option<&LocalModelSelection>,
|
||||
) -> 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!("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![],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_preserves_defaults() {
|
||||
let settings = ModelSettings::default();
|
||||
let dto = model_settings_to_dto(&settings);
|
||||
let round_trip = model_settings_from_dto(dto);
|
||||
assert_eq!(round_trip.repeat_penalty, settings.repeat_penalty);
|
||||
assert_eq!(round_trip.repeat_last_n, settings.repeat_last_n);
|
||||
assert_eq!(round_trip.enable_thinking, settings.enable_thinking);
|
||||
assert_eq!(
|
||||
round_trip.image_token_estimate,
|
||||
settings.image_token_estimate
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_llamacpp_selection_derives_quantized_model_id() {
|
||||
let req = LocalInferenceModelDownloadRequest {
|
||||
spec: "test/repo".to_string(),
|
||||
backend_id: Some("llamacpp".to_string()),
|
||||
variant_id: Some("Q4_K_M".to_string()),
|
||||
};
|
||||
let selection = explicit_model_selection(&req).unwrap();
|
||||
let model_id = local_model_id_from_request(&req, selection.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(model_id, "test/repo:Q4_K_M");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,88 +0,0 @@
|
|||
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Paths;
|
||||
|
||||
impl Paths {
|
||||
fn get_dir(dir_type: DirType) -> PathBuf {
|
||||
if let Ok(test_root) = std::env::var("GOOSE_PATH_ROOT") {
|
||||
let base = PathBuf::from(test_root);
|
||||
match dir_type {
|
||||
DirType::Config => base.join("config"),
|
||||
DirType::Data => base.join("data"),
|
||||
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
|
||||
// user config/data directories (e.g. ~/Library/Application Support/Block/goose/).
|
||||
// Changing this would orphan existing installations.
|
||||
let strategy = choose_app_strategy(AppStrategyArgs {
|
||||
top_level_domain: "Block".to_string(),
|
||||
author: "Block".to_string(),
|
||||
app_name: "goose".to_string(),
|
||||
})
|
||||
.expect("goose requires a home dir");
|
||||
|
||||
match dir_type {
|
||||
DirType::Config => strategy.config_dir(),
|
||||
DirType::Data => strategy.data_dir(),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
Self::get_dir(DirType::Config)
|
||||
}
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
Self::get_dir(DirType::Data)
|
||||
}
|
||||
|
||||
pub fn state_dir() -> PathBuf {
|
||||
Self::get_dir(DirType::State)
|
||||
}
|
||||
|
||||
pub fn plugins_dir() -> PathBuf {
|
||||
Self::get_dir(DirType::Plugins)
|
||||
}
|
||||
|
||||
pub fn agents_dir() -> PathBuf {
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn in_config_dir(subpath: &str) -> PathBuf {
|
||||
Self::config_dir().join(subpath)
|
||||
}
|
||||
|
||||
pub fn in_data_dir(subpath: &str) -> PathBuf {
|
||||
Self::data_dir().join(subpath)
|
||||
}
|
||||
}
|
||||
|
||||
enum DirType {
|
||||
Config,
|
||||
Data,
|
||||
State,
|
||||
Plugins,
|
||||
Agents,
|
||||
AgentsHome,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
use include_dir::{include_dir, Dir};
|
||||
use minijinja::{Environment, Error as MiniJinjaError, Value as MJValue};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::paths::Paths;
|
||||
|
||||
static CORE_PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/prompts");
|
||||
|
||||
pub fn render_string<T: Serialize>(
|
||||
template_str: &str,
|
||||
context: &T,
|
||||
) -> Result<String, MiniJinjaError> {
|
||||
let mut env = Environment::new();
|
||||
env.set_trim_blocks(true);
|
||||
env.set_lstrip_blocks(true);
|
||||
env.add_template("template", template_str)?;
|
||||
let tmpl = env.get_template("template")?;
|
||||
let ctx = MJValue::from_serialize(context);
|
||||
let rendered = tmpl.render(ctx)?;
|
||||
Ok(rendered.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn render_template<T: Serialize>(name: &str, context: &T) -> Result<String, MiniJinjaError> {
|
||||
let user_path = Paths::config_dir().join("prompts").join(name);
|
||||
let template_str = if user_path.exists() {
|
||||
std::fs::read_to_string(&user_path).map_err(|e| {
|
||||
MiniJinjaError::new(
|
||||
minijinja::ErrorKind::InvalidOperation,
|
||||
format!("Failed to read user template: {}", e),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
let file = CORE_PROMPTS_DIR.get_file(name).ok_or_else(|| {
|
||||
MiniJinjaError::new(
|
||||
minijinja::ErrorKind::TemplateNotFound,
|
||||
format!("Built-in template '{}' not found", name),
|
||||
)
|
||||
})?;
|
||||
String::from_utf8_lossy(file.contents()).to_string()
|
||||
};
|
||||
|
||||
render_string(&template_str, context)
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
You are goose, an autonomous AI agent created by AAIF (Agentic AI Foundation). You act on the user's
|
||||
behalf — you do not explain how to do things, you DO them directly.
|
||||
|
||||
The OS is {{os}}, the shell is {{shell}}, and the working directory is {{working_directory}}
|
||||
|
||||
When the user asks you to do something, take action immediately. Do not describe
|
||||
what you would do or give instructions — execute the commands yourself.
|
||||
|
||||
To run a shell command, start a new line with $:
|
||||
|
||||
$ ls
|
||||
|
||||
Keep your responses brief. State what you are doing, then do it. For example:
|
||||
|
||||
User: how many files are in /tmp?
|
||||
You: Let me check.
|
||||
$ ls -1 /tmp | wc -l
|
||||
|
||||
After a command runs, you will see its output. Use the output to answer the user
|
||||
or take the next step. Do not repeat commands you have already run.
|
||||
|
||||
Do not use shell commands if you already know the answer.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
pub fn filter_extensions_from_system_prompt(system: &str) -> String {
|
||||
let Some(extensions_start) = system.find("# Extensions") else {
|
||||
return system.to_string();
|
||||
};
|
||||
|
||||
let Some(after_extensions) = system.get(extensions_start + 1..) else {
|
||||
return system.to_string();
|
||||
};
|
||||
|
||||
if let Some(next_section_pos) = after_extensions.find("\n# ") {
|
||||
let Some(before) = system.get(..extensions_start) else {
|
||||
return system.to_string();
|
||||
};
|
||||
let Some(after) = system.get(extensions_start + next_section_pos + 1..) else {
|
||||
return system.to_string();
|
||||
};
|
||||
format!("{}{}", before.trim_end(), after)
|
||||
} else {
|
||||
system
|
||||
.get(..extensions_start)
|
||||
.map(|s| s.trim_end().to_string())
|
||||
.unwrap_or_else(|| system.to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
use goose_provider_types::thinking::{FilterOut, ThinkFilter};
|
||||
|
||||
pub(crate) struct ThinkingOutputFilter {
|
||||
enabled: bool,
|
||||
saw_structured_reasoning: bool,
|
||||
think_filter: ThinkFilter,
|
||||
pending_inline_thinking: String,
|
||||
accumulated_thinking: String,
|
||||
}
|
||||
|
||||
impl ThinkingOutputFilter {
|
||||
pub(crate) fn new(enable_thinking: bool, generation_prompt: &str) -> Self {
|
||||
let mut think_filter = ThinkFilter::new();
|
||||
if enable_thinking && !generation_prompt.is_empty() {
|
||||
let _ = think_filter.push(generation_prompt);
|
||||
}
|
||||
|
||||
Self {
|
||||
enabled: enable_thinking,
|
||||
saw_structured_reasoning: false,
|
||||
think_filter,
|
||||
pending_inline_thinking: String::new(),
|
||||
accumulated_thinking: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push_structured_reasoning(&mut self, reasoning: &str) -> Option<String> {
|
||||
if reasoning.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.saw_structured_reasoning = true;
|
||||
self.pending_inline_thinking.clear();
|
||||
self.think_filter = ThinkFilter::new();
|
||||
self.accumulated_thinking.push_str(reasoning);
|
||||
Some(reasoning.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn push_text(&mut self, text: &str) -> FilterOut {
|
||||
if !self.enabled {
|
||||
return FilterOut {
|
||||
content: text.to_string(),
|
||||
thinking: String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut filtered = self.think_filter.push(text);
|
||||
if self.saw_structured_reasoning {
|
||||
filtered.thinking.clear();
|
||||
} else if !filtered.thinking.is_empty() {
|
||||
self.pending_inline_thinking.push_str(&filtered.thinking);
|
||||
filtered.thinking.clear();
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self) -> FilterOut {
|
||||
let mut filtered = if self.enabled && !self.saw_structured_reasoning {
|
||||
std::mem::take(&mut self.think_filter).finish()
|
||||
} else {
|
||||
FilterOut::default()
|
||||
};
|
||||
|
||||
if !self.saw_structured_reasoning {
|
||||
let mut thinking = std::mem::take(&mut self.pending_inline_thinking);
|
||||
thinking.push_str(&filtered.thinking);
|
||||
if !thinking.is_empty() {
|
||||
self.accumulated_thinking.push_str(&thinking);
|
||||
}
|
||||
filtered.thinking = thinking;
|
||||
} else {
|
||||
filtered.thinking.clear();
|
||||
}
|
||||
|
||||
filtered
|
||||
}
|
||||
|
||||
pub(crate) fn accumulated_thinking(&self) -> &str {
|
||||
&self.accumulated_thinking
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,6 @@ once_cell = { workspace = true }
|
|||
lopdf = { version = "0.42", 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 = "3", default-features = false }
|
||||
umya-spreadsheet = { version = "2", default-features = false }
|
||||
shell-words = { workspace = true }
|
||||
process-wrap = { version = "9", default-features = false, features = ["std"] }
|
||||
|
|
|
|||
|
|
@ -84,11 +84,11 @@ pub async fn pdf_tool(
|
|||
last_was_text = false;
|
||||
}
|
||||
}
|
||||
Object::Real(offset)
|
||||
if *offset < -100.0 =>
|
||||
{
|
||||
text.push(' ');
|
||||
last_was_text = false;
|
||||
Object::Real(offset) => {
|
||||
if *offset < -100.0 {
|
||||
text.push(' ');
|
||||
last_was_text = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use umya_spreadsheet::{structs::Workbook, Worksheet};
|
||||
use umya_spreadsheet::{Spreadsheet, Worksheet};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WorksheetInfo {
|
||||
|
|
@ -28,7 +28,7 @@ pub struct RangeData {
|
|||
}
|
||||
|
||||
pub struct XlsxTool {
|
||||
workbook: Workbook,
|
||||
workbook: Spreadsheet,
|
||||
}
|
||||
|
||||
impl XlsxTool {
|
||||
|
|
@ -40,10 +40,10 @@ impl XlsxTool {
|
|||
|
||||
pub fn list_worksheets(&self) -> Result<Vec<WorksheetInfo>> {
|
||||
let mut worksheets = Vec::new();
|
||||
for (index, worksheet) in self.workbook.sheet_collection().iter().enumerate() {
|
||||
for (index, worksheet) in self.workbook.get_sheet_collection().iter().enumerate() {
|
||||
let (column_count, row_count) = self.get_worksheet_dimensions(worksheet)?;
|
||||
worksheets.push(WorksheetInfo {
|
||||
name: worksheet.name().to_string(),
|
||||
name: worksheet.get_name().to_string(),
|
||||
index,
|
||||
column_count,
|
||||
row_count,
|
||||
|
|
@ -54,13 +54,13 @@ impl XlsxTool {
|
|||
|
||||
pub fn get_worksheet_by_name(&self, name: &str) -> Result<&Worksheet> {
|
||||
self.workbook
|
||||
.sheet_by_name(name)
|
||||
.get_sheet_by_name(name)
|
||||
.context("Worksheet not found")
|
||||
}
|
||||
|
||||
pub fn get_worksheet_by_index(&self, index: usize) -> Result<&Worksheet> {
|
||||
self.workbook
|
||||
.sheet_collection()
|
||||
.get_sheet_collection()
|
||||
.get(index)
|
||||
.context("Worksheet index out of bounds")
|
||||
}
|
||||
|
|
@ -71,12 +71,12 @@ impl XlsxTool {
|
|||
let mut max_row = 0;
|
||||
|
||||
// Iterate through all rows
|
||||
for row_num in 1..=worksheet.highest_row() {
|
||||
for col_num in 1..=worksheet.highest_column() {
|
||||
if let Some(cell) = worksheet.cell((col_num, row_num)) {
|
||||
let coord = cell.coordinate();
|
||||
max_col = max_col.max(coord.col_num() as usize);
|
||||
max_row = max_row.max(coord.row_num() as usize);
|
||||
for row_num in 1..=worksheet.get_highest_row() {
|
||||
for col_num in 1..=worksheet.get_highest_column() {
|
||||
if let Some(cell) = worksheet.get_cell((col_num, row_num)) {
|
||||
let coord = cell.get_coordinate();
|
||||
max_col = max_col.max(*coord.get_col_num() as usize);
|
||||
max_row = max_row.max(*coord.get_row_num() as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,9 +86,9 @@ impl XlsxTool {
|
|||
|
||||
pub fn get_column_names(&self, worksheet: &Worksheet) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for col_num in 1..=worksheet.highest_column() {
|
||||
if let Some(cell) = worksheet.cell((col_num, 1)) {
|
||||
names.push(cell.value().into_owned());
|
||||
for col_num in 1..=worksheet.get_highest_column() {
|
||||
if let Some(cell) = worksheet.get_cell((col_num, 1)) {
|
||||
names.push(cell.get_value().into_owned());
|
||||
} else {
|
||||
names.push(String::new());
|
||||
}
|
||||
|
|
@ -104,13 +104,13 @@ impl XlsxTool {
|
|||
for row_idx in start_row..=end_row {
|
||||
let mut row_values = Vec::new();
|
||||
for col_idx in start_col..=end_col {
|
||||
let cell_value = if let Some(cell) = worksheet.cell((col_idx, row_idx)) {
|
||||
let cell_value = if let Some(cell) = worksheet.get_cell((col_idx, row_idx)) {
|
||||
CellValue {
|
||||
value: cell.value().into_owned(),
|
||||
formula: if cell.formula().is_empty() {
|
||||
value: cell.get_value().into_owned(),
|
||||
formula: if cell.get_formula().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cell.formula().to_string())
|
||||
Some(cell.get_formula().to_string())
|
||||
},
|
||||
}
|
||||
} else {
|
||||
|
|
@ -142,10 +142,12 @@ impl XlsxTool {
|
|||
) -> Result<()> {
|
||||
let worksheet = self
|
||||
.workbook
|
||||
.sheet_by_name_mut(worksheet_name)
|
||||
.get_sheet_by_name_mut(worksheet_name)
|
||||
.context("Worksheet not found")?;
|
||||
|
||||
worksheet.cell_mut((col, row)).set_value(value.to_string());
|
||||
worksheet
|
||||
.get_cell_mut((col, row))
|
||||
.set_value(value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -169,18 +171,18 @@ impl XlsxTool {
|
|||
search_text.to_string()
|
||||
};
|
||||
|
||||
for row_num in 1..=worksheet.highest_row() {
|
||||
for col_num in 1..=worksheet.highest_column() {
|
||||
if let Some(cell) = worksheet.cell((col_num, row_num)) {
|
||||
for row_num in 1..=worksheet.get_highest_row() {
|
||||
for col_num in 1..=worksheet.get_highest_column() {
|
||||
if let Some(cell) = worksheet.get_cell((col_num, row_num)) {
|
||||
let cell_value = if !case_sensitive {
|
||||
cell.value().to_lowercase()
|
||||
cell.get_value().to_lowercase()
|
||||
} else {
|
||||
cell.value().to_string()
|
||||
cell.get_value().to_string()
|
||||
};
|
||||
|
||||
if cell_value.contains(&search_text) {
|
||||
let coord = cell.coordinate();
|
||||
matches.push((coord.row_num(), coord.col_num()));
|
||||
let coord = cell.get_coordinate();
|
||||
matches.push((*coord.get_row_num(), *coord.get_col_num()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,14 +192,14 @@ impl XlsxTool {
|
|||
}
|
||||
|
||||
pub fn get_cell_value(&self, worksheet: &Worksheet, row: u32, col: u32) -> Result<CellValue> {
|
||||
let cell = worksheet.cell((col, row)).context("Cell not found")?;
|
||||
let cell = worksheet.get_cell((col, row)).context("Cell not found")?;
|
||||
|
||||
Ok(CellValue {
|
||||
value: cell.value().into_owned(),
|
||||
formula: if cell.formula().is_empty() {
|
||||
value: cell.get_value().into_owned(),
|
||||
formula: if cell.get_formula().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cell.formula().to_string())
|
||||
Some(cell.get_formula().to_string())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ impl MemoryServer {
|
|||
let category_memories = self.retrieve(&category, is_global, working_dir)?;
|
||||
memories.insert(
|
||||
category,
|
||||
category_memories.into_values().flatten().collect(),
|
||||
category_memories.into_iter().flat_map(|(_, v)| v).collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
[package]
|
||||
name = "goose-provider-types"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true, features = ["unicode"] }
|
||||
reqwest = { workspace = true }
|
||||
rmcp = { workspace = true, features = ["server", "macros"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
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"] }
|
||||
|
||||
[dev-dependencies]
|
||||
env-lock = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
test-case = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
tokio-stream = { workspace = true }
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
fn is_in_unicode_tag_range(c: char) -> bool {
|
||||
matches!(c, '\u{E0000}'..='\u{E007F}')
|
||||
}
|
||||
|
||||
pub fn sanitize_unicode_tags(text: &str) -> String {
|
||||
let normalized: String = text.nfc().collect();
|
||||
|
||||
normalized
|
||||
.chars()
|
||||
.filter(|&c| !is_in_unicode_tag_range(c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract the model name from a JSON object. Common with most providers to have this top level attribute.
|
||||
pub fn get_model(data: &serde_json::Value) -> String {
|
||||
if let Some(model) = data.get("model") {
|
||||
if let Some(model_str) = model.as_str() {
|
||||
model_str.to_string()
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
}
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "goose-providers"
|
||||
version = "0.1.0-alpha.0"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
|
@ -13,10 +13,6 @@ workspace = true
|
|||
|
||||
[features]
|
||||
default = []
|
||||
local-inference = ["dep:goose-local-inference"]
|
||||
cuda = ["local-inference", "goose-local-inference/cuda"]
|
||||
vulkan = ["local-inference", "goose-local-inference/vulkan"]
|
||||
mlx = ["local-inference", "goose-local-inference/mlx"]
|
||||
rustls-tls = [
|
||||
"reqwest/rustls",
|
||||
"rmcp/reqwest",
|
||||
|
|
@ -33,28 +29,31 @@ native-tls = [
|
|||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false }
|
||||
goose-local-inference = { version = "0.1.0-alpha.0", path = "../goose-local-inference", default-features = false, optional = true }
|
||||
once_cell = { workspace = true }
|
||||
regex = { workspace = true, features = ["unicode"] }
|
||||
reqwest = { workspace = true }
|
||||
rmcp = { workspace = true, features = ["server", "macros"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
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 }
|
||||
tokio-stream = { workspace = true, features = ["io-util"] }
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
rand = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = { 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 }
|
||||
# v0.10 matches the der/const-oid series used by sec1 v0.7 and pkcs1 v0.7; upgrading to v0.11 causes type mismatches across those crates.
|
||||
pkcs8 = { version = "0.10", default-features = false, features = ["alloc", "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 }
|
||||
include_dir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
test-case = { workspace = true }
|
||||
|
|
@ -62,7 +61,6 @@ tempfile = { workspace = true }
|
|||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
tokio-stream = { workspace = true }
|
||||
env-lock = { workspace = true }
|
||||
wiremock.workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "streaming"
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use futures::StreamExt;
|
||||
use goose_providers::{
|
||||
base::Provider, conversation::message::Message, declarative::EnvKeyResolver, model::ModelConfig,
|
||||
};
|
||||
|
||||
async fn complete(provider: &dyn Provider, model: ModelConfig) -> Result<()> {
|
||||
let system = "You are a knowledgable geography expert";
|
||||
let messages = [Message::user().with_text("what is the capital of France?")];
|
||||
let mut stream = provider.stream(&model, system, &messages, &[]).await?;
|
||||
|
||||
while let Some((Some(msg), _)) = stream.next().await.transpose()? {
|
||||
print!("{}", msg.as_concat_text());
|
||||
}
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let model = ModelConfig::new("deepseek-v4-flash");
|
||||
let provider = goose_providers::deepseek::create(None, EnvKeyResolver {})?;
|
||||
complete(provider.as_ref(), model).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
use crate::api_client::{AuthMethod, TlsConfig};
|
||||
use crate::base::ProviderDescriptor;
|
||||
use crate::declarative::{DeclarativeProviderConfig, KeyResolver};
|
||||
use crate::errors::ProviderError;
|
||||
use crate::request_log::{start_log, LoggerHandleExt};
|
||||
use anyhow::Result;
|
||||
|
|
@ -92,24 +90,6 @@ impl AnthropicProviderBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn api_client(mut self, api_client: ApiClient) -> Self {
|
||||
self.api_client = api_client;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self {
|
||||
self.api_client = f(self.api_client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn try_map_api_client(
|
||||
mut self,
|
||||
f: impl FnOnce(ApiClient) -> Result<ApiClient>,
|
||||
) -> Result<Self> {
|
||||
self.api_client = f(self.api_client)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn supports_streaming(mut self, supports_streaming: bool) -> Self {
|
||||
self.supports_streaming = supports_streaming;
|
||||
self
|
||||
|
|
@ -307,96 +287,3 @@ impl Provider for AnthropicProvider {
|
|||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions {
|
||||
AnthropicFormatOptions {
|
||||
preserve_unsigned_thinking: preserves_thinking,
|
||||
preserve_thinking_context: preserves_thinking,
|
||||
thinking_disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_declarative_config(
|
||||
config: DeclarativeProviderConfig,
|
||||
tls_config: Option<TlsConfig>,
|
||||
key_resolver: impl KeyResolver,
|
||||
) -> Result<AnthropicProviderBuilder> {
|
||||
let custom_models = if !config.models.is_empty() {
|
||||
Some(
|
||||
config
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| m.name.clone())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if config.dynamic_models == Some(false) && custom_models.is_none() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider '{}' has dynamic_models: false but no static models listed; \
|
||||
at least one entry in `models` is required.",
|
||||
config.name
|
||||
));
|
||||
}
|
||||
|
||||
let api_key = if config.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match key_resolver.resolve_key(config.api_key_env.as_str()) {
|
||||
Ok(key) => Some(key),
|
||||
Err(err) => {
|
||||
if config.requires_auth {
|
||||
anyhow::bail!("missing required key {}: {}", config.api_key_env, err);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let auth = match api_key {
|
||||
Some(key) if !key.is_empty() => AuthMethod::ApiKey {
|
||||
header_name: "x-api-key".to_string(),
|
||||
key,
|
||||
},
|
||||
_ => AuthMethod::NoAuth,
|
||||
};
|
||||
|
||||
let format_options = format_options_for_provider(config.preserves_thinking);
|
||||
|
||||
let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)?;
|
||||
|
||||
if let Some(headers) = &config.headers {
|
||||
let mut header_map = reqwest::header::HeaderMap::new();
|
||||
header_map.insert(
|
||||
reqwest::header::HeaderName::from_static("anthropic-version"),
|
||||
reqwest::header::HeaderValue::from_static(ANTHROPIC_API_VERSION),
|
||||
);
|
||||
for (key, value) in headers {
|
||||
let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?;
|
||||
let header_value = reqwest::header::HeaderValue::from_str(value)?;
|
||||
header_map.insert(header_name, header_value);
|
||||
}
|
||||
api_client = api_client.with_headers(header_map)?;
|
||||
} else {
|
||||
api_client = api_client.with_header("anthropic-version", ANTHROPIC_API_VERSION)?;
|
||||
}
|
||||
|
||||
let supports_streaming = config.supports_streaming.unwrap_or(true);
|
||||
|
||||
if !supports_streaming {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Anthropic provider does not support non-streaming mode. All Claude models support streaming. \
|
||||
Please remove 'supports_streaming: false' from your provider configuration."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AnthropicProviderBuilder::new(api_client)
|
||||
.supports_streaming(supports_streaming)
|
||||
.name(config.name.clone())
|
||||
.custom_models(custom_models)
|
||||
.dynamic_models(config.dynamic_models)
|
||||
.skip_canonical_filtering(config.skip_canonical_filtering)
|
||||
.format_options(format_options))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ impl ModelMapping {
|
|||
///
|
||||
/// This avoids network calls by looking up all known models for the provider,
|
||||
/// filtering to text-input + tool-calling models, and sorting by release date.
|
||||
/// The returned names are the canonical short names (e.g. "claude-sonnet-4.5").
|
||||
/// The returned names are the canonical short names (e.g. "claude-3.5-sonnet").
|
||||
///
|
||||
/// TODO: This trades speed for correctness — the canonical registry may not perfectly
|
||||
/// match what the provider API returns (new models not yet in the registry, deprecated
|
||||
|
|
@ -110,8 +110,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn cloud_provider_retains_cost() {
|
||||
let canonical = maybe_get_canonical_model("anthropic", "claude-sonnet-4-5-20250929")
|
||||
.expect("claude-sonnet-4.5 should resolve");
|
||||
let canonical = maybe_get_canonical_model("anthropic", "claude-3-5-sonnet-20241022")
|
||||
.expect("claude-3.5-sonnet should resolve");
|
||||
assert!(canonical.cost.input.is_some());
|
||||
assert!(canonical.cost.output.is_some());
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Canonical Model System
|
||||
|
||||
Provides a unified view of model metadata (pricing, capabilities, context limits) across different LLM providers.
|
||||
Normalizes provider-specific model names (e.g., `claude-sonnet-4-5-20250929`)
|
||||
to canonical IDs (e.g., `anthropic/claude-sonnet-4.5`).
|
||||
Normalizes provider-specific model names (e.g., `claude-3-5-sonnet-20241022`)
|
||||
to canonical IDs (e.g., `anthropic/claude-3.5-sonnet`).
|
||||
|
||||
## Build Canonical Models
|
||||
Fetches latest model metadata from OpenRouter and validates provider mappings:
|
||||
|
|
@ -13,11 +13,11 @@ cargo run --bin build_canonical_models --no-check # Build only, skip checker
|
|||
|
||||
This script performs two operations by default:
|
||||
1. **Builds canonical models** - Fetches from OpenRouter API and updates the registry
|
||||
- Writes to: `crates/goose-provider-types/src/canonical/data/canonical_models.json`
|
||||
- Writes to: `crates/goose-providers/src/canonical/data/canonical_models.json`
|
||||
2. **Checks model mappings** (unless `--no-check` is passed) - Tests provider mappings and tracks changes over time
|
||||
- Reports unmapped models
|
||||
- Compares with previous runs (like a lock file)
|
||||
- Shows changed/added/removed mappings
|
||||
- Writes to: `crates/goose-provider-types/src/canonical/data/canonical_mapping_report.json`
|
||||
- Writes to: `crates/goose-providers/src/canonical/data/canonical_mapping_report.json`
|
||||
|
||||
The script is currently built from `crates/goose/src/bin/build_canonical_models.rs` and writes into this crate's `src/canonical/data` directory.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue