diff --git a/.github/DISCUSSION_TEMPLATE/qa.yml b/.github/DISCUSSION_TEMPLATE/qa.yml
new file mode 100644
index 000000000000..9bbd676aff86
--- /dev/null
+++ b/.github/DISCUSSION_TEMPLATE/qa.yml
@@ -0,0 +1,26 @@
+title: "❓ Question: [Brief summary]"
+labels:
+ - help
+body:
+ - type: markdown
+ attributes:
+ value: |
+ 💡 Before posting, please attach your **diagnostics zip** — it helps the Goose team debug faster and saves everyone time.
+ [How to capture and share diagnostics](https://block.github.io/goose/docs/troubleshooting/diagnostics-and-reporting/)
+ - type: textarea
+ id: problem
+ attributes:
+ label: What happened?
+ description: Describe the issue in detail and attach your diagnostics zip if possible.
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ description: Tell us how to reproduce the issue — commands, steps, or context.
+ - type: textarea
+ id: version
+ attributes:
+ label: Goose version and environment
+ description: Include your Goose version and operating system if known.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 689e3ef1702a..e2d4d93e0ef8 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -2,17 +2,22 @@
name: Bug report
about: Create a report to help us improve
title: ''
-labels: ''
+labels: bug
assignees: ''
-
---
**Describe the bug**
-Note: Please check the common issues on https://block.github.io/goose/docs/troubleshooting before filing a report
+💡 Before filing, please check common issues:
+https://block.github.io/goose/docs/troubleshooting
+
+📦 To help us debug faster, attach your **diagnostics zip** if possible.
+👉 How to capture it: https://block.github.io/goose/docs/troubleshooting/diagnostics-and-reporting/
A clear and concise description of what the bug is.
+---
+
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
@@ -20,18 +25,26 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
+---
+
**Expected behavior**
A clear and concise description of what you expected to happen.
+---
+
**Screenshots**
If applicable, add screenshots to help explain your problem.
-**Please provide following information:**
- - **OS & Arch:** [e.g. Ubuntu 22.04 x86]
- - **Interface:** [UI/CLI]
- - **Version:** [e.g. v1.0.2]
- - **Extensions enabled:** [e.g. Computer Controller, Figma]
- - **Provider & Model:** [e.g. Google - gemini-1.5-pro]
+---
+
+**Please provide the following information**
+- **OS & Arch:** [e.g. Ubuntu 22.04 x86]
+- **Interface:** [UI / CLI]
+- **Version:** [e.g. v1.0.2]
+- **Extensions enabled:** [e.g. Computer Controller, Figma]
+- **Provider & Model:** [e.g. Google – gemini-1.5-pro]
+
+---
**Additional context**
Add any other context about the problem here.
diff --git a/.github/actions/generate-release-pr-body/action.yaml b/.github/actions/generate-release-pr-body/action.yaml
new file mode 100644
index 000000000000..72ccd754e382
--- /dev/null
+++ b/.github/actions/generate-release-pr-body/action.yaml
@@ -0,0 +1,44 @@
+name: 'Generate Release Notes'
+description: 'Generate release notes for a given version'
+
+inputs:
+ version:
+ description: 'The version being released'
+ required: true
+ head_ref:
+ description: 'The commit SHA or reference of the head of the release branch'
+ required: true
+ prior_ref:
+ description: 'The previous version to compare against'
+ required: true
+
+outputs:
+ pr_body_file:
+ description: 'Path to the generated PR body file'
+ value: ${{ steps.generate.outputs.pr_body_file }}
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Generate release notes
+ id: generate
+ shell: bash
+ env:
+ VERSION: ${{ inputs.version }}
+ HEAD_REF: ${{ inputs.head_ref }}
+ PRIOR_REF: ${{ inputs.prior_ref }}
+ TEMPLATE_FILE: "${{ github.action_path }}/pr_body_template.txt"
+ run: |
+ git fetch origin --tags
+
+ {
+ sed -e "s/{{VERSION}}/${VERSION}/g" \
+ -e "s/{{PRIOR_VERSION}}/${PRIOR_REF}/g" \
+ "$TEMPLATE_FILE"
+ git log --pretty=format:"- %s (%h)" --reverse ${PRIOR_REF}..${HEAD_REF}
+ echo ""
+ echo "---"
+ echo "*This release PR was generated automatically.*"
+ } > pr_body.txt
+
+ echo "pr_body_file=pr_body.txt" >> $GITHUB_OUTPUT
diff --git a/.github/actions/generate-release-pr-body/pr_body_template.txt b/.github/actions/generate-release-pr-body/pr_body_template.txt
new file mode 100644
index 000000000000..e63e7c2041f7
--- /dev/null
+++ b/.github/actions/generate-release-pr-body/pr_body_template.txt
@@ -0,0 +1,19 @@
+# Release v{{VERSION}}
+
+## How to Release
+
+Push the release tag to trigger the release:
+ ```bash
+ git fetch && git tag v{{VERSION}} origin/release/{{VERSION}}
+ git push origin v{{VERSION}}
+ ```
+This PR will auto-merge once the tag is pushed.
+
+## Important Notes
+
+- All commits in this release should have corresponding cherry-picks in `main`
+- This PR can be closed if the release is not needed.
+
+## Changes in This Release
+
+**Comparing:** `{{PRIOR_VERSION}}...v{{VERSION}}`
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index b8f1f7e29f82..bc90ec22d9a6 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -14,6 +14,10 @@
- [ ] Build / Release
- [ ] Other (specify below)
+### AI Assistance
+
+- [ ] This PR was created or reviewed with AI assistance
+
### Testing
@@ -27,5 +31,6 @@ Before:
After:
-
+### Submitting a Recipe?
+
**Email**:
diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml
index 188f8388beab..5aede7a1dbea 100644
--- a/.github/workflows/build-cli.yml
+++ b/.github/workflows/build-cli.yml
@@ -97,10 +97,10 @@ jobs:
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f
with:
path: target
- key: ${{ runner.os }}-cargo-build-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('rust-toolchain.toml') }}
+ key: ${{ runner.os }}-cargo-build-release-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('rust-toolchain.toml') }}
restore-keys: |
- ${{ runner.os }}-cargo-build-${{ hashFiles('Cargo.lock') }}-
- ${{ runner.os }}-cargo-build-
+ ${{ runner.os }}-cargo-build-release-${{ hashFiles('Cargo.lock') }}-
+ ${{ runner.os }}-cargo-build-release-
- name: Build CLI (Linux/macOS)
if: matrix.use-cross
diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml
index f1b001535903..a8d698b8259a 100644
--- a/.github/workflows/bundle-desktop-intel.yml
+++ b/.github/workflows/bundle-desktop-intel.yml
@@ -83,9 +83,9 @@ jobs:
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3
with:
path: target
- key: ${{ runner.os }}-intel-cargo-build-${{ hashFiles('**/Cargo.lock') }}
+ key: ${{ runner.os }}-intel-cargo-build-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
- ${{ runner.os }}-intel-cargo-build-
+ ${{ runner.os }}-intel-cargo-build-release-
- name: Build goose-server for Intel macOS (x86_64)
diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml
index 426d139e084e..8d24787c9d57 100644
--- a/.github/workflows/bundle-desktop-linux.yml
+++ b/.github/workflows/bundle-desktop-linux.yml
@@ -95,9 +95,9 @@ jobs:
${{ env.CARGO_HOME }}/registry/index/
${{ env.CARGO_HOME }}/registry/cache/
${{ env.CARGO_HOME }}/git/db/
- key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+ key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
- ${{ runner.os }}-cargo-
+ ${{ runner.os }}-cargo-release-
- name: Build goosed binary
env:
diff --git a/.github/workflows/bundle-desktop-manual.yml b/.github/workflows/bundle-desktop-manual.yml
new file mode 100644
index 000000000000..f2943e8d6637
--- /dev/null
+++ b/.github/workflows/bundle-desktop-manual.yml
@@ -0,0 +1,19 @@
+name: Manual Desktop Bundle (Unsigned)
+
+on:
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: 'Branch name to bundle app from'
+ required: true
+ type: string
+
+jobs:
+ bundle-desktop-unsigned:
+ uses: ./.github/workflows/bundle-desktop.yml
+ permissions:
+ id-token: write
+ contents: read
+ with:
+ signing: false
+ ref: ${{ inputs.branch }}
diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml
index 433ed661c5e6..bbde5345f659 100644
--- a/.github/workflows/bundle-desktop-windows.yml
+++ b/.github/workflows/bundle-desktop-windows.yml
@@ -92,10 +92,10 @@ jobs:
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f
with:
path: target
- key: ${{ runner.os }}-cargo-build-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('rust-toolchain.toml') }}
+ key: ${{ runner.os }}-cargo-build-release-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('rust-toolchain.toml') }}
restore-keys: |
- ${{ runner.os }}-cargo-build-${{ hashFiles('Cargo.lock') }}-
- ${{ runner.os }}-cargo-build-
+ ${{ runner.os }}-cargo-build-release-${{ hashFiles('Cargo.lock') }}-
+ ${{ runner.os }}-cargo-build-release-
# 4) Build Rust for Windows using Docker (cross-compilation with enhanced caching)
- name: Build Windows executable using Docker cross-compilation with enhanced caching
diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml
index 8995eb92c6d1..dd9f58cc6b32 100644
--- a/.github/workflows/bundle-desktop.yml
+++ b/.github/workflows/bundle-desktop.yml
@@ -3,6 +3,7 @@
# - release.yml
# - canary.yml
# - pr-comment-bundle-desktop.yml
+# - bundle-desktop-manual.yml
on:
workflow_call:
inputs:
@@ -127,9 +128,9 @@ jobs:
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3
with:
path: target
- key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
+ key: ${{ runner.os }}-cargo-build-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
- ${{ runner.os }}-cargo-build-
+ ${{ runner.os }}-cargo-build-release-
# Build the project
- name: Build goosed
@@ -295,4 +296,4 @@ jobs:
exit 1
fi
# Kill the app to clean up
- pkill -f "Goose.app/Contents/MacOS/Goose"
\ No newline at end of file
+ pkill -f "Goose.app/Contents/MacOS/Goose"
diff --git a/.github/workflows/check-release-pr.yaml b/.github/workflows/check-release-pr.yaml
new file mode 100644
index 000000000000..d5462ee41749
--- /dev/null
+++ b/.github/workflows/check-release-pr.yaml
@@ -0,0 +1,57 @@
+name: Check Release PR
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ branches:
+ - main
+
+jobs:
+ check-commits:
+ runs-on: ubuntu-latest
+ if: startsWith(github.head_ref, 'release/')
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
+ with:
+ ref: ${{ github.head_ref }}
+ fetch-depth: 0
+
+ - name: Check all PR commits exist in main
+ run: |
+ git fetch origin main
+
+ CHERRY_OUTPUT=$(git cherry origin/main HEAD)
+
+ if [ -z "$CHERRY_OUTPUT" ]; then
+ echo "✅ All commits already exist in main"
+ exit 0
+ fi
+
+ echo "Cherry check results:"
+ echo "$CHERRY_OUTPUT"
+
+ MISSING_COMMITS=$(echo "$CHERRY_OUTPUT" | grep '^+' | cut -d' ' -f2)
+
+ if [ -z "$MISSING_COMMITS" ]; then
+ echo "✅ All commits exist in main"
+ exit 0
+ fi
+
+ COMMIT_COUNT=$(echo "$MISSING_COMMITS" | wc -l)
+ FIRST_COMMIT=$(git rev-list --reverse HEAD ^origin/main | head -1)
+
+ if [ "$COMMIT_COUNT" -eq 1 ] && echo "$MISSING_COMMITS" | grep -q "$FIRST_COMMIT"; then
+ echo "✅ Only version bump commit is unique"
+ git log --oneline -1 "$FIRST_COMMIT"
+ else
+ echo "❌ Found commits that should exist in main:"
+ for commit in $MISSING_COMMITS; do
+ if [ "$commit" != "$FIRST_COMMIT" ]; then
+ git log --oneline -1 "$commit"
+ fi
+ done
+ echo "Make sure commits have equivalents in main. If you've since updated main, re-run this job"
+ exit 1
+ fi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 172b02d30e0d..3a3395fccc7a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,9 +73,9 @@ jobs:
${{ env.CARGO_HOME }}/registry/cache/
${{ env.CARGO_HOME }}/git/db/
target/
- key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+ key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
- ${{ runner.os }}-cargo-
+ ${{ runner.os }}-cargo-debug-
- name: Build and Test
run: |
@@ -131,13 +131,4 @@ jobs:
run: source ../../bin/activate-hermit && npm run test:run
working-directory: ui/desktop
- # Faster Desktop App build for PRs only
- bundle-desktop-unsigned:
- uses: ./.github/workflows/bundle-desktop.yml
- permissions:
- id-token: write
- contents: read
- needs: changes
- if: (github.event_name == 'pull_request' || github.event_name == 'merge_group') && (needs.changes.outputs.code == 'true' || github.event_name != 'pull_request')
- with:
- signing: false
+
diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml
new file mode 100644
index 000000000000..5d9047ab2e75
--- /dev/null
+++ b/.github/workflows/create-release-pr.yaml
@@ -0,0 +1,83 @@
+name: Release Workflow
+
+on:
+ workflow_call:
+ inputs:
+ bump_type:
+ description: 'Type of version bump (minor or patch)'
+ required: true
+ type: string
+ target_branch:
+ description: 'Target branch for the pull request'
+ required: false
+ type: string
+ default: 'main'
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ create-release:
+ runs-on: ubuntu-latest
+ env:
+ BUMP_TYPE: ${{ inputs.bump_type }}
+ TARGET_BRANCH: ${{ inputs.target_branch }}
+
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
+ with:
+ ref: ${{ inputs.target_branch }}
+ fetch-depth: 0 # to generate complete release log
+
+ - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
+
+ - name: Validate input and set old version
+ run: |
+ if [[ "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "patch" ]]; then
+ echo "Error: bump_type must be 'minor' or 'patch'"
+ exit 1
+ fi
+
+ - name: create release branch
+ run: |
+ PRIOR_VERSION=$(just get-tag-version)
+ if [[ "$BUMP_TYPE" == "minor" ]]; then
+ VERSION=$(just get-next-minor-version)
+ else
+ VERSION=$(just get-next-patch-version)
+ fi
+
+ echo "prior_ref=v$PRIOR_VERSION" >> $GITHUB_ENV
+ echo "version=$VERSION" >> $GITHUB_ENV
+ echo "Version: $VERSION"
+
+ git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+
+ just prepare-release $VERSION
+ BRANCH_NAME=$(git branch --show-current)
+ echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV
+ echo "Branch: $BRANCH_NAME"
+
+ - name: push release branch
+ run: |
+ git push origin "${{ env.branch_name }}"
+
+ - name: Generate release notes
+ uses: ./.github/actions/generate-release-pr-body
+ with:
+ version: ${{ env.version }}
+ head_ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ prior_ref: ${{ env.prior_ref }}
+
+ - name: Create Pull Request
+ run: |
+ gh pr create \
+ -B "$TARGET_BRANCH" \
+ -H "${{ env.branch_name }}" \
+ --title "chore(release): release version ${{ env.version }} ($BUMP_TYPE)" \
+ --body-file pr_body.txt
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/merge-release-pr-on-tag.yaml b/.github/workflows/merge-release-pr-on-tag.yaml
new file mode 100644
index 000000000000..cbf122f7c55c
--- /dev/null
+++ b/.github/workflows/merge-release-pr-on-tag.yaml
@@ -0,0 +1,148 @@
+name: Merge release PR on tag push
+
+on:
+ push:
+ tags:
+ - 'v[0-9]+.[0-9]+.[0-9]+'
+
+permissions:
+ actions: write
+ contents: write
+ pull-requests: write
+ checks: read
+
+jobs:
+ trigger-patch-release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
+
+ - name: Extract version from tag
+ id: version
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ VERSION=${TAG#v}
+ BRANCH="release/${VERSION}"
+
+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
+ echo "branch=${BRANCH}" >> $GITHUB_OUTPUT
+
+ echo "Tag: ${TAG}"
+ echo "Version: ${VERSION}"
+ echo "Expected branch: ${BRANCH}"
+
+ - name: Find matching PR
+ id: find_pr
+ env:
+ GH_TOKEN: ${{ github.token }}
+ BRANCH: ${{ steps.version.outputs.branch }}
+ run: |
+ PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
+
+ if [ -z "$PR_NUMBER" ]; then
+ echo "❌ No open PR found for branch: $BRANCH"
+ echo "pr_found=false" >> $GITHUB_OUTPUT
+ exit 1
+ else
+ echo "✅ Found PR #$PR_NUMBER for branch: $BRANCH"
+ echo "pr_found=true" >> $GITHUB_OUTPUT
+ echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Get PR details and check status
+ if: steps.find_pr.outputs.pr_found == 'true'
+ id: pr_status
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ steps.find_pr.outputs.pr_number }}
+ run: |
+ PR_DATA=$(gh pr view $PR_NUMBER --json title,headRefName,baseRefName,mergeable,statusCheckRollup)
+
+ TITLE=$(echo "$PR_DATA" | jq -r '.title')
+ HEAD_BRANCH=$(echo "$PR_DATA" | jq -r '.headRefName')
+ BASE_BRANCH=$(echo "$PR_DATA" | jq -r '.baseRefName')
+ MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable')
+
+ echo "PR Title: $TITLE"
+ echo "Head Branch: $HEAD_BRANCH"
+ echo "Base Branch: $BASE_BRANCH"
+ echo "Mergeable: $MERGEABLE"
+
+ if [ "$MERGEABLE" != "MERGEABLE" ]; then
+ echo "❌ PR is not in a mergeable state: $MERGEABLE"
+ echo "can_merge=false" >> $GITHUB_OUTPUT
+ echo "merge_reason=PR is not mergeable (state: $MERGEABLE)" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+
+ STATUS_CHECKS=$(echo "$PR_DATA" | jq -r '.statusCheckRollup[]? | select(.conclusion != null) | "\(.context): \(.conclusion)"')
+ FAILED_CHECKS=$(echo "$PR_DATA" | jq -r '.statusCheckRollup[]? | select(.conclusion == "FAILURE" or .conclusion == "CANCELLED" or .conclusion == "TIMED_OUT") | .context')
+ PENDING_CHECKS=$(echo "$PR_DATA" | jq -r '.statusCheckRollup[]? | select(.conclusion == null) | .context')
+
+ echo "Status checks:"
+ if [ -n "$STATUS_CHECKS" ]; then
+ echo "$STATUS_CHECKS"
+ else
+ echo "No status checks found"
+ fi
+
+ if [ -n "$FAILED_CHECKS" ]; then
+ echo "❌ Failed checks found:"
+ echo "$FAILED_CHECKS"
+ echo "can_merge=false" >> $GITHUB_OUTPUT
+ echo "merge_reason=Some checks are failing" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+
+ if [ -n "$PENDING_CHECKS" ]; then
+ echo "⏳ Pending checks found:"
+ echo "$PENDING_CHECKS"
+ echo "can_merge=false" >> $GITHUB_OUTPUT
+ echo "merge_reason=Some checks are still pending" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+
+ echo "✅ All checks are passing and PR is ready to merge"
+ echo "can_merge=true" >> $GITHUB_OUTPUT
+
+ - name: Get branch SHA before merge
+ if: steps.find_pr.outputs.pr_found == 'true' && steps.pr_status.outputs.can_merge == 'true'
+ id: branch_info
+ env:
+ BRANCH: ${{ steps.version.outputs.branch }}
+ run: |
+ git fetch origin
+
+ BRANCH_SHA=$(git rev-parse "origin/$BRANCH")
+ echo "branch_sha=$BRANCH_SHA" >> $GITHUB_OUTPUT
+ echo "Branch SHA: $BRANCH_SHA"
+
+ - name: Merge PR
+ if: steps.find_pr.outputs.pr_found == 'true' && steps.pr_status.outputs.can_merge == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ steps.find_pr.outputs.pr_number }}
+ VERSION: ${{ steps.version.outputs.version }}
+ TAG: ${{ steps.version.outputs.tag }}
+ run: |
+ gh pr merge $PR_NUMBER --squash --delete-branch --subject "Release $VERSION" --body "Auto-merged release PR after tag $TAG was pushed"
+
+ - name: Restore branch
+ if: steps.find_pr.outputs.pr_found == 'true' && steps.pr_status.outputs.can_merge == 'true'
+ env:
+ BRANCH: ${{ steps.version.outputs.branch }}
+ BRANCH_SHA: ${{ steps.branch_info.outputs.branch_sha }}
+ run: |
+ git checkout -b "$BRANCH" "$BRANCH_SHA"
+ git push origin "$BRANCH"
+
+ - name: Trigger patch release
+ env:
+ BRANCH: ${{ steps.version.outputs.branch }}
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh workflow run patch-release.yaml \
+ --field target_branch=$BRANCH
diff --git a/.github/workflows/minor-release.yaml b/.github/workflows/minor-release.yaml
new file mode 100644
index 000000000000..99ce40a43932
--- /dev/null
+++ b/.github/workflows/minor-release.yaml
@@ -0,0 +1,16 @@
+name: Create Minor Release PR
+
+permissions:
+ contents: write
+ pull-requests: write
+
+on:
+ schedule:
+ - cron: '0 0 * * 2'
+ workflow_dispatch:
+
+jobs:
+ release:
+ uses: ./.github/workflows/create-release-pr.yaml
+ with:
+ bump_type: "minor"
diff --git a/.github/workflows/patch-release.yaml b/.github/workflows/patch-release.yaml
new file mode 100644
index 000000000000..29ed3c076093
--- /dev/null
+++ b/.github/workflows/patch-release.yaml
@@ -0,0 +1,20 @@
+name: Create Patch Release PR
+
+permissions:
+ contents: write
+ pull-requests: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ target_branch:
+ description: 'Target branch for hotfix'
+ required: true
+ type: string
+
+jobs:
+ hotfix:
+ uses: ./.github/workflows/create-release-pr.yaml
+ with:
+ bump_type: "patch"
+ target_branch: ${{ inputs.target_branch }}
diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml
index 84a0e6964790..124c0fcb4511 100644
--- a/.github/workflows/pr-smoke-test.yml
+++ b/.github/workflows/pr-smoke-test.yml
@@ -2,6 +2,9 @@ on:
pull_request:
branches:
- main
+ push:
+ branches:
+ - main
workflow_dispatch:
inputs:
branch:
@@ -13,8 +16,16 @@ on:
name: Live Provider Tests
jobs:
+ check-fork:
+ runs-on: ubuntu-latest
+ # Skip entire workflow for PRs from forks (they don't have access to secrets)
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
+ steps:
+ - run: echo "Not a fork PR - proceeding with smoke tests"
+
changes:
runs-on: ubuntu-latest
+ needs: check-fork
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
@@ -62,9 +73,9 @@ jobs:
${{ env.CARGO_HOME }}/registry/cache/
${{ env.CARGO_HOME }}/git/db/
target/
- key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+ key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
- ${{ runner.os }}-cargo-
+ ${{ runner.os }}-cargo-release-
- name: Build Release Binary for Smoke Tests
run: |
@@ -117,6 +128,21 @@ jobs:
# Run the provider test script (binary already built and downloaded)
bash scripts/test_providers.sh
+ - name: Run MCP Tests
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
+ DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
+ DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }}
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+ TETRATE_API_KEY: ${{ secrets.TETRATE_API_KEY }}
+ HOME: /tmp/goose-home
+ GOOSE_DISABLE_KEYRING: 1
+ SKIP_BUILD: 1
+ run: |
+ bash scripts/test_mcp.sh
+
- name: Run Subrecipe Tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -127,3 +153,14 @@ jobs:
SKIP_BUILD: 1
run: |
bash scripts/test_subrecipes.sh
+
+ - name: Run Compaction Tests
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ GOOSE_PROVIDER: anthropic
+ GOOSE_MODEL: claude-sonnet-4-5-20250929
+ HOME: /tmp/goose-home
+ GOOSE_DISABLE_KEYRING: 1
+ SKIP_BUILD: 1
+ run: |
+ bash scripts/test_compaction.sh
diff --git a/.github/workflows/update-release-pr.yaml b/.github/workflows/update-release-pr.yaml
new file mode 100644
index 000000000000..fc5cc94afe9a
--- /dev/null
+++ b/.github/workflows/update-release-pr.yaml
@@ -0,0 +1,66 @@
+name: Update Release Notes
+
+on:
+ pull_request:
+ types:
+ - synchronize
+ branches:
+ - main
+ - release/**
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ update-release-notes:
+ runs-on: ubuntu-latest
+ if: startsWith(github.event.pull_request.head.ref, 'release/') && github.event.pull_request.user.login == 'github-actions[bot]'
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
+ with:
+ fetch-depth: 0 # to generate complete release log
+
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
+ with:
+ ref: ${{ github.base_ref }}
+ path: './prior-version'
+
+ - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
+
+ - name: Extract version from branch name
+ env:
+ REF_NAME: ${{ github.head_ref }}
+ run: |
+ BRANCH_NAME="$REF_NAME"
+ VERSION=$(echo "$BRANCH_NAME" | sed 's/release\///')
+ echo "version=$VERSION" >> $GITHUB_ENV
+ echo "Version: $VERSION"
+
+ - name: Get prior version
+ working-directory: './prior-version'
+ run: |
+ PRIOR_VERSION=$(just get-tag-version)
+ echo "prior_ref=v$PRIOR_VERSION" >> $GITHUB_ENV
+
+ - name: Generate release notes
+ uses: ./.github/actions/generate-release-pr-body
+ with:
+ version: ${{ env.version }}
+ head_ref: ${{ github.event.pull_request.head.sha }}
+ prior_ref: ${{ env.prior_ref }}
+
+ - name: Update Pull Request
+ env:
+ REF_NAME: ${{ github.head_ref }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ PR_NUMBER=$(gh pr list --head "$REF_NAME" --json number --jq '.[0].number')
+
+ if [[ -z "$PR_NUMBER" || "$PR_NUMBER" == "null" ]]; then
+ echo "No PR found for branch $REF_NAME"
+ exit 1
+ fi
+
+ gh pr edit "$PR_NUMBER" --body-file pr_body.txt
diff --git a/AGENTS.md b/AGENTS.md
index a4d3d3d361af..6f10190d2d7d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,6 +82,7 @@ Never: Edit ui/desktop/openapi.json manually
Never: Edit Cargo.toml use cargo add
Never: Skip cargo fmt
Never: Merge without ./scripts/clippy-lint.sh
+Never: Comment self-evident operations (`// Initialize`, `// Return result`), getters/setters, constructors, or standard Rust idioms
## Entry Points
- CLI: crates/goose-cli/src/main.rs
diff --git a/BUILDING_DOCKER.md b/BUILDING_DOCKER.md
index ef3b62e0bed7..ed61e4b5e0a9 100644
--- a/BUILDING_DOCKER.md
+++ b/BUILDING_DOCKER.md
@@ -319,4 +319,4 @@ When contributing Docker-related changes:
- [goose in Docker Tutorial](documentation/docs/tutorials/goose-in-docker.md) - Step-by-step tutorial
- [Installation Guide](https://block.github.io/goose/docs/getting-started/installation) - All installation methods
-- [Configuration Guide](https://block.github.io/goose/docs/guides/config-file) - Detailed configuration options
+- [Configuration Guide](https://block.github.io/goose/docs/guides/config-files) - Detailed configuration options
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3def3d362790..7ec7a08c8268 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,23 +9,7 @@ We welcome pull requests for general contributions! If you have a larger new fea
---
-## 🎉 Hacktoberfest 2025 🎉
-
-`goose` is a participating in Hacktoberfest 2025! We’re so excited for your contributions, and have created a wide variety of issues so that anyone can contribute. Whether you're a seasoned developer or a first-time open source contributor, there's something for everyone.
-
-### Here's how you can get started:
-
-1. Read the [code of conduct](https://github.com/block/.github/blob/main/CODE_OF_CONDUCT.md).
-2. Skim the quick AI contribution tips below (and see the [full Responsible AI-Assisted Coding Guide](./ai-assisted-coding-guide.md) for details).
-3. Choose a task from this project's Hacktoberfest issues in our [Project Hub](https://github.com/block/goose/issues/4705). Each issue has the 🏷️ `hacktoberfest` label.
-4. Comment ".take" on the corresponding issue to get assigned the task.
-5. Fork the repository and create a new branch for your work.
-6. Make your changes and submit a pull request.
-7. Wait for review and address any feedback.
-
----
-
-### 🤖 Quick Responsible AI Tips
+## 🤖 Quick Responsible AI Tips
If you use Goose, Copilot, Claude, or other AI tools to help with your PRs:
@@ -51,35 +35,7 @@ If you use Goose, Copilot, Claude, or other AI tools to help with your PRs:
- Document your changes
- Ask for review if security or core code is involved
-👉 Full guide here: [Responsible AI-Assisted Coding Guide](./ai-assisted-coding-guide.md)
-
----
-
-### 🏆 Leaderboard & Prizes
-
-Every hacktoberfest PR and contribution will earn you points on our [leaderboard](https://github.com/block/goose/issues/4775). Those who end up in the top 20 participants with the most points by the end of October will earn exclusive swag and LLM credits! As you have issues merged, here is a brief explanation on how our automatic points system works.
-
-#### Point System
-
-| Weight | Points Awarded | Description |
-|---------|-------------|-------------|
-| 🐭 **Small** | 5 points | For smaller tasks that take limited time to complete and/or don't require any product knowledge. |
-| 🐰 **Medium** | 10 points | For average tasks that take additional time to complete and/or require some product knowledge. |
-| 🐂 **Large** | 15 points | For heavy tasks that takes lots of time to complete and/or possibly require deep product knowledge. |
-
-#### Prizes You Can Win
-
-- **Top 5**: $100 gift card to our [brand new goose swag shop](https://www.gooseswag.xyz/) and $100 of LLM credits!
-- **Top 6-10**: $50 gift cards for goose swag shop and $50 of LLM credits!
-- **Top 11-20**: $25 of LLM credits!
-
-Keep an eye on your progress via our [Leaderboard](https://github.com/block/goose/issues/4775).
-
-### 👩 Need help?
-
-Need help or have questions? Feel free to reach out by connecting with us in our [Discord community](https://discord.gg/goose-oss) to get direct help from our team in the `#hacktoberfest` project channel.
-
-Happy contributing!
+👉 Full guide here: [Responsible AI-Assisted Coding Guide](./HOWTOAI.md)
---
diff --git a/Cargo.lock b/Cargo.lock
index 18578f0b1640..516d6f3eae41 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2597,9 +2597,9 @@ checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "globset"
-version = "0.4.16"
+version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
@@ -2610,7 +2610,7 @@ dependencies = [
[[package]]
name = "goose"
-version = "1.11.0"
+version = "1.13.0"
dependencies = [
"ahash",
"anyhow",
@@ -2632,7 +2632,9 @@ dependencies = [
"etcetera",
"fs2",
"futures",
+ "ignore",
"include_dir",
+ "indexmap 2.12.0",
"indoc",
"insta",
"jsonschema",
@@ -2646,8 +2648,10 @@ dependencies = [
"oauth2",
"once_cell",
"opentelemetry",
+ "opentelemetry-appender-tracing",
"opentelemetry-otlp",
"opentelemetry_sdk",
+ "paste",
"rand 0.8.5",
"regex",
"reqwest 0.12.12",
@@ -2659,6 +2663,7 @@ dependencies = [
"serde_yaml",
"serial_test",
"sha2",
+ "shellexpand",
"sqlx",
"sys-info",
"temp-env",
@@ -2687,7 +2692,7 @@ dependencies = [
[[package]]
name = "goose-bench"
-version = "1.11.0"
+version = "1.13.0"
dependencies = [
"anyhow",
"async-trait",
@@ -2710,7 +2715,7 @@ dependencies = [
[[package]]
name = "goose-cli"
-version = "1.11.0"
+version = "1.13.0"
dependencies = [
"agent-client-protocol",
"anstream",
@@ -2762,7 +2767,7 @@ dependencies = [
[[package]]
name = "goose-mcp"
-version = "1.11.0"
+version = "1.13.0"
dependencies = [
"anyhow",
"async-trait",
@@ -2828,7 +2833,7 @@ dependencies = [
[[package]]
name = "goose-server"
-version = "1.11.0"
+version = "1.13.0"
dependencies = [
"anyhow",
"async-trait",
@@ -2865,7 +2870,7 @@ dependencies = [
[[package]]
name = "goose-test"
-version = "1.11.0"
+version = "1.13.0"
dependencies = [
"clap",
"serde_json",
@@ -2897,7 +2902,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"slab",
"tokio",
"tokio-util",
@@ -2916,7 +2921,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.2.0",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"slab",
"tokio",
"tokio-util",
@@ -2960,6 +2965,12 @@ dependencies = [
"foldhash",
]
+[[package]]
+name = "hashbrown"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
+
[[package]]
name = "hashlink"
version = "0.8.4"
@@ -3405,9 +3416,9 @@ dependencies = [
[[package]]
name = "ignore"
-version = "0.4.23"
+version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b"
+checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
@@ -3508,13 +3519,14 @@ dependencies = [
[[package]]
name = "indexmap"
-version = "2.7.1"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
+checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
dependencies = [
"equivalent",
- "hashbrown 0.15.2",
+ "hashbrown 0.16.0",
"serde",
+ "serde_core",
]
[[package]]
@@ -3966,7 +3978,7 @@ dependencies = [
"chrono",
"encoding_rs",
"flate2",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"itoa",
"log",
"md-5",
@@ -4559,6 +4571,18 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "opentelemetry-appender-tracing"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab5feffc321035ad94088a7e5333abb4d84a8726e54a802e736ce9dd7237e85b"
+dependencies = [
+ "opentelemetry",
+ "tracing",
+ "tracing-core",
+ "tracing-subscriber",
+]
+
[[package]]
name = "opentelemetry-http"
version = "0.27.0"
@@ -4857,7 +4881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
dependencies = [
"base64 0.22.1",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"quick-xml 0.32.0",
"serde",
"time",
@@ -5010,7 +5034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35f4dc9988d1326b065b4def5e950c3ed727aa03e3151b86cc9e2aec6b03f54"
dependencies = [
"futures",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"nix 0.29.0",
"tokio",
"tracing",
@@ -5538,9 +5562,9 @@ dependencies = [
[[package]]
name = "rmcp"
-version = "0.8.1"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f35acda8f89fca5fd8c96cae3c6d5b4c38ea0072df4c8030915f3b5ff469c1c"
+checksum = "1fdad1258f7259fdc0f2dfc266939c82c3b5d1fd72bcde274d600cdc27e60243"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -5566,9 +5590,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
-version = "0.8.1"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c9f1d5220aaa23b79c3d02e18f7a554403b3ccea544bbb6c69d6bcb3e854a274"
+checksum = "ede0589a208cc7ce81d1be68aa7e74b917fcd03c81528408bab0457e187dcd9b"
dependencies = [
"darling 0.21.0",
"proc-macro2",
@@ -5977,7 +6001,7 @@ version = "1.0.142"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
dependencies = [
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"itoa",
"memchr",
"ryu",
@@ -6026,7 +6050,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"serde",
"serde_derive",
"serde_json",
@@ -6052,7 +6076,7 @@ version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"itoa",
"ryu",
"serde",
@@ -6129,9 +6153,9 @@ checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "shellexpand"
-version = "3.1.0"
+version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b"
+checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
dependencies = [
"dirs",
]
@@ -6299,7 +6323,7 @@ dependencies = [
"futures-util",
"hashlink",
"hex",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"log",
"memchr",
"once_cell",
@@ -7032,6 +7056,7 @@ dependencies = [
"futures-core",
"futures-io",
"futures-sink",
+ "futures-util",
"pin-project-lite",
"tokio",
]
@@ -7063,7 +7088,7 @@ version = "0.22.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
dependencies = [
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"serde",
"serde_spanned",
"toml_datetime",
@@ -7547,7 +7572,7 @@ version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
dependencies = [
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"serde",
"serde_json",
"utoipa-gen",
@@ -8664,7 +8689,7 @@ dependencies = [
"crc32fast",
"crossbeam-utils",
"flate2",
- "indexmap 2.7.1",
+ "indexmap 2.12.0",
"memchr",
"zopfli",
]
diff --git a/Cargo.toml b/Cargo.toml
index 7d28bc13d5f5..15a55c891629 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
-version = "1.11.0"
+version = "1.13.0"
authors = ["Block "]
license = "Apache-2.0"
repository = "https://github.com/block/goose"
@@ -12,9 +12,10 @@ description = "An AI agent"
[workspace.lints.clippy]
uninlined_format_args = "allow"
+string_slice = "warn"
[workspace.dependencies]
-rmcp = { version = "0.8.1", features = ["schemars", "auth"] }
+rmcp = { version = "0.8.3", features = ["schemars", "auth"] }
# Patch for Windows cross-compilation issue with crunchy
[patch.crates-io]
diff --git a/HOWTOAI.md b/HOWTOAI.md
new file mode 100644
index 000000000000..32efe2cb71d1
--- /dev/null
+++ b/HOWTOAI.md
@@ -0,0 +1,317 @@
+# How to Use AI with goose
+_A practical guide for contributing to goose using AI coding assistants_
+
+goose benefits from thoughtful AI-assisted development, but contributors must maintain high standards for code quality, security, and collaboration. Whether you use goose itself, GitHub Copilot, Cursor, Claude, or other AI tools, this guide will help you contribute effectively.
+
+---
+
+## Core Principles
+
+- **Human Oversight**: You are accountable for all code you submit. Never commit code you don’t understand or can’t maintain.
+- **Quality Standards**: AI code must meet the same standards as human written code—tests, docs, and patterns included.
+- **Transparency**: Be open about significant AI usage in PRs and explain how you validated it.
+
+---
+
+## Best Practices
+
+**✅ Recommended Uses**
+
+- Generating boilerplate code and common patterns
+- Creating comprehensive test suites
+- Writing documentation and comments
+- Refactoring existing code for clarity
+- Generating utility functions and helpers
+- Explaining existing code patterns
+
+**❌ Avoid AI For**
+
+- Complex business logic without thorough review
+- Security critical authentication/authorization code
+- Code you don’t fully understand
+- Large architectural changes
+- Database migrations or schema changes
+
+**Workflow Tips**
+
+- Start small and validate often. Build, lint, and test incrementally
+- Study existing patterns before generating new code
+- Always ask: "Is this secure? Does it follow project patterns? What edge cases need testing?"
+
+**Security Considerations**
+
+- Extra review required for MCP servers, network code, file system ops, user input, and credential handling
+- Never expose secrets in prompts
+- Sanitize inputs/outputs and follow goose’s security patterns
+
+---
+
+## Testing & Review
+
+Before submitting AI assisted code, confirm that:
+- You understand every line
+- All tests pass locally (happy path + error cases)
+- Docs are updated and accurate
+- Code follows existing patterns
+
+**Always get human review** for:
+
+- Security sensitive code
+- Core architecture changes
+- Async/concurrency logic
+- MCP protocol implementations
+- Large refactors or anything you’re unsure about
+
+---
+
+## Using goose for goose development
+
+- Protect sensitive files with `.gooseignore` (e.g., `.env*`, `*.key`, `target/`, `.git/`)
+- Guide goose with `.goosehints` (patterns, error handling, formatting, tests, docs)
+- Use `/plan` to structure work, and choose modes wisely:
+ - **Chat** for understanding
+ - **Smart Approval** for most dev work
+ - **Approval** for critical areas
+ - **Autonomous** only with safety nets
+
+---
+
+## Community & Collaboration
+
+- In PRs, note significant AI use and how you validated results
+- Share prompting tips, patterns, and pitfalls
+- Be responsive to feedback and help improve this guide
+
+---
+
+## Remember
+
+AI is a powerful assistant, not a replacement for your judgment. Use it to speed up development; while keeping your brain engaged, your standards high, and goose secure.
+
+Questions? Join our [Discord](https://discord.gg/goose-oss) or [GitHub Discussions](https://github.com/block/goose/discussions) to talk more about responsible AI development.
+
+---
+
+## Getting Started with AI Tools
+
+### Quick Setup
+
+**Using goose (meta!):**
+```bash
+# Install goose
+curl -fsSL https://github.com/block/goose/releases/latest/download/install.sh | bash
+
+# Navigate to your goose clone
+cd /path/to/goose
+
+# Start goose in the repo
+goose
+```
+
+**Using GitHub Copilot:**
+- Install the [GitHub Copilot extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) for VS Code
+- Enable Copilot for Rust files in your settings
+- Recommended: Also install [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) for better code intelligence
+
+**Using Cursor:**
+- Download [Cursor](https://cursor.sh/) (VS Code fork with built-in AI)
+- Open the goose repository
+- Use Cmd/Ctrl+K for inline AI editing, Cmd/Ctrl+L for chat
+
+**Using Claude or ChatGPT:**
+- Copy relevant code sections into the chat interface
+- Provide context about the goose architecture (see below)
+- Always test generated code locally before committing
+
+### Rust-Specific Configuration
+
+If you're new to Rust, configure your AI tool to help you learn:
+
+**VS Code settings.json:**
+```json
+{
+ "rust-analyzer.checkOnSave.command": "clippy",
+ "github.copilot.enable": {
+ "rust": true
+ }
+}
+```
+
+**Cursor Rules (.cursorrules in repo root):**
+```
+This is a Rust project using cargo workspaces.
+- Follow existing error handling patterns using anyhow::Result
+- Use async/await for I/O operations
+- Follow the project's clippy lints (see clippy-baselines/)
+- Run cargo fmt before committing
+```
+
+---
+
+## Understanding goose's Architecture
+
+New to AI agents? Here are key questions to ask your AI tool:
+
+### Essential Concepts
+
+**"Explain the goose crate structure"**
+```
+Ask: "I'm looking at the goose repository. Can you explain the purpose of each crate
+in the crates/ directory and how they relate to each other?"
+
+Key insight: goose uses a workspace with specialized crates:
+- goose: Core agent logic
+- goose-cli: Command-line interface
+- goose-server: Backend for desktop app (goosed)
+- goose-mcp: MCP server implementations
+```
+
+**"How does the MCP protocol work in goose?"**
+```
+Ask: "What is the Model Context Protocol (MCP) and how does goose implement it?
+Show me an example from crates/goose-mcp/"
+
+Key insight: MCP allows goose to connect to external tools and data sources.
+Each MCP server provides specific capabilities (developer tools, file access, etc.)
+```
+
+**"What's the agent execution flow?"**
+```
+Ask: "Walk me through what happens when a user sends a message to goose.
+Start from crates/goose-cli/src/main.rs"
+
+Key insight: Message → Agent → Provider (LLM) → Tool execution → Response
+```
+
+### Navigating the Codebase with AI
+
+**Finding the right file:**
+```
+# Use ripgrep with AI assistance
+Ask: "I want to add a new shell command tool. Where should I look?"
+AI might suggest: rg "shell" crates/goose-mcp/ -l
+
+Then ask: "Explain the structure of crates/goose-mcp/src/developer/tools/shell.rs"
+```
+
+**Understanding patterns:**
+```
+Ask: "Show me the pattern for implementing a new Provider in goose"
+Then: "What's the difference between streaming and non-streaming providers?"
+```
+
+---
+
+## Practical Examples
+
+### Example 1: Understanding How to Add a New MCP Tool
+
+**Scenario:** You want to add a new tool to the developer MCP server.
+
+**Step 1 - Explore existing tools:**
+```bash
+# Ask AI: "Show me the structure of an existing MCP tool"
+ls crates/goose-mcp/src/developer/tools/
+
+# Pick a simple one to study
+# Ask AI: "Explain this tool implementation line by line"
+cat crates/goose-mcp/src/developer/tools/shell.rs
+```
+
+**Step 2 - Ask AI to draft your new tool:**
+```
+Prompt: "I want to add a new MCP tool called 'git_status' that runs git status
+and returns the output. Based on the pattern in shell.rs, draft the implementation."
+```
+
+**Step 3 - Validate with AI:**
+```
+Ask: "Review this code for:
+1. Proper error handling using anyhow::Result
+2. Security concerns (command injection, etc.)
+3. Async/await patterns matching the codebase
+4. Test coverage needs"
+```
+
+**Step 4 - Test locally:**
+```bash
+# Build and test
+cargo build -p goose-mcp
+cargo test -p goose-mcp
+
+# Run clippy
+./scripts/clippy-lint.sh
+```
+
+### Example 2: Fixing a Rust Compiler Error
+
+**Scenario:** You're getting a lifetime error you don't understand.
+
+**Step 1 - Copy the full error:**
+```bash
+cargo build 2>&1 | pbcopy # macOS
+cargo build 2>&1 | xclip # Linux
+```
+
+**Step 2 - Ask AI with context:**
+```
+Prompt: "I'm getting this Rust compiler error in the goose project:
+
+[paste error]
+
+Here's the relevant code:
+[paste code section]
+
+Explain what's wrong and how to fix it following Rust best practices."
+```
+
+**Step 3 - Understand the fix:**
+```
+Ask: "Explain why this fix works and what I should learn about Rust lifetimes"
+```
+
+**Step 4 - Apply and verify:**
+```bash
+# Apply the fix
+# Then verify it compiles and tests pass
+cargo build
+cargo test
+```
+
+### Example 3: Adding a Feature to the CLI
+
+**Scenario:** You want to add a new command-line flag to goose-cli.
+
+**Step 1 - Find the CLI argument parsing:**
+```bash
+# Ask AI: "Where does goose-cli parse command line arguments?"
+rg "clap" crates/goose-cli/src/ -l
+```
+
+**Step 2 - Study the pattern:**
+```
+Ask: "Explain how goose-cli uses clap for argument parsing.
+Show me how existing flags are defined."
+```
+
+**Step 3 - Draft your addition:**
+```
+Prompt: "I want to add a --verbose flag that enables debug logging.
+Based on the existing patterns in goose-cli, show me:
+1. How to add the flag to the CLI args struct
+2. How to pass it to the goose core
+3. How to use it to control log levels"
+```
+
+**Step 4 - Implement with validation:**
+```bash
+# Make changes
+# Build both crates
+cargo build -p goose-cli -p goose
+
+# Test the new flag
+./target/debug/goose --verbose session
+
+# Run tests
+cargo test -p goose-cli
+```
\ No newline at end of file
diff --git a/Justfile b/Justfile
index ca22e0cd6f27..afa29b714c56 100644
--- a/Justfile
+++ b/Justfile
@@ -268,6 +268,12 @@ validate version:
exit 1
fi
+get-next-minor-version:
+ @python -c "import sys; v=sys.argv[1].split('.'); print(f'{v[0]}.{int(v[1])+1}.0')" $(just get-tag-version)
+
+get-next-patch-version:
+ @python -c "import sys; v=sys.argv[1].split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" $(just get-tag-version)
+
# set cargo and app versions, must be semver
prepare-release version:
@just validate {{ version }} || exit 1
@@ -280,7 +286,8 @@ prepare-release version:
# see --workspace flag https://doc.rust-lang.org/cargo/commands/cargo-update.html
# used to update Cargo.lock after we've bumped versions in Cargo.toml
@cargo update --workspace
- @git add Cargo.toml Cargo.lock ui/desktop/package.json ui/desktop/package-lock.json
+ @just generate-openapi
+ @git add Cargo.toml Cargo.lock ui/desktop/package.json ui/desktop/package-lock.json ui/desktop/openapi.json
@git commit --message "chore(release): release version {{ version }}"
# extract version from Cargo.toml
diff --git a/README.md b/README.md
index db7b7f55dd14..de9bdde7f260 100644
--- a/README.md
+++ b/README.md
@@ -17,20 +17,6 @@ _a local, extensible, open source AI agent that automates engineering tasks_
-## 🎉 Hacktoberfest 2025 🎉
-
-`goose` is a participating project in Hacktoberfest 2025! We’re so excited for your contributions, and have created a wide variety of issues so that anyone can contribute. Whether you're a seasoned developer or a first-time open source contributor, there's something for everyone.
-
-### To get started:
-1. Read the [contributing guide](https://github.com/block/goose/blob/main/CONTRIBUTING.md).
-2. Read the [code of conduct](https://github.com/block/.github/blob/main/CODE_OF_CONDUCT.md).
-3. Read the [full Responsible AI-Assisted Coding Guide](./ai-assisted-coding-guide.md).
-4. Choose a task from this project's Hacktoberfest issues in our [Project Hub](https://github.com/block/goose/issues/4705) and follow the instructions. Each issue has the 🏷️ `hacktoberfest` label.
-
-Have questions? Connecting with us in our [Discord community](https://discord.gg/goose-oss) in the `#hacktoberfest` project channel.
-
----
-
goose is your on-machine AI agent, capable of automating complex development tasks from start to finish. More than just code suggestions, goose can build entire projects from scratch, write and execute code, debug failures, orchestrate workflows, and interact with external APIs - _autonomously_.
Whether you're prototyping an idea, refining existing code, or managing intricate engineering pipelines, goose adapts to your workflow and executes tasks with precision.
@@ -44,8 +30,13 @@ Designed for maximum flexibility, goose works with any LLM and supports multi-mo
- [Installation](https://block.github.io/goose/docs/getting-started/installation)
- [Tutorials](https://block.github.io/goose/docs/category/tutorials)
- [Documentation](https://block.github.io/goose/docs/category/getting-started)
+- [Responsible AI-Assisted Coding Guide](https://github.com/block/goose/blob/main/HOWTOAI.md)
- [Governance](https://github.com/block/goose/blob/main/GOVERNANCE.md)
+## Need Help?
+- [Diagnostics & Reporting](https://block.github.io/goose/docs/troubleshooting/diagnostics-and-reporting)
+- [Known Issues](https://block.github.io/goose/docs/troubleshooting/known-issues)
+
# a little goose humor 🦢
> Why did the developer choose goose as their AI agent?
diff --git a/ai-assisted-coding-guide.md b/ai-assisted-coding-guide.md
deleted file mode 100644
index 3464f0bb4d80..000000000000
--- a/ai-assisted-coding-guide.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Responsible AI-Assisted Coding Guide
-_Guidelines for contributing responsibly to goose during Hacktoberfest_
-
-goose benefits from thoughtful AI assisted development, but contributors must maintain high standards for code quality, security, and collaboration. Whether you use goose, Copilot, Claude, or other AI tools, these principles will help you avoid common pitfalls.
-
----
-
-## Core Principles
-
-- **Human Oversight**: You are accountable for all code you submit. Never commit code you don’t understand or can’t maintain.
-- **Quality Standards**: AI code must meet the same standards as human written code—tests, docs, and patterns included.
-- **Transparency**: Be open about significant AI usage in PRs and explain how you validated it.
-
----
-
-## Best Practices
-
-**✅ Recommended Uses**
-
-- Generating boilerplate code and common patterns
-- Creating comprehensive test suites
-- Writing documentation and comments
-- Refactoring existing code for clarity
-- Generating utility functions and helpers
-- Explaining existing code patterns
-
-**❌ Avoid AI For**
-
-- Complex business logic without thorough review
-- Security critical authentication/authorization code
-- Code you don’t fully understand
-- Large architectural changes
-- Database migrations or schema changes
-
-**Workflow Tips**
-
-- Start small and validate often—build, lint, and test incrementally
-- Study existing patterns before generating new code
-- Always ask: “Is this secure? Does it follow project patterns? What edge cases need testing?”
-
-**Security Considerations**
-
-- Extra review required for MCP servers, network code, file system ops, user input, and credential handling
-- Never expose secrets in prompts
-- Sanitize inputs/outputs and follow goose’s security patterns
-
----
-
-## Testing & Review
-
-Before submitting AI assisted code, confirm that:
-- You understand every line
-- All tests pass locally (happy path + error cases)
-- Docs are updated and accurate
-- Code follows existing patterns
-
-**Always get human review** for:
-
-- Security sensitive code
-- Core architecture changes
-- Async/concurrency logic
-- MCP protocol implementations
-- Large refactors or anything you’re unsure about
-
----
-
-## Using goose for goose Development
-
-- Protect sensitive files with `.gooseignore` (e.g., `.env*`, `*.key`, `target/`, `.git/`)
-- Guide Goose with `.goosehints` (patterns, error handling, formatting, tests, docs)
-- Use `/plan` to structure work, and choose modes wisely:
- - **Chat** for understanding
- - **Smart Approval** for most dev work
- - **Approval** for critical areas
- - **Autonomous** only with safety nets
-
----
-
-## Community & Collaboration
-
-- In PRs, note significant AI use and how you validated results
-- Share prompting tips, patterns, and pitfalls
-- Be responsive to feedback and help improve this guide
-
----
-
-## Remember
-
-AI is a powerful assistant, not a replacement for your judgment. Use it to speed up development; while keeping your brain engaged, your standards high, and goose secure.
-
-Questions? Join our [Discord](https://discord.gg/goose-oss) or [GitHub Discussions](https://github.com/block/goose/discussions) to talk more about responsible AI development.
diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml
index 83ebb8644a92..dbb546843778 100644
--- a/crates/goose-cli/Cargo.toml
+++ b/crates/goose-cli/Cargo.toml
@@ -54,7 +54,7 @@ tower-http = { version = "0.5", features = ["cors", "fs", "auth"] }
http = "1.0"
webbrowser = "1.0"
indicatif = "0.17.11"
-tokio-util = { version = "0.7.15", features = ["compat"] }
+tokio-util = { version = "0.7.15", features = ["compat", "rt"] }
is-terminal = "0.4.16"
anstream = "0.6.18"
url = "2.5.7"
diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs
index 4df791843c45..c642e48dbf70 100644
--- a/crates/goose-cli/src/cli.rs
+++ b/crates/goose-cli/src/cli.rs
@@ -19,6 +19,7 @@ use crate::commands::session::{handle_session_list, handle_session_remove};
use crate::recipes::extract_from_cli::extract_recipe_info_from_cli;
use crate::recipes::recipe::{explain_recipe, render_recipe_as_yaml};
use crate::session::{build_session, SessionBuilderConfig, SessionSettings};
+use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use goose_bench::bench_config::BenchRunConfig;
use goose_bench::runners::bench_runner::BenchRunner;
@@ -40,17 +41,17 @@ struct Cli {
#[group(required = false, multiple = false)]
pub struct Identifier {
#[arg(
- short,
+ short = 'n',
long,
value_name = "NAME",
help = "Name for the chat session (e.g., 'project-x')",
- long_help = "Specify a name for your chat session. When used with --resume, will resume this specific session if it exists.",
- alias = "id"
+ long_help = "Specify a name for your chat session. When used with --resume, will resume this specific session if it exists."
)]
pub name: Option,
#[arg(
long = "session-id",
+ alias = "id",
value_name = "SESSION_ID",
help = "Session ID (e.g., '20250921_143022')",
long_help = "Specify a session ID directly. When used with --resume, will resume this specific session if it exists."
@@ -58,7 +59,6 @@ pub struct Identifier {
pub session_id: Option,
#[arg(
- short,
long,
value_name = "PATH",
help = "Legacy: Path for the chat session",
@@ -86,9 +86,12 @@ async fn get_or_create_session_id(
.ok_or_else(|| anyhow::anyhow!("No session found to resume"))?;
Ok(Some(session_id))
} else {
- let session =
- SessionManager::create_session(std::env::current_dir()?, "CLI Session".to_string())
- .await?;
+ let session = SessionManager::create_session(
+ std::env::current_dir()?,
+ "CLI Session".to_string(),
+ SessionType::User,
+ )
+ .await?;
Ok(Some(session.id))
};
};
@@ -105,8 +108,12 @@ async fn get_or_create_session_id(
.ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?;
Ok(Some(session_id))
} else {
- let session =
- SessionManager::create_session(std::env::current_dir()?, name.clone()).await?;
+ let session = SessionManager::create_session(
+ std::env::current_dir()?,
+ name.clone(),
+ SessionType::User,
+ )
+ .await?;
SessionManager::update_session(&session.id)
.user_provided_name(name)
@@ -123,9 +130,12 @@ async fn get_or_create_session_id(
.ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?;
Ok(Some(session_id))
} else {
- let session =
- SessionManager::create_session(std::env::current_dir()?, "CLI Session".to_string())
- .await?;
+ let session = SessionManager::create_session(
+ std::env::current_dir()?,
+ "CLI Session".to_string(),
+ SessionType::User,
+ )
+ .await?;
Ok(Some(session.id))
}
}
@@ -177,7 +187,8 @@ enum SessionCommand {
ascending: bool,
#[arg(
- short = 'p',
+ short = 'w',
+ short_alias = 'p',
long = "working_dir",
help = "Filter sessions by working directory"
)]
@@ -186,16 +197,15 @@ enum SessionCommand {
#[arg(short = 'l', long = "limit", help = "Limit the number of results")]
limit: Option,
},
- #[command(about = "Remove sessions. Runs interactively if no ID or regex is provided.")]
+ #[command(about = "Remove sessions. Runs interactively if no ID, name, or regex is provided.")]
Remove {
+ #[command(flatten)]
+ identifier: Option,
#[arg(
- short,
+ short = 'r',
long,
- alias = "name",
- help = "Session ID to be removed (optional)"
+ help = "Regex for removing matched sessions (optional)"
)]
- id: Option,
- #[arg(short, long, help = "Regex for removing matched sessions (optional)")]
regex: Option,
},
#[command(about = "Export a session")]
@@ -221,12 +231,12 @@ enum SessionCommand {
},
#[command(name = "diagnostics")]
Diagnostics {
- /// Session ID to generate diagnostics for
- #[arg(short, long)]
- session_id: String,
+ /// Session identifier for generating diagnostics
+ #[command(flatten)]
+ identifier: Option,
/// Output path for the diagnostics zip file (optional, defaults to current directory)
- #[arg(short, long)]
+ #[arg(short = 'o', long)]
output: Option,
},
}
@@ -235,8 +245,12 @@ enum SessionCommand {
enum SchedulerCommand {
#[command(about = "Add a new scheduled job")]
Add {
- #[arg(long, help = "Unique ID for the job")]
- id: String,
+ #[arg(
+ long = "schedule-id",
+ alias = "id",
+ help = "Unique ID for the recurring scheduled job"
+ )]
+ schedule_id: String,
#[arg(
long,
help = "Cron expression for the schedule",
@@ -253,29 +267,33 @@ enum SchedulerCommand {
List {},
#[command(about = "Remove a scheduled job by ID")]
Remove {
- #[arg(long, help = "ID of the job to remove")] // Changed from positional to named --id
- id: String,
+ #[arg(
+ long = "schedule-id",
+ alias = "id",
+ help = "ID of the scheduled job to remove (removes the recurring schedule)"
+ )]
+ schedule_id: String,
},
/// List sessions created by a specific schedule
#[command(about = "List sessions created by a specific schedule")]
Sessions {
/// ID of the schedule
- #[arg(long, help = "ID of the schedule")] // Explicitly make it --id
- id: String,
- #[arg(long, help = "Maximum number of sessions to return")]
+ #[arg(long = "schedule-id", alias = "id", help = "ID of the schedule")]
+ schedule_id: String,
+ #[arg(short = 'l', long, help = "Maximum number of sessions to return")]
limit: Option,
},
#[command(about = "Run a scheduled job immediately")]
RunNow {
/// ID of the schedule to run
- #[arg(long, help = "ID of the schedule to run")] // Explicitly make it --id
- id: String,
+ #[arg(long = "schedule-id", alias = "id", help = "ID of the schedule to run")]
+ schedule_id: String,
},
/// Check status of scheduler services (deprecated - no external services needed)
- #[command(about = "Check status of scheduler services")]
+ #[command(about = "[Deprecated] Check status of scheduler services")]
ServicesStatus {},
/// Stop scheduler services (deprecated - no external services needed)
- #[command(about = "Stop scheduler services")]
+ #[command(about = "[Deprecated] Stop scheduler services")]
ServicesStop {},
/// Show cron expression examples and help
#[command(about = "Show cron expression examples and help")]
@@ -424,8 +442,8 @@ enum Command {
#[arg(
short,
long,
- help = "Resume a previous session (last used or specified by --name)",
- long_help = "Continue from a previous chat session. If --name or --path is provided, resumes that specific session. Otherwise resumes the last used session."
+ help = "Resume a previous session (last used or specified by --name/--session-id)",
+ long_help = "Continue from a previous session. If --name or --session-id is provided, resumes that specific session. Otherwise resumes the most recently used session."
)]
resume: bool,
@@ -709,6 +727,16 @@ enum Command {
)]
additional_sub_recipes: Vec,
+ /// Output format (text, json)
+ #[arg(
+ long = "output-format",
+ value_name = "FORMAT",
+ help = "Output format (text, json)",
+ default_value = "text",
+ value_parser = clap::builder::PossibleValuesParser::new(["text", "json"])
+ )]
+ output_format: String,
+
/// Provider to use for this run (overrides environment variable)
#[arg(
long = "provider",
@@ -882,8 +910,13 @@ pub async fn cli() -> anyhow::Result<()> {
working_dir,
limit,
}) => Ok(handle_session_list(format, ascending, working_dir, limit).await?),
- Some(SessionCommand::Remove { id, regex }) => {
- Ok(handle_session_remove(id, regex).await?)
+ Some(SessionCommand::Remove { identifier, regex }) => {
+ let (session_id, name) = if let Some(id) = identifier {
+ (id.session_id, id.name)
+ } else {
+ (None, None)
+ };
+ Ok(handle_session_remove(session_id, name, regex).await?)
}
Some(SessionCommand::Export {
identifier,
@@ -912,7 +945,19 @@ pub async fn cli() -> anyhow::Result<()> {
.await?;
Ok(())
}
- Some(SessionCommand::Diagnostics { session_id, output }) => {
+ Some(SessionCommand::Diagnostics { identifier, output }) => {
+ let session_id = if let Some(id) = identifier {
+ lookup_session_id(id).await?
+ } else {
+ match crate::commands::session::prompt_interactive_session_selection().await
+ {
+ Ok(id) => id,
+ Err(e) => {
+ eprintln!("Error: {}", e);
+ return Ok(());
+ }
+ }
+ };
crate::commands::session::handle_diagnostics(&session_id, output).await?;
Ok(())
}
@@ -963,6 +1008,7 @@ pub async fn cli() -> anyhow::Result<()> {
sub_recipes: None,
final_output_response: None,
retry_config: None,
+ output_format: "text".to_string(),
})
.await;
@@ -977,7 +1023,7 @@ pub async fn cli() -> anyhow::Result<()> {
let exit_type = if result.is_ok() { "normal" } else { "error" };
let (total_tokens, message_count) = session
- .get_metadata()
+ .get_session()
.await
.map(|m| (m.total_tokens.unwrap_or(0), m.message_count))
.unwrap_or((0, 0));
@@ -1043,6 +1089,7 @@ pub async fn cli() -> anyhow::Result<()> {
scheduled_job_id,
quiet,
additional_sub_recipes,
+ output_format,
provider,
model,
}) => {
@@ -1172,6 +1219,7 @@ pub async fn cli() -> anyhow::Result<()> {
.as_ref()
.and_then(|r| r.final_output_response.clone()),
retry_config: recipe_info.as_ref().and_then(|r| r.retry_config.clone()),
+ output_format,
})
.await;
@@ -1198,7 +1246,7 @@ pub async fn cli() -> anyhow::Result<()> {
let exit_type = if result.is_ok() { "normal" } else { "error" };
let (total_tokens, message_count) = session
- .get_metadata()
+ .get_session()
.await
.map(|m| (m.total_tokens.unwrap_or(0), m.message_count))
.unwrap_or((0, 0));
@@ -1240,25 +1288,25 @@ pub async fn cli() -> anyhow::Result<()> {
Some(Command::Schedule { command }) => {
match command {
SchedulerCommand::Add {
- id,
+ schedule_id,
cron,
recipe_source,
} => {
- handle_schedule_add(id, cron, recipe_source).await?;
+ handle_schedule_add(schedule_id, cron, recipe_source).await?;
}
SchedulerCommand::List {} => {
handle_schedule_list().await?;
}
- SchedulerCommand::Remove { id } => {
- handle_schedule_remove(id).await?;
+ SchedulerCommand::Remove { schedule_id } => {
+ handle_schedule_remove(schedule_id).await?;
}
- SchedulerCommand::Sessions { id, limit } => {
+ SchedulerCommand::Sessions { schedule_id, limit } => {
// New arm
- handle_schedule_sessions(id, limit).await?;
+ handle_schedule_sessions(schedule_id, limit).await?;
}
- SchedulerCommand::RunNow { id } => {
+ SchedulerCommand::RunNow { schedule_id } => {
// New arm
- handle_schedule_run_now(id).await?;
+ handle_schedule_run_now(schedule_id).await?;
}
SchedulerCommand::ServicesStatus {} => {
handle_schedule_services_status().await?;
@@ -1355,6 +1403,7 @@ pub async fn cli() -> anyhow::Result<()> {
sub_recipes: None,
final_output_response: None,
retry_config: None,
+ output_format: "text".to_string(),
})
.await;
session.interactive(None).await?;
diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs
index 8ee9acfe09d4..b1bb76758246 100644
--- a/crates/goose-cli/src/commands/acp.rs
+++ b/crates/goose-cli/src/commands/acp.rs
@@ -3,12 +3,15 @@ use agent_client_protocol::{
ToolCallContent,
};
use anyhow::Result;
-use goose::agents::Agent;
+use goose::agents::{Agent, SessionConfig};
use goose::config::{get_all_extensions, Config};
use goose::conversation::message::{Message, MessageContent};
use goose::conversation::Conversation;
+use goose::mcp_utils::ToolResult;
use goose::providers::create;
-use rmcp::model::{RawContent, ResourceContents};
+use goose::session::session_manager::SessionType;
+use goose::session::SessionManager;
+use rmcp::model::{Content, RawContent, ResourceContents};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::sync::Arc;
@@ -19,20 +22,129 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use url::Url;
-/// Represents a single goose session for ACP
-struct GooseSession {
+struct GooseAcpSession {
messages: Conversation,
tool_call_ids: HashMap, // Maps internal tool IDs to ACP tool call IDs
+ tool_requests: HashMap, // Store tool requests by ID for location extraction
cancel_token: Option, // Active cancellation token for prompt processing
}
-/// goose ACP Agent implementation that connects to real goose agents
struct GooseAcpAgent {
- session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
- sessions: Arc>>,
+ session_update_tx: mpsc::UnboundedSender<(SessionNotification, oneshot::Sender<()>)>,
+ sessions: Arc>>,
agent: Agent, // Shared agent instance
}
+/// Create a ToolCallLocation with common defaults
+fn create_tool_location(path: &str, line: Option) -> acp::ToolCallLocation {
+ acp::ToolCallLocation {
+ path: path.into(),
+ line,
+ meta: None,
+ }
+}
+
+/// Extract file locations from tool request and response
+fn extract_tool_locations(
+ tool_request: &goose::conversation::message::ToolRequest,
+ tool_response: &goose::conversation::message::ToolResponse,
+) -> Vec {
+ let mut locations = Vec::new();
+
+ // Get the tool call details
+ if let Ok(tool_call) = &tool_request.tool_call {
+ // Only process text_editor tool
+ if tool_call.name != "developer__text_editor" {
+ return locations;
+ }
+
+ // Extract the path from arguments
+ let path_str = tool_call
+ .arguments
+ .as_ref()
+ .and_then(|args| args.get("path"))
+ .and_then(|p| p.as_str());
+
+ if let Some(path_str) = path_str {
+ // Get the command type
+ let command = tool_call
+ .arguments
+ .as_ref()
+ .and_then(|args| args.get("command"))
+ .and_then(|c| c.as_str());
+
+ // Extract line numbers from the response content
+ if let Ok(content_items) = &tool_response.tool_result {
+ for content in content_items {
+ if let RawContent::Text(text_content) = &content.raw {
+ let text = &text_content.text;
+
+ // Parse line numbers based on command type and response format
+ match command {
+ Some("view") => {
+ // For view command, look for "lines X-Y" pattern in header
+ let line = extract_view_line_range(text)
+ .map(|range| range.0 as u32)
+ .or(Some(1));
+ locations.push(create_tool_location(path_str, line));
+ }
+ Some("str_replace") | Some("insert") => {
+ // For edits, extract the first line number from the snippet
+ let line = extract_first_line_number(text)
+ .map(|l| l as u32)
+ .or(Some(1));
+ locations.push(create_tool_location(path_str, line));
+ }
+ Some("write") => {
+ // For write, just point to the beginning of the file
+ locations.push(create_tool_location(path_str, Some(1)));
+ }
+ _ => {
+ // For other commands or unknown, default to line 1
+ locations.push(create_tool_location(path_str, Some(1)));
+ }
+ }
+ break; // Only process first text content
+ }
+ }
+ }
+
+ // If we didn't find any locations yet, add a default one
+ if locations.is_empty() {
+ locations.push(create_tool_location(path_str, Some(1)));
+ }
+ }
+ }
+
+ locations
+}
+
+/// Extract line range from view command output (e.g., "### path/to/file.rs (lines 10-20)")
+fn extract_view_line_range(text: &str) -> Option<(usize, usize)> {
+ // Look for pattern like "(lines X-Y)" or "(lines X-end)"
+ let re = regex::Regex::new(r"\(lines (\d+)-(\d+|end)\)").ok()?;
+ if let Some(caps) = re.captures(text) {
+ let start = caps.get(1)?.as_str().parse::().ok()?;
+ let end = if caps.get(2)?.as_str() == "end" {
+ start // Use start as a reasonable default
+ } else {
+ caps.get(2)?.as_str().parse::().ok()?
+ };
+ return Some((start, end));
+ }
+ None
+}
+
+/// Extract the first line number from code snippet (e.g., "123: some code")
+fn extract_first_line_number(text: &str) -> Option {
+ // Look for pattern like "123: " at the start of a line within a code block
+ let re = regex::Regex::new(r"```[^\n]*\n(\d+):").ok()?;
+ if let Some(caps) = re.captures(text) {
+ return caps.get(1)?.as_str().parse::().ok();
+ }
+ None
+}
+
fn read_resource_link(link: acp::ResourceLink) -> Option {
let url = Url::parse(&link.uri).ok()?;
if url.scheme() == "file" {
@@ -97,15 +209,14 @@ impl GooseAcpAgent {
async fn new(
session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
) -> Result {
- // Load config and create provider
let config = Config::global();
let provider_name: String = config
- .get_param("GOOSE_PROVIDER")
+ .get_goose_provider()
.map_err(|e| anyhow::anyhow!("No provider configured: {}", e))?;
let model_name: String = config
- .get_param("GOOSE_MODEL")
+ .get_goose_model()
.map_err(|e| anyhow::anyhow!("No model configured: {}", e))?;
let model_config = goose::model::ModelConfig {
@@ -217,7 +328,7 @@ impl GooseAcpAgent {
&self,
content_item: &MessageContent,
session_id: &acp::SessionId,
- session: &mut GooseSession,
+ session: &mut GooseAcpSession,
) -> Result<(), acp::Error> {
match content_item {
MessageContent::Text(text) => {
@@ -273,7 +384,7 @@ impl GooseAcpAgent {
&self,
tool_request: &goose::conversation::message::ToolRequest,
session_id: &acp::SessionId,
- session: &mut GooseSession,
+ session: &mut GooseAcpSession,
) -> Result<(), acp::Error> {
// Generate ACP tool call ID and track mapping
let acp_tool_id = format!("tool_{}", uuid::Uuid::new_v4());
@@ -281,36 +392,19 @@ impl GooseAcpAgent {
.tool_call_ids
.insert(tool_request.id.clone(), acp_tool_id.clone());
- // Extract tool name and parameters from the ToolCall if successful
- let (tool_name, locations) = match &tool_request.tool_call {
- Ok(tool_call) => {
- // Extract file locations from certain tools for client tracking
- let mut locs = Vec::new();
- if tool_call.name == "developer__text_editor" {
- // Try to extract the path from the arguments
- if let Some(path_str) = tool_call
- .arguments
- .as_ref()
- .and_then(|args_map| args_map.get("path"))
- .and_then(|p| p.as_str())
- {
- let path = std::path::PathBuf::from(path_str);
- if path.exists() && path.is_file() {
- locs.push(acp::ToolCallLocation {
- path: path_str.into(),
- line: Some(1),
- meta: None,
- });
- }
- }
- }
+ // Store the tool request for later use in response handling
+ session
+ .tool_requests
+ .insert(tool_request.id.clone(), tool_request.clone());
- (tool_call.name.to_string(), locs)
- }
- Err(_) => ("error".to_string(), vec![]),
+ // Extract tool name from the ToolCall if successful
+ let tool_name = match &tool_request.tool_call {
+ Ok(tool_call) => tool_call.name.to_string(),
+ Err(_) => "error".to_string(),
};
- // Send tool call notification
+ // Send tool call notification with empty locations initially
+ // We'll update with real locations when we get the response
let (tx, rx) = oneshot::channel();
self.session_update_tx
.send((
@@ -322,7 +416,7 @@ impl GooseAcpAgent {
kind: acp::ToolKind::default(),
status: acp::ToolCallStatus::Pending,
content: Vec::new(),
- locations,
+ locations: Vec::new(), // Will be populated in handle_tool_response
raw_input: None,
raw_output: None,
meta: None,
@@ -341,7 +435,7 @@ impl GooseAcpAgent {
&self,
tool_response: &goose::conversation::message::ToolResponse,
session_id: &acp::SessionId,
- session: &mut GooseSession,
+ session: &mut GooseAcpSession,
) -> Result<(), acp::Error> {
// Look up the ACP tool call ID
if let Some(acp_tool_id) = session.tool_call_ids.get(&tool_response.id) {
@@ -352,74 +446,17 @@ impl GooseAcpAgent {
acp::ToolCallStatus::Failed
};
- let content: Vec = match &tool_response.tool_result {
- Ok(content_items) => content_items
- .iter()
- .filter_map(|content| match &content.raw {
- RawContent::Text(val) => Some(ToolCallContent::Content {
- content: acp::ContentBlock::Text(TextContent {
- annotations: None,
- text: val.text.clone(),
- meta: None,
- }),
- }),
- RawContent::Image(val) => Some(ToolCallContent::Content {
- content: acp::ContentBlock::Image(ImageContent {
- annotations: None,
- data: val.data.clone(),
- mime_type: val.mime_type.clone(),
- uri: None,
- meta: None,
- }),
- }),
- RawContent::Resource(val) => Some(ToolCallContent::Content {
- content: acp::ContentBlock::Resource(EmbeddedResource {
- annotations: None,
- resource: match &val.resource {
- ResourceContents::TextResourceContents {
- mime_type,
- text,
- uri,
- ..
- } => acp::EmbeddedResourceResource::TextResourceContents(
- acp::TextResourceContents {
- mime_type: mime_type.clone(),
- text: text.clone(),
- uri: uri.clone(),
- meta: None,
- },
- ),
- ResourceContents::BlobResourceContents {
- mime_type,
- blob,
- uri,
- ..
- } => acp::EmbeddedResourceResource::BlobResourceContents(
- acp::BlobResourceContents {
- mime_type: mime_type.clone(),
- blob: blob.clone(),
- uri: uri.clone(),
- meta: None,
- },
- ),
- },
- meta: None,
- }),
- }),
- RawContent::Audio(_) => {
- // Audio content is not supported in ACP ContentBlock, skip it
- None
- }
- RawContent::ResourceLink(_) => {
- // ResourceLink content is not supported in ACP ContentBlock, skip it
- None
- }
- })
- .collect(),
- Err(_) => Vec::new(),
+ let content = build_tool_call_content(&tool_response.tool_result);
+
+ // Extract locations from the tool request and response
+ let locations = if let Some(tool_request) = session.tool_requests.get(&tool_response.id)
+ {
+ extract_tool_locations(tool_request, tool_response)
+ } else {
+ Vec::new()
};
- // Send status update (completed or failed)
+ // Send status update (completed or failed) with locations
let (tx, rx) = oneshot::channel();
self.session_update_tx
.send((
@@ -430,6 +467,11 @@ impl GooseAcpAgent {
fields: acp::ToolCallUpdateFields {
status: Some(status),
content: Some(content),
+ locations: if locations.is_empty() {
+ None
+ } else {
+ Some(locations)
+ },
..Default::default()
},
meta: None,
@@ -446,6 +488,76 @@ impl GooseAcpAgent {
}
}
+/// Build tool call content from tool result
+fn build_tool_call_content(tool_result: &ToolResult>) -> Vec {
+ match tool_result {
+ Ok(content_items) => content_items
+ .iter()
+ .filter_map(|content| match &content.raw {
+ RawContent::Text(val) => Some(ToolCallContent::Content {
+ content: acp::ContentBlock::Text(TextContent {
+ annotations: None,
+ text: val.text.clone(),
+ meta: None,
+ }),
+ }),
+ RawContent::Image(val) => Some(ToolCallContent::Content {
+ content: acp::ContentBlock::Image(ImageContent {
+ annotations: None,
+ data: val.data.clone(),
+ mime_type: val.mime_type.clone(),
+ uri: None,
+ meta: None,
+ }),
+ }),
+ RawContent::Resource(val) => Some(ToolCallContent::Content {
+ content: acp::ContentBlock::Resource(EmbeddedResource {
+ annotations: None,
+ resource: match &val.resource {
+ ResourceContents::TextResourceContents {
+ mime_type,
+ text,
+ uri,
+ ..
+ } => acp::EmbeddedResourceResource::TextResourceContents(
+ acp::TextResourceContents {
+ mime_type: mime_type.clone(),
+ text: text.clone(),
+ uri: uri.clone(),
+ meta: None,
+ },
+ ),
+ ResourceContents::BlobResourceContents {
+ mime_type,
+ blob,
+ uri,
+ ..
+ } => acp::EmbeddedResourceResource::BlobResourceContents(
+ acp::BlobResourceContents {
+ mime_type: mime_type.clone(),
+ blob: blob.clone(),
+ uri: uri.clone(),
+ meta: None,
+ },
+ ),
+ },
+ meta: None,
+ }),
+ }),
+ RawContent::Audio(_) => {
+ // Audio content is not supported in ACP ContentBlock, skip it
+ None
+ }
+ RawContent::ResourceLink(_) => {
+ // ResourceLink content is not supported in ACP ContentBlock, skip it
+ None
+ }
+ })
+ .collect(),
+ Err(_) => Vec::new(),
+ }
+}
+
#[async_trait::async_trait(?Send)]
impl acp::Agent for GooseAcpAgent {
async fn initialize(
@@ -496,9 +608,10 @@ impl acp::Agent for GooseAcpAgent {
// Generate a unique session ID
let session_id = uuid::Uuid::new_v4().to_string();
- let session = GooseSession {
+ let session = GooseAcpSession {
messages: Conversation::new_unvalidated(Vec::new()),
tool_call_ids: HashMap::new(),
+ tool_requests: HashMap::new(),
cancel_token: None,
};
@@ -544,30 +657,35 @@ impl acp::Agent for GooseAcpAgent {
// Create and store cancellation token for this prompt
let cancel_token = CancellationToken::new();
- // Convert ACP prompt to Goose message
- let user_message = self.convert_acp_prompt_to_message(args.prompt);
-
- // Prepare for agent reply
- let messages = {
+ // Store the cancel token in the session BEFORE starting the prompt
+ {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(&session_id)
.ok_or_else(acp::Error::invalid_params)?;
+ session.cancel_token = Some(cancel_token.clone());
+ }
- // Add message to conversation
- session.messages.push(user_message);
+ let user_message = self.convert_acp_prompt_to_message(args.prompt);
- // Store cancellation token
- session.cancel_token = Some(cancel_token.clone());
+ let session = SessionManager::create_session(
+ std::env::current_dir().unwrap_or_default(),
+ "ACP Session".to_string(),
+ SessionType::Hidden,
+ )
+ .await?;
- // Clone what we need for the reply call
- session.messages.clone()
+ let session_config = SessionConfig {
+ id: session.id.clone(),
+ schedule_id: None,
+ max_turns: None,
+ retry_config: None,
};
// Get agent's reply through the Goose agent
let mut stream = self
.agent
- .reply(messages, None, Some(cancel_token.clone()))
+ .reply(user_message, session_config, Some(cancel_token.clone()))
.await
.map_err(|e| {
error!("Error getting agent reply: {}", e);
diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs
index d67b91913151..c0005fa5400c 100644
--- a/crates/goose-cli/src/commands/bench.rs
+++ b/crates/goose-cli/src/commands/bench.rs
@@ -26,9 +26,7 @@ impl BenchBaseSession for CliSession {
}
fn get_session_id(&self) -> anyhow::Result {
- self.session_id()
- .cloned()
- .ok_or_else(|| anyhow::anyhow!("No session ID available"))
+ Ok(self.session_id().to_string())
}
}
pub async fn agent_generator(
@@ -57,6 +55,7 @@ pub async fn agent_generator(
sub_recipes: None,
final_output_response: None,
retry_config: None,
+ output_format: "text".to_string(),
})
.await;
diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs
index d4d4f8f07729..d0d670f97bd1 100644
--- a/crates/goose-cli/src/commands/configure.rs
+++ b/crates/goose-cli/src/commands/configure.rs
@@ -10,10 +10,12 @@ use goose::config::extensions::{
get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name,
name_to_key, remove_extension, set_extension, set_extension_enabled,
};
+use goose::config::paths::Paths;
use goose::config::permission::PermissionLevel;
use goose::config::signup_tetrate::TetrateAuth;
use goose::config::{
- configure_tetrate, Config, ConfigError, ExperimentManager, ExtensionEntry, PermissionManager,
+ configure_tetrate, Config, ConfigError, ExperimentManager, ExtensionEntry, GooseMode,
+ PermissionManager,
};
use goose::conversation::message::Message;
use goose::model::ModelConfig;
@@ -195,15 +197,17 @@ pub async fn handle_configure() -> anyhow::Result<()> {
}
Ok(())
} else {
+ let config_dir = Paths::config_dir().display().to_string();
+
println!();
println!(
"{}",
- style("This will update your existing config file").dim()
+ style("This will update your existing config files").dim()
);
println!(
"{} {}",
- style(" if you prefer, you can edit it directly at").dim(),
- config.path()
+ style(" if you prefer, you can edit them directly at").dim(),
+ config_dir
);
println!();
@@ -418,7 +422,7 @@ fn select_model_from_list(
}
fn try_store_secret(config: &Config, key_name: &str, value: String) -> anyhow::Result {
- match config.set_secret(key_name, Value::String(value)) {
+ match config.set_secret(key_name, &value) {
Ok(_) => Ok(true),
Err(e) => {
cliclack::outro(style(format!(
@@ -447,7 +451,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result {
.collect();
// Get current default provider if it exists
- let current_provider: Option = config.get_param("GOOSE_PROVIDER").ok();
+ let current_provider: Option = config.get_goose_provider().ok();
let default_provider = current_provider.unwrap_or_default();
// Select provider
@@ -484,9 +488,9 @@ pub async fn configure_provider_dialog() -> anyhow::Result {
return Ok(false);
}
} else {
- config.set_param(&key.name, Value::String(env_value))?;
+ config.set_param(&key.name, &env_value)?;
}
- let _ = cliclack::log::info(format!("Saved {} to config file", key.name));
+ let _ = cliclack::log::info(format!("Saved {} to {}", key.name, config.path()));
}
}
None => {
@@ -526,7 +530,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result {
return Ok(false);
}
} else {
- config.set_param(&key.name, Value::String(value))?;
+ config.set_param(&key.name, &value)?;
}
}
}
@@ -555,9 +559,9 @@ pub async fn configure_provider_dialog() -> anyhow::Result {
};
if key.secret {
- config.set_secret(&key.name, Value::String(value))?;
+ config.set_secret(&key.name, &value)?;
} else {
- config.set_param(&key.name, Value::String(value))?;
+ config.set_param(&key.name, &value)?;
}
}
}
@@ -645,10 +649,9 @@ pub async fn configure_provider_dialog() -> anyhow::Result {
match result {
Ok((_message, _usage)) => {
- // Update config with new values only if the test succeeds
- config.set_param("GOOSE_PROVIDER", Value::String(provider_name.to_string()))?;
- config.set_param("GOOSE_MODEL", Value::String(model.clone()))?;
- cliclack::outro("Configuration saved successfully")?;
+ config.set_goose_provider(provider_name)?;
+ config.set_goose_model(&model)?;
+ print_config_file_saved()?;
Ok(true)
}
Err(e) => {
@@ -709,7 +712,11 @@ pub fn toggle_extensions_dialog() -> anyhow::Result<()> {
);
}
- cliclack::outro("Extension settings updated successfully")?;
+ let config = Config::global();
+ cliclack::outro(format!(
+ "Extension settings saved successfully to {}",
+ config.path()
+ ))?;
Ok(())
}
@@ -756,7 +763,6 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
"Developer Tools",
"Code editing and shell access",
),
- ("jetbrains", "JetBrains", "Connect to jetbrains IDEs"),
(
"memory",
"Memory",
@@ -870,7 +876,7 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
// Try to store in keychain
let keychain_key = key.to_string();
- match config.set_secret(&keychain_key, Value::String(value.clone())) {
+ match config.set_secret(&keychain_key, &value) {
Ok(_) => {
// Successfully stored in keychain, add to env_keys
env_keys.push(keychain_key);
@@ -966,7 +972,7 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
// Try to store in keychain
let keychain_key = key.to_string();
- match config.set_secret(&keychain_key, Value::String(value.clone())) {
+ match config.set_secret(&keychain_key, &value) {
Ok(_) => {
// Successfully stored in keychain, add to env_keys
env_keys.push(keychain_key);
@@ -1086,7 +1092,7 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
// Try to store in keychain
let keychain_key = key.to_string();
- match config.set_secret(&keychain_key, Value::String(value.clone())) {
+ match config.set_secret(&keychain_key, &Value::String(value.clone())) {
Ok(_) => {
// Successfully stored in keychain, add to env_keys
env_keys.push(keychain_key);
@@ -1123,6 +1129,8 @@ pub fn configure_extensions_dialog() -> anyhow::Result<()> {
_ => unreachable!(),
};
+ print_config_file_saved()?;
+
Ok(())
}
@@ -1178,6 +1186,8 @@ pub fn remove_extension_dialog() -> anyhow::Result<()> {
cliclack::outro(format!("Removed {} extension", style(name).green()))?;
}
+ print_config_file_saved()?;
+
Ok(())
}
@@ -1216,6 +1226,8 @@ pub async fn configure_settings_dialog() -> anyhow::Result<()> {
)
.interact()?;
+ let mut should_print_config_path = true;
+
match setting_type {
"goose_mode" => {
configure_goose_mode_dialog()?;
@@ -1225,6 +1237,8 @@ pub async fn configure_settings_dialog() -> anyhow::Result<()> {
}
"tool_permission" => {
configure_tool_permissions_dialog().await.and(Ok(()))?;
+ // No need to print config file path since it's already handled.
+ should_print_config_path = false;
}
"tool_output" => {
configure_tool_output_dialog()?;
@@ -1241,6 +1255,10 @@ pub async fn configure_settings_dialog() -> anyhow::Result<()> {
_ => unreachable!(),
};
+ if should_print_config_path {
+ print_config_file_saved()?;
+ }
+
Ok(())
}
@@ -1254,46 +1272,35 @@ pub fn configure_goose_mode_dialog() -> anyhow::Result<()> {
let mode = cliclack::select("Which goose mode would you like to configure?")
.item(
- "auto",
+ GooseMode::Auto,
"Auto Mode",
"Full file modification, extension usage, edit, create and delete files freely"
)
.item(
- "approve",
+ GooseMode::Approve,
"Approve Mode",
"All tools, extensions and file modifications will require human approval"
)
.item(
- "smart_approve",
+ GooseMode::SmartApprove,
"Smart Approve Mode",
"Editing, creating, deleting files and using extensions will require human approval"
)
.item(
- "chat",
+ GooseMode::Chat,
"Chat Mode",
"Engage with the selected provider without using tools, extensions, or file modification"
)
.interact()?;
- match mode {
- "auto" => {
- config.set_param("GOOSE_MODE", Value::String("auto".to_string()))?;
- cliclack::outro("Set to Auto Mode - full file modification enabled")?;
- }
- "approve" => {
- config.set_param("GOOSE_MODE", Value::String("approve".to_string()))?;
- cliclack::outro("Set to Approve Mode - all tools and modifications require approval")?;
- }
- "smart_approve" => {
- config.set_param("GOOSE_MODE", Value::String("smart_approve".to_string()))?;
- cliclack::outro("Set to Smart Approve Mode - modifications require approval")?;
- }
- "chat" => {
- config.set_param("GOOSE_MODE", Value::String("chat".to_string()))?;
- cliclack::outro("Set to Chat Mode - no tools or modifications enabled")?;
- }
- _ => unreachable!(),
+ config.set_goose_mode(mode)?;
+ let msg = match mode {
+ GooseMode::Auto => "Set to Auto Mode - full file modification enabled",
+ GooseMode::Approve => "Set to Approve Mode - all tools and modifications require approval",
+ GooseMode::SmartApprove => "Set to Smart Approve Mode - modifications require approval",
+ GooseMode::Chat => "Set to Chat Mode - no tools or modifications enabled",
};
+ cliclack::outro(msg)?;
Ok(())
}
@@ -1302,28 +1309,25 @@ pub fn configure_goose_router_strategy_dialog() -> anyhow::Result<()> {
let enable_router = cliclack::select("Would you like to enable smart tool routing?")
.item(
- "true",
+ true,
"Enable Router",
"Use LLM-based intelligence to select tools",
)
.item(
- "false",
+ false,
"Disable Router",
"Use the default tool selection strategy",
)
.interact()?;
- match enable_router {
- "true" => {
- config.set_param("GOOSE_ENABLE_ROUTER", Value::String("true".to_string()))?;
- cliclack::outro("Router enabled - using LLM-based intelligence for tool selection")?;
- }
- "false" => {
- config.set_param("GOOSE_ENABLE_ROUTER", Value::String("false".to_string()))?;
- cliclack::outro("Router disabled - using default tool selection")?;
- }
- _ => unreachable!(),
+ config.set_param("GOOSE_ENABLE_ROUTER", enable_router)?;
+ let msg = if enable_router {
+ "Router enabled - using LLM-based intelligence for tool selection"
+ } else {
+ "Router disabled - using default tool selection"
};
+ cliclack::outro(msg)?;
+
Ok(())
}
@@ -1341,15 +1345,15 @@ pub fn configure_tool_output_dialog() -> anyhow::Result<()> {
match tool_log_level {
"high" => {
- config.set_param("GOOSE_CLI_MIN_PRIORITY", Value::from(0.8))?;
+ config.set_param("GOOSE_CLI_MIN_PRIORITY", 0.8)?;
cliclack::outro("Showing tool output of high importance only.")?;
}
"medium" => {
- config.set_param("GOOSE_CLI_MIN_PRIORITY", Value::from(0.2))?;
+ config.set_param("GOOSE_CLI_MIN_PRIORITY", 0.2)?;
cliclack::outro("Showing tool output of medium importance.")?;
}
"all" => {
- config.set_param("GOOSE_CLI_MIN_PRIORITY", Value::from(0.0))?;
+ config.set_param("GOOSE_CLI_MIN_PRIORITY", 0.0)?;
cliclack::outro("Showing all tool output.")?;
}
_ => unreachable!(),
@@ -1422,11 +1426,11 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> {
let config = Config::global();
let provider_name: String = config
- .get_param("GOOSE_PROVIDER")
+ .get_goose_provider()
.expect("No provider configured. Please set model provider first");
let model: String = config
- .get_param("GOOSE_MODEL")
+ .get_goose_model()
.expect("No model configured. Please set model first");
let model_config = ModelConfig::new(&model)?;
@@ -1547,6 +1551,11 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> {
tool.name, permission_label
))?;
+ cliclack::outro(format!(
+ "Changes saved to {}",
+ permission_manager.get_config_path().display()
+ ))?;
+
Ok(())
}
@@ -1567,7 +1576,7 @@ fn configure_recipe_dialog() -> anyhow::Result<()> {
if input_value.clone().trim().is_empty() {
config.delete(key_name)?;
} else {
- config.set_param(key_name, Value::String(input_value))?;
+ config.set_param(key_name, &input_value)?;
}
Ok(())
}
@@ -1594,7 +1603,7 @@ pub fn configure_max_turns_dialog() -> anyhow::Result<()> {
.interact()?;
let max_turns: u32 = max_turns_input.parse()?;
- config.set_param("GOOSE_MAX_TURNS", Value::from(max_turns))?;
+ config.set_param("GOOSE_MAX_TURNS", max_turns)?;
cliclack::outro(format!(
"Set maximum turns to {} - goose will ask for input after {} consecutive actions",
@@ -1627,7 +1636,7 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> {
// Test configuration - get the model that was configured
println!("\nTesting configuration...");
- let configured_model: String = config.get_param("GOOSE_MODEL")?;
+ let configured_model: String = config.get_goose_model()?;
let model_config = match goose::model::ModelConfig::new(&configured_model) {
Ok(config) => config,
Err(e) => {
@@ -1705,7 +1714,7 @@ pub async fn handle_tetrate_auth() -> anyhow::Result<()> {
// Test configuration
println!("\nTesting configuration...");
- let configured_model: String = config.get_param("GOOSE_MODEL")?;
+ let configured_model: String = config.get_goose_model()?;
let model_config = match goose::model::ModelConfig::new(&configured_model) {
Ok(config) => config,
Err(e) => {
@@ -1893,5 +1902,18 @@ pub fn configure_custom_provider_dialog() -> anyhow::Result<()> {
"add" => add_provider(),
"remove" => remove_provider(),
_ => unreachable!(),
- }
+ }?;
+
+ print_config_file_saved()?;
+
+ Ok(())
+}
+
+fn print_config_file_saved() -> anyhow::Result<()> {
+ let config = Config::global();
+ cliclack::outro(format!(
+ "Configuration saved successfully to {}",
+ config.path()
+ ))?;
+ Ok(())
}
diff --git a/crates/goose-cli/src/commands/info.rs b/crates/goose-cli/src/commands/info.rs
index baf972d76826..de3b83831464 100644
--- a/crates/goose-cli/src/commands/info.rs
+++ b/crates/goose-cli/src/commands/info.rs
@@ -11,15 +11,16 @@ fn print_aligned(label: &str, value: &str, width: usize) {
pub fn handle_info(verbose: bool) -> Result<()> {
let logs_dir = Paths::in_state_dir("logs");
let sessions_dir = Paths::in_data_dir("sessions");
+ let sessions_db = sessions_dir.join("sessions.db");
// Get paths using a stored reference to the global config
let config = Config::global();
- let config_file = config.path();
+ let config_dir = Paths::config_dir().display().to_string();
// Define the labels and their corresponding path values once.
let paths = [
- ("Config file:", config_file.to_string()),
- ("Sessions dir:", sessions_dir.display().to_string()),
+ ("Config dir:", config_dir),
+ ("Sessions DB (sqlite):", sessions_db.display().to_string()),
("Logs dir:", logs_dir.display().to_string()),
];
@@ -40,26 +41,22 @@ pub fn handle_info(verbose: bool) -> Result<()> {
// Print verbose info if requested
if verbose {
println!("\n{}", style("goose Configuration:").cyan().bold());
- match config.load_values() {
- Ok(values) => {
- if values.is_empty() {
- println!(" No configuration values set");
- println!(
- " Run '{}' to configure goose",
- style("goose configure").cyan()
- );
- } else {
- let sorted_values: std::collections::BTreeMap<_, _> =
- values.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
+ let values = config.all_values()?;
+ if values.is_empty() {
+ println!(" No configuration values set");
+ println!(
+ " Run '{}' to configure goose",
+ style("goose configure").cyan()
+ );
+ } else {
+ let sorted_values: std::collections::BTreeMap<_, _> =
+ values.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
- if let Ok(yaml) = serde_yaml::to_string(&sorted_values) {
- for line in yaml.lines() {
- println!(" {}", line);
- }
- }
+ if let Ok(yaml) = serde_yaml::to_string(&sorted_values) {
+ for line in yaml.lines() {
+ println!(" {}", line);
}
}
- Err(e) => println!(" Error loading configuration: {}", e),
}
}
diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs
index 2756fa2e44c0..f8773f8da7ee 100644
--- a/crates/goose-cli/src/commands/schedule.rs
+++ b/crates/goose-cli/src/commands/schedule.rs
@@ -75,13 +75,13 @@ fn validate_cron_expression(cron: &str) -> Result<()> {
}
pub async fn handle_schedule_add(
- id: String,
+ schedule_id: String,
cron: String,
recipe_source_arg: String, // This is expected to be a file path by the Scheduler
) -> Result<()> {
println!(
"[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}",
- id, cron, recipe_source_arg
+ schedule_id, cron, recipe_source_arg
);
// Validate cron expression and provide helpful feedback
@@ -90,7 +90,7 @@ pub async fn handle_schedule_add(
// The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg
// to its internal storage and validating the path.
let job = ScheduledJob {
- id: id.clone(),
+ id: schedule_id.clone(),
source: recipe_source_arg.clone(), // Pass the original user-provided path
cron,
last_run: None,
@@ -98,7 +98,6 @@ pub async fn handle_schedule_add(
paused: false,
current_session_id: None,
process_start_time: None,
- execution_mode: Some("background".to_string()), // Default to background for CLI
};
let scheduler_storage_path =
@@ -117,11 +116,12 @@ pub async fn handle_schedule_add(
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("yaml");
- let final_recipe_path = scheduled_recipes_dir.join(format!("{}.{}", id, extension));
+ let final_recipe_path =
+ scheduled_recipes_dir.join(format!("{}.{}", schedule_id, extension));
println!(
"Scheduled job '{}' added. Recipe expected at {:?}",
- id, final_recipe_path
+ schedule_id, final_recipe_path
);
Ok(())
}
@@ -139,7 +139,7 @@ pub async fn handle_schedule_add(
);
}
_ => Err(anyhow::Error::new(e))
- .context(format!("Failed to add job '{}' to scheduler", id)),
+ .context(format!("Failed to add job '{}' to scheduler", schedule_id)),
}
}
}
@@ -180,41 +180,46 @@ pub async fn handle_schedule_list() -> Result<()> {
Ok(())
}
-pub async fn handle_schedule_remove(id: String) -> Result<()> {
+pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = SchedulerFactory::create(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
- match scheduler.remove_scheduled_job(&id).await {
+ match scheduler.remove_scheduled_job(&schedule_id).await {
Ok(_) => {
- println!("Scheduled job '{}' and its associated recipe removed.", id);
+ println!(
+ "Scheduled job '{}' and its associated recipe removed.",
+ schedule_id
+ );
Ok(())
}
Err(e) => match e {
SchedulerError::JobNotFound(job_id) => {
bail!("Error: Job with ID '{}' not found.", job_id);
}
- _ => Err(anyhow::Error::new(e))
- .context(format!("Failed to remove job '{}' from scheduler", id)),
+ _ => Err(anyhow::Error::new(e)).context(format!(
+ "Failed to remove job '{}' from scheduler",
+ schedule_id
+ )),
},
}
}
-pub async fn handle_schedule_sessions(id: String, limit: Option) -> Result<()> {
+pub async fn handle_schedule_sessions(schedule_id: String, limit: Option) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = SchedulerFactory::create(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
- match scheduler.sessions(&id, limit.unwrap_or(50)).await {
+ match scheduler.sessions(&schedule_id, limit.unwrap_or(50)).await {
Ok(sessions) => {
if sessions.is_empty() {
- println!("No sessions found for schedule ID '{}'.", id);
+ println!("No sessions found for schedule ID '{}'.", schedule_id);
} else {
- println!("Sessions for schedule ID '{}':", id);
+ println!("Sessions for schedule ID '{}':", schedule_id);
// sessions is now Vec<(String, SessionMetadata)>
for (session_name, metadata) in sessions {
println!(
@@ -228,31 +233,35 @@ pub async fn handle_schedule_sessions(id: String, limit: Option) -> Resul
}
}
Err(e) => {
- bail!("Failed to get sessions for schedule '{}': {:?}", id, e);
+ bail!(
+ "Failed to get sessions for schedule '{}': {:?}",
+ schedule_id,
+ e
+ );
}
}
Ok(())
}
-pub async fn handle_schedule_run_now(id: String) -> Result<()> {
+pub async fn handle_schedule_run_now(schedule_id: String) -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = SchedulerFactory::create(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
- match scheduler.run_now(&id).await {
+ match scheduler.run_now(&schedule_id).await {
Ok(session_id) => {
println!(
"Successfully triggered schedule '{}'. New session ID: {}",
- id, session_id
+ schedule_id, session_id
);
}
Err(e) => match e {
SchedulerError::JobNotFound(job_id) => {
bail!("Error: Job with ID '{}' not found.", job_id);
}
- _ => bail!("Failed to run schedule '{}' now: {:?}", id, e),
+ _ => bail!("Failed to run schedule '{}' now: {:?}", schedule_id, e),
},
}
Ok(())
@@ -327,12 +336,12 @@ pub async fn handle_schedule_cron_help() -> Result<()> {
println!("💡 EXAMPLES:");
println!(
- " goose schedule add --id hourly-report --cron \"0 * * * *\" --recipe-source report.yaml"
+ " goose schedule add --schedule-id hourly-report --cron \"0 * * * *\" --recipe-source report.yaml"
);
println!(
- " goose schedule add --id daily-backup --cron \"@daily\" --recipe-source backup.yaml"
+ " goose schedule add --schedule-id daily-backup --cron \"@daily\" --recipe-source backup.yaml"
);
- println!(" goose schedule add --id weekly-summary --cron \"0 9 * * 1\" --recipe-source summary.yaml");
+ println!(" goose schedule add --schedule-id weekly-summary --cron \"0 9 * * 1\" --recipe-source summary.yaml");
Ok(())
}
diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs
index 40e1e1d689f3..a145f01ffd33 100644
--- a/crates/goose-cli/src/commands/session.rs
+++ b/crates/goose-cli/src/commands/session.rs
@@ -71,7 +71,11 @@ fn prompt_interactive_session_removal(sessions: &[Session]) -> Result, regex_string: Option) -> Result<()> {
+pub async fn handle_session_remove(
+ session_id: Option,
+ name: Option,
+ regex_string: Option,
+) -> Result<()> {
let all_sessions = match SessionManager::list_sessions().await {
Ok(sessions) => sessions,
Err(e) => {
@@ -82,11 +86,20 @@ pub async fn handle_session_remove(id: Option, regex_string: Option;
- if let Some(id_val) = id {
+ if let Some(id_val) = session_id {
if let Some(session) = all_sessions.iter().find(|s| s.id == id_val) {
matched_sessions = vec![session.clone()];
} else {
- return Err(anyhow::anyhow!("Session '{}' not found.", id_val));
+ return Err(anyhow::anyhow!("Session ID '{}' not found.", id_val));
+ }
+ } else if let Some(name_val) = name {
+ if let Some(session) = all_sessions.iter().find(|s| s.name == name_val) {
+ matched_sessions = vec![session.clone()];
+ } else {
+ return Err(anyhow::anyhow!(
+ "Session with name '{}' not found.",
+ name_val
+ ));
}
} else if let Some(regex_val) = regex_string {
let session_regex = Regex::new(®ex_val)
diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs
index 636b2590ebbb..b47ddcdf8d85 100644
--- a/crates/goose-cli/src/commands/web.rs
+++ b/crates/goose-cli/src/commands/web.rs
@@ -15,6 +15,7 @@ use base64::Engine;
use futures::{sink::SinkExt, stream::StreamExt};
use goose::agents::{Agent, AgentEvent};
use goose::conversation::message::Message as GooseMessage;
+use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -139,7 +140,7 @@ pub async fn handle_web(
let config = goose::config::Config::global();
- let provider_name: String = match config.get_param("GOOSE_PROVIDER") {
+ let provider_name: String = match config.get_goose_provider() {
Ok(p) => p,
Err(_) => {
eprintln!("No provider configured. Run 'goose configure' first");
@@ -147,7 +148,7 @@ pub async fn handle_web(
}
};
- let model: String = match config.get_param("GOOSE_MODEL") {
+ let model: String = match config.get_goose_model() {
Ok(m) => m,
Err(_) => {
eprintln!("No model configured. Run 'goose configure' first");
@@ -226,6 +227,7 @@ async fn serve_index() -> Result {
let session = SessionManager::create_session(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
"Web session".to_string(),
+ SessionType::User,
)
.await
.map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
@@ -467,27 +469,20 @@ async fn process_message_streaming(
let session = SessionManager::get_session(&session_id, true).await?;
let mut messages = session.conversation.unwrap_or_default();
- messages.push(user_message);
+ messages.push(user_message.clone());
let session_config = SessionConfig {
id: session.id.clone(),
- working_dir: session.working_dir,
schedule_id: None,
- execution_mode: None,
max_turns: None,
retry_config: None,
};
- match agent
- .reply(messages.clone(), Some(session_config), None)
- .await
- {
+ match agent.reply(user_message, session_config, None).await {
Ok(mut stream) => {
while let Some(result) = stream.next().await {
match result {
Ok(AgentEvent::Message(message)) => {
- SessionManager::add_message(&session_id, &message).await?;
-
for content in &message.content {
match content {
MessageContent::Text(text) => {
diff --git a/crates/goose-cli/src/logging.rs b/crates/goose-cli/src/logging.rs
index 9d3887246047..acd9d16d23ee 100644
--- a/crates/goose-cli/src/logging.rs
+++ b/crates/goose-cli/src/logging.rs
@@ -1,5 +1,4 @@
use anyhow::{Context, Result};
-use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Once;
use tokio::sync::Mutex;
@@ -16,12 +15,6 @@ use goose_bench::error_capture::ErrorCaptureLayer;
// Used to ensure we only set up tracing once
static INIT: Once = Once::new();
-/// Returns the directory where log files should be stored.
-/// Creates the directory structure if it doesn't exist.
-fn get_log_directory() -> Result {
- goose::logging::get_log_directory("cli", true)
-}
-
/// Sets up the logging infrastructure for the application.
/// This includes:
/// - File-based logging with JSON formatting (DEBUG level)
@@ -50,20 +43,15 @@ fn setup_logging_internal(
let mut setup = || {
result = (|| {
- // Set up file appender for goose module logs
- let log_dir = get_log_directory()?;
+ let log_dir = goose::logging::prepare_log_directory("cli", true)?;
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
-
- // Create log file name by prefixing with timestamp
let log_filename = if name.is_some() {
format!("{}-{}.log", timestamp, name.unwrap())
} else {
format!("{}.log", timestamp)
};
-
- // Create non-rolling file appender for detailed logs
let file_appender = tracing_appender::rolling::RollingFileAppender::new(
- Rotation::NEVER,
+ Rotation::NEVER, // we do manual rotation via file naming and cleanup_old_logs
log_dir,
log_filename,
);
@@ -102,7 +90,9 @@ fn setup_logging_internal(
}
if !force {
- if let Ok((otlp_tracing_layer, otlp_metrics_layer)) = otlp_layer::init_otlp() {
+ if let Ok((otlp_tracing_layer, otlp_metrics_layer, otlp_logs_layer)) =
+ otlp_layer::init_otlp()
+ {
layers.push(
otlp_tracing_layer
.with_filter(otlp_layer::create_otlp_tracing_filter())
@@ -113,6 +103,11 @@ fn setup_logging_internal(
.with_filter(otlp_layer::create_otlp_metrics_filter())
.boxed(),
);
+ layers.push(
+ otlp_logs_layer
+ .with_filter(otlp_layer::create_otlp_logs_filter())
+ .boxed(),
+ );
}
}
@@ -170,7 +165,7 @@ mod tests {
#[test]
fn test_log_directory_creation() {
let _temp_dir = setup_temp_home();
- let log_dir = get_log_directory().unwrap();
+ let log_dir = goose::logging::prepare_log_directory("cli", true).unwrap();
assert!(log_dir.exists());
assert!(log_dir.is_dir());
diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs
index 987b7d295b6e..ff722657289a 100644
--- a/crates/goose-cli/src/recipes/recipe.rs
+++ b/crates/goose-cli/src/recipes/recipe.rs
@@ -11,7 +11,6 @@ use goose::recipe::build_recipe::{
};
use goose::recipe::validate_recipe::parse_and_validate_parameters;
use goose::recipe::Recipe;
-use serde_json::Value;
fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result {
|key: &str, description: &str| -> Result {
@@ -98,7 +97,7 @@ pub fn collect_missing_secrets(requirements: &[SecretRequirement]) -> Result<()>
.unwrap_or_else(|_| String::new());
if !value.trim().is_empty() {
- config.set_secret(&req.key, Value::String(value))?;
+ config.set_secret(&req.key, &value)?;
println!("✅ Secret stored securely for {}", req.extension_name);
} else {
println!("⏭️ Skipped {} for {}", req.key, req.extension_name);
diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs
index 6a259d21e96f..8820ec409cf1 100644
--- a/crates/goose-cli/src/recipes/secret_discovery.rs
+++ b/crates/goose-cli/src/recipes/secret_discovery.rs
@@ -166,7 +166,6 @@ mod tests {
available_tools: Vec::new(),
},
]),
- context: None,
settings: None,
activities: None,
author: None,
@@ -210,7 +209,6 @@ mod tests {
instructions: Some("Test instructions".to_string()),
prompt: None,
extensions: None,
- context: None,
settings: None,
activities: None,
author: None,
@@ -255,7 +253,6 @@ mod tests {
available_tools: Vec::new(),
},
]),
- context: None,
settings: None,
activities: None,
author: None,
@@ -309,7 +306,6 @@ mod tests {
sequential_when_repeated: false,
description: None,
}]),
- context: None,
settings: None,
activities: None,
author: None,
diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs
index 00a90778d3ae..8d7474d4859c 100644
--- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs
+++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs
@@ -9,8 +9,10 @@ use anyhow::Result;
use goose::agents::Agent;
use goose::model::ModelConfig;
use goose::providers::{create, testprovider::TestProvider};
+use goose::session::session_manager::SessionType;
+use goose::session::SessionManager;
use std::collections::{HashMap, HashSet};
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@@ -190,7 +192,6 @@ where
)
};
- // Generate messages using the provider
let messages = vec![message_generator(&*provider_arc)];
let mock_client = weather_client();
@@ -218,11 +219,27 @@ where
.update_provider(provider_arc as Arc)
.await?;
- let mut session = CliSession::new(agent, None, false, None, None, None, None).await;
+ let session = SessionManager::create_session(
+ PathBuf::default(),
+ "scenario-runner".to_string(),
+ SessionType::Hidden,
+ )
+ .await?;
+ let mut cli_session = CliSession::new(
+ agent,
+ session.id,
+ false,
+ None,
+ None,
+ None,
+ None,
+ "text".to_string(),
+ )
+ .await;
let mut error = None;
for message in &messages {
- if let Err(e) = session
+ if let Err(e) = cli_session
.process_message(message.clone(), CancellationToken::default())
.await
{
@@ -230,7 +247,7 @@ where
break;
}
}
- let updated_messages = session.message_history();
+ let updated_messages = cli_session.message_history();
if let Some(ref err_msg) = error {
if err_msg.contains("No recorded response found") {
@@ -249,7 +266,7 @@ where
validator(&result)?;
- drop(session);
+ drop(cli_session);
if let Some(provider) = provider_for_saving {
if result.error.is_none() {
diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs
index 7e0f0fce7df1..6845e3f2e222 100644
--- a/crates/goose-cli/src/session/builder.rs
+++ b/crates/goose-cli/src/session/builder.rs
@@ -11,6 +11,7 @@ use goose::providers::create;
use goose::recipe::{Response, SubRecipe};
use goose::agents::extension::PlatformExtensionContext;
+use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use goose::session::{EnabledExtensionsState, ExtensionState};
use rustyline::EditMode;
@@ -23,9 +24,9 @@ use tokio::task::JoinSet;
///
/// This struct contains all the parameters needed to create a new session,
/// including session identification, extension configuration, and debug settings.
-#[derive(Default, Clone, Debug)]
+#[derive(Clone, Debug)]
pub struct SessionBuilderConfig {
- /// Optional session ID for resuming or identifying an existing session
+ /// Session id, optional need to deduce from context
pub session_id: Option,
/// Whether to resume an existing session
pub resume: bool,
@@ -67,6 +68,39 @@ pub struct SessionBuilderConfig {
pub final_output_response: Option,
/// Retry configuration for automated validation and recovery
pub retry_config: Option,
+ /// Output format (text, json)
+ pub output_format: String,
+}
+
+/// Manual implementation of Default to ensure proper initialization of output_format
+/// This struct requires explicit default value for output_format field
+impl Default for SessionBuilderConfig {
+ fn default() -> Self {
+ SessionBuilderConfig {
+ session_id: None,
+ resume: false,
+ no_session: false,
+ extensions: Vec::new(),
+ remote_extensions: Vec::new(),
+ streamable_http_extensions: Vec::new(),
+ builtins: Vec::new(),
+ extensions_override: None,
+ additional_system_prompt: None,
+ settings: None,
+ provider: None,
+ model: None,
+ debug: false,
+ max_tool_repetitions: None,
+ max_turns: None,
+ scheduled_job_id: None,
+ interactive: false,
+ quiet: false,
+ sub_recipes: None,
+ final_output_response: None,
+ retry_config: None,
+ output_format: "text".to_string(),
+ }
+ }
}
/// Offers to help debug an extension failure by creating a minimal debugging session
@@ -132,8 +166,23 @@ async fn offer_extension_debugging_help(
}
}
- // Create the debugging session
- let mut debug_session = CliSession::new(debug_agent, None, false, None, None, None, None).await;
+ let session = SessionManager::create_session(
+ std::env::current_dir()?,
+ "CLI Session".to_string(),
+ SessionType::Hidden,
+ )
+ .await?;
+ let mut debug_session = CliSession::new(
+ debug_agent,
+ session.id,
+ false,
+ None,
+ None,
+ None,
+ None,
+ "text".to_string(),
+ )
+ .await;
// Process the debugging request
println!("{}", style("Analyzing the extension failure...").yellow());
@@ -208,7 +257,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
.as_ref()
.and_then(|s| s.goose_provider.clone())
})
- .or_else(|| config.get_param("GOOSE_PROVIDER").ok())
+ .or_else(|| config.get_goose_provider().ok())
.expect("No provider configured. Run 'goose configure' first");
let model_name = session_config
@@ -219,7 +268,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
.as_ref()
.and_then(|s| s.goose_model.clone())
})
- .or_else(|| config.get_param("GOOSE_MODEL").ok())
+ .or_else(|| config.get_goose_model().ok())
.expect("No model configured. Run 'goose configure' first");
let temperature = session_config.settings.as_ref().and_then(|s| s.temperature);
@@ -234,13 +283,13 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
// Create the agent
let agent: Agent = Agent::new();
- if let Some(sub_recipes) = session_config.sub_recipes {
- agent.add_sub_recipes(sub_recipes).await;
- }
-
- if let Some(final_output_response) = session_config.final_output_response {
- agent.add_final_output_tool(final_output_response).await;
- }
+ agent
+ .apply_recipe_components(
+ session_config.sub_recipes,
+ session_config.final_output_response,
+ true,
+ )
+ .await;
let new_provider = match create(&provider_name, model_config).await {
Ok(provider) => provider,
@@ -278,12 +327,20 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
process::exit(1);
});
- let session_id: Option = if session_config.no_session {
- None
+ let session_id: String = if session_config.no_session {
+ let working_dir = std::env::current_dir().expect("Could not get working directory");
+ let session = SessionManager::create_session(
+ working_dir,
+ "CLI Session".to_string(),
+ SessionType::Hidden,
+ )
+ .await
+ .expect("Could not create session");
+ session.id
} else if session_config.resume {
if let Some(session_id) = session_config.session_id {
match SessionManager::get_session(&session_id, false).await {
- Ok(_) => Some(session_id),
+ Ok(_) => session_id,
Err(_) => {
output::render_error(&format!(
"Cannot resume session {} - no such session exists",
@@ -294,7 +351,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
}
} else {
match SessionManager::list_sessions().await {
- Ok(sessions) if !sessions.is_empty() => Some(sessions[0].id.clone()),
+ Ok(sessions) if !sessions.is_empty() => sessions[0].id.clone(),
_ => {
output::render_error("Cannot resume - no previous sessions found");
process::exit(1);
@@ -302,46 +359,44 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
}
}
} else {
- session_config.session_id
+ session_config.session_id.unwrap()
};
agent
.extension_manager
.set_context(PlatformExtensionContext {
- session_id: session_id.clone(),
+ session_id: Some(session_id.clone()),
extension_manager: Some(Arc::downgrade(&agent.extension_manager)),
tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)),
})
.await;
if session_config.resume {
- if let Some(session_id) = session_id.as_ref() {
- let metadata = SessionManager::get_session(session_id, false)
- .await
- .unwrap_or_else(|e| {
- output::render_error(&format!("Failed to read session metadata: {}", e));
- process::exit(1);
- });
+ let session = SessionManager::get_session(&session_id, false)
+ .await
+ .unwrap_or_else(|e| {
+ output::render_error(&format!("Failed to read session metadata: {}", e));
+ process::exit(1);
+ });
- let current_workdir =
- std::env::current_dir().expect("Failed to get current working directory");
- if current_workdir != metadata.working_dir {
- let change_workdir = cliclack::confirm(format!("{} The original working directory of this session was set to {}. Your current directory is {}. Do you want to switch back to the original working directory?", style("WARNING:").yellow(), style(metadata.working_dir.display()).cyan(), style(current_workdir.display()).cyan()))
+ let current_workdir =
+ std::env::current_dir().expect("Failed to get current working directory");
+ if current_workdir != session.working_dir {
+ let change_workdir = cliclack::confirm(format!("{} The original working directory of this session was set to {}. Your current directory is {}. Do you want to switch back to the original working directory?", style("WARNING:").yellow(), style(session.working_dir.display()).cyan(), style(current_workdir.display()).cyan()))
.initial_value(true)
.interact().expect("Failed to get user input");
- if change_workdir {
- if !metadata.working_dir.exists() {
- output::render_error(&format!(
- "Cannot switch to original working directory - {} no longer exists",
- style(metadata.working_dir.display()).cyan()
- ));
- } else if let Err(e) = std::env::set_current_dir(&metadata.working_dir) {
- output::render_error(&format!(
- "Failed to switch to original working directory: {}",
- e
- ));
- }
+ if change_workdir {
+ if !session.working_dir.exists() {
+ output::render_error(&format!(
+ "Cannot switch to original working directory - {} no longer exists",
+ style(session.working_dir.display()).cyan()
+ ));
+ } else if let Err(e) = std::env::set_current_dir(&session.working_dir) {
+ output::render_error(&format!(
+ "Failed to switch to original working directory: {}",
+ e
+ ));
}
}
}
@@ -354,22 +409,18 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
agent.disable_router_for_recipe().await;
extensions.into_iter().collect()
} else if session_config.resume {
- if let Some(session_id) = session_id.as_ref() {
- match SessionManager::get_session(session_id, false).await {
- Ok(session_data) => {
- if let Some(saved_state) =
- EnabledExtensionsState::from_extension_data(&session_data.extension_data)
- {
- check_missing_extensions_or_exit(&saved_state.extensions);
- saved_state.extensions
- } else {
- get_enabled_extensions()
- }
+ match SessionManager::get_session(&session_id, false).await {
+ Ok(session_data) => {
+ if let Some(saved_state) =
+ EnabledExtensionsState::from_extension_data(&session_data.extension_data)
+ {
+ check_missing_extensions_or_exit(&saved_state.extensions);
+ saved_state.extensions
+ } else {
+ get_enabled_extensions()
}
- _ => get_enabled_extensions(),
}
- } else {
- get_enabled_extensions()
+ _ => get_enabled_extensions(),
}
} else {
get_enabled_extensions()
@@ -450,6 +501,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
session_config.max_turns,
edit_mode,
session_config.retry_config.clone(),
+ session_config.output_format.clone(),
)
.await;
@@ -560,23 +612,19 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
}
}
- if let Some(session_id) = session_id.as_ref() {
- let session_config_for_save = SessionConfig {
- id: session_id.clone(),
- working_dir: std::env::current_dir().unwrap_or_default(),
- schedule_id: None,
- execution_mode: None,
- max_turns: None,
- retry_config: None,
- };
+ let session_config_for_save = SessionConfig {
+ id: session_id.clone(),
+ schedule_id: None,
+ max_turns: None,
+ retry_config: None,
+ };
- if let Err(e) = session
- .agent
- .save_extension_state(&session_config_for_save)
- .await
- {
- tracing::warn!("Failed to save initial extension state: {}", e);
- }
+ if let Err(e) = session
+ .agent
+ .save_extension_state(&session_config_for_save)
+ .await
+ {
+ tracing::warn!("Failed to save initial extension state: {}", e);
}
// Add CLI-specific system prompt extension
@@ -603,7 +651,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
session_config.resume,
&provider_name,
&model_name,
- &session_id,
+ &Some(session_id),
Some(&provider_for_display),
);
}
@@ -638,6 +686,7 @@ mod tests {
sub_recipes: None,
final_output_response: None,
retry_config: None,
+ output_format: "text".to_string(),
};
assert_eq!(config.extensions.len(), 1);
diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs
index 67021047458c..6c32529a9aba 100644
--- a/crates/goose-cli/src/session/completion.rs
+++ b/crates/goose-cli/src/session/completion.rs
@@ -26,7 +26,7 @@ impl GooseCompleter {
/// Complete prompt names for the /prompt command
fn complete_prompt_names(&self, line: &str) -> Result<(usize, Vec)> {
// Get the prefix of the prompt name being typed
- let prefix = if line.len() > 8 { &line[8..] } else { "" };
+ let prefix = line.get(8..).unwrap_or("");
// Get available prompts from cache
let cache = self.completion_cache.read().unwrap();
@@ -156,7 +156,7 @@ impl GooseCompleter {
/// Complete argument keys for a specific prompt
fn complete_argument_keys(&self, line: &str) -> Result<(usize, Vec)> {
- let parts: Vec<&str> = line[8..].split_whitespace().collect();
+ let parts: Vec<&str> = line.get(8..).unwrap_or("").split_whitespace().collect();
// We need at least the prompt name
if parts.is_empty() {
diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs
index 987415d1251f..34d385291f26 100644
--- a/crates/goose-cli/src/session/input.rs
+++ b/crates/goose-cli/src/session/input.rs
@@ -20,7 +20,7 @@ pub enum InputResult {
EndPlan,
Clear,
Recipe(Option),
- Summarize,
+ Compact,
}
#[derive(Debug)]
@@ -74,6 +74,7 @@ pub fn get_input(
Ok(text) => text,
Err(e) => match e {
rustyline::error::ReadlineError::Interrupted => return Ok(InputResult::Exit),
+ rustyline::error::ReadlineError::Eof => return Ok(InputResult::Exit),
_ => return Err(e.into()),
},
};
@@ -120,7 +121,8 @@ fn handle_slash_command(input: &str) -> Option {
const CMD_ENDPLAN: &str = "/endplan";
const CMD_CLEAR: &str = "/clear";
const CMD_RECIPE: &str = "/recipe";
- const CMD_SUMMARIZE: &str = "/summarize";
+ const CMD_COMPACT: &str = "/compact";
+ const CMD_SUMMARIZE_DEPRECATED: &str = "/summarize";
match input {
"/exit" | "/quit" => Some(InputResult::Exit),
@@ -168,19 +170,25 @@ fn handle_slash_command(input: &str) -> Option {
}
}
s if s.starts_with(CMD_EXTENSION) => Some(InputResult::AddExtension(
- s[CMD_EXTENSION.len()..].to_string(),
+ s.get(CMD_EXTENSION.len()..).unwrap_or("").to_string(),
)),
- s if s.starts_with(CMD_BUILTIN) => {
- Some(InputResult::AddBuiltin(s[CMD_BUILTIN.len()..].to_string()))
- }
- s if s.starts_with(CMD_MODE) => {
- Some(InputResult::GooseMode(s[CMD_MODE.len()..].to_string()))
+ s if s.starts_with(CMD_BUILTIN) => Some(InputResult::AddBuiltin(
+ s.get(CMD_BUILTIN.len()..).unwrap_or("").to_string(),
+ )),
+ s if s.starts_with(CMD_MODE) => Some(InputResult::GooseMode(
+ s.get(CMD_MODE.len()..).unwrap_or("").to_string(),
+ )),
+ s if s.starts_with(CMD_PLAN) => {
+ parse_plan_command(s.get(CMD_PLAN.len()..).unwrap_or("").trim().to_string())
}
- s if s.starts_with(CMD_PLAN) => parse_plan_command(s[CMD_PLAN.len()..].trim().to_string()),
s if s == CMD_ENDPLAN => Some(InputResult::EndPlan),
s if s == CMD_CLEAR => Some(InputResult::Clear),
s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s),
- s if s == CMD_SUMMARIZE => Some(InputResult::Summarize),
+ s if s == CMD_COMPACT => Some(InputResult::Compact),
+ s if s == CMD_SUMMARIZE_DEPRECATED => {
+ println!("{}", console::style("⚠️ Note: /summarize has been renamed to /compact and will be removed in a future release.").yellow());
+ Some(InputResult::Compact)
+ }
_ => None,
}
}
@@ -194,7 +202,7 @@ fn parse_recipe_command(s: &str) -> Option {
}
// Extract the filepath from the command
- let filepath = s[CMD_RECIPE.len()..].trim();
+ let filepath = s.get(CMD_RECIPE.len()..).unwrap_or("").trim();
if filepath.is_empty() {
return Some(InputResult::Recipe(None));
@@ -305,7 +313,7 @@ fn print_help() {
/endplan - Exit plan mode and return to 'normal' goose mode.
/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.
-/summarize - Summarize the current conversation to reduce context length while preserving key information.
+/compact - Compact the current conversation to reduce context length while preserving key information.
/? or /help - Display this help message
/clear - Clears the current chat history
@@ -541,17 +549,6 @@ mod tests {
assert!(matches!(result, Some(InputResult::Retry)));
}
- #[test]
- fn test_summarize_command() {
- // Test the summarize command
- let result = handle_slash_command("/summarize");
- assert!(matches!(result, Some(InputResult::Summarize)));
-
- // Test with whitespace
- let result = handle_slash_command(" /summarize ");
- assert!(matches!(result, Some(InputResult::Summarize)));
- }
-
#[test]
fn test_get_input_prompt_string() {
let prompt = get_input_prompt_string();
diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs
index f4712ecb3351..469b06e11b9b 100644
--- a/crates/goose-cli/src/session/mod.rs
+++ b/crates/goose-cli/src/session/mod.rs
@@ -12,6 +12,9 @@ use crate::session::task_execution_display::{
};
use goose::conversation::Conversation;
use std::io::Write;
+use std::str::FromStr;
+use tokio::signal::ctrl_c;
+use tokio_util::task::AbortOnDropHandle;
pub use self::export::message_to_markdown;
pub use builder::{build_session, SessionBuilderConfig, SessionSettings};
@@ -25,10 +28,10 @@ use goose::utils::safe_truncate;
use anyhow::{Context, Result};
use completion::GooseCompleter;
-use goose::agents::extension::{Envs, ExtensionConfig};
+use goose::agents::extension::{Envs, ExtensionConfig, PLATFORM_EXTENSIONS};
use goose::agents::types::RetryConfig;
-use goose::agents::{Agent, SessionConfig};
-use goose::config::Config;
+use goose::agents::{Agent, SessionConfig, MANUAL_COMPACT_TRIGGER};
+use goose::config::{Config, GooseMode};
use goose::providers::pricing::initialize_pricing_cache;
use goose::session::SessionManager;
use input::InputResult;
@@ -40,6 +43,7 @@ use goose::config::paths::Paths;
use goose::conversation::message::{Message, MessageContent};
use rand::{distributions::Alphanumeric, Rng};
use rustyline::EditMode;
+use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -49,6 +53,18 @@ use tokio;
use tokio_util::sync::CancellationToken;
use tracing::warn;
+#[derive(Serialize, Deserialize, Debug)]
+struct JsonOutput {
+ messages: Vec,
+ metadata: JsonMetadata,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct JsonMetadata {
+ total_tokens: Option,
+ status: String,
+}
+
pub enum RunMode {
Normal,
Plan,
@@ -57,7 +73,7 @@ pub enum RunMode {
pub struct CliSession {
agent: Agent,
messages: Conversation,
- session_id: Option,
+ session_id: String,
completion_cache: Arc>,
debug: bool,
run_mode: RunMode,
@@ -65,6 +81,7 @@ pub struct CliSession {
max_turns: Option,
edit_mode: Option,
retry_config: Option,
+ output_format: String,
}
// Cache structure for completion data
@@ -119,23 +136,21 @@ pub async fn classify_planner_response(
}
impl CliSession {
+ #[allow(clippy::too_many_arguments)]
pub async fn new(
agent: Agent,
- session_id: Option,
+ session_id: String,
debug: bool,
scheduled_job_id: Option,
max_turns: Option,
edit_mode: Option,
retry_config: Option,
+ output_format: String,
) -> Self {
- let messages = if let Some(session_id) = &session_id {
- SessionManager::get_session(session_id, true)
- .await
- .map(|session| session.conversation.unwrap_or_default())
- .unwrap()
- } else {
- Conversation::new_unvalidated(Vec::new())
- };
+ let messages = SessionManager::get_session(&session_id, true)
+ .await
+ .map(|session| session.conversation.unwrap_or_default())
+ .unwrap();
CliSession {
agent,
@@ -148,11 +163,12 @@ impl CliSession {
max_turns,
edit_mode,
retry_config,
+ output_format,
}
}
- pub fn session_id(&self) -> Option<&String> {
- self.session_id.as_ref()
+ pub fn session_id(&self) -> &String {
+ &self.session_id
}
/// Add a stdio extension to the session
@@ -283,15 +299,24 @@ impl CliSession {
/// * `builtin_name` - Name of the builtin extension(s), comma separated
pub async fn add_builtin(&mut self, builtin_name: String) -> Result<()> {
for name in builtin_name.split(',') {
- let extension_name = name.trim().to_string();
- let config = ExtensionConfig::Builtin {
- name: extension_name,
- display_name: None,
- // TODO: should set a timeout
- timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
- bundled: None,
- description: name.trim().to_string(),
- available_tools: Vec::new(),
+ let extension_name = name.trim();
+
+ let config = if PLATFORM_EXTENSIONS.contains_key(extension_name) {
+ ExtensionConfig::Platform {
+ name: extension_name.to_string(),
+ bundled: None,
+ description: name.to_string(),
+ available_tools: Vec::new(),
+ }
+ } else {
+ ExtensionConfig::Builtin {
+ name: extension_name.to_string(),
+ display_name: None,
+ timeout: None,
+ bundled: None,
+ description: name.to_string(),
+ available_tools: Vec::new(),
+ }
};
self.agent
.add_extension(config)
@@ -358,9 +383,6 @@ impl CliSession {
cancel_token: CancellationToken,
) -> Result<()> {
let cancel_token = cancel_token.clone();
-
- // TODO(Douwe): Make sure we generate the description here still:
-
self.push_message(message);
self.process_agent_response(false, cancel_token).await?;
Ok(())
@@ -442,7 +464,7 @@ impl CliSession {
// Track the current directory and last instruction in projects.json
if let Err(e) = crate::project_tracker::update_project_tracker(
Some(&content),
- self.session_id.as_deref(),
+ Some(&self.session_id),
) {
eprintln!("Warning: Failed to update project tracker with instruction: {}", e);
}
@@ -494,6 +516,10 @@ impl CliSession {
let current = output::get_theme();
let new_theme = match current {
+ output::Theme::Ansi => {
+ println!("Switching to Light theme");
+ output::Theme::Light
+ }
output::Theme::Light => {
println!("Switching to Dark theme");
output::Theme::Dark
@@ -502,10 +528,6 @@ impl CliSession {
println!("Switching to Ansi theme");
output::Theme::Ansi
}
- output::Theme::Ansi => {
- println!("Switching to Light theme");
- output::Theme::Light
- }
};
output::set_theme(new_theme);
continue;
@@ -545,21 +567,18 @@ impl CliSession {
save_history(&mut editor);
let config = Config::global();
- let mode = mode.to_lowercase();
-
- // Check if mode is valid
- if !["auto", "approve", "chat", "smart_approve"].contains(&mode.as_str()) {
- output::render_error(&format!(
- "Invalid mode '{}'. Mode must be one of: auto, approve, chat",
- mode
- ));
- continue;
- }
-
- config
- .set_param("GOOSE_MODE", Value::String(mode.to_string()))
- .unwrap();
- output::goose_mode_message(&format!("Goose mode set to '{}'", mode));
+ let mode = match GooseMode::from_str(&mode.to_lowercase()) {
+ Ok(mode) => mode,
+ Err(_) => {
+ output::render_error(&format!(
+ "Invalid mode '{}'. Mode must be one of: auto, approve, chat, smart_approve",
+ mode
+ ));
+ continue;
+ }
+ };
+ config.set_goose_mode(mode)?;
+ output::goose_mode_message(&format!("Goose mode set to '{:?}'", mode));
continue;
}
input::InputResult::Plan(options) => {
@@ -585,16 +604,14 @@ impl CliSession {
input::InputResult::Clear => {
save_history(&mut editor);
- if let Some(session_id) = &self.session_id {
- if let Err(e) = SessionManager::replace_conversation(
- session_id,
- &Conversation::default(),
- )
- .await
- {
- output::render_error(&format!("Failed to clear session: {}", e));
- continue;
- }
+ if let Err(e) = SessionManager::replace_conversation(
+ &self.session_id,
+ &Conversation::default(),
+ )
+ .await
+ {
+ output::render_error(&format!("Failed to clear session: {}", e));
+ continue;
}
self.messages.clear();
@@ -643,16 +660,16 @@ impl CliSession {
continue;
}
- InputResult::Summarize => {
+ InputResult::Compact => {
save_history(&mut editor);
- let prompt = "Are you sure you want to summarize this conversation? This will condense the message history.";
+ let prompt = "Are you sure you want to compact this conversation? This will condense the message history.";
let should_summarize =
match cliclack::confirm(prompt).initial_value(true).interact() {
Ok(choice) => choice,
Err(e) => {
if e.kind() == std::io::ErrorKind::Interrupted {
- false // If interrupted, set should_summarize to false
+ false
} else {
return Err(e.into());
}
@@ -660,90 +677,23 @@ impl CliSession {
};
if should_summarize {
- println!("{}", console::style("Summarizing conversation...").yellow());
+ self.push_message(Message::user().with_text(MANUAL_COMPACT_TRIGGER));
output::show_thinking();
-
- let (summarized_messages, _token_counts, summarization_usage) =
- goose::context_mgmt::compact_messages(
- &self.agent,
- &self.messages,
- false,
- )
+ self.process_agent_response(true, CancellationToken::default())
.await?;
-
- // Update the session messages with the summarized ones
- self.messages = summarized_messages.clone();
-
- // Persist the summarized messages and update session metadata
- if let Some(session_id) = &self.session_id {
- // Replace all messages with the summarized version
- SessionManager::replace_conversation(session_id, &summarized_messages)
- .await?;
-
- // Update session metadata with the new token counts from summarization
- if let Some(usage) = summarization_usage {
- let session =
- SessionManager::get_session(session_id, false).await?;
-
- // Update token counts with the summarization usage
- let summary_tokens = usage.usage.output_tokens.unwrap_or(0);
-
- // Update accumulated tokens (add the summarization cost)
- let accumulate = |a: Option, b: Option| -> Option {
- match (a, b) {
- (Some(x), Some(y)) => Some(x + y),
- _ => a.or(b),
- }
- };
-
- let accumulated_total = accumulate(
- session.accumulated_total_tokens,
- usage.usage.total_tokens,
- );
- let accumulated_input = accumulate(
- session.accumulated_input_tokens,
- usage.usage.input_tokens,
- );
- let accumulated_output = accumulate(
- session.accumulated_output_tokens,
- usage.usage.output_tokens,
- );
-
- SessionManager::update_session(session_id)
- .total_tokens(Some(summary_tokens))
- .input_tokens(None)
- .output_tokens(Some(summary_tokens))
- .accumulated_total_tokens(accumulated_total)
- .accumulated_input_tokens(accumulated_input)
- .accumulated_output_tokens(accumulated_output)
- .apply()
- .await?;
- }
- }
-
output::hide_thinking();
- println!(
- "{}",
- console::style("Conversation has been summarized.").green()
- );
- println!(
- "{}",
- console::style(
- "Key information has been preserved while reducing context length."
- )
- .green()
- );
} else {
- println!("{}", console::style("Summarization cancelled.").yellow());
+ println!("{}", console::style("Compaction cancelled.").yellow());
}
continue;
}
}
}
- if let Some(id) = &self.session_id {
- println!("Closing session. Session ID: {}", console::style(id).cyan());
- }
+ println!(
+ "Closing session. Session ID: {}",
+ console::style(&self.session_id).cyan()
+ );
Ok(())
}
@@ -787,12 +737,9 @@ impl CliSession {
self.run_mode = RunMode::Normal;
// set goose mode: auto if that isn't already the case
let config = Config::global();
- let curr_goose_mode =
- config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
- if curr_goose_mode != "auto" {
- config
- .set_param("GOOSE_MODE", Value::String("auto".to_string()))
- .unwrap();
+ let curr_goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
+ if curr_goose_mode != GooseMode::Auto {
+ config.set_goose_mode(GooseMode::Auto).unwrap();
}
// clear the messages before acting on the plan
@@ -807,10 +754,8 @@ impl CliSession {
output::hide_thinking();
// Reset run & goose mode
- if curr_goose_mode != "auto" {
- config
- .set_param("GOOSE_MODE", Value::String(curr_goose_mode.to_string()))
- .unwrap();
+ if curr_goose_mode != GooseMode::Auto {
+ config.set_goose_mode(curr_goose_mode)?;
}
} else {
// add the plan response (assistant message) & carry the conversation forward
@@ -841,26 +786,39 @@ impl CliSession {
interactive: bool,
cancel_token: CancellationToken,
) -> Result<()> {
- let cancel_token_clone = cancel_token.clone();
+ // Cache the output format check to avoid repeated string comparisons in the hot loop
+ let is_json_mode = self.output_format == "json";
- let session_config = self.session_id.as_ref().map(|session_id| SessionConfig {
- id: session_id.clone(),
- working_dir: std::env::current_dir().unwrap_or_default(),
+ let session_config = SessionConfig {
+ id: self.session_id.clone(),
schedule_id: self.scheduled_job_id.clone(),
- execution_mode: None,
max_turns: self.max_turns,
retry_config: self.retry_config.clone(),
+ };
+ let user_message = self
+ .messages
+ .last()
+ .ok_or_else(|| anyhow::anyhow!("No user message"))?;
+
+ let cancel_token_interrupt = cancel_token.clone();
+ let handle = tokio::spawn(async move {
+ if ctrl_c().await.is_ok() {
+ cancel_token_interrupt.cancel();
+ }
});
+ let _drop_handle = AbortOnDropHandle::new(handle);
+
let mut stream = self
.agent
.reply(
- self.messages.clone(),
+ user_message.clone(),
session_config.clone(),
Some(cancel_token.clone()),
)
.await?;
let mut progress_bars = output::McpSpinners::new();
+ let cancel_token_clone = cancel_token.clone();
use futures::StreamExt;
loop {
@@ -972,11 +930,16 @@ impl CliSession {
);
}
}
+
self.messages.push(message.clone());
if interactive {output::hide_thinking()};
let _ = progress_bars.hide();
- output::render_message(&message, self.debug);
+
+ // Don't render in JSON mode
+ if !is_json_mode {
+ output::render_message(&message, self.debug);
+ }
}
}
Some(Ok(AgentEvent::McpNotification((_id, message)))) => {
@@ -1048,17 +1011,21 @@ impl CliSession {
// TODO: proper display for subagent notifications
if interactive {
let _ = progress_bars.hide();
- println!("{}", console::style(&formatted_message).green().dim());
- } else {
+ if !is_json_mode {
+ println!("{}", console::style(&formatted_message).green().dim());
+ }
+ } else if !is_json_mode {
progress_bars.log(&formatted_message);
}
} else if let Some(ref notification_type) = message_notification_type {
if notification_type == TASK_EXECUTION_NOTIFICATION_TYPE {
if interactive {
let _ = progress_bars.hide();
- print!("{}", formatted_message);
- std::io::stdout().flush().unwrap();
- } else {
+ if !is_json_mode {
+ print!("{}", formatted_message);
+ std::io::stdout().flush().unwrap();
+ }
+ } else if !is_json_mode {
print!("{}", formatted_message);
std::io::stdout().flush().unwrap();
}
@@ -1127,8 +1094,7 @@ impl CliSession {
None => break,
}
}
- _ = tokio::signal::ctrl_c() => {
- cancel_token_clone.cancel();
+ _ = cancel_token_clone.cancelled() => {
drop(stream);
if let Err(e) = self.handle_interrupted_messages(true).await {
eprintln!("Error handling interruption: {}", e);
@@ -1137,7 +1103,29 @@ impl CliSession {
}
}
}
- println!();
+
+ // Output JSON if requested
+ if is_json_mode {
+ let metadata = match SessionManager::get_session(&self.session_id, false).await {
+ Ok(session) => JsonMetadata {
+ total_tokens: session.total_tokens,
+ status: "completed".to_string(),
+ },
+ Err(_) => JsonMetadata {
+ total_tokens: None,
+ status: "completed".to_string(),
+ },
+ };
+
+ let json_output = JsonOutput {
+ messages: self.messages.messages().to_vec(),
+ metadata,
+ };
+
+ println!("{}", serde_json::to_string_pretty(&json_output)?);
+ } else {
+ println!();
+ }
Ok(())
}
@@ -1299,16 +1287,13 @@ impl CliSession {
);
}
- pub async fn get_metadata(&self) -> Result {
- match &self.session_id {
- Some(id) => SessionManager::get_session(id, false).await,
- None => Err(anyhow::anyhow!("No session available")),
- }
+ pub async fn get_session(&self) -> Result {
+ SessionManager::get_session(&self.session_id, false).await
}
// Get the session's total token usage
pub async fn get_total_token_usage(&self) -> Result