mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Merge branch 'main' into wpfleger/db-cli
* main: (54 commits) add clippy warning for string_slice (#5422) improve linux tray icon support (#5425) feat: log rotation (#5561) use app.isPackaged instead of checking for node env development (#5465) disable RPM build-ID generation to prevent package conflicts (#5563) Add Diagnostics Info to Q&A and Bug Report Templates (#5565) fix: improve server error messages to include HTTP status code (#5532) improvement: add useful error message when attempting to use unauthenticated cursor-agent (#5300) fix: unblock acp via databricks (#5562) feat: add --output-format json flag to goose run command (#5525) Sessions required (#5548) feat: add grouped extension loading notification (#5529) we should run this on main and also test open models at least via ope… (#5556) info: print location of sessions.db via goose info (#5557) chore: remove yarn usage from documentation (#5555) cli: adjust default theme to address #1905 (#5552) Manual compaction counting fix + cli cleanup (#5480) chore(deps): bump prismjs and react-syntax-highlighter in /ui/desktop (#5549) fix: remove qwen3-coder from provider/mcp smoke tests (#5551) fix: do not build unsigned desktop app bundles on every PR in ci. add manual option. (#5550) ...
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
- [ ] Build / Release
|
||||
- [ ] Other (specify below)
|
||||
|
||||
### AI Assistance
|
||||
<!-- great that you got assistance 🔥, just check out the HOWTOAI guidance: https://github.com/block/goose/blob/main/HOWTOAI.md-->
|
||||
- [ ] This PR was created or reviewed with AI assistance
|
||||
|
||||
### Testing
|
||||
<!-- How have this change been tested? Unit/integration tests? Manual testing? -->
|
||||
|
||||
@@ -27,5 +31,6 @@ Before:
|
||||
|
||||
After:
|
||||
|
||||
<!-- For Recipe Cookbook Submissions ONLY: Include your email below to receive $10 OpenRouter credits once approved & merged -->
|
||||
### Submitting a Recipe?
|
||||
<!-- For Recipe Cookbook Submissions ONLY: Include your email below to receive $10 OpenRouter credits once approved and merged -->
|
||||
**Email**:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -3,6 +3,7 @@
|
||||
# - release.yml
|
||||
# - canary.yml
|
||||
# - pr-comment-bundle-desktop.yml
|
||||
# - bundle-desktop-manual.yml
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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 }}
|
||||
|
||||
+2
-46
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+23
-15
@@ -2667,6 +2667,7 @@ dependencies = [
|
||||
"fs2",
|
||||
"futures",
|
||||
"include_dir",
|
||||
"indexmap 2.12.0",
|
||||
"indoc",
|
||||
"insta",
|
||||
"jsonschema",
|
||||
@@ -2938,7 +2939,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap 2.7.1",
|
||||
"indexmap 2.12.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -2957,7 +2958,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.2.0",
|
||||
"indexmap 2.7.1",
|
||||
"indexmap 2.12.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -3001,6 +3002,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"
|
||||
@@ -3558,13 +3565,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]]
|
||||
@@ -4016,7 +4024,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"indexmap 2.7.1",
|
||||
"indexmap 2.12.0",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
@@ -4919,7 +4927,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",
|
||||
@@ -5072,7 +5080,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",
|
||||
@@ -6039,7 +6047,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",
|
||||
@@ -6088,7 +6096,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.7.1",
|
||||
"indexmap 2.12.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -6114,7 +6122,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",
|
||||
@@ -6361,7 +6369,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hashlink",
|
||||
"hex",
|
||||
"indexmap 2.7.1",
|
||||
"indexmap 2.12.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
@@ -7125,7 +7133,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",
|
||||
@@ -7609,7 +7617,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",
|
||||
@@ -8726,7 +8734,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"indexmap 2.7.1",
|
||||
"indexmap 2.12.0",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ description = "An AI agent"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
string_slice = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
rmcp = { version = "0.8.3", features = ["schemars", "auth"] }
|
||||
|
||||
+317
@@ -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
|
||||
```
|
||||
@@ -17,20 +17,6 @@ _a local, extensible, open source AI agent that automates engineering tasks_
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## 🎉 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?
|
||||
|
||||
@@ -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.
|
||||
+35
-10
@@ -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;
|
||||
@@ -86,9 +87,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 +109,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 +131,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))
|
||||
}
|
||||
}
|
||||
@@ -773,6 +784,16 @@ enum Command {
|
||||
)]
|
||||
additional_sub_recipes: Vec<String>,
|
||||
|
||||
/// 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",
|
||||
@@ -1051,6 +1072,7 @@ pub async fn cli() -> anyhow::Result<()> {
|
||||
sub_recipes: None,
|
||||
final_output_response: None,
|
||||
retry_config: None,
|
||||
output_format: "text".to_string(),
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1065,7 +1087,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));
|
||||
@@ -1131,6 +1153,7 @@ pub async fn cli() -> anyhow::Result<()> {
|
||||
scheduled_job_id,
|
||||
quiet,
|
||||
additional_sub_recipes,
|
||||
output_format,
|
||||
provider,
|
||||
model,
|
||||
}) => {
|
||||
@@ -1260,6 +1283,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;
|
||||
|
||||
@@ -1286,7 +1310,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));
|
||||
@@ -1443,6 +1467,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?;
|
||||
|
||||
@@ -3,11 +3,13 @@ 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::providers::create;
|
||||
use goose::session::session_manager::SessionType;
|
||||
use goose::session::SessionManager;
|
||||
use rmcp::model::{RawContent, ResourceContents};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
@@ -19,17 +21,15 @@ 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<String, String>, // Maps internal tool IDs to ACP tool call IDs
|
||||
cancel_token: Option<CancellationToken>, // 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<Mutex<HashMap<String, GooseSession>>>,
|
||||
session_update_tx: mpsc::UnboundedSender<(SessionNotification, oneshot::Sender<()>)>,
|
||||
sessions: Arc<Mutex<HashMap<String, GooseAcpSession>>>,
|
||||
agent: Agent, // Shared agent instance
|
||||
}
|
||||
|
||||
@@ -97,15 +97,14 @@ impl GooseAcpAgent {
|
||||
async fn new(
|
||||
session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
|
||||
) -> Result<Self> {
|
||||
// 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 +216,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 +272,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());
|
||||
@@ -341,7 +340,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) {
|
||||
@@ -496,7 +495,7 @@ 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(),
|
||||
cancel_token: None,
|
||||
@@ -544,30 +543,26 @@ 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 = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get_mut(&session_id)
|
||||
.ok_or_else(acp::Error::invalid_params)?;
|
||||
let session = SessionManager::create_session(
|
||||
std::env::current_dir().unwrap_or_default(),
|
||||
"ACP Session".to_string(),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add message to conversation
|
||||
session.messages.push(user_message);
|
||||
|
||||
// Store cancellation token
|
||||
session.cancel_token = Some(cancel_token.clone());
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -26,9 +26,7 @@ impl BenchBaseSession for CliSession {
|
||||
}
|
||||
|
||||
fn get_session_id(&self) -> anyhow::Result<String> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ 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;
|
||||
@@ -421,7 +422,7 @@ fn select_model_from_list(
|
||||
}
|
||||
|
||||
fn try_store_secret(config: &Config, key_name: &str, value: String) -> anyhow::Result<bool> {
|
||||
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!(
|
||||
@@ -450,7 +451,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
|
||||
.collect();
|
||||
|
||||
// Get current default provider if it exists
|
||||
let current_provider: Option<String> = config.get_param("GOOSE_PROVIDER").ok();
|
||||
let current_provider: Option<String> = config.get_goose_provider().ok();
|
||||
let default_provider = current_provider.unwrap_or_default();
|
||||
|
||||
// Select provider
|
||||
@@ -487,7 +488,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
|
||||
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 {}", key.name, config.path()));
|
||||
}
|
||||
@@ -529,7 +530,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
config.set_param(&key.name, Value::String(value))?;
|
||||
config.set_param(&key.name, &value)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,9 +559,9 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
|
||||
};
|
||||
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,9 +649,8 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
|
||||
|
||||
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()))?;
|
||||
config.set_goose_provider(provider_name)?;
|
||||
config.set_goose_model(&model)?;
|
||||
print_config_file_saved()?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -877,7 +877,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);
|
||||
@@ -973,7 +973,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);
|
||||
@@ -1093,7 +1093,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);
|
||||
@@ -1273,46 +1273,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(())
|
||||
}
|
||||
|
||||
@@ -1321,28 +1310,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(())
|
||||
}
|
||||
|
||||
@@ -1360,15 +1346,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!(),
|
||||
@@ -1441,11 +1427,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)?;
|
||||
|
||||
@@ -1591,7 +1577,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(())
|
||||
}
|
||||
@@ -1618,7 +1604,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",
|
||||
@@ -1651,7 +1637,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) => {
|
||||
@@ -1729,7 +1715,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) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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();
|
||||
@@ -19,7 +20,7 @@ pub fn handle_info(verbose: bool) -> Result<()> {
|
||||
// Define the labels and their corresponding path values once.
|
||||
let paths = [
|
||||
("Config dir:", config_dir),
|
||||
("Sessions dir:", sessions_dir.display().to_string()),
|
||||
("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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<Redirect, (http::StatusCode, String)> {
|
||||
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,21 +469,16 @@ 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 {
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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,
|
||||
);
|
||||
@@ -177,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());
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
|key: &str, description: &str| -> Result<String> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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<dyn goose::providers::base::Provider>)
|
||||
.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() {
|
||||
|
||||
@@ -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<String>,
|
||||
/// Whether to resume an existing session
|
||||
pub resume: bool,
|
||||
@@ -67,6 +68,39 @@ pub struct SessionBuilderConfig {
|
||||
pub final_output_response: Option<Response>,
|
||||
/// Retry configuration for automated validation and recovery
|
||||
pub retry_config: Option<RetryConfig>,
|
||||
/// 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);
|
||||
@@ -278,12 +327,20 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
let session_id: Option<String> = 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);
|
||||
|
||||
@@ -26,7 +26,7 @@ impl GooseCompleter {
|
||||
/// Complete prompt names for the /prompt command
|
||||
fn complete_prompt_names(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
|
||||
// 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<Pair>)> {
|
||||
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() {
|
||||
|
||||
@@ -20,7 +20,7 @@ pub enum InputResult {
|
||||
EndPlan,
|
||||
Clear,
|
||||
Recipe(Option<String>),
|
||||
Summarize,
|
||||
Compact,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -120,7 +120,8 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
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 +169,25 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
|
||||
}
|
||||
}
|
||||
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_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_MODE) => {
|
||||
Some(InputResult::GooseMode(s[CMD_MODE.len()..].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 +201,7 @@ fn parse_recipe_command(s: &str) -> Option<InputResult> {
|
||||
}
|
||||
|
||||
// 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 +312,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 +548,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();
|
||||
|
||||
+124
-157
@@ -12,6 +12,7 @@ use crate::session::task_execution_display::{
|
||||
};
|
||||
use goose::conversation::Conversation;
|
||||
use std::io::Write;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub use self::export::message_to_markdown;
|
||||
pub use builder::{build_session, SessionBuilderConfig, SessionSettings};
|
||||
@@ -27,8 +28,8 @@ use anyhow::{Context, Result};
|
||||
use completion::GooseCompleter;
|
||||
use goose::agents::extension::{Envs, ExtensionConfig};
|
||||
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 +41,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 +51,18 @@ use tokio;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct JsonOutput {
|
||||
messages: Vec<Message>,
|
||||
metadata: JsonMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct JsonMetadata {
|
||||
total_tokens: Option<i32>,
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub enum RunMode {
|
||||
Normal,
|
||||
Plan,
|
||||
@@ -57,7 +71,7 @@ pub enum RunMode {
|
||||
pub struct CliSession {
|
||||
agent: Agent,
|
||||
messages: Conversation,
|
||||
session_id: Option<String>,
|
||||
session_id: String,
|
||||
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
|
||||
debug: bool,
|
||||
run_mode: RunMode,
|
||||
@@ -65,6 +79,7 @@ pub struct CliSession {
|
||||
max_turns: Option<u32>,
|
||||
edit_mode: Option<EditMode>,
|
||||
retry_config: Option<RetryConfig>,
|
||||
output_format: String,
|
||||
}
|
||||
|
||||
// Cache structure for completion data
|
||||
@@ -119,23 +134,21 @@ pub async fn classify_planner_response(
|
||||
}
|
||||
|
||||
impl CliSession {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn new(
|
||||
agent: Agent,
|
||||
session_id: Option<String>,
|
||||
session_id: String,
|
||||
debug: bool,
|
||||
scheduled_job_id: Option<String>,
|
||||
max_turns: Option<u32>,
|
||||
edit_mode: Option<EditMode>,
|
||||
retry_config: Option<RetryConfig>,
|
||||
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 +161,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
|
||||
@@ -358,9 +372,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 +453,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 +505,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 +517,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 +556,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 +593,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 +649,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 +666,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<i32>, b: Option<i32>| -> Option<i32> {
|
||||
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 +726,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 +743,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
|
||||
@@ -843,18 +777,23 @@ impl CliSession {
|
||||
) -> Result<()> {
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
|
||||
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(),
|
||||
// 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 = 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 mut stream = self
|
||||
.agent
|
||||
.reply(
|
||||
self.messages.clone(),
|
||||
user_message.clone(),
|
||||
session_config.clone(),
|
||||
Some(cancel_token.clone()),
|
||||
)
|
||||
@@ -972,11 +911,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 +992,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();
|
||||
}
|
||||
@@ -1137,7 +1085,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 +1269,13 @@ impl CliSession {
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn get_metadata(&self) -> Result<goose::session::Session> {
|
||||
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<goose::session::Session> {
|
||||
SessionManager::get_session(&self.session_id, false).await
|
||||
}
|
||||
|
||||
// Get the session's total token usage
|
||||
pub async fn get_total_token_usage(&self) -> Result<Option<i32>> {
|
||||
let metadata = self.get_metadata().await?;
|
||||
let metadata = self.get_session().await?;
|
||||
Ok(metadata.total_tokens)
|
||||
}
|
||||
|
||||
@@ -1324,7 +1291,7 @@ impl CliSession {
|
||||
.unwrap_or(false);
|
||||
|
||||
let provider_name = config
|
||||
.get_param::<String>("GOOSE_PROVIDER")
|
||||
.get_goose_provider()
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
|
||||
// Do not get costing information if show cost is disabled
|
||||
@@ -1340,7 +1307,7 @@ impl CliSession {
|
||||
}
|
||||
}
|
||||
|
||||
match self.get_metadata().await {
|
||||
match self.get_session().await {
|
||||
Ok(metadata) => {
|
||||
let total_tokens = metadata.total_tokens.unwrap_or(0) as usize;
|
||||
|
||||
@@ -1488,7 +1455,7 @@ async fn get_reasoner() -> Result<Arc<dyn Provider>, anyhow::Error> {
|
||||
} else {
|
||||
println!("WARNING: GOOSE_PLANNER_PROVIDER not found. Using default provider...");
|
||||
config
|
||||
.get_param::<String>("GOOSE_PROVIDER")
|
||||
.get_goose_provider()
|
||||
.expect("No provider configured. Run 'goose configure' first")
|
||||
};
|
||||
|
||||
@@ -1498,7 +1465,7 @@ async fn get_reasoner() -> Result<Arc<dyn Provider>, anyhow::Error> {
|
||||
} else {
|
||||
println!("WARNING: GOOSE_PLANNER_MODEL not found. Using default model...");
|
||||
config
|
||||
.get_param::<String>("GOOSE_MODEL")
|
||||
.get_goose_model()
|
||||
.expect("No model configured. Run 'goose configure' first")
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ thread_local! {
|
||||
.unwrap_or_else(||
|
||||
Config::global().get_param::<String>("GOOSE_CLI_THEME").ok()
|
||||
.map(|val| Theme::from_config_str(&val))
|
||||
.unwrap_or(Theme::Dark)
|
||||
.unwrap_or(Theme::Ansi)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ thread_local! {
|
||||
pub fn set_theme(theme: Theme) {
|
||||
let config = Config::global();
|
||||
config
|
||||
.set_param("GOOSE_CLI_THEME", Value::String(theme.as_config_string()))
|
||||
.set_param("GOOSE_CLI_THEME", theme.as_config_string())
|
||||
.expect("Failed to set theme");
|
||||
CURRENT_THEME.with(|t| *t.borrow_mut() = theme);
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn set_theme(theme: Theme) {
|
||||
Theme::Ansi => "ansi",
|
||||
};
|
||||
|
||||
if let Err(e) = config.set_param("GOOSE_CLI_THEME", Value::String(theme_str.to_string())) {
|
||||
if let Err(e) = config.set_param("GOOSE_CLI_THEME", theme_str) {
|
||||
eprintln!("Failed to save theme setting to config: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,5 +169,4 @@
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html></content>
|
||||
<parameter name="filePath">c:\Users\ARYA SINGH\Dropbox\PC\Desktop\goose\crates\goose-mcp\src\autovisualiser\templates\mermaid_template.html
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use rmcp::model::{Content, Role};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::developer::analyze::types::{
|
||||
AnalysisMode, AnalysisResult, CallChain, EntryType, FocusedAnalysisData,
|
||||
};
|
||||
use crate::developer::lang;
|
||||
use goose::utils::safe_truncate;
|
||||
use rmcp::model::{Content, Role};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct Formatter;
|
||||
|
||||
@@ -164,13 +164,7 @@ impl Formatter {
|
||||
if imports.len() > 1 {
|
||||
format!("{}({})", group, imports.len())
|
||||
} else {
|
||||
// For single imports, show more detail
|
||||
let imp = &imports[0];
|
||||
if imp.len() > 40 {
|
||||
format!("{}...", &imp[..37])
|
||||
} else {
|
||||
imp.clone()
|
||||
}
|
||||
safe_truncate(&imports[0], 40)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -727,7 +721,7 @@ impl Formatter {
|
||||
if let Some(header_line) = output
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.starts_with("##") && line.contains(&l[3..]))
|
||||
.find(|l| l.starts_with("##") && l.get(3..).is_some_and(|s| line.contains(s)))
|
||||
{
|
||||
if !filtered.contains(header_line) {
|
||||
filtered.push_str(header_line);
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn find_method_for_receiver(
|
||||
for i in 0..parent.child_count() {
|
||||
if let Some(child) = parent.child(i) {
|
||||
if child.kind() == "field_identifier" {
|
||||
return Some(source[child.byte_range()].to_string());
|
||||
return source.get(child.byte_range()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ fn find_method_in_body_with_depth(
|
||||
for j in 0..child.child_count() {
|
||||
if let Some(name_node) = child.child(j) {
|
||||
if name_node.kind() == "identifier" {
|
||||
return Some(source[name_node.byte_range()].to_string());
|
||||
return source.get(name_node.byte_range()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,9 @@ pub fn extract_function_name_for_kind(
|
||||
for i in 0..node.child_count() {
|
||||
if let Some(child) = node.child(i) {
|
||||
if child.kind() == "type_identifier" {
|
||||
return Some(format!("impl {}", &source[child.byte_range()]));
|
||||
return source
|
||||
.get(child.byte_range())
|
||||
.map(|s| format!("impl {}", s));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +111,7 @@ pub fn find_method_for_receiver(
|
||||
for i in 0..parent.child_count() {
|
||||
if let Some(child) = parent.child(i) {
|
||||
if child.kind() == "identifier" {
|
||||
return Some(source[child.byte_range()].to_string());
|
||||
return source.get(child.byte_range()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +135,7 @@ pub fn find_receiver_type(node: &tree_sitter::Node, source: &str) -> Option<Stri
|
||||
for i in 0..parent.child_count() {
|
||||
if let Some(child) = parent.child(i) {
|
||||
if child.kind() == "type_identifier" {
|
||||
return Some(source[child.byte_range()].to_string());
|
||||
return source.get(child.byte_range()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,8 @@ impl ElementExtractor {
|
||||
source: &str,
|
||||
kinds: &[&str],
|
||||
) -> Option<String> {
|
||||
Self::find_child_by_kind(node, kinds).map(|child| source[child.byte_range()].to_string())
|
||||
Self::find_child_by_kind(node, kinds)
|
||||
.and_then(|child| source.get(child.byte_range()).map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
pub fn extract_with_depth(
|
||||
@@ -216,8 +217,13 @@ impl ElementExtractor {
|
||||
for match_ in matches.by_ref() {
|
||||
for capture in match_.captures {
|
||||
let node = capture.node;
|
||||
let text = &source[node.byte_range()];
|
||||
let line = source[..node.start_byte()].lines().count() + 1;
|
||||
let Some(text) = source.get(node.byte_range()) else {
|
||||
continue;
|
||||
};
|
||||
let line = source
|
||||
.get(..node.start_byte())
|
||||
.map(|s| s.lines().count() + 1)
|
||||
.unwrap_or(1);
|
||||
|
||||
match query.capture_names()[capture.index as usize] {
|
||||
"func" | "const" => {
|
||||
@@ -284,18 +290,25 @@ impl ElementExtractor {
|
||||
for match_ in matches.by_ref() {
|
||||
for capture in match_.captures {
|
||||
let node = capture.node;
|
||||
let text = &source[node.byte_range()];
|
||||
let Some(text) = source.get(node.byte_range()) else {
|
||||
continue;
|
||||
};
|
||||
let start_pos = node.start_position();
|
||||
|
||||
let line_start = source[..node.start_byte()]
|
||||
.rfind('\n')
|
||||
let line_start = source
|
||||
.get(..node.start_byte())
|
||||
.and_then(|s| s.rfind('\n'))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let line_end = source[node.end_byte()..]
|
||||
.find('\n')
|
||||
let line_end = source
|
||||
.get(node.end_byte()..)
|
||||
.and_then(|s| s.find('\n'))
|
||||
.map(|i| node.end_byte() + i)
|
||||
.unwrap_or(source.len());
|
||||
let context = source[line_start..line_end].trim().to_string();
|
||||
let context = source
|
||||
.get(line_start..line_end)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let caller_name = Self::find_containing_function(&node, source, language);
|
||||
|
||||
@@ -356,18 +369,25 @@ impl ElementExtractor {
|
||||
for match_ in matches.by_ref() {
|
||||
for capture in match_.captures {
|
||||
let node = capture.node;
|
||||
let text = &source[node.byte_range()];
|
||||
let Some(text) = source.get(node.byte_range()) else {
|
||||
continue;
|
||||
};
|
||||
let start_pos = node.start_position();
|
||||
|
||||
let line_start = source[..node.start_byte()]
|
||||
.rfind('\n')
|
||||
let line_start = source
|
||||
.get(..node.start_byte())
|
||||
.and_then(|s| s.rfind('\n'))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let line_end = source[node.end_byte()..]
|
||||
.find('\n')
|
||||
let line_end = source
|
||||
.get(node.end_byte()..)
|
||||
.and_then(|s| s.find('\n'))
|
||||
.map(|i| node.end_byte() + i)
|
||||
.unwrap_or(source.len());
|
||||
let context = source[line_start..line_end].trim().to_string();
|
||||
let context = source
|
||||
.get(line_start..line_end)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let capture_name = query.capture_names()[capture.index as usize];
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ impl MorphLLMEditor {
|
||||
if let (Some(start_pos), Some(end_pos)) = (text.find(&start_tag), text.find(&end_tag)) {
|
||||
if start_pos < end_pos {
|
||||
let content_start = start_pos + start_tag.len();
|
||||
let content = &text[content_start..end_pos];
|
||||
return Some(content.trim().to_string());
|
||||
if let Some(content) = text.get(content_start..end_pos) {
|
||||
return Some(content.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -1335,20 +1335,22 @@ impl DeveloperServer {
|
||||
|
||||
// Find the last space before AM/PM and replace it with U+202F
|
||||
let space_pos = filename.rfind(meridian)
|
||||
.map(|pos| filename[..pos].trim_end().len())
|
||||
.and_then(|pos| filename.get(..pos).map(|s| s.trim_end().len()))
|
||||
.unwrap_or(0);
|
||||
|
||||
if space_pos > 0 {
|
||||
let parent = path.parent().unwrap_or(Path::new(""));
|
||||
let new_filename = format!(
|
||||
"{}{}{}",
|
||||
&filename[..space_pos],
|
||||
'\u{202F}',
|
||||
&filename[space_pos+1..]
|
||||
);
|
||||
let new_path = parent.join(new_filename);
|
||||
if let (Some(before), Some(after)) = (filename.get(..space_pos), filename.get(space_pos+1..)) {
|
||||
let new_filename = format!(
|
||||
"{}{}{}",
|
||||
before,
|
||||
'\u{202F}',
|
||||
after
|
||||
);
|
||||
let new_path = parent.join(new_filename);
|
||||
|
||||
return new_path;
|
||||
return new_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3208,7 +3210,10 @@ mod tests {
|
||||
) {
|
||||
let start_idx = start + start_tag.len();
|
||||
if start_idx < end {
|
||||
let path = assistant_content.text[start_idx..end].trim();
|
||||
let Some(path) = assistant_content.text.get(start_idx..end).map(|s| s.trim())
|
||||
else {
|
||||
panic!("Failed to extract path from assistant content");
|
||||
};
|
||||
println!("Extracted path: {}", path);
|
||||
|
||||
let file_contents =
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use tracing_appender::rolling::Rotation;
|
||||
use tracing_subscriber::{
|
||||
filter::LevelFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
|
||||
@@ -8,32 +7,24 @@ use tracing_subscriber::{
|
||||
|
||||
use goose::tracing::{langfuse_layer, otlp_layer};
|
||||
|
||||
/// Returns the directory where log files should be stored.
|
||||
/// Creates the directory structure if it doesn't exist.
|
||||
fn get_log_directory() -> Result<PathBuf> {
|
||||
goose::logging::get_log_directory("server", true)
|
||||
}
|
||||
|
||||
/// Sets up the logging infrastructure for the application.
|
||||
/// This includes:
|
||||
/// - File-based logging with JSON formatting (DEBUG level)
|
||||
/// - Console output for development (INFO level)
|
||||
/// - Optional Langfuse integration (DEBUG level)
|
||||
pub fn setup_logging(name: Option<&str>) -> Result<()> {
|
||||
// Set up file appender for goose module logs
|
||||
let log_dir = get_log_directory()?;
|
||||
let log_dir = goose::logging::prepare_log_directory("server", 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, log_dir, log_filename);
|
||||
let file_appender = tracing_appender::rolling::RollingFileAppender::new(
|
||||
Rotation::NEVER, // we do manual rotation via file naming and cleanup_old_logs
|
||||
log_dir,
|
||||
log_filename,
|
||||
);
|
||||
|
||||
// Create JSON file logging layer
|
||||
let file_layer = fmt::layer()
|
||||
|
||||
@@ -6,7 +6,7 @@ use goose::config::ExtensionEntry;
|
||||
use goose::conversation::Conversation;
|
||||
use goose::permission::permission_confirmation::PrincipalType;
|
||||
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType};
|
||||
use goose::session::{Session, SessionInsights};
|
||||
use goose::session::{Session, SessionInsights, SessionType};
|
||||
use rmcp::model::{
|
||||
Annotations, Content, EmbeddedResource, Icon, ImageContent, JsonObject, RawAudioContent,
|
||||
RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ResourceContents, Role,
|
||||
@@ -458,6 +458,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
super::routes::database::RestoreBackupResponse,
|
||||
super::routes::database::DeleteBackupsRequest,
|
||||
super::routes::database::DeleteBackupsResponse,
|
||||
SessionType,
|
||||
Conversation,
|
||||
IconSchema,
|
||||
goose::session::extension_data::ExtensionData,
|
||||
|
||||
@@ -12,12 +12,13 @@ use axum::{
|
||||
use goose::config::PermissionManager;
|
||||
|
||||
use goose::agents::ExtensionConfig;
|
||||
use goose::config::Config;
|
||||
use goose::config::{Config, GooseMode};
|
||||
use goose::model::ModelConfig;
|
||||
use goose::prompt_template::render_global_file;
|
||||
use goose::providers::{create, create_with_named_model};
|
||||
use goose::recipe::Recipe;
|
||||
use goose::recipe_deeplink;
|
||||
use goose::session::session_manager::SessionType;
|
||||
use goose::session::{Session, SessionManager};
|
||||
use goose::{
|
||||
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
|
||||
@@ -137,15 +138,16 @@ async fn start_agent(
|
||||
let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let name = format!("New session {}", counter);
|
||||
|
||||
let mut session = SessionManager::create_session(PathBuf::from(&working_dir), name)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Failed to create session: {}", err);
|
||||
ErrorResponse {
|
||||
message: format!("Failed to create session: {}", err),
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
}
|
||||
})?;
|
||||
let mut session =
|
||||
SessionManager::create_session(PathBuf::from(&working_dir), name, SessionType::User)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Failed to create session: {}", err);
|
||||
ErrorResponse {
|
||||
message: format!("Failed to create session: {}", err),
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(recipe) = original_recipe {
|
||||
SessionManager::update_session(&session.id)
|
||||
@@ -211,15 +213,12 @@ async fn resume_agent(
|
||||
let config = Config::global();
|
||||
|
||||
let provider_result = async {
|
||||
let provider_name: String =
|
||||
config
|
||||
.get_param("GOOSE_PROVIDER")
|
||||
.map_err(|_| ErrorResponse {
|
||||
message: "Could not configure agent: missing provider".into(),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})?;
|
||||
let provider_name: String = config.get_goose_provider().map_err(|_| ErrorResponse {
|
||||
message: "Could not configure agent: missing provider".into(),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})?;
|
||||
|
||||
let model: String = config.get_param("GOOSE_MODEL").map_err(|_| ErrorResponse {
|
||||
let model: String = config.get_goose_model().map_err(|_| ErrorResponse {
|
||||
message: "Could not configure agent: missing model".into(),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
})?;
|
||||
@@ -348,7 +347,7 @@ async fn get_tools(
|
||||
Query(query): Query<GetToolsQuery>,
|
||||
) -> Result<Json<Vec<ToolInfo>>, StatusCode> {
|
||||
let config = Config::global();
|
||||
let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
|
||||
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
|
||||
let agent = state.get_agent_for_route(query.session_id).await?;
|
||||
let permission_manager = PermissionManager::default();
|
||||
|
||||
@@ -360,9 +359,9 @@ async fn get_tools(
|
||||
let permission = permission_manager
|
||||
.get_user_permission(&tool.name)
|
||||
.or_else(|| {
|
||||
if goose_mode == "smart_approve" {
|
||||
if goose_mode == GooseMode::SmartApprove {
|
||||
permission_manager.get_smart_approve_permission(&tool.name)
|
||||
} else if goose_mode == "approve" {
|
||||
} else if goose_mode == GooseMode::Approve {
|
||||
Some(PermissionLevel::AskBefore)
|
||||
} else {
|
||||
None
|
||||
@@ -406,10 +405,7 @@ async fn update_agent_provider(
|
||||
.await?;
|
||||
|
||||
let config = Config::global();
|
||||
let model = match payload
|
||||
.model
|
||||
.or_else(|| config.get_param("GOOSE_MODEL").ok())
|
||||
{
|
||||
let model = match payload.model.or_else(|| config.get_goose_model().ok()) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::error!("No model specified");
|
||||
|
||||
@@ -251,7 +251,7 @@ async fn transcribe_elevenlabs_handler(
|
||||
// Migrate to secret storage
|
||||
if let Err(e) = config.set(
|
||||
"ELEVENLABS_API_KEY",
|
||||
serde_json::Value::String(key.clone()),
|
||||
&serde_json::Value::String(key.clone()),
|
||||
true,
|
||||
) {
|
||||
tracing::error!("Failed to migrate ElevenLabs API key: {:?}", e);
|
||||
|
||||
@@ -88,6 +88,19 @@ pub struct UpdateCustomProviderRequest {
|
||||
pub supports_streaming: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MaskedSecret {
|
||||
pub masked_value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ConfigValueResponse {
|
||||
Value(Value),
|
||||
MaskedValue(MaskedSecret),
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/config/upsert",
|
||||
@@ -101,7 +114,7 @@ pub async fn upsert_config(
|
||||
Json(query): Json<UpsertConfigQuery>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let config = Config::global();
|
||||
let result = config.set(&query.key, query.value, query.is_secret);
|
||||
let result = config.set(&query.key, &query.value, query.is_secret);
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(Json(Value::String(format!("Upserted key {}", query.key)))),
|
||||
@@ -134,6 +147,22 @@ pub async fn remove_config(Json(query): Json<ConfigKeyQuery>) -> Result<Json<Str
|
||||
}
|
||||
}
|
||||
|
||||
const SECRET_MASK_SHOW_LEN: usize = 8;
|
||||
|
||||
fn mask_secret(secret: Value) -> String {
|
||||
let as_string = match secret {
|
||||
Value::String(s) => s,
|
||||
_ => serde_json::to_string(&secret).unwrap_or_else(|_| secret.to_string()),
|
||||
};
|
||||
|
||||
let chars: Vec<_> = as_string.chars().collect();
|
||||
let show_len = std::cmp::min(chars.len() / 2, SECRET_MASK_SHOW_LEN);
|
||||
let visible: String = chars.iter().take(show_len).collect();
|
||||
let mask = "*".repeat(chars.len() - show_len);
|
||||
|
||||
format!("{}{}", visible, mask)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/config/read",
|
||||
@@ -143,12 +172,14 @@ pub async fn remove_config(Json(query): Json<ConfigKeyQuery>) -> Result<Json<Str
|
||||
(status = 500, description = "Unable to get the configuration value"),
|
||||
)
|
||||
)]
|
||||
pub async fn read_config(Json(query): Json<ConfigKeyQuery>) -> Result<Json<Value>, StatusCode> {
|
||||
pub async fn read_config(
|
||||
Json(query): Json<ConfigKeyQuery>,
|
||||
) -> Result<Json<ConfigValueResponse>, StatusCode> {
|
||||
if query.key == "model-limits" {
|
||||
let limits = ModelConfig::get_all_model_limits();
|
||||
return Ok(Json(
|
||||
return Ok(Json(ConfigValueResponse::Value(
|
||||
serde_json::to_value(limits).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
let config = Config::global();
|
||||
@@ -156,18 +187,14 @@ pub async fn read_config(Json(query): Json<ConfigKeyQuery>) -> Result<Json<Value
|
||||
let response_value = match config.get(&query.key, query.is_secret) {
|
||||
Ok(value) => {
|
||||
if query.is_secret {
|
||||
Value::Bool(true)
|
||||
ConfigValueResponse::MaskedValue(MaskedSecret {
|
||||
masked_value: mask_secret(value),
|
||||
})
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
Err(ConfigError::NotFound(_)) => {
|
||||
if query.is_secret {
|
||||
Value::Bool(false)
|
||||
} else {
|
||||
Value::Null
|
||||
ConfigValueResponse::Value(value)
|
||||
}
|
||||
}
|
||||
Err(ConfigError::NotFound(_)) => ConfigValueResponse::Value(Value::Null),
|
||||
Err(_) => {
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
@@ -245,7 +272,7 @@ pub async fn read_all_config() -> Result<Json<ConfigResponse>, StatusCode> {
|
||||
let config = Config::global();
|
||||
|
||||
let values = config
|
||||
.load_values()
|
||||
.all_values()
|
||||
.map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||
|
||||
Ok(Json(ConfigResponse { config: values }))
|
||||
@@ -482,7 +509,7 @@ pub async fn init_config() -> Result<Json<String>, StatusCode> {
|
||||
|
||||
// Use the shared function to load init-config.yaml
|
||||
match goose::config::base::load_init_config_from_workspace() {
|
||||
Ok(init_values) => match config.save_values(init_values) {
|
||||
Ok(init_values) => match config.initialize_if_empty(init_values) {
|
||||
Ok(_) => Ok(Json("Config initialized successfully".to_string())),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
},
|
||||
@@ -557,7 +584,7 @@ pub async fn recover_config() -> Result<Json<String>, StatusCode> {
|
||||
let config = Config::global();
|
||||
|
||||
// Force a reload which will trigger recovery if needed
|
||||
match config.load_values() {
|
||||
match config.all_values() {
|
||||
Ok(values) => {
|
||||
let recovered_keys: Vec<String> = values.keys().cloned().collect();
|
||||
if recovered_keys.is_empty() {
|
||||
@@ -752,10 +779,12 @@ mod tests {
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let response = result.unwrap();
|
||||
let response = match result.unwrap().0 {
|
||||
ConfigValueResponse::Value(value) => value,
|
||||
ConfigValueResponse::MaskedValue(_) => panic!("unexpected secret"),
|
||||
};
|
||||
|
||||
let limits: Vec<goose::model::ModelLimitConfig> =
|
||||
serde_json::from_value(response.0).unwrap();
|
||||
let limits: Vec<goose::model::ModelLimitConfig> = serde_json::from_value(response).unwrap();
|
||||
assert!(!limits.is_empty());
|
||||
|
||||
let gpt4_limit = limits.iter().find(|l| l.pattern == "gpt-4o");
|
||||
|
||||
@@ -257,17 +257,30 @@ pub async fn reply(
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session_id.clone(),
|
||||
working_dir: session.working_dir.clone(),
|
||||
schedule_id: session.schedule_id.clone(),
|
||||
execution_mode: None,
|
||||
max_turns: None,
|
||||
retry_config: None,
|
||||
};
|
||||
|
||||
let user_message = match messages.last() {
|
||||
Some(msg) => msg,
|
||||
_ => {
|
||||
let _ = stream_event(
|
||||
MessageEvent::Error {
|
||||
error: "Reply started with empty messages".to_string(),
|
||||
},
|
||||
&task_tx,
|
||||
&task_cancel,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut stream = match agent
|
||||
.reply(
|
||||
messages.clone(),
|
||||
Some(session_config.clone()),
|
||||
user_message.clone(),
|
||||
session_config,
|
||||
Some(task_cancel.clone()),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -115,7 +115,6 @@ async fn create_schedule(
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: req.execution_mode.or(Some("background".to_string())), // Default to background
|
||||
};
|
||||
scheduler
|
||||
.add_scheduled_job(job.clone())
|
||||
|
||||
@@ -20,7 +20,7 @@ struct LogEntry {
|
||||
fn parse_log_line(line: &str) -> Option<LogEntry> {
|
||||
line.find(": ").and_then(|pos| {
|
||||
let (prefix, content) = line.split_at(pos);
|
||||
let content = &content[2..]; // Skip ": "
|
||||
let content = content.get(2..)?; // Skip ": "
|
||||
|
||||
let stream_type = match prefix {
|
||||
"STDIN" => StreamType::Stdin,
|
||||
|
||||
@@ -108,6 +108,7 @@ schemars = { version = "1.0.4", default-features = false, features = ["derive"]
|
||||
insta = "1.43.2"
|
||||
paste = "1.0.0"
|
||||
shellexpand = "3.1.1"
|
||||
indexmap = "2.12.0"
|
||||
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use dotenvy::dotenv;
|
||||
use futures::StreamExt;
|
||||
use goose::agents::{Agent, AgentEvent, ExtensionConfig};
|
||||
use goose::agents::{Agent, AgentEvent, ExtensionConfig, SessionConfig};
|
||||
use goose::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT};
|
||||
use goose::conversation::message::Message;
|
||||
use goose::conversation::Conversation;
|
||||
use goose::providers::create_with_named_model;
|
||||
use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL;
|
||||
use goose::session::session_manager::SessionType;
|
||||
use goose::session::SessionManager;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -32,11 +34,29 @@ async fn main() {
|
||||
println!(" {}", extension);
|
||||
}
|
||||
|
||||
let conversation = Conversation::new(vec![Message::user()
|
||||
.with_text("can you summarize the readme.md in this dir using just a haiku?")])
|
||||
.unwrap();
|
||||
let session = SessionManager::create_session(
|
||||
PathBuf::default(),
|
||||
"max-turn-test".to_string(),
|
||||
SessionType::Hidden,
|
||||
)
|
||||
.await
|
||||
.expect("session manager creation failed");
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session.id,
|
||||
schedule_id: None,
|
||||
max_turns: None,
|
||||
retry_config: None,
|
||||
};
|
||||
|
||||
let user_message = Message::user()
|
||||
.with_text("can you summarize the readme.md in this dir using just a haiku?");
|
||||
|
||||
let mut stream = agent
|
||||
.reply(user_message, session_config, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut stream = agent.reply(conversation, None, None).await.unwrap();
|
||||
while let Some(Ok(AgentEvent::Message(message))) = stream.next().await {
|
||||
println!("{}", serde_json::to_string_pretty(&message).unwrap());
|
||||
println!("\n");
|
||||
|
||||
+112
-201
@@ -29,7 +29,7 @@ use crate::agents::tool_route_manager::ToolRouteManager;
|
||||
use crate::agents::tool_router_index_manager::ToolRouterIndexManager;
|
||||
use crate::agents::types::SessionConfig;
|
||||
use crate::agents::types::{FrontendTool, SharedProvider, ToolResultReceiver};
|
||||
use crate::config::{get_enabled_extensions, Config};
|
||||
use crate::config::{get_enabled_extensions, Config, GooseMode};
|
||||
use crate::context_mgmt::DEFAULT_COMPACTION_THRESHOLD;
|
||||
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
|
||||
use crate::mcp_utils::ToolResult;
|
||||
@@ -61,11 +61,11 @@ use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DEC
|
||||
use crate::agents::subagent_task_config::TaskConfig;
|
||||
use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest};
|
||||
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
|
||||
use crate::session::SessionManager;
|
||||
use crate::session::{Session, SessionManager};
|
||||
|
||||
const DEFAULT_MAX_TURNS: u32 = 1000;
|
||||
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
|
||||
const MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation";
|
||||
pub const MANUAL_COMPACT_TRIGGER: &str = "Please compact this conversation";
|
||||
|
||||
/// Context needed for the reply function
|
||||
pub struct ReplyContext {
|
||||
@@ -73,9 +73,8 @@ pub struct ReplyContext {
|
||||
pub tools: Vec<Tool>,
|
||||
pub toolshim_tools: Vec<Tool>,
|
||||
pub system_prompt: String,
|
||||
pub goose_mode: String,
|
||||
pub goose_mode: GooseMode,
|
||||
pub initial_messages: Vec<Message>,
|
||||
pub config: &'static Config,
|
||||
}
|
||||
|
||||
pub struct ToolCategorizeResult {
|
||||
@@ -193,7 +192,7 @@ impl Agent {
|
||||
// Add permission inspector (medium-high priority)
|
||||
// Note: mode will be updated dynamically based on session config
|
||||
tool_inspection_manager.add_inspector(Box::new(PermissionInspector::new(
|
||||
"smart_approve".to_string(),
|
||||
GooseMode::SmartApprove,
|
||||
std::collections::HashSet::new(), // readonly tools - will be populated from extension manager
|
||||
std::collections::HashSet::new(), // regular tools - will be populated from extension manager
|
||||
)));
|
||||
@@ -219,16 +218,20 @@ impl Agent {
|
||||
self.retry_manager.get_attempts().await
|
||||
}
|
||||
|
||||
/// Handle retry logic for the agent reply loop
|
||||
async fn handle_retry_logic(
|
||||
&self,
|
||||
messages: &mut Conversation,
|
||||
session: &Option<SessionConfig>,
|
||||
session_config: &SessionConfig,
|
||||
initial_messages: &[Message],
|
||||
) -> Result<bool> {
|
||||
let result = self
|
||||
.retry_manager
|
||||
.handle_retry_logic(messages, session, initial_messages, &self.final_output_tool)
|
||||
.handle_retry_logic(
|
||||
messages,
|
||||
session_config,
|
||||
initial_messages,
|
||||
&self.final_output_tool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match result {
|
||||
@@ -242,7 +245,6 @@ impl Agent {
|
||||
async fn prepare_reply_context(
|
||||
&self,
|
||||
unfixed_conversation: Conversation,
|
||||
session: &Option<SessionConfig>,
|
||||
) -> Result<ReplyContext> {
|
||||
let unfixed_messages = unfixed_conversation.messages().clone();
|
||||
let (conversation, issues) = fix_conversation(unfixed_conversation.clone());
|
||||
@@ -260,11 +262,10 @@ impl Agent {
|
||||
let config = Config::global();
|
||||
|
||||
let (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt().await?;
|
||||
let goose_mode = Self::determine_goose_mode(session.as_ref(), config);
|
||||
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
|
||||
|
||||
// Update permission inspector mode to match the session mode
|
||||
self.tool_inspection_manager
|
||||
.update_permission_inspector_mode(goose_mode.clone())
|
||||
.update_permission_inspector_mode(goose_mode)
|
||||
.await;
|
||||
|
||||
Ok(ReplyContext {
|
||||
@@ -274,7 +275,6 @@ impl Agent {
|
||||
system_prompt,
|
||||
goose_mode,
|
||||
initial_messages,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ impl Agent {
|
||||
permission_check_result: &PermissionCheckResult,
|
||||
message_tool_response: Arc<Mutex<Message>>,
|
||||
cancel_token: Option<tokio_util::sync::CancellationToken>,
|
||||
session: Option<SessionConfig>,
|
||||
session: &Session,
|
||||
) -> Result<Vec<(String, ToolStream)>> {
|
||||
let mut tool_futures: Vec<(String, ToolStream)> = Vec::new();
|
||||
|
||||
@@ -311,7 +311,7 @@ impl Agent {
|
||||
tool_call,
|
||||
request.id.clone(),
|
||||
cancel_token.clone(),
|
||||
session.clone(),
|
||||
session,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -392,7 +392,7 @@ impl Agent {
|
||||
tool_call: CallToolRequestParam,
|
||||
request_id: String,
|
||||
cancellation_token: Option<CancellationToken>,
|
||||
session: Option<SessionConfig>,
|
||||
session: &Session,
|
||||
) -> (String, Result<ToolCallResult, ErrorData>) {
|
||||
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
|
||||
let arguments = tool_call
|
||||
@@ -451,17 +451,13 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
};
|
||||
let (parent_session_id, parent_working_dir) = match session.as_ref() {
|
||||
Some(s) => (Some(s.id.clone()), s.working_dir.clone()),
|
||||
None => (None, std::env::current_dir().unwrap_or_default()),
|
||||
};
|
||||
|
||||
// Get extensions from the agent's runtime state rather than global config
|
||||
// This ensures subagents inherit extensions that were dynamically enabled by the parent
|
||||
let extensions = self.get_extension_configs().await;
|
||||
|
||||
let task_config =
|
||||
TaskConfig::new(provider, parent_session_id, parent_working_dir, extensions);
|
||||
TaskConfig::new(provider, &session.id, &session.working_dir, extensions);
|
||||
|
||||
let arguments = match tool_call.arguments.clone() {
|
||||
Some(args) => Value::Object(args),
|
||||
@@ -731,118 +727,110 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, unfixed_conversation, session), fields(user_message))]
|
||||
#[instrument(skip(self, user_message, session_config), fields(user_message))]
|
||||
pub async fn reply(
|
||||
&self,
|
||||
unfixed_conversation: Conversation,
|
||||
session: Option<SessionConfig>,
|
||||
user_message: Message,
|
||||
session_config: SessionConfig,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
|
||||
let is_manual_compact = unfixed_conversation.messages().last().is_some_and(|msg| {
|
||||
msg.content.iter().any(|c| {
|
||||
if let MessageContent::Text(text) = c {
|
||||
text.text.trim() == MANUAL_COMPACT_TRIGGER
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
let is_manual_compact = user_message.content.iter().any(|c| {
|
||||
if let MessageContent::Text(text) = c {
|
||||
text.text.trim() == MANUAL_COMPACT_TRIGGER
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if !is_manual_compact {
|
||||
let session_metadata = if let Some(session_config) = &session {
|
||||
SessionManager::get_session(&session_config.id, false)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
SessionManager::add_message(&session_config.id, &user_message).await?;
|
||||
let session = SessionManager::get_session(&session_config.id, true).await?;
|
||||
|
||||
let needs_auto_compact = crate::context_mgmt::check_if_compaction_needed(
|
||||
self,
|
||||
&unfixed_conversation,
|
||||
None,
|
||||
session_metadata.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let conversation = session
|
||||
.conversation
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Session {} has no conversation", session_config.id))?;
|
||||
|
||||
if !needs_auto_compact {
|
||||
return self
|
||||
.reply_internal(unfixed_conversation, session, cancel_token)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let needs_auto_compact =
|
||||
crate::context_mgmt::check_if_compaction_needed(self, &conversation, None, &session)
|
||||
.await?;
|
||||
|
||||
let conversation_to_compact = unfixed_conversation.clone();
|
||||
let conversation_to_compact = conversation.clone();
|
||||
|
||||
Ok(Box::pin(async_stream::try_stream! {
|
||||
if !is_manual_compact {
|
||||
let config = crate::config::Config::global();
|
||||
let threshold = config
|
||||
.get_param::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
|
||||
.unwrap_or(DEFAULT_COMPACTION_THRESHOLD);
|
||||
let threshold_percentage = (threshold * 100.0) as u32;
|
||||
let final_conversation = if !needs_auto_compact {
|
||||
conversation
|
||||
} else {
|
||||
if !is_manual_compact {
|
||||
let config = crate::config::Config::global();
|
||||
let threshold = config
|
||||
.get_param::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
|
||||
.unwrap_or(DEFAULT_COMPACTION_THRESHOLD);
|
||||
let threshold_percentage = (threshold * 100.0) as u32;
|
||||
|
||||
let inline_msg = format!(
|
||||
"Exceeded auto-compact threshold of {}%. Performing auto-compaction...",
|
||||
threshold_percentage
|
||||
);
|
||||
|
||||
yield AgentEvent::Message(
|
||||
Message::assistant().with_system_notification(
|
||||
SystemNotificationType::InlineMessage,
|
||||
inline_msg,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
yield AgentEvent::Message(
|
||||
Message::assistant().with_system_notification(
|
||||
SystemNotificationType::ThinkingMessage,
|
||||
COMPACTION_THINKING_TEXT,
|
||||
)
|
||||
);
|
||||
|
||||
match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await {
|
||||
Ok((compacted_conversation, _token_counts, _summarization_usage)) => {
|
||||
if let Some(session_to_store) = &session {
|
||||
SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?;
|
||||
}
|
||||
|
||||
yield AgentEvent::HistoryReplaced(compacted_conversation.clone());
|
||||
let inline_msg = format!(
|
||||
"Exceeded auto-compact threshold of {}%. Performing auto-compaction...",
|
||||
threshold_percentage
|
||||
);
|
||||
|
||||
yield AgentEvent::Message(
|
||||
Message::assistant().with_system_notification(
|
||||
SystemNotificationType::InlineMessage,
|
||||
"Compaction complete",
|
||||
inline_msg,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if !is_manual_compact {
|
||||
let mut reply_stream = self.reply_internal(compacted_conversation, session, cancel_token).await?;
|
||||
while let Some(event) = reply_stream.next().await {
|
||||
yield event?;
|
||||
}
|
||||
yield AgentEvent::Message(
|
||||
Message::assistant().with_system_notification(
|
||||
SystemNotificationType::ThinkingMessage,
|
||||
COMPACTION_THINKING_TEXT,
|
||||
)
|
||||
);
|
||||
|
||||
match crate::context_mgmt::compact_messages(self, &conversation_to_compact, false).await {
|
||||
Ok((compacted_conversation, summarization_usage)) => {
|
||||
SessionManager::replace_conversation(&session_config.id, &compacted_conversation).await?;
|
||||
Self::update_session_metrics(&session_config, &summarization_usage, true).await?;
|
||||
|
||||
yield AgentEvent::HistoryReplaced(compacted_conversation.clone());
|
||||
|
||||
yield AgentEvent::Message(
|
||||
Message::assistant().with_system_notification(
|
||||
SystemNotificationType::InlineMessage,
|
||||
"Compaction complete",
|
||||
)
|
||||
);
|
||||
|
||||
compacted_conversation
|
||||
}
|
||||
Err(e) => {
|
||||
yield AgentEvent::Message(
|
||||
Message::assistant().with_text(
|
||||
format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session")
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
yield AgentEvent::Message(
|
||||
Message::assistant().with_text(
|
||||
format!("Ran into this error trying to compact: {e}.\n\nPlease try again or create a new session")
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if !is_manual_compact {
|
||||
let mut reply_stream = self.reply_internal(final_conversation, session_config, session, cancel_token).await?;
|
||||
while let Some(event) = reply_stream.next().await {
|
||||
yield event?;
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Main reply method that handles the actual agent processing
|
||||
async fn reply_internal(
|
||||
&self,
|
||||
conversation: Conversation,
|
||||
session: Option<SessionConfig>,
|
||||
session_config: SessionConfig,
|
||||
session: Session,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
|
||||
let context = self.prepare_reply_context(conversation, &session).await?;
|
||||
let context = self.prepare_reply_context(conversation).await?;
|
||||
let ReplyContext {
|
||||
mut conversation,
|
||||
mut tools,
|
||||
@@ -850,66 +838,22 @@ impl Agent {
|
||||
mut system_prompt,
|
||||
goose_mode,
|
||||
initial_messages,
|
||||
config,
|
||||
} = context;
|
||||
let reply_span = tracing::Span::current();
|
||||
self.reset_retry_attempts().await;
|
||||
|
||||
// This will need further refactoring. In the ideal world we pass the new message into
|
||||
// reply and load the existing conversation. Until we get to that point, fetch the conversation
|
||||
// so far and append the last (user) message that the caller already added.
|
||||
if let Some(session_config) = &session {
|
||||
let stored_conversation = SessionManager::get_session(&session_config.id, true)
|
||||
.await?
|
||||
.conversation
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Session {} has no conversation", session_config.id)
|
||||
})?;
|
||||
|
||||
match conversation.len().cmp(&stored_conversation.len()) {
|
||||
std::cmp::Ordering::Equal => {
|
||||
if conversation != stored_conversation {
|
||||
warn!("Session messages mismatch - replacing with incoming");
|
||||
SessionManager::replace_conversation(&session_config.id, &conversation)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Greater
|
||||
if conversation.len() == stored_conversation.len() + 1 =>
|
||||
{
|
||||
let last_message = conversation.last().unwrap();
|
||||
if let Some(content) = last_message.content.first().and_then(|c| c.as_text()) {
|
||||
debug!("user_message" = &content);
|
||||
}
|
||||
SessionManager::add_message(&session_config.id, last_message).await?;
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
"Unexpected session state: stored={}, incoming={}. Replacing.",
|
||||
stored_conversation.len(),
|
||||
conversation.len()
|
||||
);
|
||||
SessionManager::replace_conversation(&session_config.id, &conversation).await?;
|
||||
}
|
||||
let provider = self.provider().await?;
|
||||
let session_id = session_config.id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = SessionManager::maybe_update_name(&session_id, provider).await {
|
||||
warn!("Failed to generate session description: {}", e);
|
||||
}
|
||||
let provider = self.provider().await?;
|
||||
let session_id = session_config.id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = SessionManager::maybe_update_name(&session_id, provider).await {
|
||||
warn!("Failed to generate session description: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(async_stream::try_stream! {
|
||||
let _ = reply_span.enter();
|
||||
let mut turns_taken = 0u32;
|
||||
let max_turns = session
|
||||
.as_ref()
|
||||
.and_then(|s| s.max_turns)
|
||||
.unwrap_or_else(|| {
|
||||
config.get_param("GOOSE_MAX_TURNS").unwrap_or(DEFAULT_MAX_TURNS)
|
||||
});
|
||||
let max_turns = session_config.max_turns.unwrap_or(DEFAULT_MAX_TURNS);
|
||||
|
||||
loop {
|
||||
if is_token_cancelled(&cancel_token) {
|
||||
@@ -990,11 +934,8 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Record usage for the session
|
||||
if let Some(ref session_config) = &session {
|
||||
if let Some(ref usage) = usage {
|
||||
Self::update_session_metrics(session_config, usage).await?;
|
||||
}
|
||||
if let Some(ref usage) = usage {
|
||||
Self::update_session_metrics(&session_config, usage, false).await?;
|
||||
}
|
||||
|
||||
if let Some(response) = response {
|
||||
@@ -1030,8 +971,7 @@ impl Agent {
|
||||
yield AgentEvent::Message(msg);
|
||||
}
|
||||
|
||||
let mode = goose_mode.clone();
|
||||
if mode.as_str() == "chat" {
|
||||
if goose_mode == GooseMode::Chat {
|
||||
// Skip all tool calls in chat mode
|
||||
for request in remaining_requests {
|
||||
let mut response = message_tool_response.lock().await;
|
||||
@@ -1080,18 +1020,17 @@ impl Agent {
|
||||
&permission_check_result,
|
||||
message_tool_response.clone(),
|
||||
cancel_token.clone(),
|
||||
session.clone(),
|
||||
&session,
|
||||
).await?;
|
||||
|
||||
let tool_futures_arc = Arc::new(Mutex::new(tool_futures));
|
||||
|
||||
// Process tools requiring approval
|
||||
let mut tool_approval_stream = self.handle_approval_tool_requests(
|
||||
&permission_check_result.needs_approval,
|
||||
tool_futures_arc.clone(),
|
||||
message_tool_response.clone(),
|
||||
cancel_token.clone(),
|
||||
session.clone(),
|
||||
&session,
|
||||
&inspection_results,
|
||||
);
|
||||
|
||||
@@ -1138,10 +1077,8 @@ impl Agent {
|
||||
}
|
||||
|
||||
if all_install_successful && !enable_extension_request_ids.is_empty() {
|
||||
if let Some(ref session_config) = session {
|
||||
if let Err(e) = self.save_extension_state(session_config).await {
|
||||
warn!("Failed to save extension state after runtime changes: {}", e);
|
||||
}
|
||||
if let Err(e) = self.save_extension_state(&session_config).await {
|
||||
warn!("Failed to save extension state after runtime changes: {}", e);
|
||||
}
|
||||
tools_updated = true;
|
||||
}
|
||||
@@ -1169,14 +1106,11 @@ impl Agent {
|
||||
);
|
||||
|
||||
match crate::context_mgmt::compact_messages(self, &conversation, true).await {
|
||||
Ok((compacted_conversation, _token_counts, _usage)) => {
|
||||
if let Some(session_to_store) = &session {
|
||||
SessionManager::replace_conversation(&session_to_store.id, &compacted_conversation).await?
|
||||
}
|
||||
|
||||
Ok((compacted_conversation, usage)) => {
|
||||
SessionManager::replace_conversation(&session_config.id, &compacted_conversation).await?;
|
||||
Self::update_session_metrics(&session_config, &usage, true).await?;
|
||||
conversation = compacted_conversation;
|
||||
did_recovery_compact_this_iteration = true;
|
||||
|
||||
yield AgentEvent::HistoryReplaced(conversation.clone());
|
||||
continue;
|
||||
}
|
||||
@@ -1222,7 +1156,7 @@ impl Agent {
|
||||
} else if did_recovery_compact_this_iteration {
|
||||
// Avoid setting exit_chat; continue from last user message in the conversation
|
||||
} else {
|
||||
match self.handle_retry_logic(&mut conversation, &session, &initial_messages).await {
|
||||
match self.handle_retry_logic(&mut conversation, &session_config, &initial_messages).await {
|
||||
Ok(should_retry) => {
|
||||
if should_retry {
|
||||
info!("Retry logic triggered, restarting agent loop");
|
||||
@@ -1243,10 +1177,8 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(session_config) = &session {
|
||||
for msg in &messages_to_add {
|
||||
SessionManager::add_message(&session_config.id, msg).await?;
|
||||
}
|
||||
for msg in &messages_to_add {
|
||||
SessionManager::add_message(&session_config.id, msg).await?;
|
||||
}
|
||||
conversation.extend(messages_to_add);
|
||||
if exit_chat {
|
||||
@@ -1258,19 +1190,6 @@ impl Agent {
|
||||
}))
|
||||
}
|
||||
|
||||
fn determine_goose_mode(session: Option<&SessionConfig>, config: &Config) -> String {
|
||||
let mode = session.and_then(|s| s.execution_mode.as_deref());
|
||||
|
||||
match mode {
|
||||
Some("foreground") => "chat".to_string(),
|
||||
Some("background") => "auto".to_string(),
|
||||
_ => config
|
||||
.get_param("GOOSE_MODE")
|
||||
.unwrap_or_else(|_| "auto".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend the system prompt with one line of additional instruction
|
||||
pub async fn extend_system_prompt(&self, instruction: String) {
|
||||
let mut prompt_manager = self.prompt_manager.lock().await;
|
||||
prompt_manager.add_system_prompt_extra(instruction);
|
||||
@@ -1446,17 +1365,9 @@ impl Agent {
|
||||
.unwrap_or(&content)
|
||||
.trim()
|
||||
.to_string();
|
||||
tracing::debug!(
|
||||
"Cleaned content for parsing: {}",
|
||||
&clean_content[..std::cmp::min(200, clean_content.len())]
|
||||
);
|
||||
|
||||
// try to parse json response from the LLM
|
||||
tracing::debug!("Attempting to parse recipe content as JSON");
|
||||
let (instructions, activities) =
|
||||
if let Ok(json_content) = serde_json::from_str::<Value>(&clean_content) {
|
||||
tracing::debug!("Successfully parsed JSON content");
|
||||
|
||||
let instructions = json_content
|
||||
.get("instructions")
|
||||
.ok_or_else(|| anyhow!("Missing 'instructions' in json response"))?
|
||||
@@ -1525,7 +1436,7 @@ impl Agent {
|
||||
// but it doesn't know and the plumbing looks complicated.
|
||||
let config = Config::global();
|
||||
let provider_name: String = config
|
||||
.get_param("GOOSE_PROVIDER")
|
||||
.get_goose_provider()
|
||||
.expect("No provider configured. Run 'goose configure' first");
|
||||
|
||||
let settings = Settings {
|
||||
|
||||
@@ -215,6 +215,8 @@ pub enum ExtensionConfig {
|
||||
Sse {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[schema(required)]
|
||||
description: String,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
@@ -234,6 +236,8 @@ pub enum ExtensionConfig {
|
||||
Stdio {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[schema(required)]
|
||||
description: String,
|
||||
cmd: String,
|
||||
args: Vec<String>,
|
||||
@@ -252,6 +256,8 @@ pub enum ExtensionConfig {
|
||||
Builtin {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[schema(required)]
|
||||
description: String,
|
||||
display_name: Option<String>, // needed for the UI
|
||||
timeout: Option<u64>,
|
||||
@@ -265,6 +271,8 @@ pub enum ExtensionConfig {
|
||||
Platform {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[schema(required)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
@@ -276,6 +284,8 @@ pub enum ExtensionConfig {
|
||||
StreamableHttp {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[schema(required)]
|
||||
description: String,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
@@ -297,6 +307,8 @@ pub enum ExtensionConfig {
|
||||
Frontend {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[schema(required)]
|
||||
description: String,
|
||||
/// The tools provided by the frontend
|
||||
tools: Vec<Tool>,
|
||||
@@ -312,6 +324,8 @@ pub enum ExtensionConfig {
|
||||
InlinePython {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
#[schema(required)]
|
||||
description: String,
|
||||
/// The Python code to execute
|
||||
code: String,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::agents::types::SharedProvider;
|
||||
use crate::session_context::SESSION_ID_HEADER;
|
||||
use rmcp::model::{Content, ErrorCode, JsonObject};
|
||||
/// MCP client implementation for Goose
|
||||
use rmcp::{
|
||||
@@ -334,7 +335,7 @@ impl McpClientTrait for McpClient {
|
||||
ClientRequest::ListResourcesRequest(ListResourcesRequest {
|
||||
params: Some(PaginatedRequestParam { cursor }),
|
||||
method: Default::default(),
|
||||
extensions: Default::default(),
|
||||
extensions: inject_session_into_extensions(Default::default()),
|
||||
}),
|
||||
cancel_token,
|
||||
)
|
||||
@@ -358,7 +359,7 @@ impl McpClientTrait for McpClient {
|
||||
uri: uri.to_string(),
|
||||
},
|
||||
method: Default::default(),
|
||||
extensions: Default::default(),
|
||||
extensions: inject_session_into_extensions(Default::default()),
|
||||
}),
|
||||
cancel_token,
|
||||
)
|
||||
@@ -380,7 +381,7 @@ impl McpClientTrait for McpClient {
|
||||
ClientRequest::ListToolsRequest(ListToolsRequest {
|
||||
params: Some(PaginatedRequestParam { cursor }),
|
||||
method: Default::default(),
|
||||
extensions: Default::default(),
|
||||
extensions: inject_session_into_extensions(Default::default()),
|
||||
}),
|
||||
cancel_token,
|
||||
)
|
||||
@@ -406,7 +407,7 @@ impl McpClientTrait for McpClient {
|
||||
arguments,
|
||||
},
|
||||
method: Default::default(),
|
||||
extensions: Default::default(),
|
||||
extensions: inject_session_into_extensions(Default::default()),
|
||||
}),
|
||||
cancel_token,
|
||||
)
|
||||
@@ -428,7 +429,7 @@ impl McpClientTrait for McpClient {
|
||||
ClientRequest::ListPromptsRequest(ListPromptsRequest {
|
||||
params: Some(PaginatedRequestParam { cursor }),
|
||||
method: Default::default(),
|
||||
extensions: Default::default(),
|
||||
extensions: inject_session_into_extensions(Default::default()),
|
||||
}),
|
||||
cancel_token,
|
||||
)
|
||||
@@ -458,7 +459,7 @@ impl McpClientTrait for McpClient {
|
||||
arguments,
|
||||
},
|
||||
method: Default::default(),
|
||||
extensions: Default::default(),
|
||||
extensions: inject_session_into_extensions(Default::default()),
|
||||
}),
|
||||
cancel_token,
|
||||
)
|
||||
@@ -476,3 +477,118 @@ impl McpClientTrait for McpClient {
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces session ID, case-insensitively, in Extensions._meta.
|
||||
fn inject_session_into_extensions(
|
||||
mut extensions: rmcp::model::Extensions,
|
||||
) -> rmcp::model::Extensions {
|
||||
use rmcp::model::Meta;
|
||||
|
||||
if let Some(session_id) = crate::session_context::current_session_id() {
|
||||
let mut meta_map = extensions
|
||||
.get::<Meta>()
|
||||
.map(|meta| meta.0.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// JsonObject is case-sensitive, so we use retain for case-insensitive removal
|
||||
meta_map.retain(|k, _| !k.eq_ignore_ascii_case(SESSION_ID_HEADER));
|
||||
|
||||
meta_map.insert(SESSION_ID_HEADER.to_string(), Value::String(session_id));
|
||||
|
||||
extensions.insert(Meta(meta_map));
|
||||
}
|
||||
|
||||
extensions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rmcp::model::Meta;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_in_mcp_meta() {
|
||||
use serde_json::json;
|
||||
|
||||
let session_id = "test-session-789";
|
||||
crate::session_context::with_session_id(Some(session_id.to_string()), async {
|
||||
let extensions = inject_session_into_extensions(Default::default());
|
||||
let meta = extensions.get::<Meta>().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
&meta.0,
|
||||
json!({
|
||||
SESSION_ID_HEADER: session_id
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_session_id_in_mcp_when_absent() {
|
||||
let extensions = inject_session_into_extensions(Default::default());
|
||||
let meta = extensions.get::<Meta>();
|
||||
|
||||
assert!(meta.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_mcp_operations_include_session() {
|
||||
use serde_json::json;
|
||||
|
||||
let session_id = "consistent-session-id";
|
||||
crate::session_context::with_session_id(Some(session_id.to_string()), async {
|
||||
let ext1 = inject_session_into_extensions(Default::default());
|
||||
let ext2 = inject_session_into_extensions(Default::default());
|
||||
let ext3 = inject_session_into_extensions(Default::default());
|
||||
|
||||
for ext in [&ext1, &ext2, &ext3] {
|
||||
assert_eq!(
|
||||
&ext.get::<Meta>().unwrap().0,
|
||||
json!({
|
||||
SESSION_ID_HEADER: session_id
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_case_insensitive_replacement() {
|
||||
use rmcp::model::{Extensions, Meta};
|
||||
use serde_json::{from_value, json};
|
||||
|
||||
let session_id = "new-session-id";
|
||||
crate::session_context::with_session_id(Some(session_id.to_string()), async {
|
||||
let mut extensions = Extensions::new();
|
||||
extensions.insert(
|
||||
from_value::<Meta>(json!({
|
||||
"GOOSE-SESSION-ID": "old-session-1",
|
||||
"Goose-Session-Id": "old-session-2",
|
||||
"other-key": "preserve-me"
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let extensions = inject_session_into_extensions(extensions);
|
||||
let meta = extensions.get::<Meta>().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
&meta.0,
|
||||
json!({
|
||||
SESSION_ID_HEADER: session_id,
|
||||
"other-key": "preserve-me"
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod tool_route_manager;
|
||||
mod tool_router_index_manager;
|
||||
pub mod types;
|
||||
|
||||
pub use agent::{Agent, AgentEvent};
|
||||
pub use agent::{Agent, AgentEvent, MANUAL_COMPACT_TRIGGER};
|
||||
pub use extension::ExtensionConfig;
|
||||
pub use extension_manager::ExtensionManager;
|
||||
pub use prompt_manager::PromptManager;
|
||||
|
||||
@@ -33,7 +33,6 @@ pub fn manage_schedule_tool() -> Tool {
|
||||
"job_id": {"type": "string", "description": "Job identifier for operations on existing jobs"},
|
||||
"recipe_path": {"type": "string", "description": "Path to recipe file for create action"},
|
||||
"cron_expression": {"type": "string", "description": "A cron expression for create action. Supports both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats. 5-field expressions are automatically converted to 6-field by prepending '0' for seconds."},
|
||||
"execution_mode": {"type": "string", "description": "Execution mode for create action: 'foreground' or 'background'", "enum": ["foreground", "background"], "default": "background"},
|
||||
"limit": {"type": "integer", "description": "Limit for sessions list", "default": 50},
|
||||
"session_id": {"type": "string", "description": "Session identifier for session_content action"}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@ use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::agents::extension::ExtensionInfo;
|
||||
use crate::agents::recipe_tools::dynamic_task_tools::should_enabled_subagents;
|
||||
use crate::agents::router_tools::llm_search_tool_prompt;
|
||||
use crate::{config::Config, prompt_template, utils::sanitize_unicode_tags};
|
||||
use crate::{
|
||||
config::{Config, GooseMode},
|
||||
prompt_template,
|
||||
utils::sanitize_unicode_tags,
|
||||
};
|
||||
|
||||
const MAX_EXTENSIONS: usize = 5;
|
||||
const MAX_TOOLS: usize = 50;
|
||||
@@ -34,7 +37,7 @@ struct SystemPromptContext {
|
||||
current_date_time: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
extension_tool_limits: Option<(usize, usize)>,
|
||||
goose_mode: String,
|
||||
goose_mode: GooseMode,
|
||||
is_autonomous: bool,
|
||||
enable_subagents: bool,
|
||||
max_extensions: usize,
|
||||
@@ -106,9 +109,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
|
||||
.collect();
|
||||
|
||||
let config = Config::global();
|
||||
let goose_mode = config
|
||||
.get_param("GOOSE_MODE")
|
||||
.unwrap_or_else(|_| Cow::from("auto"));
|
||||
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
|
||||
|
||||
let extension_tool_limits = self
|
||||
.extension_tool_count
|
||||
@@ -119,8 +120,8 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
|
||||
tool_selection_strategy: self.router_enabled.then(llm_search_tool_prompt),
|
||||
current_date_time: self.manager.current_date_timestamp.clone(),
|
||||
extension_tool_limits,
|
||||
goose_mode: goose_mode.to_string(),
|
||||
is_autonomous: goose_mode == "auto",
|
||||
goose_mode,
|
||||
is_autonomous: goose_mode == GooseMode::Auto,
|
||||
enable_subagents: should_enabled_subagents(self.model_name.as_str()),
|
||||
max_extensions: MAX_EXTENSIONS,
|
||||
max_tools: MAX_TOOLS,
|
||||
@@ -137,7 +138,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
|
||||
});
|
||||
|
||||
let mut system_prompt_extras = self.manager.system_prompt_extras.clone();
|
||||
if goose_mode == "chat" {
|
||||
if goose_mode == GooseMode::Chat {
|
||||
system_prompt_extras.push(
|
||||
"Right now you are in the chat only mode, no access to any tool use and system."
|
||||
.to_string(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::agents::subagent_execution_tool::{
|
||||
task_types::{Task, TaskType},
|
||||
};
|
||||
use crate::agents::tool_execution::ToolCallResult;
|
||||
use crate::config::GooseMode;
|
||||
use crate::recipe::{Recipe, RecipeBuilder};
|
||||
use anyhow::{anyhow, Result};
|
||||
use rmcp::model::{Content, ErrorCode, ErrorData, Tool, ToolAnnotations};
|
||||
@@ -93,7 +94,7 @@ pub struct TaskParameter {
|
||||
|
||||
pub fn should_enabled_subagents(model_name: &str) -> bool {
|
||||
let config = crate::config::Config::global();
|
||||
let is_autonomous = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string()) == "auto";
|
||||
let is_autonomous = config.get_goose_mode().unwrap_or(GooseMode::Auto) == GooseMode::Auto;
|
||||
if !is_autonomous {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ impl Agent {
|
||||
pub(crate) async fn update_session_metrics(
|
||||
session_config: &crate::agents::types::SessionConfig,
|
||||
usage: &ProviderUsage,
|
||||
is_compaction_usage: bool,
|
||||
) -> Result<()> {
|
||||
let session_id = session_config.id.as_str();
|
||||
let session = SessionManager::get_session(session_id, false).await?;
|
||||
@@ -273,11 +274,23 @@ impl Agent {
|
||||
let accumulated_output =
|
||||
accumulate(session.accumulated_output_tokens, usage.usage.output_tokens);
|
||||
|
||||
let (current_total, current_input, current_output) = if is_compaction_usage {
|
||||
// After compaction: summary output becomes new input context
|
||||
let new_input = usage.usage.output_tokens;
|
||||
(new_input, new_input, None)
|
||||
} else {
|
||||
(
|
||||
usage.usage.total_tokens,
|
||||
usage.usage.input_tokens,
|
||||
usage.usage.output_tokens,
|
||||
)
|
||||
};
|
||||
|
||||
SessionManager::update_session(session_id)
|
||||
.schedule_id(session_config.schedule_id.clone())
|
||||
.total_tokens(usage.usage.total_tokens)
|
||||
.input_tokens(usage.usage.input_tokens)
|
||||
.output_tokens(usage.usage.output_tokens)
|
||||
.total_tokens(current_total)
|
||||
.input_tokens(current_input)
|
||||
.output_tokens(current_output)
|
||||
.accumulated_total_tokens(accumulated_total)
|
||||
.accumulated_input_tokens(accumulated_input)
|
||||
.accumulated_output_tokens(accumulated_output)
|
||||
|
||||
@@ -108,18 +108,13 @@ impl RetryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle retry logic for the agent reply loop
|
||||
pub async fn handle_retry_logic(
|
||||
&self,
|
||||
messages: &mut Conversation,
|
||||
session: &Option<SessionConfig>,
|
||||
session_config: &SessionConfig,
|
||||
initial_messages: &[Message],
|
||||
final_output_tool: &Arc<Mutex<Option<crate::agents::final_output_tool::FinalOutputTool>>>,
|
||||
) -> Result<RetryResult> {
|
||||
let Some(session_config) = session else {
|
||||
return Ok(RetryResult::Skipped);
|
||||
};
|
||||
|
||||
let Some(retry_config) = &session_config.retry_config else {
|
||||
return Ok(RetryResult::Skipped);
|
||||
};
|
||||
|
||||
@@ -186,7 +186,6 @@ impl Agent {
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: Some(execution_mode.to_string()),
|
||||
};
|
||||
|
||||
match scheduler.add_scheduled_job(job).await {
|
||||
|
||||
@@ -249,7 +249,7 @@ fn extract_json_from_line(line: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let potential_json = &line[start..=end];
|
||||
let potential_json = line.get(start..=end)?;
|
||||
if serde_json::from_str::<Value>(potential_json).is_ok() {
|
||||
Some(potential_json.to_string())
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::session::session_manager::SessionType;
|
||||
use crate::{
|
||||
agents::{
|
||||
extension::PlatformExtensionContext, subagent_task_config::TaskConfig, Agent, AgentEvent,
|
||||
SessionConfig,
|
||||
},
|
||||
agents::{subagent_task_config::TaskConfig, AgentEvent, SessionConfig},
|
||||
conversation::{message::Message, Conversation},
|
||||
execution::manager::AgentManager,
|
||||
session::SessionManager,
|
||||
@@ -10,8 +8,8 @@ use crate::{
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures::StreamExt;
|
||||
use rmcp::model::{ErrorCode, ErrorData};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::{future::Future, sync::Arc};
|
||||
use tracing::debug;
|
||||
|
||||
/// Standalone function to run a complete subagent task with output options
|
||||
@@ -104,34 +102,18 @@ fn get_agent_messages(
|
||||
.map_err(|e| anyhow!("Failed to create AgentManager: {}", e))?;
|
||||
let parent_session_id = task_config.parent_session_id;
|
||||
let working_dir = task_config.parent_working_dir;
|
||||
let (agent, session_id) = match parent_session_id {
|
||||
Some(parent_session_id) => {
|
||||
let session = SessionManager::create_session(
|
||||
working_dir.clone(),
|
||||
format!("Subagent task for: {}", parent_session_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?;
|
||||
let session = SessionManager::create_session(
|
||||
working_dir.clone(),
|
||||
format!("Subagent task for: {}", parent_session_id),
|
||||
SessionType::SubAgent,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?;
|
||||
|
||||
let agent = agent_manager
|
||||
.get_or_create_agent(session.id.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to get sub agent session file path: {}", e))?;
|
||||
(agent, Some(session.id))
|
||||
}
|
||||
None => {
|
||||
let agent = Arc::new(Agent::new());
|
||||
agent
|
||||
.extension_manager
|
||||
.set_context(PlatformExtensionContext {
|
||||
session_id: None,
|
||||
extension_manager: Some(Arc::downgrade(&agent.extension_manager)),
|
||||
tool_route_manager: Some(Arc::downgrade(&agent.tool_route_manager)),
|
||||
})
|
||||
.await;
|
||||
(agent, None)
|
||||
}
|
||||
};
|
||||
let agent = agent_manager
|
||||
.get_or_create_agent(session.id.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to get sub agent session file path: {}", e))?;
|
||||
|
||||
agent
|
||||
.update_provider(task_config.provider)
|
||||
@@ -148,26 +130,21 @@ fn get_agent_messages(
|
||||
}
|
||||
}
|
||||
|
||||
let mut conversation =
|
||||
Conversation::new_unvalidated(
|
||||
vec![Message::user().with_text(text_instruction.clone())],
|
||||
);
|
||||
let session_config = if let Some(session_id) = session_id {
|
||||
Some(SessionConfig {
|
||||
id: session_id,
|
||||
working_dir,
|
||||
schedule_id: None,
|
||||
execution_mode: None,
|
||||
max_turns: task_config.max_turns.map(|v| v as u32),
|
||||
retry_config: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let user_message = Message::user().with_text(text_instruction);
|
||||
let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]);
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session.id.clone(),
|
||||
schedule_id: None,
|
||||
max_turns: task_config.max_turns.map(|v| v as u32),
|
||||
retry_config: None,
|
||||
};
|
||||
let mut stream = agent
|
||||
.reply(conversation.clone(), session_config, None)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?;
|
||||
|
||||
let mut stream = crate::session_context::with_session_id(Some(session.id.clone()), async {
|
||||
agent.reply(user_message, session_config, None).await
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?;
|
||||
while let Some(message_result) = stream.next().await {
|
||||
match message_result {
|
||||
Ok(AgentEvent::Message(msg)) => conversation.push(msg),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::agents::ExtensionConfig;
|
||||
use crate::providers::base::Provider;
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Default maximum number of turns for task execution
|
||||
@@ -15,7 +15,7 @@ pub const GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR: &str = "GOOSE_SUBAGENT_MAX_TURNS";
|
||||
#[derive(Clone)]
|
||||
pub struct TaskConfig {
|
||||
pub provider: Arc<dyn Provider>,
|
||||
pub parent_session_id: Option<String>,
|
||||
pub parent_session_id: String,
|
||||
pub parent_working_dir: PathBuf,
|
||||
pub extensions: Vec<ExtensionConfig>,
|
||||
pub max_turns: Option<usize>,
|
||||
@@ -34,17 +34,16 @@ impl fmt::Debug for TaskConfig {
|
||||
}
|
||||
|
||||
impl TaskConfig {
|
||||
/// Create a new TaskConfig with all required dependencies
|
||||
pub fn new(
|
||||
provider: Arc<dyn Provider>,
|
||||
parent_session_id: Option<String>,
|
||||
parent_working_dir: PathBuf,
|
||||
parent_session_id: &str,
|
||||
parent_working_dir: &Path,
|
||||
extensions: Vec<ExtensionConfig>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
parent_session_id,
|
||||
parent_working_dir,
|
||||
parent_session_id: parent_session_id.to_owned(),
|
||||
parent_working_dir: parent_working_dir.to_owned(),
|
||||
extensions,
|
||||
max_turns: Some(
|
||||
env::var(GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR)
|
||||
|
||||
@@ -29,8 +29,9 @@ impl From<ToolResult<Vec<Content>>> for ToolCallResult {
|
||||
}
|
||||
|
||||
use super::agent::{tool_stream, ToolStream};
|
||||
use crate::agents::{Agent, SessionConfig};
|
||||
use crate::agents::Agent;
|
||||
use crate::conversation::message::{Message, ToolRequest};
|
||||
use crate::session::Session;
|
||||
use crate::tool_inspection::get_security_finding_id_from_results;
|
||||
|
||||
pub const DECLINED_RESPONSE: &str = "The user has declined to run this tool. \
|
||||
@@ -53,7 +54,7 @@ impl Agent {
|
||||
tool_futures: Arc<Mutex<Vec<(String, ToolStream)>>>,
|
||||
message_tool_response: Arc<Mutex<Message>>,
|
||||
cancellation_token: Option<CancellationToken>,
|
||||
session: Option<SessionConfig>,
|
||||
session: &'a Session,
|
||||
inspection_results: &'a [crate::tool_inspection::InspectionResult],
|
||||
) -> BoxStream<'a, anyhow::Result<Message>> {
|
||||
try_stream! {
|
||||
@@ -93,7 +94,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow {
|
||||
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session.clone()).await;
|
||||
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session).await;
|
||||
let mut futures = tool_futures.lock().await;
|
||||
|
||||
futures.push((req_id, match tool_result {
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::mcp_utils::ToolResult;
|
||||
use crate::providers::base::Provider;
|
||||
use rmcp::model::{Content, Tool};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use utoipa::ToSchema;
|
||||
@@ -84,14 +83,10 @@ pub struct FrontendTool {
|
||||
/// Session configuration for an agent
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionConfig {
|
||||
/// Unique identifier for the session
|
||||
/// Identifier of the underlying Session
|
||||
pub id: String,
|
||||
/// Working directory for the session
|
||||
pub working_dir: PathBuf,
|
||||
/// ID of the schedule that triggered this session, if any
|
||||
pub schedule_id: Option<String>,
|
||||
/// Execution mode for scheduled jobs: "foreground" or "background"
|
||||
pub execution_mode: Option<String>,
|
||||
/// Maximum number of turns (iterations) allowed without user input
|
||||
pub max_turns: Option<u32>,
|
||||
/// Retry configuration for automated validation and recovery
|
||||
|
||||
+100
-113
@@ -1,9 +1,11 @@
|
||||
use crate::config::paths::Paths;
|
||||
use crate::config::GooseMode;
|
||||
use fs2::FileExt;
|
||||
use keyring::Entry;
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_yaml::Mapping;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -143,9 +145,18 @@ macro_rules! declare_param {
|
||||
self.get_param(stringify!($param_name))
|
||||
}
|
||||
}
|
||||
paste::paste! {
|
||||
pub fn [<set_ $param_name:lower>](&self, v: impl Into<$param_type>) -> Result<(), ConfigError> {
|
||||
self.set_param(stringify!($param_name), &v.into())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_yaml_content(content: &str) -> Result<Mapping, ConfigError> {
|
||||
serde_yaml::from_str(content).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Get the global configuration instance.
|
||||
///
|
||||
@@ -198,8 +209,7 @@ impl Config {
|
||||
self.config_path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
// Load current values from the config file
|
||||
pub fn load_values(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
fn load(&self) -> Result<Mapping, ConfigError> {
|
||||
if self.config_path.exists() {
|
||||
self.load_values_with_recovery()
|
||||
} else {
|
||||
@@ -215,19 +225,27 @@ impl Config {
|
||||
tracing::info!("No backup found, creating default configuration");
|
||||
|
||||
// Try to load from init-config.yaml if it exists, otherwise use empty config
|
||||
let default_config = self
|
||||
.load_init_config_if_exists()
|
||||
.unwrap_or_else(|_| HashMap::new());
|
||||
let default_config = self.load_init_config_if_exists().unwrap_or_default();
|
||||
|
||||
self.create_and_save_default_config(default_config)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_values(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
self.load().map(|m| {
|
||||
HashMap::from_iter(m.into_iter().filter_map(|(k, v)| {
|
||||
k.as_str()
|
||||
.map(|k| k.to_string())
|
||||
.zip(serde_json::to_value(v).ok())
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
// Helper method to create and save default config with consistent logging
|
||||
fn create_and_save_default_config(
|
||||
&self,
|
||||
default_config: HashMap<String, Value>,
|
||||
) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
default_config: Mapping,
|
||||
) -> Result<Mapping, ConfigError> {
|
||||
// Try to write the default config to disk
|
||||
match self.save_values(default_config.clone()) {
|
||||
Ok(_) => {
|
||||
@@ -249,12 +267,10 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Load values with automatic recovery from corruption
|
||||
fn load_values_with_recovery(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
fn load_values_with_recovery(&self) -> Result<Mapping, ConfigError> {
|
||||
let file_content = std::fs::read_to_string(&self.config_path)?;
|
||||
|
||||
// First attempt: try to parse the current config
|
||||
match self.parse_yaml_content(&file_content) {
|
||||
match parse_yaml_content(&file_content) {
|
||||
Ok(values) => Ok(values),
|
||||
Err(parse_error) => {
|
||||
tracing::warn!(
|
||||
@@ -271,40 +287,21 @@ impl Config {
|
||||
// Last resort: create a fresh default config file
|
||||
tracing::error!("Could not recover config file, creating fresh default configuration. Original error: {}", parse_error);
|
||||
|
||||
// Try to load from init-config.yaml if it exists, otherwise use empty config
|
||||
let default_config = self
|
||||
.load_init_config_if_exists()
|
||||
.unwrap_or_else(|_| HashMap::new());
|
||||
let default_config = self.load_init_config_if_exists().unwrap_or_default();
|
||||
|
||||
self.create_and_save_default_config(default_config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse YAML content into HashMap
|
||||
fn parse_yaml_content(&self, content: &str) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
if content.trim().is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
|
||||
let json_value: Value = serde_json::to_value(yaml_value)?;
|
||||
|
||||
match json_value {
|
||||
Value::Object(map) => Ok(map.into_iter().collect()),
|
||||
_ => Ok(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to restore from backup file
|
||||
fn try_restore_from_backup(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
fn try_restore_from_backup(&self) -> Result<Mapping, ConfigError> {
|
||||
let backup_paths = self.get_backup_paths();
|
||||
|
||||
for backup_path in backup_paths {
|
||||
if backup_path.exists() {
|
||||
match std::fs::read_to_string(&backup_path) {
|
||||
Ok(backup_content) => {
|
||||
match self.parse_yaml_content(&backup_content) {
|
||||
match parse_yaml_content(&backup_content) {
|
||||
Ok(values) => {
|
||||
// Successfully parsed backup, restore it as the main config
|
||||
if let Err(e) = self.save_values(values.clone()) {
|
||||
@@ -364,13 +361,11 @@ impl Config {
|
||||
paths
|
||||
}
|
||||
|
||||
// Try to load init-config.yaml from workspace root if it exists
|
||||
fn load_init_config_if_exists(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
fn load_init_config_if_exists(&self) -> Result<Mapping, ConfigError> {
|
||||
load_init_config_from_workspace()
|
||||
}
|
||||
|
||||
// Save current values to the config file
|
||||
pub fn save_values(&self, values: HashMap<String, Value>) -> Result<(), ConfigError> {
|
||||
fn save_values(&self, values: Mapping) -> Result<(), ConfigError> {
|
||||
// Create backup before writing new config
|
||||
self.create_backup_if_needed()?;
|
||||
|
||||
@@ -410,6 +405,15 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn initialize_if_empty(&self, values: Mapping) -> Result<(), ConfigError> {
|
||||
let _guard = self.guard.lock().unwrap();
|
||||
if !self.exists() {
|
||||
self.save_values(values)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup of current config file if it exists and is valid
|
||||
fn create_backup_if_needed(&self) -> Result<(), ConfigError> {
|
||||
if !self.config_path.exists() {
|
||||
@@ -418,7 +422,7 @@ impl Config {
|
||||
|
||||
// Check if current config is valid before backing it up
|
||||
let current_content = std::fs::read_to_string(&self.config_path)?;
|
||||
if self.parse_yaml_content(¤t_content).is_err() {
|
||||
if parse_yaml_content(¤t_content).is_err() {
|
||||
// Don't back up corrupted files
|
||||
return Ok(());
|
||||
}
|
||||
@@ -477,8 +481,7 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Load current secrets from the keyring
|
||||
pub fn load_secrets(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
pub fn all_secrets(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
||||
match &self.secrets {
|
||||
SecretStorage::Keyring { service } => {
|
||||
let entry = Entry::new(service, KEYRING_USERNAME)?;
|
||||
@@ -551,7 +554,10 @@ impl Config {
|
||||
}
|
||||
|
||||
// save a parameter in the appropriate location based on if it's secret or not
|
||||
pub fn set(&self, key: &str, value: Value, is_secret: bool) -> Result<(), ConfigError> {
|
||||
pub fn set<V>(&self, key: &str, value: &V, is_secret: bool) -> Result<(), ConfigError>
|
||||
where
|
||||
V: Serialize,
|
||||
{
|
||||
if is_secret {
|
||||
self.set_secret(key, value)
|
||||
} else {
|
||||
@@ -576,21 +582,17 @@ impl Config {
|
||||
/// - The value cannot be deserialized into the requested type
|
||||
/// - There is an error reading the config file
|
||||
pub fn get_param<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T, ConfigError> {
|
||||
// First check environment variables (convert to uppercase)
|
||||
let env_key = key.to_uppercase();
|
||||
if let Ok(val) = env::var(&env_key) {
|
||||
let value = Self::parse_env_value(&val)?;
|
||||
return Ok(serde_json::from_value(value)?);
|
||||
}
|
||||
|
||||
// Load current values from file
|
||||
let values = self.load_values()?;
|
||||
|
||||
// Then check our stored values
|
||||
let values = self.load()?;
|
||||
values
|
||||
.get(key)
|
||||
.ok_or_else(|| ConfigError::NotFound(key.to_string()))
|
||||
.and_then(|v| Ok(serde_json::from_value(v.clone())?))
|
||||
.and_then(|v| Ok(serde_yaml::from_value(v.clone())?))
|
||||
}
|
||||
|
||||
/// Set a configuration value in the config file (non-secret).
|
||||
@@ -606,17 +608,10 @@ impl Config {
|
||||
/// Returns a ConfigError if:
|
||||
/// - There is an error reading or writing the config file
|
||||
/// - There is an error serializing the value
|
||||
pub fn set_param(&self, key: &str, value: Value) -> Result<(), ConfigError> {
|
||||
// Lock before reading to prevent race condition.
|
||||
pub fn set_param<V: Serialize>(&self, key: &str, value: V) -> Result<(), ConfigError> {
|
||||
let _guard = self.guard.lock().unwrap();
|
||||
|
||||
// Load current values with recovery if needed
|
||||
let mut values = self.load_values()?;
|
||||
|
||||
// Modify values
|
||||
values.insert(key.to_string(), value);
|
||||
|
||||
// Save all values using the atomic write approach
|
||||
let mut values = self.load()?;
|
||||
values.insert(serde_yaml::to_value(key)?, serde_yaml::to_value(value)?);
|
||||
self.save_values(values)
|
||||
}
|
||||
|
||||
@@ -637,8 +632,8 @@ impl Config {
|
||||
// Lock before reading to prevent race condition.
|
||||
let _guard = self.guard.lock().unwrap();
|
||||
|
||||
let mut values = self.load_values()?;
|
||||
values.remove(key);
|
||||
let mut values = self.load()?;
|
||||
values.shift_remove(key);
|
||||
|
||||
self.save_values(values)
|
||||
}
|
||||
@@ -668,7 +663,7 @@ impl Config {
|
||||
}
|
||||
|
||||
// Then check keyring
|
||||
let values = self.load_secrets()?;
|
||||
let values = self.all_secrets()?;
|
||||
values
|
||||
.get(key)
|
||||
.ok_or_else(|| ConfigError::NotFound(key.to_string()))
|
||||
@@ -689,12 +684,15 @@ impl Config {
|
||||
/// Returns a ConfigError if:
|
||||
/// - There is an error accessing the keyring
|
||||
/// - There is an error serializing the value
|
||||
pub fn set_secret(&self, key: &str, value: Value) -> Result<(), ConfigError> {
|
||||
pub fn set_secret<V>(&self, key: &str, value: &V) -> Result<(), ConfigError>
|
||||
where
|
||||
V: Serialize,
|
||||
{
|
||||
// Lock before reading to prevent race condition.
|
||||
let _guard = self.guard.lock().unwrap();
|
||||
|
||||
let mut values = self.load_secrets()?;
|
||||
values.insert(key.to_string(), value);
|
||||
let mut values = self.all_secrets()?;
|
||||
values.insert(key.to_string(), serde_json::to_value(value)?);
|
||||
|
||||
match &self.secrets {
|
||||
SecretStorage::Keyring { service } => {
|
||||
@@ -724,7 +722,7 @@ impl Config {
|
||||
// Lock before reading to prevent race condition.
|
||||
let _guard = self.guard.lock().unwrap();
|
||||
|
||||
let mut values = self.load_secrets()?;
|
||||
let mut values = self.all_secrets()?;
|
||||
values.remove(key);
|
||||
|
||||
match &self.secrets {
|
||||
@@ -742,11 +740,14 @@ impl Config {
|
||||
}
|
||||
|
||||
declare_param!(GOOSE_SEARCH_PATHS, Vec<String>);
|
||||
declare_param!(GOOSE_MODE, GooseMode);
|
||||
declare_param!(GOOSE_PROVIDER, String);
|
||||
declare_param!(GOOSE_MODEL, String);
|
||||
}
|
||||
|
||||
/// Load init-config.yaml from workspace root if it exists.
|
||||
/// This function is shared between the config recovery and the init_config endpoint.
|
||||
pub fn load_init_config_from_workspace() -> Result<HashMap<String, Value>, ConfigError> {
|
||||
pub fn load_init_config_from_workspace() -> Result<Mapping, ConfigError> {
|
||||
let workspace_root = match std::env::current_exe() {
|
||||
Ok(mut exe_path) => {
|
||||
while let Some(parent) = exe_path.parent() {
|
||||
@@ -779,23 +780,7 @@ pub fn load_init_config_from_workspace() -> Result<HashMap<String, Value>, Confi
|
||||
}
|
||||
|
||||
let init_content = std::fs::read_to_string(&init_config_path)?;
|
||||
let init_values: HashMap<String, Value> =
|
||||
match serde_yaml::from_str::<serde_yaml::Value>(&init_content) {
|
||||
Ok(yaml_value) => {
|
||||
let json_value: Value = serde_json::to_value(yaml_value)?;
|
||||
match json_value {
|
||||
Value::Object(map) => map.into_iter().collect(),
|
||||
_ => HashMap::new(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse init-config.yaml: {}", e);
|
||||
return Err(ConfigError::DeserializeError(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Loaded init-config.yaml with {} keys", init_values.len());
|
||||
Ok(init_values)
|
||||
parse_yaml_content(&init_content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -819,7 +804,7 @@ mod tests {
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// Set a simple string value
|
||||
config.set_param("test_key", Value::String("test_value".to_string()))?;
|
||||
config.set_param("test_key", "test_value")?;
|
||||
|
||||
// Test simple string retrieval
|
||||
let value: String = config.get_param("test_key")?;
|
||||
@@ -874,8 +859,8 @@ mod tests {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
config.set_param("key1", Value::String("value1".to_string()))?;
|
||||
config.set_param("key2", Value::Number(42.into()))?;
|
||||
config.set_param("key1", "value1")?;
|
||||
config.set_param("key2", 42)?;
|
||||
|
||||
// Read the file directly to check YAML formatting
|
||||
let content = std::fs::read_to_string(temp_file.path())?;
|
||||
@@ -890,12 +875,11 @@ mod tests {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
config.set_param("key", Value::String("value".to_string()))?;
|
||||
config.set_param("test_key", "test_value")?;
|
||||
config.set_param("another_key", 42)?;
|
||||
config.set_param("third_key", true)?;
|
||||
|
||||
let value: String = config.get_param("key")?;
|
||||
assert_eq!(value, "value");
|
||||
|
||||
config.delete("key")?;
|
||||
let _values = config.load()?;
|
||||
|
||||
let result: Result<String, ConfigError> = config.get_param("key");
|
||||
assert!(matches!(result, Err(ConfigError::NotFound(_))));
|
||||
@@ -909,7 +893,7 @@ mod tests {
|
||||
let secrets_file = NamedTempFile::new().unwrap();
|
||||
let config = Config::new_with_file_secrets(config_file.path(), secrets_file.path())?;
|
||||
|
||||
config.set_secret("key", Value::String("value".to_string()))?;
|
||||
config.set_secret("key", &"value")?;
|
||||
|
||||
let value: String = config.get_secret("key")?;
|
||||
assert_eq!(value, "value");
|
||||
@@ -930,7 +914,7 @@ mod tests {
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// Test setting and getting a simple secret
|
||||
config.set_secret("api_key", Value::String("secret123".to_string()))?;
|
||||
config.set_secret("api_key", &Value::String("secret123".to_string()))?;
|
||||
let value: String = config.get_secret("api_key")?;
|
||||
assert_eq!(value, "secret123");
|
||||
|
||||
@@ -957,8 +941,8 @@ mod tests {
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// Set multiple secrets
|
||||
config.set_secret("key1", Value::String("secret1".to_string()))?;
|
||||
config.set_secret("key2", Value::String("secret2".to_string()))?;
|
||||
config.set_secret("key1", &Value::String("secret1".to_string()))?;
|
||||
config.set_secret("key2", &Value::String("secret2".to_string()))?;
|
||||
|
||||
// Verify both exist
|
||||
let value1: String = config.get_secret("key1")?;
|
||||
@@ -987,11 +971,11 @@ mod tests {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let config = Arc::new(Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?);
|
||||
let barrier = Arc::new(Barrier::new(3)); // For 3 concurrent threads
|
||||
let values = Arc::new(Mutex::new(HashMap::new()));
|
||||
let values = Arc::new(Mutex::new(Mapping::new()));
|
||||
let mut handles = vec![];
|
||||
|
||||
// Initialize with empty values
|
||||
config.save_values(HashMap::new())?;
|
||||
config.save_values(Default::default())?;
|
||||
|
||||
// Spawn 3 threads that will try to write simultaneously
|
||||
for i in 0..3 {
|
||||
@@ -1004,7 +988,10 @@ mod tests {
|
||||
|
||||
// Get the lock and update values
|
||||
let mut values = values.lock().unwrap();
|
||||
values.insert(format!("key{}", i), Value::String(format!("value{}", i)));
|
||||
values.insert(
|
||||
serde_yaml::to_value(format!("key{}", i)).unwrap(),
|
||||
serde_yaml::to_value(format!("value{}", i)).unwrap(),
|
||||
);
|
||||
|
||||
// Write all values
|
||||
config.save_values(values.clone())?;
|
||||
@@ -1019,7 +1006,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Verify all values were written correctly
|
||||
let final_values = config.load_values()?;
|
||||
let final_values = config.all_values()?;
|
||||
|
||||
// Print the final values for debugging
|
||||
println!("Final values: {:?}", final_values);
|
||||
@@ -1056,7 +1043,7 @@ mod tests {
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// Create a valid config first
|
||||
config.set_param("key1", Value::String("value1".to_string()))?;
|
||||
config.set_param("key1", "value1")?;
|
||||
|
||||
// Verify the backup was created by the first write
|
||||
let backup_paths = config.get_backup_paths();
|
||||
@@ -1066,7 +1053,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Make another write to ensure backup is created
|
||||
config.set_param("key2", Value::Number(42.into()))?;
|
||||
config.set_param("key2", 42)?;
|
||||
|
||||
// Check again
|
||||
for (i, path) in backup_paths.iter().enumerate() {
|
||||
@@ -1081,7 +1068,7 @@ mod tests {
|
||||
std::fs::write(temp_file.path(), "invalid: yaml: content: [unclosed")?;
|
||||
|
||||
// Try to load values - should recover from backup
|
||||
let recovered_values = config.load_values()?;
|
||||
let recovered_values = config.all_values()?;
|
||||
println!("Recovered values: {:?}", recovered_values);
|
||||
|
||||
// Should have recovered the data
|
||||
@@ -1102,7 +1089,7 @@ mod tests {
|
||||
std::fs::write(temp_file.path(), "invalid: yaml: content: [unclosed")?;
|
||||
|
||||
// Try to load values - should create a fresh default config
|
||||
let recovered_values = config.load_values()?;
|
||||
let recovered_values = config.all_values()?;
|
||||
|
||||
// Should return empty config
|
||||
assert_eq!(recovered_values.len(), 0);
|
||||
@@ -1115,7 +1102,7 @@ mod tests {
|
||||
assert!(parsed.is_mapping());
|
||||
|
||||
// Should be able to load it again without issues
|
||||
let reloaded_values = config.load_values()?;
|
||||
let reloaded_values = config.all_values()?;
|
||||
assert_eq!(reloaded_values.len(), 0);
|
||||
|
||||
Ok(())
|
||||
@@ -1133,7 +1120,7 @@ mod tests {
|
||||
let config = Config::new(config_path, TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// Try to load values - should create a fresh default config file
|
||||
let values = config.load_values()?;
|
||||
let values = config.all_values()?;
|
||||
|
||||
// Should return empty config
|
||||
assert_eq!(values.len(), 0);
|
||||
@@ -1147,7 +1134,7 @@ mod tests {
|
||||
assert!(parsed.is_mapping());
|
||||
|
||||
// Should be able to load it again without issues
|
||||
let reloaded_values = config.load_values()?;
|
||||
let reloaded_values = config.all_values()?;
|
||||
assert_eq!(reloaded_values.len(), 0);
|
||||
|
||||
Ok(())
|
||||
@@ -1160,15 +1147,15 @@ mod tests {
|
||||
let config = Config::new(config_path, TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// First, create a config with some data
|
||||
config.set_param("test_key_backup", Value::String("backup_value".to_string()))?;
|
||||
config.set_param("another_key", Value::Number(42.into()))?;
|
||||
config.set_param("test_key_backup", "backup_value")?;
|
||||
config.set_param("another_key", 42)?;
|
||||
|
||||
// Verify the backup was created
|
||||
let backup_paths = config.get_backup_paths();
|
||||
let primary_backup = &backup_paths[0]; // .bak file
|
||||
|
||||
// Make sure we have a backup by doing another write
|
||||
config.set_param("third_key", Value::Bool(true))?;
|
||||
config.set_param("third_key", true)?;
|
||||
assert!(primary_backup.exists(), "Backup should exist after writes");
|
||||
|
||||
// Now delete the main config file to simulate it being lost
|
||||
@@ -1176,7 +1163,7 @@ mod tests {
|
||||
assert!(!config_path.exists());
|
||||
|
||||
// Try to load values - should recover from backup
|
||||
let recovered_values = config.load_values()?;
|
||||
let recovered_values = config.all_values()?;
|
||||
|
||||
// Should have recovered the data from backup
|
||||
assert!(
|
||||
@@ -1204,7 +1191,7 @@ mod tests {
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// Set initial values
|
||||
config.set_param("key1", Value::String("value1".to_string()))?;
|
||||
config.set_param("key1", "value1")?;
|
||||
|
||||
// Verify the config file exists and is valid
|
||||
assert!(temp_file.path().exists());
|
||||
@@ -1225,7 +1212,7 @@ mod tests {
|
||||
|
||||
// Create multiple versions to test rotation
|
||||
for i in 1..=7 {
|
||||
config.set_param("version", Value::Number(i.into()))?;
|
||||
config.set_param("version", i)?;
|
||||
}
|
||||
|
||||
let backup_paths = config.get_backup_paths();
|
||||
@@ -1460,7 +1447,7 @@ mod tests {
|
||||
let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?;
|
||||
|
||||
// Set value in config file
|
||||
config.set_param("test_precedence", Value::String("file_value".to_string()))?;
|
||||
config.set_param("test_precedence", "file_value")?;
|
||||
|
||||
// Verify file value is returned when no env var
|
||||
let value: String = config.get_param("test_precedence")?;
|
||||
|
||||
@@ -97,7 +97,7 @@ pub fn create_custom_provider(
|
||||
let api_key_name = generate_api_key_name(&id);
|
||||
|
||||
let config = Config::global();
|
||||
config.set_secret(&api_key_name, serde_json::Value::String(api_key))?;
|
||||
config.set_secret(&api_key_name, &api_key)?;
|
||||
|
||||
let model_infos: Vec<ModelInfo> = models
|
||||
.into_iter()
|
||||
@@ -147,10 +147,7 @@ pub fn update_custom_provider(
|
||||
|
||||
let config = Config::global();
|
||||
if !api_key.is_empty() {
|
||||
config.set_secret(
|
||||
&existing_config.api_key_env,
|
||||
serde_json::Value::String(api_key),
|
||||
)?;
|
||||
config.set_secret(&existing_config.api_key_env, &api_key)?;
|
||||
}
|
||||
|
||||
if editable {
|
||||
|
||||
@@ -35,7 +35,7 @@ impl ExperimentManager {
|
||||
Self::refresh_experiments(&mut experiments);
|
||||
experiments.insert(name.to_string(), enabled);
|
||||
|
||||
config.set_param("experiments", serde_json::to_value(experiments)?)?;
|
||||
config.set_param("experiments", experiments)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::base::Config;
|
||||
use crate::agents::extension::PLATFORM_EXTENSIONS;
|
||||
use crate::agents::ExtensionConfig;
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use serde_yaml::Mapping;
|
||||
use tracing::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -27,56 +27,32 @@ pub fn name_to_key(name: &str) -> String {
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn get_extensions_map() -> HashMap<String, ExtensionEntry> {
|
||||
let raw: Value = Config::global()
|
||||
.get_param::<Value>(EXTENSIONS_CONFIG_KEY)
|
||||
fn get_extensions_map() -> IndexMap<String, ExtensionEntry> {
|
||||
let raw: Mapping = Config::global()
|
||||
.get_param(EXTENSIONS_CONFIG_KEY)
|
||||
.unwrap_or_else(|err| {
|
||||
warn!(
|
||||
"Failed to load {}: {err}. Falling back to empty object.",
|
||||
EXTENSIONS_CONFIG_KEY
|
||||
);
|
||||
Value::Object(serde_json::Map::new())
|
||||
Default::default()
|
||||
});
|
||||
|
||||
let mut extensions_map: HashMap<String, ExtensionEntry> = match raw {
|
||||
Value::Object(obj) => {
|
||||
let mut m = HashMap::with_capacity(obj.len());
|
||||
for (k, mut v) in obj {
|
||||
if let Value::Object(ref mut inner) = v {
|
||||
match inner.get("description") {
|
||||
Some(Value::Null) | None => {
|
||||
inner.insert("description".to_string(), Value::String(String::new()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match serde_json::from_value::<ExtensionEntry>(v.clone()) {
|
||||
Ok(entry) => {
|
||||
m.insert(k, entry);
|
||||
}
|
||||
Err(err) => {
|
||||
let bad_json = serde_json::to_string(&v).unwrap_or_else(|e| {
|
||||
format!("<failed to serialize malformed value: {e}>")
|
||||
});
|
||||
warn!(
|
||||
extension = %k,
|
||||
error = %err,
|
||||
bad_json = %bad_json,
|
||||
"Skipping malformed extension"
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut extensions_map = IndexMap::with_capacity(raw.len());
|
||||
for (k, v) in raw {
|
||||
match (k, serde_yaml::from_value::<ExtensionEntry>(v)) {
|
||||
(serde_yaml::Value::String(s), Ok(entry)) => {
|
||||
extensions_map.insert(s, entry);
|
||||
}
|
||||
(k, v) => {
|
||||
warn!(
|
||||
key = ?k,
|
||||
value = ?v,
|
||||
"Skipping malformed extension config entry"
|
||||
);
|
||||
}
|
||||
m
|
||||
}
|
||||
other => {
|
||||
warn!(
|
||||
"Expected object for {}, got {}. Using empty map.",
|
||||
EXTENSIONS_CONFIG_KEY, other
|
||||
);
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if !extensions_map.is_empty() {
|
||||
for (name, def) in PLATFORM_EXTENSIONS.iter() {
|
||||
@@ -99,17 +75,11 @@ fn get_extensions_map() -> HashMap<String, ExtensionEntry> {
|
||||
extensions_map
|
||||
}
|
||||
|
||||
fn save_extensions_map(extensions: HashMap<String, ExtensionEntry>) {
|
||||
fn save_extensions_map(extensions: IndexMap<String, ExtensionEntry>) {
|
||||
let config = Config::global();
|
||||
match serde_json::to_value(extensions) {
|
||||
Ok(value) => {
|
||||
if let Err(e) = config.set_param(EXTENSIONS_CONFIG_KEY, value) {
|
||||
tracing::debug!("Failed to save extensions config: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to serialize extensions: {}", e);
|
||||
}
|
||||
if let Err(e) = config.set_param(EXTENSIONS_CONFIG_KEY, &extensions) {
|
||||
// TODO(jack) why is this just a debug statement?
|
||||
tracing::debug!("Failed to save extensions config: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +100,7 @@ pub fn set_extension(entry: ExtensionEntry) {
|
||||
|
||||
pub fn remove_extension(key: &str) {
|
||||
let mut extensions = get_extensions_map();
|
||||
extensions.remove(key);
|
||||
extensions.shift_remove(key);
|
||||
save_extensions_map(extensions);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GooseMode {
|
||||
Auto,
|
||||
Approve,
|
||||
SmartApprove,
|
||||
Chat,
|
||||
}
|
||||
|
||||
impl FromStr for GooseMode {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"auto" => Ok(GooseMode::Auto),
|
||||
"approve" => Ok(GooseMode::Approve),
|
||||
"smart_approve" => Ok(GooseMode::SmartApprove),
|
||||
"chat" => Ok(GooseMode::Chat),
|
||||
_ => Err(format!("invalid mode: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod base;
|
||||
pub mod declarative_providers;
|
||||
mod experiments;
|
||||
pub mod extensions;
|
||||
pub mod goose_mode;
|
||||
pub mod paths;
|
||||
pub mod permission;
|
||||
pub mod search_path;
|
||||
@@ -16,6 +17,7 @@ pub use extensions::{
|
||||
get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name,
|
||||
is_extension_enabled, remove_extension, set_extension, set_extension_enabled, ExtensionEntry,
|
||||
};
|
||||
pub use goose_mode::GooseMode;
|
||||
pub use permission::PermissionManager;
|
||||
pub use signup_openrouter::configure_openrouter;
|
||||
pub use signup_tetrate::configure_tetrate;
|
||||
|
||||
@@ -162,14 +162,10 @@ impl PkceAuthFlow {
|
||||
pub use self::PkceAuthFlow as OpenRouterAuth;
|
||||
|
||||
use crate::config::Config;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn configure_openrouter(config: &Config, api_key: String) -> Result<()> {
|
||||
config.set_secret("OPENROUTER_API_KEY", Value::String(api_key))?;
|
||||
config.set_param("GOOSE_PROVIDER", Value::String("openrouter".to_string()))?;
|
||||
config.set_param(
|
||||
"GOOSE_MODEL",
|
||||
Value::String(OPENROUTER_DEFAULT_MODEL.to_string()),
|
||||
)?;
|
||||
config.set_secret("OPENROUTER_API_KEY", &api_key)?;
|
||||
config.set_goose_provider("openrouter")?;
|
||||
config.set_goose_model(OPENROUTER_DEFAULT_MODEL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -163,14 +163,10 @@ impl PkceAuthFlow {
|
||||
pub use self::PkceAuthFlow as TetrateAuth;
|
||||
|
||||
use crate::config::Config;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn configure_tetrate(config: &Config, api_key: String) -> Result<()> {
|
||||
config.set_secret("TETRATE_API_KEY", Value::String(api_key))?;
|
||||
config.set_param("GOOSE_PROVIDER", Value::String("tetrate".to_string()))?;
|
||||
config.set_param(
|
||||
"GOOSE_MODEL",
|
||||
Value::String(TETRATE_DEFAULT_MODEL.to_string()),
|
||||
)?;
|
||||
config.set_secret("TETRATE_API_KEY", &api_key)?;
|
||||
config.set_goose_provider("tetrate")?;
|
||||
config.set_goose_model(TETRATE_DEFAULT_MODEL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -76,12 +76,9 @@ fn test_configure_tetrate() {
|
||||
config.get_secret::<String>("TETRATE_API_KEY").unwrap(),
|
||||
test_key
|
||||
);
|
||||
assert_eq!(config.get_goose_provider().unwrap(), "tetrate");
|
||||
assert_eq!(
|
||||
config.get_param::<String>("GOOSE_PROVIDER").unwrap(),
|
||||
"tetrate"
|
||||
);
|
||||
assert_eq!(
|
||||
config.get_param::<String>("GOOSE_MODEL").unwrap(),
|
||||
config.get_goose_model().unwrap(),
|
||||
TETRATE_DEFAULT_MODEL.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,13 +31,12 @@ struct SummarizeContext {
|
||||
/// # Returns
|
||||
/// * A tuple containing:
|
||||
/// - `Conversation`: The compacted messages
|
||||
/// - `Vec<usize>`: Token counts for each message
|
||||
/// - `Option<ProviderUsage>`: Provider usage from summarization
|
||||
/// - `ProviderUsage`: Provider usage from summarization
|
||||
pub async fn compact_messages(
|
||||
agent: &Agent,
|
||||
conversation: &Conversation,
|
||||
preserve_last_user_message: bool,
|
||||
) -> Result<(Conversation, Vec<usize>, Option<ProviderUsage>)> {
|
||||
) -> Result<(Conversation, ProviderUsage)> {
|
||||
info!("Performing message compaction");
|
||||
|
||||
let messages = conversation.messages();
|
||||
@@ -99,16 +98,8 @@ pub async fn compact_messages(
|
||||
};
|
||||
|
||||
let provider = agent.provider().await?;
|
||||
let summary = do_compact(provider.clone(), messages_to_compact).await?;
|
||||
|
||||
let (summary_message, summarization_usage) = match summary {
|
||||
Some((summary_message, provider_usage)) => (summary_message, Some(provider_usage)),
|
||||
None => {
|
||||
// No summary was generated (empty input)
|
||||
tracing::warn!("Summarization failed. Returning empty messages.");
|
||||
return Ok((Conversation::empty(), vec![], None));
|
||||
}
|
||||
};
|
||||
let (summary_message, summarization_usage) =
|
||||
do_compact(provider.clone(), messages_to_compact).await?;
|
||||
|
||||
// Create the final message list with updated visibility metadata:
|
||||
// 1. Original messages become user_visible but not agent_visible
|
||||
@@ -116,27 +107,17 @@ pub async fn compact_messages(
|
||||
// 3. Assistant messages to continue the conversation remain both user_visible and agent_visible
|
||||
|
||||
let mut final_messages = Vec::new();
|
||||
let mut final_token_counts = Vec::new();
|
||||
|
||||
// Add all original messages with updated visibility (preserve user_visible, set agent_visible=false)
|
||||
for msg in messages_to_compact.iter().cloned() {
|
||||
let updated_metadata = msg.metadata.with_agent_invisible();
|
||||
let updated_msg = msg.with_metadata(updated_metadata);
|
||||
final_messages.push(updated_msg);
|
||||
// Token count doesn't matter for agent_visible=false messages, but we'll use 0
|
||||
final_token_counts.push(0);
|
||||
}
|
||||
|
||||
// Add the summary message (agent_visible=true, user_visible=false)
|
||||
let summary_msg = summary_message.with_metadata(MessageMetadata::agent_only());
|
||||
// For token counting purposes, we use the output tokens (the actual summary content)
|
||||
// since that's what will be in the context going forward
|
||||
let summary_tokens = summarization_usage
|
||||
.as_ref()
|
||||
.and_then(|usage| usage.usage.output_tokens)
|
||||
.unwrap_or(0) as usize;
|
||||
final_messages.push(summary_msg);
|
||||
final_token_counts.push(summary_tokens);
|
||||
|
||||
// Add an assistant message to continue the conversation (agent_visible=true, user_visible=false)
|
||||
let assistant_message = Message::assistant()
|
||||
@@ -146,9 +127,7 @@ Do not mention that you read a summary or that conversation summarization occurr
|
||||
Just continue the conversation naturally based on the summarized context"
|
||||
)
|
||||
.with_metadata(MessageMetadata::agent_only());
|
||||
let assistant_message_tokens: usize = 0; // Not counted since it's for agent context only
|
||||
final_messages.push(assistant_message);
|
||||
final_token_counts.push(assistant_message_tokens);
|
||||
|
||||
// Add back the preserved user message if it exists
|
||||
if let Some(user_text) = preserved_user_text {
|
||||
@@ -157,7 +136,6 @@ Just continue the conversation naturally based on the summarized context"
|
||||
|
||||
Ok((
|
||||
Conversation::new_unvalidated(final_messages),
|
||||
final_token_counts,
|
||||
summarization_usage,
|
||||
))
|
||||
}
|
||||
@@ -167,11 +145,10 @@ pub async fn check_if_compaction_needed(
|
||||
agent: &Agent,
|
||||
conversation: &Conversation,
|
||||
threshold_override: Option<f64>,
|
||||
session_metadata: Option<&crate::session::Session>,
|
||||
session: &crate::session::Session,
|
||||
) -> Result<bool> {
|
||||
let messages = conversation.messages();
|
||||
let config = Config::global();
|
||||
// TODO(Douwe): check the default here; it seems to reset to 0.3 sometimes
|
||||
let threshold = threshold_override.unwrap_or_else(|| {
|
||||
config
|
||||
.get_param::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
|
||||
@@ -181,7 +158,7 @@ pub async fn check_if_compaction_needed(
|
||||
let provider = agent.provider().await?;
|
||||
let context_limit = provider.get_model_config().context_limit();
|
||||
|
||||
let (current_tokens, token_source) = match session_metadata.and_then(|m| m.total_tokens) {
|
||||
let (current_tokens, token_source) = match session.total_tokens {
|
||||
Some(tokens) => (tokens as usize, "session metadata"),
|
||||
None => {
|
||||
let token_counter = create_token_counter()
|
||||
@@ -222,7 +199,7 @@ pub async fn check_if_compaction_needed(
|
||||
async fn do_compact(
|
||||
provider: Arc<dyn Provider>,
|
||||
messages: &[Message],
|
||||
) -> Result<Option<(Message, ProviderUsage)>, anyhow::Error> {
|
||||
) -> Result<(Message, ProviderUsage), anyhow::Error> {
|
||||
let agent_visible_messages: Vec<&Message> = messages
|
||||
.iter()
|
||||
.filter(|msg| msg.is_agent_visible())
|
||||
@@ -255,7 +232,7 @@ async fn do_compact(
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to ensure usage tokens: {}", e))?;
|
||||
|
||||
Ok(Some((response, provider_usage)))
|
||||
Ok((response, provider_usage))
|
||||
}
|
||||
|
||||
fn format_message_for_compacting(msg: &Message) -> String {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::agents::extension::PlatformExtensionContext;
|
||||
use crate::agents::Agent;
|
||||
use crate::config::paths::Paths;
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::create;
|
||||
use crate::scheduler_factory::SchedulerFactory;
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use anyhow::Result;
|
||||
@@ -10,7 +8,7 @@ use lru::LruCache;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{OnceCell, RwLock};
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, info};
|
||||
|
||||
const DEFAULT_MAX_SESSION: usize = 100;
|
||||
|
||||
@@ -23,7 +21,7 @@ pub struct AgentManager {
|
||||
}
|
||||
|
||||
impl AgentManager {
|
||||
/// Reset the global singleton - ONLY for testing
|
||||
#[cfg(test)]
|
||||
pub fn reset_for_test() {
|
||||
unsafe {
|
||||
// Cast away the const to get mutable access
|
||||
@@ -34,7 +32,6 @@ impl AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Private constructor - prevents direct instantiation in production
|
||||
async fn new(max_sessions: Option<usize>) -> Result<Self> {
|
||||
let schedule_file_path = Paths::data_dir().join("schedule.json");
|
||||
|
||||
@@ -49,8 +46,6 @@ impl AgentManager {
|
||||
default_provider: Arc::new(RwLock::new(None)),
|
||||
};
|
||||
|
||||
let _ = manager.configure_default_provider().await;
|
||||
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
@@ -73,39 +68,6 @@ impl AgentManager {
|
||||
*self.default_provider.write().await = Some(provider);
|
||||
}
|
||||
|
||||
pub async fn configure_default_provider(&self) -> Result<()> {
|
||||
let provider_name = std::env::var("GOOSE_DEFAULT_PROVIDER")
|
||||
.or_else(|_| std::env::var("GOOSE_PROVIDER__TYPE"))
|
||||
.ok();
|
||||
|
||||
let model_name = std::env::var("GOOSE_DEFAULT_MODEL")
|
||||
.or_else(|_| std::env::var("GOOSE_PROVIDER__MODEL"))
|
||||
.ok();
|
||||
|
||||
if provider_name.is_none() || model_name.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let (Some(provider_name), Some(model_name)) = (provider_name, model_name) {
|
||||
match ModelConfig::new(&model_name) {
|
||||
Ok(model_config) => match create(&provider_name, model_config).await {
|
||||
Ok(provider) => {
|
||||
self.set_default_provider(provider).await;
|
||||
info!(
|
||||
"Configured default provider: {} with model: {}",
|
||||
provider_name, model_name
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create default provider {}: {}", provider_name, e)
|
||||
}
|
||||
},
|
||||
Err(e) => warn!("Failed to create model config for {}: {}", model_name, e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_or_create_agent(&self, session_id: String) -> Result<Arc<Agent>> {
|
||||
{
|
||||
let mut sessions = self.sessions.write().await;
|
||||
@@ -154,3 +116,230 @@ impl AgentManager {
|
||||
self.sessions.read().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serial_test::serial;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::execution::{manager::AgentManager, SessionExecutionMode};
|
||||
|
||||
#[test]
|
||||
fn test_execution_mode_constructors() {
|
||||
assert_eq!(
|
||||
SessionExecutionMode::chat(),
|
||||
SessionExecutionMode::Interactive
|
||||
);
|
||||
assert_eq!(
|
||||
SessionExecutionMode::scheduled(),
|
||||
SessionExecutionMode::Background
|
||||
);
|
||||
|
||||
let parent = "parent-123".to_string();
|
||||
assert_eq!(
|
||||
SessionExecutionMode::task(parent.clone()),
|
||||
SessionExecutionMode::SubTask {
|
||||
parent_session: parent
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_session_isolation() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
let session1 = uuid::Uuid::new_v4().to_string();
|
||||
let session2 = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let agent1 = manager.get_or_create_agent(session1.clone()).await.unwrap();
|
||||
|
||||
let agent2 = manager.get_or_create_agent(session2.clone()).await.unwrap();
|
||||
|
||||
// Different sessions should have different agents
|
||||
assert!(!Arc::ptr_eq(&agent1, &agent2));
|
||||
|
||||
// Getting the same session should return the same agent
|
||||
let agent1_again = manager.get_or_create_agent(session1).await.unwrap();
|
||||
|
||||
assert!(Arc::ptr_eq(&agent1, &agent1_again));
|
||||
|
||||
AgentManager::reset_for_test();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_session_limit() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
let sessions: Vec<_> = (0..100).map(|i| format!("session-{}", i)).collect();
|
||||
|
||||
for session in &sessions {
|
||||
manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
}
|
||||
|
||||
// Create a new session after cleanup
|
||||
let new_session = "new-session".to_string();
|
||||
let _new_agent = manager.get_or_create_agent(new_session).await.unwrap();
|
||||
|
||||
assert_eq!(manager.session_count().await, 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_session() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session = String::from("remove-test");
|
||||
|
||||
manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
assert!(manager.has_session(&session).await);
|
||||
|
||||
manager.remove_session(&session).await.unwrap();
|
||||
assert!(!manager.has_session(&session).await);
|
||||
|
||||
assert!(manager.remove_session(&session).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_concurrent_access() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session = String::from("concurrent-test");
|
||||
|
||||
let mut handles = vec![];
|
||||
for _ in 0..10 {
|
||||
let mgr = Arc::clone(&manager);
|
||||
let sess = session.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
mgr.get_or_create_agent(sess).await.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
let agents: Vec<_> = futures::future::join_all(handles)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
for agent in &agents[1..] {
|
||||
assert!(Arc::ptr_eq(&agents[0], agent));
|
||||
}
|
||||
|
||||
assert_eq!(manager.session_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_concurrent_session_creation_race_condition() {
|
||||
// Test that concurrent attempts to create the same new session ID
|
||||
// result in only one agent being created (tests double-check pattern)
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session_id = String::from("race-condition-test");
|
||||
|
||||
// Spawn multiple tasks trying to create the same NEW session simultaneously
|
||||
let mut handles = vec![];
|
||||
for _ in 0..20 {
|
||||
let sess = session_id.clone();
|
||||
let mgr_clone = Arc::clone(&manager);
|
||||
handles.push(tokio::spawn(async move {
|
||||
mgr_clone.get_or_create_agent(sess).await.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect all agents
|
||||
let agents: Vec<_> = futures::future::join_all(handles)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
for agent in &agents[1..] {
|
||||
assert!(
|
||||
Arc::ptr_eq(&agents[0], agent),
|
||||
"All concurrent requests should get the same agent"
|
||||
);
|
||||
}
|
||||
assert_eq!(manager.session_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_default_provider() {
|
||||
use crate::providers::testprovider::TestProvider;
|
||||
use std::sync::Arc;
|
||||
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
// Create a test provider for replaying (doesn't need inner provider)
|
||||
let temp_file = format!(
|
||||
"{}/test_provider_{}.json",
|
||||
std::env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
|
||||
// Create an empty test provider (will fail on actual use but that's ok for this test)
|
||||
let test_provider = TestProvider::new_replaying(&temp_file)
|
||||
.unwrap_or_else(|_| TestProvider::new_replaying("/tmp/dummy.json").unwrap());
|
||||
|
||||
manager.set_default_provider(Arc::new(test_provider)).await;
|
||||
|
||||
let session = String::from("provider-test");
|
||||
let _agent = manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
|
||||
assert!(manager.has_session(&session).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_eviction_updates_last_used() {
|
||||
AgentManager::reset_for_test();
|
||||
// Test that accessing a session updates its last_used timestamp
|
||||
// and affects eviction order
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
let sessions: Vec<_> = (0..100).map(|i| format!("session-{}", i)).collect();
|
||||
|
||||
for session in &sessions {
|
||||
manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
// Small delay to ensure different timestamps
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
// Access the first session again to update its last_used
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
manager
|
||||
.get_or_create_agent(sessions[0].clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now create a 101st session - should evict session2 (least recently used)
|
||||
let session101 = String::from("session-101");
|
||||
manager
|
||||
.get_or_create_agent(session101.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(manager.has_session(&sessions[0]).await);
|
||||
assert!(!manager.has_session(&sessions[1]).await);
|
||||
assert!(manager.has_session(&session101).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_nonexistent_session_error() {
|
||||
// Test that removing a non-existent session returns an error
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session = String::from("never-created");
|
||||
|
||||
let result = manager.remove_session(&session).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod scheduler_factory;
|
||||
pub mod scheduler_trait;
|
||||
pub mod security;
|
||||
pub mod session;
|
||||
pub mod session_context;
|
||||
pub mod token_counter;
|
||||
pub mod tool_inspection;
|
||||
pub mod tool_monitor;
|
||||
|
||||
+39
-13
@@ -2,33 +2,59 @@ use crate::config::paths::Paths;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Returns the directory where log files should be stored for a specific component.
|
||||
/// Creates the directory structure if it doesn't exist.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `component` - The component name (e.g., "cli", "server", "debug")
|
||||
/// * `component` - The component name (e.g., "cli", "server", "debug", "llm")
|
||||
/// * `use_date_subdir` - Whether to create a date-based subdirectory
|
||||
pub fn get_log_directory(component: &str, use_date_subdir: bool) -> Result<PathBuf> {
|
||||
pub fn prepare_log_directory(component: &str, use_date_subdir: bool) -> Result<PathBuf> {
|
||||
let base_log_dir = Paths::in_state_dir("logs");
|
||||
|
||||
let _ = cleanup_old_logs(component);
|
||||
|
||||
let component_dir = base_log_dir.join(component);
|
||||
|
||||
let log_dir = if use_date_subdir {
|
||||
// Create date-based subdirectory
|
||||
let now = chrono::Local::now();
|
||||
component_dir.join(now.format("%Y-%m-%d").to_string())
|
||||
component_dir.join(chrono::Local::now().format("%Y-%m-%d").to_string())
|
||||
} else {
|
||||
component_dir
|
||||
};
|
||||
|
||||
// Ensure log directory exists
|
||||
fs::create_dir_all(&log_dir).context("Failed to create log directory")?;
|
||||
|
||||
Ok(log_dir)
|
||||
}
|
||||
|
||||
pub fn cleanup_old_logs(component: &str) -> Result<()> {
|
||||
let base_log_dir = Paths::in_state_dir("logs");
|
||||
let component_dir = base_log_dir.join(component);
|
||||
|
||||
if !component_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let two_weeks = SystemTime::now() - Duration::from_secs(14 * 24 * 60 * 60);
|
||||
let entries = fs::read_dir(&component_dir)?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if modified < two_weeks && path.is_dir() {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -37,7 +63,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_log_directory_basic_functionality() {
|
||||
// Test basic directory creation without date subdirectory
|
||||
let result = get_log_directory("cli", false);
|
||||
let result = prepare_log_directory("cli", false);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log_dir = result.unwrap();
|
||||
@@ -59,7 +85,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_log_directory_with_date_subdir() {
|
||||
// Test date-based subdirectory creation
|
||||
let result = get_log_directory("server", true);
|
||||
let result = prepare_log_directory("server", true);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log_dir = result.unwrap();
|
||||
@@ -90,11 +116,11 @@ mod tests {
|
||||
// Test that multiple calls return the same result and don't fail
|
||||
let component = "debug";
|
||||
|
||||
let result1 = get_log_directory(component, false);
|
||||
let result1 = prepare_log_directory(component, false);
|
||||
assert!(result1.is_ok());
|
||||
let log_dir1 = result1.unwrap();
|
||||
|
||||
let result2 = get_log_directory(component, false);
|
||||
let result2 = prepare_log_directory(component, false);
|
||||
assert!(result2.is_ok());
|
||||
let log_dir2 = result2.unwrap();
|
||||
|
||||
@@ -104,11 +130,11 @@ mod tests {
|
||||
assert!(log_dir2.exists());
|
||||
|
||||
// Test same behavior with date subdirectories
|
||||
let result3 = get_log_directory(component, true);
|
||||
let result3 = prepare_log_directory(component, true);
|
||||
assert!(result3.is_ok());
|
||||
let log_dir3 = result3.unwrap();
|
||||
|
||||
let result4 = get_log_directory(component, true);
|
||||
let result4 = prepare_log_directory(component, true);
|
||||
assert!(result4.is_ok());
|
||||
let log_dir4 = result4.unwrap();
|
||||
|
||||
@@ -123,7 +149,7 @@ mod tests {
|
||||
let mut created_dirs = Vec::new();
|
||||
|
||||
for component in &components {
|
||||
let result = get_log_directory(component, false);
|
||||
let result = prepare_log_directory(component, false);
|
||||
assert!(result.is_ok(), "Failed for component: {}", component);
|
||||
|
||||
let log_dir = result.unwrap();
|
||||
|
||||
@@ -27,9 +27,8 @@ pub async fn save_credentials(
|
||||
token_response,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&credentials)?;
|
||||
let key = secret_key(name);
|
||||
config.set_secret(&key, value)?;
|
||||
config.set_secret(&key, &credentials)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::agents::extension_manager_extension::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
|
||||
use crate::config::permission::PermissionLevel;
|
||||
use crate::config::PermissionManager;
|
||||
use crate::config::{GooseMode, PermissionManager};
|
||||
use crate::conversation::message::{Message, ToolRequest};
|
||||
use crate::permission::permission_judge::PermissionCheckResult;
|
||||
use crate::tool_inspection::{InspectionAction, InspectionResult, ToolInspector};
|
||||
@@ -12,7 +12,7 @@ use tokio::sync::Mutex;
|
||||
|
||||
/// Permission Inspector that handles tool permission checking
|
||||
pub struct PermissionInspector {
|
||||
mode: Arc<Mutex<String>>,
|
||||
mode: Arc<Mutex<GooseMode>>,
|
||||
readonly_tools: HashSet<String>,
|
||||
regular_tools: HashSet<String>,
|
||||
pub permission_manager: Arc<Mutex<PermissionManager>>,
|
||||
@@ -20,7 +20,7 @@ pub struct PermissionInspector {
|
||||
|
||||
impl PermissionInspector {
|
||||
pub fn new(
|
||||
mode: String,
|
||||
mode: GooseMode,
|
||||
readonly_tools: HashSet<String>,
|
||||
regular_tools: HashSet<String>,
|
||||
) -> Self {
|
||||
@@ -33,7 +33,7 @@ impl PermissionInspector {
|
||||
}
|
||||
|
||||
pub fn with_permission_manager(
|
||||
mode: String,
|
||||
mode: GooseMode,
|
||||
readonly_tools: HashSet<String>,
|
||||
regular_tools: HashSet<String>,
|
||||
permission_manager: Arc<Mutex<PermissionManager>>,
|
||||
@@ -47,7 +47,7 @@ impl PermissionInspector {
|
||||
}
|
||||
|
||||
/// Update the mode of this permission inspector
|
||||
pub async fn update_mode(&self, new_mode: String) {
|
||||
pub async fn update_mode(&self, new_mode: GooseMode) {
|
||||
let mut mode = self.mode.lock().await;
|
||||
*mode = new_mode;
|
||||
}
|
||||
@@ -139,45 +139,42 @@ impl ToolInspector for PermissionInspector {
|
||||
if let Ok(tool_call) = &request.tool_call {
|
||||
let tool_name = &tool_call.name;
|
||||
|
||||
// Handle different modes
|
||||
let action = if *mode == "chat" {
|
||||
// In chat mode, all tools are skipped (handled elsewhere)
|
||||
continue;
|
||||
} else if *mode == "auto" {
|
||||
// In auto mode, all tools are approved
|
||||
InspectionAction::Allow
|
||||
} else {
|
||||
// Smart mode - check permissions
|
||||
|
||||
// 1. Check user-defined permission first
|
||||
if let Some(level) = permission_manager.get_user_permission(tool_name) {
|
||||
match level {
|
||||
PermissionLevel::AlwaysAllow => InspectionAction::Allow,
|
||||
PermissionLevel::NeverAllow => InspectionAction::Deny,
|
||||
PermissionLevel::AskBefore => InspectionAction::RequireApproval(None),
|
||||
let action = match *mode {
|
||||
GooseMode::Chat => continue,
|
||||
GooseMode::Auto => InspectionAction::Allow,
|
||||
GooseMode::Approve | GooseMode::SmartApprove => {
|
||||
// 1. Check user-defined permission first
|
||||
if let Some(level) = permission_manager.get_user_permission(tool_name) {
|
||||
match level {
|
||||
PermissionLevel::AlwaysAllow => InspectionAction::Allow,
|
||||
PermissionLevel::NeverAllow => InspectionAction::Deny,
|
||||
PermissionLevel::AskBefore => {
|
||||
InspectionAction::RequireApproval(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Check if it's a readonly or regular tool (both pre-approved)
|
||||
else if self.readonly_tools.contains(tool_name.as_ref())
|
||||
|| self.regular_tools.contains(tool_name.as_ref())
|
||||
{
|
||||
InspectionAction::Allow
|
||||
}
|
||||
// 4. Special case for extension management
|
||||
else if tool_name == MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE {
|
||||
InspectionAction::RequireApproval(Some(
|
||||
"Extension management requires approval for security".to_string(),
|
||||
))
|
||||
}
|
||||
// 5. Default: require approval for unknown tools
|
||||
else {
|
||||
InspectionAction::RequireApproval(None)
|
||||
}
|
||||
}
|
||||
// 2. Check if it's a readonly or regular tool (both pre-approved)
|
||||
else if self.readonly_tools.contains(tool_name.as_ref())
|
||||
|| self.regular_tools.contains(tool_name.as_ref())
|
||||
{
|
||||
InspectionAction::Allow
|
||||
}
|
||||
// 4. Special case for extension management
|
||||
else if tool_name == MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE {
|
||||
InspectionAction::RequireApproval(Some(
|
||||
"Extension management requires approval for security".to_string(),
|
||||
))
|
||||
}
|
||||
// 5. Default: require approval for unknown tools
|
||||
else {
|
||||
InspectionAction::RequireApproval(None)
|
||||
}
|
||||
};
|
||||
|
||||
let reason = match &action {
|
||||
InspectionAction::Allow => {
|
||||
if *mode == "auto" {
|
||||
if *mode == GooseMode::Auto {
|
||||
"Auto mode - all tools approved".to_string()
|
||||
} else if self.readonly_tools.contains(tool_name.as_ref()) {
|
||||
"Tool marked as read-only".to_string()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::session_context::SESSION_ID_HEADER;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{
|
||||
@@ -369,6 +370,10 @@ impl<'a> ApiRequestBuilder<'a> {
|
||||
let mut request = request_builder(url, &self.client.client);
|
||||
request = request.headers(self.headers.clone());
|
||||
|
||||
if let Some(session_id) = crate::session_context::current_session_id() {
|
||||
request = request.header(SESSION_ID_HEADER, session_id);
|
||||
}
|
||||
|
||||
request = match &self.client.auth {
|
||||
AuthMethod::BearerToken(token) => {
|
||||
request.header("Authorization", format!("Bearer {}", token))
|
||||
@@ -398,3 +403,55 @@ impl fmt::Debug for ApiClient {
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_header_injection() {
|
||||
let client = ApiClient::new(
|
||||
"http://localhost:8080".to_string(),
|
||||
AuthMethod::BearerToken("test-token".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Execute request within session context
|
||||
crate::session_context::with_session_id(Some("test-session-456".to_string()), async {
|
||||
let builder = client.request("/test");
|
||||
let request = builder
|
||||
.send_request(|url, client| client.get(url))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let headers = request.build().unwrap().headers().clone();
|
||||
|
||||
assert!(headers.contains_key(SESSION_ID_HEADER));
|
||||
assert_eq!(
|
||||
headers.get(SESSION_ID_HEADER).unwrap().to_str().unwrap(),
|
||||
"test-session-456"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_session_id_header_when_absent() {
|
||||
let client = ApiClient::new(
|
||||
"http://localhost:8080".to_string(),
|
||||
AuthMethod::BearerToken("test-token".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Build a request without session context
|
||||
let builder = client.request("/test");
|
||||
let request = builder
|
||||
.send_request(|url, client| client.get(url))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let headers = request.build().unwrap().headers().clone();
|
||||
|
||||
assert!(!headers.contains_key(SESSION_ID_HEADER));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ impl BedrockProvider {
|
||||
}
|
||||
};
|
||||
|
||||
set_aws_env_vars(config.load_values());
|
||||
set_aws_env_vars(config.load_secrets());
|
||||
set_aws_env_vars(config.all_values());
|
||||
set_aws_env_vars(config.all_secrets());
|
||||
|
||||
let sdk_config = aws_config::load_from_env().await;
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ use tokio::process::Command;
|
||||
|
||||
use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
|
||||
use super::errors::ProviderError;
|
||||
use super::utils::RequestLog;
|
||||
use crate::config::Config;
|
||||
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
|
||||
use crate::config::{Config, GooseMode};
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
use crate::model::ModelConfig;
|
||||
use rmcp::model::Tool;
|
||||
@@ -103,28 +103,6 @@ impl ClaudeCodeProvider {
|
||||
None
|
||||
}
|
||||
|
||||
/// Filter out the Extensions section from the system prompt
|
||||
fn filter_extensions_from_system_prompt(&self, system: &str) -> String {
|
||||
// Find the Extensions section and remove it
|
||||
if let Some(extensions_start) = system.find("# Extensions") {
|
||||
// Look for the next major section that starts with #
|
||||
let after_extensions = &system[extensions_start..];
|
||||
if let Some(next_section_pos) = after_extensions[1..].find("\n# ") {
|
||||
// Found next section, keep everything before Extensions and after the next section
|
||||
let before_extensions = &system[..extensions_start];
|
||||
let next_section_start = extensions_start + next_section_pos + 1;
|
||||
let after_next_section = &system[next_section_start..];
|
||||
format!("{}{}", before_extensions.trim_end(), after_next_section)
|
||||
} else {
|
||||
// No next section found, just remove everything from Extensions onward
|
||||
system[..extensions_start].trim_end().to_string()
|
||||
}
|
||||
} else {
|
||||
// No Extensions section found, return original
|
||||
system.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert goose messages to the format expected by claude CLI
|
||||
fn messages_to_claude_format(&self, _system: &str, messages: &[Message]) -> Result<Value> {
|
||||
let mut claude_messages = Vec::new();
|
||||
@@ -303,8 +281,7 @@ impl ClaudeCodeProvider {
|
||||
ProviderError::RequestFailed(format!("Failed to format messages: {}", e))
|
||||
})?;
|
||||
|
||||
// Create a filtered system prompt without Extensions section
|
||||
let filtered_system = self.filter_extensions_from_system_prompt(system);
|
||||
let filtered_system = filter_extensions_from_system_prompt(system);
|
||||
|
||||
if std::env::var("GOOSE_CLAUDE_CODE_DEBUG").is_ok() {
|
||||
println!("=== CLAUDE CODE PROVIDER DEBUG ===");
|
||||
@@ -338,10 +315,8 @@ impl ClaudeCodeProvider {
|
||||
|
||||
// Add permission mode based on GOOSE_MODE setting
|
||||
let config = Config::global();
|
||||
if let Ok(goose_mode) = config.get_param::<String>("GOOSE_MODE") {
|
||||
if goose_mode.as_str() == "auto" {
|
||||
cmd.arg("--permission-mode").arg("acceptEdits");
|
||||
}
|
||||
if let Ok(GooseMode::Auto) = config.get_goose_mode() {
|
||||
cmd.arg("--permission-mode").arg("acceptEdits");
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
@@ -523,18 +498,6 @@ mod tests {
|
||||
use super::ModelConfig;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_permission_mode_flag_construction() {
|
||||
// Test that in auto mode, the --permission-mode acceptEdits flag is added
|
||||
std::env::set_var("GOOSE_MODE", "auto");
|
||||
|
||||
let config = Config::global();
|
||||
let goose_mode: String = config.get_param("GOOSE_MODE").unwrap();
|
||||
assert_eq!(goose_mode, "auto");
|
||||
|
||||
std::env::remove_var("GOOSE_MODE");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_claude_code_invalid_model_no_fallback() {
|
||||
// Test that an invalid model is kept as-is (no fallback)
|
||||
|
||||
@@ -9,7 +9,7 @@ use tokio::process::Command;
|
||||
|
||||
use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
|
||||
use super::errors::ProviderError;
|
||||
use super::utils::RequestLog;
|
||||
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
use crate::model::ModelConfig;
|
||||
use rmcp::model::Tool;
|
||||
@@ -47,6 +47,17 @@ impl CursorAgentProvider {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get authentication status from cursor-agent
|
||||
async fn get_authentication_status(&self) -> bool {
|
||||
Command::new(&self.command)
|
||||
.arg("status")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).contains("✓ Logged in as"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Search for cursor-agent executable in common installation locations
|
||||
fn find_cursor_agent_executable(command_name: &str) -> Option<String> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
@@ -101,34 +112,11 @@ impl CursorAgentProvider {
|
||||
None
|
||||
}
|
||||
|
||||
/// Filter out the Extensions section from the system prompt
|
||||
fn filter_extensions_from_system_prompt(&self, system: &str) -> String {
|
||||
// Find the Extensions section and remove it
|
||||
if let Some(extensions_start) = system.find("# Extensions") {
|
||||
// Look for the next major section that starts with #
|
||||
let after_extensions = &system[extensions_start..];
|
||||
if let Some(next_section_pos) = after_extensions[1..].find("\n# ") {
|
||||
// Found next section, keep everything before Extensions and after the next section
|
||||
let before_extensions = &system[..extensions_start];
|
||||
let next_section_start = extensions_start + next_section_pos + 1;
|
||||
let after_next_section = &system[next_section_start..];
|
||||
format!("{}{}", before_extensions.trim_end(), after_next_section)
|
||||
} else {
|
||||
// No next section found, just remove everything from Extensions onward
|
||||
system[..extensions_start].trim_end().to_string()
|
||||
}
|
||||
} else {
|
||||
// No Extensions section found, return original
|
||||
system.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert goose messages to a simple prompt format for cursor-agent CLI
|
||||
fn messages_to_cursor_agent_format(&self, system: &str, messages: &[Message]) -> String {
|
||||
let mut full_prompt = String::new();
|
||||
|
||||
// Add system prompt
|
||||
let filtered_system = self.filter_extensions_from_system_prompt(system);
|
||||
let filtered_system = filter_extensions_from_system_prompt(system);
|
||||
full_prompt.push_str(&filtered_system);
|
||||
full_prompt.push_str("\n\n");
|
||||
|
||||
@@ -256,7 +244,7 @@ impl CursorAgentProvider {
|
||||
println!("Original system prompt length: {} chars", system.len());
|
||||
println!(
|
||||
"Filtered system prompt length: {} chars",
|
||||
self.filter_extensions_from_system_prompt(system).len()
|
||||
filter_extensions_from_system_prompt(system).len()
|
||||
);
|
||||
println!("Full prompt: {}", prompt);
|
||||
println!("Model: {}", self.model.model_name);
|
||||
@@ -319,6 +307,11 @@ impl CursorAgentProvider {
|
||||
})?;
|
||||
|
||||
if !exit_status.success() {
|
||||
if !self.get_authentication_status().await {
|
||||
return Err(ProviderError::Authentication(
|
||||
"You are not logged in to cursor-agent. Please run 'cursor-agent login' to authenticate first."
|
||||
.to_string()));
|
||||
}
|
||||
return Err(ProviderError::RequestFailed(format!(
|
||||
"Command failed with exit code: {:?}",
|
||||
exit_status.code()
|
||||
|
||||
@@ -152,28 +152,24 @@ impl DatabricksProvider {
|
||||
};
|
||||
|
||||
// Check if the default fast model exists in the workspace
|
||||
let model_with_fast = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(async {
|
||||
if let Ok(Some(models)) = provider.fetch_supported_models().await {
|
||||
if models.contains(&DATABRICKS_DEFAULT_FAST_MODEL.to_string()) {
|
||||
tracing::debug!(
|
||||
"Found {} in Databricks workspace, setting as fast model",
|
||||
DATABRICKS_DEFAULT_FAST_MODEL
|
||||
);
|
||||
model.with_fast(DATABRICKS_DEFAULT_FAST_MODEL.to_string())
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"{} not found in Databricks workspace, not setting fast model",
|
||||
DATABRICKS_DEFAULT_FAST_MODEL
|
||||
);
|
||||
model
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Could not fetch Databricks models, not setting fast model");
|
||||
model
|
||||
}
|
||||
})
|
||||
});
|
||||
let model_with_fast = if let Ok(Some(models)) = provider.fetch_supported_models().await {
|
||||
if models.contains(&DATABRICKS_DEFAULT_FAST_MODEL.to_string()) {
|
||||
tracing::debug!(
|
||||
"Found {} in Databricks workspace, setting as fast model",
|
||||
DATABRICKS_DEFAULT_FAST_MODEL
|
||||
);
|
||||
model.with_fast(DATABRICKS_DEFAULT_FAST_MODEL.to_string())
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"{} not found in Databricks workspace, not setting fast model",
|
||||
DATABRICKS_DEFAULT_FAST_MODEL
|
||||
);
|
||||
model
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Could not fetch Databricks models, not setting fast model");
|
||||
model
|
||||
};
|
||||
|
||||
provider.model = model_with_fast;
|
||||
Ok(provider)
|
||||
|
||||
@@ -212,13 +212,7 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<Data
|
||||
// Skip tool confirmation requests
|
||||
}
|
||||
MessageContent::Image(image) => {
|
||||
// Handle direct image content
|
||||
content_array.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": convert_image(image, image_format)
|
||||
}
|
||||
}));
|
||||
content_array.push(convert_image(image, image_format));
|
||||
}
|
||||
MessageContent::FrontendToolRequest(req) => {
|
||||
// Frontend tool requests are converted to text messages
|
||||
|
||||
@@ -238,7 +238,7 @@ pub fn process_map(map: &Map<String, Value>, parent_key: Option<&str>) -> Value
|
||||
value.clone()
|
||||
}
|
||||
}
|
||||
_ => value.clone(),
|
||||
_ => process_value(value, Some(key.as_str())),
|
||||
};
|
||||
|
||||
Some((key.clone(), processed_value))
|
||||
@@ -858,4 +858,36 @@ mod tests {
|
||||
|
||||
assert_eq!(payload, expected_payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tools_with_nullable_types_converted_to_single_type() {
|
||||
// Test that type arrays like ["string", "null"] are converted to single types
|
||||
let params = object!({
|
||||
"properties": {
|
||||
"nullable_field": {
|
||||
"type": ["string", "null"],
|
||||
"description": "A nullable string field"
|
||||
},
|
||||
"regular_field": {
|
||||
"type": "number",
|
||||
"description": "A regular number field"
|
||||
}
|
||||
}
|
||||
});
|
||||
let tools = vec![Tool::new("test_tool", "test description", params)];
|
||||
let result = format_tools(&tools);
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0]["name"], "test_tool");
|
||||
|
||||
// Verify that the type array was converted to a single string type
|
||||
let nullable_field = &result[0]["parameters"]["properties"]["nullable_field"];
|
||||
assert_eq!(nullable_field["type"], "string");
|
||||
assert_eq!(nullable_field["description"], "A nullable string field");
|
||||
|
||||
// Verify that regular types are unchanged
|
||||
let regular_field = &result[0]["parameters"]["properties"]["regular_field"];
|
||||
assert_eq!(regular_field["type"], "number");
|
||||
assert_eq!(regular_field["description"], "A regular number field");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,9 @@ pub fn parse_streaming_response(sse_data: &str) -> Result<Message> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json_str = &line[6..]; // Remove "data: " prefix
|
||||
let Some(json_str) = line.get(6..) else {
|
||||
continue;
|
||||
}; // Remove "data: " prefix
|
||||
if json_str.trim().is_empty() || json_str.trim() == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use tokio::process::Command;
|
||||
|
||||
use super::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
|
||||
use super::errors::ProviderError;
|
||||
use super::utils::RequestLog;
|
||||
use super::utils::{filter_extensions_from_system_prompt, RequestLog};
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
|
||||
use crate::model::ModelConfig;
|
||||
@@ -103,28 +103,6 @@ impl GeminiCliProvider {
|
||||
None
|
||||
}
|
||||
|
||||
/// Filter out the Extensions section from the system prompt
|
||||
fn filter_extensions_from_system_prompt(&self, system: &str) -> String {
|
||||
// Find the Extensions section and remove it
|
||||
if let Some(extensions_start) = system.find("# Extensions") {
|
||||
// Look for the next major section that starts with #
|
||||
let after_extensions = &system[extensions_start..];
|
||||
if let Some(next_section_pos) = after_extensions[1..].find("\n# ") {
|
||||
// Found next section, keep everything before Extensions and after the next section
|
||||
let before_extensions = &system[..extensions_start];
|
||||
let next_section_start = extensions_start + next_section_pos + 1;
|
||||
let after_next_section = &system[next_section_start..];
|
||||
format!("{}{}", before_extensions.trim_end(), after_next_section)
|
||||
} else {
|
||||
// No next section found, just remove everything from Extensions onward
|
||||
system[..extensions_start].trim_end().to_string()
|
||||
}
|
||||
} else {
|
||||
// No Extensions section found, return original
|
||||
system.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute gemini CLI command with simple text prompt
|
||||
async fn execute_command(
|
||||
&self,
|
||||
@@ -135,8 +113,7 @@ impl GeminiCliProvider {
|
||||
// Create a simple prompt combining system + conversation
|
||||
let mut full_prompt = String::new();
|
||||
|
||||
// Add system prompt
|
||||
let filtered_system = self.filter_extensions_from_system_prompt(system);
|
||||
let filtered_system = filter_extensions_from_system_prompt(system);
|
||||
full_prompt.push_str(&filtered_system);
|
||||
full_prompt.push_str("\n\n");
|
||||
|
||||
|
||||
@@ -169,7 +169,9 @@ impl GithubCopilotProvider {
|
||||
if !tline.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
let payload = &tline[6..];
|
||||
let Some(payload) = tline.get(6..) else {
|
||||
continue;
|
||||
};
|
||||
if payload == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
@@ -235,7 +237,7 @@ impl GithubCopilotProvider {
|
||||
.get_access_token()
|
||||
.await
|
||||
.context("unable to login into github")?;
|
||||
config.set_secret("GITHUB_COPILOT_TOKEN", Value::String(token.clone()))?;
|
||||
config.set_secret("GITHUB_COPILOT_TOKEN", &token)?;
|
||||
token
|
||||
}
|
||||
_ => return Err(err.into()),
|
||||
@@ -500,7 +502,7 @@ impl Provider for GithubCopilotProvider {
|
||||
|
||||
// Save the token
|
||||
config
|
||||
.set_secret("GITHUB_COPILOT_TOKEN", Value::String(token))
|
||||
.set_secret("GITHUB_COPILOT_TOKEN", &token)
|
||||
.map_err(|e| ProviderError::ExecutionError(format!("Failed to save token: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod anthropic;
|
||||
mod api_client;
|
||||
pub mod api_client;
|
||||
pub mod azure;
|
||||
pub mod azureauth;
|
||||
pub mod base;
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::utils::{
|
||||
get_model, handle_response_openai_compat, handle_status_openai_compat, RequestLog,
|
||||
};
|
||||
use crate::config::declarative_providers::DeclarativeProviderConfig;
|
||||
use crate::config::GooseMode;
|
||||
use crate::conversation::message::Message;
|
||||
use crate::conversation::Conversation;
|
||||
|
||||
@@ -199,8 +200,12 @@ impl Provider for OllamaProvider {
|
||||
tools: &[Tool],
|
||||
) -> Result<(Message, ProviderUsage), ProviderError> {
|
||||
let config = crate::config::Config::global();
|
||||
let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
|
||||
let filtered_tools = if goose_mode == "chat" { &[] } else { tools };
|
||||
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
|
||||
let filtered_tools = if goose_mode == GooseMode::Chat {
|
||||
&[]
|
||||
} else {
|
||||
tools
|
||||
};
|
||||
|
||||
let payload = create_request(
|
||||
&self.model,
|
||||
|
||||
@@ -16,6 +16,7 @@ use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata, ProviderUsag
|
||||
use super::embedding::{EmbeddingCapable, EmbeddingRequest, EmbeddingResponse};
|
||||
use super::errors::ProviderError;
|
||||
use super::formats::openai::{create_request, get_usage, response_to_message};
|
||||
use super::retry::ProviderRetry;
|
||||
use super::utils::{
|
||||
get_model, handle_response_openai_compat, handle_status_openai_compat, ImageFormat,
|
||||
};
|
||||
@@ -112,6 +113,20 @@ impl OpenAiProvider {
|
||||
})
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn new(api_client: ApiClient, model: ModelConfig) -> Self {
|
||||
Self {
|
||||
api_client,
|
||||
base_path: "v1/chat/completions".to_string(),
|
||||
organization: None,
|
||||
project: None,
|
||||
model,
|
||||
custom_headers: None,
|
||||
supports_streaming: true,
|
||||
name: Self::metadata().name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_custom_config(
|
||||
model: ModelConfig,
|
||||
config: DeclarativeProviderConfig,
|
||||
@@ -226,9 +241,15 @@ impl Provider for OpenAiProvider {
|
||||
let payload = create_request(model_config, system, messages, tools, &ImageFormat::OpenAi)?;
|
||||
|
||||
let mut log = RequestLog::start(&self.model, &payload)?;
|
||||
let json_response = self.post(&payload).await.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
let json_response = self
|
||||
.with_retry(|| async {
|
||||
let payload_clone = payload.clone();
|
||||
self.post(&payload_clone).await
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
|
||||
let message = response_to_message(&json_response)?;
|
||||
let usage = json_response
|
||||
@@ -246,19 +267,30 @@ impl Provider for OpenAiProvider {
|
||||
|
||||
async fn fetch_supported_models(&self) -> Result<Option<Vec<String>>, ProviderError> {
|
||||
let models_path = self.base_path.replace("v1/chat/completions", "v1/models");
|
||||
let response = self.api_client.response_get(&models_path).await?;
|
||||
let json = handle_response_openai_compat(response).await?;
|
||||
if let Some(err_obj) = json.get("error") {
|
||||
let msg = err_obj
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
return Err(ProviderError::Authentication(msg.to_string()));
|
||||
}
|
||||
let response = self
|
||||
.with_retry(|| async {
|
||||
let response = self.api_client.response_get(&models_path).await?;
|
||||
let json = handle_response_openai_compat(response).await?;
|
||||
if let Some(err_obj) = json.get("error") {
|
||||
let msg = err_obj
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
return Err(ProviderError::Authentication(msg.to_string()));
|
||||
}
|
||||
Ok(json)
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
tracing::warn!("Failed to fetch supported models from OpenAI: {:?}", e);
|
||||
})?;
|
||||
|
||||
let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
|
||||
ProviderError::UsageError("Missing data field in JSON response".into())
|
||||
})?;
|
||||
let data = response
|
||||
.get("data")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| {
|
||||
ProviderError::UsageError("Missing data field in JSON response".into())
|
||||
})?;
|
||||
let mut models: Vec<String> = data
|
||||
.iter()
|
||||
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
|
||||
@@ -296,17 +328,24 @@ impl Provider for OpenAiProvider {
|
||||
let mut log = RequestLog::start(&self.model, &payload)?;
|
||||
|
||||
let response = self
|
||||
.api_client
|
||||
.response_post(&self.base_path, &payload)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
let response = handle_status_openai_compat(response)
|
||||
.with_retry(|| async {
|
||||
let resp = self
|
||||
.api_client
|
||||
.response_post(&self.base_path, &payload)
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(super::utils::map_http_error_to_provider_error(
|
||||
status, None, // We'll let handle_status_openai_compat parse the error
|
||||
));
|
||||
}
|
||||
Ok(resp)
|
||||
})
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
let _ = log.error(e);
|
||||
})?;
|
||||
let response = handle_status_openai_compat(response).await?;
|
||||
|
||||
let stream = response.bytes_stream().map_err(io::Error::other);
|
||||
|
||||
@@ -352,8 +391,18 @@ impl EmbeddingCapable for OpenAiProvider {
|
||||
};
|
||||
|
||||
let response = self
|
||||
.api_client
|
||||
.api_post("v1/embeddings", &serde_json::to_value(request)?)
|
||||
.with_retry(|| async {
|
||||
let request_clone = EmbeddingRequest {
|
||||
input: request.input.clone(),
|
||||
model: request.model.clone(),
|
||||
};
|
||||
let request_value = serde_json::to_value(request_clone)
|
||||
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?;
|
||||
self.api_client
|
||||
.api_post("v1/embeddings", &request_value)
|
||||
.await
|
||||
.map_err(|e| ProviderError::ExecutionError(e.to_string()))
|
||||
})
|
||||
.await?;
|
||||
|
||||
if response.status != StatusCode::OK {
|
||||
|
||||
@@ -53,8 +53,8 @@ impl SageMakerTgiProvider {
|
||||
}
|
||||
};
|
||||
|
||||
set_aws_env_vars(config.load_values());
|
||||
set_aws_env_vars(config.load_secrets());
|
||||
set_aws_env_vars(config.all_values());
|
||||
set_aws_env_vars(config.all_secrets());
|
||||
|
||||
let aws_config = aws_config::load_from_env().await;
|
||||
|
||||
@@ -246,7 +246,7 @@ impl SageMakerTgiProvider {
|
||||
// Remove any remaining HTML-like tags using a simple pattern
|
||||
// This is a basic implementation - for production use, consider using a proper HTML parser
|
||||
while let Some(start) = result.find('<') {
|
||||
if let Some(end) = result[start..].find('>') {
|
||||
if let Some(end) = result.get(start..).and_then(|s| s.find('>')) {
|
||||
result.replace_range(start..start + end + 1, "");
|
||||
} else {
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::base::Usage;
|
||||
use super::errors::GoogleErrorCode;
|
||||
use crate::config::paths::Paths;
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::errors::{OpenAIError, ProviderError};
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -48,6 +47,31 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_extensions_from_system_prompt(system: &str) -> String {
|
||||
let Some(extensions_start) = system.find("# Extensions") else {
|
||||
return system.to_string();
|
||||
};
|
||||
|
||||
let Some(after_extensions) = system.get(extensions_start + 1..) else {
|
||||
return system.to_string();
|
||||
};
|
||||
|
||||
if let Some(next_section_pos) = after_extensions.find("\n# ") {
|
||||
let Some(before) = system.get(..extensions_start) else {
|
||||
return system.to_string();
|
||||
};
|
||||
let Some(after) = system.get(extensions_start + next_section_pos + 1..) else {
|
||||
return system.to_string();
|
||||
};
|
||||
format!("{}{}", before.trim_end(), after)
|
||||
} else {
|
||||
system
|
||||
.get(..extensions_start)
|
||||
.map(|s| s.trim_end().to_string())
|
||||
.unwrap_or_else(|| system.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_context_length_exceeded(text: &str) -> bool {
|
||||
let check_phrases = [
|
||||
"too long",
|
||||
@@ -68,6 +92,16 @@ fn check_context_length_exceeded(text: &str) -> bool {
|
||||
.any(|phrase| text_lower.contains(phrase))
|
||||
}
|
||||
|
||||
fn format_server_error_message(status_code: StatusCode, payload: Option<&Value>) -> String {
|
||||
match payload {
|
||||
Some(Value::Null) | None => format!(
|
||||
"HTTP {}: No response body received from server",
|
||||
status_code.as_u16()
|
||||
),
|
||||
Some(p) => format!("HTTP {}: {}", status_code.as_u16(), p),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_http_error_to_provider_error(
|
||||
status: StatusCode,
|
||||
payload: Option<Value>,
|
||||
@@ -79,7 +113,7 @@ pub fn map_http_error_to_provider_error(
|
||||
"Authentication failed. Please ensure your API keys are valid and have the required permissions. \
|
||||
Status: {}{}",
|
||||
status,
|
||||
payload.as_ref().map(|p| format!(". Response: {:?}", p)).unwrap_or_default()
|
||||
payload.as_ref().map(|p| format!(". Response: {}", p)).unwrap_or_default()
|
||||
);
|
||||
ProviderError::Authentication(message)
|
||||
}
|
||||
@@ -116,7 +150,9 @@ pub fn map_http_error_to_provider_error(
|
||||
details: format!("{:?}", payload),
|
||||
retry_delay: None,
|
||||
},
|
||||
_ if status.is_server_error() => ProviderError::ServerError(format!("{:?}", payload)),
|
||||
_ if status.is_server_error() => {
|
||||
ProviderError::ServerError(format_server_error_message(status, payload.as_ref()))
|
||||
}
|
||||
_ => ProviderError::RequestFailed(format!("Request failed with status: {}", status)),
|
||||
};
|
||||
|
||||
@@ -295,12 +331,9 @@ pub async fn handle_response_google_compat(response: Response) -> Result<Value,
|
||||
retry_delay,
|
||||
})
|
||||
}
|
||||
_ if final_status.is_server_error() => {
|
||||
Err(ProviderError::ServerError(format!("{:?}", payload)))
|
||||
}
|
||||
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => {
|
||||
Err(ProviderError::ServerError(format!("{:?}", payload)))
|
||||
}
|
||||
_ if final_status.is_server_error() => Err(ProviderError::ServerError(
|
||||
format_server_error_message(final_status, payload.as_ref()),
|
||||
)),
|
||||
_ => {
|
||||
tracing::debug!(
|
||||
"{}", format!("Provider request failed with status: {}. Payload: {:?}", final_status, payload)
|
||||
@@ -462,8 +495,7 @@ impl RequestLog {
|
||||
where
|
||||
Payload: Serialize,
|
||||
{
|
||||
let logs_dir = Paths::in_state_dir("logs");
|
||||
std::fs::create_dir_all(&logs_dir)?;
|
||||
let logs_dir = crate::logging::prepare_log_directory("llm", true)?;
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let temp_name = format!("llm_request.{request_id}.jsonl");
|
||||
@@ -520,7 +552,7 @@ impl RequestLog {
|
||||
fn finish(&mut self) -> Result<()> {
|
||||
if let Some(mut writer) = self.writer.take() {
|
||||
writer.flush()?;
|
||||
let logs_dir = Paths::in_state_dir("logs");
|
||||
let logs_dir = crate::logging::prepare_log_directory("llm", true)?;
|
||||
let log_path = |i| logs_dir.join(format!("llm_request.{}.jsonl", i));
|
||||
|
||||
for i in (0..LOGS_TO_KEEP - 1).rev() {
|
||||
@@ -1059,15 +1091,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_map_http_error_to_provider_error() {
|
||||
let test_cases = vec![
|
||||
// UNAUTHORIZED/FORBIDDEN - with payload
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Some(json!({"error": "auth failed"})),
|
||||
ProviderError::Authentication(
|
||||
"Authentication failed. Please ensure your API keys are valid and have the required permissions. Status: 401 Unauthorized. Response: Object {\"error\": String(\"auth failed\")}".to_string(),
|
||||
"Authentication failed. Please ensure your API keys are valid and have the required permissions. Status: 401 Unauthorized. Response: {\"error\":\"auth failed\"}".to_string(),
|
||||
),
|
||||
),
|
||||
// UNAUTHORIZED/FORBIDDEN - without payload
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
None,
|
||||
@@ -1075,7 +1105,6 @@ mod tests {
|
||||
"Authentication failed. Please ensure your API keys are valid and have the required permissions. Status: 403 Forbidden".to_string(),
|
||||
),
|
||||
),
|
||||
// BAD_REQUEST - with context_length_exceeded detection
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Some(json!({"error": {"message": "context_length_exceeded"}})),
|
||||
@@ -1083,7 +1112,6 @@ mod tests {
|
||||
"{\"error\":{\"message\":\"context_length_exceeded\"}}".to_string(),
|
||||
),
|
||||
),
|
||||
// BAD_REQUEST - with error.message extraction
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Some(json!({"error": {"message": "Custom error"}})),
|
||||
@@ -1091,7 +1119,6 @@ mod tests {
|
||||
"Request failed with status: 400 Bad Request. Message: Custom error".to_string(),
|
||||
),
|
||||
),
|
||||
// BAD_REQUEST - without payload
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
None,
|
||||
@@ -1099,7 +1126,6 @@ mod tests {
|
||||
"Request failed with status: 400 Bad Request".to_string(),
|
||||
),
|
||||
),
|
||||
// TOO_MANY_REQUESTS
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Some(json!({"retry_after": 60})),
|
||||
@@ -1108,17 +1134,29 @@ mod tests {
|
||||
retry_delay: None,
|
||||
},
|
||||
),
|
||||
// is_server_error() without payload
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
None,
|
||||
ProviderError::ServerError("None".to_string()),
|
||||
ProviderError::ServerError(format_server_error_message(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
None,
|
||||
)),
|
||||
),
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Some(Value::Null),
|
||||
ProviderError::ServerError(format_server_error_message(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Some(&Value::Null),
|
||||
)),
|
||||
),
|
||||
// is_server_error() with payload
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Some(json!({"error": "upstream error"})),
|
||||
ProviderError::ServerError("Some(Object {\"error\": String(\"upstream error\")})".to_string()),
|
||||
ProviderError::ServerError(format_server_error_message(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Some(&json!({"error": "upstream error"})),
|
||||
)),
|
||||
),
|
||||
// Default - any other status code
|
||||
(
|
||||
|
||||
@@ -275,7 +275,9 @@ data: [DONE]
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
let payload = &line[6..];
|
||||
let Some(payload) = line.get(6..) else {
|
||||
continue;
|
||||
};
|
||||
if payload == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
@@ -325,7 +327,9 @@ data: [DONE]
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
let payload = &line[6..];
|
||||
let Some(payload) = line.get(6..) else {
|
||||
continue;
|
||||
};
|
||||
if payload == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
@@ -377,7 +381,9 @@ data: [DONE]
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
let payload = &line[6..];
|
||||
let Some(payload) = line.get(6..) else {
|
||||
continue;
|
||||
};
|
||||
if payload == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflic
|
||||
use crate::providers::create;
|
||||
use crate::recipe::Recipe;
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::session::session_manager::SessionType;
|
||||
use crate::session::{Session, SessionManager};
|
||||
|
||||
// Track running tasks with their abort handles
|
||||
@@ -152,8 +153,6 @@ pub struct ScheduledJob {
|
||||
pub current_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub process_start_time: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub execution_mode: Option<String>, // "foreground" or "background"
|
||||
}
|
||||
|
||||
async fn persist_jobs_from_arc(
|
||||
@@ -1104,7 +1103,7 @@ async fn run_scheduled_job_internal(
|
||||
agent_provider = provider;
|
||||
} else {
|
||||
let global_config = Config::global();
|
||||
let provider_name: String = match global_config.get_param("GOOSE_PROVIDER") {
|
||||
let provider_name: String = match global_config.get_goose_provider() {
|
||||
Ok(name) => name,
|
||||
Err(_) => return Err(JobExecutionError {
|
||||
job_id: job.id.clone(),
|
||||
@@ -1114,7 +1113,7 @@ async fn run_scheduled_job_internal(
|
||||
}),
|
||||
};
|
||||
let model_name: String =
|
||||
match global_config.get_param("GOOSE_MODEL") {
|
||||
match global_config.get_goose_model() {
|
||||
Ok(name) => name,
|
||||
Err(_) => return Err(JobExecutionError {
|
||||
job_id: job.id.clone(),
|
||||
@@ -1160,8 +1159,6 @@ async fn run_scheduled_job_internal(
|
||||
});
|
||||
}
|
||||
tracing::info!("Agent configured with provider for job '{}'", job.id);
|
||||
let execution_mode = job.execution_mode.as_deref().unwrap_or("background");
|
||||
tracing::info!("Job '{}' running in {} mode", job.id, execution_mode);
|
||||
|
||||
let current_dir = match std::env::current_dir() {
|
||||
Ok(cd) => cd,
|
||||
@@ -1173,10 +1170,10 @@ async fn run_scheduled_job_internal(
|
||||
}
|
||||
};
|
||||
|
||||
// Create session upfront
|
||||
let session = match SessionManager::create_session(
|
||||
current_dir.clone(),
|
||||
format!("Scheduled job: {}", job.id),
|
||||
SessionType::Scheduled,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1204,21 +1201,21 @@ async fn run_scheduled_job_internal(
|
||||
.or(recipe.instructions.as_ref())
|
||||
.unwrap();
|
||||
|
||||
let mut conversation =
|
||||
Conversation::new_unvalidated(vec![Message::user().with_text(prompt_text.clone())]);
|
||||
let user_message = Message::user().with_text(prompt_text);
|
||||
let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]);
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session.id.clone(),
|
||||
working_dir: current_dir.clone(),
|
||||
schedule_id: Some(job.id.clone()),
|
||||
execution_mode: job.execution_mode.clone(),
|
||||
max_turns: None,
|
||||
retry_config: None,
|
||||
};
|
||||
|
||||
match agent
|
||||
.reply(conversation.clone(), Some(session_config.clone()), None)
|
||||
.await
|
||||
let session_id = Some(session_config.id.clone());
|
||||
match crate::session_context::with_session_id(session_id, async {
|
||||
agent.reply(user_message, session_config, None).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(mut stream) => {
|
||||
use futures::StreamExt;
|
||||
@@ -1452,7 +1449,6 @@ mod tests {
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
execution_mode: Some("background".to_string()), // Default for test
|
||||
};
|
||||
|
||||
let mock_model_config = ModelConfig::new_or_fail("test_model");
|
||||
|
||||
@@ -6,4 +6,4 @@ pub mod session_manager;
|
||||
|
||||
pub use diagnostics::generate_diagnostics;
|
||||
pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState, TodoState};
|
||||
pub use session_manager::{Session, SessionInsights, SessionManager};
|
||||
pub use session_manager::{Session, SessionInsights, SessionManager, SessionType};
|
||||
|
||||
@@ -18,7 +18,47 @@ use tokio::sync::OnceCell;
|
||||
use tracing::{info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
const CURRENT_SCHEMA_VERSION: i32 = 4;
|
||||
const CURRENT_SCHEMA_VERSION: i32 = 5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionType {
|
||||
User,
|
||||
Scheduled,
|
||||
SubAgent,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl Default for SessionType {
|
||||
fn default() -> Self {
|
||||
Self::User
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SessionType::User => write!(f, "user"),
|
||||
SessionType::SubAgent => write!(f, "sub_agent"),
|
||||
SessionType::Hidden => write!(f, "hidden"),
|
||||
SessionType::Scheduled => write!(f, "scheduled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for SessionType {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"user" => Ok(SessionType::User),
|
||||
"sub_agent" => Ok(SessionType::SubAgent),
|
||||
"hidden" => Ok(SessionType::Hidden),
|
||||
"scheduled" => Ok(SessionType::Scheduled),
|
||||
_ => Err(anyhow::anyhow!("Invalid session type: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static SESSION_STORAGE: OnceCell<Arc<SessionStorage>> = OnceCell::const_new();
|
||||
|
||||
@@ -27,11 +67,12 @@ pub struct Session {
|
||||
pub id: String,
|
||||
#[schema(value_type = String)]
|
||||
pub working_dir: PathBuf,
|
||||
// Allow importing session exports from before 'description' was renamed to 'name'
|
||||
#[serde(alias = "description")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub user_set_name: bool,
|
||||
#[serde(default)]
|
||||
pub session_type: SessionType,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub extension_data: ExtensionData,
|
||||
@@ -52,6 +93,7 @@ pub struct SessionUpdateBuilder {
|
||||
session_id: String,
|
||||
name: Option<String>,
|
||||
user_set_name: Option<bool>,
|
||||
session_type: Option<SessionType>,
|
||||
working_dir: Option<PathBuf>,
|
||||
extension_data: Option<ExtensionData>,
|
||||
total_tokens: Option<Option<i32>>,
|
||||
@@ -105,6 +147,7 @@ impl SessionUpdateBuilder {
|
||||
session_id,
|
||||
name: None,
|
||||
user_set_name: None,
|
||||
session_type: None,
|
||||
working_dir: None,
|
||||
extension_data: None,
|
||||
total_tokens: None,
|
||||
@@ -137,6 +180,11 @@ impl SessionUpdateBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn session_type(mut self, session_type: SessionType) -> Self {
|
||||
self.session_type = Some(session_type);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn working_dir(mut self, working_dir: PathBuf) -> Self {
|
||||
self.working_dir = Some(working_dir);
|
||||
self
|
||||
@@ -210,10 +258,14 @@ impl SessionManager {
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
pub async fn create_session(working_dir: PathBuf, name: String) -> Result<Session> {
|
||||
pub async fn create_session(
|
||||
working_dir: PathBuf,
|
||||
name: String,
|
||||
session_type: SessionType,
|
||||
) -> Result<Session> {
|
||||
Self::instance()
|
||||
.await?
|
||||
.create_session(working_dir, name)
|
||||
.create_session(working_dir, name, session_type)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -349,6 +401,7 @@ impl Default for Session {
|
||||
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
name: String::new(),
|
||||
user_set_name: false,
|
||||
session_type: SessionType::default(),
|
||||
created_at: Default::default(),
|
||||
updated_at: Default::default(),
|
||||
extension_data: ExtensionData::default(),
|
||||
@@ -396,11 +449,17 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
|
||||
|
||||
let user_set_name = row.try_get("user_set_name").unwrap_or(false);
|
||||
|
||||
let session_type_str: String = row
|
||||
.try_get("session_type")
|
||||
.unwrap_or_else(|_| "user".to_string());
|
||||
let session_type = session_type_str.parse().unwrap_or_default();
|
||||
|
||||
Ok(Session {
|
||||
id: row.try_get("id")?,
|
||||
working_dir: PathBuf::from(row.try_get::<String, _>("working_dir")?),
|
||||
name,
|
||||
user_set_name,
|
||||
session_type,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
extension_data: serde_json::from_str(&row.try_get::<String, _>("extension_data")?)
|
||||
@@ -489,6 +548,7 @@ impl SessionStorage {
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
user_set_name BOOLEAN DEFAULT FALSE,
|
||||
session_type TEXT NOT NULL DEFAULT 'user',
|
||||
working_dir TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -534,6 +594,9 @@ impl SessionStorage {
|
||||
sqlx::query("CREATE INDEX idx_sessions_updated ON sessions(updated_at DESC)")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
@@ -596,31 +659,32 @@ impl SessionStorage {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sessions (
|
||||
id, name, user_set_name, working_dir, created_at, updated_at, extension_data,
|
||||
id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data,
|
||||
total_tokens, input_tokens, output_tokens,
|
||||
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
|
||||
schedule_id, recipe_json, user_recipe_values_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&session.id)
|
||||
.bind(&session.name)
|
||||
.bind(session.user_set_name)
|
||||
.bind(session.working_dir.to_string_lossy().as_ref())
|
||||
.bind(session.created_at)
|
||||
.bind(session.updated_at)
|
||||
.bind(serde_json::to_string(&session.extension_data)?)
|
||||
.bind(session.total_tokens)
|
||||
.bind(session.input_tokens)
|
||||
.bind(session.output_tokens)
|
||||
.bind(session.accumulated_total_tokens)
|
||||
.bind(session.accumulated_input_tokens)
|
||||
.bind(session.accumulated_output_tokens)
|
||||
.bind(&session.schedule_id)
|
||||
.bind(recipe_json)
|
||||
.bind(user_recipe_values_json)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
.bind(&session.id)
|
||||
.bind(&session.name)
|
||||
.bind(session.user_set_name)
|
||||
.bind(session.session_type.to_string())
|
||||
.bind(session.working_dir.to_string_lossy().as_ref())
|
||||
.bind(session.created_at)
|
||||
.bind(session.updated_at)
|
||||
.bind(serde_json::to_string(&session.extension_data)?)
|
||||
.bind(session.total_tokens)
|
||||
.bind(session.input_tokens)
|
||||
.bind(session.output_tokens)
|
||||
.bind(session.accumulated_total_tokens)
|
||||
.bind(session.accumulated_input_tokens)
|
||||
.bind(session.accumulated_output_tokens)
|
||||
.bind(&session.schedule_id)
|
||||
.bind(recipe_json)
|
||||
.bind(user_recipe_values_json)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(conversation) = &session.conversation {
|
||||
self.replace_conversation(&session.id, conversation).await?;
|
||||
@@ -750,6 +814,19 @@ impl SessionStorage {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
5 => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user'
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Unknown migration version: {}", version);
|
||||
}
|
||||
@@ -758,11 +835,16 @@ impl SessionStorage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_session(&self, working_dir: PathBuf, name: String) -> Result<Session> {
|
||||
async fn create_session(
|
||||
&self,
|
||||
working_dir: PathBuf,
|
||||
name: String,
|
||||
session_type: SessionType,
|
||||
) -> Result<Session> {
|
||||
let today = chrono::Utc::now().format("%Y%m%d").to_string();
|
||||
Ok(sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO sessions (id, name, user_set_name, working_dir, extension_data)
|
||||
INSERT INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data)
|
||||
VALUES (
|
||||
? || '_' || CAST(COALESCE((
|
||||
SELECT MAX(CAST(SUBSTR(id, 10) AS INTEGER))
|
||||
@@ -772,23 +854,25 @@ impl SessionStorage {
|
||||
?,
|
||||
FALSE,
|
||||
?,
|
||||
?,
|
||||
'{}'
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(&today)
|
||||
.bind(&today)
|
||||
.bind(&name)
|
||||
.bind(working_dir.to_string_lossy().as_ref())
|
||||
.fetch_one(&self.pool)
|
||||
.await?)
|
||||
.bind(&today)
|
||||
.bind(&today)
|
||||
.bind(&name)
|
||||
.bind(session_type.to_string())
|
||||
.bind(working_dir.to_string_lossy().as_ref())
|
||||
.fetch_one(&self.pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn get_session(&self, id: &str, include_messages: bool) -> Result<Session> {
|
||||
let mut session = sqlx::query_as::<_, Session>(
|
||||
r#"
|
||||
SELECT id, working_dir, name, description, user_set_name, created_at, updated_at, extension_data,
|
||||
SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data,
|
||||
total_tokens, input_tokens, output_tokens,
|
||||
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
|
||||
schedule_id, recipe_json, user_recipe_values_json
|
||||
@@ -796,10 +880,10 @@ impl SessionStorage {
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
|
||||
|
||||
if include_messages {
|
||||
let conv = self.get_conversation(&session.id).await?;
|
||||
@@ -836,6 +920,7 @@ impl SessionStorage {
|
||||
|
||||
add_update!(builder.name, "name");
|
||||
add_update!(builder.user_set_name, "user_set_name");
|
||||
add_update!(builder.session_type, "session_type");
|
||||
add_update!(builder.working_dir, "working_dir");
|
||||
add_update!(builder.extension_data, "extension_data");
|
||||
add_update!(builder.total_tokens, "total_tokens");
|
||||
@@ -866,6 +951,9 @@ impl SessionStorage {
|
||||
if let Some(user_set_name) = builder.user_set_name {
|
||||
q = q.bind(user_set_name);
|
||||
}
|
||||
if let Some(session_type) = builder.session_type {
|
||||
q = q.bind(session_type.to_string());
|
||||
}
|
||||
if let Some(wd) = builder.working_dir {
|
||||
q = q.bind(wd.to_string_lossy().to_string());
|
||||
}
|
||||
@@ -935,7 +1023,6 @@ impl SessionStorage {
|
||||
|
||||
let mut message = Message::new(role, created_timestamp, content);
|
||||
message.metadata = metadata;
|
||||
// TODO(Douwe): make id required
|
||||
message = message.with_id(format!("msg_{}_{}", session_id, idx));
|
||||
messages.push(message);
|
||||
}
|
||||
@@ -1005,20 +1092,21 @@ impl SessionStorage {
|
||||
async fn list_sessions(&self) -> Result<Vec<Session>> {
|
||||
sqlx::query_as::<_, Session>(
|
||||
r#"
|
||||
SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.created_at, s.updated_at, s.extension_data,
|
||||
SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data,
|
||||
s.total_tokens, s.input_tokens, s.output_tokens,
|
||||
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
|
||||
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
|
||||
COUNT(m.id) as message_count
|
||||
FROM sessions s
|
||||
INNER JOIN messages m ON s.id = m.session_id
|
||||
WHERE s.session_type = 'user' OR s.session_type = 'scheduled'
|
||||
GROUP BY s.id
|
||||
ORDER BY s.updated_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> Result<()> {
|
||||
@@ -1071,7 +1159,11 @@ impl SessionStorage {
|
||||
let import: Session = serde_json::from_str(json)?;
|
||||
|
||||
let session = self
|
||||
.create_session(import.working_dir.clone(), import.name.clone())
|
||||
.create_session(
|
||||
import.working_dir.clone(),
|
||||
import.name.clone(),
|
||||
import.session_type,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut builder = SessionUpdateBuilder::new(session.id.clone())
|
||||
@@ -1404,7 +1496,7 @@ mod tests {
|
||||
let description = format!("Test session {}", i);
|
||||
|
||||
let session = session_storage
|
||||
.create_session(working_dir.clone(), description)
|
||||
.create_session(working_dir.clone(), description, SessionType::User)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1496,7 +1588,11 @@ mod tests {
|
||||
let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap());
|
||||
|
||||
let original = storage
|
||||
.create_session(PathBuf::from("/tmp/test"), DESCRIPTION.to_string())
|
||||
.create_session(
|
||||
PathBuf::from("/tmp/test"),
|
||||
DESCRIPTION.to_string(),
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1589,7 +1685,11 @@ mod tests {
|
||||
let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap());
|
||||
|
||||
let session1 = storage
|
||||
.create_session(PathBuf::from("/tmp/test1"), "Original session".to_string())
|
||||
.create_session(
|
||||
PathBuf::from("/tmp/test1"),
|
||||
"Original session".to_string(),
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1636,7 +1736,11 @@ mod tests {
|
||||
let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap());
|
||||
|
||||
storage
|
||||
.create_session(PathBuf::from("/tmp/test"), "Test session".to_string())
|
||||
.create_session(
|
||||
PathBuf::from("/tmp/test"),
|
||||
"Test session".to_string(),
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use tokio::task_local;
|
||||
|
||||
pub const SESSION_ID_HEADER: &str = "goose-session-id";
|
||||
|
||||
task_local! {
|
||||
pub static SESSION_ID: Option<String>;
|
||||
}
|
||||
|
||||
pub async fn with_session_id<F>(session_id: Option<String>, f: F) -> F::Output
|
||||
where
|
||||
F: std::future::Future,
|
||||
{
|
||||
if let Some(id) = session_id {
|
||||
SESSION_ID.scope(Some(id), f).await
|
||||
} else {
|
||||
f.await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_session_id() -> Option<String> {
|
||||
SESSION_ID.try_with(|id| id.clone()).ok().flatten()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_available_when_set() {
|
||||
with_session_id(Some("test-session-123".to_string()), async {
|
||||
assert_eq!(current_session_id(), Some("test-session-123".to_string()));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_none_when_not_set() {
|
||||
let id = current_session_id();
|
||||
assert_eq!(id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_none_when_explicitly_none() {
|
||||
with_session_id(None, async {
|
||||
assert_eq!(current_session_id(), None);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_scoped_correctly() {
|
||||
assert_eq!(current_session_id(), None);
|
||||
|
||||
with_session_id(Some("outer-session".to_string()), async {
|
||||
assert_eq!(current_session_id(), Some("outer-session".to_string()));
|
||||
|
||||
with_session_id(Some("inner-session".to_string()), async {
|
||||
assert_eq!(current_session_id(), Some("inner-session".to_string()));
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(current_session_id(), Some("outer-session".to_string()));
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(current_session_id(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_id_across_await_points() {
|
||||
with_session_id(Some("persistent-session".to_string()), async {
|
||||
assert_eq!(current_session_id(), Some("persistent-session".to_string()));
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
|
||||
assert_eq!(current_session_id(), Some("persistent-session".to_string()));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config::GooseMode;
|
||||
use crate::conversation::message::{Message, ToolRequest};
|
||||
use crate::permission::permission_inspector::PermissionInspector;
|
||||
use crate::permission::permission_judge::PermissionCheckResult;
|
||||
@@ -116,7 +117,7 @@ impl ToolInspectionManager {
|
||||
}
|
||||
|
||||
/// Update the permission inspector's mode
|
||||
pub async fn update_permission_inspector_mode(&self, mode: String) {
|
||||
pub async fn update_permission_inspector_mode(&self, mode: GooseMode) {
|
||||
for inspector in &self.inspectors {
|
||||
if inspector.name() == "permission" {
|
||||
// Downcast to PermissionInspector to access update_mode method
|
||||
|
||||
@@ -301,16 +301,10 @@ mod tests {
|
||||
|
||||
// Set values in config
|
||||
test_config
|
||||
.set_param(
|
||||
"otel_exporter_otlp_endpoint",
|
||||
serde_json::Value::String("http://config:4318".to_string()),
|
||||
)
|
||||
.set_param("otel_exporter_otlp_endpoint", "http://config:4318")
|
||||
.unwrap();
|
||||
test_config
|
||||
.set_param(
|
||||
"otel_exporter_otlp_timeout",
|
||||
serde_json::Value::Number(3000.into()),
|
||||
)
|
||||
.set_param("otel_exporter_otlp_timeout", 3000)
|
||||
.unwrap();
|
||||
|
||||
// Test that from_config reads from the config file
|
||||
|
||||
+403
-1329
File diff suppressed because it is too large
Load Diff
@@ -1,256 +0,0 @@
|
||||
mod execution_tests {
|
||||
use goose::execution::manager::AgentManager;
|
||||
use goose::execution::SessionExecutionMode;
|
||||
use serial_test::serial;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn test_execution_mode_constructors() {
|
||||
assert_eq!(
|
||||
SessionExecutionMode::chat(),
|
||||
SessionExecutionMode::Interactive
|
||||
);
|
||||
assert_eq!(
|
||||
SessionExecutionMode::scheduled(),
|
||||
SessionExecutionMode::Background
|
||||
);
|
||||
|
||||
let parent = "parent-123".to_string();
|
||||
assert_eq!(
|
||||
SessionExecutionMode::task(parent.clone()),
|
||||
SessionExecutionMode::SubTask {
|
||||
parent_session: parent
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_session_isolation() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
let session1 = uuid::Uuid::new_v4().to_string();
|
||||
let session2 = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let agent1 = manager.get_or_create_agent(session1.clone()).await.unwrap();
|
||||
|
||||
let agent2 = manager.get_or_create_agent(session2.clone()).await.unwrap();
|
||||
|
||||
// Different sessions should have different agents
|
||||
assert!(!Arc::ptr_eq(&agent1, &agent2));
|
||||
|
||||
// Getting the same session should return the same agent
|
||||
let agent1_again = manager.get_or_create_agent(session1).await.unwrap();
|
||||
|
||||
assert!(Arc::ptr_eq(&agent1, &agent1_again));
|
||||
|
||||
AgentManager::reset_for_test();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_session_limit() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
let sessions: Vec<_> = (0..100).map(|i| format!("session-{}", i)).collect();
|
||||
|
||||
for session in &sessions {
|
||||
manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
}
|
||||
|
||||
// Create a new session after cleanup
|
||||
let new_session = "new-session".to_string();
|
||||
let _new_agent = manager.get_or_create_agent(new_session).await.unwrap();
|
||||
|
||||
assert_eq!(manager.session_count().await, 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_session() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session = String::from("remove-test");
|
||||
|
||||
manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
assert!(manager.has_session(&session).await);
|
||||
|
||||
manager.remove_session(&session).await.unwrap();
|
||||
assert!(!manager.has_session(&session).await);
|
||||
|
||||
assert!(manager.remove_session(&session).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_concurrent_access() {
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session = String::from("concurrent-test");
|
||||
|
||||
let mut handles = vec![];
|
||||
for _ in 0..10 {
|
||||
let mgr = Arc::clone(&manager);
|
||||
let sess = session.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
mgr.get_or_create_agent(sess).await.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
let agents: Vec<_> = futures::future::join_all(handles)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
for agent in &agents[1..] {
|
||||
assert!(Arc::ptr_eq(&agents[0], agent));
|
||||
}
|
||||
|
||||
assert_eq!(manager.session_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_concurrent_session_creation_race_condition() {
|
||||
// Test that concurrent attempts to create the same new session ID
|
||||
// result in only one agent being created (tests double-check pattern)
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session_id = String::from("race-condition-test");
|
||||
|
||||
// Spawn multiple tasks trying to create the same NEW session simultaneously
|
||||
let mut handles = vec![];
|
||||
for _ in 0..20 {
|
||||
let sess = session_id.clone();
|
||||
let mgr_clone = Arc::clone(&manager);
|
||||
handles.push(tokio::spawn(async move {
|
||||
mgr_clone.get_or_create_agent(sess).await.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect all agents
|
||||
let agents: Vec<_> = futures::future::join_all(handles)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
for agent in &agents[1..] {
|
||||
assert!(
|
||||
Arc::ptr_eq(&agents[0], agent),
|
||||
"All concurrent requests should get the same agent"
|
||||
);
|
||||
}
|
||||
assert_eq!(manager.session_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_configure_default_provider() {
|
||||
use std::env;
|
||||
|
||||
AgentManager::reset_for_test();
|
||||
|
||||
let original_provider = env::var("GOOSE_DEFAULT_PROVIDER").ok();
|
||||
let original_model = env::var("GOOSE_DEFAULT_MODEL").ok();
|
||||
|
||||
env::set_var("GOOSE_DEFAULT_PROVIDER", "openai");
|
||||
env::set_var("GOOSE_DEFAULT_MODEL", "gpt-4o-mini");
|
||||
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let result = manager.configure_default_provider().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Restore original env vars
|
||||
if let Some(val) = original_provider {
|
||||
env::set_var("GOOSE_DEFAULT_PROVIDER", val);
|
||||
} else {
|
||||
env::remove_var("GOOSE_DEFAULT_PROVIDER");
|
||||
}
|
||||
if let Some(val) = original_model {
|
||||
env::set_var("GOOSE_DEFAULT_MODEL", val);
|
||||
} else {
|
||||
env::remove_var("GOOSE_DEFAULT_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_default_provider() {
|
||||
use goose::providers::testprovider::TestProvider;
|
||||
use std::sync::Arc;
|
||||
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
// Create a test provider for replaying (doesn't need inner provider)
|
||||
let temp_file = format!(
|
||||
"{}/test_provider_{}.json",
|
||||
std::env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
|
||||
// Create an empty test provider (will fail on actual use but that's ok for this test)
|
||||
let test_provider = TestProvider::new_replaying(&temp_file)
|
||||
.unwrap_or_else(|_| TestProvider::new_replaying("/tmp/dummy.json").unwrap());
|
||||
|
||||
manager.set_default_provider(Arc::new(test_provider)).await;
|
||||
|
||||
let session = String::from("provider-test");
|
||||
let _agent = manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
|
||||
assert!(manager.has_session(&session).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_eviction_updates_last_used() {
|
||||
AgentManager::reset_for_test();
|
||||
// Test that accessing a session updates its last_used timestamp
|
||||
// and affects eviction order
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
|
||||
let sessions: Vec<_> = (0..100).map(|i| format!("session-{}", i)).collect();
|
||||
|
||||
for session in &sessions {
|
||||
manager.get_or_create_agent(session.clone()).await.unwrap();
|
||||
// Small delay to ensure different timestamps
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
// Access the first session again to update its last_used
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
manager
|
||||
.get_or_create_agent(sessions[0].clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now create a 101st session - should evict session2 (least recently used)
|
||||
let session101 = String::from("session-101");
|
||||
manager
|
||||
.get_or_create_agent(session101.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(manager.has_session(&sessions[0]).await);
|
||||
assert!(!manager.has_session(&sessions[1]).await);
|
||||
assert!(manager.has_session(&session101).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_nonexistent_session_error() {
|
||||
// Test that removing a non-existent session returns an error
|
||||
AgentManager::reset_for_test();
|
||||
let manager = AgentManager::instance().await.unwrap();
|
||||
let session = String::from("never-created");
|
||||
|
||||
let result = manager.remove_session(&session).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not found"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user