Merge pull request #1171 from charmbracelet/crush-fantasy

Andrey Nering created

feat: refactor providers and improve system prompt

Change summary

.gitignore                                                                         |    1 
.goreleaser.yml                                                                    |    9 
CRUSH.md                                                                           |    2 
README.md                                                                          |   12 
Taskfile.yaml                                                                      |    2 
go.mod                                                                             |   57 
go.sum                                                                             |  110 
internal/agent/.env.sample                                                         |    4 
internal/agent/agent.go                                                            |  845 
internal/agent/agent_test.go                                                       |  619 
internal/agent/agent_tool.go                                                       |  109 
internal/agent/common_test.go                                                      |  211 
internal/agent/coordinator.go                                                      |  751 
internal/agent/errors.go                                                           |    2 
internal/agent/event.go                                                            |   51 
internal/agent/prompt/prompt.go                                                    |  248 
internal/agent/prompts.go                                                          |   36 
internal/agent/recorder_test.go                                                    |  116 
internal/agent/templates/agent_tool.md                                             |   15 
internal/agent/templates/coder.md.tpl                                              |  348 
internal/agent/templates/initialize.md                                             |   27 
internal/agent/templates/summary.md                                                |   48 
internal/agent/templates/task.md.tpl                                               |   15 
internal/agent/templates/title.md                                                  |    2 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml             |   66 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml         |   75 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml            |   78 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml             |   72 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml             |   69 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml               |   75 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml        |   69 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml   |   69 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml           |   69 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml           |   66 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml      |   66 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml         |   69 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml            |   69 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml                 |   61 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml             |   59 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml                |   65 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml                 |   65 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml                 |   67 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml                   |   57 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml            |   77 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml       |   63 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml               |   57 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml               |   49 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml          |   71 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml             |   67 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml                |   61 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml           |   63 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml       |   57 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml          |   61 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml           |   67 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml           |   55 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml             |   55 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml      |   57 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml |   65 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml         |   51 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml         |   49 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml    |   57 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml       |   63 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml          |   61 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml                   |   57 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml               |   57 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml                  |   59 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml                   |   55 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml                   |   53 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml                     |   55 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml              |   57 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml         |   55 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml                 |   51 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml                 |   49 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml            |   57 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml               |   59 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml                  |   57 
internal/agent/tools/bash.go                                                       |  315 
internal/agent/tools/bash.tpl                                                      |  116 
internal/agent/tools/diagnostics.go                                                |   56 
internal/agent/tools/diagnostics.md                                                |   24 
internal/agent/tools/download.go                                                   |  159 
internal/agent/tools/download.md                                                   |   28 
internal/agent/tools/edit.go                                                       |  449 
internal/agent/tools/edit.md                                                       |  147 
internal/agent/tools/fetch.go                                                      |  205 
internal/agent/tools/fetch.md                                                      |   28 
internal/agent/tools/file.go                                                       |    0 
internal/agent/tools/glob.go                                                       |  118 
internal/agent/tools/glob.md                                                       |   40 
internal/agent/tools/grep.go                                                       |  170 
internal/agent/tools/grep.md                                                       |   49 
internal/agent/tools/grep_test.go                                                  |    0 
internal/agent/tools/ls.go                                                         |  160 
internal/agent/tools/ls.md                                                         |   34 
internal/agent/tools/mcp-tools.go                                                  |  179 
internal/agent/tools/multiedit.go                                                  |  366 
internal/agent/tools/multiedit.md                                                  |  112 
internal/agent/tools/references.go                                                 |  126 
internal/agent/tools/references.md                                                 |   26 
internal/agent/tools/rg.go                                                         |    0 
internal/agent/tools/safe.go                                                       |    0 
internal/agent/tools/sourcegraph.go                                                |  267 
internal/agent/tools/sourcegraph.md                                                |   55 
internal/agent/tools/testdata/grep.txt                                             |    0 
internal/agent/tools/tools.go                                                      |   39 
internal/agent/tools/view.go                                                       |  308 
internal/agent/tools/view.md                                                       |   35 
internal/agent/tools/write.go                                                      |  177 
internal/agent/tools/write.md                                                      |   30 
internal/app/app.go                                                                |   80 
internal/config/config.go                                                          |   47 
internal/config/init.go                                                            |   20 
internal/config/load.go                                                            |   54 
internal/config/load_test.go                                                       |   16 
internal/config/provider.go                                                        |    3 
internal/db/db.go                                                                  |    2 
internal/db/files.sql.go                                                           |    2 
internal/db/messages.sql.go                                                        |   28 
internal/db/migrations/20250810000000_add_is_summary_message.sql                   |    5 
internal/db/models.go                                                              |   21 
internal/db/querier.go                                                             |    2 
internal/db/sessions.sql.go                                                        |    2 
internal/db/sql/messages.sql                                                       |    3 
internal/llm/agent/agent-tool.go                                                   |   33 
internal/llm/agent/agent.go                                                        | 1138 
internal/llm/agent/event.go                                                        |   53 
internal/llm/prompt/anthropic.md                                                   |  108 
internal/llm/prompt/coder.go                                                       |  100 
internal/llm/prompt/gemini.md                                                      |  165 
internal/llm/prompt/init.md                                                        |    9 
internal/llm/prompt/initialize.go                                                  |   10 
internal/llm/prompt/prompt.go                                                      |  143 
internal/llm/prompt/prompt_test.go                                                 |   69 
internal/llm/prompt/summarize.md                                                   |   11 
internal/llm/prompt/summarizer.go                                                  |   10 
internal/llm/prompt/task.go                                                        |   15 
internal/llm/prompt/title.go                                                       |   10 
internal/llm/prompt/v2.md                                                          |  267 
internal/llm/provider/anthropic.go                                                 |  598 
internal/llm/provider/azure.go                                                     |   39 
internal/llm/provider/bedrock.go                                                   |   93 
internal/llm/provider/gemini.go                                                    |  579 
internal/llm/provider/openai.go                                                    |  604 
internal/llm/provider/openai_test.go                                               |  166 
internal/llm/provider/provider.go                                                  |  208 
internal/llm/provider/vertexai.go                                                  |   40 
internal/llm/tools/bash.go                                                         |  395 
internal/llm/tools/bash.md                                                         |  161 
internal/llm/tools/diagnostics.md                                                  |   21 
internal/llm/tools/download.go                                                     |  196 
internal/llm/tools/download.md                                                     |   34 
internal/llm/tools/edit.go                                                         |  486 
internal/llm/tools/edit.md                                                         |   60 
internal/llm/tools/fetch.go                                                        |  236 
internal/llm/tools/fetch.md                                                        |   34 
internal/llm/tools/glob.go                                                         |  150 
internal/llm/tools/glob.md                                                         |   46 
internal/llm/tools/grep.md                                                         |   54 
internal/llm/tools/ls.md                                                           |   40 
internal/llm/tools/multiedit.go                                                    |  424 
internal/llm/tools/multiedit.md                                                    |   48 
internal/llm/tools/references.md                                                   |   36 
internal/llm/tools/sourcegraph.go                                                  |  302 
internal/llm/tools/sourcegraph.md                                                  |  102 
internal/llm/tools/tools.go                                                        |   85 
internal/llm/tools/view.go                                                         |  343 
internal/llm/tools/view.md                                                         |   42 
internal/llm/tools/write.go                                                        |  208 
internal/llm/tools/write.md                                                        |   38 
internal/message/content.go                                                        |  164 
internal/message/message.go                                                        |   43 
internal/session/session.go                                                        |   27 
internal/shell/persistent.go                                                       |    6 
internal/tui/components/chat/chat.go                                               |   37 
internal/tui/components/chat/editor/editor.go                                      |    6 
internal/tui/components/chat/header/header.go                                      |    2 
internal/tui/components/chat/messages/messages.go                                  |    9 
internal/tui/components/chat/messages/renderer.go                                  |    4 
internal/tui/components/chat/messages/tool.go                                      |    4 
internal/tui/components/chat/sidebar/sidebar.go                                    |   16 
internal/tui/components/chat/splash/splash.go                                      |    6 
internal/tui/components/dialogs/commands/commands.go                               |   10 
internal/tui/components/dialogs/compact/compact.go                                 |  272 
internal/tui/components/dialogs/compact/keys.go                                    |   71 
internal/tui/components/dialogs/models/list.go                                     |   46 
internal/tui/components/dialogs/permissions/permissions.go                         |    2 
internal/tui/components/dialogs/reasoning/reasoning.go                             |   24 
internal/tui/components/mcp/mcp.go                                                 |   12 
internal/tui/page/chat/chat.go                                                     |   77 
internal/tui/tui.go                                                                |   57 
190 files changed, 10,751 insertions(+), 9,109 deletions(-)

Detailed changes

.gitignore πŸ”—

@@ -49,3 +49,4 @@ Thumbs.db
 manpages/
 completions/
 !internal/tui/components/completions/
+.prettierignore

.goreleaser.yml πŸ”—

@@ -98,6 +98,7 @@ checksum:
 
 aur_sources:
   - private_key: "{{ .Env.AUR_KEY }}"
+    disable: "{{ with .Prerelease }}true{{ end }}"
     git_url: "ssh://aur@aur.archlinux.org/crush.git"
     commit_author:
       name: "Charm"
@@ -141,6 +142,7 @@ aur_sources:
 
 aurs:
   - private_key: "{{ .Env.AUR_KEY }}"
+    disable: "{{ with .Prerelease }}true{{ end }}"
     git_url: "ssh://aur@aur.archlinux.org/crush-bin.git"
     commit_author:
       name: "Charm"
@@ -170,7 +172,7 @@ aurs:
       install -Dm644 README* "${pkgdir}/usr/share/doc/crush/"
 
 furies:
-  - disable: "{{ .IsNightly }}"
+  - disable: "{{ if (or .Prerelease .IsNightly) }}true{{ end }}"
     account: "{{ with .Env.FURY_TOKEN }}charmcli{{ else }}{{ end }}"
     secret_name: FURY_TOKEN
 
@@ -179,6 +181,7 @@ brews:
       owner: charmbracelet
       name: homebrew-tap
       token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     commit_author:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
@@ -194,6 +197,7 @@ scoops:
       owner: charmbracelet
       name: scoop-bucket
       token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     commit_author:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
@@ -203,6 +207,7 @@ npms:
     repository: "git+https://github.com/charmbracelet/crush.git"
     bugs: https://github.com/charmbracelet/crush/issues
     access: public
+    disable: "{{ with .Prerelease }}true{{ end }}"
 
 nfpms:
   - formats:
@@ -257,6 +262,7 @@ nix:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"
     license: fsl11Mit
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     extra_install: |-
       installManPage ./manpages/crush.1.gz
       installShellCompletion ./completions/*
@@ -267,6 +273,7 @@ winget:
     publisher_url: https://charm.land
     release_notes_url: "https://github.com/charmbracelet/crush/releases/tag/{{.Tag}}"
     license_url: https://github.com/charmbracelet/crush/blob/main/LICENSE.md
+    skip_upload: "{{ with .Prerelease }}true{{ end }}"
     commit_author:
       name: "Charm"
       email: "charmcli@users.noreply.github.com"

CRUSH.md πŸ”—

@@ -6,7 +6,7 @@
 - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
 - **Update Golden Files**: `go test ./... -update` (regenerates .golden files when test output changes)
   - Update specific package: `go test ./internal/tui/components/core -update` (in this case, we're updating "core")
-- **Lint**: `task lint-fix`
+- **Lint**: `task lint:fix`
 - **Format**: `task fmt` (gofumpt -w .)
 - **Dev**: `task dev` (runs with profiling enabled)
 

README.md πŸ”—

@@ -367,7 +367,7 @@ Local models can also be configured via OpenAI-compatible API. Here are two comm
     "ollama": {
       "name": "Ollama",
       "base_url": "http://localhost:11434/v1/",
-      "type": "openai",
+      "type": "openai-compat",
       "models": [
         {
           "name": "Qwen 3 30B",
@@ -389,7 +389,7 @@ Local models can also be configured via OpenAI-compatible API. Here are two comm
     "lmstudio": {
       "name": "LM Studio",
       "base_url": "http://localhost:1234/v1/",
-      "type": "openai",
+      "type": "openai-compat",
       "models": [
         {
           "name": "Qwen 3 30B",
@@ -408,6 +408,12 @@ Local models can also be configured via OpenAI-compatible API. Here are two comm
 Crush supports custom provider configurations for both OpenAI-compatible and
 Anthropic-compatible APIs.
 
+> [!NOTE]
+> Note that we support two "types" for OpenAI. Make sure to choose the right one
+> to ensure the best experience!
+> * `openai` should be used when proxying or routing requests through OpenAI.
+> * `openai-compat` should be used when using non-OpenAI providers that have OpenAI-compatible APIs.
+
 #### OpenAI-Compatible APIs
 
 Here’s an example configuration for Deepseek, which uses an OpenAI-compatible
@@ -418,7 +424,7 @@ API. Don't forget to set `DEEPSEEK_API_KEY` in your environment.
   "$schema": "https://charm.land/crush.json",
   "providers": {
     "deepseek": {
-      "type": "openai",
+      "type": "openai-compat",
       "base_url": "https://api.deepseek.com/v1",
       "api_key": "$DEEPSEEK_API_KEY",
       "models": [

Taskfile.yaml πŸ”—

@@ -25,7 +25,7 @@ tasks:
     env:
       GOEXPERIMENT: null
 
-  lint-fix:
+  lint:fix:
     desc: Run base linters and fix issues
     cmds:
       - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m --fix

go.mod πŸ”—

@@ -3,6 +3,7 @@ module github.com/charmbracelet/crush
 go 1.25.0
 
 require (
+	charm.land/fantasy v0.1.1
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
 	github.com/PuerkitoBio/goquery v1.10.3
@@ -11,10 +12,9 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.9.1
 	github.com/charlievieth/fastwalk v1.0.14
-	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904
 	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
 	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5
-	github.com/charmbracelet/catwalk v0.7.0
+	github.com/charmbracelet/catwalk v0.7.1-0.20251026125030-34dd898c1f9a
 	github.com/charmbracelet/fang v0.4.3
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea
@@ -23,6 +23,8 @@ require (
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
 	github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
 	github.com/charmbracelet/x/exp/ordered v0.1.0
+	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
+	github.com/charmbracelet/x/term v0.2.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.13.0
@@ -32,8 +34,7 @@ require (
 	github.com/ncruces/go-sqlite3 v0.29.1
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/nxadm/tail v1.4.11
-	github.com/openai/openai-go v1.12.0
-	github.com/pressly/goose/v3 v3.26.0
+	github.com/pressly/goose/v3 v3.25.0
 	github.com/qjebbs/go-jsons v1.0.0-alpha.4
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.1
@@ -43,19 +44,21 @@ require (
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.0.2
+	go.yaml.in/yaml/v4 v4.0.0-rc.2
+	gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
 )
 
 require (
 	cloud.google.com/go v0.116.0 // indirect
-	cloud.google.com/go/auth v0.13.0 // indirect
-	cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
-	cloud.google.com/go/compute/metadata v0.6.0 // indirect
+	cloud.google.com/go/auth v0.17.0 // indirect
+	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+	cloud.google.com/go/compute/metadata v0.8.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
-	github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.39.3 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
@@ -68,17 +71,18 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
 	github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
-	github.com/aws/smithy-go v1.20.3 // indirect
+	github.com/aws/smithy-go v1.23.1 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
+	github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
 	github.com/charmbracelet/colorprofile v0.3.2
+	github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97 // indirect
 	github.com/charmbracelet/ultraviolet v0.0.0-20251017140847-d4ace4d6e731
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
-	github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
-	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
-	github.com/charmbracelet/x/term v0.2.1
+	github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
+	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
@@ -91,11 +95,12 @@ require (
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/jsonschema-go v0.3.0 // indirect
-	github.com/google/s2a-go v0.1.8 // indirect
-	github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
-	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
+	github.com/google/s2a-go v0.1.9 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
+	github.com/googleapis/gax-go/v2 v2.15.0 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -116,6 +121,7 @@ require (
 	github.com/muesli/mango-pflag v0.1.0 // indirect
 	github.com/muesli/roff v0.1.0 // indirect
 	github.com/ncruces/julianday v1.0.0 // indirect
+	github.com/openai/openai-go/v2 v2.7.1
 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/posthog/posthog-go v1.6.12
@@ -136,27 +142,26 @@ require (
 	github.com/yuin/goldmark v1.7.8 // indirect
 	github.com/yuin/goldmark-emoji v1.0.5 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
 	go.opentelemetry.io/otel v1.37.0 // indirect
 	go.opentelemetry.io/otel/metric v1.37.0 // indirect
 	go.opentelemetry.io/otel/trace v1.37.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/crypto v0.42.0 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
-	golang.org/x/image v0.26.0 // indirect
-	golang.org/x/net v0.43.0 // indirect
-	golang.org/x/oauth2 v0.30.0 // indirect
+	golang.org/x/image v0.27.0 // indirect
+	golang.org/x/net v0.44.0 // indirect
+	golang.org/x/oauth2 v0.32.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
 	golang.org/x/sys v0.37.0 // indirect
 	golang.org/x/term v0.35.0 // indirect
 	golang.org/x/text v0.30.0
-	golang.org/x/time v0.8.0 // indirect
-	google.golang.org/api v0.211.0 // indirect
-	google.golang.org/genai v1.32.0
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
-	google.golang.org/grpc v1.71.0 // indirect
-	google.golang.org/protobuf v1.36.8 // indirect
+	golang.org/x/time v0.12.0 // indirect
+	google.golang.org/api v0.239.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+	google.golang.org/grpc v1.74.2 // indirect
+	google.golang.org/protobuf v1.36.10 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5

go.sum πŸ”—

@@ -1,11 +1,13 @@
+charm.land/fantasy v0.1.1 h1:kDNz4telLb9tT8+pdUHUR/MxglOW8y5BPfD3Puj8SFY=
+charm.land/fantasy v0.1.1/go.mod h1:ieknq+wH55tn161hmVBZmP1X+kZFxZpn+hWA/otrN8c=
 cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
 cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
-cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
-cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
-cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
-cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
-cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
-cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
+cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
+cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
+cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -32,8 +34,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
 github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
-github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
+github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM=
+github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
 github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
@@ -58,8 +60,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrA
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
-github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
-github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
+github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -80,14 +82,16 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5 h1:oAChAeh730gtLKK/BpaTeJHzmj3KFuEfQ7AZgf2VGHM=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.5/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo=
-github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio=
-github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
+github.com/charmbracelet/catwalk v0.7.1-0.20251026125030-34dd898c1f9a h1:O3NMgyqjDzxZhsp1ODDdo6VXQJ0fxq2tH1WCxuU0ymk=
+github.com/charmbracelet/catwalk v0.7.1-0.20251026125030-34dd898c1f9a/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
 github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
 github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
 github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
 github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
+github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97 h1:HK7B5Q+0FidxjQD5CovniMw7axkUeMHwgVkxkbmiW/s=
+github.com/charmbracelet/go-genai v0.0.0-20251021165952-9befde14ce97/go.mod h1:ZagL2esO4qxlOJBj0d4PVvLM82akQFtne8s3ivxBnTQ=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM=
 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
@@ -104,8 +108,10 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHE
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
 github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
+github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
+github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -144,6 +150,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -153,14 +161,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
 github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
-github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
-github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
-github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
-github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
+github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
+github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -230,8 +238,8 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
 github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
-github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
-github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
+github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
+github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
 github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
 github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -241,8 +249,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posthog/posthog-go v1.6.12 h1:rsOBL/YdMfLJtOVjKJLgdzYmvaL3aIW6IVbAteSe+aI=
 github.com/posthog/posthog-go v1.6.12/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
-github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
-github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
+github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
 github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
 github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -315,22 +323,24 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
 github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
 go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
 go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
 go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
 go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
-go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
-go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
-go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
+go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
+go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
+go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
+go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
 go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
 go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
+go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -342,8 +352,8 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
 golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
-golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
+golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
+golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -360,10 +370,10 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
-golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
-golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
-golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
+golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
+golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
+golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -414,8 +424,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
 golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
-golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -425,20 +435,20 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
 golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
 golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
-google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
-google.golang.org/genai v1.32.0 h1:kku/m3kWOncjnw8EIa2sgmrPLhaxFHaP+uqOq5ZckvI=
-google.golang.org/genai v1.32.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
-google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
-google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
-google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
+google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
+google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117 h1:fbE/sTnBb9UNfE8cJsOzrYYPqVWVHb7jWH4SI1W//cM=
+gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117/go.mod h1:YuVT9NPq7t3oT2WpUemB0DbNL7djIjgajZycxoDLnqs=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

internal/agent/agent.go πŸ”—

@@ -0,0 +1,845 @@
+package agent
+
+import (
+	"context"
+	_ "embed"
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"charm.land/fantasy"
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/bedrock"
+	"charm.land/fantasy/providers/google"
+	"charm.land/fantasy/providers/openai"
+	"charm.land/fantasy/providers/openrouter"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+//go:embed templates/title.md
+var titlePrompt []byte
+
+//go:embed templates/summary.md
+var summaryPrompt []byte
+
+type SessionAgentCall struct {
+	SessionID        string
+	Prompt           string
+	ProviderOptions  fantasy.ProviderOptions
+	Attachments      []message.Attachment
+	MaxOutputTokens  int64
+	Temperature      *float64
+	TopP             *float64
+	TopK             *int64
+	FrequencyPenalty *float64
+	PresencePenalty  *float64
+}
+
+type SessionAgent interface {
+	Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
+	SetModels(large Model, small Model)
+	SetTools(tools []fantasy.AgentTool)
+	Cancel(sessionID string)
+	CancelAll()
+	IsSessionBusy(sessionID string) bool
+	IsBusy() bool
+	QueuedPrompts(sessionID string) int
+	ClearQueue(sessionID string)
+	Summarize(context.Context, string, fantasy.ProviderOptions) error
+	Model() Model
+}
+
+type Model struct {
+	Model      fantasy.LanguageModel
+	CatwalkCfg catwalk.Model
+	ModelCfg   config.SelectedModel
+}
+
+type sessionAgent struct {
+	largeModel           Model
+	smallModel           Model
+	systemPromptPrefix   string
+	systemPrompt         string
+	tools                []fantasy.AgentTool
+	sessions             session.Service
+	messages             message.Service
+	disableAutoSummarize bool
+	isYolo               bool
+
+	messageQueue   *csync.Map[string, []SessionAgentCall]
+	activeRequests *csync.Map[string, context.CancelFunc]
+}
+
+type SessionAgentOptions struct {
+	LargeModel           Model
+	SmallModel           Model
+	SystemPromptPrefix   string
+	SystemPrompt         string
+	DisableAutoSummarize bool
+	IsYolo               bool
+	Sessions             session.Service
+	Messages             message.Service
+	Tools                []fantasy.AgentTool
+}
+
+func NewSessionAgent(
+	opts SessionAgentOptions,
+) SessionAgent {
+	return &sessionAgent{
+		largeModel:           opts.LargeModel,
+		smallModel:           opts.SmallModel,
+		systemPromptPrefix:   opts.SystemPromptPrefix,
+		systemPrompt:         opts.SystemPrompt,
+		sessions:             opts.Sessions,
+		messages:             opts.Messages,
+		disableAutoSummarize: opts.DisableAutoSummarize,
+		tools:                opts.Tools,
+		isYolo:               opts.IsYolo,
+		messageQueue:         csync.NewMap[string, []SessionAgentCall](),
+		activeRequests:       csync.NewMap[string, context.CancelFunc](),
+	}
+}
+
+func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
+	if call.Prompt == "" {
+		return nil, ErrEmptyPrompt
+	}
+	if call.SessionID == "" {
+		return nil, ErrSessionMissing
+	}
+
+	// Queue the message if busy
+	if a.IsSessionBusy(call.SessionID) {
+		existing, ok := a.messageQueue.Get(call.SessionID)
+		if !ok {
+			existing = []SessionAgentCall{}
+		}
+		existing = append(existing, call)
+		a.messageQueue.Set(call.SessionID, existing)
+		return nil, nil
+	}
+
+	if len(a.tools) > 0 {
+		// add anthropic caching to the last tool
+		a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions())
+	}
+
+	agent := fantasy.NewAgent(
+		a.largeModel.Model,
+		fantasy.WithSystemPrompt(a.systemPrompt),
+		fantasy.WithTools(a.tools...),
+	)
+
+	sessionLock := sync.Mutex{}
+	currentSession, err := a.sessions.Get(ctx, call.SessionID)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session: %w", err)
+	}
+
+	msgs, err := a.getSessionMessages(ctx, currentSession)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session messages: %w", err)
+	}
+
+	var wg sync.WaitGroup
+	// Generate title if first message
+	if len(msgs) == 0 {
+		wg.Go(func() {
+			sessionLock.Lock()
+			a.generateTitle(ctx, &currentSession, call.Prompt)
+			sessionLock.Unlock()
+		})
+	}
+
+	// Add the user message to the session
+	_, err = a.createUserMessage(ctx, call)
+	if err != nil {
+		return nil, err
+	}
+
+	// add the session to the context
+	ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
+
+	genCtx, cancel := context.WithCancel(ctx)
+	a.activeRequests.Set(call.SessionID, cancel)
+
+	defer cancel()
+	defer a.activeRequests.Del(call.SessionID)
+
+	history, files := a.preparePrompt(msgs, call.Attachments...)
+
+	startTime := time.Now()
+	a.eventPromptSent(call.SessionID)
+
+	var currentAssistant *message.Message
+	var shouldSummarize bool
+	result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
+		Prompt:           call.Prompt,
+		Files:            files,
+		Messages:         history,
+		ProviderOptions:  call.ProviderOptions,
+		MaxOutputTokens:  &call.MaxOutputTokens,
+		TopP:             call.TopP,
+		Temperature:      call.Temperature,
+		PresencePenalty:  call.PresencePenalty,
+		TopK:             call.TopK,
+		FrequencyPenalty: call.FrequencyPenalty,
+		// Before each step create the new assistant message
+		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
+			prepared.Messages = options.Messages
+			// reset all cached items
+			for i := range prepared.Messages {
+				prepared.Messages[i].ProviderOptions = nil
+			}
+
+			queuedCalls, _ := a.messageQueue.Get(call.SessionID)
+			a.messageQueue.Del(call.SessionID)
+			for _, queued := range queuedCalls {
+				userMessage, createErr := a.createUserMessage(callContext, queued)
+				if createErr != nil {
+					return callContext, prepared, createErr
+				}
+				prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
+			}
+
+			lastSystemRoleInx := 0
+			systemMessageUpdated := false
+			for i, msg := range prepared.Messages {
+				// only add cache control to the last message
+				if msg.Role == fantasy.MessageRoleSystem {
+					lastSystemRoleInx = i
+				} else if !systemMessageUpdated {
+					prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions()
+					systemMessageUpdated = true
+				}
+				// than add cache control to the last 2 messages
+				if i > len(prepared.Messages)-3 {
+					prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
+				}
+			}
+
+			if a.systemPromptPrefix != "" {
+				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
+			}
+
+			var assistantMsg message.Message
+			assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
+				Role:     message.Assistant,
+				Parts:    []message.ContentPart{},
+				Model:    a.largeModel.ModelCfg.Model,
+				Provider: a.largeModel.ModelCfg.Provider,
+			})
+			if err != nil {
+				return callContext, prepared, err
+			}
+			callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
+			currentAssistant = &assistantMsg
+			return callContext, prepared, err
+		},
+		OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
+			currentAssistant.AppendReasoningContent(reasoning.Text)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnReasoningDelta: func(id string, text string) error {
+			currentAssistant.AppendReasoningContent(text)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
+			// handle anthropic signature
+			if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok {
+				if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok {
+					currentAssistant.AppendReasoningSignature(reasoning.Signature)
+				}
+			}
+			if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
+				if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
+					currentAssistant.AppendReasoningSignature(reasoning.Signature)
+				}
+			}
+			if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
+				if reasoning, ok := openaiData.(*openai.ResponsesReasoningMetadata); ok {
+					currentAssistant.SetReasoningResponsesData(reasoning)
+				}
+			}
+			currentAssistant.FinishThinking()
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnTextDelta: func(id string, text string) error {
+			currentAssistant.AppendContent(text)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnToolInputStart: func(id string, toolName string) error {
+			toolCall := message.ToolCall{
+				ID:               id,
+				Name:             toolName,
+				ProviderExecuted: false,
+				Finished:         false,
+			}
+			currentAssistant.AddToolCall(toolCall)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnRetry: func(err *fantasy.APICallError, delay time.Duration) {
+			// TODO: implement
+		},
+		OnToolCall: func(tc fantasy.ToolCallContent) error {
+			toolCall := message.ToolCall{
+				ID:               tc.ToolCallID,
+				Name:             tc.ToolName,
+				Input:            tc.Input,
+				ProviderExecuted: false,
+				Finished:         true,
+			}
+			currentAssistant.AddToolCall(toolCall)
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		OnToolResult: func(result fantasy.ToolResultContent) error {
+			var resultContent string
+			isError := false
+			switch result.Result.GetType() {
+			case fantasy.ToolResultContentTypeText:
+				r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result)
+				if ok {
+					resultContent = r.Text
+				}
+			case fantasy.ToolResultContentTypeError:
+				r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result)
+				if ok {
+					isError = true
+					resultContent = r.Error.Error()
+				}
+			case fantasy.ToolResultContentTypeMedia:
+				// TODO: handle this message type
+			}
+			toolResult := message.ToolResult{
+				ToolCallID: result.ToolCallID,
+				Name:       result.ToolName,
+				Content:    resultContent,
+				IsError:    isError,
+				Metadata:   result.ClientMetadata,
+			}
+			_, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
+				Role: message.Tool,
+				Parts: []message.ContentPart{
+					toolResult,
+				},
+			})
+			if createMsgErr != nil {
+				return createMsgErr
+			}
+			return nil
+		},
+		OnStepFinish: func(stepResult fantasy.StepResult) error {
+			finishReason := message.FinishReasonUnknown
+			switch stepResult.FinishReason {
+			case fantasy.FinishReasonLength:
+				finishReason = message.FinishReasonMaxTokens
+			case fantasy.FinishReasonStop:
+				finishReason = message.FinishReasonEndTurn
+			case fantasy.FinishReasonToolCalls:
+				finishReason = message.FinishReasonToolUse
+			}
+			currentAssistant.AddFinish(finishReason, "", "")
+			a.updateSessionUsage(a.largeModel, &currentSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
+			sessionLock.Lock()
+			_, sessionErr := a.sessions.Save(genCtx, currentSession)
+			sessionLock.Unlock()
+			if sessionErr != nil {
+				return sessionErr
+			}
+			return a.messages.Update(genCtx, *currentAssistant)
+		},
+		StopWhen: []fantasy.StopCondition{
+			func(_ []fantasy.StepResult) bool {
+				cw := int64(a.largeModel.CatwalkCfg.ContextWindow)
+				tokens := currentSession.CompletionTokens + currentSession.PromptTokens
+				remaining := cw - tokens
+				var threshold int64
+				if cw > 200_000 {
+					threshold = 20_000
+				} else {
+					threshold = int64(float64(cw) * 0.2)
+				}
+				if (remaining <= threshold) && !a.disableAutoSummarize {
+					shouldSummarize = true
+					return true
+				}
+				return false
+			},
+		},
+	})
+
+	a.eventPromptResponded(call.SessionID, time.Since(startTime).Truncate(time.Second))
+
+	if err != nil {
+		isCancelErr := errors.Is(err, context.Canceled)
+		isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+		if currentAssistant == nil {
+			return result, err
+		}
+		// Ensure we finish thinking on error to close the reasoning state
+		currentAssistant.FinishThinking()
+		toolCalls := currentAssistant.ToolCalls()
+		// INFO: we use the parent context here because the genCtx has been cancelled
+		msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
+		if createErr != nil {
+			return nil, createErr
+		}
+		for _, tc := range toolCalls {
+			if !tc.Finished {
+				tc.Finished = true
+				tc.Input = "{}"
+				currentAssistant.AddToolCall(tc)
+				updateErr := a.messages.Update(ctx, *currentAssistant)
+				if updateErr != nil {
+					return nil, updateErr
+				}
+			}
+
+			found := false
+			for _, msg := range msgs {
+				if msg.Role == message.Tool {
+					for _, tr := range msg.ToolResults() {
+						if tr.ToolCallID == tc.ID {
+							found = true
+							break
+						}
+					}
+				}
+				if found {
+					break
+				}
+			}
+			if found {
+				continue
+			}
+			content := "There was an error while executing the tool"
+			if isCancelErr {
+				content = "Tool execution canceled by user"
+			} else if isPermissionErr {
+				content = "Permission denied"
+			}
+			toolResult := message.ToolResult{
+				ToolCallID: tc.ID,
+				Name:       tc.Name,
+				Content:    content,
+				IsError:    true,
+			}
+			_, createErr = a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
+				Role: message.Tool,
+				Parts: []message.ContentPart{
+					toolResult,
+				},
+			})
+			if createErr != nil {
+				return nil, createErr
+			}
+		}
+		if isCancelErr {
+			currentAssistant.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
+		} else if isPermissionErr {
+			currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "Permission denied", "")
+		} else {
+			currentAssistant.AddFinish(message.FinishReasonError, "API Error", err.Error())
+		}
+		// INFO: we use the parent context here because the genCtx has been cancelled
+		updateErr := a.messages.Update(ctx, *currentAssistant)
+		if updateErr != nil {
+			return nil, updateErr
+		}
+		return nil, err
+	}
+	wg.Wait()
+
+	if shouldSummarize {
+		a.activeRequests.Del(call.SessionID)
+		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
+			return nil, summarizeErr
+		}
+		// if the agent was not done...
+		if len(currentAssistant.ToolCalls()) > 0 {
+			existing, ok := a.messageQueue.Get(call.SessionID)
+			if !ok {
+				existing = []SessionAgentCall{}
+			}
+			call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
+			existing = append(existing, call)
+			a.messageQueue.Set(call.SessionID, existing)
+		}
+	}
+
+	// release active request before processing queued messages
+	a.activeRequests.Del(call.SessionID)
+	cancel()
+
+	queuedMessages, ok := a.messageQueue.Get(call.SessionID)
+	if !ok || len(queuedMessages) == 0 {
+		return result, err
+	}
+	// there are queued messages restart the loop
+	firstQueuedMessage := queuedMessages[0]
+	a.messageQueue.Set(call.SessionID, queuedMessages[1:])
+	return a.Run(ctx, firstQueuedMessage)
+}
+
+func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
+	if a.IsSessionBusy(sessionID) {
+		return ErrSessionBusy
+	}
+
+	currentSession, err := a.sessions.Get(ctx, sessionID)
+	if err != nil {
+		return fmt.Errorf("failed to get session: %w", err)
+	}
+	msgs, err := a.getSessionMessages(ctx, currentSession)
+	if err != nil {
+		return err
+	}
+	if len(msgs) == 0 {
+		// nothing to summarize
+		return nil
+	}
+
+	aiMsgs, _ := a.preparePrompt(msgs)
+
+	genCtx, cancel := context.WithCancel(ctx)
+	a.activeRequests.Set(sessionID, cancel)
+	defer a.activeRequests.Del(sessionID)
+	defer cancel()
+
+	agent := fantasy.NewAgent(a.largeModel.Model,
+		fantasy.WithSystemPrompt(string(summaryPrompt)),
+	)
+	summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
+		Role:             message.Assistant,
+		Model:            a.largeModel.Model.Model(),
+		Provider:         a.largeModel.Model.Provider(),
+		IsSummaryMessage: true,
+	})
+	if err != nil {
+		return err
+	}
+
+	resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
+		Prompt:          "Provide a detailed summary of our conversation above.",
+		Messages:        aiMsgs,
+		ProviderOptions: opts,
+		OnReasoningDelta: func(id string, text string) error {
+			summaryMessage.AppendReasoningContent(text)
+			return a.messages.Update(genCtx, summaryMessage)
+		},
+		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
+			// handle anthropic signature
+			if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
+				if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
+					summaryMessage.AppendReasoningSignature(signature.Signature)
+				}
+			}
+			summaryMessage.FinishThinking()
+			return a.messages.Update(genCtx, summaryMessage)
+		},
+		OnTextDelta: func(id, text string) error {
+			summaryMessage.AppendContent(text)
+			return a.messages.Update(genCtx, summaryMessage)
+		},
+	})
+	if err != nil {
+		isCancelErr := errors.Is(err, context.Canceled)
+		if isCancelErr {
+			// User cancelled summarize we need to remove the summary message
+			deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
+			return deleteErr
+		}
+		return err
+	}
+
+	summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
+	err = a.messages.Update(genCtx, summaryMessage)
+	if err != nil {
+		return err
+	}
+
+	var openrouterCost *float64
+	for _, step := range resp.Steps {
+		stepCost := a.openrouterCost(step.ProviderMetadata)
+		if stepCost != nil {
+			newCost := *stepCost
+			if openrouterCost != nil {
+				newCost += *openrouterCost
+			}
+			openrouterCost = &newCost
+		}
+	}
+
+	a.updateSessionUsage(a.largeModel, &currentSession, resp.TotalUsage, openrouterCost)
+
+	// just in case get just the last usage
+	usage := resp.Response.Usage
+	currentSession.SummaryMessageID = summaryMessage.ID
+	currentSession.CompletionTokens = usage.OutputTokens
+	currentSession.PromptTokens = 0
+	_, err = a.sessions.Save(genCtx, currentSession)
+	return err
+}
+
+func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
+	if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
+		return fantasy.ProviderOptions{}
+	}
+	return fantasy.ProviderOptions{
+		anthropic.Name: &anthropic.ProviderCacheControlOptions{
+			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
+		},
+		bedrock.Name: &anthropic.ProviderCacheControlOptions{
+			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
+		},
+	}
+}
+
+func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
+	var attachmentParts []message.ContentPart
+	for _, attachment := range call.Attachments {
+		attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
+	}
+	parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
+	parts = append(parts, attachmentParts...)
+	msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
+		Role:  message.User,
+		Parts: parts,
+	})
+	if err != nil {
+		return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
+	}
+	return msg, nil
+}
+
+func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
+	var history []fantasy.Message
+	for _, m := range msgs {
+		if len(m.Parts) == 0 {
+			continue
+		}
+		// Assistant message without content or tool calls (cancelled before it returned anything)
+		if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
+			continue
+		}
+		history = append(history, m.ToAIMessage()...)
+	}
+
+	var files []fantasy.FilePart
+	for _, attachment := range attachments {
+		files = append(files, fantasy.FilePart{
+			Filename:  attachment.FileName,
+			Data:      attachment.Content,
+			MediaType: attachment.MimeType,
+		})
+	}
+
+	return history, files
+}
+
+func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
+	msgs, err := a.messages.List(ctx, session.ID)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list messages: %w", err)
+	}
+
+	if session.SummaryMessageID != "" {
+		summaryMsgInex := -1
+		for i, msg := range msgs {
+			if msg.ID == session.SummaryMessageID {
+				summaryMsgInex = i
+				break
+			}
+		}
+		if summaryMsgInex != -1 {
+			msgs = msgs[summaryMsgInex:]
+			msgs[0].Role = message.User
+		}
+	}
+	return msgs, nil
+}
+
+func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Session, prompt string) {
+	if prompt == "" {
+		return
+	}
+
+	var maxOutput int64 = 40
+	if a.smallModel.CatwalkCfg.CanReason {
+		maxOutput = a.smallModel.CatwalkCfg.DefaultMaxTokens
+	}
+
+	agent := fantasy.NewAgent(a.smallModel.Model,
+		fantasy.WithSystemPrompt(string(titlePrompt)+"\n /no_think"),
+		fantasy.WithMaxOutputTokens(maxOutput),
+	)
+
+	resp, err := agent.Stream(ctx, fantasy.AgentStreamCall{
+		Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", prompt),
+	})
+	if err != nil {
+		slog.Error("error generating title", "err", err)
+		return
+	}
+
+	title := resp.Response.Content.Text()
+
+	title = strings.ReplaceAll(title, "\n", " ")
+
+	// remove thinking tags if present
+	if idx := strings.Index(title, "</think>"); idx > 0 {
+		title = title[idx+len("</think>"):]
+	}
+
+	title = strings.TrimSpace(title)
+	if title == "" {
+		slog.Warn("failed to generate title", "warn", "empty title")
+		return
+	}
+
+	session.Title = title
+
+	var openrouterCost *float64
+	for _, step := range resp.Steps {
+		stepCost := a.openrouterCost(step.ProviderMetadata)
+		if stepCost != nil {
+			newCost := *stepCost
+			if openrouterCost != nil {
+				newCost += *openrouterCost
+			}
+			openrouterCost = &newCost
+		}
+	}
+
+	a.updateSessionUsage(a.smallModel, session, resp.TotalUsage, openrouterCost)
+	_, saveErr := a.sessions.Save(ctx, *session)
+	if saveErr != nil {
+		slog.Error("failed to save session title & usage", "error", saveErr)
+		return
+	}
+}
+
+func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float64 {
+	openrouterMetadata, ok := metadata[openrouter.Name]
+	if !ok {
+		return nil
+	}
+
+	opts, ok := openrouterMetadata.(*openrouter.ProviderMetadata)
+	if !ok {
+		return nil
+	}
+	return &opts.Usage.Cost
+}
+
+func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
+	modelConfig := model.CatwalkCfg
+	cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
+		modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
+		modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
+		modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
+
+	a.eventTokensUsed(session.ID, model, usage, cost)
+
+	if overrideCost != nil {
+		session.Cost += *overrideCost
+	} else {
+		session.Cost += cost
+	}
+
+	session.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
+	session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
+}
+
+func (a *sessionAgent) Cancel(sessionID string) {
+	// Cancel regular requests
+	if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
+		slog.Info("Request cancellation initiated", "session_id", sessionID)
+		cancel()
+	}
+
+	// Also check for summarize requests
+	if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
+		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+		cancel()
+	}
+
+	if a.QueuedPrompts(sessionID) > 0 {
+		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		a.messageQueue.Del(sessionID)
+	}
+}
+
+func (a *sessionAgent) ClearQueue(sessionID string) {
+	if a.QueuedPrompts(sessionID) > 0 {
+		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		a.messageQueue.Del(sessionID)
+	}
+}
+
+func (a *sessionAgent) CancelAll() {
+	if !a.IsBusy() {
+		return
+	}
+	for key := range a.activeRequests.Seq2() {
+		a.Cancel(key) // key is sessionID
+	}
+
+	timeout := time.After(5 * time.Second)
+	for a.IsBusy() {
+		select {
+		case <-timeout:
+			return
+		default:
+			time.Sleep(200 * time.Millisecond)
+		}
+	}
+}
+
+func (a *sessionAgent) IsBusy() bool {
+	var busy bool
+	for cancelFunc := range a.activeRequests.Seq() {
+		if cancelFunc != nil {
+			busy = true
+			break
+		}
+	}
+	return busy
+}
+
+func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
+	_, busy := a.activeRequests.Get(sessionID)
+	return busy
+}
+
+func (a *sessionAgent) QueuedPrompts(sessionID string) int {
+	l, ok := a.messageQueue.Get(sessionID)
+	if !ok {
+		return 0
+	}
+	return len(l)
+}
+
+func (a *sessionAgent) SetModels(large Model, small Model) {
+	a.largeModel = large
+	a.smallModel = small
+}
+
+func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
+	a.tools = tools
+}
+
+func (a *sessionAgent) Model() Model {
+	return a.largeModel
+}

internal/agent/agent_test.go πŸ”—

@@ -0,0 +1,619 @@
+package agent
+
+import (
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/shell"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
+
+	_ "github.com/joho/godotenv/autoload"
+)
+
+var modelPairs = []modelPair{
+	{"anthropic-sonnet", anthropicBuilder("claude-sonnet-4-5-20250929"), anthropicBuilder("claude-3-5-haiku-20241022")},
+	{"openai-gpt-5", openaiBuilder("gpt-5"), openaiBuilder("gpt-4o")},
+	{"openrouter-kimi-k2", openRouterBuilder("moonshotai/kimi-k2-0905"), openRouterBuilder("qwen/qwen3-next-80b-a3b-instruct")},
+	{"zai-glm4.6", zAIBuilder("glm-4.6"), zAIBuilder("glm-4.5-air")},
+}
+
+func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) {
+	large, err := pair.largeModel(t, r)
+	require.NoError(t, err)
+	small, err := pair.smallModel(t, r)
+	require.NoError(t, err)
+	return large, small
+}
+
+func setupAgent(t *testing.T, pair modelPair) (SessionAgent, env) {
+	r := newRecorder(t)
+	large, small := getModels(t, r, pair)
+	env := testEnv(t)
+
+	createSimpleGoProject(t, env.workingDir)
+	agent, err := coderAgent(r, env, large, small)
+	shell.Reset(env.workingDir)
+	require.NoError(t, err)
+	return agent, env
+}
+
+func TestCoderAgent(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("We're having VCR matching issues on Windows. Skipping for now.")
+	}
+
+	for _, pair := range modelPairs {
+		t.Run(pair.name, func(t *testing.T) {
+			t.Run("simple test", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "Hello",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+				// Should have the agent and user message
+				assert.Equal(t, len(msgs), 2)
+			})
+			t.Run("read a file", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "Read the go mod",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+				foundFile := false
+				var tcID string
+			out:
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.ViewToolName {
+								tcID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == tcID {
+								if strings.Contains(tr.Content, "module example.com/testproject") {
+									foundFile = true
+									break out
+								}
+							}
+						}
+					}
+				}
+				require.True(t, foundFile)
+			})
+			t.Run("update a file", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "update the main.go file by changing the print to say hello from crush",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundRead := false
+				foundWrite := false
+				var readTCID, writeTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.ViewToolName {
+								readTCID = tc.ID
+							}
+							if tc.Name == tools.EditToolName || tc.Name == tools.WriteToolName {
+								writeTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == readTCID {
+								foundRead = true
+							}
+							if tr.ToolCallID == writeTCID {
+								foundWrite = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundRead, "Expected to find a read operation")
+				require.True(t, foundWrite, "Expected to find a write operation")
+
+				mainGoPath := filepath.Join(env.workingDir, "main.go")
+				content, err := os.ReadFile(mainGoPath)
+				require.NoError(t, err)
+				require.Contains(t, strings.ToLower(string(content)), "hello from crush")
+			})
+			t.Run("bash tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use bash to create a file named test.txt with content 'hello bash'",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundBash := false
+				var bashTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.BashToolName {
+								bashTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == bashTCID {
+								foundBash = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundBash, "Expected to find a bash operation")
+
+				testFilePath := filepath.Join(env.workingDir, "test.txt")
+				content, err := os.ReadFile(testFilePath)
+				require.NoError(t, err)
+				require.Contains(t, string(content), "hello bash")
+			})
+			t.Run("download tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "download the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundDownload := false
+				var downloadTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.DownloadToolName {
+								downloadTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == downloadTCID {
+								foundDownload = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundDownload, "Expected to find a download operation")
+
+				examplePath := filepath.Join(env.workingDir, "example.txt")
+				_, err = os.Stat(examplePath)
+				require.NoError(t, err, "Expected example.txt file to exist")
+			})
+			t.Run("fetch tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "fetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word 'John Doe'",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundFetch := false
+				var fetchTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.FetchToolName {
+								fetchTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == fetchTCID {
+								foundFetch = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundFetch, "Expected to find a fetch operation")
+			})
+			t.Run("glob tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use glob to find all .go files in the current directory",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundGlob := false
+				var globTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.GlobToolName {
+								globTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == globTCID {
+								foundGlob = true
+								require.Contains(t, tr.Content, "main.go", "Expected glob to find main.go")
+							}
+						}
+					}
+				}
+
+				require.True(t, foundGlob, "Expected to find a glob operation")
+			})
+			t.Run("grep tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use grep to search for the word 'package' in go files",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundGrep := false
+				var grepTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.GrepToolName {
+								grepTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == grepTCID {
+								foundGrep = true
+								require.Contains(t, tr.Content, "main.go", "Expected grep to find main.go")
+							}
+						}
+					}
+				}
+
+				require.True(t, foundGrep, "Expected to find a grep operation")
+			})
+			t.Run("ls tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use ls to list the files in the current directory",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundLS := false
+				var lsTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.LSToolName {
+								lsTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == lsTCID {
+								foundLS = true
+								require.Contains(t, tr.Content, "main.go", "Expected ls to list main.go")
+								require.Contains(t, tr.Content, "go.mod", "Expected ls to list go.mod")
+							}
+						}
+					}
+				}
+
+				require.True(t, foundLS, "Expected to find an ls operation")
+			})
+			t.Run("multiedit tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use multiedit to change 'Hello, World!' to 'Hello, Crush!' and add a comment '// Greeting' above the fmt.Println line in main.go",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundMultiEdit := false
+				var multiEditTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.MultiEditToolName {
+								multiEditTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == multiEditTCID {
+								foundMultiEdit = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundMultiEdit, "Expected to find a multiedit operation")
+
+				mainGoPath := filepath.Join(env.workingDir, "main.go")
+				content, err := os.ReadFile(mainGoPath)
+				require.NoError(t, err)
+				require.Contains(t, string(content), "Hello, Crush!", "Expected file to contain 'Hello, Crush!'")
+			})
+			t.Run("sourcegraph tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use sourcegraph to search for 'func main' in Go repositories",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundSourcegraph := false
+				var sourcegraphTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.SourcegraphToolName {
+								sourcegraphTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == sourcegraphTCID {
+								foundSourcegraph = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundSourcegraph, "Expected to find a sourcegraph operation")
+			})
+			t.Run("write tool", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use write to create a new file called config.json with content '{\"name\": \"test\", \"version\": \"1.0.0\"}'",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				foundWrite := false
+				var writeTCID string
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant {
+						for _, tc := range msg.ToolCalls() {
+							if tc.Name == tools.WriteToolName {
+								writeTCID = tc.ID
+							}
+						}
+					}
+					if msg.Role == message.Tool {
+						for _, tr := range msg.ToolResults() {
+							if tr.ToolCallID == writeTCID {
+								foundWrite = true
+							}
+						}
+					}
+				}
+
+				require.True(t, foundWrite, "Expected to find a write operation")
+
+				configPath := filepath.Join(env.workingDir, "config.json")
+				content, err := os.ReadFile(configPath)
+				require.NoError(t, err)
+				require.Contains(t, string(content), "test", "Expected config.json to contain 'test'")
+				require.Contains(t, string(content), "1.0.0", "Expected config.json to contain '1.0.0'")
+			})
+			t.Run("parallel tool calls", func(t *testing.T) {
+				agent, env := setupAgent(t, pair)
+
+				session, err := env.sessions.Create(t.Context(), "New Session")
+				require.NoError(t, err)
+
+				res, err := agent.Run(t.Context(), SessionAgentCall{
+					Prompt:          "use glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel",
+					SessionID:       session.ID,
+					MaxOutputTokens: 10000,
+				})
+				require.NoError(t, err)
+				assert.NotNil(t, res)
+
+				msgs, err := env.messages.List(t.Context(), session.ID)
+				require.NoError(t, err)
+
+				var assistantMsg *message.Message
+				var toolMsgs []message.Message
+
+				for _, msg := range msgs {
+					if msg.Role == message.Assistant && len(msg.ToolCalls()) > 0 {
+						assistantMsg = &msg
+					}
+					if msg.Role == message.Tool {
+						toolMsgs = append(toolMsgs, msg)
+					}
+				}
+
+				require.NotNil(t, assistantMsg, "Expected to find an assistant message with tool calls")
+				require.NotNil(t, toolMsgs, "Expected to find a tool message")
+
+				toolCalls := assistantMsg.ToolCalls()
+				require.GreaterOrEqual(t, len(toolCalls), 2, "Expected at least 2 tool calls in parallel")
+
+				foundGlob := false
+				foundLS := false
+				var globTCID, lsTCID string
+
+				for _, tc := range toolCalls {
+					if tc.Name == tools.GlobToolName {
+						foundGlob = true
+						globTCID = tc.ID
+					}
+					if tc.Name == tools.LSToolName {
+						foundLS = true
+						lsTCID = tc.ID
+					}
+				}
+
+				require.True(t, foundGlob, "Expected to find a glob tool call")
+				require.True(t, foundLS, "Expected to find an ls tool call")
+
+				require.GreaterOrEqual(t, len(toolMsgs), 2, "Expected at least 2 tool results in the same message")
+
+				foundGlobResult := false
+				foundLSResult := false
+
+				for _, msg := range toolMsgs {
+					for _, tr := range msg.ToolResults() {
+						if tr.ToolCallID == globTCID {
+							foundGlobResult = true
+							require.Contains(t, tr.Content, "main.go", "Expected glob result to contain main.go")
+							require.False(t, tr.IsError, "Expected glob result to not be an error")
+						}
+						if tr.ToolCallID == lsTCID {
+							foundLSResult = true
+							require.Contains(t, tr.Content, "main.go", "Expected ls result to contain main.go")
+							require.False(t, tr.IsError, "Expected ls result to not be an error")
+						}
+					}
+				}
+
+				require.True(t, foundGlobResult, "Expected to find glob tool result")
+				require.True(t, foundLSResult, "Expected to find ls tool result")
+			})
+		})
+	}
+}

internal/agent/agent_tool.go πŸ”—

@@ -0,0 +1,109 @@
+package agent
+
+import (
+	"context"
+	_ "embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+
+	"charm.land/fantasy"
+
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+)
+
+//go:embed templates/agent_tool.md
+var agentToolDescription []byte
+
+type AgentParams struct {
+	Prompt string `json:"prompt" description:"The task for the agent to perform"`
+}
+
+const (
+	AgentToolName = "agent"
+)
+
+func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
+	agentCfg, ok := c.cfg.Agents[config.AgentTask]
+	if !ok {
+		return nil, errors.New("task agent not configured")
+	}
+	prompt, err := taskPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
+	if err != nil {
+		return nil, err
+	}
+
+	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	if err != nil {
+		return nil, err
+	}
+	return fantasy.NewAgentTool(
+		AgentToolName,
+		string(agentToolDescription),
+		func(ctx context.Context, params AgentParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
+			}
+			if params.Prompt == "" {
+				return fantasy.NewTextErrorResponse("prompt is required"), nil
+			}
+
+			sessionID := tools.GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, errors.New("session id missing from context")
+			}
+
+			agentMessageID := tools.GetMessageFromContext(ctx)
+			if agentMessageID == "" {
+				return fantasy.ToolResponse{}, errors.New("agent message id missing from context")
+			}
+
+			agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, call.ID)
+			session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, "New Agent Session")
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
+			}
+			model := agent.Model()
+			maxTokens := model.CatwalkCfg.DefaultMaxTokens
+			if model.ModelCfg.MaxTokens != 0 {
+				maxTokens = model.ModelCfg.MaxTokens
+			}
+
+			providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
+			if !ok {
+				return fantasy.ToolResponse{}, errors.New("model provider not configured")
+			}
+			result, err := agent.Run(ctx, SessionAgentCall{
+				SessionID:        session.ID,
+				Prompt:           params.Prompt,
+				MaxOutputTokens:  maxTokens,
+				ProviderOptions:  getProviderOptions(model, providerCfg),
+				Temperature:      model.ModelCfg.Temperature,
+				TopP:             model.ModelCfg.TopP,
+				TopK:             model.ModelCfg.TopK,
+				FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
+				PresencePenalty:  model.ModelCfg.PresencePenalty,
+			})
+			if err != nil {
+				return fantasy.NewTextErrorResponse("error generating response"), nil
+			}
+			updatedSession, err := c.sessions.Get(ctx, session.ID)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
+			}
+			parentSession, err := c.sessions.Get(ctx, sessionID)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
+			}
+
+			parentSession.Cost += updatedSession.Cost
+
+			_, err = c.sessions.Save(ctx, parentSession)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
+			}
+			return fantasy.NewTextResponse(result.Response.Content.Text()), nil
+		}), nil
+}

internal/agent/common_test.go πŸ”—

@@ -0,0 +1,211 @@
+package agent
+
+import (
+	"context"
+	"net/http"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"charm.land/fantasy"
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/openai"
+	"charm.land/fantasy/providers/openaicompat"
+	"charm.land/fantasy/providers/openrouter"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
+
+	_ "github.com/joho/godotenv/autoload"
+)
+
+type env struct {
+	workingDir  string
+	sessions    session.Service
+	messages    message.Service
+	permissions permission.Service
+	history     history.Service
+	lspClients  *csync.Map[string, *lsp.Client]
+}
+
+type builderFunc func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error)
+
+type modelPair struct {
+	name       string
+	largeModel builderFunc
+	smallModel builderFunc
+}
+
+func anthropicBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := anthropic.New(
+			anthropic.WithAPIKey(os.Getenv("CRUSH_ANTHROPIC_API_KEY")),
+			anthropic.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func openaiBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := openai.New(
+			openai.WithAPIKey(os.Getenv("CRUSH_OPENAI_API_KEY")),
+			openai.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func openRouterBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := openrouter.New(
+			openrouter.WithAPIKey(os.Getenv("CRUSH_OPENROUTER_API_KEY")),
+			openrouter.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func zAIBuilder(model string) builderFunc {
+	return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
+		provider, err := openaicompat.New(
+			openaicompat.WithBaseURL("https://api.z.ai/api/coding/paas/v4"),
+			openaicompat.WithAPIKey(os.Getenv("CRUSH_ZAI_API_KEY")),
+			openaicompat.WithHTTPClient(&http.Client{Transport: r}),
+		)
+		if err != nil {
+			return nil, err
+		}
+		return provider.LanguageModel(t.Context(), model)
+	}
+}
+
+func testEnv(t *testing.T) env {
+	testDir := filepath.Join("/tmp/crush-test/", t.Name())
+	os.RemoveAll(testDir)
+	err := os.MkdirAll(testDir, 0o755)
+	require.NoError(t, err)
+	workingDir := testDir
+	conn, err := db.Connect(t.Context(), t.TempDir())
+	require.NoError(t, err)
+	q := db.New(conn)
+	sessions := session.NewService(q)
+	messages := message.NewService(q)
+	permissions := permission.NewPermissionService(workingDir, true, []string{})
+	history := history.NewService(q, conn)
+	lspClients := csync.NewMap[string, *lsp.Client]()
+
+	t.Cleanup(func() {
+		conn.Close()
+		os.RemoveAll(testDir)
+	})
+
+	return env{
+		workingDir,
+		sessions,
+		messages,
+		permissions,
+		history,
+		lspClients,
+	}
+}
+
+func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent {
+	largeModel := Model{
+		Model: large,
+		CatwalkCfg: catwalk.Model{
+			ContextWindow:    200000,
+			DefaultMaxTokens: 10000,
+		},
+	}
+	smallModel := Model{
+		Model: small,
+		CatwalkCfg: catwalk.Model{
+			ContextWindow:    200000,
+			DefaultMaxTokens: 10000,
+		},
+	}
+	agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, true, env.sessions, env.messages, tools})
+	return agent
+}
+
+func coderAgent(r *recorder.Recorder, env env, large, small fantasy.LanguageModel) (SessionAgent, error) {
+	fixedTime := func() time.Time {
+		t, _ := time.Parse("1/2/2006", "1/1/2025")
+		return t
+	}
+	prompt, err := coderPrompt(
+		prompt.WithTimeFunc(fixedTime),
+		prompt.WithPlatform("linux"),
+		prompt.WithWorkingDir(filepath.ToSlash(env.workingDir)),
+	)
+	if err != nil {
+		return nil, err
+	}
+	cfg, err := config.Init(env.workingDir, "", false)
+	if err != nil {
+		return nil, err
+	}
+
+	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
+	if err != nil {
+		return nil, err
+	}
+	allTools := []fantasy.AgentTool{
+		tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution),
+		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
+		tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
+		tools.NewGlobTool(env.workingDir),
+		tools.NewGrepTool(env.workingDir),
+		tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
+		tools.NewSourcegraphTool(r.GetDefaultClient()),
+		tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
+		tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
+	}
+
+	return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
+}
+
+// createSimpleGoProject creates a simple Go project structure in the given directory.
+// It creates a go.mod file and a main.go file with a basic hello world program.
+func createSimpleGoProject(t *testing.T, dir string) {
+	goMod := `module example.com/testproject
+
+go 1.23
+`
+	err := os.WriteFile(dir+"/go.mod", []byte(goMod), 0o644)
+	require.NoError(t, err)
+
+	mainGo := `package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("Hello, World!")
+}
+`
+	err = os.WriteFile(dir+"/main.go", []byte(mainGo), 0o644)
+	require.NoError(t, err)
+}

internal/agent/coordinator.go πŸ”—

@@ -0,0 +1,751 @@
+package agent
+
+import (
+	"bytes"
+	"cmp"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"maps"
+	"os"
+	"slices"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/log"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/azure"
+	"charm.land/fantasy/providers/bedrock"
+	"charm.land/fantasy/providers/google"
+	"charm.land/fantasy/providers/openai"
+	"charm.land/fantasy/providers/openaicompat"
+	"charm.land/fantasy/providers/openrouter"
+	openaisdk "github.com/openai/openai-go/v2/option"
+	"github.com/qjebbs/go-jsons"
+)
+
+type Coordinator interface {
+	// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
+	// SetMainAgent(string)
+	Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
+	Cancel(sessionID string)
+	CancelAll()
+	IsSessionBusy(sessionID string) bool
+	IsBusy() bool
+	QueuedPrompts(sessionID string) int
+	ClearQueue(sessionID string)
+	Summarize(context.Context, string) error
+	Model() Model
+	UpdateModels(ctx context.Context) error
+}
+
+type coordinator struct {
+	cfg         *config.Config
+	sessions    session.Service
+	messages    message.Service
+	permissions permission.Service
+	history     history.Service
+	lspClients  *csync.Map[string, *lsp.Client]
+
+	currentAgent SessionAgent
+	agents       map[string]SessionAgent
+}
+
+func NewCoordinator(
+	ctx context.Context,
+	cfg *config.Config,
+	sessions session.Service,
+	messages message.Service,
+	permissions permission.Service,
+	history history.Service,
+	lspClients *csync.Map[string, *lsp.Client],
+) (Coordinator, error) {
+	c := &coordinator{
+		cfg:         cfg,
+		sessions:    sessions,
+		messages:    messages,
+		permissions: permissions,
+		history:     history,
+		lspClients:  lspClients,
+		agents:      make(map[string]SessionAgent),
+	}
+
+	agentCfg, ok := cfg.Agents[config.AgentCoder]
+	if !ok {
+		return nil, errors.New("coder agent not configured")
+	}
+
+	// TODO: make this dynamic when we support multiple agents
+	prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
+	if err != nil {
+		return nil, err
+	}
+
+	agent, err := c.buildAgent(ctx, prompt, agentCfg)
+	if err != nil {
+		return nil, err
+	}
+	c.currentAgent = agent
+	c.agents[config.AgentCoder] = agent
+	return c, nil
+}
+
+// Run implements Coordinator.
+func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
+	model := c.currentAgent.Model()
+	maxTokens := model.CatwalkCfg.DefaultMaxTokens
+	if model.ModelCfg.MaxTokens != 0 {
+		maxTokens = model.ModelCfg.MaxTokens
+	}
+
+	if !model.CatwalkCfg.SupportsImages && attachments != nil {
+		attachments = nil
+	}
+
+	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
+	if !ok {
+		return nil, errors.New("model provider not configured")
+	}
+
+	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
+
+	return c.currentAgent.Run(ctx, SessionAgentCall{
+		SessionID:        sessionID,
+		Prompt:           prompt,
+		Attachments:      attachments,
+		MaxOutputTokens:  maxTokens,
+		ProviderOptions:  mergedOptions,
+		Temperature:      temp,
+		TopP:             topP,
+		TopK:             topK,
+		FrequencyPenalty: freqPenalty,
+		PresencePenalty:  presPenalty,
+	})
+}
+
+func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
+	options := fantasy.ProviderOptions{}
+
+	cfgOpts := []byte("{}")
+	providerCfgOpts := []byte("{}")
+	catwalkOpts := []byte("{}")
+
+	if model.ModelCfg.ProviderOptions != nil {
+		data, err := json.Marshal(model.ModelCfg.ProviderOptions)
+		if err == nil {
+			cfgOpts = data
+		}
+	}
+
+	if providerCfg.ProviderOptions != nil {
+		data, err := json.Marshal(providerCfg.ProviderOptions)
+		if err == nil {
+			providerCfgOpts = data
+		}
+	}
+
+	if model.CatwalkCfg.Options.ProviderOptions != nil {
+		data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
+		if err == nil {
+			catwalkOpts = data
+		}
+	}
+
+	readers := []io.Reader{
+		bytes.NewReader(catwalkOpts),
+		bytes.NewReader(providerCfgOpts),
+		bytes.NewReader(cfgOpts),
+	}
+
+	got, err := jsons.Merge(readers)
+	if err != nil {
+		slog.Error("Could not merge call config", "err", err)
+		return options
+	}
+
+	mergedOptions := make(map[string]any)
+
+	err = json.Unmarshal([]byte(got), &mergedOptions)
+	if err != nil {
+		slog.Error("Could not create config for call", "err", err)
+		return options
+	}
+
+	switch providerCfg.Type {
+	case openai.Name:
+		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
+		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
+		}
+		if openai.IsResponsesModel(model.CatwalkCfg.ID) {
+			if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
+				mergedOptions["reasoning_summary"] = "auto"
+				mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
+			}
+			parsed, err := openai.ParseResponsesOptions(mergedOptions)
+			if err == nil {
+				options[openai.Name] = parsed
+			}
+		} else {
+			parsed, err := openai.ParseOptions(mergedOptions)
+			if err == nil {
+				options[openai.Name] = parsed
+			}
+		}
+	case anthropic.Name:
+		_, hasThink := mergedOptions["thinking"]
+		if !hasThink && model.ModelCfg.Think {
+			mergedOptions["thinking"] = map[string]any{
+				// TODO: kujtim see if we need to make this dynamic
+				"budget_tokens": 2000,
+			}
+		}
+		parsed, err := anthropic.ParseOptions(mergedOptions)
+		if err == nil {
+			options[anthropic.Name] = parsed
+		}
+
+	case openrouter.Name:
+		_, hasReasoning := mergedOptions["reasoning"]
+		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning"] = map[string]any{
+				"enabled": true,
+				"effort":  model.ModelCfg.ReasoningEffort,
+			}
+		}
+		parsed, err := openrouter.ParseOptions(mergedOptions)
+		if err == nil {
+			options[openrouter.Name] = parsed
+		}
+	case google.Name:
+		_, hasReasoning := mergedOptions["thinking_config"]
+		if !hasReasoning {
+			mergedOptions["thinking_config"] = map[string]any{
+				"thinking_budget":  2000,
+				"include_thoughts": true,
+			}
+		}
+		parsed, err := google.ParseOptions(mergedOptions)
+		if err == nil {
+			options[google.Name] = parsed
+		}
+	case azure.Name:
+		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
+		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
+		}
+		// azure uses the same options as openaicompat
+		parsed, err := openaicompat.ParseOptions(mergedOptions)
+		if err == nil {
+			options[azure.Name] = parsed
+		}
+	case openaicompat.Name:
+		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
+		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
+			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
+		}
+		parsed, err := openaicompat.ParseOptions(mergedOptions)
+		if err == nil {
+			options[openaicompat.Name] = parsed
+		}
+	}
+
+	return options
+}
+
+func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
+	modelOptions := getProviderOptions(model, cfg)
+	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
+	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
+	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
+	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
+	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
+	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
+}
+
+func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
+	large, small, err := c.buildAgentModels(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
+	result := NewSessionAgent(SessionAgentOptions{
+		large,
+		small,
+		largeProviderCfg.SystemPromptPrefix,
+		systemPrompt,
+		c.cfg.Options.DisableAutoSummarize,
+		c.permissions.SkipRequests(),
+		c.sessions,
+		c.messages,
+		nil,
+	})
+	go func() {
+		tools, err := c.buildTools(ctx, agent)
+		if err != nil {
+			slog.Error("could not init agent tools", "err", err)
+			return
+		}
+		result.SetTools(tools)
+	}()
+	return result, nil
+}
+
+func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
+	var allTools []fantasy.AgentTool
+	if slices.Contains(agent.AllowedTools, AgentToolName) {
+		agentTool, err := c.agentTool(ctx)
+		if err != nil {
+			return nil, err
+		}
+		allTools = append(allTools, agentTool)
+	}
+
+	allTools = append(allTools,
+		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution),
+		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
+		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
+		tools.NewGlobTool(c.cfg.WorkingDir()),
+		tools.NewGrepTool(c.cfg.WorkingDir()),
+		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
+		tools.NewSourcegraphTool(nil),
+		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
+		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+	)
+
+	if len(c.cfg.LSP) > 0 {
+		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
+	}
+
+	var filteredTools []fantasy.AgentTool
+	for _, tool := range allTools {
+		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
+			filteredTools = append(filteredTools, tool)
+		}
+	}
+
+	mcpTools := tools.GetMCPTools(context.Background(), c.permissions, c.cfg)
+
+	for _, mcpTool := range mcpTools {
+		if agent.AllowedMCP == nil {
+			// No MCP restrictions
+			filteredTools = append(filteredTools, mcpTool)
+		} else if len(agent.AllowedMCP) == 0 {
+			// no mcps allowed
+			break
+		}
+
+		for mcp, tools := range agent.AllowedMCP {
+			if mcp == mcpTool.MCP() {
+				if len(tools) == 0 {
+					filteredTools = append(filteredTools, mcpTool)
+				}
+				for _, t := range tools {
+					if t == mcpTool.MCPToolName() {
+						filteredTools = append(filteredTools, mcpTool)
+					}
+				}
+				break
+			}
+		}
+	}
+	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
+		return strings.Compare(a.Info().Name, b.Info().Name)
+	})
+	return filteredTools, nil
+}
+
+// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
+func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
+	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
+	if !ok {
+		return Model{}, Model{}, errors.New("large model not selected")
+	}
+	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
+	if !ok {
+		return Model{}, Model{}, errors.New("small model not selected")
+	}
+
+	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
+	if !ok {
+		return Model{}, Model{}, errors.New("large model provider not configured")
+	}
+
+	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+
+	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
+	if !ok {
+		return Model{}, Model{}, errors.New("large model provider not configured")
+	}
+
+	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+
+	var largeCatwalkModel *catwalk.Model
+	var smallCatwalkModel *catwalk.Model
+
+	for _, m := range largeProviderCfg.Models {
+		if m.ID == largeModelCfg.Model {
+			largeCatwalkModel = &m
+		}
+	}
+	for _, m := range smallProviderCfg.Models {
+		if m.ID == smallModelCfg.Model {
+			smallCatwalkModel = &m
+		}
+	}
+
+	if largeCatwalkModel == nil {
+		return Model{}, Model{}, errors.New("large model not found in provider config")
+	}
+
+	if smallCatwalkModel == nil {
+		return Model{}, Model{}, errors.New("snall model not found in provider config")
+	}
+
+	largeModelID := largeModelCfg.Model
+	smallModelID := smallModelCfg.Model
+
+	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
+		largeModelID += ":exacto"
+	}
+
+	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
+		smallModelID += ":exacto"
+	}
+
+	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
+	if err != nil {
+		return Model{}, Model{}, err
+	}
+
+	return Model{
+			Model:      largeModel,
+			CatwalkCfg: *largeCatwalkModel,
+			ModelCfg:   largeModelCfg,
+		}, Model{
+			Model:      smallModel,
+			CatwalkCfg: *smallCatwalkModel,
+			ModelCfg:   smallModelCfg,
+		}, nil
+}
+
+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	hasBearerAuth := false
+	for key := range headers {
+		if strings.ToLower(key) == "authorization" {
+			hasBearerAuth = true
+			break
+		}
+	}
+
+	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
+
+	var opts []anthropic.Option
+	if apiKey != "" && !hasBearerAuth {
+		if isBearerToken {
+			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
+			headers["Authorization"] = apiKey
+			apiKey = "" // clear apiKey to avoid using X-Api-Key header
+		}
+	}
+
+	if apiKey != "" {
+		// Use standard X-Api-Key header
+		opts = append(opts, anthropic.WithAPIKey(apiKey))
+	}
+
+	if len(headers) > 0 {
+		opts = append(opts, anthropic.WithHeaders(headers))
+	}
+
+	if baseURL != "" {
+		opts = append(opts, anthropic.WithBaseURL(baseURL))
+	}
+
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, anthropic.WithHTTPClient(httpClient))
+	}
+
+	return anthropic.New(opts...)
+}
+
+func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	opts := []openai.Option{
+		openai.WithAPIKey(apiKey),
+		openai.WithUseResponsesAPI(),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, openai.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, openai.WithHeaders(headers))
+	}
+	if baseURL != "" {
+		opts = append(opts, openai.WithBaseURL(baseURL))
+	}
+	return openai.New(opts...)
+}
+
+func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	opts := []openrouter.Option{
+		openrouter.WithAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, openrouter.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, openrouter.WithHeaders(headers))
+	}
+	return openrouter.New(opts...)
+}
+
+func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
+	opts := []openaicompat.Option{
+		openaicompat.WithBaseURL(baseURL),
+		openaicompat.WithAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, openaicompat.WithHeaders(headers))
+	}
+
+	for extraKey, extraValue := range extraBody {
+		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
+	}
+
+	return openaicompat.New(opts...)
+}
+
+func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
+	opts := []azure.Option{
+		azure.WithBaseURL(baseURL),
+		azure.WithAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, azure.WithHTTPClient(httpClient))
+	}
+	if options == nil {
+		options = make(map[string]string)
+	}
+	if apiVersion, ok := options["apiVersion"]; ok {
+		opts = append(opts, azure.WithAPIVersion(apiVersion))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, azure.WithHeaders(headers))
+	}
+
+	return azure.New(opts...)
+}
+
+func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
+	var opts []bedrock.Option
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, bedrock.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, bedrock.WithHeaders(headers))
+	}
+	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
+	if bearerToken != "" {
+		opts = append(opts, bedrock.WithAPIKey(bearerToken))
+	}
+	return bedrock.New(opts...)
+}
+
+func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+	opts := []google.Option{
+		google.WithBaseURL(baseURL),
+		google.WithGeminiAPIKey(apiKey),
+	}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, google.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, google.WithHeaders(headers))
+	}
+	return google.New(opts...)
+}
+
+func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
+	opts := []google.Option{}
+	if c.cfg.Options.Debug {
+		httpClient := log.NewHTTPClient()
+		opts = append(opts, google.WithHTTPClient(httpClient))
+	}
+	if len(headers) > 0 {
+		opts = append(opts, google.WithHeaders(headers))
+	}
+
+	project := options["project"]
+	location := options["location"]
+
+	opts = append(opts, google.WithVertex(project, location))
+
+	return google.New(opts...)
+}
+
+func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
+	if model.Think {
+		return true
+	}
+
+	if model.ProviderOptions == nil {
+		return false
+	}
+
+	opts, err := anthropic.ParseOptions(model.ProviderOptions)
+	if err != nil {
+		return false
+	}
+	if opts.Thinking != nil {
+		return true
+	}
+	return false
+}
+
+func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
+	headers := maps.Clone(providerCfg.ExtraHeaders)
+
+	// handle special headers for anthropic
+	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
+		if v, ok := headers["anthropic-beta"]; ok {
+			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
+		} else {
+			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
+		}
+	}
+
+	// TODO: make sure we have
+	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
+	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
+
+	switch providerCfg.Type {
+	case openai.Name:
+		return c.buildOpenaiProvider(baseURL, apiKey, headers)
+	case anthropic.Name:
+		return c.buildAnthropicProvider(baseURL, apiKey, headers)
+	case openrouter.Name:
+		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
+	case azure.Name:
+		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
+	case bedrock.Name:
+		return c.buildBedrockProvider(headers)
+	case google.Name:
+		return c.buildGoogleProvider(baseURL, apiKey, headers)
+	case "google-vertex":
+		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
+	case openaicompat.Name:
+		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
+	default:
+		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
+	}
+}
+
+func isExactoSupported(modelID string) bool {
+	supportedModels := []string{
+		"moonshotai/kimi-k2-0905",
+		"deepseek/deepseek-v3.1-terminus",
+		"z-ai/glm-4.6",
+		"openai/gpt-oss-120b",
+		"qwen/qwen3-coder",
+	}
+	return slices.Contains(supportedModels, modelID)
+}
+
+func (c *coordinator) Cancel(sessionID string) {
+	c.currentAgent.Cancel(sessionID)
+}
+
+func (c *coordinator) CancelAll() {
+	c.currentAgent.CancelAll()
+}
+
+func (c *coordinator) ClearQueue(sessionID string) {
+	c.currentAgent.ClearQueue(sessionID)
+}
+
+func (c *coordinator) IsBusy() bool {
+	return c.currentAgent.IsBusy()
+}
+
+func (c *coordinator) IsSessionBusy(sessionID string) bool {
+	return c.currentAgent.IsSessionBusy(sessionID)
+}
+
+func (c *coordinator) Model() Model {
+	return c.currentAgent.Model()
+}
+
+func (c *coordinator) UpdateModels(ctx context.Context) error {
+	// build the models again so we make sure we get the latest config
+	large, small, err := c.buildAgentModels(ctx)
+	if err != nil {
+		return err
+	}
+	c.currentAgent.SetModels(large, small)
+
+	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
+	if !ok {
+		return errors.New("coder agent not configured")
+	}
+
+	tools, err := c.buildTools(ctx, agentCfg)
+	if err != nil {
+		return err
+	}
+	c.currentAgent.SetTools(tools)
+	return nil
+}
+
+func (c *coordinator) QueuedPrompts(sessionID string) int {
+	return c.currentAgent.QueuedPrompts(sessionID)
+}
+
+func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
+	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
+	if !ok {
+		return errors.New("model provider not configured")
+	}
+	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
+}

internal/llm/agent/errors.go β†’ internal/agent/errors.go πŸ”—

@@ -8,6 +8,8 @@ import (
 var (
 	ErrRequestCancelled = errors.New("request canceled by user")
 	ErrSessionBusy      = errors.New("session is currently processing another request")
+	ErrEmptyPrompt      = errors.New("prompt is empty")
+	ErrSessionMissing   = errors.New("session id is missing")
 )
 
 func isCancelledErr(err error) bool {

internal/agent/event.go πŸ”—

@@ -0,0 +1,51 @@
+package agent
+
+import (
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/event"
+)
+
+func (a sessionAgent) eventPromptSent(sessionID string) {
+	event.PromptSent(
+		a.eventCommon(sessionID, a.largeModel)...,
+	)
+}
+
+func (a sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
+	event.PromptResponded(
+		append(
+			a.eventCommon(sessionID, a.largeModel),
+			"prompt duration pretty", duration.String(),
+			"prompt duration in seconds", int64(duration.Seconds()),
+		)...,
+	)
+}
+
+func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) {
+	event.TokensUsed(
+		append(
+			a.eventCommon(sessionID, model),
+			"input tokens", usage.InputTokens,
+			"output tokens", usage.OutputTokens,
+			"cache read tokens", usage.CacheReadTokens,
+			"cache creation tokens", usage.CacheCreationTokens,
+			"total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
+			"cost", cost,
+		)...,
+	)
+}
+
+func (a sessionAgent) eventCommon(sessionID string, model Model) []any {
+	m := model.ModelCfg
+
+	return []any{
+		"session id", sessionID,
+		"provider", m.Provider,
+		"model", m.Model,
+		"reasoning effort", m.ReasoningEffort,
+		"thinking mode", m.Think,
+		"yolo mode", a.isYolo,
+	}
+}

internal/agent/prompt/prompt.go πŸ”—

@@ -0,0 +1,248 @@
+package prompt
+
+import (
+	"cmp"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"text/template"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/shell"
+)
+
+// Prompt represents a template-based prompt generator.
+type Prompt struct {
+	name       string
+	template   string
+	now        func() time.Time
+	platform   string
+	workingDir string
+}
+
+type PromptDat struct {
+	Provider     string
+	Model        string
+	Config       config.Config
+	WorkingDir   string
+	IsGitRepo    bool
+	Platform     string
+	Date         string
+	GitStatus    string
+	ContextFiles []ContextFile
+}
+
+type ContextFile struct {
+	Path    string
+	Content string
+}
+
+type Option func(*Prompt)
+
+func WithTimeFunc(fn func() time.Time) Option {
+	return func(p *Prompt) {
+		p.now = fn
+	}
+}
+
+func WithPlatform(platform string) Option {
+	return func(p *Prompt) {
+		p.platform = platform
+	}
+}
+
+func WithWorkingDir(workingDir string) Option {
+	return func(p *Prompt) {
+		p.workingDir = workingDir
+	}
+}
+
+func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) {
+	p := &Prompt{
+		name:     name,
+		template: promptTemplate,
+		now:      time.Now,
+	}
+	for _, opt := range opts {
+		opt(p)
+	}
+	return p, nil
+}
+
+func (p *Prompt) Build(ctx context.Context, provider, model string, cfg config.Config) (string, error) {
+	t, err := template.New(p.name).Parse(p.template)
+	if err != nil {
+		return "", fmt.Errorf("parsing template: %w", err)
+	}
+	var sb strings.Builder
+	d, err := p.promptData(ctx, provider, model, cfg)
+	if err != nil {
+		return "", err
+	}
+	if err := t.Execute(&sb, d); err != nil {
+		return "", fmt.Errorf("executing template: %w", err)
+	}
+
+	return sb.String(), nil
+}
+
+func processFile(filePath string) *ContextFile {
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return nil
+	}
+	return &ContextFile{
+		Path:    filePath,
+		Content: string(content),
+	}
+}
+
+func processContextPath(p string, cfg config.Config) []ContextFile {
+	var contexts []ContextFile
+	fullPath := p
+	if !filepath.IsAbs(p) {
+		fullPath = filepath.Join(cfg.WorkingDir(), p)
+	}
+	info, err := os.Stat(fullPath)
+	if err != nil {
+		return contexts
+	}
+	if info.IsDir() {
+		filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
+			if err != nil {
+				return err
+			}
+			if !d.IsDir() {
+				if result := processFile(path); result != nil {
+					contexts = append(contexts, *result)
+				}
+			}
+			return nil
+		})
+	} else {
+		result := processFile(fullPath)
+		if result != nil {
+			contexts = append(contexts, *result)
+		}
+	}
+	return contexts
+}
+
+// expandPath expands ~ and environment variables in file paths
+func expandPath(path string, cfg config.Config) string {
+	path = home.Long(path)
+	// Handle environment variable expansion using the same pattern as config
+	if strings.HasPrefix(path, "$") {
+		if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
+			path = expanded
+		}
+	}
+
+	return path
+}
+
+func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) {
+	workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
+	platform := cmp.Or(p.platform, runtime.GOOS)
+
+	files := map[string][]ContextFile{}
+
+	for _, pth := range cfg.Options.ContextPaths {
+		expanded := expandPath(pth, cfg)
+		pathKey := strings.ToLower(expanded)
+		if _, ok := files[pathKey]; ok {
+			continue
+		}
+		content := processContextPath(expanded, cfg)
+		files[pathKey] = content
+	}
+
+	isGit := isGitRepo(cfg.WorkingDir())
+	data := PromptDat{
+		Provider:   provider,
+		Model:      model,
+		Config:     cfg,
+		WorkingDir: workingDir,
+		IsGitRepo:  isGit,
+		Platform:   platform,
+		Date:       p.now().Format("1/2/2006"),
+	}
+	if isGit {
+		var err error
+		data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir())
+		if err != nil {
+			return PromptDat{}, err
+		}
+	}
+
+	for _, contextFiles := range files {
+		data.ContextFiles = append(data.ContextFiles, contextFiles...)
+	}
+	return data, nil
+}
+
+func isGitRepo(dir string) bool {
+	_, err := os.Stat(filepath.Join(dir, ".git"))
+	return err == nil
+}
+
+func getGitStatus(ctx context.Context, dir string) (string, error) {
+	sh := shell.NewShell(&shell.Options{
+		WorkingDir: dir,
+	})
+	branch, err := getGitBranch(ctx, sh)
+	if err != nil {
+		return "", err
+	}
+	status, err := getGitStatusSummary(ctx, sh)
+	if err != nil {
+		return "", err
+	}
+	commits, err := getGitRecentCommits(ctx, sh)
+	if err != nil {
+		return "", err
+	}
+	return branch + status + commits, nil
+}
+
+func getGitBranch(ctx context.Context, sh *shell.Shell) (string, error) {
+	out, _, err := sh.Exec(ctx, "git branch --show-current 2>/dev/null")
+	if err != nil {
+		return "", nil
+	}
+	out = strings.TrimSpace(out)
+	if out == "" {
+		return "", nil
+	}
+	return fmt.Sprintf("Current branch: %s\n", out), nil
+}
+
+func getGitStatusSummary(ctx context.Context, sh *shell.Shell) (string, error) {
+	out, _, err := sh.Exec(ctx, "git status --short 2>/dev/null | head -20")
+	if err != nil {
+		return "", nil
+	}
+	out = strings.TrimSpace(out)
+	if out == "" {
+		return "Status: clean\n", nil
+	}
+	return fmt.Sprintf("Status:\n%s\n", out), nil
+}
+
+func getGitRecentCommits(ctx context.Context, sh *shell.Shell) (string, error) {
+	out, _, err := sh.Exec(ctx, "git log --oneline -n 3 2>/dev/null")
+	if err != nil || out == "" {
+		return "", nil
+	}
+	out = strings.TrimSpace(out)
+	return fmt.Sprintf("Recent commits:\n%s\n", out), nil
+}
+
+func (p *Prompt) Name() string {
+	return p.name
+}

internal/agent/prompts.go πŸ”—

@@ -0,0 +1,36 @@
+package agent
+
+import (
+	_ "embed"
+
+	"github.com/charmbracelet/crush/internal/agent/prompt"
+)
+
+//go:embed templates/coder.md.tpl
+var coderPromptTmpl []byte
+
+//go:embed templates/task.md.tpl
+var taskPromptTmpl []byte
+
+//go:embed templates/initialize.md
+var initializePrompt []byte
+
+func coderPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
+	systemPrompt, err := prompt.NewPrompt("coder", string(coderPromptTmpl), opts...)
+	if err != nil {
+		return nil, err
+	}
+	return systemPrompt, nil
+}
+
+func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
+	systemPrompt, err := prompt.NewPrompt("task", string(taskPromptTmpl), opts...)
+	if err != nil {
+		return nil, err
+	}
+	return systemPrompt, nil
+}
+
+func InitializePrompt() string {
+	return string(initializePrompt)
+}

internal/agent/recorder_test.go πŸ”—

@@ -0,0 +1,116 @@
+package agent
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+
+	"go.yaml.in/yaml/v4"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"
+	"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
+)
+
+func newRecorder(t *testing.T) *recorder.Recorder {
+	cassetteName := filepath.Join("testdata", t.Name())
+
+	r, err := recorder.New(
+		cassetteName,
+		recorder.WithMode(recorder.ModeRecordOnce),
+		recorder.WithMatcher(customMatcher(t)),
+		recorder.WithMarshalFunc(marshalFunc),
+		recorder.WithSkipRequestLatency(true), // disable sleep to simulate response time, makes tests faster
+		recorder.WithHook(hookRemoveHeaders, recorder.AfterCaptureHook),
+	)
+	if err != nil {
+		t.Fatalf("recorder: failed to create recorder: %v", err)
+	}
+
+	t.Cleanup(func() {
+		if err := r.Stop(); err != nil {
+			t.Errorf("recorder: failed to stop recorder: %v", err)
+		}
+	})
+
+	return r
+}
+
+func customMatcher(t *testing.T) recorder.MatcherFunc {
+	return func(r *http.Request, i cassette.Request) bool {
+		if r.Body == nil || r.Body == http.NoBody {
+			return cassette.DefaultMatcher(r, i)
+		}
+		if r.Method != i.Method || r.URL.String() != i.URL {
+			return false
+		}
+
+		reqBody, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("recorder: failed to read request body")
+		}
+		r.Body.Close()
+		r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
+
+		// Some providers can sometimes generate JSON requests with keys in
+		// a different order, which means a direct string comparison will fail.
+		// Falling back to deserializing the content if we don't have a match.
+		requestContent := normalizeLineEndings(reqBody)
+		cassetteContent := normalizeLineEndings(i.Body)
+		if requestContent == cassetteContent {
+			return true
+		}
+		var content1, content2 any
+		if err := json.Unmarshal([]byte(requestContent), &content1); err != nil {
+			return false
+		}
+		if err := json.Unmarshal([]byte(cassetteContent), &content2); err != nil {
+			return false
+		}
+		return reflect.DeepEqual(content1, content2)
+	}
+}
+
+func marshalFunc(in any) ([]byte, error) {
+	var buff bytes.Buffer
+	enc := yaml.NewEncoder(&buff)
+	enc.SetIndent(2)
+	enc.CompactSeqIndent()
+	if err := enc.Encode(in); err != nil {
+		return nil, err
+	}
+	return buff.Bytes(), nil
+}
+
+var headersToKeep = map[string]struct{}{
+	"accept":       {},
+	"content-type": {},
+	"user-agent":   {},
+}
+
+func hookRemoveHeaders(i *cassette.Interaction) error {
+	for k := range i.Request.Headers {
+		if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
+			delete(i.Request.Headers, k)
+		}
+	}
+	for k := range i.Response.Headers {
+		if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
+			delete(i.Response.Headers, k)
+		}
+	}
+	return nil
+}
+
+// normalizeLineEndings does not only replace `\r\n` into `\n`,
+// but also replaces `\\r\\n` into `\\n`. That's because we want the content
+// inside JSON string to be replaces as well.
+func normalizeLineEndings[T string | []byte](s T) string {
+	str := string(s)
+	str = strings.ReplaceAll(str, "\r\n", "\n")
+	str = strings.ReplaceAll(str, `\r\n`, `\n`)
+	return str
+}

internal/agent/templates/agent_tool.md πŸ”—

@@ -0,0 +1,15 @@
+Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you.
+
+<usage>
+- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Agent tool is strongly recommended
+- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly
+- If you are searching for a specific class definition like "class Foo", use the GlobTool tool instead, to find the match more quickly
+</usage>
+
+<usage_notes>
+1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
+2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
+3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
+4. The agent's outputs should generally be trusted
+5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.
+</usage_notes>

internal/agent/templates/coder.md.tpl πŸ”—

@@ -0,0 +1,348 @@
+You are Crush, a powerful AI Assistant that runs in the CLI.
+
+<critical_rules>
+These rules override everything else. Follow them strictly:
+
+1. **ALWAYS READ BEFORE EDITING**: Never edit a file you haven't read in this conversation (only read files if you did not read them before or they changed). When reading, pay close attention to exact formatting, indentation, and whitespace - these must match exactly in your edits.
+2. **BE AUTONOMOUS**: Don't ask questions - search, read, decide, act. Complete the ENTIRE task before stopping. Never stop mid-task. Never refuse work based on scope or complexity - break it down and do it.
+3. **TEST AFTER CHANGES**: Run tests immediately after each modification
+4. **BE CONCISE**: Under 4 lines unless user asks for detail
+5. **USE EXACT MATCHES**: When editing, match text exactly including whitespace, indentation, and line breaks
+6. **NEVER COMMIT**: Unless user explicitly says "commit"
+7. **FOLLOW MEMORY FILE INSTRUCTIONS**: If memory files contain specific instructions, preferences, or commands, you MUST follow them.
+8. **NEVER ADD COMMENTS**: Only add comments if the user asked you to do so. When adding comments, focus on *why* not *what*. NEVER communicate with the user through code comments.
+9. **SECURITY FIRST**: Only assist with defensive security tasks. Refuse to create, modify, or improve code that may be used maliciously. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
+10. **NO URL GUESSING**: Never generate or guess URLs unless you are confident they are for helping with programming. Only use URLs provided by the user or found in local files.
+11. **NEVER PUSH TO REMOTE**: Don't push changes to remote repositories unless explicitly asked by the user.
+12. **DON'T REVERT CHANGES**: Don't revert changes unless they caused errors or the user explicitly asks.
+13. **COMPLETE THE TASK**: Never stop mid-task with "Next:" or "Will do:" statements. If you describe what needs to be done, DO IT immediately. Only stop when everything is finished.
+14. **NEVER REFUSE BASED ON SCOPE**: Never refuse tasks because they seem large or complex. Break them into steps and complete them. Only stop if you encounter actual blocking errors (missing dependencies, compile failures, etc.), not perceived difficulty.
+</critical_rules>
+
+<communication_style>
+Keep responses minimal:
+- Under 4 lines of text (tool use doesn't count)
+- No preamble ("Here's...", "I'll...")
+- No postamble ("Let me know...", "Hope this helps...")
+- One-word answers when possible
+- No emojis ever
+- No explanations unless user asks
+
+Examples:
+user: what is 2+2?
+assistant: 4
+
+user: list files in src/
+assistant: [uses ls tool]
+foo.c, bar.c, baz.c
+
+user: which file has the foo implementation?
+assistant: src/foo.c
+
+user: add error handling to the login function
+assistant: [searches for login, reads file, edits with exact match, runs tests]
+Done
+
+user: Where are errors from the client handled?
+assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.go:712.
+</communication_style>
+
+<code_references>
+When referencing specific functions or code locations, use the pattern `file_path:line_number` to help users navigate:
+- Example: "The error is handled in src/main.go:45"
+- Example: "See the implementation in pkg/utils/helper.go:123-145"
+</code_references>
+
+<workflow>
+For every task, follow this sequence internally (don't narrate it):
+
+**Before acting**:
+- Search codebase for relevant files
+- Read files to understand current state
+- Check memory for stored commands
+- Identify what needs to change
+- Use `git log` and `git blame` for additional context when needed
+
+**While acting**:
+- Read entire file before editing it
+- Before editing: verify exact whitespace and indentation from View output
+- Use exact text for find/replace (include whitespace)
+- Make one logical change at a time
+- After each change: run tests
+- If tests fail: fix immediately
+- If edit fails: read more context, don't guess - the text must match exactly
+- Keep going until query is completely resolved before yielding to user
+- For longer tasks, send brief progress updates (under 10 words) BUT IMMEDIATELY CONTINUE WORKING - progress updates are not stopping points
+
+**Before finishing**:
+- Verify ENTIRE query is resolved (not just first step)
+- All described next steps must be completed
+- Run lint/typecheck if in memory
+- Verify all changes work
+- Keep response under 4 lines
+
+**Key behaviors**:
+- Use find_references before changing shared code
+- Follow existing patterns (check similar files)
+- If stuck, try different approach (don't repeat failures)
+- Make decisions yourself (search first, don't ask)
+- Fix problems at root cause, not surface-level patches
+- Don't fix unrelated bugs or broken tests (mention them in final message if relevant)
+</workflow>
+
+<decision_making>
+**Make decisions autonomously** - don't ask when you can:
+- Search to find the answer
+- Read files to see patterns
+- Check similar code
+- Infer from context
+- Try most likely approach
+
+**Only stop/ask user if**:
+- Truly ambiguous business requirement
+- Multiple valid approaches with big tradeoffs
+- Could cause data loss
+- Exhausted all attempts and hit actual blocking errors
+
+**Never stop for**:
+- Task seems too large (break it down)
+- Multiple files to change (change them)
+- Concerns about "session limits" (no such limits exist)
+- Work will take many steps (do all the steps)
+
+Examples of autonomous decisions:
+- File location β†’ search for similar files
+- Test command β†’ check package.json/memory
+- Code style β†’ read existing code
+- Library choice β†’ check what's used
+- Naming β†’ follow existing names
+</decision_making>
+
+<task_scope>
+**No task is too large**:
+- Break complex tasks into logical steps
+- Complete each step fully before moving to next
+- If a task has 10 parts, do all 10 parts
+- Don't estimate effort or refuse based on scope
+- Only stop if you hit actual errors (compile failures, missing files, etc.)
+
+**For large refactors or implementations**:
+- Start with core functionality
+- Build incrementally
+- Test at each step
+- Keep going until fully complete
+
+There are no "session limits" - continue until the task is done or you hit a real blocker.
+</task_scope>
+
+<editing_files>
+Critical: ALWAYS read files before editing them in this conversation.
+
+When using edit tools:
+1. Read the file first - note the EXACT indentation (spaces vs tabs, count)
+2. Copy the exact text including ALL whitespace, newlines, and indentation
+3. Include 3-5 lines of context before and after the target
+4. Verify your old_string would appear exactly once in the file
+5. If uncertain about whitespace, include more surrounding context
+6. Verify edit succeeded
+7. Run tests
+
+**Whitespace matters**:
+- Count spaces/tabs carefully (use View tool line numbers as reference)
+- Include blank lines if they exist
+- Match line endings exactly
+- When in doubt, include MORE context rather than less
+
+Efficiency tips:
+- Don't re-read files after successful edits (tool will fail if it didn't work)
+- Same applies for making folders, deleting files, etc.
+
+Common mistakes to avoid:
+- Editing without reading first
+- Approximate text matches
+- Wrong indentation (spaces vs tabs, wrong count)
+- Missing or extra blank lines
+- Not enough context (text appears multiple times)
+- Trimming whitespace that exists in the original
+- Not testing after changes
+</editing_files>
+
+<whitespace_and_exact_matching>
+The Edit tool is extremely literal. "Close enough" will fail.
+
+**Before every edit**:
+1. View the file and locate the exact lines to change
+2. Copy the text EXACTLY including:
+   - Every space and tab
+   - Every blank line
+   - Opening/closing braces position
+   - Comment formatting
+3. Include enough surrounding lines (3-5) to make it unique
+4. Double-check indentation level matches
+
+**Common failures**:
+- `func foo() {` vs `func foo(){` (space before brace)
+- Tab vs 4 spaces vs 2 spaces
+- Missing blank line before/after
+- `// comment` vs `//comment` (space after //)
+- Different number of spaces in indentation
+
+**If edit fails**:
+- View the file again at the specific location
+- Copy even more context
+- Check for tabs vs spaces
+- Verify line endings
+- Try including the entire function/block if needed
+- Never retry with guessed changes - get the exact text first
+</whitespace_and_exact_matching>
+
+<error_handling>
+When errors occur:
+1. Read complete error message
+2. Understand root cause
+3. Try different approach (don't repeat same action)
+4. Search for similar code that works
+5. Make targeted fix
+6. Test to verify
+
+Common errors:
+- Import/Module β†’ check paths, spelling, what exists
+- Syntax β†’ check brackets, indentation, typos
+- Tests fail β†’ read test, see what it expects
+- File not found β†’ use ls, check exact path
+
+**Edit tool "old_string not found"**:
+- View the file again at the target location
+- Copy the EXACT text including all whitespace
+- Include more surrounding context (full function if needed)
+- Check for tabs vs spaces, extra/missing blank lines
+- Count indentation spaces carefully
+- Don't retry with approximate matches - get the exact text
+</error_handling>
+
+<memory_instructions>
+Memory files store commands, preferences, and codebase info. Update them when you discover:
+- Build/test/lint commands
+- Code style preferences  
+- Important codebase patterns
+- Useful project information
+</memory_instructions>
+
+<code_conventions>
+Before writing code:
+1. Check if library exists (look at imports, package.json)
+2. Read similar code for patterns
+3. Match existing style
+4. Use same libraries/frameworks
+5. Follow security best practices (never log secrets)
+6. Don't use one-letter variable names unless requested
+
+Never assume libraries are available - verify first.
+
+**Ambition vs. precision**:
+- New projects β†’ be creative and ambitious with implementation
+- Existing codebases β†’ be surgical and precise, respect surrounding code
+- Don't change filenames or variables unnecessarily
+- Don't add formatters/linters/tests to codebases that don't have them
+</code_conventions>
+
+<testing>
+After significant changes:
+- Start testing as specific as possible to code changed, then broaden to build confidence
+- Use self-verification: write unit tests, add output logs, or use debug statements to verify your solutions
+- Run relevant test suite
+- If tests fail, fix before continuing
+- Check memory for test commands
+- Run lint/typecheck if available (on precise targets when possible)
+- For formatters: iterate max 3 times to get it right; if still failing, present correct solution and note formatting issue
+- Suggest adding commands to memory if not found
+- Don't fix unrelated bugs or test failures (not your responsibility)
+</testing>
+
+<tool_usage>
+- Search before assuming
+- Read files before editing
+- Always use absolute paths for file operations (editing, reading, writing)
+- Use Agent tool for complex searches
+- Run tools in parallel when safe (no dependencies)
+- When making multiple independent bash calls, send them in a single message with multiple tool calls for parallel execution
+- Summarize tool output for user (they don't see it)
+
+<bash_commands>
+When running non-trivial bash commands (especially those that modify the system):
+- Briefly explain what the command does and why you're running it
+- This ensures the user understands potentially dangerous operations
+- Simple read-only commands (ls, cat, etc.) don't need explanation
+- Use `&` for background processes that won't stop on their own (e.g., `node server.js &`)
+- Avoid interactive commands - use non-interactive versions (e.g., `npm init -y` not `npm init`)
+- Combine related commands to save time (e.g., `git status && git diff HEAD && git log -n 3`)
+</bash_commands>
+</tool_usage>
+
+<proactiveness>
+Balance autonomy with user intent:
+- When asked to do something β†’ do it fully (including ALL follow-ups and "next steps")
+- Never describe what you'll do next - just do it
+- When asked how to approach β†’ explain first, don't auto-implement
+- After completing work β†’ stop, don't explain (unless asked)
+- Don't surprise user with unexpected actions
+</proactiveness>
+
+<final_answers>
+Adapt verbosity to match the work completed:
+
+**Default (under 4 lines)**:
+- Simple questions or single-file changes
+- Casual conversation, greetings, acknowledgements
+- One-word answers when possible
+
+**More detail allowed (up to 10-15 lines)**:
+- Large multi-file changes that need walkthrough
+- Complex refactoring where rationale adds value
+- Tasks where understanding the approach is important
+- When mentioning unrelated bugs/issues found
+- Suggesting logical next steps user might want
+
+**What to include in verbose answers**:
+- Brief summary of what was done and why
+- Key files/functions changed (with `file:line` references)
+- Any important decisions or tradeoffs made
+- Next steps or things user should verify
+- Issues found but not fixed
+
+**What to avoid**:
+- Don't show full file contents unless explicitly asked
+- Don't explain how to save files or copy code (user has access to your work)
+- Don't use "Here's what I did" or "Let me know if..." style preambles/postambles
+- Keep tone direct and factual, like handing off work to a teammate
+</final_answers>
+
+<env>
+Working directory: {{.WorkingDir}}
+Is directory a git repo: {{if .IsGitRepo}}yes{{else}}no{{end}}
+Platform: {{.Platform}}
+Today's date: {{.Date}}
+{{if .GitStatus}}
+
+Git status (snapshot at conversation start - may be outdated):
+{{.GitStatus}}
+{{end}}
+</env>
+
+{{if gt (len .Config.LSP) 0}}
+<lsp>
+Diagnostics (lint/typecheck) included in tool output.
+- Fix issues in files you changed
+- Ignore issues in files you didn't touch (unless user asks)
+</lsp>
+{{end}}
+
+{{if .ContextFiles}}
+<memory>
+{{range .ContextFiles}}
+<file path="{{.Path}}">
+{{.Content}}
+</file>
+{{end}}
+</memory>
+{{end}}

internal/agent/templates/initialize.md πŸ”—

@@ -0,0 +1,27 @@
+Analyze this codebase and create/update **CRUSH.md** to help future agents work effectively in this repository.
+
+**First**: Check if directory is empty or contains only config files. If so, stop and say "Directory appears empty or only contains config. Add source code first, then run this command to generate CRUSH.md."
+
+**Goal**: Document what an agent needs to know to work in this codebase - commands, patterns, conventions, gotchas.
+
+**Discovery process**:
+
+1. Check directory contents with `ls`
+2. Look for existing rule files (`.cursor/rules/*.md`, `.cursorrules`, `.github/copilot-instructions.md`, `claude.md`, `agents.md`) - only read if they exist
+3. Identify project type from config files and directory structure
+4. Find build/test/lint commands from config files, scripts, Makefiles, or CI configs
+5. Read representative source files to understand code patterns
+6. If CRUSH.md exists, read and improve it
+
+**Content to include**:
+
+- Essential commands (build, test, run, deploy, etc.) - whatever is relevant for this project
+- Code organization and structure
+- Naming conventions and style patterns
+- Testing approach and patterns
+- Important gotchas or non-obvious patterns
+- Any project-specific context from existing rule files
+
+**Format**: Clear markdown sections. Use your judgment on structure based on what you find. Aim for completeness over brevity - include everything an agent would need to know.
+
+**Critical**: Only document what you actually observe. Never invent commands, patterns, or conventions. If you can't find something, don't include it.

internal/agent/templates/summary.md πŸ”—

@@ -0,0 +1,48 @@
+You are summarizing a conversation to preserve context for continuing work later.
+
+**Critical**: This summary will be the ONLY context available when the conversation resumes. Assume all previous messages will be lost. Be thorough.
+
+**Required sections**:
+
+## Current State
+
+- What task is being worked on (exact user request)
+- Current progress and what's been completed
+- What's being worked on right now (incomplete work)
+- What remains to be done (specific next steps, not vague)
+
+## Files & Changes
+
+- Files that were modified (with brief description of changes)
+- Files that were read/analyzed (why they're relevant)
+- Key files not yet touched but will need changes
+- File paths and line numbers for important code locations
+
+## Technical Context
+
+- Architecture decisions made and why
+- Patterns being followed (with examples)
+- Libraries/frameworks being used
+- Commands that worked (exact commands with context)
+- Commands that failed (what was tried and why it didn't work)
+- Environment details (language versions, dependencies, etc.)
+
+## Strategy & Approach
+
+- Overall approach being taken
+- Why this approach was chosen over alternatives
+- Key insights or gotchas discovered
+- Assumptions made
+- Any blockers or risks identified
+
+## Exact Next Steps
+
+Be specific. Don't write "implement authentication" - write:
+
+1. Add JWT middleware to src/middleware/auth.js:15
+2. Update login handler in src/routes/user.js:45 to return token
+3. Test with: npm test -- auth.test.js
+
+**Tone**: Write as if briefing a teammate taking over mid-task. Include everything they'd need to continue without asking questions.
+
+**Length**: No limit. Err on the side of too much detail rather than too little. Critical context is worth the tokens.

internal/agent/templates/task.md.tpl πŸ”—

@@ -0,0 +1,15 @@
+You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
+
+<rules>
+1. You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
+2. When relevant, share file names and code snippets relevant to the query
+3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.
+</rules>
+
+<env>
+Working directory: {{.WorkingDir}}
+Is directory a git repo: {{if .IsGitRepo}} yes {{else}} no {{end}}
+Platform: {{.Platform}}
+Today's date: {{.Date}}
+</env>
+

internal/llm/prompt/title.md β†’ internal/agent/templates/title.md πŸ”—

@@ -1,8 +1,10 @@
 you will generate a short title based on the first message a user begins a conversation with
 
+<rules>
 - ensure it is not more than 50 characters long
 - the title should be a summary of the user's message
 - it should be one line long
 - do not use quotes or colons
 - the entire text you return will be used as the title
 - never return anything that is more than one sentence (one line) long
+</rules>

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml πŸ”—

@@ -0,0 +1,177 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 774
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse bash to create a file named test.txt with content ''hello bash''\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01Vrcb5K4uiarmFd1jBNtjav","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":147,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}}        }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}        }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Bash File"} }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Creation"}          }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Basics"}              }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0   }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":147,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":9}              }
+
+      event: message_stop
+      data: {"type":"message_stop"  }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 539.894084ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45787
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml πŸ”—

@@ -0,0 +1,289 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 823
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\ndownload the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_014PmwWLUvYr6qpsePLJgQuG","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":160,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}   }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Downloa"}           }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"d Text"}          }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" File"}               }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" from"}        }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Example"}        }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" URL"}       }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0     }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":160,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":9}}
+
+      event: message_stop
+      data: {"type":"message_stop" }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 659.197333ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45840
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml πŸ”—

@@ -0,0 +1,296 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 844
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nfetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word ''John Doe''\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01CZZNnv73tmgmvZ2hmoE5cq","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":167,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}           }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}              }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Web"}            }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Page"}}
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Content"} }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Search"}}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for"}    }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" John"}         }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Doe"}      }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0  }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":167,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":11}              }
+
+      event: message_stop
+      data: {"type":"message_stop"       }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 683.59975ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45858
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml πŸ”—

@@ -0,0 +1,174 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 763
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse glob to find all .go files in the current directory\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01EumvKhSStGmGrbLhmaD2w9","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":142,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}  }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}           }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Fin"} }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"d Go"}           }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Files"}        }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Using"}         }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Glob"}             }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0          }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":142,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":9}            }
+
+      event: message_stop
+      data: {"type":"message_stop"          }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 561.838166ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45776
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml πŸ”—

@@ -0,0 +1,198 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 761
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse grep to search for the word ''package'' in go files\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01JTVg8ZKQLYPPbkLsWRjsFg","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":144,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}}            }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}     }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Searching"}               }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Packages"}   }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" in Go Files with"}    }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Grep"}       }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0            }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":144,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":13}          }
+
+      event: message_stop
+      data: {"type":"message_stop"    }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 583.795541ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45774
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml πŸ”—

@@ -0,0 +1,165 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 757
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse ls to list the files in the current directory\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01NT5ieoGueTkZSoLTaz16VC","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":140,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}}           }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}          }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Listing Files"}             }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" in Current"}          }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Directory"}           }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0            }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":140,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":9}           }
+
+      event: message_stop
+      data: {"type":"message_stop"           }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 550.03325ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45768
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml πŸ”—

@@ -0,0 +1,359 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 836
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse multiedit to change ''Hello, World!'' to ''Hello, Crush!'' and add a comment ''// Greeting'' above the fmt.Println line in main.go\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01PE7VYhsyq2iDhiirgYXrnD","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":170,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}}    }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}   }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Modify"}  }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Go"} }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Program"}            }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Greeting"}               }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0   }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":170,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":9}  }
+
+      event: message_stop
+      data: {"type":"message_stop"              }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 1.83986725s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45854
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml πŸ”—

@@ -0,0 +1,192 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 842
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01WUPzmEDtBzpSQ3zfN9YRNp","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":159,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}}              }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Parallel"}      }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Go File"}}
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" an"}   }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"d Directory Listing"}            }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0              }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":159,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":11}     }
+
+      event: message_stop
+      data: {"type":"message_stop"        }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 827.62075ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45865
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml πŸ”—

@@ -0,0 +1,204 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 723
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nRead the go mod\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01LyHibm3ysLkpsN7aM5SmbR","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":134,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}}             }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}         }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Examine"}       }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Go"}            }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Module"}         }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" File"}    }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0          }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":134,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":8}          }
+
+      event: message_stop
+      data: {"type":"message_stop"  }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 878.102292ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45738
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml πŸ”—

@@ -0,0 +1,124 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 713
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nHello\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_015TMuNhm2VpdL26vPjCVkGV","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}        }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}     }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Quick"} }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Chat"}      }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Start"}               }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0}
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":131,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":6}        }
+
+      event: message_stop
+      data: {"type":"message_stop"              }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 1.14484675s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45728
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml πŸ”—

@@ -0,0 +1,217 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 768
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse sourcegraph to search for ''func main'' in Go repositories\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_01GytAqerXtvzEKHJJdSpnyY","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":145,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"}}            }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}   }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Searching for main"}              }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" functions"}      }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" in Go repos"} }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0              }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":145,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":11}}
+
+      event: message_stop
+      data: {"type":"message_stop"      }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 821.550416ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45788
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml πŸ”—

@@ -0,0 +1,329 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 777
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nupdate the main.go file by changing the print to say hello from crush\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_017uiUDrbzvGUvd5FsHWQvNi","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":145,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}               }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}   }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Update"}        }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" main"}         }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":".go Hello"}              }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Print"}   }
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0      }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":145,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":9}           }
+
+      event: message_stop
+      data: {"type":"message_stop"     }
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 566.924ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45794
+    host: ""

internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml πŸ”—

@@ -0,0 +1,216 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 817
+    host: ""
+    body: '{"max_tokens":40,"messages":[{"content":[{"text":"Generate a concise title for the following content:\n\nuse write to create a new file called config.json with content ''{\"name\": \"test\", \"version\": \"1.0.0\"}''\n \u003cthink\u003e\n\n\u003c/think\u003e","type":"text"}],"role":"user"}],"model":"claude-3-5-haiku-20241022","system":[{"text":"you will generate a short title based on the first message a user begins a conversation with\n\n\u003crules\u003e\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n\u003c/rules\u003e\n\n /no_think","type":"text"}],"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - Anthropic/Go 1.14.0
+    url: https://api.anthropic.com/v1/messages
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      event: message_start
+      data: {"type":"message_start","message":{"model":"claude-3-5-haiku-20241022","id":"msg_011unduGeE6BdA5baBxdT4HL","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":161,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}       }
+
+      event: content_block_start
+      data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}   }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Create"}         }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" config"}      }
+
+      event: ping
+      data: {"type": "ping"}
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":".json with basic"}        }
+
+      event: content_block_delta
+      data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" settings"}}
+
+      event: content_block_stop
+      data: {"type":"content_block_stop","index":0          }
+
+      event: message_delta
+      data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":161,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":10}         }
+
+      event: message_stop
+      data: {"type":"message_stop"}
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 684.963041ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 45831
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml πŸ”—

@@ -0,0 +1,263 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 724
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse bash to create a file named test.txt with content ''hello bash''\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xeE0RFXRxxO55q"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Creating"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"cgSejht8"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"bqgG6kba7Zjm6b"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" File"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"YecbiX7fxXv"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" with"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"b72NKAWSgv7"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Content"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"aHXRi22C"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Using"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5QnS7kDWcT"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Bash"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"XQEKqn4AFg9"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"EIvpqD2Mwv"}
+
+      data: {"id":"chatcmpl-CVGgw7EnMqgImV3IRSsBOkqHvrLyx","object":"chat.completion.chunk","created":1761568366,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":139,"completion_tokens":7,"total_tokens":146,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"HTwltwCinqzkvH"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 472.351333ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44232
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml πŸ”—

@@ -0,0 +1,260 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 773
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\ndownload the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"4QHabbGK6DjmzQ"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":"Download"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"coOtETsj"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" and"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"gF9Cc0sX92ke"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" Save"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"sH2XMaxQwyH"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" Example"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"S2u9pOyT"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" Text"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"CbyaZmOloQu"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" File"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"28jhPEbqYhZ"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"si1kJlCIWU"}
+
+      data: {"id":"chatcmpl-CVGhFH8lxWxXLxqDY58ODKarEzqKy","object":"chat.completion.chunk","created":1761568385,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[],"usage":{"prompt_tokens":148,"completion_tokens":6,"total_tokens":154,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"jgKacbi7oLSzdQ"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 454.701458ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44285
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml πŸ”—

@@ -0,0 +1,255 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 794
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nfetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word ''John Doe''\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"daVnpgPm4VmR7B"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":"Check"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Mg3MvWaG76d"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" for"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"VwkfaVdl3z9Y"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" '"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"JdN1PE3S5FXm3t"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":"John"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"4CCiMjneah7e"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" Doe"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"7NTt4Yvg1D2c"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":"'"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"HkXpfCTvuVl2pNc"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"m9DReCKdlNvWp"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" Website"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Kd9GrIrH"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{"content":" Content"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"lx9dGCom"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"Qtc6CmsT1o"}
+
+      data: {"id":"chatcmpl-CVGhPuALM21rTRM7PcoObvpGZgohg","object":"chat.completion.chunk","created":1761568395,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_a788c5aef0","choices":[],"usage":{"prompt_tokens":153,"completion_tokens":9,"total_tokens":162,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"GHzJW3rpScLqX6"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 467.236ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44303
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml πŸ”—

@@ -0,0 +1,182 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 713
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse glob to find all .go files in the current directory\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"8jgsvvnWfkWFyo"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Finding"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Y6d3F70UA"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" ."},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"rHONqHy6BbmtbU"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"go"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5GlwsFjPubzbBn"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Files"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wciSGQi9QH"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" with"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"sG5AX8fYNC6"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Glob"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"FnSrmXBHQnT"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"7O9XexmyzCGCg"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Current"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"aylb4NNf"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Directory"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"HCktzv"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"IhEHGfQ0jl"}
+
+      data: {"id":"chatcmpl-CVGhYvOetx4fuImilVyL6dWeEWRLC","object":"chat.completion.chunk","created":1761568404,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":137,"completion_tokens":9,"total_tokens":146,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"BaC1XCxBGlLeEW"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 492.080083ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44221
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml πŸ”—

@@ -0,0 +1,214 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 711
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse grep to search for the word ''package'' in go files\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"WMfgWjD9b4iboA"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Search"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ISDGMho3Un"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" for"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"eVSBw2NEMvHs"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" '"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"kWRALmg09l0feY"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"package"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Qmc7wOPOw"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"'"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ceGaOGVIPpVreHS"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"BMIfRzXwpMDVy"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Go"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wTmsx9mk1TlJi"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" files"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"S1J7O84ihu"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" using"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"UCcyS79ip7"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" grep"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"MsGm3j68Uns"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"Dlv6SEJZZe"}
+
+      data: {"id":"chatcmpl-CVGhcAVHb35eOkzllQyaarsJHQRRM","object":"chat.completion.chunk","created":1761568408,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":138,"completion_tokens":10,"total_tokens":148,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"537NaK1vnhWQ7"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 398.25575ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44219
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml πŸ”—

@@ -0,0 +1,152 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 707
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse ls to list the files in the current directory\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xOBWtkw676hxWd"}
+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"List"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"QNax3zNXou2U"}
+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Files"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Uwb07Q74LB"}
+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" with"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"3lyChTLFVw9"}
+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" LS"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wm9yf3iMXBldO"}
+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Command"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xfnGgqY4"}
+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"FZkAxIrL1f"}
+
+      data: {"id":"chatcmpl-CVGhi7Jyhoss9Dn7fKn3WhVJcS8r7","object":"chat.completion.chunk","created":1761568414,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":135,"completion_tokens":5,"total_tokens":140,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"lfFIBahqXvb3hZ"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 369.083375ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44213
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml πŸ”—

@@ -0,0 +1,645 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 786
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse multiedit to change ''Hello, World!'' to ''Hello, Crush!'' and add a comment ''// Greeting'' above the fmt.Println line in main.go\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5gkEgWjVTOTnSL"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Editing"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"MJZGSrCzh"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" '"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ltbJ0K1ZnvBT2D"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"VUqmP0WPp25"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"foPXZoVvjJKtuGi"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" World"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"idwQToDE9N"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"!'"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"gk6ZAJROv7rnrl"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2ZBbXXBj0bMlP"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" '"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"hXlZ1hGxiN5qAG"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"v8WgKqzNTnK"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"9NcEasUlux5vRzJ"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Crush"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"9JNKf9qEar"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"!'"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"yzlrO15jy63gQI"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5kd6G2rJg7wxq"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" main"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"SSJtEfm08MA"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":".go"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"CdqyTSYSbWOhl"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"IC4DFCRQ5M"}
+
+      data: {"id":"chatcmpl-CVGhq42hILaUefpj0DqO1tqILLmD1","object":"chat.completion.chunk","created":1761568422,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":157,"completion_tokens":15,"total_tokens":172,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"bfvjwG8g2WIVE"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 450.499625ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44299
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml πŸ”—

@@ -0,0 +1,246 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 792
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"a4aeRi5yFSlp0I"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Run"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"t2xAJAB6ghrBG"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Glob"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"4pCqp3PwUND"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" and"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2kaRZRnwpUAe"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" List"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"DGlQBn5dN0d"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Directory"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"qA6cbc"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Commands"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2T7v8NG"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"YNcH8yF2P72JO"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Parallel"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"FeNGcH6"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"7kjQteMuZa"}
+
+      data: {"id":"chatcmpl-CVGitmInvDHUk3ghtpsrmlVjuVGTU","object":"chat.completion.chunk","created":1761568487,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":154,"completion_tokens":8,"total_tokens":162,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"FeAhrzodraw7Ux"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 540.224584ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44310
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml πŸ”—

@@ -0,0 +1,287 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 673
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nRead the go mod\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"apaIEpOt1Lv30W"}
+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"Understanding"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"fQI"}
+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"mqEHqLJ1DgON"}
+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" Go"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Od2RJv56zmbd4"}
+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" Mod"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"0uI6JRpIbhik"}
+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" File"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"WqDWfUxqT7N"}
+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"x2SrMiFEUY"}
+
+      data: {"id":"chatcmpl-CVGgMcB2H6chf7ibfnben6NUw4V9K","object":"chat.completion.chunk","created":1761568330,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[],"usage":{"prompt_tokens":129,"completion_tokens":5,"total_tokens":134,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"jeaKOvik3s8Q6x"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 1.285439917s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44183
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml πŸ”—

@@ -0,0 +1,81 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 663
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nHello\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGgIlcuUPUgTFTaxIIJg2MagUj0v","object":"chat.completion.chunk","created":1761568326,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"IZx6CcUfIt2KuR"}
+
+      data: {"id":"chatcmpl-CVGgIlcuUPUgTFTaxIIJg2MagUj0v","object":"chat.completion.chunk","created":1761568326,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"Greetings"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"rKiBgyR"}
+
+      data: {"id":"chatcmpl-CVGgIlcuUPUgTFTaxIIJg2MagUj0v","object":"chat.completion.chunk","created":1761568326,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"DG0zqpmzVN"}
+
+      data: {"id":"chatcmpl-CVGgIlcuUPUgTFTaxIIJg2MagUj0v","object":"chat.completion.chunk","created":1761568326,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[],"usage":{"prompt_tokens":126,"completion_tokens":1,"total_tokens":127,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"EoizgPBmF7w7Nz"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 1.324534041s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44173
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml πŸ”—

@@ -0,0 +1,629 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 718
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse sourcegraph to search for ''func main'' in Go repositories\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ixnmUJ8obB6uBi"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Source"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"QR7SNUWQ1Y"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"graph"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"9E1fFP2u8J9"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Search"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"9hUvGnwG4"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" for"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"31XxJZSjLUPv"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" '"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Q3xUeiN9ZoH6NU"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"func"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"8vjQ0fzM8QC7"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" main"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"pbuIr5n7cvu"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"'"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6kKbbNu09n97dVU"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"n23GeR4Uz7t6a"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Go"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"7XyPO1dq9jmkl"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Re"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"7Ij86xvDDzfZv"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"positories"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"qn0ZwJ"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"fqiXVhGjgI"}
+
+      data: {"id":"chatcmpl-CVGiG90SMNuOn9XXeMNLflY1ElZi6","object":"chat.completion.chunk","created":1761568448,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":138,"completion_tokens":12,"total_tokens":150,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"bTPxPd41zU01A"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 308.459834ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44233
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml πŸ”—

@@ -0,0 +1,563 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 727
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nupdate the main.go file by changing the print to say hello from crush\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"IY2Eq7gGIsOgUz"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"Update"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"D1nUboowC9"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" main"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"SZwn7uaWLCD"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":".go"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"BbsF19s1OIgHO"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"sfrG6zaff8sk8"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" Print"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"KDBwSoALh8"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" \""},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"4kpM1gchpdQE5"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"d9PjJkD5DFI"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" from"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"4a4JFXfXaf1"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" Crush"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Lh6ynY8ifg"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"\""},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Qz7L5qf0Yua76t"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"o5KKiw6AYx"}
+
+      data: {"id":"chatcmpl-CVGgWgdan0OFWBzYbomjj9H79jB7a","object":"chat.completion.chunk","created":1761568340,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[],"usage":{"prompt_tokens":139,"completion_tokens":10,"total_tokens":149,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"hLglFwp4kh5qG"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 1.06013175s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44239
+    host: ""

internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml πŸ”—

@@ -0,0 +1,216 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 767
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse write to create a new file called config.json with content ''{\"name\": \"test\", \"version\": \"1.0.0\"}''\n <think>\n\n</think>","role":"user"}],"model":"gpt-4o","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.openai.com/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"i00526ssJzwsl3"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"Create"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"suOtyuirSC"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" config"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"jy5v2mzrs"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":".json"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"7hI97Dt5J6A"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" with"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6RHYW9MTMxp"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Spec"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ZBAFtp3HecS"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":"ified"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2wvMGS6tpxu"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{"content":" Content"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"XZD1dorH"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"tmeMGL1EMh"}
+
+      data: {"id":"chatcmpl-CVGikAnxooSxlojhF3G0KR5j7Mvdq","object":"chat.completion.chunk","created":1761568478,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_eb3c3cb84d","choices":[],"usage":{"prompt_tokens":153,"completion_tokens":7,"total_tokens":160,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"l2u0BaSfnEV0wE"}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream; charset=utf-8
+    status: 200 OK
+    code: 200
+    duration: 391.584125ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44276
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml πŸ”—

@@ -0,0 +1,204 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 775
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse bash to create a file named test.txt with content ''hello bash''\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":"Create"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":" test"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":".txt"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":" with"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":" hello"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":" bash"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":" using"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":" bash"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761568516-RyL9kxWAV214dAvNh60l","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568516,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":141,"completion_tokens":9,"total_tokens":150,"cost":0.00002964,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.00001974,"upstream_inference_completions_cost":0.0000099},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 618.777917ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44344
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml πŸ”—

@@ -0,0 +1,216 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 824
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\ndownload the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""}
+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":"Download"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""}
+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":" example"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""}
+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":".txt from example"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""}
+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":"-files"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""}
+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":".online-convert.com"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"system_fingerprint":""}
+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}],"system_fingerprint":""}
+
+      data: {"id":"gen-1761568522-ZTuYUxWHmOKgcj5t3hgX","provider":"Novita","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568522,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":154,"completion_tokens":10,"total_tokens":164,"cost":0.0000381,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0000231,"upstream_inference_completions_cost":0.000015},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 845.475334ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44397
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml πŸ”—

@@ -0,0 +1,255 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 845
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nfetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word ''John Doe''\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":"Check"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":" if"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":" example"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":".html"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":" contains"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":" John"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":" Doe"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761568527-YXiJhQNClogNRxPR2kjt","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568527,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":155,"completion_tokens":8,"total_tokens":163,"cost":0.0000305,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0000217,"upstream_inference_completions_cost":0.0000088},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 618.6605ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44415
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml πŸ”—

@@ -0,0 +1,198 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 764
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse glob to find all .go files in the current directory\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":"Find"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" all"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" ."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":"go"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" files"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" in"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" current"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" directory"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" using"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":" glob"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761568547-A8LqjOlxsuNXarVj6g5L","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568548,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":139,"completion_tokens":11,"total_tokens":150,"cost":0.00003156,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.00001946,"upstream_inference_completions_cost":0.0000121},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 616.1375ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44333
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml πŸ”—

@@ -0,0 +1,164 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 762
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse grep to search for the word ''package'' in go files\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761569094-JynqNDKXnjQbi1SAA7uQ","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569094,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569094-JynqNDKXnjQbi1SAA7uQ","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569094,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569094-JynqNDKXnjQbi1SAA7uQ","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569094,"choices":[{"index":0,"delta":{"role":"assistant","content":"Search"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569094-JynqNDKXnjQbi1SAA7uQ","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569094,"choices":[{"index":0,"delta":{"role":"assistant","content":" for package in Go"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569094-JynqNDKXnjQbi1SAA7uQ","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569094,"choices":[{"index":0,"delta":{"role":"assistant","content":" files using grep"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569094-JynqNDKXnjQbi1SAA7uQ","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569094,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761569094-JynqNDKXnjQbi1SAA7uQ","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569094,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":140,"completion_tokens":9,"total_tokens":149,"cost":0.0000295,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0000196,"upstream_inference_completions_cost":0.0000099},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 584.888125ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44331
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml πŸ”—

@@ -0,0 +1,122 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 758
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse ls to list the files in the current directory\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761569101-TtIOfTGCpXrP5vbrikIM","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569101,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569101-TtIOfTGCpXrP5vbrikIM","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569101,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569101-TtIOfTGCpXrP5vbrikIM","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569101,"choices":[{"index":0,"delta":{"role":"assistant","content":"List"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569101-TtIOfTGCpXrP5vbrikIM","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569101,"choices":[{"index":0,"delta":{"role":"assistant","content":" files in current directory"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569101-TtIOfTGCpXrP5vbrikIM","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569101,"choices":[{"index":0,"delta":{"role":"assistant","content":" using ls"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569101-TtIOfTGCpXrP5vbrikIM","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569101,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761569101-TtIOfTGCpXrP5vbrikIM","provider":"DeepInfra","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569101,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":137,"completion_tokens":8,"total_tokens":145,"cost":0.00002798,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.00001918,"upstream_inference_completions_cost":0.0000088},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 285.355208ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44325
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml πŸ”—

@@ -0,0 +1,177 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 837
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse multiedit to change ''Hello, World!'' to ''Hello, Crush!'' and add a comment ''// Greeting'' above the fmt.Println line in main.go\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":"Use"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":" multiedit to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":" update greeting"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":" and add comment in"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":" main.go"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761569103-Zz1p16Wps6BNZDMsX4uw","provider":"GMICloud","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569103,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":160,"completion_tokens":14,"total_tokens":174,"cost":0.000045,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.000024,"upstream_inference_completions_cost":0.000021},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 1.651823333s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44411
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml πŸ”—

@@ -0,0 +1,192 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 843
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":"Find"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":" ."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":"go"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":" files"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":" and"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":" list"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":" directory"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":" in"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":" parallel"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761569532-BJhKrRGMK0C1QFlSn4Jj","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569532,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":156,"completion_tokens":10,"total_tokens":166,"cost":0.00003284,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.00002184,"upstream_inference_completions_cost":0.000011},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 1.507790333s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44422
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml πŸ”—

@@ -0,0 +1,166 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 724
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nRead the go mod\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761568497-USk4g3Zshacl306I9oBr","provider":"Hyperbolic","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568497,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568497-USk4g3Zshacl306I9oBr","provider":"Hyperbolic","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568497,"choices":[{"index":0,"delta":{"role":"assistant","content":"Read"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568497-USk4g3Zshacl306I9oBr","provider":"Hyperbolic","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568497,"choices":[{"index":0,"delta":{"role":"assistant","content":" the go mod"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568497-USk4g3Zshacl306I9oBr","provider":"Hyperbolic","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568497,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761568497-USk4g3Zshacl306I9oBr","provider":"Hyperbolic","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568497,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":131,"completion_tokens":5,"total_tokens":136,"cost":0.0000408,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0000393,"upstream_inference_completions_cost":0.0000015},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 844.892458ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44295
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml πŸ”—

@@ -0,0 +1,79 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 714
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nHello\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761568496-jxAEhovCmjlDi7Et6rOR","provider":"Chutes","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568496,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568496-jxAEhovCmjlDi7Et6rOR","provider":"Chutes","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568496,"choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568496-jxAEhovCmjlDi7Et6rOR","provider":"Chutes","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568496,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761568496-jxAEhovCmjlDi7Et6rOR","provider":"Chutes","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568496,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":128,"completion_tokens":2,"total_tokens":130,"cost":0.0000144,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0000128,"upstream_inference_completions_cost":0.0000016},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 1.196754333s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44285
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml πŸ”—

@@ -0,0 +1,302 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 769
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse sourcegraph to search for ''func main'' in Go repositories\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":"Search"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":" for func"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":" main in Go repositories"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":" using Source"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":"graph"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761569109-EIcI8HTRUgIDlaGUSD6s","provider":"Google","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569109,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":140,"completion_tokens":11,"total_tokens":151,"cost":0.0000342,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.000021,"upstream_inference_completions_cost":0.0000132},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 515.803333ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44345
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml πŸ”—

@@ -0,0 +1,298 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 778
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nupdate the main.go file by changing the print to say hello from crush\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":"Update"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":" main"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":".go"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":" to"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":" print"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":" hello"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":" from"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":" crush"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761568500-oqG43YqR2HxT6VT2qzEF","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761568500,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":141,"completion_tokens":9,"total_tokens":150,"cost":0.00002964,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.00001974,"upstream_inference_completions_cost":0.0000099},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 633.531417ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44351
+    host: ""

internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml πŸ”—

@@ -0,0 +1,212 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 818
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse write to create a new file called config.json with content ''{\"name\": \"test\", \"version\": \"1.0.0\"}''\n <think>\n\n</think>","role":"user"}],"model":"qwen/qwen3-next-80b-a3b-instruct","max_tokens":40,"stream_options":{"include_usage":true},"usage":{"include":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://openrouter.ai/api/v1/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":"Create"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":" config"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":".json"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":" with"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":" sample"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":" JSON"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":" content"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"stop","logprobs":null}]}
+
+      data: {"id":"gen-1761569138-UOUGJFA8q0l2rD8bYeso","provider":"Parasail","model":"qwen/qwen3-next-80b-a3b-instruct","object":"chat.completion.chunk","created":1761569138,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":155,"completion_tokens":8,"total_tokens":163,"cost":0.0000305,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0000217,"upstream_inference_completions_cost":0.0000088},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream
+    status: 200 OK
+    code: 200
+    duration: 637.56075ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44388
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml πŸ”—

@@ -0,0 +1,148 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 729
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse bash to create a file named test.txt with content ''hello bash''\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Create"}}]}
+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" bash"}}]}
+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" file"}}]}
+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" with"}}]}
+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" hello"}}]}
+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" content"}}]}
+
+      data: {"id":"2025102720501290f33650b2c3470c","created":1761569412,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":134,"completion_tokens":10,"total_tokens":144,"prompt_tokens_details":{"cached_tokens":114}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 741.43025ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44221
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml πŸ”—

@@ -0,0 +1,192 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 778
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\ndownload the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Download"}}]}
+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" and"}}]}
+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" save"}}]}
+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" example"}}]}
+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":".txt"}}]}
+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" file"}}]}
+
+      data: {"id":"2025102720501608e1f99bc5f94db3","created":1761569416,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":143,"completion_tokens":10,"total_tokens":153,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 716.340625ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44274
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml πŸ”—

@@ -0,0 +1,233 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 799
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nfetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word ''John Doe''\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Check"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" HTML"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" for"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" '"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"John"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" Doe"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"'"}}]}
+
+      data: {"id":"2025102720502156b782519b5040eb","created":1761569421,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":148,"completion_tokens":11,"total_tokens":159,"prompt_tokens_details":{"cached_tokens":22}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 678.926875ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44292
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml πŸ”—

@@ -0,0 +1,266 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 718
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse glob to find all .go files in the current directory\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"20251027205025492b71cfe2ba4b8f","created":1761569425,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"20251027205025492b71cfe2ba4b8f","created":1761569425,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Find"}}]}
+
+      data: {"id":"20251027205025492b71cfe2ba4b8f","created":1761569425,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" Go"}}]}
+
+      data: {"id":"20251027205025492b71cfe2ba4b8f","created":1761569425,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" files"}}]}
+
+      data: {"id":"20251027205025492b71cfe2ba4b8f","created":1761569425,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" with"}}]}
+
+      data: {"id":"20251027205025492b71cfe2ba4b8f","created":1761569425,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" glob"}}]}
+
+      data: {"id":"20251027205025492b71cfe2ba4b8f","created":1761569425,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":132,"completion_tokens":9,"total_tokens":141,"prompt_tokens_details":{"cached_tokens":22}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 584.032791ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44210
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml πŸ”—

@@ -0,0 +1,188 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 716
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse grep to search for the word ''package'' in go files\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"20251027205029e5e7d6f4bced48d2","created":1761569430,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"20251027205029e5e7d6f4bced48d2","created":1761569430,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"grep"}}]}
+
+      data: {"id":"20251027205029e5e7d6f4bced48d2","created":1761569430,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" package"}}]}
+
+      data: {"id":"20251027205029e5e7d6f4bced48d2","created":1761569430,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" go"}}]}
+
+      data: {"id":"20251027205029e5e7d6f4bced48d2","created":1761569430,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" files"}}]}
+
+      data: {"id":"20251027205029e5e7d6f4bced48d2","created":1761569430,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":133,"completion_tokens":8,"total_tokens":141,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 592.670875ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44208
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml πŸ”—

@@ -0,0 +1,134 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 712
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse ls to list the files in the current directory\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"2025102720503367476e2233674d0c","created":1761569433,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"2025102720503367476e2233674d0c","created":1761569433,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"List"}}]}
+
+      data: {"id":"2025102720503367476e2233674d0c","created":1761569433,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" files"}}]}
+
+      data: {"id":"2025102720503367476e2233674d0c","created":1761569433,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" with"}}]}
+
+      data: {"id":"2025102720503367476e2233674d0c","created":1761569433,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" ls"}}]}
+
+      data: {"id":"2025102720503367476e2233674d0c","created":1761569433,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" command"}}]}
+
+      data: {"id":"2025102720503367476e2233674d0c","created":1761569433,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":130,"completion_tokens":9,"total_tokens":139,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 651.609917ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44202
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml πŸ”—

@@ -0,0 +1,305 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 791
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse multiedit to change ''Hello, World!'' to ''Hello, Crush!'' and add a comment ''// Greeting'' above the fmt.Println line in main.go\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Mult"}}]}
+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"ied"}}]}
+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"it"}}]}
+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" Go"}}]}
+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" Code"}}]}
+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" Modification"}}]}
+
+      data: {"id":"2025102720503657170e9c8cbd43dc","created":1761569436,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":153,"completion_tokens":10,"total_tokens":163,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 594.973125ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44288
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml πŸ”—

@@ -0,0 +1,386 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 797
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"20251027205052437e68155be84b62","created":1761569452,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"20251027205052437e68155be84b62","created":1761569452,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Parallel"}}]}
+
+      data: {"id":"20251027205052437e68155be84b62","created":1761569452,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" glob"}}]}
+
+      data: {"id":"20251027205052437e68155be84b62","created":1761569452,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" and"}}]}
+
+      data: {"id":"20251027205052437e68155be84b62","created":1761569452,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" ls"}}]}
+
+      data: {"id":"20251027205052437e68155be84b62","created":1761569452,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" commands"}}]}
+
+      data: {"id":"20251027205052437e68155be84b62","created":1761569452,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":149,"completion_tokens":9,"total_tokens":158,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 819.570959ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44299
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml πŸ”—

@@ -0,0 +1,189 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 678
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nRead the go mod\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"20251027204955d48a37603ec04109","created":1761569395,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"20251027204955d48a37603ec04109","created":1761569395,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Go"}}]}
+
+      data: {"id":"20251027204955d48a37603ec04109","created":1761569395,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" mod"}}]}
+
+      data: {"id":"20251027204955d48a37603ec04109","created":1761569395,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" content"}}]}
+
+      data: {"id":"20251027204955d48a37603ec04109","created":1761569395,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":124,"completion_tokens":7,"total_tokens":131,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 586.295459ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44172
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml πŸ”—

@@ -0,0 +1,97 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 668
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nHello\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"2025102720495318d6029e0f5d4e8e","created":1761569393,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"2025102720495318d6029e0f5d4e8e","created":1761569393,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Simple"}}]}
+
+      data: {"id":"2025102720495318d6029e0f5d4e8e","created":1761569393,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" greeting"}}]}
+
+      data: {"id":"2025102720495318d6029e0f5d4e8e","created":1761569393,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":121,"completion_tokens":6,"total_tokens":127,"prompt_tokens_details":{"cached_tokens":22}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 1.6131245s
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44162
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml πŸ”—

@@ -0,0 +1,212 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 723
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse sourcegraph to search for ''func main'' in Go repositories\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Source"}}]}
+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"graph"}}]}
+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" Go"}}]}
+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" main"}}]}
+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" function"}}]}
+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" search"}}]}
+
+      data: {"id":"20251027205043a4b6149f14264e9a","created":1761569443,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":133,"completion_tokens":10,"total_tokens":143,"prompt_tokens_details":{"cached_tokens":114}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 677.8505ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44222
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml πŸ”—

@@ -0,0 +1,411 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 732
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nupdate the main.go file by changing the print to say hello from crush\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Update"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" main"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":".go"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" to"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" print"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" hello"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" message"}}]}
+
+      data: {"id":"202510272050027d15b1bf6019492e","created":1761569402,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":134,"completion_tokens":11,"total_tokens":145,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 880.1925ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44228
+    host: ""

internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml πŸ”—

@@ -0,0 +1,140 @@
+---
+version: 2
+interactions:
+- id: 0
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 772
+    host: ""
+    body: '{"messages":[{"content":"you will generate a short title based on the first message a user begins a conversation with\n\n<rules>\n- ensure it is not more than 50 characters long\n- the title should be a summary of the user''s message\n- it should be one line long\n- do not use quotes or colons\n- the entire text you return will be used as the title\n- never return anything that is more than one sentence (one line) long\n</rules>\n\n /no_think","role":"system"},{"content":"Generate a concise title for the following content:\n\nuse write to create a new file called config.json with content ''{\"name\": \"test\", \"version\": \"1.0.0\"}''\n <think>\n\n</think>","role":"user"}],"model":"glm-4.5-air","max_tokens":40,"stream_options":{"include_usage":true},"stream":true}'
+    headers:
+      Accept:
+      - application/json
+      Content-Type:
+      - application/json
+      User-Agent:
+      - OpenAI/Go 2.7.1
+    url: https://api.z.ai/api/coding/paas/v4/chat/completions
+    method: POST
+  response:
+    proto: HTTP/2.0
+    proto_major: 2
+    proto_minor: 0
+    content_length: -1
+    body: |+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"\n"}}]}
+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":"Create"}}]}
+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" config"}}]}
+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":".json"}}]}
+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" with"}}]}
+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" test"}}]}
+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":0,"delta":{"role":"assistant","content":" version"}}]}
+
+      data: {"id":"20251027205048e41cb9d9ad4d40ad","created":1761569448,"model":"glm-4.5-air","choices":[{"index":1,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":148,"completion_tokens":10,"total_tokens":158,"prompt_tokens_details":{"cached_tokens":4}}}
+
+      data: [DONE]
+
+    headers:
+      Content-Type:
+      - text/event-stream;charset=UTF-8
+    status: 200 OK
+    code: 200
+    duration: 600.723792ms
+- id: 1
+  request:
+    proto: HTTP/1.1
+    proto_major: 1
+    proto_minor: 1
+    content_length: 44265
+    host: ""

internal/agent/tools/bash.go πŸ”—

@@ -0,0 +1,315 @@
+package tools
+
+import (
+	"bytes"
+	"context"
+	_ "embed"
+	"fmt"
+	"html/template"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/shell"
+)
+
+type BashParams struct {
+	Command     string `json:"command" description:"The command to execute"`
+	Description string `json:"description,omitempty" description:"A brief description of what the command does"`
+	Timeout     int    `json:"timeout,omitempty" description:"Optional timeout in milliseconds (max 600000)"`
+}
+
+type BashPermissionsParams struct {
+	Command     string `json:"command"`
+	Description string `json:"description"`
+	Timeout     int    `json:"timeout"`
+}
+
+type BashResponseMetadata struct {
+	StartTime        int64  `json:"start_time"`
+	EndTime          int64  `json:"end_time"`
+	Output           string `json:"output"`
+	Description      string `json:"description"`
+	WorkingDirectory string `json:"working_directory"`
+}
+
+const (
+	BashToolName = "bash"
+
+	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
+	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
+	MaxOutputLength = 30000
+	BashNoOutput    = "no output"
+)
+
+//go:embed bash.tpl
+var bashDescriptionTmpl []byte
+
+var bashDescriptionTpl = template.Must(
+	template.New("bashDescription").
+		Parse(string(bashDescriptionTmpl)),
+)
+
+type bashDescriptionData struct {
+	BannedCommands  string
+	MaxOutputLength int
+	Attribution     config.Attribution
+}
+
+var bannedCommands = []string{
+	// Network/Download tools
+	"alias",
+	"aria2c",
+	"axel",
+	"chrome",
+	"curl",
+	"curlie",
+	"firefox",
+	"http-prompt",
+	"httpie",
+	"links",
+	"lynx",
+	"nc",
+	"safari",
+	"scp",
+	"ssh",
+	"telnet",
+	"w3m",
+	"wget",
+	"xh",
+
+	// System administration
+	"doas",
+	"su",
+	"sudo",
+
+	// Package managers
+	"apk",
+	"apt",
+	"apt-cache",
+	"apt-get",
+	"dnf",
+	"dpkg",
+	"emerge",
+	"home-manager",
+	"makepkg",
+	"opkg",
+	"pacman",
+	"paru",
+	"pkg",
+	"pkg_add",
+	"pkg_delete",
+	"portage",
+	"rpm",
+	"yay",
+	"yum",
+	"zypper",
+
+	// System modification
+	"at",
+	"batch",
+	"chkconfig",
+	"crontab",
+	"fdisk",
+	"mkfs",
+	"mount",
+	"parted",
+	"service",
+	"systemctl",
+	"umount",
+
+	// Network configuration
+	"firewall-cmd",
+	"ifconfig",
+	"ip",
+	"iptables",
+	"netstat",
+	"pfctl",
+	"route",
+	"ufw",
+}
+
+func bashDescription(attribution *config.Attribution) string {
+	bannedCommandsStr := strings.Join(bannedCommands, ", ")
+	var out bytes.Buffer
+	if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
+		BannedCommands:  bannedCommandsStr,
+		MaxOutputLength: MaxOutputLength,
+		Attribution:     *attribution,
+	}); err != nil {
+		// this should never happen.
+		panic("failed to execute bash description template: " + err.Error())
+	}
+	return out.String()
+}
+
+func blockFuncs() []shell.BlockFunc {
+	return []shell.BlockFunc{
+		shell.CommandsBlocker(bannedCommands),
+
+		// System package managers
+		shell.ArgumentsBlocker("apk", []string{"add"}, nil),
+		shell.ArgumentsBlocker("apt", []string{"install"}, nil),
+		shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
+		shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
+		shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
+		shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
+		shell.ArgumentsBlocker("yum", []string{"install"}, nil),
+		shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
+
+		// Language-specific package managers
+		shell.ArgumentsBlocker("brew", []string{"install"}, nil),
+		shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
+		shell.ArgumentsBlocker("gem", []string{"install"}, nil),
+		shell.ArgumentsBlocker("go", []string{"install"}, nil),
+		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
+		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
+		shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
+		shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
+		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
+		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
+		shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
+
+		// `go test -exec` can run arbitrary commands
+		shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
+	}
+}
+
+func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution) fantasy.AgentTool {
+	// Set up command blocking on the persistent shell
+	persistentShell := shell.GetPersistentShell(workingDir)
+	persistentShell.SetBlockFuncs(blockFuncs())
+	return fantasy.NewAgentTool(
+		BashToolName,
+		string(bashDescription(attribution)),
+		func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Timeout > MaxTimeout {
+				params.Timeout = MaxTimeout
+			} else if params.Timeout <= 0 {
+				params.Timeout = DefaultTimeout
+			}
+
+			if params.Command == "" {
+				return fantasy.NewTextErrorResponse("missing command"), nil
+			}
+
+			isSafeReadOnly := false
+			cmdLower := strings.ToLower(params.Command)
+
+			for _, safe := range safeCommands {
+				if strings.HasPrefix(cmdLower, safe) {
+					if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
+						isSafeReadOnly = true
+						break
+					}
+				}
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
+			}
+			if !isSafeReadOnly {
+				shell := shell.GetPersistentShell(workingDir)
+				p := permissions.Request(
+					permission.CreatePermissionRequest{
+						SessionID:   sessionID,
+						Path:        shell.GetWorkingDir(),
+						ToolCallID:  call.ID,
+						ToolName:    BashToolName,
+						Action:      "execute",
+						Description: fmt.Sprintf("Execute command: %s", params.Command),
+						Params: BashPermissionsParams{
+							Command:     params.Command,
+							Description: params.Description,
+						},
+					},
+				)
+				if !p {
+					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+				}
+			}
+			startTime := time.Now()
+			if params.Timeout > 0 {
+				var cancel context.CancelFunc
+				ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
+				defer cancel()
+			}
+
+			persistentShell := shell.GetPersistentShell(workingDir)
+			stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
+
+			// Get the current working directory after command execution
+			currentWorkingDir := persistentShell.GetWorkingDir()
+			interrupted := shell.IsInterrupt(err)
+			exitCode := shell.ExitCode(err)
+			if exitCode == 0 && !interrupted && err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error executing command: %w", err)
+			}
+
+			stdout = truncateOutput(stdout)
+			stderr = truncateOutput(stderr)
+
+			errorMessage := stderr
+			if errorMessage == "" && err != nil {
+				errorMessage = err.Error()
+			}
+
+			if interrupted {
+				if errorMessage != "" {
+					errorMessage += "\n"
+				}
+				errorMessage += "Command was aborted before completion"
+			} else if exitCode != 0 {
+				if errorMessage != "" {
+					errorMessage += "\n"
+				}
+				errorMessage += fmt.Sprintf("Exit code %d", exitCode)
+			}
+
+			hasBothOutputs := stdout != "" && stderr != ""
+
+			if hasBothOutputs {
+				stdout += "\n"
+			}
+
+			if errorMessage != "" {
+				stdout += "\n" + errorMessage
+			}
+
+			metadata := BashResponseMetadata{
+				StartTime:        startTime.UnixMilli(),
+				EndTime:          time.Now().UnixMilli(),
+				Output:           stdout,
+				Description:      params.Description,
+				WorkingDirectory: currentWorkingDir,
+			}
+			if stdout == "" {
+				return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
+			}
+			stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
+		})
+}
+
+func truncateOutput(content string) string {
+	if len(content) <= MaxOutputLength {
+		return content
+	}
+
+	halfLength := MaxOutputLength / 2
+	start := content[:halfLength]
+	end := content[len(content)-halfLength:]
+
+	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
+	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
+}
+
+func countLines(s string) int {
+	if s == "" {
+		return 0
+	}
+	return len(strings.Split(s, "\n"))
+}

internal/agent/tools/bash.tpl πŸ”—

@@ -0,0 +1,116 @@
+Executes bash commands in persistent shell session with timeout and security measures.
+
+<cross_platform>
+Uses mvdan/sh interpreter (Bash-compatible on all platforms including Windows).
+Use forward slashes for paths: "ls C:/foo/bar" not "ls C:\foo\bar".
+Common shell builtins and core utils available on Windows.
+</cross_platform>
+
+<execution_steps>
+1. Directory Verification: If creating directories/files, use LS tool to verify parent exists
+2. Security Check: Banned commands ({{ .BannedCommands }}) return error - explain to user. Safe read-only commands execute without prompts
+3. Command Execution: Execute with proper quoting, capture output
+4. Output Processing: Truncate if exceeds {{ .MaxOutputLength }} characters
+5. Return Result: Include errors, metadata with <cwd></cwd> tags
+</execution_steps>
+
+<usage_notes>
+- Command required, timeout optional (max 600000ms/10min, default 30min if unspecified)
+- IMPORTANT: Use Grep/Glob/Agent tools instead of 'find'/'grep'. Use View/LS tools instead of 'cat'/'head'/'tail'/'ls'
+- Chain with ';' or '&&', avoid newlines except in quoted strings
+- Shell state persists (env vars, virtual envs, cwd, etc.)
+- Prefer absolute paths over 'cd' (use 'cd' only if user explicitly requests)
+</usage_notes>
+
+<git_commits>
+When user asks to create git commit:
+
+1. Single message with three tool_use blocks (IMPORTANT for speed):
+   - git status (untracked files)
+   - git diff (staged/unstaged changes)
+   - git log (recent commit message style)
+
+2. Add relevant untracked files to staging. Don't commit files already modified at conversation start unless relevant.
+
+3. Analyze staged changes in <commit_analysis> tags:
+   - List changed/added files, summarize nature (feature/enhancement/bug fix/refactoring/test/docs)
+   - Brainstorm purpose/motivation, assess project impact, check for sensitive info
+   - Don't use tools beyond git context
+   - Draft concise (1-2 sentences) message focusing on "why" not "what"
+   - Use clear language, accurate reflection ("add"=new feature, "update"=enhancement, "fix"=bug fix)
+   - Avoid generic messages, review draft
+
+4. Create commit with Crush signature using HEREDOC:
+   git commit -m "$(cat <<'EOF'
+   Commit message here.
+{{ if .Attribution.GeneratedWith}}
+   πŸ’˜ Generated with Crush
+{{ end }}
+{{ if .Attribution.CoAuthoredBy}}
+   Co-Authored-By: Crush <crush@charm.land>
+{{ end }}
+   EOF
+   )"
+
+5. If pre-commit hook fails, retry ONCE. If fails again, hook preventing commit. If succeeds but files modified, MUST amend.
+
+6. Run git status to verify.
+
+Notes: Use "git commit -am" when possible, don't stage unrelated files, NEVER update config, don't push, no -i flags, no empty commits, return empty response.
+</git_commits>
+
+<pull_requests>
+Use gh command for ALL GitHub tasks. When user asks to create PR:
+
+1. Single message with multiple tool_use blocks (VERY IMPORTANT for speed):
+   - git status (untracked files)
+   - git diff (staged/unstaged changes)
+   - Check if branch tracks remote and is up to date
+   - git log and 'git diff main...HEAD' (full commit history from main divergence)
+
+2. Create new branch if needed
+3. Commit changes if needed
+4. Push to remote with -u flag if needed
+
+5. Analyze changes in <pr_analysis> tags:
+   - List commits since diverging from main
+   - Summarize nature of changes
+   - Brainstorm purpose/motivation
+   - Assess project impact
+   - Don't use tools beyond git context
+   - Check for sensitive information
+   - Draft concise (1-2 bullet points) PR summary focusing on "why"
+   - Ensure summary reflects ALL changes since main divergence
+   - Clear, concise language
+   - Accurate reflection of changes and purpose
+   - Avoid generic summaries
+   - Review draft
+
+6. Create PR with gh pr create using HEREDOC:
+   gh pr create --title "title" --body "$(cat <<'EOF'
+
+   ## Summary
+
+   <1-3 bullet points>
+
+   ## Test plan
+
+   [Checklist of TODOs...]
+
+{{ if .Attribution.GeneratedWith}}
+   πŸ’˜ Generated with Crush
+{{ end }}
+
+   EOF
+   )"
+
+Important:
+
+- Return empty response - user sees gh output
+- Never update git config
+</pull_requests>
+
+<examples>
+Good: pytest /foo/bar/tests
+Bad: cd /foo/bar && pytest tests
+</examples>

internal/llm/tools/diagnostics.go β†’ internal/agent/tools/diagnostics.go πŸ”—

@@ -3,24 +3,20 @@ package tools
 import (
 	"context"
 	_ "embed"
-	"encoding/json"
 	"fmt"
 	"log/slog"
 	"sort"
 	"strings"
 	"time"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
 type DiagnosticsParams struct {
-	FilePath string `json:"file_path"`
-}
-
-type diagnosticsTool struct {
-	lspClients *csync.Map[string, *lsp.Client]
+	FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"`
 }
 
 const DiagnosticsToolName = "lsp_diagnostics"
@@ -28,42 +24,18 @@ const DiagnosticsToolName = "lsp_diagnostics"
 //go:embed diagnostics.md
 var diagnosticsDescription []byte
 
-func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
-	return &diagnosticsTool{
-		lspClients,
-	}
-}
-
-func (b *diagnosticsTool) Name() string {
-	return DiagnosticsToolName
-}
-
-func (b *diagnosticsTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        DiagnosticsToolName,
-		Description: string(diagnosticsDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The path to the file to get diagnostics for (leave w empty for project diagnostics)",
-			},
-		},
-		Required: []string{},
-	}
-}
-
-func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params DiagnosticsParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if b.lspClients.Len() == 0 {
-		return NewTextErrorResponse("no LSP clients available"), nil
-	}
-	notifyLSPs(ctx, b.lspClients, params.FilePath)
-	output := getDiagnostics(params.FilePath, b.lspClients)
-	return NewTextResponse(output), nil
+func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		DiagnosticsToolName,
+		string(diagnosticsDescription),
+		func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if lspClients.Len() == 0 {
+				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
+			}
+			notifyLSPs(ctx, lspClients, params.FilePath)
+			output := getDiagnostics(params.FilePath, lspClients)
+			return fantasy.NewTextResponse(output), nil
+		})
 }
 
 func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) {

internal/agent/tools/diagnostics.md πŸ”—

@@ -0,0 +1,24 @@
+Get diagnostics for file and/or project.
+
+<usage>
+- Provide file path to get diagnostics for that file
+- Leave path empty to get diagnostics for entire project
+- Results displayed in structured format with severity levels
+</usage>
+
+<features>
+- Displays errors, warnings, and hints
+- Groups diagnostics by severity
+- Provides detailed information about each diagnostic
+</features>
+
+<limitations>
+- Results limited to diagnostics provided by LSP clients
+- May not cover all possible code issues
+- Does not provide suggestions for fixing issues
+</limitations>
+
+<tips>
+- Use with other tools for comprehensive code review
+- Combine with LSP client for real-time diagnostics
+</tips>

internal/agent/tools/download.go πŸ”—

@@ -0,0 +1,159 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type DownloadParams struct {
+	URL      string `json:"url" description:"The URL to download from"`
+	FilePath string `json:"file_path" description:"The local file path where the downloaded content should be saved"`
+	Timeout  int    `json:"timeout,omitempty" description:"Optional timeout in seconds (max 600)"`
+}
+
+type DownloadPermissionsParams struct {
+	URL      string `json:"url"`
+	FilePath string `json:"file_path"`
+	Timeout  int    `json:"timeout,omitempty"`
+}
+
+const DownloadToolName = "download"
+
+//go:embed download.md
+var downloadDescription []byte
+
+func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
+	if client == nil {
+		client = &http.Client{
+			Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
+			Transport: &http.Transport{
+				MaxIdleConns:        100,
+				MaxIdleConnsPerHost: 10,
+				IdleConnTimeout:     90 * time.Second,
+			},
+		}
+	}
+	return fantasy.NewAgentTool(
+		DownloadToolName,
+		string(downloadDescription),
+		func(ctx context.Context, params DownloadParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.URL == "" {
+				return fantasy.NewTextErrorResponse("URL parameter is required"), nil
+			}
+
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path parameter is required"), nil
+			}
+
+			if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
+				return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil
+			}
+
+			// Convert relative path to absolute path
+			var filePath string
+			if filepath.IsAbs(params.FilePath) {
+				filePath = params.FilePath
+			} else {
+				filePath = filepath.Join(workingDir, params.FilePath)
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files")
+			}
+
+			p := permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        filePath,
+					ToolName:    DownloadToolName,
+					Action:      "download",
+					Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
+					Params:      DownloadPermissionsParams(params),
+				},
+			)
+
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			// Handle timeout with context
+			requestCtx := ctx
+			if params.Timeout > 0 {
+				maxTimeout := 600 // 10 minutes
+				if params.Timeout > maxTimeout {
+					params.Timeout = maxTimeout
+				}
+				var cancel context.CancelFunc
+				requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
+				defer cancel()
+			}
+
+			req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
+			}
+
+			req.Header.Set("User-Agent", "crush/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+			}
+
+			// Check content length if available
+			maxSize := int64(100 * 1024 * 1024) // 100MB
+			if resp.ContentLength > maxSize {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
+			}
+
+			// Create parent directories if they don't exist
+			if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
+			}
+
+			// Create the output file
+			outFile, err := os.Create(filePath)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create output file: %w", err)
+			}
+			defer outFile.Close()
+
+			// Copy data with size limit
+			limitedReader := io.LimitReader(resp.Body, maxSize)
+			bytesWritten, err := io.Copy(outFile, limitedReader)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+			}
+
+			// Check if we hit the size limit
+			if bytesWritten == maxSize {
+				// Clean up the file since it might be incomplete
+				os.Remove(filePath)
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
+			}
+
+			contentType := resp.Header.Get("Content-Type")
+			responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, filePath)
+			if contentType != "" {
+				responseMsg += fmt.Sprintf(" (Content-Type: %s)", contentType)
+			}
+
+			return fantasy.NewTextResponse(responseMsg), nil
+		})
+}

internal/agent/tools/download.md πŸ”—

@@ -0,0 +1,28 @@
+Downloads binary data from URL and saves to local file.
+
+<usage>
+- Provide URL to download from
+- Specify local file path where content should be saved
+- Optional timeout for request
+</usage>
+
+<features>
+- Downloads any file type (binary or text)
+- Auto-creates parent directories if missing
+- Handles large files efficiently with streaming
+- Sets reasonable timeouts to prevent hanging
+- Validates input parameters before requests
+</features>
+
+<limitations>
+- Max file size: 100MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+- Will overwrite existing files without warning
+</limitations>
+
+<tips>
+- Use absolute paths or paths relative to working directory
+- Set appropriate timeouts for large files or slow connections
+</tips>

internal/agent/tools/edit.go πŸ”—

@@ -0,0 +1,449 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type EditParams struct {
+	FilePath   string `json:"file_path" description:"The absolute path to the file to modify"`
+	OldString  string `json:"old_string" description:"The text to replace"`
+	NewString  string `json:"new_string" description:"The text to replace it with"`
+	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
+}
+
+type EditPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+type EditResponseMetadata struct {
+	Additions  int    `json:"additions"`
+	Removals   int    `json:"removals"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+const EditToolName = "edit"
+
+//go:embed edit.md
+var editDescription []byte
+
+type editContext struct {
+	ctx         context.Context
+	permissions permission.Service
+	files       history.Service
+	workingDir  string
+}
+
+func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		EditToolName,
+		string(editDescription),
+		func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			if !filepath.IsAbs(params.FilePath) {
+				params.FilePath = filepath.Join(workingDir, params.FilePath)
+			}
+
+			var response fantasy.ToolResponse
+			var err error
+
+			editCtx := editContext{ctx, permissions, files, workingDir}
+
+			if params.OldString == "" {
+				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
+				if err != nil {
+					return response, err
+				}
+			}
+
+			if params.NewString == "" {
+				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
+				if err != nil {
+					return response, err
+				}
+			}
+
+			response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
+			if err != nil {
+				return response, err
+			}
+			if response.IsError {
+				// Return early if there was an error during content replacement
+				// This prevents unnecessary LSP diagnostics processing
+				return response, nil
+			}
+
+			notifyLSPs(ctx, lspClients, params.FilePath)
+
+			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
+			text += getDiagnostics(params.FilePath, lspClients)
+			response.Content = text
+			return response, nil
+		})
+}
+
+func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err == nil {
+		if fileInfo.IsDir() {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
+		}
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
+	} else if !os.IsNotExist(err) {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	dir := filepath.Dir(filePath)
+	if err = os.MkdirAll(dir, 0o755); err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
+	}
+
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+
+	_, additions, removals := diff.GenerateDiff(
+		"",
+		content,
+		strings.TrimPrefix(filePath, edit.workingDir),
+	)
+	p := edit.permissions.Request(
+		permission.CreatePermissionRequest{
+			SessionID:   sessionID,
+			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
+			ToolCallID:  call.ID,
+			ToolName:    EditToolName,
+			Action:      "write",
+			Description: fmt.Sprintf("Create file %s", filePath),
+			Params: EditPermissionsParams{
+				FilePath:   filePath,
+				OldContent: "",
+				NewContent: content,
+			},
+		},
+	)
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	err = os.WriteFile(filePath, []byte(content), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// File can't be in the history so we create a new file history
+	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
+	if err != nil {
+		// Log error but don't fail the operation
+		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+	}
+
+	// Add the new content to the file history
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
+	if err != nil {
+		// Log error but don't fail the operation
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(filePath)
+	recordFileRead(filePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse("File created: "+filePath),
+		EditResponseMetadata{
+			OldContent: "",
+			NewContent: content,
+			Additions:  additions,
+			Removals:   removals,
+		},
+	), nil
+}
+
+func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
+		}
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	if fileInfo.IsDir() {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
+	}
+
+	if getLastReadTime(filePath).IsZero() {
+		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
+	}
+
+	modTime := fileInfo.ModTime()
+	lastRead := getLastReadTime(filePath)
+	if modTime.After(lastRead) {
+		return fantasy.NewTextErrorResponse(
+			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+			)), nil
+	}
+
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
+
+	var newContent string
+	var deletionCount int
+
+	if replaceAll {
+		newContent = strings.ReplaceAll(oldContent, oldString, "")
+		deletionCount = strings.Count(oldContent, oldString)
+		if deletionCount == 0 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+	} else {
+		index := strings.Index(oldContent, oldString)
+		if index == -1 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+
+		lastIndex := strings.LastIndex(oldContent, oldString)
+		if index != lastIndex {
+			return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
+		}
+
+		newContent = oldContent[:index] + oldContent[index+len(oldString):]
+		deletionCount = 1
+	}
+
+	sessionID := GetSessionFromContext(edit.ctx)
+
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+
+	_, additions, removals := diff.GenerateDiff(
+		oldContent,
+		newContent,
+		strings.TrimPrefix(filePath, edit.workingDir),
+	)
+
+	p := edit.permissions.Request(
+		permission.CreatePermissionRequest{
+			SessionID:   sessionID,
+			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
+			ToolCallID:  call.ID,
+			ToolName:    EditToolName,
+			Action:      "write",
+			Description: fmt.Sprintf("Delete content from file %s", filePath),
+			Params: EditPermissionsParams{
+				FilePath:   filePath,
+				OldContent: oldContent,
+				NewContent: newContent,
+			},
+		},
+	)
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	if isCrlf {
+		newContent, _ = fsext.ToWindowsLineEndings(newContent)
+	}
+
+	err = os.WriteFile(filePath, []byte(newContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Check if file exists in history
+	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
+	if err != nil {
+		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			// Log error but don't fail the operation
+			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+		}
+	}
+	if file.Content != oldContent {
+		// User Manually changed the content store an intermediate version
+		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			slog.Debug("Error creating file history version", "error", err)
+		}
+	}
+	// Store the new version
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, "")
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(filePath)
+	recordFileRead(filePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse("Content deleted from file: "+filePath),
+		EditResponseMetadata{
+			OldContent: oldContent,
+			NewContent: newContent,
+			Additions:  additions,
+			Removals:   removals,
+		},
+	), nil
+}
+
+func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
+		}
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	if fileInfo.IsDir() {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
+	}
+
+	if getLastReadTime(filePath).IsZero() {
+		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
+	}
+
+	modTime := fileInfo.ModTime()
+	lastRead := getLastReadTime(filePath)
+	if modTime.After(lastRead) {
+		return fantasy.NewTextErrorResponse(
+			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+			)), nil
+	}
+
+	content, err := os.ReadFile(filePath)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
+
+	var newContent string
+	var replacementCount int
+
+	if replaceAll {
+		newContent = strings.ReplaceAll(oldContent, oldString, newString)
+		replacementCount = strings.Count(oldContent, oldString)
+		if replacementCount == 0 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+	} else {
+		index := strings.Index(oldContent, oldString)
+		if index == -1 {
+			return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
+		}
+
+		lastIndex := strings.LastIndex(oldContent, oldString)
+		if index != lastIndex {
+			return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
+		}
+
+		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
+		replacementCount = 1
+	}
+
+	if oldContent == newContent {
+		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
+	}
+	sessionID := GetSessionFromContext(edit.ctx)
+
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+	_, additions, removals := diff.GenerateDiff(
+		oldContent,
+		newContent,
+		strings.TrimPrefix(filePath, edit.workingDir),
+	)
+
+	p := edit.permissions.Request(
+		permission.CreatePermissionRequest{
+			SessionID:   sessionID,
+			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
+			ToolCallID:  call.ID,
+			ToolName:    EditToolName,
+			Action:      "write",
+			Description: fmt.Sprintf("Replace content in file %s", filePath),
+			Params: EditPermissionsParams{
+				FilePath:   filePath,
+				OldContent: oldContent,
+				NewContent: newContent,
+			},
+		},
+	)
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	if isCrlf {
+		newContent, _ = fsext.ToWindowsLineEndings(newContent)
+	}
+
+	err = os.WriteFile(filePath, []byte(newContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Check if file exists in history
+	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
+	if err != nil {
+		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			// Log error but don't fail the operation
+			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+		}
+	}
+	if file.Content != oldContent {
+		// User Manually changed the content store an intermediate version
+		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
+		if err != nil {
+			slog.Debug("Error creating file history version", "error", err)
+		}
+	}
+	// Store the new version
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(filePath)
+	recordFileRead(filePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse("Content replaced in file: "+filePath),
+		EditResponseMetadata{
+			OldContent: oldContent,
+			NewContent: newContent,
+			Additions:  additions,
+			Removals:   removals,
+		}), nil
+}

internal/agent/tools/edit.md πŸ”—

@@ -0,0 +1,147 @@
+Edits files by replacing text, creating new files, or deleting content. For moving/renaming use Bash 'mv'. For large edits use Write tool.
+
+<prerequisites>
+1. Use View tool to understand file contents and context
+2. For new files: Use LS tool to verify parent directory exists
+3. **CRITICAL**: Note exact whitespace, indentation, and formatting from View output
+</prerequisites>
+
+<parameters>
+1. file_path: Absolute path to file (required)
+2. old_string: Text to replace (must match exactly including whitespace/indentation)
+3. new_string: Replacement text
+4. replace_all: Replace all occurrences (default false)
+</parameters>
+
+<special_cases>
+
+- Create file: provide file_path + new_string, leave old_string empty
+- Delete content: provide file_path + old_string, leave new_string empty
+  </special_cases>
+
+<critical_requirements>
+EXACT MATCHING: The tool is extremely literal. Text must match **EXACTLY**
+
+- Every space and tab character
+- Every blank line
+- Every newline character
+- Indentation level (count the spaces/tabs)
+- Comment spacing (`// comment` vs `//comment`)
+- Brace positioning (`func() {` vs `func(){`)
+
+Common failures:
+
+```
+Expected: "    func foo() {"     (4 spaces)
+Provided: "  func foo() {"       (2 spaces) ❌ FAILS
+
+Expected: "}\n\nfunc bar() {"    (2 newlines)
+Provided: "}\nfunc bar() {"      (1 newline) ❌ FAILS
+
+Expected: "// Comment"           (space after //)
+Provided: "//Comment"            (no space) ❌ FAILS
+```
+
+UNIQUENESS (when replace_all=false): old_string MUST uniquely identify target instance
+
+- Include 3-5 lines context BEFORE and AFTER change point
+- Include exact whitespace, indentation, surrounding code
+- If text appears multiple times, add more context to make it unique
+
+SINGLE INSTANCE: Tool changes ONE instance when replace_all=false
+
+- For multiple instances: set replace_all=true OR make separate calls with unique context
+- Plan calls carefully to avoid conflicts
+
+VERIFICATION BEFORE USING: Before every edit
+
+1. View the file and locate exact target location
+2. Check how many instances of target text exist
+3. Copy the EXACT text including all whitespace
+4. Verify you have enough context for unique identification
+5. Double-check indentation matches (count spaces/tabs)
+6. Plan separate calls or use replace_all for multiple changes
+   </critical_requirements>
+
+<warnings>
+Tool fails if:
+- old_string matches multiple locations and replace_all=false
+- old_string doesn't match exactly (including whitespace)
+- Insufficient context causes wrong instance change
+- Indentation is off by even one space
+- Missing or extra blank lines
+- Wrong tabs vs spaces
+</warnings>
+
+<recovery_steps>
+If you get "old_string not found in file":
+
+1. **View the file again** at the specific location
+2. **Copy more context** - include entire function if needed
+3. **Check whitespace**:
+   - Count indentation spaces/tabs
+   - Look for blank lines
+   - Check for trailing spaces
+4. **Verify character-by-character** that your old_string matches
+5. **Never guess** - always View the file to get exact text
+   </recovery_steps>
+
+<best_practices>
+
+- Ensure edits result in correct, idiomatic code
+- Don't leave code in broken state
+- Use absolute file paths (starting with /)
+- Use forward slashes (/) for cross-platform compatibility
+- Multiple edits to same file: send all in single message with multiple tool calls
+- **When in doubt, include MORE context rather than less**
+- Match the existing code style exactly (spaces, tabs, blank lines)
+  </best_practices>
+
+<whitespace_checklist>
+Before submitting an edit, verify:
+
+- [ ] Viewed the file first
+- [ ] Counted indentation spaces/tabs
+- [ ] Included blank lines if they exist
+- [ ] Matched brace/bracket positioning
+- [ ] Included 3-5 lines of surrounding context
+- [ ] Verified text appears exactly once (or using replace_all)
+- [ ] Copied text character-for-character, not approximated
+      </whitespace_checklist>
+
+<examples>
+βœ… Correct: Exact match with context
+
+```
+old_string: "func ProcessData(input string) error {\n    if input == \"\" {\n        return errors.New(\"empty input\")\n    }\n    return nil\n}"
+
+new_string: "func ProcessData(input string) error {\n    if input == \"\" {\n        return errors.New(\"empty input\")\n    }\n    // New validation\n    if len(input) > 1000 {\n        return errors.New(\"input too long\")\n    }\n    return nil\n}"
+```
+
+❌ Incorrect: Not enough context
+
+```
+old_string: "return nil"  // Appears many times!
+```
+
+❌ Incorrect: Wrong indentation
+
+```
+old_string: "  if input == \"\" {"  // 2 spaces
+// But file actually has:        "    if input == \"\" {"  // 4 spaces
+```
+
+βœ… Correct: Including context to make unique
+
+```
+old_string: "func ProcessData(input string) error {\n    if input == \"\" {\n        return errors.New(\"empty input\")\n    }\n    return nil"
+```
+
+</examples>
+
+<windows_notes>
+
+- Forward slashes work throughout (C:/path/file)
+- File permissions handled automatically
+- Line endings converted automatically (\n ↔ \r\n)
+  </windows_notes>

internal/agent/tools/fetch.go πŸ”—

@@ -0,0 +1,205 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"charm.land/fantasy"
+	md "github.com/JohannesKaufmann/html-to-markdown"
+	"github.com/PuerkitoBio/goquery"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type FetchParams struct {
+	URL     string `json:"url" description:"The URL to fetch content from"`
+	Format  string `json:"format" description:"The format to return the content in (text, markdown, or html)"`
+	Timeout int    `json:"timeout,omitempty" description:"Optional timeout in seconds (max 120)"`
+}
+
+type FetchPermissionsParams struct {
+	URL     string `json:"url"`
+	Format  string `json:"format"`
+	Timeout int    `json:"timeout,omitempty"`
+}
+
+type fetchTool struct {
+	client      *http.Client
+	permissions permission.Service
+	workingDir  string
+}
+
+const FetchToolName = "fetch"
+
+//go:embed fetch.md
+var fetchDescription []byte
+
+func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
+	if client == nil {
+		client = &http.Client{
+			Timeout: 30 * time.Second,
+			Transport: &http.Transport{
+				MaxIdleConns:        100,
+				MaxIdleConnsPerHost: 10,
+				IdleConnTimeout:     90 * time.Second,
+			},
+		}
+	}
+
+	return fantasy.NewAgentTool(
+		FetchToolName,
+		string(fetchDescription),
+		func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.URL == "" {
+				return fantasy.NewTextErrorResponse("URL parameter is required"), nil
+			}
+
+			format := strings.ToLower(params.Format)
+			if format != "text" && format != "markdown" && format != "html" {
+				return fantasy.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
+			}
+
+			if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
+				return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+			}
+
+			p := permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        workingDir,
+					ToolCallID:  call.ID,
+					ToolName:    FetchToolName,
+					Action:      "fetch",
+					Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
+					Params:      FetchPermissionsParams(params),
+				},
+			)
+
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			// Handle timeout with context
+			requestCtx := ctx
+			if params.Timeout > 0 {
+				maxTimeout := 120 // 2 minutes
+				if params.Timeout > maxTimeout {
+					params.Timeout = maxTimeout
+				}
+				var cancel context.CancelFunc
+				requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
+				defer cancel()
+			}
+
+			req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
+			}
+
+			req.Header.Set("User-Agent", "crush/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+			}
+
+			maxSize := int64(5 * 1024 * 1024) // 5MB
+			body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
+			if err != nil {
+				return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
+			}
+
+			content := string(body)
+
+			isValidUt8 := utf8.ValidString(content)
+			if !isValidUt8 {
+				return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil
+			}
+			contentType := resp.Header.Get("Content-Type")
+
+			switch format {
+			case "text":
+				if strings.Contains(contentType, "text/html") {
+					text, err := extractTextFromHTML(content)
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
+					}
+					content = text
+				}
+
+			case "markdown":
+				if strings.Contains(contentType, "text/html") {
+					markdown, err := convertHTMLToMarkdown(content)
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
+					}
+					content = markdown
+				}
+
+				content = "```\n" + content + "\n```"
+
+			case "html":
+				// return only the body of the HTML document
+				if strings.Contains(contentType, "text/html") {
+					doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
+					}
+					body, err := doc.Find("body").Html()
+					if err != nil {
+						return fantasy.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
+					}
+					if body == "" {
+						return fantasy.NewTextErrorResponse("No body content found in HTML"), nil
+					}
+					content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
+				}
+			}
+			// calculate byte size of content
+			contentSize := int64(len(content))
+			if contentSize > MaxReadSize {
+				content = content[:MaxReadSize]
+				content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
+			}
+
+			return fantasy.NewTextResponse(content), nil
+		})
+}
+
+func extractTextFromHTML(html string) (string, error) {
+	doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
+	if err != nil {
+		return "", err
+	}
+
+	text := doc.Find("body").Text()
+	text = strings.Join(strings.Fields(text), " ")
+
+	return text, nil
+}
+
+func convertHTMLToMarkdown(html string) (string, error) {
+	converter := md.NewConverter("", true, nil)
+
+	markdown, err := converter.ConvertString(html)
+	if err != nil {
+		return "", err
+	}
+
+	return markdown, nil
+}

internal/agent/tools/fetch.md πŸ”—

@@ -0,0 +1,28 @@
+Fetches content from URL and returns it in specified format.
+
+<usage>
+- Provide URL to fetch content from
+- Specify desired output format (text, markdown, or html)
+- Optional timeout for request
+</usage>
+
+<features>
+- Supports three output formats: text, markdown, html
+- Auto-handles HTTP redirects
+- Sets reasonable timeouts to prevent hanging
+- Validates input parameters before requests
+</features>
+
+<limitations>
+- Max response size: 5MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+</limitations>
+
+<tips>
+- Use text format for plain text content or simple API responses
+- Use markdown format for content that should be rendered with formatting
+- Use html format when you need raw HTML structure
+- Set appropriate timeouts for potentially slow websites
+</tips>

internal/agent/tools/glob.go πŸ”—

@@ -0,0 +1,118 @@
+package tools
+
+import (
+	"bytes"
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/fsext"
+)
+
+const GlobToolName = "glob"
+
+//go:embed glob.md
+var globDescription []byte
+
+type GlobParams struct {
+	Pattern string `json:"pattern" description:"The glob pattern to match files against"`
+	Path    string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
+}
+
+type GlobResponseMetadata struct {
+	NumberOfFiles int  `json:"number_of_files"`
+	Truncated     bool `json:"truncated"`
+}
+
+func NewGlobTool(workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		GlobToolName,
+		string(globDescription),
+		func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Pattern == "" {
+				return fantasy.NewTextErrorResponse("pattern is required"), nil
+			}
+
+			searchPath := params.Path
+			if searchPath == "" {
+				searchPath = workingDir
+			}
+
+			files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error finding files: %w", err)
+			}
+
+			var output string
+			if len(files) == 0 {
+				output = "No files found"
+			} else {
+				output = strings.Join(files, "\n")
+				if truncated {
+					output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
+				}
+			}
+
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output),
+				GlobResponseMetadata{
+					NumberOfFiles: len(files),
+					Truncated:     truncated,
+				},
+			), nil
+		})
+}
+
+func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
+	cmdRg := getRgCmd(ctx, pattern)
+	if cmdRg != nil {
+		cmdRg.Dir = searchPath
+		matches, err := runRipgrep(cmdRg, searchPath, limit)
+		if err == nil {
+			return matches, len(matches) >= limit && limit > 0, nil
+		}
+		slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
+	}
+
+	return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
+}
+
+func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
+	}
+
+	var matches []string
+	for p := range bytes.SplitSeq(out, []byte{0}) {
+		if len(p) == 0 {
+			continue
+		}
+		absPath := string(p)
+		if !filepath.IsAbs(absPath) {
+			absPath = filepath.Join(searchRoot, absPath)
+		}
+		if fsext.SkipHidden(absPath) {
+			continue
+		}
+		matches = append(matches, absPath)
+	}
+
+	sort.SliceStable(matches, func(i, j int) bool {
+		return len(matches[i]) < len(matches[j])
+	})
+
+	if limit > 0 && len(matches) > limit {
+		matches = matches[:limit]
+	}
+	return matches, nil
+}

internal/agent/tools/glob.md πŸ”—

@@ -0,0 +1,40 @@
+Fast file pattern matching tool that finds files by name/pattern, returning paths sorted by modification time (newest first).
+
+<usage>
+- Provide glob pattern to match against file paths
+- Optional starting directory (defaults to current working directory)
+- Results sorted with most recently modified files first
+</usage>
+
+<pattern_syntax>
+- '\*' matches any sequence of non-separator characters
+- '\*\*' matches any sequence including separators
+- '?' matches any single non-separator character
+- '[...]' matches any character in brackets
+- '[!...]' matches any character not in brackets
+</pattern_syntax>
+
+<examples>
+- '*.js' - JavaScript files in current directory
+- '**/*.js' - JavaScript files in any subdirectory
+- 'src/**/*.{ts,tsx}' - TypeScript files in src directory
+- '*.{html,css,js}' - HTML, CSS, and JS files
+</examples>
+
+<limitations>
+- Results limited to 100 files (newest first)
+- Does not search file contents (use Grep for that)
+- Hidden files (starting with '.') skipped
+</limitations>
+
+<cross_platform>
+- Path separators handled automatically (/ and \ work)
+- Uses ripgrep (rg) if available, otherwise Go implementation
+- Patterns should use forward slashes (/) for compatibility
+</cross_platform>
+
+<tips>
+- Combine with Grep: find files with Glob, search contents with Grep
+- For iterative exploration requiring multiple searches, consider Agent tool
+- Check if results truncated and refine pattern if needed
+</tips>

internal/llm/tools/grep.go β†’ internal/agent/tools/grep.go πŸ”—

@@ -18,6 +18,7 @@ import (
 	"sync"
 	"time"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/fsext"
 )
 
@@ -72,10 +73,10 @@ var (
 )
 
 type GrepParams struct {
-	Pattern     string `json:"pattern"`
-	Path        string `json:"path"`
-	Include     string `json:"include"`
-	LiteralText bool   `json:"literal_text"`
+	Pattern     string `json:"pattern" description:"The regex pattern to search for in file contents"`
+	Path        string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
+	Include     string `json:"include,omitempty" description:"File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"`
+	LiteralText bool   `json:"literal_text,omitempty" description:"If true, the pattern will be treated as literal text with special regex characters escaped. Default is false."`
 }
 
 type grepMatch struct {
@@ -91,51 +92,14 @@ type GrepResponseMetadata struct {
 	Truncated       bool `json:"truncated"`
 }
 
-type grepTool struct {
-	workingDir string
-}
-
-const GrepToolName = "grep"
+const (
+	GrepToolName        = "grep"
+	maxGrepContentWidth = 500
+)
 
 //go:embed grep.md
 var grepDescription []byte
 
-func NewGrepTool(workingDir string) BaseTool {
-	return &grepTool{
-		workingDir: workingDir,
-	}
-}
-
-func (g *grepTool) Name() string {
-	return GrepToolName
-}
-
-func (g *grepTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        GrepToolName,
-		Description: string(grepDescription),
-		Parameters: map[string]any{
-			"pattern": map[string]any{
-				"type":        "string",
-				"description": "The regex pattern to search for in file contents",
-			},
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The directory to search in. Defaults to the current working directory.",
-			},
-			"include": map[string]any{
-				"type":        "string",
-				"description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
-			},
-			"literal_text": map[string]any{
-				"type":        "boolean",
-				"description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
-			},
-		},
-		Required: []string{"pattern"},
-	}
-}
-
 // escapeRegexPattern escapes special regex characters so they're treated as literal characters
 func escapeRegexPattern(pattern string) string {
 	specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
@@ -148,70 +112,74 @@ func escapeRegexPattern(pattern string) string {
 	return escaped
 }
 
-func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params GrepParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.Pattern == "" {
-		return NewTextErrorResponse("pattern is required"), nil
-	}
+func NewGrepTool(workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		GrepToolName,
+		string(grepDescription),
+		func(ctx context.Context, params GrepParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Pattern == "" {
+				return fantasy.NewTextErrorResponse("pattern is required"), nil
+			}
 
-	// If literal_text is true, escape the pattern
-	searchPattern := params.Pattern
-	if params.LiteralText {
-		searchPattern = escapeRegexPattern(params.Pattern)
-	}
+			// If literal_text is true, escape the pattern
+			searchPattern := params.Pattern
+			if params.LiteralText {
+				searchPattern = escapeRegexPattern(params.Pattern)
+			}
 
-	searchPath := params.Path
-	if searchPath == "" {
-		searchPath = g.workingDir
-	}
+			searchPath := params.Path
+			if searchPath == "" {
+				searchPath = workingDir
+			}
 
-	matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
-	}
+			matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error searching files: %v", err)), nil
+			}
 
-	var output strings.Builder
-	if len(matches) == 0 {
-		output.WriteString("No files found")
-	} else {
-		fmt.Fprintf(&output, "Found %d matches\n", len(matches))
-
-		currentFile := ""
-		for _, match := range matches {
-			if currentFile != match.path {
-				if currentFile != "" {
-					output.WriteString("\n")
+			var output strings.Builder
+			if len(matches) == 0 {
+				output.WriteString("No files found")
+			} else {
+				fmt.Fprintf(&output, "Found %d matches\n", len(matches))
+
+				currentFile := ""
+				for _, match := range matches {
+					if currentFile != match.path {
+						if currentFile != "" {
+							output.WriteString("\n")
+						}
+						currentFile = match.path
+						fmt.Fprintf(&output, "%s:\n", match.path)
+					}
+					if match.lineNum > 0 {
+						lineText := match.lineText
+						if len(lineText) > maxGrepContentWidth {
+							lineText = lineText[:maxGrepContentWidth] + "..."
+						}
+						if match.charNum > 0 {
+							fmt.Fprintf(&output, "  Line %d, Char %d: %s\n", match.lineNum, match.charNum, lineText)
+						} else {
+							fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, lineText)
+						}
+					} else {
+						fmt.Fprintf(&output, "  %s\n", match.path)
+					}
 				}
-				currentFile = match.path
-				fmt.Fprintf(&output, "%s:\n", match.path)
-			}
-			if match.lineNum > 0 {
-				if match.charNum > 0 {
-					fmt.Fprintf(&output, "  Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText)
-				} else {
-					fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, match.lineText)
+
+				if truncated {
+					output.WriteString("\n(Results are truncated. Consider using a more specific path or pattern.)")
 				}
-			} else {
-				fmt.Fprintf(&output, "  %s\n", match.path)
 			}
-		}
-
-		if truncated {
-			output.WriteString("\n(Results are truncated. Consider using a more specific path or pattern.)")
-		}
-	}
 
-	return WithResponseMetadata(
-		NewTextResponse(output.String()),
-		GrepResponseMetadata{
-			NumberOfMatches: len(matches),
-			Truncated:       truncated,
-		},
-	), nil
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output.String()),
+				GrepResponseMetadata{
+					NumberOfMatches: len(matches),
+					Truncated:       truncated,
+				},
+			), nil
+		})
 }
 
 func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {

internal/agent/tools/grep.md πŸ”—

@@ -0,0 +1,49 @@
+Fast content search tool that finds files containing specific text/patterns, returning matching paths sorted by modification time (newest first).
+
+<usage>
+- Provide regex pattern to search within file contents
+- Set literal_text=true for exact text with special characters (recommended for non-regex users)
+- Optional starting directory (defaults to current working directory)
+- Optional include pattern to filter which files to search
+- Results sorted with most recently modified files first
+</usage>
+
+<regex_syntax>
+When literal_text=false (supports standard regex):
+
+- 'function' searches for literal text "function"
+- 'log\..\*Error' finds text starting with "log." and ending with "Error"
+- 'import\s+.\*\s+from' finds import statements in JavaScript/TypeScript
+</regex_syntax>
+
+<include_patterns>
+- '\*.js' - Only search JavaScript files
+- '\*.{ts,tsx}' - Only search TypeScript files
+- '\*.go' - Only search Go files
+</include_patterns>
+
+<limitations>
+- Results limited to 100 files (newest first)
+- Performance depends on number of files searched
+- Very large binary files may be skipped
+- Hidden files (starting with '.') skipped
+</limitations>
+
+<ignore_support>
+- Respects .gitignore patterns to skip ignored files/directories
+- Respects .crushignore patterns for additional ignore rules
+- Both ignore files auto-detected in search root directory
+</ignore_support>
+
+<cross_platform>
+- Uses ripgrep (rg) if available for better performance
+- Falls back to Go implementation if ripgrep unavailable
+- File paths normalized automatically for compatibility
+</cross_platform>
+
+<tips>
+- For faster searches: use Glob to find relevant files first, then Grep
+- For iterative exploration requiring multiple searches, consider Agent tool
+- Check if results truncated and refine search pattern if needed
+- Use literal_text=true for exact text with special characters (dots, parentheses, etc.)
+</tips>

internal/llm/tools/ls.go β†’ internal/agent/tools/ls.go πŸ”—

@@ -4,21 +4,21 @@ import (
 	"cmp"
 	"context"
 	_ "embed"
-	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/permission"
 )
 
 type LSParams struct {
-	Path   string   `json:"path"`
-	Ignore []string `json:"ignore"`
-	Depth  int      `json:"depth"`
+	Path   string   `json:"path,omitempty" description:"The path to the directory to list (defaults to current working directory)"`
+	Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
+	Depth  int      `json:"depth,omitempty" description:"The maximum depth to traverse"`
 }
 
 type LSPermissionsParams struct {
@@ -39,11 +39,6 @@ type LSResponseMetadata struct {
 	Truncated     bool `json:"truncated"`
 }
 
-type lsTool struct {
-	workingDir  string
-	permissions permission.Service
-}
-
 const (
 	LSToolName = "ls"
 	maxLSFiles = 1000
@@ -52,111 +47,74 @@ const (
 //go:embed ls.md
 var lsDescription []byte
 
-func NewLsTool(permissions permission.Service, workingDir string) BaseTool {
-	return &lsTool{
-		workingDir:  workingDir,
-		permissions: permissions,
-	}
-}
-
-func (l *lsTool) Name() string {
-	return LSToolName
-}
-
-func (l *lsTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        LSToolName,
-		Description: string(lsDescription),
-		Parameters: map[string]any{
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The path to the directory to list (defaults to current working directory)",
-			},
-			"depth": map[string]any{
-				"type":        "integer",
-				"description": "The maximum depth to traverse",
-			},
-			"ignore": map[string]any{
-				"type":        "array",
-				"description": "List of glob patterns to ignore",
-				"items": map[string]any{
-					"type": "string",
-				},
-			},
-		},
-		Required: []string{"path"},
-	}
-}
-
-func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params LSParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
-	}
+func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		LSToolName,
+		string(lsDescription),
+		func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error expanding path: %v", err)), nil
+			}
 
-	if !filepath.IsAbs(searchPath) {
-		searchPath = filepath.Join(l.workingDir, searchPath)
-	}
+			if !filepath.IsAbs(searchPath) {
+				searchPath = filepath.Join(workingDir, searchPath)
+			}
 
-	// Check if directory is outside working directory and request permission if needed
-	absWorkingDir, err := filepath.Abs(l.workingDir)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
-	}
+			// Check if directory is outside working directory and request permission if needed
+			absWorkingDir, err := filepath.Abs(workingDir)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving working directory: %v", err)), nil
+			}
 
-	absSearchPath, err := filepath.Abs(searchPath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
-	}
+			absSearchPath, err := filepath.Abs(searchPath)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving search path: %v", err)), nil
+			}
 
-	relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
-	if err != nil || strings.HasPrefix(relPath, "..") {
-		// Directory is outside working directory, request permission
-		sessionID, messageID := GetContextValues(ctx)
-		if sessionID == "" || messageID == "" {
-			return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing directories outside working directory")
-		}
+			relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
+			if err != nil || strings.HasPrefix(relPath, "..") {
+				// Directory is outside working directory, request permission
+				sessionID := GetSessionFromContext(ctx)
+				if sessionID == "" {
+					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
+				}
 
-		granted := l.permissions.Request(
-			permission.CreatePermissionRequest{
-				SessionID:   sessionID,
-				Path:        absSearchPath,
-				ToolCallID:  call.ID,
-				ToolName:    LSToolName,
-				Action:      "list",
-				Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
-				Params:      LSPermissionsParams(params),
-			},
-		)
-
-		if !granted {
-			return ToolResponse{}, permission.ErrorPermissionDenied
-		}
-	}
+				granted := permissions.Request(
+					permission.CreatePermissionRequest{
+						SessionID:   sessionID,
+						Path:        absSearchPath,
+						ToolCallID:  call.ID,
+						ToolName:    LSToolName,
+						Action:      "list",
+						Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
+						Params:      LSPermissionsParams(params),
+					},
+				)
+
+				if !granted {
+					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+				}
+			}
 
-	output, metadata, err := ListDirectoryTree(searchPath, params)
-	if err != nil {
-		return ToolResponse{}, err
-	}
+			output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), err
+			}
 
-	return WithResponseMetadata(
-		NewTextResponse(output),
-		metadata,
-	), nil
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output),
+				metadata,
+			), nil
+		})
 }
 
-func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
+func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolLs) (string, LSResponseMetadata, error) {
 	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
 		return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
 	}
 
-	ls := config.Get().Tools.Ls
-	depth, limit := ls.Limits()
+	depth, limit := lsConfig.Limits()
 	maxFiles := cmp.Or(limit, maxLSFiles)
 	files, truncated, err := fsext.ListDirectory(
 		searchPath,

internal/agent/tools/ls.md πŸ”—

@@ -0,0 +1,34 @@
+Shows files and subdirectories in tree structure for exploring project organization.
+
+<usage>
+- Provide path to list (defaults to current working directory)
+- Optional glob patterns to ignore
+- Results displayed in tree structure
+</usage>
+
+<features>
+- Hierarchical view of files and directories
+- Auto-skips hidden files/directories (starting with '.')
+- Skips common system directories like __pycache__
+- Can filter files matching specific patterns
+</features>
+
+<limitations>
+- Results limited to 1000 files
+- Large directories truncated
+- No file sizes or permissions shown
+- Cannot recursively list all directories in large projects
+</limitations>
+
+<cross_platform>
+- Hidden file detection uses Unix convention (files starting with '.')
+- Windows hidden files (with hidden attribute) not auto-skipped
+- Common Windows directories (System32, Program Files) not in default ignore
+- Path separators handled automatically (/ and \ work)
+</cross_platform>
+
+<tips>
+- Use Glob for finding files by name patterns instead of browsing
+- Use Grep for searching file contents
+- Combine with other tools for effective exploration
+</tips>

internal/llm/agent/mcp-tools.go β†’ internal/agent/tools/mcp-tools.go πŸ”—

@@ -1,4 +1,4 @@
-package agent
+package tools
 
 import (
 	"cmp"
@@ -12,14 +12,15 @@ import (
 	"net/http"
 	"os"
 	"os/exec"
+	"slices"
 	"strings"
 	"sync"
 	"time"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/version"
@@ -80,25 +81,42 @@ type MCPClientInfo struct {
 
 var (
 	mcpToolsOnce    sync.Once
-	mcpTools        = csync.NewMap[string, tools.BaseTool]()
-	mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]()
+	mcpTools        = csync.NewMap[string, *McpTool]()
+	mcpClient2Tools = csync.NewMap[string, []*McpTool]()
 	mcpClients      = csync.NewMap[string, *mcp.ClientSession]()
 	mcpStates       = csync.NewMap[string, MCPClientInfo]()
 	mcpBroker       = pubsub.NewBroker[MCPEvent]()
 )
 
 type McpTool struct {
-	mcpName     string
-	tool        *mcp.Tool
-	permissions permission.Service
-	workingDir  string
+	mcpName         string
+	tool            *mcp.Tool
+	permissions     permission.Service
+	workingDir      string
+	providerOptions fantasy.ProviderOptions
 }
 
-func (b *McpTool) Name() string {
-	return fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name)
+func (m *McpTool) SetProviderOptions(opts fantasy.ProviderOptions) {
+	m.providerOptions = opts
 }
 
-func (b *McpTool) Info() tools.ToolInfo {
+func (m *McpTool) ProviderOptions() fantasy.ProviderOptions {
+	return m.providerOptions
+}
+
+func (m *McpTool) Name() string {
+	return fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name)
+}
+
+func (m *McpTool) MCP() string {
+	return m.mcpName
+}
+
+func (m *McpTool) MCPToolName() string {
+	return m.tool.Name
+}
+
+func (b *McpTool) Info() fantasy.ToolInfo {
 	parameters := make(map[string]any)
 	required := make([]string, 0)
 
@@ -119,7 +137,7 @@ func (b *McpTool) Info() tools.ToolInfo {
 		}
 	}
 
-	return tools.ToolInfo{
+	return fantasy.ToolInfo{
 		Name:        fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
 		Description: b.tool.Description,
 		Parameters:  parameters,
@@ -127,22 +145,22 @@ func (b *McpTool) Info() tools.ToolInfo {
 	}
 }
 
-func runTool(ctx context.Context, name, toolName string, input string) (tools.ToolResponse, error) {
+func runTool(ctx context.Context, name, toolName string, input string) (fantasy.ToolResponse, error) {
 	var args map[string]any
 	if err := json.Unmarshal([]byte(input), &args); err != nil {
-		return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
 	}
 
 	c, err := getOrRenewClient(ctx, name)
 	if err != nil {
-		return tools.NewTextErrorResponse(err.Error()), nil
+		return fantasy.NewTextErrorResponse(err.Error()), nil
 	}
 	result, err := c.CallTool(ctx, &mcp.CallToolParams{
 		Name:      toolName,
 		Arguments: args,
 	})
 	if err != nil {
-		return tools.NewTextErrorResponse(err.Error()), nil
+		return fantasy.NewTextErrorResponse(err.Error()), nil
 	}
 
 	output := make([]string, 0, len(result.Content))
@@ -153,7 +171,7 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
 			output = append(output, fmt.Sprintf("%v", v))
 		}
 	}
-	return tools.NewTextResponse(strings.Join(output, "\n")), nil
+	return fantasy.NewTextResponse(strings.Join(output, "\n")), nil
 }
 
 func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
@@ -185,36 +203,36 @@ func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, err
 	return sess, nil
 }
 
-func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
-	sessionID, messageID := tools.GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
+func (m *McpTool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	sessionID := GetSessionFromContext(ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
 	}
-	permissionDescription := fmt.Sprintf("execute %s with the following parameters:", b.Info().Name)
-	p := b.permissions.Request(
+	permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
+	p := m.permissions.Request(
 		permission.CreatePermissionRequest{
 			SessionID:   sessionID,
 			ToolCallID:  params.ID,
-			Path:        b.workingDir,
-			ToolName:    b.Info().Name,
+			Path:        m.workingDir,
+			ToolName:    m.Info().Name,
 			Action:      "execute",
 			Description: permissionDescription,
 			Params:      params.Input,
 		},
 	)
 	if !p {
-		return tools.ToolResponse{}, permission.ErrorPermissionDenied
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
-	return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
+	return runTool(ctx, m.mcpName, m.tool.Name, params.Input)
 }
 
-func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]tools.BaseTool, error) {
+func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]*McpTool, error) {
 	result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
 	if err != nil {
 		return nil, err
 	}
-	mcpTools := make([]tools.BaseTool, 0, len(result.Tools))
+	mcpTools := make([]*McpTool, 0, len(result.Tools))
 	for _, tool := range result.Tools {
 		mcpTools = append(mcpTools, &McpTool{
 			mcpName:     name,
@@ -284,66 +302,69 @@ func CloseMCPClients() error {
 	return errors.Join(errs...)
 }
 
-func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) {
-	var wg sync.WaitGroup
-	// Initialize states for all configured MCPs
-	for name, m := range cfg.MCP {
-		if m.Disabled {
-			updateMCPState(name, MCPStateDisabled, nil, nil, 0)
-			slog.Debug("skipping disabled mcp", "name", name)
-			continue
-		}
+func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []*McpTool {
+	mcpToolsOnce.Do(func() {
+		var wg sync.WaitGroup
+		// Initialize states for all configured MCPs
+		for name, m := range cfg.MCP {
+			if m.Disabled {
+				updateMCPState(name, MCPStateDisabled, nil, nil, 0)
+				slog.Debug("skipping disabled mcp", "name", name)
+				continue
+			}
 
-		// Set initial starting state
-		updateMCPState(name, MCPStateStarting, nil, nil, 0)
-
-		wg.Add(1)
-		go func(name string, m config.MCPConfig) {
-			defer func() {
-				wg.Done()
-				if r := recover(); r != nil {
-					var err error
-					switch v := r.(type) {
-					case error:
-						err = v
-					case string:
-						err = fmt.Errorf("panic: %s", v)
-					default:
-						err = fmt.Errorf("panic: %v", v)
+			// Set initial starting state
+			updateMCPState(name, MCPStateStarting, nil, nil, 0)
+
+			wg.Add(1)
+			go func(name string, m config.MCPConfig) {
+				defer func() {
+					wg.Done()
+					if r := recover(); r != nil {
+						var err error
+						switch v := r.(type) {
+						case error:
+							err = v
+						case string:
+							err = fmt.Errorf("panic: %s", v)
+						default:
+							err = fmt.Errorf("panic: %v", v)
+						}
+						updateMCPState(name, MCPStateError, err, nil, 0)
+						slog.Error("panic in mcp client initialization", "error", err, "name", name)
 					}
-					updateMCPState(name, MCPStateError, err, nil, 0)
-					slog.Error("panic in mcp client initialization", "error", err, "name", name)
-				}
-			}()
+				}()
 
-			ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
-			defer cancel()
+				ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
+				defer cancel()
 
-			c, err := createMCPSession(ctx, name, m, cfg.Resolver())
-			if err != nil {
-				return
-			}
+				c, err := createMCPSession(ctx, name, m, cfg.Resolver())
+				if err != nil {
+					return
+				}
 
-			mcpClients.Set(name, c)
+				mcpClients.Set(name, c)
 
-			tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
-			if err != nil {
-				slog.Error("error listing tools", "error", err)
-				updateMCPState(name, MCPStateError, err, nil, 0)
-				c.Close()
-				return
-			}
+				tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
+				if err != nil {
+					slog.Error("error listing tools", "error", err)
+					updateMCPState(name, MCPStateError, err, nil, 0)
+					c.Close()
+					return
+				}
 
-			updateMcpTools(name, tools)
-			mcpClients.Set(name, c)
-			updateMCPState(name, MCPStateConnected, nil, c, len(tools))
-		}(name, m)
-	}
-	wg.Wait()
+				updateMcpTools(name, tools)
+				mcpClients.Set(name, c)
+				updateMCPState(name, MCPStateConnected, nil, c, len(tools))
+			}(name, m)
+		}
+		wg.Wait()
+	})
+	return slices.Collect(mcpTools.Seq())
 }
 
 // updateMcpTools updates the global mcpTools and mcpClient2Tools maps
-func updateMcpTools(mcpName string, tools []tools.BaseTool) {
+func updateMcpTools(mcpName string, tools []*McpTool) {
 	if len(tools) == 0 {
 		mcpClient2Tools.Del(mcpName)
 	} else {

internal/agent/tools/multiedit.go πŸ”—

@@ -0,0 +1,366 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type MultiEditOperation struct {
+	OldString  string `json:"old_string" description:"The text to replace"`
+	NewString  string `json:"new_string" description:"The text to replace it with"`
+	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
+}
+
+type MultiEditParams struct {
+	FilePath string               `json:"file_path" description:"The absolute path to the file to modify"`
+	Edits    []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
+}
+
+type MultiEditPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+type MultiEditResponseMetadata struct {
+	Additions    int    `json:"additions"`
+	Removals     int    `json:"removals"`
+	OldContent   string `json:"old_content,omitempty"`
+	NewContent   string `json:"new_content,omitempty"`
+	EditsApplied int    `json:"edits_applied"`
+}
+
+const MultiEditToolName = "multiedit"
+
+//go:embed multiedit.md
+var multieditDescription []byte
+
+func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		MultiEditToolName,
+		string(multieditDescription),
+		func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			if len(params.Edits) == 0 {
+				return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
+			}
+
+			if !filepath.IsAbs(params.FilePath) {
+				params.FilePath = filepath.Join(workingDir, params.FilePath)
+			}
+
+			// Validate all edits before applying any
+			if err := validateEdits(params.Edits); err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+
+			var response fantasy.ToolResponse
+			var err error
+
+			editCtx := editContext{ctx, permissions, files, workingDir}
+			// Handle file creation case (first edit has empty old_string)
+			if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
+				response, err = processMultiEditWithCreation(editCtx, params, call)
+			} else {
+				response, err = processMultiEditExistingFile(editCtx, params, call)
+			}
+
+			if err != nil {
+				return response, err
+			}
+
+			if response.IsError {
+				return response, nil
+			}
+
+			// Notify LSP clients about the change
+			notifyLSPs(ctx, lspClients, params.FilePath)
+
+			// Wait for LSP diagnostics and add them to the response
+			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
+			text += getDiagnostics(params.FilePath, lspClients)
+			response.Content = text
+			return response, nil
+		})
+}
+
+func validateEdits(edits []MultiEditOperation) error {
+	for i, edit := range edits {
+		if edit.OldString == edit.NewString {
+			return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
+		}
+		// Only the first edit can have empty old_string (for file creation)
+		if i > 0 && edit.OldString == "" {
+			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
+		}
+	}
+	return nil
+}
+
+func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	// First edit creates the file
+	firstEdit := params.Edits[0]
+	if firstEdit.OldString != "" {
+		return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
+	}
+
+	// Check if file already exists
+	if _, err := os.Stat(params.FilePath); err == nil {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
+	} else if !os.IsNotExist(err) {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	// Create parent directories
+	dir := filepath.Dir(params.FilePath)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
+	}
+
+	// Start with the content from the first edit
+	currentContent := firstEdit.NewString
+
+	// Apply remaining edits to the content
+	for i := 1; i < len(params.Edits); i++ {
+		edit := params.Edits[i]
+		newContent, err := applyEditToContent(currentContent, edit)
+		if err != nil {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
+		}
+		currentContent = newContent
+	}
+
+	// Get session and message IDs
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
+	}
+
+	// Check permissions
+	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
+
+	p := edit.permissions.Request(permission.CreatePermissionRequest{
+		SessionID:   sessionID,
+		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
+		ToolCallID:  call.ID,
+		ToolName:    MultiEditToolName,
+		Action:      "write",
+		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
+		Params: MultiEditPermissionsParams{
+			FilePath:   params.FilePath,
+			OldContent: "",
+			NewContent: currentContent,
+		},
+	})
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	// Write the file
+	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Update file history
+	_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+	}
+
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(params.FilePath)
+	recordFileRead(params.FilePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
+		MultiEditResponseMetadata{
+			OldContent:   "",
+			NewContent:   currentContent,
+			Additions:    additions,
+			Removals:     removals,
+			EditsApplied: len(params.Edits),
+		},
+	), nil
+}
+
+func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+	// Validate file exists and is readable
+	fileInfo, err := os.Stat(params.FilePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
+		}
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	if fileInfo.IsDir() {
+		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
+	}
+
+	// Check if file was read before editing
+	if getLastReadTime(params.FilePath).IsZero() {
+		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
+	}
+
+	// Check if file was modified since last read
+	modTime := fileInfo.ModTime()
+	lastRead := getLastReadTime(params.FilePath)
+	if modTime.After(lastRead) {
+		return fantasy.NewTextErrorResponse(
+			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+			)), nil
+	}
+
+	// Read current file content
+	content, err := os.ReadFile(params.FilePath)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
+	currentContent := oldContent
+
+	// Apply all edits sequentially
+	for i, edit := range params.Edits {
+		newContent, err := applyEditToContent(currentContent, edit)
+		if err != nil {
+			return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
+		}
+		currentContent = newContent
+	}
+
+	// Check if content actually changed
+	if oldContent == currentContent {
+		return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
+	}
+
+	// Get session and message IDs
+	sessionID := GetSessionFromContext(edit.ctx)
+	if sessionID == "" {
+		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
+	}
+
+	// Generate diff and check permissions
+	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
+	p := edit.permissions.Request(permission.CreatePermissionRequest{
+		SessionID:   sessionID,
+		Path:        fsext.PathOrPrefix(params.FilePath, edit.workingDir),
+		ToolCallID:  call.ID,
+		ToolName:    MultiEditToolName,
+		Action:      "write",
+		Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
+		Params: MultiEditPermissionsParams{
+			FilePath:   params.FilePath,
+			OldContent: oldContent,
+			NewContent: currentContent,
+		},
+	})
+	if !p {
+		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+	}
+
+	if isCrlf {
+		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
+	}
+
+	// Write the updated content
+	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
+	if err != nil {
+		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Update file history
+	file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
+	if err != nil {
+		_, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
+		if err != nil {
+			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+		}
+	}
+	if file.Content != oldContent {
+		// User manually changed the content, store an intermediate version
+		_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
+		if err != nil {
+			slog.Debug("Error creating file history version", "error", err)
+		}
+	}
+
+	// Store the new version
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
+	if err != nil {
+		slog.Debug("Error creating file history version", "error", err)
+	}
+
+	recordFileWrite(params.FilePath)
+	recordFileRead(params.FilePath)
+
+	return fantasy.WithResponseMetadata(
+		fantasy.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
+		MultiEditResponseMetadata{
+			OldContent:   oldContent,
+			NewContent:   currentContent,
+			Additions:    additions,
+			Removals:     removals,
+			EditsApplied: len(params.Edits),
+		},
+	), nil
+}
+
+func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
+	if edit.OldString == "" && edit.NewString == "" {
+		return content, nil
+	}
+
+	if edit.OldString == "" {
+		return "", fmt.Errorf("old_string cannot be empty for content replacement")
+	}
+
+	var newContent string
+	var replacementCount int
+
+	if edit.ReplaceAll {
+		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
+		replacementCount = strings.Count(content, edit.OldString)
+		if replacementCount == 0 {
+			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
+		}
+	} else {
+		index := strings.Index(content, edit.OldString)
+		if index == -1 {
+			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
+		}
+
+		lastIndex := strings.LastIndex(content, edit.OldString)
+		if index != lastIndex {
+			return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
+		}
+
+		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
+		replacementCount = 1
+	}
+
+	return newContent, nil
+}

internal/agent/tools/multiedit.md πŸ”—

@@ -0,0 +1,112 @@
+Makes multiple edits to a single file in one operation. Built on Edit tool for efficient multiple find-and-replace operations. Prefer over Edit tool for multiple edits to same file.
+
+<prerequisites>
+1. Use View tool to understand file contents and context
+2. Verify directory path is correct
+3. CRITICAL: Note exact whitespace, indentation, and formatting from View output
+</prerequisites>
+
+<parameters>
+1. file_path: Absolute path to file (required)
+2. edits: Array of edit operations, each containing:
+   - old_string: Text to replace (must match exactly including whitespace/indentation)
+   - new_string: Replacement text
+   - replace_all: Replace all occurrences (optional, defaults to false)
+</parameters>
+
+<operation>
+- Edits applied sequentially in provided order.
+- Each edit operates on result of previous edit.
+- ATOMIC: If any single edit fails, the entire operation fails and no changes are applied.
+- Ideal for several changes to different parts of same file.
+</operation>
+
+<inherited_rules>
+All instructions from the Edit tool documentation apply verbatim to every edit item:
+- Critical requirements for exact matching and uniqueness
+- Warnings and common failures (tabs vs spaces, blank lines, brace placement, etc.)
+- Verification steps before using, recovery steps, best practices, and whitespace checklist
+Use the same level of precision as Edit. Multiedit often fails due to formatting mismatchesβ€”double-check whitespace for every edit.
+</inherited_rules>
+
+<critical_requirements>
+1. Apply Edit tool rules to EACH edit (see edit.md).
+2. Edits are atomicβ€”either all succeed or none are applied.
+3. Plan sequence carefully: earlier edits change the file content that later edits must match.
+4. Ensure each old_string is unique at its application time (after prior edits).
+</critical_requirements>
+
+<verification_before_using>
+1. View the file and copy exact text (including whitespace) for each target.
+2. Check how many instances each old_string has BEFORE the sequence starts.
+3. Dry-run mentally: after applying edit #N, will edit #N+1 still match? Adjust old_string/new_string accordingly.
+4. Prefer fewer, larger context blocks over many tiny fragments that are easy to misalign.
+5. If edits are independent, consider separate multiedit batches per logical region.
+</verification_before_using>
+
+<warnings>
+- Operation fails if any old_string doesn’t match exactly (including whitespace) or equals new_string.
+- Earlier edits can invalidate later matches (added/removed spaces, lines, or reordered text).
+- Mixed tabs/spaces, trailing spaces, or missing blank lines commonly cause failures.
+- replace_all may affect unintended regionsβ€”use carefully or provide more context.
+</warnings>
+
+<recovery_steps>
+If the operation fails:
+1. Identify the first failing edit (start from top; test subsets to isolate).
+2. View the file again and copy more surrounding context for that edit.
+3. Recalculate later old_string values based on the file state AFTER preceding edits.
+4. Reduce the batch (apply earlier stable edits first), then follow up with the rest.
+</recovery_steps>
+
+<best_practices>
+- Ensure all edits result in correct, idiomatic code; don’t leave code broken.
+- Use absolute file paths (starting with /).
+- Use replace_all only when you’re certain; otherwise provide unique context.
+- Match existing style exactly (spaces, tabs, blank lines).
+- Test after the operation; if it fails, fix and retry in smaller chunks.
+</best_practices>
+
+<whitespace_checklist>
+For EACH edit, verify:
+- [ ] Viewed the file first
+- [ ] Counted indentation spaces/tabs
+- [ ] Included blank lines if present
+- [ ] Matched brace/bracket positioning
+- [ ] Included 3–5 lines of surrounding context
+- [ ] Verified text appears exactly once (or using replace_all deliberately)
+- [ ] Copied text character-for-character, not approximated
+</whitespace_checklist>
+
+<examples>
+βœ… Correct: Sequential edits where the second match accounts for the first change
+
+```
+edits: [
+  {
+    old_string: "func A() {\n    doOld()\n}",
+    new_string: "func A() {\n    doNew()\n}",
+  },
+  {
+    // Uses context that still exists AFTER the first replacement
+    old_string: "func B() {\n    callA()\n}",
+    new_string: "func B() {\n    callA()\n    logChange()\n}",
+  },
+]
+```
+
+❌ Incorrect: Second old_string no longer matches due to whitespace change introduced by the first edit
+
+```
+edits: [
+  {
+    old_string: "func A() {\n    doOld()\n}",
+    new_string: "func A() {\n\n    doNew()\n}", // Added extra blank line
+  },
+  {
+    old_string: "func A() {\n    doNew()\n}", // Missing the new blank line, will FAIL
+    new_string: "func A() {\n    doNew()\n    logChange()\n}",
+  },
+]
+```
+</examples>

internal/llm/tools/references.go β†’ internal/agent/tools/references.go πŸ”—

@@ -4,7 +4,6 @@ import (
 	"cmp"
 	"context"
 	_ "embed"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"log/slog"
@@ -15,14 +14,15 @@ import (
 	"sort"
 	"strings"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
 type ReferencesParams struct {
-	Symbol string `json:"symbol"`
-	Path   string `json:"path"`
+	Symbol string `json:"symbol" description:"The symbol name to search for (e.g., function name, variable name, type name)"`
+	Path   string `json:"path,omitempty" description:"The directory to search in. Use a directory/file to narrow down the symbol search. Defaults to the current working directory."`
 }
 
 type referencesTool struct {
@@ -34,95 +34,71 @@ const ReferencesToolName = "lsp_references"
 //go:embed references.md
 var referencesDescription []byte
 
-func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
-	return &referencesTool{
-		lspClients,
-	}
-}
-
-func (r *referencesTool) Name() string {
-	return ReferencesToolName
-}
-
-func (r *referencesTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        ReferencesToolName,
-		Description: string(referencesDescription),
-		Parameters: map[string]any{
-			"symbol": map[string]any{
-				"type":        "string",
-				"description": "The symbol name to search for (e.g., function name, variable name, type name).",
-			},
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.",
-			},
-		},
-		Required: []string{"symbol"},
-	}
-}
-
-func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params ReferencesParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
+func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		ReferencesToolName,
+		string(referencesDescription),
+		func(ctx context.Context, params ReferencesParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Symbol == "" {
+				return fantasy.NewTextErrorResponse("symbol is required"), nil
+			}
 
-	if params.Symbol == "" {
-		return NewTextErrorResponse("symbol is required"), nil
-	}
+			if lspClients.Len() == 0 {
+				return fantasy.NewTextErrorResponse("no LSP clients available"), nil
+			}
 
-	if r.lspClients.Len() == 0 {
-		return NewTextErrorResponse("no LSP clients available"), nil
-	}
+			workingDir := cmp.Or(params.Path, ".")
 
-	workingDir := cmp.Or(params.Path, ".")
+			matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
+			}
 
-	matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
-	if err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
-	}
+			if len(matches) == 0 {
+				return fantasy.NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
+			}
 
-	if len(matches) == 0 {
-		return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
-	}
+			var allLocations []protocol.Location
+			var allErrs error
+			for _, match := range matches {
+				locations, err := find(ctx, lspClients, params.Symbol, match)
+				if err != nil {
+					if strings.Contains(err.Error(), "no identifier found") {
+						// grep probably matched a comment, string value, or something else that's irrelevant
+						continue
+					}
+					slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
+					allErrs = errors.Join(allErrs, err)
+					continue
+				}
+				allLocations = append(allLocations, locations...)
+				// XXX: should we break here or look for all results?
+			}
 
-	var allLocations []protocol.Location
-	var allErrs error
-	for _, match := range matches {
-		locations, err := r.find(ctx, params.Symbol, match)
-		if err != nil {
-			if strings.Contains(err.Error(), "no identifier found") {
-				// grep probably matched a comment, string value, or something else that's irrelevant
-				continue
+			if len(allLocations) > 0 {
+				output := formatReferences(cleanupLocations(allLocations))
+				return fantasy.NewTextResponse(output), nil
 			}
-			slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
-			allErrs = errors.Join(allErrs, err)
-			continue
-		}
-		allLocations = append(allLocations, locations...)
-		// XXX: should we break here or look for all results?
-	}
 
-	if len(allLocations) > 0 {
-		output := formatReferences(cleanupLocations(allLocations))
-		return NewTextResponse(output), nil
-	}
+			if allErrs != nil {
+				return fantasy.NewTextErrorResponse(allErrs.Error()), nil
+			}
+			return fantasy.NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
+		})
+}
 
-	if allErrs != nil {
-		return NewTextErrorResponse(allErrs.Error()), nil
-	}
-	return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
+func (r *referencesTool) Name() string {
+	return ReferencesToolName
 }
 
-func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) {
+func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) {
 	absPath, err := filepath.Abs(match.path)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get absolute path: %s", err)
 	}
 
 	var client *lsp.Client
-	for c := range r.lspClients.Seq() {
+	for c := range lspClients.Seq() {
 		if c.HandlesFile(absPath) {
 			client = c
 			break

internal/agent/tools/references.md πŸ”—

@@ -0,0 +1,26 @@
+Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
+
+<usage>
+- Provide symbol name (e.g., "MyFunction", "myVariable", "MyType").
+- Optional path to narrow search to a directory or file (defaults to current directory).
+- Tool automatically locates the symbol and returns all references.
+</usage>
+
+<features>
+- Semantic-aware reference search (more accurate than grep/glob).
+- Returns references grouped by file with line and column numbers.
+- Supports multiple programming languages via LSP.
+- Finds only real references (not comments or unrelated strings).
+</features>
+
+<limitations>
+- May not find references in files not opened or indexed by the LSP server.
+- Results depend on the capabilities of the active LSP providers.
+</limitations>
+
+<tips>
+- Use this first when searching for where a symbol is used.
+- Do not use grep/glob for symbol searches.
+- Narrow scope with the path parameter for faster, more relevant results.
+- Use qualified names (e.g., pkg.Func, Class.method) for higher precision.
+</tips>

internal/agent/tools/sourcegraph.go πŸ”—

@@ -0,0 +1,267 @@
+package tools
+
+import (
+	"bytes"
+	"context"
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+)
+
+type SourcegraphParams struct {
+	Query         string `json:"query" description:"The Sourcegraph search query"`
+	Count         int    `json:"count,omitempty" description:"Optional number of results to return (default: 10, max: 20)"`
+	ContextWindow int    `json:"context_window,omitempty" description:"The context around the match to return (default: 10 lines)"`
+	Timeout       int    `json:"timeout,omitempty" description:"Optional timeout in seconds (max 120)"`
+}
+
+type SourcegraphResponseMetadata struct {
+	NumberOfMatches int  `json:"number_of_matches"`
+	Truncated       bool `json:"truncated"`
+}
+
+const SourcegraphToolName = "sourcegraph"
+
+//go:embed sourcegraph.md
+var sourcegraphDescription []byte
+
+func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
+	if client == nil {
+		client = &http.Client{
+			Timeout: 30 * time.Second,
+			Transport: &http.Transport{
+				MaxIdleConns:        100,
+				MaxIdleConnsPerHost: 10,
+				IdleConnTimeout:     90 * time.Second,
+			},
+		}
+	}
+	return fantasy.NewAgentTool(
+		SourcegraphToolName,
+		string(sourcegraphDescription),
+		func(ctx context.Context, params SourcegraphParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.Query == "" {
+				return fantasy.NewTextErrorResponse("Query parameter is required"), nil
+			}
+
+			if params.Count <= 0 {
+				params.Count = 10
+			} else if params.Count > 20 {
+				params.Count = 20 // Limit to 20 results
+			}
+
+			if params.ContextWindow <= 0 {
+				params.ContextWindow = 10 // Default context window
+			}
+
+			// Handle timeout with context
+			requestCtx := ctx
+			if params.Timeout > 0 {
+				maxTimeout := 120 // 2 minutes
+				if params.Timeout > maxTimeout {
+					params.Timeout = maxTimeout
+				}
+				var cancel context.CancelFunc
+				requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
+				defer cancel()
+			}
+
+			type graphqlRequest struct {
+				Query     string `json:"query"`
+				Variables struct {
+					Query string `json:"query"`
+				} `json:"variables"`
+			}
+
+			request := graphqlRequest{
+				Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
+			}
+			request.Variables.Query = params.Query
+
+			graphqlQueryBytes, err := json.Marshal(request)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
+			}
+			graphqlQuery := string(graphqlQueryBytes)
+
+			req, err := http.NewRequestWithContext(
+				requestCtx,
+				"POST",
+				"https://sourcegraph.com/.api/graphql",
+				bytes.NewBuffer([]byte(graphqlQuery)),
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
+			}
+
+			req.Header.Set("Content-Type", "application/json")
+			req.Header.Set("User-Agent", "crush/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				body, _ := io.ReadAll(resp.Body)
+				if len(body) > 0 {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
+				}
+
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+			}
+			body, err := io.ReadAll(resp.Body)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
+			}
+
+			var result map[string]any
+			if err = json.Unmarshal(body, &result); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
+			}
+
+			formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
+			if err != nil {
+				return fantasy.NewTextErrorResponse("Failed to format results: " + err.Error()), nil
+			}
+
+			return fantasy.NewTextResponse(formattedResults), nil
+		})
+}
+
+func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
+	var buffer strings.Builder
+
+	if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
+		buffer.WriteString("## Sourcegraph API Error\n\n")
+		for _, err := range errors {
+			if errMap, ok := err.(map[string]any); ok {
+				if message, ok := errMap["message"].(string); ok {
+					buffer.WriteString(fmt.Sprintf("- %s\n", message))
+				}
+			}
+		}
+		return buffer.String(), nil
+	}
+
+	data, ok := result["data"].(map[string]any)
+	if !ok {
+		return "", fmt.Errorf("invalid response format: missing data field")
+	}
+
+	search, ok := data["search"].(map[string]any)
+	if !ok {
+		return "", fmt.Errorf("invalid response format: missing search field")
+	}
+
+	searchResults, ok := search["results"].(map[string]any)
+	if !ok {
+		return "", fmt.Errorf("invalid response format: missing results field")
+	}
+
+	matchCount, _ := searchResults["matchCount"].(float64)
+	resultCount, _ := searchResults["resultCount"].(float64)
+	limitHit, _ := searchResults["limitHit"].(bool)
+
+	buffer.WriteString("# Sourcegraph Search Results\n\n")
+	buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
+
+	if limitHit {
+		buffer.WriteString("(Result limit reached, try a more specific query)\n")
+	}
+
+	buffer.WriteString("\n")
+
+	results, ok := searchResults["results"].([]any)
+	if !ok || len(results) == 0 {
+		buffer.WriteString("No results found. Try a different query.\n")
+		return buffer.String(), nil
+	}
+
+	maxResults := 10
+	if len(results) > maxResults {
+		results = results[:maxResults]
+	}
+
+	for i, res := range results {
+		fileMatch, ok := res.(map[string]any)
+		if !ok {
+			continue
+		}
+
+		typeName, _ := fileMatch["__typename"].(string)
+		if typeName != "FileMatch" {
+			continue
+		}
+
+		repo, _ := fileMatch["repository"].(map[string]any)
+		file, _ := fileMatch["file"].(map[string]any)
+		lineMatches, _ := fileMatch["lineMatches"].([]any)
+
+		if repo == nil || file == nil {
+			continue
+		}
+
+		repoName, _ := repo["name"].(string)
+		filePath, _ := file["path"].(string)
+		fileURL, _ := file["url"].(string)
+		fileContent, _ := file["content"].(string)
+
+		buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
+
+		if fileURL != "" {
+			buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
+		}
+
+		if len(lineMatches) > 0 {
+			for _, lm := range lineMatches {
+				lineMatch, ok := lm.(map[string]any)
+				if !ok {
+					continue
+				}
+
+				lineNumber, _ := lineMatch["lineNumber"].(float64)
+				preview, _ := lineMatch["preview"].(string)
+
+				if fileContent != "" {
+					lines := strings.Split(fileContent, "\n")
+
+					buffer.WriteString("```\n")
+
+					startLine := max(1, int(lineNumber)-contextWindow)
+
+					for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
+						if j >= 0 {
+							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
+						}
+					}
+
+					buffer.WriteString(fmt.Sprintf("%d|  %s\n", int(lineNumber), preview))
+
+					endLine := int(lineNumber) + contextWindow
+
+					for j := int(lineNumber); j < endLine && j < len(lines); j++ {
+						if j < len(lines) {
+							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
+						}
+					}
+
+					buffer.WriteString("```\n\n")
+				} else {
+					buffer.WriteString("```\n")
+					buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
+					buffer.WriteString("```\n\n")
+				}
+			}
+		}
+	}
+
+	return buffer.String(), nil
+}

internal/agent/tools/sourcegraph.md πŸ”—

@@ -0,0 +1,55 @@
+Search code across public repositories using Sourcegraph's GraphQL API.
+
+<usage>
+- Provide search query using Sourcegraph syntax
+- Optional result count (default: 10, max: 20)
+- Optional timeout for request
+</usage>
+
+<basic_syntax>
+- "fmt.Println" - exact matches
+- "file:.go fmt.Println" - limit to Go files
+- "repo:^github\.com/golang/go$ fmt.Println" - specific repos
+- "lang:go fmt.Println" - limit to Go code
+- "fmt.Println AND log.Fatal" - combined terms
+- "fmt\.(Print|Printf|Println)" - regex patterns
+- "\"exact phrase\"" - exact phrase matching
+- "-file:test" or "-repo:forks" - exclude matches
+</basic_syntax>
+
+<key_filters>
+Repository: repo:name, repo:^exact$, repo:org/repo@branch, -repo:exclude, fork:yes, archived:yes, visibility:public
+File: file:\.js$, file:internal/, -file:test, file:has.content(text)
+Content: content:"exact", -content:"unwanted", case:yes
+Type: type:symbol, type:file, type:path, type:diff, type:commit
+Time: after:"1 month ago", before:"2023-01-01", author:name, message:"fix"
+Result: select:repo, select:file, select:content, count:100, timeout:30s
+</key_filters>
+
+<examples>
+- "file:.go context.WithTimeout" - Go code using context.WithTimeout
+- "lang:typescript useState type:symbol" - TypeScript React useState hooks
+- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Kubernetes pod files
+- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with base images
+</examples>
+
+<boolean_operators>
+- "term1 AND term2" - both terms
+- "term1 OR term2" - either term
+- "term1 NOT term2" - term1 but not term2
+- "term1 and (term2 or term3)" - grouping with parentheses
+</boolean_operators>
+
+<limitations>
+- Only searches public repositories
+- Rate limits may apply
+- Complex queries take longer
+- Max 20 results per query
+</limitations>
+
+<tips>
+- Use specific file extensions to narrow results
+- Add repo: filters for targeted searches
+- Use type:symbol for function/method definitions
+- Use type:file to find relevant files
+</tips>

internal/agent/tools/tools.go πŸ”—

@@ -0,0 +1,39 @@
+package tools
+
+import (
+	"context"
+)
+
+type (
+	sessionIDContextKey string
+	messageIDContextKey string
+)
+
+const (
+	SessionIDContextKey sessionIDContextKey = "session_id"
+	MessageIDContextKey messageIDContextKey = "message_id"
+)
+
+func GetSessionFromContext(ctx context.Context) string {
+	sessionID := ctx.Value(SessionIDContextKey)
+	if sessionID == nil {
+		return ""
+	}
+	s, ok := sessionID.(string)
+	if !ok {
+		return ""
+	}
+	return s
+}
+
+func GetMessageFromContext(ctx context.Context) string {
+	messageID := ctx.Value(MessageIDContextKey)
+	if messageID == nil {
+		return ""
+	}
+	s, ok := messageID.(string)
+	if !ok {
+		return ""
+	}
+	return s
+}

internal/agent/tools/view.go πŸ”—

@@ -0,0 +1,308 @@
+package tools
+
+import (
+	"bufio"
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"unicode/utf8"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+//go:embed view.md
+var viewDescription []byte
+
+type ViewParams struct {
+	FilePath string `json:"file_path" description:"The path to the file to read"`
+	Offset   int    `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
+	Limit    int    `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
+}
+
+type ViewPermissionsParams struct {
+	FilePath string `json:"file_path"`
+	Offset   int    `json:"offset"`
+	Limit    int    `json:"limit"`
+}
+
+type viewTool struct {
+	lspClients  *csync.Map[string, *lsp.Client]
+	workingDir  string
+	permissions permission.Service
+}
+
+type ViewResponseMetadata struct {
+	FilePath string `json:"file_path"`
+	Content  string `json:"content"`
+}
+
+const (
+	ViewToolName     = "view"
+	MaxReadSize      = 250 * 1024
+	DefaultReadLimit = 2000
+	MaxLineLength    = 2000
+)
+
+func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		ViewToolName,
+		string(viewDescription),
+		func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			// Handle relative paths
+			filePath := params.FilePath
+			if !filepath.IsAbs(filePath) {
+				filePath = filepath.Join(workingDir, filePath)
+			}
+
+			// Check if file is outside working directory and request permission if needed
+			absWorkingDir, err := filepath.Abs(workingDir)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
+			}
+
+			absFilePath, err := filepath.Abs(filePath)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
+			}
+
+			relPath, err := filepath.Rel(absWorkingDir, absFilePath)
+			if err != nil || strings.HasPrefix(relPath, "..") {
+				// File is outside working directory, request permission
+				sessionID := GetSessionFromContext(ctx)
+				if sessionID == "" {
+					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
+				}
+
+				granted := permissions.Request(
+					permission.CreatePermissionRequest{
+						SessionID:   sessionID,
+						Path:        absFilePath,
+						ToolCallID:  call.ID,
+						ToolName:    ViewToolName,
+						Action:      "read",
+						Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
+						Params:      ViewPermissionsParams(params),
+					},
+				)
+
+				if !granted {
+					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+				}
+			}
+
+			// Check if file exists
+			fileInfo, err := os.Stat(filePath)
+			if err != nil {
+				if os.IsNotExist(err) {
+					// Try to offer suggestions for similarly named files
+					dir := filepath.Dir(filePath)
+					base := filepath.Base(filePath)
+
+					dirEntries, dirErr := os.ReadDir(dir)
+					if dirErr == nil {
+						var suggestions []string
+						for _, entry := range dirEntries {
+							if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
+								strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
+								suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
+								if len(suggestions) >= 3 {
+									break
+								}
+							}
+						}
+
+						if len(suggestions) > 0 {
+							return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
+								filePath, strings.Join(suggestions, "\n"))), nil
+						}
+					}
+
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
+				}
+				return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
+			}
+
+			// Check if it's a directory
+			if fileInfo.IsDir() {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
+			}
+
+			// Check file size
+			if fileInfo.Size() > MaxReadSize {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
+					fileInfo.Size(), MaxReadSize)), nil
+			}
+
+			// Set default limit if not provided
+			if params.Limit <= 0 {
+				params.Limit = DefaultReadLimit
+			}
+
+			// Check if it's an image file
+			isImage, imageType := isImageFile(filePath)
+			// TODO: handle images
+			if isImage {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
+			}
+
+			// Read the file content
+			content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
+			isValidUt8 := utf8.ValidString(content)
+			if !isValidUt8 {
+				return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
+			}
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
+			}
+
+			notifyLSPs(ctx, lspClients, filePath)
+			output := "<file>\n"
+			// Format the output with line numbers
+			output += addLineNumbers(content, params.Offset+1)
+
+			// Add a note if the content was truncated
+			if lineCount > params.Offset+len(strings.Split(content, "\n")) {
+				output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
+					params.Offset+len(strings.Split(content, "\n")))
+			}
+			output += "\n</file>\n"
+			output += getDiagnostics(filePath, lspClients)
+			recordFileRead(filePath)
+			return fantasy.WithResponseMetadata(
+				fantasy.NewTextResponse(output),
+				ViewResponseMetadata{
+					FilePath: filePath,
+					Content:  content,
+				},
+			), nil
+		})
+}
+
+func addLineNumbers(content string, startLine int) string {
+	if content == "" {
+		return ""
+	}
+
+	lines := strings.Split(content, "\n")
+
+	var result []string
+	for i, line := range lines {
+		line = strings.TrimSuffix(line, "\r")
+
+		lineNum := i + startLine
+		numStr := fmt.Sprintf("%d", lineNum)
+
+		if len(numStr) >= 6 {
+			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
+		} else {
+			paddedNum := fmt.Sprintf("%6s", numStr)
+			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
+		}
+	}
+
+	return strings.Join(result, "\n")
+}
+
+func readTextFile(filePath string, offset, limit int) (string, int, error) {
+	file, err := os.Open(filePath)
+	if err != nil {
+		return "", 0, err
+	}
+	defer file.Close()
+
+	lineCount := 0
+
+	scanner := NewLineScanner(file)
+	if offset > 0 {
+		for lineCount < offset && scanner.Scan() {
+			lineCount++
+		}
+		if err = scanner.Err(); err != nil {
+			return "", 0, err
+		}
+	}
+
+	if offset == 0 {
+		_, err = file.Seek(0, io.SeekStart)
+		if err != nil {
+			return "", 0, err
+		}
+	}
+
+	// Pre-allocate slice with expected capacity
+	lines := make([]string, 0, limit)
+	lineCount = offset
+
+	for scanner.Scan() && len(lines) < limit {
+		lineCount++
+		lineText := scanner.Text()
+		if len(lineText) > MaxLineLength {
+			lineText = lineText[:MaxLineLength] + "..."
+		}
+		lines = append(lines, lineText)
+	}
+
+	// Continue scanning to get total line count
+	for scanner.Scan() {
+		lineCount++
+	}
+
+	if err := scanner.Err(); err != nil {
+		return "", 0, err
+	}
+
+	return strings.Join(lines, "\n"), lineCount, nil
+}
+
+func isImageFile(filePath string) (bool, string) {
+	ext := strings.ToLower(filepath.Ext(filePath))
+	switch ext {
+	case ".jpg", ".jpeg":
+		return true, "JPEG"
+	case ".png":
+		return true, "PNG"
+	case ".gif":
+		return true, "GIF"
+	case ".bmp":
+		return true, "BMP"
+	case ".svg":
+		return true, "SVG"
+	case ".webp":
+		return true, "WebP"
+	default:
+		return false, ""
+	}
+}
+
+type LineScanner struct {
+	scanner *bufio.Scanner
+}
+
+func NewLineScanner(r io.Reader) *LineScanner {
+	return &LineScanner{
+		scanner: bufio.NewScanner(r),
+	}
+}
+
+func (s *LineScanner) Scan() bool {
+	return s.scanner.Scan()
+}
+
+func (s *LineScanner) Text() string {
+	return s.scanner.Text()
+}
+
+func (s *LineScanner) Err() error {
+	return s.scanner.Err()
+}

internal/agent/tools/view.md πŸ”—

@@ -0,0 +1,35 @@
+Reads and displays file contents with line numbers for examining code, logs, or text data.
+
+<usage>
+- Provide file path to read
+- Optional offset: start reading from specific line (0-based)
+- Optional limit: control lines read (default 2000)
+- Don't use for directories (use LS tool instead)
+</usage>
+
+<features>
+- Displays contents with line numbers
+- Can read from any file position using offset
+- Handles large files by limiting lines read
+- Auto-truncates very long lines for display
+- Suggests similar filenames when file not found
+</features>
+
+<limitations>
+- Max file size: 250KB
+- Default limit: 2000 lines
+- Lines >2000 chars truncated
+- Cannot display binary files/images (identifies them)
+</limitations>
+
+<cross_platform>
+- Handles Windows (CRLF) and Unix (LF) line endings
+- Works with forward slashes (/) and backslashes (\)
+- Auto-detects text encoding for common formats
+</cross_platform>
+
+<tips>
+- Use with Glob to find files first
+- For code exploration: Grep to find relevant files, then View to examine
+- For large files: use offset parameter for specific sections
+</tips>

internal/agent/tools/write.go πŸ”—

@@ -0,0 +1,177 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+//go:embed write.md
+var writeDescription []byte
+
+type WriteParams struct {
+	FilePath string `json:"file_path" description:"The path to the file to write"`
+	Content  string `json:"content" description:"The content to write to the file"`
+}
+
+type WritePermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+type writeTool struct {
+	lspClients  *csync.Map[string, *lsp.Client]
+	permissions permission.Service
+	files       history.Service
+	workingDir  string
+}
+
+type WriteResponseMetadata struct {
+	Diff      string `json:"diff"`
+	Additions int    `json:"additions"`
+	Removals  int    `json:"removals"`
+}
+
+const WriteToolName = "write"
+
+func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		WriteToolName,
+		string(writeDescription),
+		func(ctx context.Context, params WriteParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			if params.Content == "" {
+				return fantasy.NewTextErrorResponse("content is required"), nil
+			}
+
+			filePath := params.FilePath
+			if !filepath.IsAbs(filePath) {
+				filePath = filepath.Join(workingDir, filePath)
+			}
+
+			fileInfo, err := os.Stat(filePath)
+			if err == nil {
+				if fileInfo.IsDir() {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
+				}
+
+				modTime := fileInfo.ModTime()
+				lastRead := getLastReadTime(filePath)
+				if modTime.After(lastRead) {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
+						filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
+				}
+
+				oldContent, readErr := os.ReadFile(filePath)
+				if readErr == nil && string(oldContent) == params.Content {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
+				}
+			} else if !os.IsNotExist(err) {
+				return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
+			}
+
+			dir := filepath.Dir(filePath)
+			if err = os.MkdirAll(dir, 0o755); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
+			}
+
+			oldContent := ""
+			if fileInfo != nil && !fileInfo.IsDir() {
+				oldBytes, readErr := os.ReadFile(filePath)
+				if readErr == nil {
+					oldContent = string(oldBytes)
+				}
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+			}
+
+			diff, additions, removals := diff.GenerateDiff(
+				oldContent,
+				params.Content,
+				strings.TrimPrefix(filePath, workingDir),
+			)
+
+			p := permissions.Request(
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        fsext.PathOrPrefix(filePath, workingDir),
+					ToolCallID:  call.ID,
+					ToolName:    WriteToolName,
+					Action:      "write",
+					Description: fmt.Sprintf("Create file %s", filePath),
+					Params: WritePermissionsParams{
+						FilePath:   filePath,
+						OldContent: oldContent,
+						NewContent: params.Content,
+					},
+				},
+			)
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			err = os.WriteFile(filePath, []byte(params.Content), 0o644)
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error writing file: %w", err)
+			}
+
+			// Check if file exists in history
+			file, err := files.GetByPathAndSession(ctx, filePath, sessionID)
+			if err != nil {
+				_, err = files.Create(ctx, sessionID, filePath, oldContent)
+				if err != nil {
+					// Log error but don't fail the operation
+					return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+				}
+			}
+			if file.Content != oldContent {
+				// User Manually changed the content store an intermediate version
+				_, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
+				if err != nil {
+					slog.Debug("Error creating file history version", "error", err)
+				}
+			}
+			// Store the new version
+			_, err = files.CreateVersion(ctx, sessionID, filePath, params.Content)
+			if err != nil {
+				slog.Debug("Error creating file history version", "error", err)
+			}
+
+			recordFileWrite(filePath)
+			recordFileRead(filePath)
+
+			notifyLSPs(ctx, lspClients, params.FilePath)
+
+			result := fmt.Sprintf("File successfully written: %s", filePath)
+			result = fmt.Sprintf("<result>\n%s\n</result>", result)
+			result += getDiagnostics(filePath, lspClients)
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
+				WriteResponseMetadata{
+					Diff:      diff,
+					Additions: additions,
+					Removals:  removals,
+				},
+			), nil
+		})
+}

internal/agent/tools/write.md πŸ”—

@@ -0,0 +1,30 @@
+Creates or updates files in filesystem for saving/modifying text content.
+
+<usage>
+- Provide file path to write
+- Include content to write to file
+- Tool creates necessary parent directories automatically
+</usage>
+
+<features>
+- Creates new files or overwrites existing ones
+- Auto-creates parent directories if missing
+- Checks if file modified since last read for safety
+- Avoids unnecessary writes when content unchanged
+</features>
+
+<limitations>
+- Read file before writing to avoid conflicts
+- Cannot append (rewrites entire file)
+</limitations>
+
+<cross_platform>
+- Use forward slashes (/) for compatibility
+</cross_platform>
+
+<tips>
+- Use View tool first to examine existing files before modifying
+- Use LS tool to verify location when creating new files
+- Combine with Glob/Grep to find and modify multiple files
+- Include descriptive comments when changing existing code
+</tips>

internal/app/app.go πŸ”—

@@ -9,13 +9,15 @@ import (
 	"sync"
 	"time"
 
+	"charm.land/fantasy"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/format"
 	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/message"
@@ -31,7 +33,7 @@ type App struct {
 	History     history.Service
 	Permissions permission.Service
 
-	CoderAgent agent.Service
+	AgentCoordinator agent.Coordinator
 
 	LSPClients *csync.Map[string, *lsp.Client]
 
@@ -84,12 +86,12 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 	app.cleanupFuncs = append(app.cleanupFuncs, conn.Close)
 
 	// TODO: remove the concept of agent config, most likely.
-	if cfg.IsConfigured() {
-		if err := app.InitCoderAgent(); err != nil {
-			return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
-		}
-	} else {
+	if !cfg.IsConfigured() {
 		slog.Warn("No agent configuration found")
+		return app, nil
+	}
+	if err := app.InitCoderAgent(ctx); err != nil {
+		return nil, fmt.Errorf("failed to initialize coder agent: %w", err)
 	}
 	return app, nil
 }
@@ -142,10 +144,23 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	// Automatically approve all permission requests for this non-interactive session
 	app.Permissions.AutoApproveSession(sess.ID)
 
-	done, err := app.CoderAgent.Run(ctx, sess.ID, prompt)
-	if err != nil {
-		return fmt.Errorf("failed to start agent processing stream: %w", err)
+	type response struct {
+		result *fantasy.AgentResult
+		err    error
 	}
+	done := make(chan response, 1)
+
+	go func(ctx context.Context, sessionID, prompt string) {
+		result, err := app.AgentCoordinator.Run(ctx, sess.ID, prompt)
+		if err != nil {
+			done <- response{
+				err: fmt.Errorf("failed to start agent processing stream: %w", err),
+			}
+		}
+		done <- response{
+			result: result,
+		}
+	}(ctx, sess.ID, prompt)
 
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
@@ -158,26 +173,13 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 		select {
 		case result := <-done:
 			stopSpinner()
-
-			if result.Error != nil {
-				if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
+			if result.err != nil {
+				if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
 					slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
 					return nil
 				}
-				return fmt.Errorf("agent processing failed: %w", result.Error)
-			}
-
-			msgContent := result.Message.Content().String()
-			readBts := messageReadBytes[result.Message.ID]
-
-			if len(msgContent) < readBts {
-				slog.Error("Non-interactive: message content is shorter than read bytes", "message_length", len(msgContent), "read_bytes", readBts)
-				return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(msgContent), readBts)
+				return fmt.Errorf("agent processing failed: %w", result.err)
 			}
-			fmt.Println(msgContent[readBts:])
-			messageReadBytes[result.Message.ID] = len(msgContent)
-
-			slog.Info("Non-interactive: run completed", "session_id", sess.ID)
 			return nil
 
 		case event := <-messageEvents:
@@ -205,8 +207,8 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
 	}
 }
 
-func (app *App) UpdateAgentModel() error {
-	return app.CoderAgent.UpdateModel()
+func (app *App) UpdateAgentModel(ctx context.Context) error {
+	return app.AgentCoordinator.UpdateModels(ctx)
 }
 
 func (app *App) setupEvents() {
@@ -217,7 +219,7 @@ func (app *App) setupEvents() {
 	setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
-	setupSubscriber(ctx, app.serviceEventsWG, "mcp", agent.SubscribeMCPEvents, app.events)
+	setupSubscriber(ctx, app.serviceEventsWG, "mcp", tools.SubscribeMCPEvents, app.events)
 	setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
 	cleanupFunc := func() error {
 		cancel()
@@ -260,18 +262,18 @@ func setupSubscriber[T any](
 	})
 }
 
-func (app *App) InitCoderAgent() error {
-	coderAgentCfg := app.config.Agents["coder"]
+func (app *App) InitCoderAgent(ctx context.Context) error {
+	coderAgentCfg := app.config.Agents[config.AgentCoder]
 	if coderAgentCfg.ID == "" {
 		return fmt.Errorf("coder agent configuration is missing")
 	}
 	var err error
-	app.CoderAgent, err = agent.NewAgent(
-		app.globalCtx,
-		coderAgentCfg,
-		app.Permissions,
+	app.AgentCoordinator, err = agent.NewCoordinator(
+		ctx,
+		app.config,
 		app.Sessions,
 		app.Messages,
+		app.Permissions,
 		app.History,
 		app.LSPClients,
 	)
@@ -281,9 +283,7 @@ func (app *App) InitCoderAgent() error {
 	}
 
 	// Add MCP client cleanup to shutdown process
-	app.cleanupFuncs = append(app.cleanupFuncs, agent.CloseMCPClients)
-
-	setupSubscriber(app.eventsCtx, app.serviceEventsWG, "coderAgent", app.CoderAgent.Subscribe, app.events)
+	app.cleanupFuncs = append(app.cleanupFuncs, tools.CloseMCPClients)
 	return nil
 }
 
@@ -321,8 +321,8 @@ func (app *App) Subscribe(program *tea.Program) {
 
 // Shutdown performs a graceful shutdown of the application.
 func (app *App) Shutdown() {
-	if app.CoderAgent != nil {
-		app.CoderAgent.CancelAll()
+	if app.AgentCoordinator != nil {
+		app.AgentCoordinator.CancelAll()
 	}
 
 	// Shutdown all LSP clients.

internal/config/config.go πŸ”—

@@ -48,6 +48,11 @@ const (
 	SelectedModelTypeSmall SelectedModelType = "small"
 )
 
+const (
+	AgentCoder string = "coder"
+	AgentTask  string = "task"
+)
+
 type SelectedModel struct {
 	// The model id as used by the provider API.
 	// Required.
@@ -59,11 +64,19 @@ type SelectedModel struct {
 	// Only used by models that use the openai provider and need this set.
 	ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"description=Reasoning effort level for OpenAI models that support it,enum=low,enum=medium,enum=high"`
 
-	// Overrides the default model configuration.
-	MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,minimum=1,maximum=200000,example=4096"`
-
 	// Used by anthropic models that can reason to indicate if the model should think.
 	Think bool `json:"think,omitempty" jsonschema:"description=Enable thinking mode for Anthropic models that support reasoning"`
+
+	// Overrides the default model configuration.
+	MaxTokens        int64    `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,minimum=1,maximum=200000,example=4096"`
+	Temperature      *float64 `json:"temperature,omitempty" jsonschema:"description=Sampling temperature,minimum=0,maximum=1,example=0.7"`
+	TopP             *float64 `json:"top_p,omitempty" jsonschema:"description=Top-p (nucleus) sampling parameter,minimum=0,maximum=1,example=0.9"`
+	TopK             *int64   `json:"top_k,omitempty" jsonschema:"description=Top-k sampling parameter"`
+	FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" jsonschema:"description=Frequency penalty to reduce repetition"`
+	PresencePenalty  *float64 `json:"presence_penalty,omitempty" jsonschema:"description=Presence penalty to increase topic diversity"`
+
+	// Override provider specific options.
+	ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for the model"`
 }
 
 type ProviderConfig struct {
@@ -86,7 +99,9 @@ type ProviderConfig struct {
 	// Extra headers to send with each request to the provider.
 	ExtraHeaders map[string]string `json:"extra_headers,omitempty" jsonschema:"description=Additional HTTP headers to send with requests"`
 	// Extra body
-	ExtraBody map[string]any `json:"extra_body,omitempty" jsonschema:"description=Additional fields to include in request bodies"`
+	ExtraBody map[string]any `json:"extra_body,omitempty" jsonschema:"description=Additional fields to include in request bodies, only works with openai-compatible providers"`
+
+	ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for this provider"`
 
 	// Used to pass extra parameters to the provider.
 	ExtraParams map[string]string `json:"-"`
@@ -251,10 +266,6 @@ type Agent struct {
 	//  if the string array is nil, all tools from the AllowedMCP are available
 	AllowedMCP map[string][]string `json:"allowed_mcp,omitempty"`
 
-	// The list of LSPs that this agent can use
-	//  if this is nil, all LSPs are available
-	AllowedLSP []string `json:"allowed_lsp,omitempty"`
-
 	// Overrides the context paths for this agent
 	ContextPaths []string `json:"context_paths,omitempty"`
 }
@@ -292,10 +303,10 @@ type Config struct {
 
 	Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
 
+	Agents map[string]Agent `json:"-"`
+
 	// Internal
 	workingDir string `json:"-"`
-	// TODO: most likely remove this concept when I come back to it
-	Agents map[string]Agent `json:"-"`
 	// TODO: find a better way to do this this should probably not be part of the config
 	resolver       VariableResolver
 	dataConfigDir  string             `json:"-"`
@@ -461,6 +472,8 @@ func allToolNames() []string {
 		"download",
 		"edit",
 		"multiedit",
+		"lsp_diagnostics",
+		"lsp_references",
 		"fetch",
 		"glob",
 		"grep",
@@ -501,16 +514,17 @@ func (c *Config) SetupAgents() {
 	allowedTools := resolveAllowedTools(allToolNames(), c.Options.DisabledTools)
 
 	agents := map[string]Agent{
-		"coder": {
-			ID:           "coder",
+		AgentCoder: {
+			ID:           AgentCoder,
 			Name:         "Coder",
 			Description:  "An agent that helps with executing coding tasks.",
 			Model:        SelectedModelTypeLarge,
 			ContextPaths: c.Options.ContextPaths,
 			AllowedTools: allowedTools,
 		},
-		"task": {
-			ID:           "task",
+
+		AgentTask: {
+			ID:           AgentCoder,
 			Name:         "Task",
 			Description:  "An agent that helps with searching for context and finding implementation details.",
 			Model:        SelectedModelTypeLarge,
@@ -518,7 +532,6 @@ func (c *Config) SetupAgents() {
 			AllowedTools: resolveReadOnlyTools(allowedTools),
 			// NO MCPs or LSPs by default
 			AllowedMCP: map[string][]string{},
-			AllowedLSP: []string{},
 		},
 	}
 	c.Agents = agents
@@ -533,7 +546,7 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 	headers := make(map[string]string)
 	apiKey, _ := resolver.ResolveValue(c.APIKey)
 	switch c.Type {
-	case catwalk.TypeOpenAI:
+	case catwalk.TypeOpenAI, catwalk.TypeOpenAICompat:
 		baseURL, _ := resolver.ResolveValue(c.BaseURL)
 		if baseURL == "" {
 			baseURL = "https://api.openai.com/v1"
@@ -552,7 +565,7 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 		testURL = baseURL + "/models"
 		headers["x-api-key"] = apiKey
 		headers["anthropic-version"] = "2023-06-01"
-	case catwalk.TypeGemini:
+	case catwalk.TypeGoogle:
 		baseURL, _ := resolver.ResolveValue(c.BaseURL)
 		if baseURL == "" {
 			baseURL = "https://generativelanguage.googleapis.com"

internal/config/init.go πŸ”—

@@ -7,6 +7,8 @@ import (
 	"slices"
 	"strings"
 	"sync/atomic"
+
+	"github.com/charmbracelet/crush/internal/fsext"
 )
 
 const (
@@ -59,6 +61,15 @@ func ProjectNeedsInitialization() (bool, error) {
 		return false, nil
 	}
 
+	// If the working directory has no non-ignored files, skip initialization step
+	empty, err := dirHasNoVisibleFiles(cfg.WorkingDir())
+	if err != nil {
+		return false, fmt.Errorf("failed to check if directory is empty: %w", err)
+	}
+	if empty {
+		return false, nil
+	}
+
 	return true, nil
 }
 
@@ -90,6 +101,15 @@ func contextPathsExist(dir string) (bool, error) {
 	return false, nil
 }
 
+// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules
+func dirHasNoVisibleFiles(dir string) (bool, error) {
+	files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
+	if err != nil {
+		return false, err
+	}
+	return len(files) == 0, nil
+}
+
 func MarkProjectInitialized() error {
 	cfg := Get()
 	if cfg == nil {

internal/config/load.go πŸ”—

@@ -261,7 +261,12 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 		}
 		// default to OpenAI if not set
 		if providerConfig.Type == "" {
-			providerConfig.Type = catwalk.TypeOpenAI
+			providerConfig.Type = catwalk.TypeOpenAICompat
+		}
+		if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) {
+			slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
+			c.Providers.Del(id)
+			continue
 		}
 
 		if providerConfig.Disable {
@@ -282,12 +287,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 			c.Providers.Del(id)
 			continue
 		}
-		if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic && providerConfig.Type != catwalk.TypeGemini {
-			slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
-			c.Providers.Del(id)
-			continue
-		}
-
 		apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
 		if apiKey == "" || err != nil {
 			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
@@ -348,6 +347,13 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
 		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
 	}
+
+	if c.Options.Attribution == nil {
+		c.Options.Attribution = &Attribution{
+			CoAuthoredBy:  true,
+			GeneratedWith: true,
+		}
+	}
 }
 
 // applyLSPDefaults applies default values from powernap to LSP configurations
@@ -494,6 +500,21 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
 				large.ReasoningEffort = largeModelSelected.ReasoningEffort
 			}
 			large.Think = largeModelSelected.Think
+			if largeModelSelected.Temperature != nil {
+				large.Temperature = largeModelSelected.Temperature
+			}
+			if largeModelSelected.TopP != nil {
+				large.TopP = largeModelSelected.TopP
+			}
+			if largeModelSelected.TopK != nil {
+				large.TopK = largeModelSelected.TopK
+			}
+			if largeModelSelected.FrequencyPenalty != nil {
+				large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
+			}
+			if largeModelSelected.PresencePenalty != nil {
+				large.PresencePenalty = largeModelSelected.PresencePenalty
+			}
 		}
 	}
 	smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
@@ -519,7 +540,24 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
 			} else {
 				small.MaxTokens = model.DefaultMaxTokens
 			}
-			small.ReasoningEffort = smallModelSelected.ReasoningEffort
+			if smallModelSelected.ReasoningEffort != "" {
+				small.ReasoningEffort = smallModelSelected.ReasoningEffort
+			}
+			if smallModelSelected.Temperature != nil {
+				small.Temperature = smallModelSelected.Temperature
+			}
+			if smallModelSelected.TopP != nil {
+				small.TopP = smallModelSelected.TopP
+			}
+			if smallModelSelected.TopK != nil {
+				small.TopK = smallModelSelected.TopK
+			}
+			if smallModelSelected.FrequencyPenalty != nil {
+				small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
+			}
+			if smallModelSelected.PresencePenalty != nil {
+				small.PresencePenalty = smallModelSelected.PresencePenalty
+			}
 			small.Think = smallModelSelected.Think
 		}
 	}

internal/config/load_test.go πŸ”—

@@ -462,11 +462,11 @@ func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
 	}
 
 	cfg.SetupAgents()
-	coderAgent, ok := cfg.Agents["coder"]
+	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 	assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
 
-	taskAgent, ok := cfg.Agents["task"]
+	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 }
@@ -483,11 +483,11 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	}
 
 	cfg.SetupAgents()
-	coderAgent, ok := cfg.Agents["coder"]
+	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "multiedit", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
 
-	taskAgent, ok := cfg.Agents["task"]
+	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 }
@@ -506,11 +506,11 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	}
 
 	cfg.SetupAgents()
-	coderAgent, ok := cfg.Agents["coder"]
+	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "fetch", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "write"}, coderAgent.AllowedTools)
 
-	taskAgent, ok := cfg.Agents["task"]
+	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{}, taskAgent.AllowedTools)
 }

internal/config/provider.go πŸ”—

@@ -141,7 +141,6 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 		}
 		return providers, nil
 	}
-
 	switch {
 	case autoUpdateDisabled:
 		slog.Warn("Providers auto-update is disabled")
@@ -163,7 +162,7 @@ func loadProviders(autoUpdateDisabled bool, client ProviderClient, path string)
 
 		providers, err := catwalkGetAndSave()
 		if err != nil {
-			catwalkUrl := fmt.Sprintf("%s/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
+			catwalkUrl := fmt.Sprintf("%s/v2/providers", cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL))
 			return nil, fmt.Errorf("Crush was unable to fetch an updated list of providers from %s. Consider setting CRUSH_DISABLE_PROVIDER_AUTO_UPDATE=1 to use the embedded providers bundled at the time of this Crush release. You can also update providers manually. For more info see crush update-providers --help. %w", catwalkUrl, err) //nolint:staticcheck
 		}
 		return providers, nil

internal/db/db.go πŸ”—

@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 
 package db
 

internal/db/files.sql.go πŸ”—

@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 // source: files.sql
 
 package db

internal/db/messages.sql.go πŸ”—

@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 // source: messages.sql
 
 package db
@@ -18,21 +18,23 @@ INSERT INTO messages (
     parts,
     model,
     provider,
+    is_summary_message,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
-RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
+RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
 `
 
 type CreateMessageParams struct {
-	ID        string         `json:"id"`
-	SessionID string         `json:"session_id"`
-	Role      string         `json:"role"`
-	Parts     string         `json:"parts"`
-	Model     sql.NullString `json:"model"`
-	Provider  sql.NullString `json:"provider"`
+	ID               string         `json:"id"`
+	SessionID        string         `json:"session_id"`
+	Role             string         `json:"role"`
+	Parts            string         `json:"parts"`
+	Model            sql.NullString `json:"model"`
+	Provider         sql.NullString `json:"provider"`
+	IsSummaryMessage int64          `json:"is_summary_message"`
 }
 
 func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -43,6 +45,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		arg.Parts,
 		arg.Model,
 		arg.Provider,
+		arg.IsSummaryMessage,
 	)
 	var i Message
 	err := row.Scan(
@@ -55,6 +58,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		&i.UpdatedAt,
 		&i.FinishedAt,
 		&i.Provider,
+		&i.IsSummaryMessage,
 	)
 	return i, err
 }
@@ -80,7 +84,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
 }
 
 const getMessage = `-- name: GetMessage :one
-SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
 FROM messages
 WHERE id = ? LIMIT 1
 `
@@ -98,12 +102,13 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
 		&i.UpdatedAt,
 		&i.FinishedAt,
 		&i.Provider,
+		&i.IsSummaryMessage,
 	)
 	return i, err
 }
 
 const listMessagesBySession = `-- name: ListMessagesBySession :many
-SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
 FROM messages
 WHERE session_id = ?
 ORDER BY created_at ASC
@@ -128,6 +133,7 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
 			&i.UpdatedAt,
 			&i.FinishedAt,
 			&i.Provider,
+			&i.IsSummaryMessage,
 		); err != nil {
 			return nil, err
 		}

internal/db/models.go πŸ”—

@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 
 package db
 
@@ -19,15 +19,16 @@ type File struct {
 }
 
 type Message struct {
-	ID         string         `json:"id"`
-	SessionID  string         `json:"session_id"`
-	Role       string         `json:"role"`
-	Parts      string         `json:"parts"`
-	Model      sql.NullString `json:"model"`
-	CreatedAt  int64          `json:"created_at"`
-	UpdatedAt  int64          `json:"updated_at"`
-	FinishedAt sql.NullInt64  `json:"finished_at"`
-	Provider   sql.NullString `json:"provider"`
+	ID               string         `json:"id"`
+	SessionID        string         `json:"session_id"`
+	Role             string         `json:"role"`
+	Parts            string         `json:"parts"`
+	Model            sql.NullString `json:"model"`
+	CreatedAt        int64          `json:"created_at"`
+	UpdatedAt        int64          `json:"updated_at"`
+	FinishedAt       sql.NullInt64  `json:"finished_at"`
+	Provider         sql.NullString `json:"provider"`
+	IsSummaryMessage int64          `json:"is_summary_message"`
 }
 
 type Session struct {

internal/db/sessions.sql.go πŸ”—

@@ -1,6 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
 // versions:
-//   sqlc v1.29.0
+//   sqlc v1.30.0
 // source: sessions.sql
 
 package db

internal/db/sql/messages.sql πŸ”—

@@ -17,10 +17,11 @@ INSERT INTO messages (
     parts,
     model,
     provider,
+    is_summary_message,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
 RETURNING *;
 

internal/llm/agent/agent-tool.go πŸ”—

@@ -1,106 +0,0 @@
-package agent
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/session"
-)
-
-type agentTool struct {
-	agent    Service
-	sessions session.Service
-	messages message.Service
-}
-
-const (
-	AgentToolName = "agent"
-)
-
-type AgentParams struct {
-	Prompt string `json:"prompt"`
-}
-
-func (b *agentTool) Name() string {
-	return AgentToolName
-}
-
-func (b *agentTool) Info() tools.ToolInfo {
-	return tools.ToolInfo{
-		Name:        AgentToolName,

internal/llm/agent/agent.go πŸ”—

@@ -1,1138 +0,0 @@
-// Package agent contains the implementation of the AI agent service.
-package agent
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"log/slog"
-	"maps"
-	"slices"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/llm/prompt"
-	"github.com/charmbracelet/crush/internal/llm/provider"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/pubsub"
-	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/shell"
-)
-
-type AgentEventType string
-
-const (
-	AgentEventTypeError     AgentEventType = "error"
-	AgentEventTypeResponse  AgentEventType = "response"
-	AgentEventTypeSummarize AgentEventType = "summarize"
-)
-
-type AgentEvent struct {
-	Type    AgentEventType
-	Message message.Message
-	Error   error
-
-	// When summarizing
-	SessionID string
-	Progress  string
-	Done      bool
-}
-
-type Service interface {
-	pubsub.Suscriber[AgentEvent]
-	Model() catwalk.Model
-	Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
-	Cancel(sessionID string)
-	CancelAll()
-	IsSessionBusy(sessionID string) bool
-	IsBusy() bool
-	Summarize(ctx context.Context, sessionID string) error
-	UpdateModel() error
-	QueuedPrompts(sessionID string) int
-	ClearQueue(sessionID string)
-}
-
-type agent struct {
-	*pubsub.Broker[AgentEvent]
-	agentCfg    config.Agent
-	sessions    session.Service
-	messages    message.Service
-	permissions permission.Service
-	baseTools   *csync.Map[string, tools.BaseTool]
-	mcpTools    *csync.Map[string, tools.BaseTool]
-	lspClients  *csync.Map[string, *lsp.Client]
-
-	// We need this to be able to update it when model changes
-	agentToolFn  func() (tools.BaseTool, error)
-	cleanupFuncs []func()
-
-	provider   provider.Provider
-	providerID string
-
-	titleProvider       provider.Provider
-	summarizeProvider   provider.Provider
-	summarizeProviderID string
-
-	activeRequests *csync.Map[string, context.CancelFunc]
-	promptQueue    *csync.Map[string, []string]
-}
-
-var agentPromptMap = map[string]prompt.PromptID{
-	"coder": prompt.PromptCoder,
-	"task":  prompt.PromptTask,
-}
-
-func NewAgent(
-	ctx context.Context,
-	agentCfg config.Agent,
-	// These services are needed in the tools
-	permissions permission.Service,
-	sessions session.Service,
-	messages message.Service,
-	history history.Service,
-	lspClients *csync.Map[string, *lsp.Client],
-) (Service, error) {
-	cfg := config.Get()
-
-	var agentToolFn func() (tools.BaseTool, error)
-	if agentCfg.ID == "coder" && slices.Contains(agentCfg.AllowedTools, AgentToolName) {
-		agentToolFn = func() (tools.BaseTool, error) {
-			taskAgentCfg := config.Get().Agents["task"]
-			if taskAgentCfg.ID == "" {
-				return nil, fmt.Errorf("task agent not found in config")
-			}
-			taskAgent, err := NewAgent(ctx, taskAgentCfg, permissions, sessions, messages, history, lspClients)
-			if err != nil {
-				return nil, fmt.Errorf("failed to create task agent: %w", err)
-			}
-			return NewAgentTool(taskAgent, sessions, messages), nil
-		}
-	}
-
-	providerCfg := config.Get().GetProviderForModel(agentCfg.Model)
-	if providerCfg == nil {
-		return nil, fmt.Errorf("provider for agent %s not found in config", agentCfg.Name)
-	}
-	model := config.Get().GetModelByType(agentCfg.Model)
-
-	if model == nil {
-		return nil, fmt.Errorf("model not found for agent %s", agentCfg.Name)
-	}
-
-	promptID := agentPromptMap[agentCfg.ID]
-	if promptID == "" {
-		promptID = prompt.PromptDefault
-	}
-	opts := []provider.ProviderClientOption{
-		provider.WithModel(agentCfg.Model),
-		provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID, config.Get().Options.ContextPaths...)),
-	}
-	agentProvider, err := provider.NewProvider(*providerCfg, opts...)
-	if err != nil {
-		return nil, err
-	}
-
-	smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
-	var smallModelProviderCfg *config.ProviderConfig
-	if smallModelCfg.Provider == providerCfg.ID {
-		smallModelProviderCfg = providerCfg
-	} else {
-		smallModelProviderCfg = cfg.GetProviderForModel(config.SelectedModelTypeSmall)
-
-		if smallModelProviderCfg.ID == "" {
-			return nil, fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
-		}
-	}
-	smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
-	if smallModel.ID == "" {
-		return nil, fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
-	}
-
-	titleOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeSmall),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
-	}
-	titleProvider, err := provider.NewProvider(*smallModelProviderCfg, titleOpts...)
-	if err != nil {
-		return nil, err
-	}
-
-	summarizeOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeLarge),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
-	}
-	summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
-	if err != nil {
-		return nil, err
-	}
-
-	baseToolsFn := func() map[string]tools.BaseTool {
-		slog.Debug("Initializing agent base tools", "agent", agentCfg.ID)
-		defer func() {
-			slog.Debug("Initialized agent base tools", "agent", agentCfg.ID)
-		}()
-
-		// Base tools available to all agents
-		cwd := cfg.WorkingDir()
-		result := make(map[string]tools.BaseTool)
-		for _, tool := range []tools.BaseTool{
-			tools.NewBashTool(permissions, cwd, cfg.Options.Attribution),
-			tools.NewDownloadTool(permissions, cwd),
-			tools.NewEditTool(lspClients, permissions, history, cwd),
-			tools.NewMultiEditTool(lspClients, permissions, history, cwd),
-			tools.NewFetchTool(permissions, cwd),
-			tools.NewGlobTool(cwd),
-			tools.NewGrepTool(cwd),
-			tools.NewLsTool(permissions, cwd),
-			tools.NewSourcegraphTool(),
-			tools.NewViewTool(lspClients, permissions, cwd),
-			tools.NewWriteTool(lspClients, permissions, history, cwd),
-		} {
-			result[tool.Name()] = tool
-		}
-		return result
-	}
-	mcpToolsFn := func() map[string]tools.BaseTool {
-		slog.Debug("Initializing agent mcp tools", "agent", agentCfg.ID)
-		defer func() {
-			slog.Debug("Initialized agent mcp tools", "agent", agentCfg.ID)
-		}()
-
-		mcpToolsOnce.Do(func() {
-			doGetMCPTools(ctx, permissions, cfg)
-		})
-
-		return maps.Collect(mcpTools.Seq2())
-	}
-
-	a := &agent{
-		Broker:              pubsub.NewBroker[AgentEvent](),
-		agentCfg:            agentCfg,
-		provider:            agentProvider,
-		providerID:          string(providerCfg.ID),
-		messages:            messages,
-		sessions:            sessions,
-		titleProvider:       titleProvider,
-		summarizeProvider:   summarizeProvider,
-		summarizeProviderID: string(providerCfg.ID),
-		agentToolFn:         agentToolFn,
-		activeRequests:      csync.NewMap[string, context.CancelFunc](),
-		mcpTools:            csync.NewLazyMap(mcpToolsFn),
-		baseTools:           csync.NewLazyMap(baseToolsFn),
-		promptQueue:         csync.NewMap[string, []string](),
-		permissions:         permissions,
-		lspClients:          lspClients,
-	}
-	a.setupEvents(ctx)
-	return a, nil
-}
-
-func (a *agent) Model() catwalk.Model {
-	return *config.Get().GetModelByType(a.agentCfg.Model)
-}
-
-func (a *agent) Cancel(sessionID string) {
-	// Cancel regular requests
-	if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
-		slog.Info("Request cancellation initiated", "session_id", sessionID)
-		cancel()
-	}
-
-	// Also check for summarize requests
-	if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
-		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
-		cancel()
-	}
-
-	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
-		a.promptQueue.Del(sessionID)
-	}
-}
-
-func (a *agent) IsBusy() bool {
-	var busy bool
-	for cancelFunc := range a.activeRequests.Seq() {
-		if cancelFunc != nil {
-			busy = true
-			break
-		}
-	}
-	return busy
-}
-
-func (a *agent) IsSessionBusy(sessionID string) bool {
-	_, busy := a.activeRequests.Get(sessionID)
-	return busy
-}
-
-func (a *agent) QueuedPrompts(sessionID string) int {
-	l, ok := a.promptQueue.Get(sessionID)
-	if !ok {
-		return 0
-	}
-	return len(l)
-}
-
-func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
-	if content == "" {
-		return nil
-	}
-	if a.titleProvider == nil {
-		return nil
-	}
-	session, err := a.sessions.Get(ctx, sessionID)
-	if err != nil {
-		return err
-	}
-	parts := []message.ContentPart{message.TextContent{
-		Text: fmt.Sprintf("Generate a concise title for the following content:\n\n%s", content),
-	}}
-
-	// Use streaming approach like summarization
-	response := a.titleProvider.StreamResponse(
-		ctx,
-		[]message.Message{
-			{
-				Role:  message.User,
-				Parts: parts,
-			},
-		},
-		nil,
-	)
-
-	var finalResponse *provider.ProviderResponse
-	for r := range response {
-		if r.Error != nil {
-			return r.Error
-		}
-		finalResponse = r.Response
-	}
-
-	if finalResponse == nil {
-		return fmt.Errorf("no response received from title provider")
-	}
-
-	title := strings.ReplaceAll(finalResponse.Content, "\n", " ")
-
-	if idx := strings.Index(title, "</think>"); idx > 0 {
-		title = title[idx+len("</think>"):]
-	}
-
-	title = strings.TrimSpace(title)
-	if title == "" {
-		return nil
-	}
-
-	session.Title = title
-	_, err = a.sessions.Save(ctx, session)
-	return err
-}
-
-func (a *agent) err(err error) AgentEvent {
-	return AgentEvent{
-		Type:  AgentEventTypeError,
-		Error: err,
-	}
-}
-
-func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
-	if !a.Model().SupportsImages && attachments != nil {
-		attachments = nil
-	}
-	events := make(chan AgentEvent, 1)
-	if a.IsSessionBusy(sessionID) {
-		existing, ok := a.promptQueue.Get(sessionID)
-		if !ok {
-			existing = []string{}
-		}
-		existing = append(existing, content)
-		a.promptQueue.Set(sessionID, existing)
-		return nil, nil
-	}
-
-	genCtx, cancel := context.WithCancel(ctx)
-	a.activeRequests.Set(sessionID, cancel)
-	startTime := time.Now()
-
-	go func() {
-		slog.Debug("Request started", "sessionID", sessionID)
-		defer log.RecoverPanic("agent.Run", func() {
-			events <- a.err(fmt.Errorf("panic while running the agent"))
-		})
-		var attachmentParts []message.ContentPart
-		for _, attachment := range attachments {
-			attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
-		}
-		result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
-		if result.Error != nil {
-			if isCancelledErr(result.Error) {
-				slog.Error("Request canceled", "sessionID", sessionID)
-			} else {
-				slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error())
-				event.Error(result.Error)
-			}
-		} else {
-			slog.Debug("Request completed", "sessionID", sessionID)
-		}
-		a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
-		a.activeRequests.Del(sessionID)
-		cancel()
-		a.Publish(pubsub.CreatedEvent, result)
-		events <- result
-		close(events)
-	}()
-	a.eventPromptSent(sessionID)
-	return events, nil
-}
-
-func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
-	cfg := config.Get()
-	// List existing messages; if none, start title generation asynchronously.
-	msgs, err := a.messages.List(ctx, sessionID)
-	if err != nil {
-		return a.err(fmt.Errorf("failed to list messages: %w", err))
-	}
-	if len(msgs) == 0 {
-		go func() {
-			defer log.RecoverPanic("agent.Run", func() {
-				slog.Error("panic while generating title")
-			})
-			titleErr := a.generateTitle(ctx, sessionID, content)
-			if titleErr != nil && !errors.Is(titleErr, context.Canceled) && !errors.Is(titleErr, context.DeadlineExceeded) {
-				slog.Error("failed to generate title", "error", titleErr)
-			}
-		}()
-	}
-	session, err := a.sessions.Get(ctx, sessionID)
-	if err != nil {
-		return a.err(fmt.Errorf("failed to get session: %w", err))
-	}
-	if session.SummaryMessageID != "" {
-		summaryMsgInex := -1
-		for i, msg := range msgs {
-			if msg.ID == session.SummaryMessageID {
-				summaryMsgInex = i
-				break
-			}
-		}
-		if summaryMsgInex != -1 {
-			msgs = msgs[summaryMsgInex:]
-			msgs[0].Role = message.User
-		}
-	}
-
-	userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
-	if err != nil {
-		return a.err(fmt.Errorf("failed to create user message: %w", err))
-	}
-	// Append the new user message to the conversation history.
-	msgHistory := append(msgs, userMsg)
-
-	for {
-		// Check for cancellation before each iteration
-		select {
-		case <-ctx.Done():
-			return a.err(ctx.Err())
-		default:
-			// Continue processing
-		}
-		agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
-		if err != nil {
-			if errors.Is(err, context.Canceled) {
-				agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
-				a.messages.Update(context.Background(), agentMessage)
-				return a.err(ErrRequestCancelled)
-			}
-			return a.err(fmt.Errorf("failed to process events: %w", err))
-		}
-		if cfg.Options.Debug {
-			slog.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
-		}
-		if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
-			// We are not done, we need to respond with the tool response
-			msgHistory = append(msgHistory, agentMessage, *toolResults)
-			// If there are queued prompts, process the next one
-			nextPrompt, ok := a.promptQueue.Take(sessionID)
-			if ok {
-				for _, prompt := range nextPrompt {
-					// Create a new user message for the queued prompt
-					userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
-					if err != nil {
-						return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
-					}
-					// Append the new user message to the conversation history
-					msgHistory = append(msgHistory, userMsg)
-				}
-			}
-
-			continue
-		} else if agentMessage.FinishReason() == message.FinishReasonEndTurn {
-			queuePrompts, ok := a.promptQueue.Take(sessionID)
-			if ok {
-				for _, prompt := range queuePrompts {
-					if prompt == "" {
-						continue
-					}
-					userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
-					if err != nil {
-						return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
-					}
-					msgHistory = append(msgHistory, userMsg)
-				}
-				continue
-			}
-		}
-		if agentMessage.FinishReason() == "" {
-			// Kujtim: could not track down where this is happening but this means its cancelled
-			agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
-			_ = a.messages.Update(context.Background(), agentMessage)
-			return a.err(ErrRequestCancelled)
-		}
-		return AgentEvent{
-			Type:    AgentEventTypeResponse,
-			Message: agentMessage,
-			Done:    true,
-		}
-	}
-}
-
-func (a *agent) createUserMessage(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) (message.Message, error) {
-	parts := []message.ContentPart{message.TextContent{Text: content}}
-	parts = append(parts, attachmentParts...)
-	return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
-		Role:  message.User,
-		Parts: parts,
-	})
-}
-
-func (a *agent) getAllTools() ([]tools.BaseTool, error) {
-	var allTools []tools.BaseTool
-	for tool := range a.baseTools.Seq() {
-		if a.agentCfg.AllowedTools == nil || slices.Contains(a.agentCfg.AllowedTools, tool.Name()) {
-			allTools = append(allTools, tool)
-		}
-	}
-	if a.agentCfg.ID == "coder" {
-		allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
-		if a.lspClients.Len() > 0 {
-			allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients))
-		}
-	}
-	if a.agentToolFn != nil {
-		agentTool, agentToolErr := a.agentToolFn()
-		if agentToolErr != nil {
-			return nil, agentToolErr
-		}
-		allTools = append(allTools, agentTool)
-	}
-
-	slices.SortFunc(allTools, func(a, b tools.BaseTool) int {
-		return strings.Compare(a.Name(), b.Name())
-	})
-	return allTools, nil
-}
-
-func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
-	ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
-
-	// Create the assistant message first so the spinner shows immediately
-	assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
-		Role:     message.Assistant,
-		Parts:    []message.ContentPart{},
-		Model:    a.Model().ID,
-		Provider: a.providerID,
-	})
-	if err != nil {
-		return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
-	}
-
-	allTools, toolsErr := a.getAllTools()
-	if toolsErr != nil {
-		return assistantMsg, nil, toolsErr
-	}
-	// Now collect tools (which may block on MCP initialization)
-	eventChan := a.provider.StreamResponse(ctx, msgHistory, allTools)
-
-	// Add the session and message ID into the context if needed by tools.
-	ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
-
-loop:
-	for {
-		select {
-		case event, ok := <-eventChan:
-			if !ok {
-				break loop
-			}
-			if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
-				if errors.Is(processErr, context.Canceled) {
-					a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-				} else {
-					a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
-				}
-				return assistantMsg, nil, processErr
-			}
-		case <-ctx.Done():
-			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-			return assistantMsg, nil, ctx.Err()
-		}
-	}
-
-	toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
-	toolCalls := assistantMsg.ToolCalls()
-	for i, toolCall := range toolCalls {
-		select {
-		case <-ctx.Done():
-			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-			// Make all future tool calls cancelled
-			for j := i; j < len(toolCalls); j++ {
-				toolResults[j] = message.ToolResult{
-					ToolCallID: toolCalls[j].ID,
-					Content:    "Tool execution canceled by user",
-					IsError:    true,
-				}
-			}
-			goto out
-		default:
-			// Continue processing
-			var tool tools.BaseTool
-			allTools, _ = a.getAllTools()
-			for _, availableTool := range allTools {
-				if availableTool.Info().Name == toolCall.Name {
-					tool = availableTool
-					break
-				}
-			}
-
-			// Tool not found
-			if tool == nil {
-				toolResults[i] = message.ToolResult{
-					ToolCallID: toolCall.ID,
-					Content:    fmt.Sprintf("Tool not found: %s", toolCall.Name),
-					IsError:    true,
-				}
-				continue
-			}
-
-			// Run tool in goroutine to allow cancellation
-			type toolExecResult struct {
-				response tools.ToolResponse
-				err      error
-			}
-			resultChan := make(chan toolExecResult, 1)
-
-			go func() {
-				response, err := tool.Run(ctx, tools.ToolCall{
-					ID:    toolCall.ID,
-					Name:  toolCall.Name,
-					Input: toolCall.Input,
-				})
-				resultChan <- toolExecResult{response: response, err: err}
-			}()
-
-			var toolResponse tools.ToolResponse
-			var toolErr error
-
-			select {
-			case <-ctx.Done():
-				a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
-				// Mark remaining tool calls as cancelled
-				for j := i; j < len(toolCalls); j++ {
-					toolResults[j] = message.ToolResult{
-						ToolCallID: toolCalls[j].ID,
-						Content:    "Tool execution canceled by user",
-						IsError:    true,
-					}
-				}
-				goto out
-			case result := <-resultChan:
-				toolResponse = result.response
-				toolErr = result.err
-			}
-
-			if toolErr != nil {
-				slog.Error("Tool execution error", "toolCall", toolCall.ID, "error", toolErr)
-				if errors.Is(toolErr, permission.ErrorPermissionDenied) {
-					toolResults[i] = message.ToolResult{
-						ToolCallID: toolCall.ID,
-						Content:    "Permission denied",
-						IsError:    true,
-					}
-					for j := i + 1; j < len(toolCalls); j++ {
-						toolResults[j] = message.ToolResult{
-							ToolCallID: toolCalls[j].ID,
-							Content:    "Tool execution canceled by user",
-							IsError:    true,
-						}
-					}
-					a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied, "Permission denied", "")
-					break
-				}
-			}
-			toolResults[i] = message.ToolResult{
-				ToolCallID: toolCall.ID,
-				Content:    toolResponse.Content,
-				Metadata:   toolResponse.Metadata,
-				IsError:    toolResponse.IsError,
-			}
-		}
-	}
-out:
-	if len(toolResults) == 0 {
-		return assistantMsg, nil, nil
-	}
-	parts := make([]message.ContentPart, 0)
-	for _, tr := range toolResults {
-		parts = append(parts, tr)
-	}
-	msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
-		Role:     message.Tool,
-		Parts:    parts,
-		Provider: a.providerID,
-	})
-	if err != nil {
-		return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
-	}
-
-	return assistantMsg, &msg, err
-}
-
-func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReason message.FinishReason, message, details string) {
-	msg.AddFinish(finishReason, message, details)
-	_ = a.messages.Update(ctx, *msg)
-}
-
-func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
-	select {
-	case <-ctx.Done():
-		return ctx.Err()
-	default:
-		// Continue processing.
-	}
-
-	switch event.Type {
-	case provider.EventThinkingDelta:
-		assistantMsg.AppendReasoningContent(event.Thinking)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventSignatureDelta:
-		assistantMsg.AppendReasoningSignature(event.Signature)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventContentDelta:
-		assistantMsg.FinishThinking()
-		assistantMsg.AppendContent(event.Content)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventToolUseStart:
-		assistantMsg.FinishThinking()
-		slog.Info("Tool call started", "toolCall", event.ToolCall)
-		assistantMsg.AddToolCall(*event.ToolCall)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventToolUseDelta:
-		assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventToolUseStop:
-		slog.Info("Finished tool call", "toolCall", event.ToolCall)
-		assistantMsg.FinishToolCall(event.ToolCall.ID)
-		return a.messages.Update(ctx, *assistantMsg)
-	case provider.EventError:
-		return event.Error
-	case provider.EventComplete:
-		assistantMsg.FinishThinking()
-		assistantMsg.SetToolCalls(event.Response.ToolCalls)
-		assistantMsg.AddFinish(event.Response.FinishReason, "", "")
-		if err := a.messages.Update(ctx, *assistantMsg); err != nil {
-			return fmt.Errorf("failed to update message: %w", err)
-		}
-		return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
-	}
-
-	return nil
-}
-
-func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
-	sess, err := a.sessions.Get(ctx, sessionID)
-	if err != nil {
-		return fmt.Errorf("failed to get session: %w", err)
-	}
-
-	cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
-		model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
-		model.CostPer1MIn/1e6*float64(usage.InputTokens) +
-		model.CostPer1MOut/1e6*float64(usage.OutputTokens)
-
-	a.eventTokensUsed(sessionID, usage, cost)
-
-	sess.Cost += cost
-	sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
-	sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
-
-	_, err = a.sessions.Save(ctx, sess)
-	if err != nil {
-		return fmt.Errorf("failed to save session: %w", err)
-	}
-	return nil
-}
-
-func (a *agent) Summarize(ctx context.Context, sessionID string) error {
-	if a.summarizeProvider == nil {
-		return fmt.Errorf("summarize provider not available")
-	}
-
-	// Check if session is busy
-	if a.IsSessionBusy(sessionID) {
-		return ErrSessionBusy
-	}
-
-	// Create a new context with cancellation
-	summarizeCtx, cancel := context.WithCancel(ctx)
-
-	// Store the cancel function in activeRequests to allow cancellation
-	a.activeRequests.Set(sessionID+"-summarize", cancel)
-
-	go func() {
-		defer a.activeRequests.Del(sessionID + "-summarize")
-		defer cancel()
-		event := AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Starting summarization...",
-		}
-
-		a.Publish(pubsub.CreatedEvent, event)
-		// Get all messages from the session
-		msgs, err := a.messages.List(summarizeCtx, sessionID)
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to list messages: %w", err),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
-
-		if len(msgs) == 0 {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("no messages to summarize"),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-
-		event = AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Analyzing conversation...",
-		}
-		a.Publish(pubsub.CreatedEvent, event)
-
-		// Add a system message to guide the summarization
-		summarizePrompt := "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next."
-
-		// Create a new message with the summarize prompt
-		promptMsg := message.Message{
-			Role:  message.User,
-			Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt}},
-		}
-
-		// Append the prompt to the messages
-		msgsWithPrompt := append(msgs, promptMsg)
-
-		event = AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Generating summary...",
-		}
-
-		a.Publish(pubsub.CreatedEvent, event)
-
-		// Send the messages to the summarize provider
-		response := a.summarizeProvider.StreamResponse(
-			summarizeCtx,
-			msgsWithPrompt,
-			nil,
-		)
-		var finalResponse *provider.ProviderResponse
-		for r := range response {
-			if r.Error != nil {
-				event = AgentEvent{
-					Type:  AgentEventTypeError,
-					Error: fmt.Errorf("failed to summarize: %w", r.Error),
-					Done:  true,
-				}
-				a.Publish(pubsub.CreatedEvent, event)
-				return
-			}
-			finalResponse = r.Response
-		}
-
-		summary := strings.TrimSpace(finalResponse.Content)
-		if summary == "" {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("empty summary returned"),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		shell := shell.GetPersistentShell(config.Get().WorkingDir())
-		summary += "\n\n**Current working directory of the persistent shell**\n\n" + shell.GetWorkingDir()
-		event = AgentEvent{
-			Type:     AgentEventTypeSummarize,
-			Progress: "Creating new session...",
-		}
-
-		a.Publish(pubsub.CreatedEvent, event)
-		oldSession, err := a.sessions.Get(summarizeCtx, sessionID)
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to get session: %w", err),
-				Done:  true,
-			}
-
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		// Create a message in the new session with the summary
-		msg, err := a.messages.Create(summarizeCtx, oldSession.ID, message.CreateMessageParams{
-			Role: message.Assistant,
-			Parts: []message.ContentPart{
-				message.TextContent{Text: summary},
-				message.Finish{
-					Reason: message.FinishReasonEndTurn,
-					Time:   time.Now().Unix(),
-				},
-			},
-			Model:    a.summarizeProvider.Model().ID,
-			Provider: a.summarizeProviderID,
-		})
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to create summary message: %w", err),
-				Done:  true,
-			}
-
-			a.Publish(pubsub.CreatedEvent, event)
-			return
-		}
-		oldSession.SummaryMessageID = msg.ID
-		oldSession.CompletionTokens = finalResponse.Usage.OutputTokens
-		oldSession.PromptTokens = 0
-		model := a.summarizeProvider.Model()
-		usage := finalResponse.Usage
-		cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
-			model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
-			model.CostPer1MIn/1e6*float64(usage.InputTokens) +
-			model.CostPer1MOut/1e6*float64(usage.OutputTokens)
-		oldSession.Cost += cost
-		_, err = a.sessions.Save(summarizeCtx, oldSession)
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to save session: %w", err),
-				Done:  true,
-			}
-			a.Publish(pubsub.CreatedEvent, event)
-		}
-
-		event = AgentEvent{
-			Type:      AgentEventTypeSummarize,
-			SessionID: oldSession.ID,
-			Progress:  "Summary complete",
-			Done:      true,
-		}
-		a.Publish(pubsub.CreatedEvent, event)
-		// Send final success event with the new session ID
-	}()
-
-	return nil
-}
-
-func (a *agent) ClearQueue(sessionID string) {
-	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
-		a.promptQueue.Del(sessionID)
-	}
-}
-
-func (a *agent) CancelAll() {
-	if !a.IsBusy() {
-		return
-	}
-	for key := range a.activeRequests.Seq2() {
-		a.Cancel(key) // key is sessionID
-	}
-
-	for _, cleanup := range a.cleanupFuncs {
-		if cleanup != nil {
-			cleanup()
-		}
-	}
-
-	timeout := time.After(5 * time.Second)
-	for a.IsBusy() {
-		select {
-		case <-timeout:
-			return
-		default:
-			time.Sleep(200 * time.Millisecond)
-		}
-	}
-}
-
-func (a *agent) UpdateModel() error {
-	cfg := config.Get()
-
-	// Get current provider configuration
-	currentProviderCfg := cfg.GetProviderForModel(a.agentCfg.Model)
-	if currentProviderCfg == nil || currentProviderCfg.ID == "" {
-		return fmt.Errorf("provider for agent %s not found in config", a.agentCfg.Name)
-	}
-
-	// Check if provider has changed
-	if string(currentProviderCfg.ID) != a.providerID {
-		// Provider changed, need to recreate the main provider
-		model := cfg.GetModelByType(a.agentCfg.Model)
-		if model.ID == "" {
-			return fmt.Errorf("model not found for agent %s", a.agentCfg.Name)
-		}
-
-		promptID := agentPromptMap[a.agentCfg.ID]
-		if promptID == "" {
-			promptID = prompt.PromptDefault
-		}
-
-		opts := []provider.ProviderClientOption{
-			provider.WithModel(a.agentCfg.Model),
-			provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID, cfg.Options.ContextPaths...)),
-		}
-
-		newProvider, err := provider.NewProvider(*currentProviderCfg, opts...)
-		if err != nil {
-			return fmt.Errorf("failed to create new provider: %w", err)
-		}
-
-		// Update the provider and provider ID
-		a.provider = newProvider
-		a.providerID = string(currentProviderCfg.ID)
-	}
-
-	// Check if providers have changed for title (small) and summarize (large)
-	smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
-	var smallModelProviderCfg config.ProviderConfig
-	for p := range cfg.Providers.Seq() {
-		if p.ID == smallModelCfg.Provider {
-			smallModelProviderCfg = p
-			break
-		}
-	}
-	if smallModelProviderCfg.ID == "" {
-		return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
-	}
-
-	largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
-	var largeModelProviderCfg config.ProviderConfig
-	for p := range cfg.Providers.Seq() {
-		if p.ID == largeModelCfg.Provider {
-			largeModelProviderCfg = p
-			break
-		}
-	}
-	if largeModelProviderCfg.ID == "" {
-		return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
-	}
-
-	var maxTitleTokens int64 = 40
-
-	// if the max output is too low for the gemini provider it won't return anything
-	if smallModelCfg.Provider == "gemini" {
-		maxTitleTokens = 1000
-	}
-	// Recreate title provider
-	titleOpts := []provider.ProviderClientOption{
-		provider.WithModel(config.SelectedModelTypeSmall),
-		provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
-		provider.WithMaxTokens(maxTitleTokens),
-	}
-	newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
-	if err != nil {
-		return fmt.Errorf("failed to create new title provider: %w", err)
-	}
-	a.titleProvider = newTitleProvider
-
-	// Recreate summarize provider if provider changed (now large model)
-	if string(largeModelProviderCfg.ID) != a.summarizeProviderID {
-		largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge)
-		if largeModel == nil {
-			return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID)
-		}
-		summarizeOpts := []provider.ProviderClientOption{
-			provider.WithModel(config.SelectedModelTypeLarge),
-			provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
-		}
-		newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
-		if err != nil {
-			return fmt.Errorf("failed to create new summarize provider: %w", err)
-		}
-		a.summarizeProvider = newSummarizeProvider
-		a.summarizeProviderID = string(largeModelProviderCfg.ID)
-	}
-
-	return nil
-}
-
-func (a *agent) setupEvents(ctx context.Context) {
-	ctx, cancel := context.WithCancel(ctx)
-
-	go func() {
-		subCh := SubscribeMCPEvents(ctx)
-
-		for {
-			select {
-			case event, ok := <-subCh:
-				if !ok {
-					slog.Debug("MCPEvents subscription channel closed")
-					return
-				}
-				switch event.Payload.Type {
-				case MCPEventToolsListChanged:
-					name := event.Payload.Name
-					c, ok := mcpClients.Get(name)
-					if !ok {
-						slog.Warn("MCP client not found for tools update", "name", name)
-						continue
-					}
-					cfg := config.Get()
-					tools, err := getTools(ctx, name, a.permissions, c, cfg.WorkingDir())
-					if err != nil {
-						slog.Error("error listing tools", "error", err)
-						updateMCPState(name, MCPStateError, err, nil, 0)
-						_ = c.Close()
-						continue
-					}
-					updateMcpTools(name, tools)
-					a.mcpTools.Reset(maps.Collect(mcpTools.Seq2()))
-					updateMCPState(name, MCPStateConnected, nil, c, a.mcpTools.Len())
-				default:
-					continue
-				}
-			case <-ctx.Done():
-				slog.Debug("MCPEvents subscription cancelled")
-				return
-			}
-		}
-	}()
-
-	a.cleanupFuncs = append(a.cleanupFuncs, cancel)
-}

internal/llm/agent/event.go πŸ”—

@@ -1,53 +0,0 @@
-package agent
-
-import (
-	"time"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/llm/provider"
-)
-
-func (a *agent) eventPromptSent(sessionID string) {
-	event.PromptSent(
-		a.eventCommon(sessionID)...,
-	)
-}
-
-func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) {
-	event.PromptResponded(
-		append(
-			a.eventCommon(sessionID),
-			"prompt duration pretty", duration.String(),
-			"prompt duration in seconds", int64(duration.Seconds()),
-		)...,
-	)
-}
-
-func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) {
-	event.TokensUsed(
-		append(
-			a.eventCommon(sessionID),
-			"input tokens", usage.InputTokens,
-			"output tokens", usage.OutputTokens,
-			"cache read tokens", usage.CacheReadTokens,
-			"cache creation tokens", usage.CacheCreationTokens,
-			"total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
-			"cost", cost,
-		)...,
-	)
-}
-
-func (a *agent) eventCommon(sessionID string) []any {
-	cfg := config.Get()
-	currentModel := cfg.Models[cfg.Agents["coder"].Model]
-
-	return []any{
-		"session id", sessionID,
-		"provider", currentModel.Provider,
-		"model", currentModel.Model,
-		"reasoning effort", currentModel.ReasoningEffort,
-		"thinking mode", currentModel.Think,
-		"yolo mode", a.permissions.SkipRequests(),
-	}
-}

internal/llm/prompt/anthropic.md πŸ”—

@@ -1,108 +0,0 @@
-You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
-
-IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
-
-# Memory
-
-If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
-
-1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
-2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
-3. Maintaining useful information about the codebase structure and organization
-
-When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
-
-# Tone and style
-
-You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
-Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
-Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
-If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
-IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
-IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
-IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
-<example>
-user: 2 + 2
-assistant: 4
-</example>
-
-<example>
-user: what is 2+2?
-assistant: 4
-</example>
-
-<example>
-user: is 11 a prime number?
-assistant: true
-</example>
-
-<example>
-user: what command should I run to list files in the current directory?
-assistant: ls
-</example>
-
-<example>
-user: what command should I run to watch files in the current directory?
-assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
-npm run dev
-</example>
-
-<example>
-user: How many golf balls fit inside a jetta?
-assistant: 150000
-</example>
-
-<example>
-user: what files are in the directory src/?
-assistant: [runs ls and sees foo.c, bar.c, baz.c]
-user: which file contains the implementation of foo?
-assistant: src/foo.c
-</example>
-
-<example>
-user: write tests for new feature
-assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
-</example>
-
-# Proactiveness
-
-You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
-
-1. Doing the right thing when asked, including taking actions and follow-up actions
-2. Not surprising the user with actions you take without asking
-   For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
-3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
-
-# Following conventions
-
-When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
-
-- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
-- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
-- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
-- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
-
-# Code style
-
-- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
-
-# Doing tasks
-
-The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
-1. Use the available search tools to understand the codebase and the user's query.
-2. Implement the solution using all tools available to you
-3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
-4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
-
-NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
-
-# Tool usage policy
-
-- When doing file search, prefer to use the Agent tool in order to reduce context usage.
-- IMPORTANT: All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
-- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
-
-VERY IMPORTANT NEVER use emojis in your responses.
-
-You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.

internal/llm/prompt/coder.go πŸ”—

@@ -1,100 +0,0 @@
-package prompt
-
-import (
-	_ "embed"
-	"fmt"
-	"os"
-	"path/filepath"
-	"runtime"
-	"strconv"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-)
-
-func CoderPrompt(p string, contextFiles ...string) string {
-	var basePrompt string
-
-	basePrompt = string(anthropicCoderPrompt)
-	switch p {
-	case string(catwalk.InferenceProviderOpenAI):
-		// seems to behave better
-		basePrompt = string(coderV2Prompt)
-	case string(catwalk.InferenceProviderGemini):
-		basePrompt = string(geminiCoderPrompt)
-	}
-	if ok, _ := strconv.ParseBool(os.Getenv("CRUSH_CODER_V2")); ok {
-		basePrompt = string(coderV2Prompt)
-	}
-	envInfo := getEnvironmentInfo()
-
-	basePrompt = fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
-
-	contextContent := getContextFromPaths(config.Get().WorkingDir(), contextFiles)
-	if contextContent != "" {
-		return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
-	}
-	return basePrompt
-}
-
-//go:embed anthropic.md
-var anthropicCoderPrompt []byte
-
-//go:embed gemini.md
-var geminiCoderPrompt []byte
-
-//go:embed v2.md
-var coderV2Prompt []byte
-
-func getEnvironmentInfo() string {
-	cwd := config.Get().WorkingDir()
-	isGit := isGitRepo(cwd)
-	platform := runtime.GOOS
-	date := time.Now().Format("1/2/2006")
-	output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
-	return fmt.Sprintf(`Here is useful information about the environment you are running in:
-<env>
-Working directory: %s
-Is directory a git repo: %s
-Platform: %s
-Today's date: %s
-</env>
-<project>
-%s
-</project>
-		`, cwd, boolToYesNo(isGit), platform, date, output)
-}
-
-func isGitRepo(dir string) bool {
-	_, err := os.Stat(filepath.Join(dir, ".git"))
-	return err == nil
-}
-
-func lspInformation() string {
-	cfg := config.Get()
-	hasLSP := false
-	for _, v := range cfg.LSP {
-		if !v.Disabled {
-			hasLSP = true
-			break
-		}
-	}
-	if !hasLSP {
-		return ""
-	}
-	return `# LSP Information
-Tools that support it will also include useful diagnostics such as linting and typechecking.
-- These diagnostics will be automatically enabled when you run the tool, and will be displayed in the output at the bottom within the <file_diagnostics></file_diagnostics> and <project_diagnostics></project_diagnostics> tags.
-- Take necessary actions to fix the issues.
-- You should ignore diagnostics of files that you did not change or are not related or caused by your changes unless the user explicitly asks you to fix them.
-`
-}
-
-func boolToYesNo(b bool) string {
-	if b {
-		return "Yes"
-	}
-	return "No"
-}

internal/llm/prompt/gemini.md πŸ”—

@@ -1,165 +0,0 @@
-You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
-
-IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
-
-# Memory
-
-If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
-
-1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
-2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
-3. Maintaining useful information about the codebase structure and organization
-
-When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
-
-# Core Mandates
-
-- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
-- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
-- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
-- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically.
-- **Comments:** Add code comments sparingly. Focus on _why_ something is done, especially for complex logic, rather than _what_ is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. _NEVER_ talk to the user or describe your changes through comments.
-- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
-- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked _how_ to do something, explain first, don't just do it.
-- **Explaining Changes:** After completing a code modification or file operation _do not_ provide summaries unless asked.
-- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
-
-# Code style
-
-- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
-
-# Primary Workflows
-
-## Software Engineering Tasks
-
-When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
-
-1. **Understand:** Think about the user's request and the relevant codebase context. Use `grep` and `glob` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use `view` to understand context and validate any assumptions you may have.
-2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
-3. **Implement:** Use the available tools (e.g., `edit`, `write` `bash` ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
-4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
-5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
-
-NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
-
-# Operational Guidelines
-
-## Tone and Style (CLI Interaction)
-
-- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
-- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
-- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.
-- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
-- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
-- **Tools vs. Text:** Use tools for actions, text output _only_ for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
-- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
-
-## Security and Safety Rules
-
-- **Explain Critical Commands:** Before executing commands with `bash` that modify the file system, codebase, or system state, you _must_ provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety.
-- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
-
-## Tool Usage
-
-- **File Paths:** Always use absolute paths when referring to files with tools like `view` or `write`. Relative paths are not supported. You must provide an absolute path.
-- **Parallelism:** IMPORTANT: All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
-- **Command Execution:** Use the `bash` tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** Use background processes (via `&`) for commands that are unlikely to stop on their own, e.g. `node server.js &`. If unsure, ask the user.
-- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. `git rebase -i`). Use non-interactive versions of commands (e.g. `npm init -y` instead of `npm init`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
-
-# Examples (Illustrating Tone and Workflow)
-
-<example>
-user: 1 + 2
-model: 3
-</example>
-
-<example>
-user: is 13 a prime number?
-model: true
-</example>
-
-<example>
-user: list files here.
-model: [tool_call: ls for path '.']
-</example>
-
-<example>
-user: start the server implemented in server.js
-model: [tool_call: bash for 'node server.js &' because it must run in the background]
-</example>
-
-<example>
-user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
-model: Okay, I can refactor 'src/auth.py'.
-First, I'll analyze the code and check for a test safety net before planning any changes.
-[tool_call: glob for pattern 'tests/test_auth.py']
-[tool_call: view for absolute_path '/path/to/tests/test_auth.py']
-(After analysis)
-Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
-I'll also confirm 'requests' is a dependency.
-[tool_call: view for absolute_path '/path/to/requirements.txt']
-(After analysis)
-Looks good, 'requests' is available.
-
-Here's the plan:
-
-1. Replace the 'urllib' calls with 'requests'.
-2. Add proper 'try...except' error handling for the new network calls.
-3. Remove the old 'urllib' import.
-4. Run the project's linter and tests to verify the changes.
-
-Should I proceed?
-user: Yes
-model:
-[tool_call: edit to apply the refactoring to 'src/auth.py']
-Refactoring complete. Running verification...
-[tool_call: bash for 'ruff check src/auth.py && pytest']
-(After verification passes)
-All checks passed. This is a stable checkpoint.
-</example>
-
-<example>
-user: Delete the temp directory.
-model: I can run `rm -rf ./temp`. This will permanently delete the directory and all its contents.
-</example>
-
-<example>
-user: Write tests for someFile.ts
-model:
-Okay, I can write those tests. First, I'll read `someFile.ts` to understand its functionality.
-[tool_call: view for absolute_path '/path/to/someFile.ts' or use glob to find `someFile.ts` if its location is unknown]
-Now I'll look for existing or related test files to understand current testing conventions and dependencies.
-[tool_call: glob for pattern '**/*.test.ts' and '**/*.spec.ts']
-(After reviewing existing tests and the file content)
-[tool_call: write to create /path/to/someFile.test.ts with the test code]
-I've written the tests. Now I'll run the project's test command to verify them.
-[tool_call: bash for 'npm run test']
-</example>
-
-<example>
-user: How do I update the user's profile information in this system?
-model:
-I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
-[tool_call: grep for pattern 'UserProfile|updateProfile|editUser']
-(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
-Okay, `/path/to/UserProfileService.java` seems like the most relevant file. I'll read its content to understand how updates are handled.
-[tool_call: view for absolute_path '/path/to/UserProfileService.java']
-(After reading the file)
-It appears the `updateUserProfile` method in `UserProfileService.java` is responsible for this. It expects a user ID and a `UserProfileDTO` object...
-</example>
-
-<example>
-user: Where are all the 'app.config' files in this project? I need to check their settings.
-model:
-[tool_call: glob for pattern '**/app.config']
-(Assuming glob returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
-I found the following 'app.config' files:
-- /path/to/moduleA/app.config
-- /path/to/moduleB/app.config
-To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
-</example>
-
-# Final Reminder
-
-Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use `view` to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.

internal/llm/prompt/init.md πŸ”—

@@ -1,9 +0,0 @@
-`Please analyze this codebase and create a **CRUSH.md** file containing:
-
-- Build/lint/test commands - especially for running a single test
-- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
-
-The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long.
-If there's already a **CRUSH.md**, improve it.
-
-If there are Cursor rules (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.

internal/llm/prompt/prompt.go πŸ”—

@@ -1,143 +0,0 @@
-package prompt
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"sync"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/env"
-	"github.com/charmbracelet/crush/internal/home"
-)
-
-type PromptID string
-
-const (
-	PromptCoder      PromptID = "coder"
-	PromptTitle      PromptID = "title"
-	PromptTask       PromptID = "task"
-	PromptSummarizer PromptID = "summarizer"
-	PromptDefault    PromptID = "default"
-)
-
-func GetPrompt(promptID PromptID, provider string, contextPaths ...string) string {
-	basePrompt := ""
-	switch promptID {
-	case PromptCoder:
-		basePrompt = CoderPrompt(provider, contextPaths...)
-	case PromptTitle:
-		basePrompt = TitlePrompt()
-	case PromptTask:
-		basePrompt = TaskPrompt()
-	case PromptSummarizer:
-		basePrompt = SummarizerPrompt()
-	default:
-		basePrompt = "You are a helpful assistant"
-	}
-	return basePrompt
-}
-
-func getContextFromPaths(workingDir string, contextPaths []string) string {
-	return processContextPaths(workingDir, contextPaths)
-}
-
-// expandPath expands ~ and environment variables in file paths
-func expandPath(path string) string {
-	path = home.Long(path)
-
-	// Handle environment variable expansion using the same pattern as config
-	if strings.HasPrefix(path, "$") {
-		resolver := config.NewEnvironmentVariableResolver(env.New())
-		if expanded, err := resolver.ResolveValue(path); err == nil {
-			path = expanded
-		}
-	}
-
-	return path
-}
-
-func processContextPaths(workDir string, paths []string) string {
-	var (
-		wg       sync.WaitGroup
-		resultCh = make(chan string)
-	)
-
-	// Track processed files to avoid duplicates
-	processedFiles := csync.NewMap[string, bool]()
-
-	for _, path := range paths {
-		wg.Add(1)
-		go func(p string) {
-			defer wg.Done()
-
-			// Expand ~ and environment variables before processing
-			p = expandPath(p)
-
-			// Use absolute path if provided, otherwise join with workDir
-			fullPath := p
-			if !filepath.IsAbs(p) {
-				fullPath = filepath.Join(workDir, p)
-			}
-
-			// Check if the path is a directory using os.Stat
-			info, err := os.Stat(fullPath)
-			if err != nil {
-				return // Skip if path doesn't exist or can't be accessed
-			}
-
-			if info.IsDir() {
-				filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
-					if err != nil {
-						return err
-					}
-					if !d.IsDir() {
-						// Check if we've already processed this file (case-insensitive)
-						lowerPath := strings.ToLower(path)
-
-						if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
-							processedFiles.Set(lowerPath, true)
-							if result := processFile(path); result != "" {
-								resultCh <- result
-							}
-						}
-					}
-					return nil
-				})
-			} else {
-				// It's a file, process it directly
-				// Check if we've already processed this file (case-insensitive)
-				lowerPath := strings.ToLower(fullPath)
-
-				if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
-					processedFiles.Set(lowerPath, true)
-					result := processFile(fullPath)
-					if result != "" {
-						resultCh <- result
-					}
-				}
-			}
-		}(path)
-	}
-
-	go func() {
-		wg.Wait()
-		close(resultCh)
-	}()
-
-	results := make([]string, 0)
-	for result := range resultCh {
-		results = append(results, result)
-	}
-
-	return strings.Join(results, "\n")
-}
-
-func processFile(filePath string) string {
-	content, err := os.ReadFile(filePath)
-	if err != nil {
-		return ""
-	}
-	return "# From:" + filePath + "\n" + string(content)
-}

internal/llm/prompt/prompt_test.go πŸ”—

@@ -1,69 +0,0 @@
-package prompt
-
-import (
-	"os"
-	"strings"
-	"testing"
-
-	"github.com/charmbracelet/crush/internal/home"
-)
-
-func TestExpandPath(t *testing.T) {
-	tests := []struct {
-		name     string
-		input    string
-		expected func() string
-	}{
-		{
-			name:  "regular path unchanged",
-			input: "/absolute/path",
-			expected: func() string {
-				return "/absolute/path"
-			},
-		},
-		{
-			name:  "tilde expansion",
-			input: "~/documents",
-			expected: func() string {
-				return home.Dir() + "/documents"
-			},
-		},
-		{
-			name:  "tilde only",
-			input: "~",
-			expected: func() string {
-				return home.Dir()
-			},
-		},
-		{
-			name:  "environment variable expansion",
-			input: "$HOME",
-			expected: func() string {
-				return os.Getenv("HOME")
-			},
-		},
-		{
-			name:  "relative path unchanged",
-			input: "relative/path",
-			expected: func() string {
-				return "relative/path"
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			result := expandPath(tt.input)
-			expected := tt.expected()
-
-			// Skip test if environment variable is not set
-			if strings.HasPrefix(tt.input, "$") && expected == "" {
-				t.Skip("Environment variable not set")
-			}
-
-			if result != expected {
-				t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, expected)
-			}
-		})
-	}
-}

internal/llm/prompt/summarize.md πŸ”—

@@ -1,11 +0,0 @@
-You are a helpful AI assistant tasked with summarizing conversations.
-
-When asked to summarize, provide a detailed but concise summary of the conversation.
-Focus on information that would be helpful for continuing the conversation, including:
-
-- What was done
-- What is currently being worked on
-- Which files are being modified
-- What needs to be done next
-
-Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.

internal/llm/prompt/summarizer.go πŸ”—

@@ -1,10 +0,0 @@
-package prompt
-
-import _ "embed"
-
-//go:embed summarize.md
-var summarizePrompt []byte
-
-func SummarizerPrompt() string {
-	return string(summarizePrompt)
-}

internal/llm/prompt/task.go πŸ”—

@@ -1,15 +0,0 @@
-package prompt
-
-import (
-	"fmt"
-)
-
-func TaskPrompt() string {
-	agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
-Notes:
-1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
-2. When relevant, share file names and code snippets relevant to the query
-3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.`
-
-	return fmt.Sprintf("%s\n%s\n", agentPrompt, getEnvironmentInfo())
-}

internal/llm/prompt/title.go πŸ”—

@@ -1,10 +0,0 @@
-package prompt
-
-import _ "embed"
-
-//go:embed title.md
-var titlePrompt []byte
-
-func TitlePrompt() string {
-	return string(titlePrompt)
-}

internal/llm/prompt/v2.md πŸ”—

@@ -1,267 +0,0 @@
-You are Crush, an autonomous software engineering agent that helps users with coding tasks. Use the instructions below and the tools available to you to assist the user.
-
-# Core Principles
-
-You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user.
-
-Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
-
-You MUST iterate and keep going until the problem is solved.
-
-You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
-
-Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
-
-**IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames, directory structure, and existing codebase patterns.**
-
-When the user provides URLs or when you need to research external information, use the fetch tool to gather that information. If you find relevant links in the fetched content, follow them to gather comprehensive information.
-
-When working with third-party packages, libraries, or frameworks that you're unfamiliar with or need to verify usage patterns for, you can use the Sourcegraph tool to search for code examples across public repositories. This can help you understand best practices and common implementation patterns.
-
-Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
-
-If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
-
-Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking approach if needed. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
-
-You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
-
-You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
-
-You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
-
-# Proactiveness and Balance
-
-You should strive to strike a balance between:
-
-1. Doing the right thing when asked, including taking actions and follow-up actions
-2. Not surprising the user with actions you take without asking
-3. Being thorough and autonomous while staying focused on the user's actual request
-
-For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. However, when they ask you to solve a problem or implement something, be proactive in completing the entire task.
-
-# Workflow
-
-1. **Understand the Context**: Think about what the code you're editing is supposed to do based on filenames, directory structure, and existing patterns.
-2. **Fetch URLs**: Fetch any URLs provided by the user using the `fetch` tool.
-3. **Deep Problem Understanding**: Carefully read the issue and think critically about what is required.
-4. **Codebase Investigation**: Explore relevant files, search for key functions, and gather context.
-5. **Research**: If needed, research the problem using available tools.
-6. **Plan Development**: Develop a clear, step-by-step plan with a todo list.
-7. **Incremental Implementation**: Make small, testable code changes.
-8. **Debug and Test**: Debug as needed and test frequently.
-9. **Iterate**: Continue until the root cause is fixed and all tests pass.
-10. **Comprehensive Validation**: Reflect and validate thoroughly after tests pass.
-
-Refer to the detailed sections below for more information on each step.
-
-## 1. Understanding Context and Fetching URLs
-
-- **Context First**: Before diving into code, understand what the existing code is supposed to do based on file names, directory structure, imports, and existing patterns.
-- **URL Fetching**: If the user provides a URL, use the `fetch` tool to retrieve the content.
-- **Recursive Information Gathering**: If you find additional relevant URLs or links, fetch those as well until you have all necessary information.
-
-## 2. Deep Problem Understanding
-
-Carefully read the issue and think hard about a plan to solve it before coding. Consider:
-
-- What is the expected behavior?
-- What are the edge cases?
-- What are the potential pitfalls?
-- How does this fit into the larger context of the codebase?
-- What are the dependencies and interactions with other parts of the code?
-
-## 3. Codebase Investigation
-
-- Explore relevant files and directories using `ls`, `view`, `glob`, and `grep` tools.
-- Search for key functions, classes, or variables related to the issue.
-- Read and understand relevant code snippets.
-- Identify the root cause of the problem.
-- Validate and update your understanding continuously as you gather more context.
-
-## 4. Research When Needed
-
-- Use the `sourcegraph` tool when you need to find code examples or verify usage patterns for libraries/frameworks.
-- Use the `fetch` tool to retrieve documentation or other web resources.
-- Look for patterns, best practices, and implementation examples.
-- Focus your research on what's necessary to solve the specific problem at hand.
-
-## 5. Develop a Detailed Plan
-
-- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
-- Create a todo list in markdown format to track your progress.
-- Each time you complete a step, check it off using `[x]` syntax.
-- Each time you check off a step, display the updated todo list to the user.
-- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn.
-
-## 6. Making Code Changes
-
-- Before editing, always read the relevant file contents or section to ensure complete context using the `view` tool.
-- Always read at least 2000 lines of code at a time to ensure you have enough context.
-- If a patch is not applied correctly, attempt to reapply it.
-- Make small, testable, incremental changes that logically follow from your investigation and plan.
-- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.
-- Prefer using the `multiedit` tool when making multiple edits to the same file.
-
-## 7. Debugging and Testing
-
-- Use the `bash` tool to run commands and check for errors.
-- Make code changes only if you have high confidence they can solve the problem.
-- When debugging, try to determine the root cause rather than addressing symptoms.
-- Debug for as long as needed to identify the root cause and identify a fix.
-- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening.
-- To test hypotheses, you can also add test statements or functions.
-- Revisit your assumptions if unexpected behavior occurs.
-- **Test rigorously and frequently** - this is critical for success.
-
-# Memory
-
-If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
-
-1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
-2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
-3. Maintaining useful information about the codebase structure and organization
-
-When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
-
-# How to Create a Todo List
-
-Use the following format to create a todo list:
-
-```markdown
-- [ ] Step 1: Description of the first step
-- [ ] Step 2: Description of the second step
-- [ ] Step 3: Description of the third step
-```
-
-Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above. Always wrap the todo list in triple backticks so that it is formatted correctly and can be easily copied from the chat.
-
-Always show the completed todo list to the user as the last item in your message, so that they can see that you have addressed all of the steps.
-
-# Communication Guidelines
-
-Always communicate clearly and concisely in a casual, friendly yet professional tone.
-
-<examples>
-"Let me fetch the URL you provided to gather more information."
-"Ok, I've got all of the information I need on the API and I know how to use it."
-"Now, I will search the codebase for the function that handles the API requests."
-"I need to update several files here - stand by"
-"OK! Now let's run the tests to make sure everything is working correctly."
-"Whelp - I see we have some problems. Let's fix those up."
-</examples>
-
-- Respond with clear, direct answers. Use bullet points and code blocks for structure.
-- Avoid unnecessary explanations, repetition, and filler.
-- Always write code directly to the correct files.
-- Do not display code to the user unless they specifically ask for it.
-- Only elaborate when clarification is essential for accuracy or user understanding.
-
-# Tone and Style
-
-You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
-
-Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
-
-Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
-
-If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
-
-IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request.
-
-IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
-
-VERY IMPORTANT: NEVER use emojis in your responses.
-
-# Following Conventions
-
-When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
-
-- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
-- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
-- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
-- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
-
-# Code Style
-
-- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
-
-# Task Execution
-
-The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
-1. Use the available search tools to understand the codebase and the user's query.
-2. Implement the solution using all tools available to you
-3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
-4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
-
-NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
-
-# Tool Usage Policy
-
-- When doing file search, prefer to use the Agent tool in order to reduce context usage.
-- **IMPORTANT**: If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel for efficiency.
-- **IMPORTANT**: The user does not see the full output of the tool responses, so if you need the output of the tool for your response, make sure to summarize it for the user.
-- All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
-
-# Reading Files and Folders
-
-**Always check if you have already read a file, folder, or workspace structure before reading it again.**
-
-- If you have already read the content and it has not changed, do NOT re-read it.
-- Only re-read files or folders if:
-  - You suspect the content has changed since your last read.
-  - You have made edits to the file or folder.
-  - You encounter an error that suggests the context may be stale or incomplete.
-- Use your internal memory and previous context to avoid redundant reads.
-- This will save time, reduce unnecessary operations, and make your workflow more efficient.
-
-# Directory Context and Navigation
-
-**Always maintain awareness of your current working directory by tracking it mentally from the command history.**
-
-- **Remember directory changes**: When you use `cd` to change directories, mentally note and remember the new location for all subsequent operations.
-- **Track your location from context**: Use the command history and previous `cd` commands to know where you currently are without constantly checking.
-- **Check location only when commands fail**: If a command fails unexpectedly with file/path errors, then use `pwd` to verify your current directory as the failure might be due to being in the wrong location.
-- **Use relative paths confidently**: Once you know your location, use relative paths appropriately based on your mental model of the current directory.
-- **Maintain directory awareness across operations**: Keep track of where you are throughout a multi-step task, especially when working with files in different directories.
-
-**When to verify with `pwd`:**
-
-- After a command fails with "file not found" or similar path-related or `exit status 1` errors
-- When resuming work or continuing from a previous step if uncertain
-- When you realize you may have lost track of your current location
-
-**Mental tracking example:**
-
-```bash
-# You start in /project/root
-cd src/components  # Now mentally note: I'm in /project/root/src/components
-# Work with files here using relative paths
-ls ./Button.tsx  # This should work because I know I'm in components/
-# If this fails, THEN run pwd to double-check location
-```
-
-# Git and Version Control
-
-If the user tells you to stage and commit, you may do so.
-
-You are NEVER allowed to stage and commit files automatically. Only do this when explicitly requested.
-
-# Error Handling and Recovery
-
-- When you encounter errors, don't give up - analyze the error carefully and try alternative approaches.
-- If a tool fails, try a different tool or approach to accomplish the same goal.
-- When debugging, be systematic: isolate the problem, test hypotheses, and iterate until resolved.
-- Always validate your solutions work correctly before considering the task complete.
-
-# Final Validation
-
-Before completing any task:
-
-1. Ensure all todo items are checked off
-2. Run all relevant tests
-3. Run linting and type checking if available
-4. Verify the original problem is solved
-5. Test edge cases and boundary conditions
-6. Confirm no regressions were introduced

internal/llm/provider/anthropic.go πŸ”—

@@ -1,598 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/http"
-	"regexp"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/anthropic-sdk-go"
-	"github.com/charmbracelet/anthropic-sdk-go/bedrock"
-	"github.com/charmbracelet/anthropic-sdk-go/option"
-	"github.com/charmbracelet/anthropic-sdk-go/vertex"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/message"
-)
-
-// Pre-compiled regex for parsing context limit errors.
-var contextLimitRegex = regexp.MustCompile(`input length and ` + "`max_tokens`" + ` exceed context limit: (\d+) \+ (\d+) > (\d+)`)
-
-type anthropicClient struct {
-	providerOptions   providerClientOptions
-	tp                AnthropicClientType
-	client            anthropic.Client
-	adjustedMaxTokens int // Used when context limit is hit
-}
-
-type AnthropicClient ProviderClient
-
-type AnthropicClientType string
-
-const (
-	AnthropicClientTypeNormal  AnthropicClientType = "normal"
-	AnthropicClientTypeBedrock AnthropicClientType = "bedrock"
-	AnthropicClientTypeVertex  AnthropicClientType = "vertex"
-)
-
-func newAnthropicClient(opts providerClientOptions, tp AnthropicClientType) AnthropicClient {
-	return &anthropicClient{
-		providerOptions: opts,
-		tp:              tp,
-		client:          createAnthropicClient(opts, tp),
-	}
-}
-
-func createAnthropicClient(opts providerClientOptions, tp AnthropicClientType) anthropic.Client {
-	anthropicClientOptions := []option.RequestOption{}
-
-	// Check if Authorization header is provided in extra headers
-	hasBearerAuth := false
-	if opts.extraHeaders != nil {
-		for key := range opts.extraHeaders {
-			if strings.ToLower(key) == "authorization" {
-				hasBearerAuth = true
-				break
-			}
-		}
-	}
-
-	isBearerToken := strings.HasPrefix(opts.apiKey, "Bearer ")
-
-	if opts.apiKey != "" && !hasBearerAuth {
-		if isBearerToken {
-			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
-			anthropicClientOptions = append(anthropicClientOptions, option.WithHeader("Authorization", opts.apiKey))
-		} else {
-			// Use standard X-Api-Key header
-			anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
-		}
-	} else if hasBearerAuth {
-		slog.Debug("Skipping X-Api-Key header because Authorization header is provided")
-	}
-
-	if opts.baseURL != "" {
-		resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
-		if err == nil && resolvedBaseURL != "" {
-			anthropicClientOptions = append(anthropicClientOptions, option.WithBaseURL(resolvedBaseURL))
-		}
-	}
-
-	if config.Get().Options.Debug {
-		httpClient := log.NewHTTPClient()
-		anthropicClientOptions = append(anthropicClientOptions, option.WithHTTPClient(httpClient))
-	}
-
-	switch tp {
-	case AnthropicClientTypeBedrock:
-		anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
-	case AnthropicClientTypeVertex:
-		project := opts.extraParams["project"]
-		location := opts.extraParams["location"]
-		anthropicClientOptions = append(anthropicClientOptions, vertex.WithGoogleAuth(context.Background(), location, project))
-	}
-	for key, header := range opts.extraHeaders {
-		anthropicClientOptions = append(anthropicClientOptions, option.WithHeaderAdd(key, header))
-	}
-	for key, value := range opts.extraBody {
-		anthropicClientOptions = append(anthropicClientOptions, option.WithJSONSet(key, value))
-	}
-	return anthropic.NewClient(anthropicClientOptions...)
-}
-
-func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
-	for i, msg := range messages {
-		cache := false
-		if i > len(messages)-3 {
-			cache = true
-		}
-		switch msg.Role {
-		case message.User:
-			content := anthropic.NewTextBlock(msg.Content().String())
-			if cache && !a.providerOptions.disableCache {
-				content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
-					Type: "ephemeral",
-				}
-			}
-			var contentBlocks []anthropic.ContentBlockParamUnion
-			contentBlocks = append(contentBlocks, content)
-			for _, binaryContent := range msg.BinaryContent() {
-				base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
-				imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
-				contentBlocks = append(contentBlocks, imageBlock)
-			}
-			anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
-
-		case message.Assistant:
-			blocks := []anthropic.ContentBlockParamUnion{}
-
-			// Add thinking blocks first if present (required when thinking is enabled with tool use)
-			if reasoningContent := msg.ReasoningContent(); reasoningContent.Thinking != "" {
-				thinkingBlock := anthropic.NewThinkingBlock(reasoningContent.Signature, reasoningContent.Thinking)
-				blocks = append(blocks, thinkingBlock)
-			}
-
-			if msg.Content().String() != "" {
-				content := anthropic.NewTextBlock(msg.Content().String())
-				if cache && !a.providerOptions.disableCache {
-					content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
-						Type: "ephemeral",
-					}
-				}
-				blocks = append(blocks, content)
-			}
-
-			for _, toolCall := range msg.ToolCalls() {
-				if !toolCall.Finished {
-					continue
-				}
-				var inputMap map[string]any
-				err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
-				if err != nil {
-					continue
-				}
-				blocks = append(blocks, anthropic.NewToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
-			}
-
-			if len(blocks) == 0 {
-				continue
-			}
-			anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
-
-		case message.Tool:
-			results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
-			for i, toolResult := range msg.ToolResults() {
-				results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
-			}
-			anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
-		}
-	}
-	return anthropicMessages
-}
-
-func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
-	if len(tools) == 0 {
-		return nil
-	}
-	anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
-
-	for i, tool := range tools {
-		info := tool.Info()
-		toolParam := anthropic.ToolParam{
-			Name:        info.Name,
-			Description: anthropic.String(info.Description),
-			InputSchema: anthropic.ToolInputSchemaParam{
-				Properties: info.Parameters,
-				Required:   info.Required,
-			},
-		}
-
-		if i == len(tools)-1 && !a.providerOptions.disableCache {
-			toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
-				Type: "ephemeral",
-			}
-		}
-
-		anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
-	}
-
-	return anthropicTools
-}
-
-func (a *anthropicClient) finishReason(reason string) message.FinishReason {
-	switch reason {
-	case "end_turn":
-		return message.FinishReasonEndTurn
-	case "max_tokens":
-		return message.FinishReasonMaxTokens
-	case "tool_use":
-		return message.FinishReasonToolUse
-	case "stop_sequence":
-		return message.FinishReasonEndTurn
-	default:
-		return message.FinishReasonUnknown
-	}
-}
-
-func (a *anthropicClient) isThinkingEnabled() bool {
-	cfg := config.Get()
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if a.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-	return a.Model().CanReason && modelConfig.Think
-}
-
-func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
-	model := a.providerOptions.model(a.providerOptions.modelType)
-	var thinkingParam anthropic.ThinkingConfigParamUnion
-	cfg := config.Get()
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if a.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-	temperature := anthropic.Float(0)
-
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-	if a.isThinkingEnabled() {
-		thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(maxTokens) * 0.8))
-		temperature = anthropic.Float(1)
-	}
-	// Override max tokens if set in provider options
-	if a.providerOptions.maxTokens > 0 {
-		maxTokens = a.providerOptions.maxTokens
-	}
-
-	// Use adjusted max tokens if context limit was hit
-	if a.adjustedMaxTokens > 0 {
-		maxTokens = int64(a.adjustedMaxTokens)
-	}
-
-	systemBlocks := []anthropic.TextBlockParam{}
-
-	// Add custom system prompt prefix if configured
-	if a.providerOptions.systemPromptPrefix != "" {
-		systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
-			Text: a.providerOptions.systemPromptPrefix,
-		})
-	}
-
-	systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
-		Text: a.providerOptions.systemMessage,
-		CacheControl: anthropic.CacheControlEphemeralParam{
-			Type: "ephemeral",
-		},
-	})
-
-	return anthropic.MessageNewParams{
-		Model:       anthropic.Model(model.ID),
-		MaxTokens:   maxTokens,
-		Temperature: temperature,
-		Messages:    messages,
-		Tools:       tools,
-		Thinking:    thinkingParam,
-		System:      systemBlocks,
-	}
-}
-
-func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
-	attempts := 0
-	for {
-		attempts++
-		// Prepare messages on each attempt in case max_tokens was adjusted
-		preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
-
-		var opts []option.RequestOption
-		if a.isThinkingEnabled() {
-			opts = append(opts, option.WithHeaderAdd("anthropic-beta", "interleaved-thinking-2025-05-14"))
-		}
-		anthropicResponse, err := a.client.Messages.New(
-			ctx,
-			preparedMessages,
-			opts...,
-		)
-		// If there is an error we are going to see if we can retry the call
-		if err != nil {
-			retry, after, retryErr := a.shouldRetry(attempts, err)
-			if retryErr != nil {
-				return nil, retryErr
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					return nil, ctx.Err()
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			return nil, retryErr
-		}
-
-		content := ""
-		for _, block := range anthropicResponse.Content {
-			if text, ok := block.AsAny().(anthropic.TextBlock); ok {
-				content += text.Text
-			}
-		}
-
-		return &ProviderResponse{
-			Content:   content,
-			ToolCalls: a.toolCalls(*anthropicResponse),
-			Usage:     a.usage(*anthropicResponse),
-		}, nil
-	}
-}
-
-func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	attempts := 0
-	eventChan := make(chan ProviderEvent)
-	go func() {
-		for {
-			attempts++
-			// Prepare messages on each attempt in case max_tokens was adjusted
-			preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
-
-			var opts []option.RequestOption
-			if a.isThinkingEnabled() {
-				opts = append(opts, option.WithHeaderAdd("anthropic-beta", "interleaved-thinking-2025-05-14"))
-			}
-
-			anthropicStream := a.client.Messages.NewStreaming(
-				ctx,
-				preparedMessages,
-				opts...,
-			)
-			accumulatedMessage := anthropic.Message{}
-
-			currentToolCallID := ""
-			for anthropicStream.Next() {
-				event := anthropicStream.Current()
-				err := accumulatedMessage.Accumulate(event)
-				if err != nil {
-					slog.Warn("Error accumulating message", "error", err)
-					continue
-				}
-
-				switch event := event.AsAny().(type) {
-				case anthropic.ContentBlockStartEvent:
-					switch event.ContentBlock.Type {
-					case "text":
-						eventChan <- ProviderEvent{Type: EventContentStart}
-					case "tool_use":
-						currentToolCallID = event.ContentBlock.ID
-						eventChan <- ProviderEvent{
-							Type: EventToolUseStart,
-							ToolCall: &message.ToolCall{
-								ID:       event.ContentBlock.ID,
-								Name:     event.ContentBlock.Name,
-								Finished: false,
-							},
-						}
-					}
-
-				case anthropic.ContentBlockDeltaEvent:
-					if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
-						eventChan <- ProviderEvent{
-							Type:     EventThinkingDelta,
-							Thinking: event.Delta.Thinking,
-						}
-					} else if event.Delta.Type == "signature_delta" && event.Delta.Signature != "" {
-						eventChan <- ProviderEvent{
-							Type:      EventSignatureDelta,
-							Signature: event.Delta.Signature,
-						}
-					} else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
-						eventChan <- ProviderEvent{
-							Type:    EventContentDelta,
-							Content: event.Delta.Text,
-						}
-					} else if event.Delta.Type == "input_json_delta" {
-						if currentToolCallID != "" {
-							eventChan <- ProviderEvent{
-								Type: EventToolUseDelta,
-								ToolCall: &message.ToolCall{
-									ID:       currentToolCallID,
-									Finished: false,
-									Input:    event.Delta.PartialJSON,
-								},
-							}
-						}
-					}
-				case anthropic.ContentBlockStopEvent:
-					if currentToolCallID != "" {
-						eventChan <- ProviderEvent{
-							Type: EventToolUseStop,
-							ToolCall: &message.ToolCall{
-								ID: currentToolCallID,
-							},
-						}
-						currentToolCallID = ""
-					} else {
-						eventChan <- ProviderEvent{Type: EventContentStop}
-					}
-
-				case anthropic.MessageStopEvent:
-					content := ""
-					for _, block := range accumulatedMessage.Content {
-						if text, ok := block.AsAny().(anthropic.TextBlock); ok {
-							content += text.Text
-						}
-					}
-
-					eventChan <- ProviderEvent{
-						Type: EventComplete,
-						Response: &ProviderResponse{
-							Content:      content,
-							ToolCalls:    a.toolCalls(accumulatedMessage),
-							Usage:        a.usage(accumulatedMessage),
-							FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
-						},
-						Content: content,
-					}
-				}
-			}
-
-			err := anthropicStream.Err()
-			if err == nil || errors.Is(err, io.EOF) {
-				close(eventChan)
-				return
-			}
-
-			// If there is an error we are going to see if we can retry the call
-			retry, after, retryErr := a.shouldRetry(attempts, err)
-			if retryErr != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-				close(eventChan)
-				return
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					// context cancelled
-					if ctx.Err() != nil {
-						eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-					}
-					close(eventChan)
-					return
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			if ctx.Err() != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-			}
-
-			close(eventChan)
-			return
-		}
-	}()
-	return eventChan
-}
-
-func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
-	var apiErr *anthropic.Error
-	if !errors.As(err, &apiErr) {
-		return false, 0, err
-	}
-
-	if attempts > maxRetries {
-		return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
-	}
-
-	if apiErr.StatusCode == http.StatusUnauthorized {
-		prev := a.providerOptions.apiKey
-		// in case the key comes from a script, we try to re-evaluate it.
-		a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey)
-		if err != nil {
-			return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
-		}
-		// if it didn't change, do not retry.
-		if prev == a.providerOptions.apiKey {
-			return false, 0, err
-		}
-		a.client = createAnthropicClient(a.providerOptions, a.tp)
-		return true, 0, nil
-	}
-
-	// Handle context limit exceeded error (400 Bad Request)
-	if apiErr.StatusCode == http.StatusBadRequest {
-		if adjusted, ok := a.handleContextLimitError(apiErr); ok {
-			a.adjustedMaxTokens = adjusted
-			slog.Debug("Adjusted max_tokens due to context limit", "new_max_tokens", adjusted)
-			return true, 0, nil
-		}
-	}
-
-	isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded")
-	// 529 (unofficial): The service is overloaded
-	if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded {
-		return false, 0, err
-	}
-
-	retryMs := 0
-	retryAfterValues := apiErr.Response.Header.Values("Retry-After")
-
-	backoffMs := 2000 * (1 << (attempts - 1))
-	jitterMs := int(float64(backoffMs) * 0.2)
-	retryMs = backoffMs + jitterMs
-	if len(retryAfterValues) > 0 {
-		if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
-			retryMs = retryMs * 1000
-		}
-	}
-	return true, int64(retryMs), nil
-}
-
-// handleContextLimitError parses context limit error and returns adjusted max_tokens
-func (a *anthropicClient) handleContextLimitError(apiErr *anthropic.Error) (int, bool) {
-	// Parse error message like: "input length and max_tokens exceed context limit: 154978 + 50000 > 200000"
-	errorMsg := apiErr.Error()
-
-	matches := contextLimitRegex.FindStringSubmatch(errorMsg)
-
-	if len(matches) != 4 {
-		return 0, false
-	}
-
-	inputTokens, err1 := strconv.Atoi(matches[1])
-	contextLimit, err2 := strconv.Atoi(matches[3])
-
-	if err1 != nil || err2 != nil {
-		return 0, false
-	}
-
-	// Calculate safe max_tokens with a buffer of 1000 tokens
-	safeMaxTokens := contextLimit - inputTokens - 1000
-
-	// Ensure we don't go below a minimum threshold
-	safeMaxTokens = max(safeMaxTokens, 1000)
-
-	return safeMaxTokens, true
-}
-
-func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
-	var toolCalls []message.ToolCall
-
-	for _, block := range msg.Content {
-		switch variant := block.AsAny().(type) {
-		case anthropic.ToolUseBlock:
-			toolCall := message.ToolCall{
-				ID:       variant.ID,
-				Name:     variant.Name,
-				Input:    string(variant.Input),
-				Type:     string(variant.Type),
-				Finished: true,
-			}
-			toolCalls = append(toolCalls, toolCall)
-		}
-	}
-
-	return toolCalls
-}
-
-func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
-	return TokenUsage{
-		InputTokens:         msg.Usage.InputTokens,
-		OutputTokens:        msg.Usage.OutputTokens,
-		CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
-		CacheReadTokens:     msg.Usage.CacheReadInputTokens,
-	}
-}
-
-func (a *anthropicClient) Model() catwalk.Model {
-	return a.providerOptions.model(a.providerOptions.modelType)
-}

internal/llm/provider/azure.go πŸ”—

@@ -1,39 +0,0 @@
-package provider
-
-import (
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/openai/openai-go"
-	"github.com/openai/openai-go/azure"
-	"github.com/openai/openai-go/option"
-)
-
-type azureClient struct {
-	*openaiClient
-}
-
-type AzureClient ProviderClient
-
-func newAzureClient(opts providerClientOptions) AzureClient {
-	apiVersion := opts.extraParams["apiVersion"]
-	if apiVersion == "" {
-		apiVersion = "2025-01-01-preview"
-	}
-
-	reqOpts := []option.RequestOption{
-		azure.WithEndpoint(opts.baseURL, apiVersion),
-	}
-
-	if config.Get().Options.Debug {
-		httpClient := log.NewHTTPClient()
-		reqOpts = append(reqOpts, option.WithHTTPClient(httpClient))
-	}
-
-	reqOpts = append(reqOpts, azure.WithAPIKey(opts.apiKey))
-	base := &openaiClient{
-		providerOptions: opts,
-		client:          openai.NewClient(reqOpts...),
-	}
-
-	return &azureClient{openaiClient: base}
-}

internal/llm/provider/bedrock.go πŸ”—

@@ -1,93 +0,0 @@
-package provider
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"strings"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/message"
-)
-
-type bedrockClient struct {
-	providerOptions providerClientOptions
-	childProvider   ProviderClient
-}
-
-type BedrockClient ProviderClient
-
-func newBedrockClient(opts providerClientOptions) BedrockClient {
-	// Get AWS region from environment
-	region := opts.extraParams["region"]
-	if region == "" {
-		region = "us-east-1" // default region
-	}
-	if len(region) < 2 {
-		return &bedrockClient{
-			providerOptions: opts,
-			childProvider:   nil, // Will cause an error when used
-		}
-	}
-
-	opts.model = func(modelType config.SelectedModelType) catwalk.Model {
-		model := config.Get().GetModelByType(modelType)
-
-		// Prefix the model name with region
-		regionPrefix := region[:2]
-		modelName := model.ID
-		model.ID = fmt.Sprintf("%s.%s", regionPrefix, modelName)
-		return *model
-	}
-
-	model := opts.model(opts.modelType)
-
-	// Determine which provider to use based on the model
-	if strings.Contains(string(model.ID), "anthropic") {
-		// Create Anthropic client with Bedrock configuration
-		anthropicOpts := opts
-		// TODO: later find a way to check if the AWS account has caching enabled
-		opts.disableCache = true // Disable cache for Bedrock
-		return &bedrockClient{
-			providerOptions: opts,
-			childProvider:   newAnthropicClient(anthropicOpts, AnthropicClientTypeBedrock),
-		}
-	}
-
-	// Return client with nil childProvider if model is not supported
-	// This will cause an error when used
-	return &bedrockClient{
-		providerOptions: opts,
-		childProvider:   nil,
-	}
-}
-
-func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
-	if b.childProvider == nil {
-		return nil, errors.New("unsupported model for bedrock provider")
-	}
-	return b.childProvider.send(ctx, messages, tools)
-}
-
-func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	eventChan := make(chan ProviderEvent)
-
-	if b.childProvider == nil {
-		go func() {
-			eventChan <- ProviderEvent{
-				Type:  EventError,
-				Error: errors.New("unsupported model for bedrock provider"),
-			}
-			close(eventChan)
-		}()
-		return eventChan
-	}
-
-	return b.childProvider.stream(ctx, messages, tools)
-}
-
-func (b *bedrockClient) Model() catwalk.Model {
-	return b.providerOptions.model(b.providerOptions.modelType)
-}

internal/llm/provider/gemini.go πŸ”—

@@ -1,579 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/google/uuid"
-	"google.golang.org/genai"
-)
-
-type geminiClient struct {
-	providerOptions providerClientOptions
-	client          *genai.Client
-}
-
-type GeminiClient ProviderClient
-
-func newGeminiClient(opts providerClientOptions) GeminiClient {
-	client, err := createGeminiClient(opts)
-	if err != nil {
-		slog.Error("Failed to create Gemini client", "error", err)
-		return nil
-	}
-
-	return &geminiClient{
-		providerOptions: opts,
-		client:          client,
-	}
-}
-
-func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
-	cc := &genai.ClientConfig{
-		APIKey:  opts.apiKey,
-		Backend: genai.BackendGeminiAPI,
-	}
-	if opts.baseURL != "" {
-		resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
-		if err == nil && resolvedBaseURL != "" {
-			cc.HTTPOptions = genai.HTTPOptions{
-				BaseURL: resolvedBaseURL,
-			}
-		}
-	}
-	if config.Get().Options.Debug {
-		cc.HTTPClient = log.NewHTTPClient()
-	}
-	client, err := genai.NewClient(context.Background(), cc)
-	if err != nil {
-		return nil, err
-	}
-	return client, nil
-}
-
-func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
-	var history []*genai.Content
-	for _, msg := range messages {
-		switch msg.Role {
-		case message.User:
-			var parts []*genai.Part
-			parts = append(parts, &genai.Part{Text: msg.Content().String()})
-			for _, binaryContent := range msg.BinaryContent() {
-				parts = append(parts, &genai.Part{InlineData: &genai.Blob{
-					MIMEType: binaryContent.MIMEType,
-					Data:     binaryContent.Data,
-				}})
-			}
-			history = append(history, &genai.Content{
-				Parts: parts,
-				Role:  genai.RoleUser,
-			})
-		case message.Assistant:
-			var assistantParts []*genai.Part
-
-			if msg.Content().String() != "" {
-				assistantParts = append(assistantParts, &genai.Part{Text: msg.Content().String()})
-			}
-
-			if len(msg.ToolCalls()) > 0 {
-				for _, call := range msg.ToolCalls() {
-					if !call.Finished {
-						continue
-					}
-					args, _ := parseJSONToMap(call.Input)
-					assistantParts = append(assistantParts, &genai.Part{
-						FunctionCall: &genai.FunctionCall{
-							Name: call.Name,
-							Args: args,
-						},
-					})
-				}
-			}
-
-			if len(assistantParts) > 0 {
-				history = append(history, &genai.Content{
-					Role:  genai.RoleModel,
-					Parts: assistantParts,
-				})
-			}
-
-		case message.Tool:
-			var toolParts []*genai.Part
-			for _, result := range msg.ToolResults() {
-				response := map[string]any{"result": result.Content}
-				parsed, err := parseJSONToMap(result.Content)
-				if err == nil {
-					response = parsed
-				}
-
-				var toolCall message.ToolCall
-				for _, m := range messages {
-					if m.Role == message.Assistant {
-						for _, call := range m.ToolCalls() {
-							if call.ID == result.ToolCallID {
-								toolCall = call
-								break
-							}
-						}
-					}
-				}
-
-				toolParts = append(toolParts, &genai.Part{
-					FunctionResponse: &genai.FunctionResponse{
-						Name:     toolCall.Name,
-						Response: response,
-					},
-				})
-			}
-			if len(toolParts) > 0 {
-				history = append(history, &genai.Content{
-					Parts: toolParts,
-					Role:  genai.RoleUser,
-				})
-			}
-		}
-	}
-
-	return history
-}
-
-func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
-	geminiTool := &genai.Tool{}
-	geminiTool.FunctionDeclarations = make([]*genai.FunctionDeclaration, 0, len(tools))
-
-	for _, tool := range tools {
-		info := tool.Info()
-		declaration := &genai.FunctionDeclaration{
-			Name:        info.Name,
-			Description: info.Description,
-			Parameters: &genai.Schema{
-				Type:       genai.TypeObject,
-				Properties: convertSchemaProperties(info.Parameters),
-				Required:   info.Required,
-			},
-		}
-
-		geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, declaration)
-	}
-
-	return []*genai.Tool{geminiTool}
-}
-
-func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
-	switch reason {
-	case genai.FinishReasonStop:
-		return message.FinishReasonEndTurn
-	case genai.FinishReasonMaxTokens:
-		return message.FinishReasonMaxTokens
-	default:
-		return message.FinishReasonUnknown
-	}
-}
-
-func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
-	// Convert messages
-	geminiMessages := g.convertMessages(messages)
-	model := g.providerOptions.model(g.providerOptions.modelType)
-	cfg := config.Get()
-
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if g.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-	systemMessage := g.providerOptions.systemMessage
-	if g.providerOptions.systemPromptPrefix != "" {
-		systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
-	}
-	history := geminiMessages[:len(geminiMessages)-1] // All but last message
-	lastMsg := geminiMessages[len(geminiMessages)-1]
-	config := &genai.GenerateContentConfig{
-		MaxOutputTokens: int32(maxTokens),
-		SystemInstruction: &genai.Content{
-			Parts: []*genai.Part{{Text: systemMessage}},
-		},
-	}
-	config.Tools = g.convertTools(tools)
-	chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
-
-	attempts := 0
-	for {
-		attempts++
-		var toolCalls []message.ToolCall
-
-		var lastMsgParts []genai.Part
-		for _, part := range lastMsg.Parts {
-			lastMsgParts = append(lastMsgParts, *part)
-		}
-		resp, err := chat.SendMessage(ctx, lastMsgParts...)
-		// If there is an error we are going to see if we can retry the call
-		if err != nil {
-			retry, after, retryErr := g.shouldRetry(attempts, err)
-			if retryErr != nil {
-				return nil, retryErr
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					return nil, ctx.Err()
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			return nil, retryErr
-		}
-
-		content := ""
-
-		if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
-			for _, part := range resp.Candidates[0].Content.Parts {
-				switch {
-				case part.Text != "":
-					content = string(part.Text)
-				case part.FunctionCall != nil:
-					id := "call_" + uuid.New().String()
-					args, _ := json.Marshal(part.FunctionCall.Args)
-					toolCalls = append(toolCalls, message.ToolCall{
-						ID:       id,
-						Name:     part.FunctionCall.Name,
-						Input:    string(args),
-						Type:     "function",
-						Finished: true,
-					})
-				}
-			}
-		}
-		finishReason := message.FinishReasonEndTurn
-		if len(resp.Candidates) > 0 {
-			finishReason = g.finishReason(resp.Candidates[0].FinishReason)
-		}
-		if len(toolCalls) > 0 {
-			finishReason = message.FinishReasonToolUse
-		}
-
-		return &ProviderResponse{
-			Content:      content,
-			ToolCalls:    toolCalls,
-			Usage:        g.usage(resp),
-			FinishReason: finishReason,
-		}, nil
-	}
-}
-
-func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	// Convert messages
-	geminiMessages := g.convertMessages(messages)
-
-	model := g.providerOptions.model(g.providerOptions.modelType)
-	cfg := config.Get()
-
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if g.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-
-	// Override max tokens if set in provider options
-	if g.providerOptions.maxTokens > 0 {
-		maxTokens = g.providerOptions.maxTokens
-	}
-	systemMessage := g.providerOptions.systemMessage
-	if g.providerOptions.systemPromptPrefix != "" {
-		systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
-	}
-	history := geminiMessages[:len(geminiMessages)-1] // All but last message
-	lastMsg := geminiMessages[len(geminiMessages)-1]
-	config := &genai.GenerateContentConfig{
-		MaxOutputTokens: int32(maxTokens),
-		SystemInstruction: &genai.Content{
-			Parts: []*genai.Part{{Text: systemMessage}},
-		},
-	}
-	config.Tools = g.convertTools(tools)
-	chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
-
-	attempts := 0
-	eventChan := make(chan ProviderEvent)
-
-	go func() {
-		defer close(eventChan)
-
-		for {
-			attempts++
-
-			currentContent := ""
-			toolCalls := []message.ToolCall{}
-			var finalResp *genai.GenerateContentResponse
-
-			eventChan <- ProviderEvent{Type: EventContentStart}
-
-			var lastMsgParts []genai.Part
-
-			for _, part := range lastMsg.Parts {
-				lastMsgParts = append(lastMsgParts, *part)
-			}
-
-			for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
-				if err != nil {
-					retry, after, retryErr := g.shouldRetry(attempts, err)
-					if retryErr != nil {
-						eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-						return
-					}
-					if retry {
-						slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-						select {
-						case <-ctx.Done():
-							if ctx.Err() != nil {
-								eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-							}
-
-							return
-						case <-time.After(time.Duration(after) * time.Millisecond):
-							continue
-						}
-					} else {
-						eventChan <- ProviderEvent{Type: EventError, Error: err}
-						return
-					}
-				}
-
-				finalResp = resp
-
-				if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
-					for _, part := range resp.Candidates[0].Content.Parts {
-						switch {
-						case part.Text != "":
-							delta := string(part.Text)
-							if delta != "" {
-								eventChan <- ProviderEvent{
-									Type:    EventContentDelta,
-									Content: delta,
-								}
-								currentContent += delta
-							}
-						case part.FunctionCall != nil:
-							id := "call_" + uuid.New().String()
-							args, _ := json.Marshal(part.FunctionCall.Args)
-							newCall := message.ToolCall{
-								ID:       id,
-								Name:     part.FunctionCall.Name,
-								Input:    string(args),
-								Type:     "function",
-								Finished: true,
-							}
-
-							toolCalls = append(toolCalls, newCall)
-						}
-					}
-				} else {
-					// no content received
-					break
-				}
-			}
-
-			eventChan <- ProviderEvent{Type: EventContentStop}
-
-			if finalResp != nil {
-				finishReason := message.FinishReasonEndTurn
-				if len(finalResp.Candidates) > 0 {
-					finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
-				}
-				if len(toolCalls) > 0 {
-					finishReason = message.FinishReasonToolUse
-				}
-				eventChan <- ProviderEvent{
-					Type: EventComplete,
-					Response: &ProviderResponse{
-						Content:      currentContent,
-						ToolCalls:    toolCalls,
-						Usage:        g.usage(finalResp),
-						FinishReason: finishReason,
-					},
-				}
-				return
-			} else {
-				eventChan <- ProviderEvent{
-					Type:  EventError,
-					Error: errors.New("no content received"),
-				}
-			}
-		}
-	}()
-
-	return eventChan
-}
-
-func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
-	// Check if error is a rate limit error
-	if attempts > maxRetries {
-		return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
-	}
-
-	// Gemini doesn't have a standard error type we can check against
-	// So we'll check the error message for rate limit indicators
-	if errors.Is(err, io.EOF) {
-		return false, 0, err
-	}
-
-	errMsg := err.Error()
-	isRateLimit := contains(errMsg, "rate limit", "quota exceeded", "too many requests")
-
-	// Check for token expiration (401 Unauthorized)
-	if contains(errMsg, "unauthorized", "invalid api key", "api key expired") {
-		prev := g.providerOptions.apiKey
-		// in case the key comes from a script, we try to re-evaluate it.
-		g.providerOptions.apiKey, err = config.Get().Resolve(g.providerOptions.config.APIKey)
-		if err != nil {
-			return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
-		}
-		// if it didn't change, do not retry.
-		if prev == g.providerOptions.apiKey {
-			return false, 0, err
-		}
-		g.client, err = createGeminiClient(g.providerOptions)
-		if err != nil {
-			return false, 0, fmt.Errorf("failed to create Gemini client after API key refresh: %w", err)
-		}
-		return true, 0, nil
-	}
-
-	// Check for common rate limit error messages
-
-	if !isRateLimit {
-		return false, 0, err
-	}
-
-	// Calculate backoff with jitter
-	backoffMs := 2000 * (1 << (attempts - 1))
-	jitterMs := int(float64(backoffMs) * 0.2)
-	retryMs := backoffMs + jitterMs
-
-	return true, int64(retryMs), nil
-}
-
-func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
-	if resp == nil || resp.UsageMetadata == nil {
-		return TokenUsage{}
-	}
-
-	return TokenUsage{
-		InputTokens:         int64(resp.UsageMetadata.PromptTokenCount),
-		OutputTokens:        int64(resp.UsageMetadata.CandidatesTokenCount),
-		CacheCreationTokens: 0, // Not directly provided by Gemini
-		CacheReadTokens:     int64(resp.UsageMetadata.CachedContentTokenCount),
-	}
-}
-
-func (g *geminiClient) Model() catwalk.Model {
-	return g.providerOptions.model(g.providerOptions.modelType)
-}
-
-// Helper functions
-func parseJSONToMap(jsonStr string) (map[string]any, error) {
-	var result map[string]any
-	err := json.Unmarshal([]byte(jsonStr), &result)
-	return result, err
-}
-
-func convertSchemaProperties(parameters map[string]any) map[string]*genai.Schema {
-	properties := make(map[string]*genai.Schema)
-
-	for name, param := range parameters {
-		properties[name] = convertToSchema(param)
-	}
-
-	return properties
-}
-
-func convertToSchema(param any) *genai.Schema {
-	schema := &genai.Schema{Type: genai.TypeString}
-
-	paramMap, ok := param.(map[string]any)
-	if !ok {
-		return schema
-	}
-
-	if desc, ok := paramMap["description"].(string); ok {
-		schema.Description = desc
-	}
-
-	typeVal, hasType := paramMap["type"]
-	if !hasType {
-		return schema
-	}
-
-	typeStr, ok := typeVal.(string)
-	if !ok {
-		return schema
-	}
-
-	schema.Type = mapJSONTypeToGenAI(typeStr)
-
-	switch typeStr {
-	case "array":
-		schema.Items = processArrayItems(paramMap)
-	case "object":
-		if props, ok := paramMap["properties"].(map[string]any); ok {
-			schema.Properties = convertSchemaProperties(props)
-		}
-	}
-
-	return schema
-}
-
-func processArrayItems(paramMap map[string]any) *genai.Schema {
-	items, ok := paramMap["items"].(map[string]any)
-	if !ok {
-		return nil
-	}
-
-	return convertToSchema(items)
-}
-
-func mapJSONTypeToGenAI(jsonType string) genai.Type {
-	switch jsonType {
-	case "string":
-		return genai.TypeString
-	case "number":
-		return genai.TypeNumber
-	case "integer":
-		return genai.TypeInteger
-	case "boolean":
-		return genai.TypeBoolean
-	case "array":
-		return genai.TypeArray
-	case "object":
-		return genai.TypeObject
-	default:
-		return genai.TypeString // Default to string for unknown types
-	}
-}
-
-func contains(s string, substrs ...string) bool {
-	for _, substr := range substrs {
-		if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
-			return true
-		}
-	}
-	return false
-}

internal/llm/provider/openai.go πŸ”—

@@ -1,604 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/http"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/log"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/google/uuid"
-	"github.com/openai/openai-go"
-	"github.com/openai/openai-go/option"
-	"github.com/openai/openai-go/packages/param"
-	"github.com/openai/openai-go/shared"
-)
-
-type openaiClient struct {
-	providerOptions providerClientOptions
-	client          openai.Client
-}
-
-type OpenAIClient ProviderClient
-
-func newOpenAIClient(opts providerClientOptions) OpenAIClient {
-	return &openaiClient{
-		providerOptions: opts,
-		client:          createOpenAIClient(opts),
-	}
-}
-
-func createOpenAIClient(opts providerClientOptions) openai.Client {
-	openaiClientOptions := []option.RequestOption{}
-	if opts.apiKey != "" {
-		openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
-	}
-	if opts.baseURL != "" {
-		resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
-		if err == nil && resolvedBaseURL != "" {
-			openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(resolvedBaseURL))
-		}
-	}
-
-	if config.Get().Options.Debug {
-		httpClient := log.NewHTTPClient()
-		openaiClientOptions = append(openaiClientOptions, option.WithHTTPClient(httpClient))
-	}
-
-	for key, value := range opts.extraHeaders {
-		openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
-	}
-
-	for extraKey, extraValue := range opts.extraBody {
-		openaiClientOptions = append(openaiClientOptions, option.WithJSONSet(extraKey, extraValue))
-	}
-
-	return openai.NewClient(openaiClientOptions...)
-}
-
-func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
-	isAnthropicModel := o.providerOptions.config.ID == string(catwalk.InferenceProviderOpenRouter) && strings.HasPrefix(o.Model().ID, "anthropic/")
-	// Add system message first
-	systemMessage := o.providerOptions.systemMessage
-	if o.providerOptions.systemPromptPrefix != "" {
-		systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
-	}
-
-	system := openai.SystemMessage(systemMessage)
-	if isAnthropicModel && !o.providerOptions.disableCache {
-		systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
-		systemTextBlock.SetExtraFields(
-			map[string]any{
-				"cache_control": map[string]string{
-					"type": "ephemeral",
-				},
-			},
-		)
-		var content []openai.ChatCompletionContentPartTextParam
-		content = append(content, systemTextBlock)
-		system = openai.SystemMessage(content)
-	}
-	openaiMessages = append(openaiMessages, system)
-
-	for i, msg := range messages {
-		cache := false
-		if i > len(messages)-3 {
-			cache = true
-		}
-		switch msg.Role {
-		case message.User:
-			var content []openai.ChatCompletionContentPartUnionParam
-
-			textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
-			content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
-			hasBinaryContent := false
-			for _, binaryContent := range msg.BinaryContent() {
-				hasBinaryContent = true
-				imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
-				imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
-
-				content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
-			}
-			if cache && !o.providerOptions.disableCache && isAnthropicModel {
-				textBlock.SetExtraFields(map[string]any{
-					"cache_control": map[string]string{
-						"type": "ephemeral",
-					},
-				})
-			}
-			if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
-				openaiMessages = append(openaiMessages, openai.UserMessage(content))
-			} else {
-				openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
-			}
-
-		case message.Assistant:
-			assistantMsg := openai.ChatCompletionAssistantMessageParam{
-				Role: "assistant",
-			}
-
-			// Only include finished tool calls; interrupted tool calls must not be resent.
-			if len(msg.ToolCalls()) > 0 {
-				finished := make([]message.ToolCall, 0, len(msg.ToolCalls()))
-				for _, call := range msg.ToolCalls() {
-					if call.Finished {
-						finished = append(finished, call)
-					}
-				}
-				if len(finished) > 0 {
-					assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(finished))
-					for i, call := range finished {
-						assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
-							ID:   call.ID,
-							Type: "function",
-							Function: openai.ChatCompletionMessageToolCallFunctionParam{
-								Name:      call.Name,
-								Arguments: call.Input,
-							},
-						}
-					}
-				}
-			}
-			if msg.Content().String() != "" {
-				assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
-					OfString: param.NewOpt(msg.Content().Text),
-				}
-			}
-
-			if cache && !o.providerOptions.disableCache && isAnthropicModel {
-				assistantMsg.SetExtraFields(map[string]any{
-					"cache_control": map[string]string{
-						"type": "ephemeral",
-					},
-				})
-			}
-			// Skip empty assistant messages (no content and no finished tool calls)
-			if msg.Content().String() == "" && len(assistantMsg.ToolCalls) == 0 {
-				continue
-			}
-
-			openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
-				OfAssistant: &assistantMsg,
-			})
-
-		case message.Tool:
-			for _, result := range msg.ToolResults() {
-				openaiMessages = append(openaiMessages,
-					openai.ToolMessage(result.Content, result.ToolCallID),
-				)
-			}
-		}
-	}
-
-	return openaiMessages
-}
-
-func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
-	openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
-
-	for i, tool := range tools {
-		info := tool.Info()
-		openaiTools[i] = openai.ChatCompletionToolParam{
-			Function: openai.FunctionDefinitionParam{
-				Name:        info.Name,
-				Description: openai.String(info.Description),
-				Parameters: openai.FunctionParameters{
-					"type":       "object",
-					"properties": info.Parameters,
-					"required":   info.Required,
-				},
-			},
-		}
-	}
-
-	return openaiTools
-}
-
-func (o *openaiClient) finishReason(reason string) message.FinishReason {
-	switch reason {
-	case "stop":
-		return message.FinishReasonEndTurn
-	case "length":
-		return message.FinishReasonMaxTokens
-	case "tool_calls":
-		return message.FinishReasonToolUse
-	default:
-		return message.FinishReasonUnknown
-	}
-}
-
-func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
-	model := o.providerOptions.model(o.providerOptions.modelType)
-	cfg := config.Get()
-
-	modelConfig := cfg.Models[config.SelectedModelTypeLarge]
-	if o.providerOptions.modelType == config.SelectedModelTypeSmall {
-		modelConfig = cfg.Models[config.SelectedModelTypeSmall]
-	}
-
-	reasoningEffort := modelConfig.ReasoningEffort
-
-	params := openai.ChatCompletionNewParams{
-		Model:    openai.ChatModel(model.ID),
-		Messages: messages,
-		Tools:    tools,
-	}
-
-	maxTokens := model.DefaultMaxTokens
-	if modelConfig.MaxTokens > 0 {
-		maxTokens = modelConfig.MaxTokens
-	}
-
-	// Override max tokens if set in provider options
-	if o.providerOptions.maxTokens > 0 {
-		maxTokens = o.providerOptions.maxTokens
-	}
-	if model.CanReason {
-		params.MaxCompletionTokens = openai.Int(maxTokens)
-		switch reasoningEffort {
-		case "low":
-			params.ReasoningEffort = shared.ReasoningEffortLow
-		case "medium":
-			params.ReasoningEffort = shared.ReasoningEffortMedium
-		case "high":
-			params.ReasoningEffort = shared.ReasoningEffortHigh
-		case "minimal":
-			params.ReasoningEffort = shared.ReasoningEffort("minimal")
-		default:
-			params.ReasoningEffort = shared.ReasoningEffort(reasoningEffort)
-		}
-	} else {
-		params.MaxTokens = openai.Int(maxTokens)
-	}
-
-	return params
-}
-
-func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
-	params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
-	attempts := 0
-	for {
-		attempts++
-		openaiResponse, err := o.client.Chat.Completions.New(
-			ctx,
-			params,
-		)
-		// If there is an error we are going to see if we can retry the call
-		if err != nil {
-			retry, after, retryErr := o.shouldRetry(attempts, err)
-			if retryErr != nil {
-				return nil, retryErr
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					return nil, ctx.Err()
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			return nil, retryErr
-		}
-
-		if len(openaiResponse.Choices) == 0 {
-			return nil, fmt.Errorf("received empty response from OpenAI API - check endpoint configuration")
-		}
-
-		content := ""
-		if openaiResponse.Choices[0].Message.Content != "" {
-			content = openaiResponse.Choices[0].Message.Content
-		}
-
-		toolCalls := o.toolCalls(*openaiResponse)
-		finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
-
-		if len(toolCalls) > 0 {
-			finishReason = message.FinishReasonToolUse
-		}
-
-		return &ProviderResponse{
-			Content:      content,
-			ToolCalls:    toolCalls,
-			Usage:        o.usage(*openaiResponse),
-			FinishReason: finishReason,
-		}, nil
-	}
-}
-
-func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
-	params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
-		IncludeUsage: openai.Bool(true),
-	}
-
-	attempts := 0
-	eventChan := make(chan ProviderEvent)
-
-	go func() {
-		for {
-			attempts++
-			// Kujtim: fixes an issue with anthropig models on openrouter
-			if len(params.Tools) == 0 {
-				params.Tools = nil
-			}
-			openaiStream := o.client.Chat.Completions.NewStreaming(
-				ctx,
-				params,
-			)
-
-			acc := openai.ChatCompletionAccumulator{}
-			currentContent := ""
-			toolCalls := make([]message.ToolCall, 0)
-			msgToolCalls := make(map[int64]openai.ChatCompletionMessageToolCall)
-			toolMap := make(map[string]openai.ChatCompletionMessageToolCall)
-			toolCallIDMap := make(map[string]string)
-			for openaiStream.Next() {
-				chunk := openaiStream.Current()
-				// Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
-				if len(chunk.Choices) != 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
-					chunk.Choices[0].Delta.ToolCalls[0].Index = 0
-				}
-				acc.AddChunk(chunk)
-				for i, choice := range chunk.Choices {
-					reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"]
-					if ok && reasoning.Raw() != "" {
-						reasoningStr := ""
-						json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr)
-						if reasoningStr != "" {
-							eventChan <- ProviderEvent{
-								Type:     EventThinkingDelta,
-								Thinking: reasoningStr,
-							}
-						}
-					}
-					if choice.Delta.Content != "" {
-						eventChan <- ProviderEvent{
-							Type:    EventContentDelta,
-							Content: choice.Delta.Content,
-						}
-						currentContent += choice.Delta.Content
-					} else if len(choice.Delta.ToolCalls) > 0 {
-						toolCall := choice.Delta.ToolCalls[0]
-						if strings.HasPrefix(toolCall.ID, "functions.") {
-							exID, ok := toolCallIDMap[toolCall.ID]
-							if !ok {
-								newID := uuid.NewString()
-								toolCallIDMap[toolCall.ID] = newID
-								toolCall.ID = newID
-							} else {
-								toolCall.ID = exID
-							}
-						}
-						newToolCall := false
-						if existingToolCall, ok := msgToolCalls[toolCall.Index]; ok { // tool call exists
-							if toolCall.ID != "" && toolCall.ID != existingToolCall.ID {
-								found := false
-								// try to find the tool based on the ID
-								for _, tool := range msgToolCalls {
-									if tool.ID == toolCall.ID {
-										existingToolCall.Function.Arguments += toolCall.Function.Arguments
-										msgToolCalls[toolCall.Index] = existingToolCall
-										toolMap[existingToolCall.ID] = existingToolCall
-										found = true
-									}
-								}
-								if !found {
-									newToolCall = true
-								}
-							} else {
-								existingToolCall.Function.Arguments += toolCall.Function.Arguments
-								msgToolCalls[toolCall.Index] = existingToolCall
-								toolMap[existingToolCall.ID] = existingToolCall
-							}
-						} else {
-							newToolCall = true
-						}
-						if newToolCall { // new tool call
-							if toolCall.ID == "" {
-								toolCall.ID = uuid.NewString()
-							}
-							eventChan <- ProviderEvent{
-								Type: EventToolUseStart,
-								ToolCall: &message.ToolCall{
-									ID:       toolCall.ID,
-									Name:     toolCall.Function.Name,
-									Finished: false,
-								},
-							}
-							msgToolCalls[toolCall.Index] = openai.ChatCompletionMessageToolCall{
-								ID:   toolCall.ID,
-								Type: "function",
-								Function: openai.ChatCompletionMessageToolCallFunction{
-									Name:      toolCall.Function.Name,
-									Arguments: toolCall.Function.Arguments,
-								},
-							}
-							toolMap[toolCall.ID] = msgToolCalls[toolCall.Index]
-						}
-						toolCalls := []openai.ChatCompletionMessageToolCall{}
-						for _, tc := range toolMap {
-							toolCalls = append(toolCalls, tc)
-						}
-						acc.Choices[i].Message.ToolCalls = toolCalls
-					}
-				}
-			}
-
-			err := openaiStream.Err()
-			if err == nil || errors.Is(err, io.EOF) {
-				if len(acc.Choices) == 0 {
-					eventChan <- ProviderEvent{
-						Type:  EventError,
-						Error: fmt.Errorf("received empty streaming response from OpenAI API - check endpoint configuration"),
-					}
-					return
-				}
-
-				resultFinishReason := acc.Choices[0].FinishReason
-				if resultFinishReason == "" {
-					// If the finish reason is empty, we assume it was a successful completion
-					// INFO: this is happening for openrouter for some reason
-					resultFinishReason = "stop"
-				}
-				// Stream completed successfully
-				finishReason := o.finishReason(resultFinishReason)
-				if len(acc.Choices[0].Message.ToolCalls) > 0 {
-					toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
-				}
-				if len(toolCalls) > 0 {
-					finishReason = message.FinishReasonToolUse
-				}
-
-				eventChan <- ProviderEvent{
-					Type: EventComplete,
-					Response: &ProviderResponse{
-						Content:      currentContent,
-						ToolCalls:    toolCalls,
-						Usage:        o.usage(acc.ChatCompletion),
-						FinishReason: finishReason,
-					},
-				}
-				close(eventChan)
-				return
-			}
-
-			// If there is an error we are going to see if we can retry the call
-			retry, after, retryErr := o.shouldRetry(attempts, err)
-			if retryErr != nil {
-				eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-				close(eventChan)
-				return
-			}
-			if retry {
-				slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
-				select {
-				case <-ctx.Done():
-					// context cancelled
-					if ctx.Err() != nil {
-						eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-					}
-					close(eventChan)
-					return
-				case <-time.After(time.Duration(after) * time.Millisecond):
-					continue
-				}
-			}
-			eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
-			close(eventChan)
-			return
-		}
-	}()
-
-	return eventChan
-}
-
-func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
-	if attempts > maxRetries {
-		return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
-	}
-	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
-		return false, 0, err
-	}
-	var apiErr *openai.Error
-	retryMs := 0
-	retryAfterValues := []string{}
-	if errors.As(err, &apiErr) {
-		// Check for token expiration (401 Unauthorized)
-		if apiErr.StatusCode == http.StatusUnauthorized {
-			prev := o.providerOptions.apiKey
-			// in case the key comes from a script, we try to re-evaluate it.
-			o.providerOptions.apiKey, err = config.Get().Resolve(o.providerOptions.config.APIKey)
-			if err != nil {
-				return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
-			}
-			// if it didn't change, do not retry.
-			if prev == o.providerOptions.apiKey {
-				return false, 0, err
-			}
-			o.client = createOpenAIClient(o.providerOptions)
-			return true, 0, nil
-		}
-
-		if apiErr.StatusCode == http.StatusTooManyRequests {
-			// Check if this is an insufficient quota error (permanent)
-			if apiErr.Type == "insufficient_quota" || apiErr.Code == "insufficient_quota" {
-				return false, 0, fmt.Errorf("OpenAI quota exceeded: %s. Please check your plan and billing details", apiErr.Message)
-			}
-			// Other 429 errors (rate limiting) can be retried
-		} else if apiErr.StatusCode != http.StatusInternalServerError {
-			return false, 0, err
-		}
-
-		if apiErr.Response != nil {
-			retryAfterValues = apiErr.Response.Header.Values("Retry-After")
-		}
-	}
-
-	if apiErr != nil {
-		slog.Warn("OpenAI API error", "status_code", apiErr.StatusCode, "message", apiErr.Message, "type", apiErr.Type)
-		if len(retryAfterValues) > 0 {
-			slog.Warn("Retry-After header", "values", retryAfterValues)
-		}
-	} else {
-		slog.Error("OpenAI API error", "error", err.Error(), "attempt", attempts, "max_retries", maxRetries)
-	}
-
-	backoffMs := 2000 * (1 << (attempts - 1))
-	jitterMs := int(float64(backoffMs) * 0.2)
-	retryMs = backoffMs + jitterMs
-	if len(retryAfterValues) > 0 {
-		if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
-			retryMs = retryMs * 1000
-		}
-	}
-	return true, int64(retryMs), nil
-}
-
-func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
-	var toolCalls []message.ToolCall
-
-	if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
-		for _, call := range completion.Choices[0].Message.ToolCalls {
-			// accumulator for some reason does this.
-			if call.Function.Name == "" {
-				continue
-			}
-			toolCall := message.ToolCall{
-				ID:       call.ID,
-				Name:     call.Function.Name,
-				Input:    call.Function.Arguments,
-				Type:     "function",
-				Finished: true,
-			}
-			toolCalls = append(toolCalls, toolCall)
-		}
-	}
-
-	return toolCalls
-}
-
-func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
-	cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
-	inputTokens := completion.Usage.PromptTokens - cachedTokens
-
-	return TokenUsage{
-		InputTokens:         inputTokens,
-		OutputTokens:        completion.Usage.CompletionTokens,
-		CacheCreationTokens: 0, // OpenAI doesn't provide this directly
-		CacheReadTokens:     cachedTokens,
-	}
-}
-
-func (o *openaiClient) Model() catwalk.Model {
-	return o.providerOptions.model(o.providerOptions.modelType)
-}

internal/llm/provider/openai_test.go πŸ”—

@@ -1,166 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/openai/openai-go"
-	"github.com/openai/openai-go/option"
-)
-
-func TestMain(m *testing.M) {
-	_, err := config.Init(".", "", true)
-	if err != nil {
-		panic("Failed to initialize config: " + err.Error())
-	}
-
-	os.Exit(m.Run())
-}
-
-func TestOpenAIClientStreamChoices(t *testing.T) {
-	// Create a mock server that returns Server-Sent Events with empty choices
-	// This simulates the 🀑 behavior when a server returns 200 instead of 404
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("Content-Type", "text/event-stream")
-		w.Header().Set("Cache-Control", "no-cache")
-		w.Header().Set("Connection", "keep-alive")
-		w.WriteHeader(http.StatusOK)
-
-		emptyChoicesChunk := map[string]any{
-			"id":      "chat-completion-test",
-			"object":  "chat.completion.chunk",
-			"created": time.Now().Unix(),
-			"model":   "test-model",
-			"choices": []any{}, // Empty choices array that causes panic
-		}
-
-		jsonData, _ := json.Marshal(emptyChoicesChunk)
-		w.Write([]byte("data: " + string(jsonData) + "\n\n"))
-		w.Write([]byte("data: [DONE]\n\n"))
-	}))
-	defer server.Close()
-
-	// Create OpenAI client pointing to our mock server
-	client := &openaiClient{
-		providerOptions: providerClientOptions{
-			modelType:     config.SelectedModelTypeLarge,
-			apiKey:        "test-key",
-			systemMessage: "test",
-			model: func(config.SelectedModelType) catwalk.Model {
-				return catwalk.Model{
-					ID:   "test-model",
-					Name: "test-model",
-				}
-			},
-		},
-		client: openai.NewClient(
-			option.WithAPIKey("test-key"),
-			option.WithBaseURL(server.URL),
-		),
-	}
-
-	// Create test messages
-	messages := []message.Message{
-		{
-			Role:  message.User,
-			Parts: []message.ContentPart{message.TextContent{Text: "Hello"}},
-		},
-	}
-
-	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
-	defer cancel()
-
-	eventsChan := client.stream(ctx, messages, nil)
-
-	// Collect events - this will panic without the bounds check
-	for event := range eventsChan {
-		t.Logf("Received event: %+v", event)
-		if event.Type == EventError || event.Type == EventComplete {
-			break
-		}
-	}
-}
-
-func TestOpenAIClient429InsufficientQuotaError(t *testing.T) {
-	client := &openaiClient{
-		providerOptions: providerClientOptions{
-			modelType:     config.SelectedModelTypeLarge,
-			apiKey:        "test-key",
-			systemMessage: "test",
-			config: config.ProviderConfig{
-				ID:     "test-openai",
-				APIKey: "test-key",
-			},
-			model: func(config.SelectedModelType) catwalk.Model {
-				return catwalk.Model{
-					ID:   "test-model",
-					Name: "test-model",
-				}
-			},
-		},
-	}
-
-	// Test insufficient_quota error should not retry
-	apiErr := &openai.Error{
-		StatusCode: 429,
-		Message:    "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
-		Type:       "insufficient_quota",
-		Code:       "insufficient_quota",
-	}
-
-	retry, _, err := client.shouldRetry(1, apiErr)
-	if retry {
-		t.Error("Expected shouldRetry to return false for insufficient_quota error, but got true")
-	}
-	if err == nil {
-		t.Error("Expected shouldRetry to return an error for insufficient_quota, but got nil")
-	}
-	if err != nil && !strings.Contains(err.Error(), "quota") {
-		t.Errorf("Expected error message to mention quota, got: %v", err)
-	}
-}
-
-func TestOpenAIClient429RateLimitError(t *testing.T) {
-	client := &openaiClient{
-		providerOptions: providerClientOptions{
-			modelType:     config.SelectedModelTypeLarge,
-			apiKey:        "test-key",
-			systemMessage: "test",
-			config: config.ProviderConfig{
-				ID:     "test-openai",
-				APIKey: "test-key",
-			},
-			model: func(config.SelectedModelType) catwalk.Model {
-				return catwalk.Model{
-					ID:   "test-model",
-					Name: "test-model",
-				}
-			},
-		},
-	}
-
-	// Test regular rate limit error should retry
-	apiErr := &openai.Error{
-		StatusCode: 429,
-		Message:    "Rate limit reached for requests",
-		Type:       "rate_limit_exceeded",
-		Code:       "rate_limit_exceeded",
-	}
-
-	retry, _, err := client.shouldRetry(1, apiErr)
-	if !retry {
-		t.Error("Expected shouldRetry to return true for rate_limit_exceeded error, but got false")
-	}
-	if err != nil {
-		t.Errorf("Expected shouldRetry to return nil error for rate_limit_exceeded, but got: %v", err)
-	}
-}

internal/llm/provider/provider.go πŸ”—

@@ -1,208 +0,0 @@
-package provider
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/tools"
-	"github.com/charmbracelet/crush/internal/message"
-)
-
-type EventType string
-
-const maxRetries = 3
-
-const (
-	EventContentStart   EventType = "content_start"
-	EventToolUseStart   EventType = "tool_use_start"
-	EventToolUseDelta   EventType = "tool_use_delta"
-	EventToolUseStop    EventType = "tool_use_stop"
-	EventContentDelta   EventType = "content_delta"
-	EventThinkingDelta  EventType = "thinking_delta"
-	EventSignatureDelta EventType = "signature_delta"
-	EventContentStop    EventType = "content_stop"
-	EventComplete       EventType = "complete"
-	EventError          EventType = "error"
-	EventWarning        EventType = "warning"
-)
-
-type TokenUsage struct {
-	InputTokens         int64
-	OutputTokens        int64
-	CacheCreationTokens int64
-	CacheReadTokens     int64
-}
-
-type ProviderResponse struct {
-	Content      string
-	ToolCalls    []message.ToolCall
-	Usage        TokenUsage
-	FinishReason message.FinishReason
-}
-
-type ProviderEvent struct {
-	Type EventType
-
-	Content   string
-	Thinking  string
-	Signature string
-	Response  *ProviderResponse
-	ToolCall  *message.ToolCall
-	Error     error
-}
-type Provider interface {
-	SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
-
-	StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
-
-	Model() catwalk.Model
-}
-
-type providerClientOptions struct {
-	baseURL            string
-	config             config.ProviderConfig
-	apiKey             string
-	modelType          config.SelectedModelType
-	model              func(config.SelectedModelType) catwalk.Model
-	disableCache       bool
-	systemMessage      string
-	systemPromptPrefix string
-	maxTokens          int64
-	extraHeaders       map[string]string
-	extraBody          map[string]any
-	extraParams        map[string]string
-}
-
-type ProviderClientOption func(*providerClientOptions)
-
-type ProviderClient interface {
-	send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
-	stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
-
-	Model() catwalk.Model
-}
-
-type baseProvider[C ProviderClient] struct {
-	options providerClientOptions
-	client  C
-}
-
-func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
-	for _, msg := range messages {
-		// The message has no content
-		if len(msg.Parts) == 0 {
-			continue
-		}
-		cleaned = append(cleaned, msg)
-	}
-	return cleaned
-}
-
-func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
-	messages = p.cleanMessages(messages)
-	return p.client.send(ctx, messages, tools)
-}
-
-func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
-	messages = p.cleanMessages(messages)
-	return p.client.stream(ctx, messages, tools)
-}
-
-func (p *baseProvider[C]) Model() catwalk.Model {
-	return p.client.Model()
-}
-
-func WithModel(model config.SelectedModelType) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.modelType = model
-	}
-}
-
-func WithDisableCache(disableCache bool) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.disableCache = disableCache
-	}
-}
-
-func WithSystemMessage(systemMessage string) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.systemMessage = systemMessage
-	}
-}
-
-func WithMaxTokens(maxTokens int64) ProviderClientOption {
-	return func(options *providerClientOptions) {
-		options.maxTokens = maxTokens
-	}
-}
-
-func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provider, error) {
-	restore := config.PushPopCrushEnv()
-	defer restore()
-	resolvedAPIKey, err := config.Get().Resolve(cfg.APIKey)
-	if err != nil {
-		return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
-	}
-
-	// Resolve extra headers
-	resolvedExtraHeaders := make(map[string]string)
-	for key, value := range cfg.ExtraHeaders {
-		resolvedValue, err := config.Get().Resolve(value)
-		if err != nil {
-			return nil, fmt.Errorf("failed to resolve extra header %s for provider %s: %w", key, cfg.ID, err)
-		}
-		resolvedExtraHeaders[key] = resolvedValue
-	}
-
-	clientOptions := providerClientOptions{
-		baseURL:            cfg.BaseURL,
-		config:             cfg,
-		apiKey:             resolvedAPIKey,
-		extraHeaders:       resolvedExtraHeaders,
-		extraBody:          cfg.ExtraBody,
-		extraParams:        cfg.ExtraParams,
-		systemPromptPrefix: cfg.SystemPromptPrefix,
-		model: func(tp config.SelectedModelType) catwalk.Model {
-			return *config.Get().GetModelByType(tp)
-		},
-	}
-	for _, o := range opts {
-		o(&clientOptions)
-	}
-	switch cfg.Type {
-	case catwalk.TypeAnthropic:
-		return &baseProvider[AnthropicClient]{
-			options: clientOptions,
-			client:  newAnthropicClient(clientOptions, AnthropicClientTypeNormal),
-		}, nil
-	case catwalk.TypeOpenAI:
-		return &baseProvider[OpenAIClient]{
-			options: clientOptions,
-			client:  newOpenAIClient(clientOptions),
-		}, nil
-	case catwalk.TypeGemini:
-		return &baseProvider[GeminiClient]{
-			options: clientOptions,
-			client:  newGeminiClient(clientOptions),
-		}, nil
-	case catwalk.TypeBedrock:
-		return &baseProvider[BedrockClient]{
-			options: clientOptions,
-			client:  newBedrockClient(clientOptions),
-		}, nil
-	case catwalk.TypeAzure:
-		return &baseProvider[AzureClient]{
-			options: clientOptions,
-			client:  newAzureClient(clientOptions),
-		}, nil
-	case catwalk.TypeVertexAI:
-		return &baseProvider[VertexAIClient]{
-			options: clientOptions,
-			client:  newVertexAIClient(clientOptions),
-		}, nil
-	}
-	return nil, fmt.Errorf("provider not supported: %s", cfg.Type)
-}

internal/llm/provider/vertexai.go πŸ”—

@@ -1,40 +0,0 @@
-package provider
-
-import (
-	"context"
-	"log/slog"
-	"strings"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/log"
-	"google.golang.org/genai"
-)
-
-type VertexAIClient ProviderClient
-
-func newVertexAIClient(opts providerClientOptions) VertexAIClient {
-	project := opts.extraParams["project"]
-	location := opts.extraParams["location"]
-	cc := &genai.ClientConfig{
-		Project:  project,
-		Location: location,
-		Backend:  genai.BackendVertexAI,
-	}
-	if config.Get().Options.Debug {
-		cc.HTTPClient = log.NewHTTPClient()
-	}
-	client, err := genai.NewClient(context.Background(), cc)
-	if err != nil {
-		slog.Error("Failed to create VertexAI client", "error", err)
-		return nil
-	}
-
-	model := opts.model(opts.modelType)
-	if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude") || strings.Contains(model.ID, "sonnet") {
-		return newAnthropicClient(opts, AnthropicClientTypeVertex)
-	}
-	return &geminiClient{
-		providerOptions: opts,
-		client:          client,
-	}
-}

internal/llm/tools/bash.go πŸ”—

@@ -1,395 +0,0 @@
-package tools
-
-import (
-	"bytes"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"html/template"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/permission"
-	"github.com/charmbracelet/crush/internal/shell"
-)
-
-type BashParams struct {
-	Command string `json:"command"`
-	Timeout int    `json:"timeout"`
-}
-
-type BashPermissionsParams struct {
-	Command string `json:"command"`
-	Timeout int    `json:"timeout"`
-}
-
-type BashResponseMetadata struct {
-	StartTime        int64  `json:"start_time"`
-	EndTime          int64  `json:"end_time"`
-	Output           string `json:"output"`
-	WorkingDirectory string `json:"working_directory"`
-}
-type bashTool struct {
-	permissions permission.Service
-	workingDir  string
-	attribution *config.Attribution
-}
-
-const (
-	BashToolName = "bash"
-
-	DefaultTimeout  = 1 * 60 * 1000  // 1 minutes in milliseconds
-	MaxTimeout      = 10 * 60 * 1000 // 10 minutes in milliseconds
-	MaxOutputLength = 30000
-	BashNoOutput    = "no output"
-)
-
-//go:embed bash.md
-var bashDescription []byte
-
-var bashDescriptionTpl = template.Must(
-	template.New("bashDescription").
-		Parse(string(bashDescription)),
-)
-
-type bashDescriptionData struct {
-	BannedCommands     string
-	MaxOutputLength    int
-	AttributionStep    string
-	AttributionExample string
-	PRAttribution      string
-}
-
-var bannedCommands = []string{
-	// Network/Download tools
-	"alias",
-	"aria2c",
-	"axel",
-	"chrome",
-	"curl",
-	"curlie",
-	"firefox",
-	"http-prompt",
-	"httpie",
-	"links",
-	"lynx",
-	"nc",
-	"safari",
-	"scp",
-	"ssh",
-	"telnet",
-	"w3m",
-	"wget",
-	"xh",
-
-	// System administration
-	"doas",
-	"su",
-	"sudo",
-
-	// Package managers
-	"apk",
-	"apt",
-	"apt-cache",
-	"apt-get",
-	"dnf",
-	"dpkg",
-	"emerge",
-	"home-manager",
-	"makepkg",
-	"opkg",
-	"pacman",
-	"paru",
-	"pkg",
-	"pkg_add",
-	"pkg_delete",
-	"portage",
-	"rpm",
-	"yay",
-	"yum",
-	"zypper",
-
-	// System modification
-	"at",
-	"batch",
-	"chkconfig",
-	"crontab",
-	"fdisk",
-	"mkfs",
-	"mount",
-	"parted",
-	"service",
-	"systemctl",
-	"umount",
-
-	// Network configuration
-	"firewall-cmd",
-	"ifconfig",
-	"ip",
-	"iptables",
-	"netstat",
-	"pfctl",
-	"route",
-	"ufw",
-}
-
-func (b *bashTool) bashDescription() string {
-	bannedCommandsStr := strings.Join(bannedCommands, ", ")
-
-	// Build attribution text based on settings
-	var attributionStep, attributionExample, prAttribution string
-
-	// Default to true if attribution is nil (backward compatibility)
-	generatedWith := b.attribution == nil || b.attribution.GeneratedWith
-	coAuthoredBy := b.attribution == nil || b.attribution.CoAuthoredBy
-
-	// Build PR attribution
-	if generatedWith {
-		prAttribution = "πŸ’˜ Generated with Crush"
-	}
-
-	if generatedWith || coAuthoredBy {
-		var attributionParts []string
-		if generatedWith {
-			attributionParts = append(attributionParts, "πŸ’˜ Generated with Crush")
-		}
-		if coAuthoredBy {
-			attributionParts = append(attributionParts, "Co-Authored-By: Crush <crush@charm.land>")
-		}
-
-		if len(attributionParts) > 0 {
-			attributionStep = fmt.Sprintf("4. Create the commit with a message ending with:\n%s", strings.Join(attributionParts, "\n"))
-
-			attributionText := strings.Join(attributionParts, "\n ")
-			attributionExample = fmt.Sprintf(`<example>
-git commit -m "$(cat <<'EOF'
- Commit message here.
-
- %s
- EOF
-)"</example>`, attributionText)
-		}
-	}
-
-	if attributionStep == "" {
-		attributionStep = "4. Create the commit with your commit message."
-		attributionExample = `<example>
-git commit -m "$(cat <<'EOF'
- Commit message here.
- EOF
-)"</example>`
-	}
-
-	var out bytes.Buffer
-	if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
-		BannedCommands:     bannedCommandsStr,
-		MaxOutputLength:    MaxOutputLength,
-		AttributionStep:    attributionStep,
-		AttributionExample: attributionExample,
-		PRAttribution:      prAttribution,
-	}); err != nil {
-		// this should never happen.
-		panic("failed to execute bash description template: " + err.Error())
-	}
-	return out.String()
-}
-
-func blockFuncs() []shell.BlockFunc {
-	return []shell.BlockFunc{
-		shell.CommandsBlocker(bannedCommands),
-
-		// System package managers
-		shell.ArgumentsBlocker("apk", []string{"add"}, nil),
-		shell.ArgumentsBlocker("apt", []string{"install"}, nil),
-		shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
-		shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
-		shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
-		shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
-		shell.ArgumentsBlocker("yum", []string{"install"}, nil),
-		shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
-
-		// Language-specific package managers
-		shell.ArgumentsBlocker("brew", []string{"install"}, nil),
-		shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
-		shell.ArgumentsBlocker("gem", []string{"install"}, nil),
-		shell.ArgumentsBlocker("go", []string{"install"}, nil),
-		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
-		shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
-		shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
-		shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
-		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
-		shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
-		shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
-
-		// `go test -exec` can run arbitrary commands
-		shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
-	}
-}
-
-func NewBashTool(permission permission.Service, workingDir string, attribution *config.Attribution) BaseTool {
-	// Set up command blocking on the persistent shell
-	persistentShell := shell.GetPersistentShell(workingDir)
-	persistentShell.SetBlockFuncs(blockFuncs())
-
-	return &bashTool{
-		permissions: permission,
-		workingDir:  workingDir,
-		attribution: attribution,
-	}
-}
-
-func (b *bashTool) Name() string {
-	return BashToolName
-}
-
-func (b *bashTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        BashToolName,
-		Description: b.bashDescription(),
-		Parameters: map[string]any{
-			"command": map[string]any{
-				"type":        "string",
-				"description": "The command to execute",
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in milliseconds (max 600000)",
-			},
-		},
-		Required: []string{"command"},
-	}
-}
-
-func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params BashParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("invalid parameters"), nil
-	}
-
-	if params.Timeout > MaxTimeout {
-		params.Timeout = MaxTimeout
-	} else if params.Timeout <= 0 {
-		params.Timeout = DefaultTimeout
-	}
-
-	if params.Command == "" {
-		return NewTextErrorResponse("missing command"), nil
-	}
-
-	isSafeReadOnly := false
-	cmdLower := strings.ToLower(params.Command)
-
-	for _, safe := range safeCommands {
-		if strings.HasPrefix(cmdLower, safe) {
-			if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
-				isSafeReadOnly = true
-				break
-			}
-		}
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for executing shell command")
-	}
-	if !isSafeReadOnly {
-		shell := shell.GetPersistentShell(b.workingDir)
-		p := b.permissions.Request(
-			permission.CreatePermissionRequest{
-				SessionID:   sessionID,
-				Path:        shell.GetWorkingDir(),
-				ToolCallID:  call.ID,
-				ToolName:    BashToolName,
-				Action:      "execute",
-				Description: fmt.Sprintf("Execute command: %s", params.Command),
-				Params: BashPermissionsParams{
-					Command: params.Command,
-				},
-			},
-		)
-		if !p {
-			return ToolResponse{}, permission.ErrorPermissionDenied
-		}
-	}
-	startTime := time.Now()
-	if params.Timeout > 0 {
-		var cancel context.CancelFunc
-		ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
-		defer cancel()
-	}
-
-	persistentShell := shell.GetPersistentShell(b.workingDir)
-	stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
-
-	// Get the current working directory after command execution
-	currentWorkingDir := persistentShell.GetWorkingDir()
-	interrupted := shell.IsInterrupt(err)
-	exitCode := shell.ExitCode(err)
-	if exitCode == 0 && !interrupted && err != nil {
-		return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
-	}
-
-	stdout = truncateOutput(stdout)
-	stderr = truncateOutput(stderr)
-
-	errorMessage := stderr
-	if errorMessage == "" && err != nil {
-		errorMessage = err.Error()
-	}
-
-	if interrupted {
-		if errorMessage != "" {
-			errorMessage += "\n"
-		}
-		errorMessage += "Command was aborted before completion"
-	} else if exitCode != 0 {
-		if errorMessage != "" {
-			errorMessage += "\n"
-		}
-		errorMessage += fmt.Sprintf("Exit code %d", exitCode)
-	}
-
-	hasBothOutputs := stdout != "" && stderr != ""
-
-	if hasBothOutputs {
-		stdout += "\n"
-	}
-
-	if errorMessage != "" {
-		stdout += "\n" + errorMessage
-	}
-
-	metadata := BashResponseMetadata{
-		StartTime:        startTime.UnixMilli(),
-		EndTime:          time.Now().UnixMilli(),
-		Output:           stdout,
-		WorkingDirectory: currentWorkingDir,
-	}
-	if stdout == "" {
-		return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
-	}
-	stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
-	return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
-}
-
-func truncateOutput(content string) string {
-	if len(content) <= MaxOutputLength {
-		return content
-	}
-
-	halfLength := MaxOutputLength / 2
-	start := content[:halfLength]
-	end := content[len(content)-halfLength:]
-
-	truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
-	return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
-}
-
-func countLines(s string) int {
-	if s == "" {
-		return 0
-	}
-	return len(strings.Split(s, "\n"))
-}

internal/llm/tools/bash.md πŸ”—

@@ -1,161 +0,0 @@
-Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
-
-CROSS-PLATFORM SHELL SUPPORT:
-
-- This tool uses a shell interpreter (mvdan/sh) that mimics the Bash language,
-  so you should use Bash syntax in all platforms, including Windows.
-  The most common shell builtins and core utils are available in Windows as
-  well.
-- Make sure to use forward slashes (/) as path separators in commands, even on
-  Windows. Example: "ls C:/foo/bar" instead of "ls C:\foo\bar".
-
-Before executing the command, please follow these steps:
-
-1. Directory Verification:
-
-- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
-- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
-
-2. Security Check:
-
-- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
-- Verify that the command is not one of the banned commands: {{ .BannedCommands }}.
-
-3. Command Execution:
-
-- After ensuring proper quoting, execute the command.
-- Capture the output of the command.
-
-4. Output Processing:
-
-- If the output exceeds {{ .MaxOutputLength }} characters, output will be truncated before being returned to you.
-- Prepare the output for display to the user.
-
-5. Return Result:
-
-- Provide the processed output of the command.
-- If any errors occurred during execution, include those in the output.
-- The result will also have metadata like the cwd (current working directory) at the end, included with <cwd></cwd> tags.
-
-Usage notes:
-
-- The command argument is required.
-- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
-- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search. You MUST avoid read tools like 'cat', 'head', 'tail', and 'ls', and use FileRead and LS tools to read files.
-- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
-- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
-- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
-  <good-example>
-  pytest /foo/bar/tests
-  </good-example>
-  <bad-example>
-  cd /foo/bar && pytest tests
-  </bad-example>
-
-# Committing changes with git
-
-When the user asks you to create a new git commit, follow these steps carefully:
-
-1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
-
-- Run a git status command to see all untracked files.
-- Run a git diff command to see both staged and unstaged changes that will be committed.
-- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
-
-2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
-
-3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
-
-<commit_analysis>
-
-- List the files that have been changed or added
-- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
-- Brainstorm the purpose or motivation behind these changes
-- Do not use tools to explore code, beyond what is available in the git context
-- Assess the impact of these changes on the overall project
-- Check for any sensitive information that shouldn't be committed
-- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
-- Ensure your language is clear, concise, and to the point
-- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
-- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
-- Review the draft message to ensure it accurately reflects the changes and their purpose
-  </commit_analysis>
-
-{{ .AttributionStep }}
-
-- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
-  {{ .AttributionExample }}
-
-5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
-
-6. Finally, run git status to make sure the commit succeeded.
-
-Important notes:
-
-- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
-- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
-- NEVER update the git config
-- DO NOT push to the remote repository
-- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
-- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
-- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
-- Return an empty response - the user will see the git output directly
-
-# Creating pull requests
-
-Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
-
-IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
-
-1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
-
-- Run a git status command to see all untracked files.
-- Run a git diff command to see both staged and unstaged changes that will be committed.
-- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
-- Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
-
-2. Create new branch if needed
-
-3. Commit changes if needed
-
-4. Push to remote with -u flag if needed
-
-5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
-
-<pr_analysis>
-
-- List the commits since diverging from the main branch
-- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
-- Brainstorm the purpose or motivation behind these changes
-- Assess the impact of these changes on the overall project
-- Do not use tools to explore code, beyond what is available in the git context
-- Check for any sensitive information that shouldn't be committed
-- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
-- Ensure the summary accurately reflects all changes since diverging from the main branch
-- Ensure your language is clear, concise, and to the point
-- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
-- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
-- Review the draft summary to ensure it accurately reflects the changes and their purpose
-  </pr_analysis>
-
-6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
-   <example>
-   gh pr create --title "the pr title" --body "$(cat <<'EOF'
-
-## Summary
-
-<1-3 bullet points>
-
-## Test plan
-
-[Checklist of TODOs for testing the pull request...]
-
-{{ .PRAttribution }}
-EOF
-)"
-</example>
-
-Important:
-
-- Return an empty response - the user will see the gh output directly
-- Never update git config

internal/llm/tools/diagnostics.md πŸ”—

@@ -1,21 +0,0 @@
-Get diagnostics for a file and/or project.
-WHEN TO USE THIS TOOL:
-
-- Use when you need to check for errors or warnings in your code
-- Helpful for debugging and ensuring code quality
-- Good for getting a quick overview of issues in a file or project
-  HOW TO USE:
-- Provide a path to a file to get diagnostics for that file
-- Leave the path empty to get diagnostics for the entire project
-- Results are displayed in a structured format with severity levels
-  FEATURES:
-- Displays errors, warnings, and hints
-- Groups diagnostics by severity
-- Provides detailed information about each diagnostic
-  LIMITATIONS:
-- Results are limited to the diagnostics provided by the LSP clients
-- May not cover all possible issues in the code
-- Does not provide suggestions for fixing issues
-  TIPS:
-- Use in conjunction with other tools for a comprehensive code review
-- Combine with the LSP client for real-time diagnostics

internal/llm/tools/download.go πŸ”—

@@ -1,196 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type DownloadParams struct {
-	URL      string `json:"url"`
-	FilePath string `json:"file_path"`
-	Timeout  int    `json:"timeout,omitempty"`
-}
-
-type DownloadPermissionsParams struct {
-	URL      string `json:"url"`
-	FilePath string `json:"file_path"`
-	Timeout  int    `json:"timeout,omitempty"`
-}
-
-type downloadTool struct {
-	client      *http.Client
-	permissions permission.Service
-	workingDir  string
-}
-
-const DownloadToolName = "download"
-
-//go:embed download.md
-var downloadDescription []byte
-
-func NewDownloadTool(permissions permission.Service, workingDir string) BaseTool {
-	return &downloadTool{
-		client: &http.Client{
-			Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
-		},
-		permissions: permissions,
-		workingDir:  workingDir,
-	}
-}
-
-func (t *downloadTool) Name() string {
-	return DownloadToolName
-}
-
-func (t *downloadTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        DownloadToolName,
-		Description: string(downloadDescription),
-		Parameters: map[string]any{
-			"url": map[string]any{
-				"type":        "string",
-				"description": "The URL to download from",
-			},
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The local file path where the downloaded content should be saved",
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in seconds (max 600)",
-			},
-		},
-		Required: []string{"url", "file_path"},
-	}
-}
-
-func (t *downloadTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params DownloadParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("Failed to parse download parameters: " + err.Error()), nil
-	}
-
-	if params.URL == "" {
-		return NewTextErrorResponse("URL parameter is required"), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path parameter is required"), nil
-	}
-
-	if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
-		return NewTextErrorResponse("URL must start with http:// or https://"), nil
-	}
-
-	// Convert relative path to absolute path
-	var filePath string
-	if filepath.IsAbs(params.FilePath) {
-		filePath = params.FilePath
-	} else {
-		filePath = filepath.Join(t.workingDir, params.FilePath)
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for downloading files")
-	}
-
-	p := t.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        filePath,
-			ToolName:    DownloadToolName,
-			Action:      "download",
-			Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
-			Params:      DownloadPermissionsParams(params),
-		},
-	)
-
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	// Handle timeout with context
-	requestCtx := ctx
-	if params.Timeout > 0 {
-		maxTimeout := 600 // 10 minutes
-		if params.Timeout > maxTimeout {
-			params.Timeout = maxTimeout
-		}
-		var cancel context.CancelFunc
-		requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
-		defer cancel()
-	}
-
-	req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("User-Agent", "crush/1.0")
-
-	resp, err := t.client.Do(req)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
-	}
-
-	// Check content length if available
-	maxSize := int64(100 * 1024 * 1024) // 100MB
-	if resp.ContentLength > maxSize {
-		return NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
-	}
-
-	// Create parent directories if they don't exist
-	if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
-	}
-
-	// Create the output file
-	outFile, err := os.Create(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create output file: %w", err)
-	}
-	defer outFile.Close()
-
-	// Copy data with size limit
-	limitedReader := io.LimitReader(resp.Body, maxSize)
-	bytesWritten, err := io.Copy(outFile, limitedReader)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Check if we hit the size limit
-	if bytesWritten == maxSize {
-		// Clean up the file since it might be incomplete
-		os.Remove(filePath)
-		return NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
-	}
-
-	contentType := resp.Header.Get("Content-Type")
-	responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, filePath)
-	if contentType != "" {
-		responseMsg += fmt.Sprintf(" (Content-Type: %s)", contentType)
-	}
-
-	return NewTextResponse(responseMsg), nil
-}

internal/llm/tools/download.md πŸ”—

@@ -1,34 +0,0 @@
-Downloads binary data from a URL and saves it to a local file.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to download files, images, or other binary data from URLs
-- Helpful for downloading assets, documents, or any file type
-- Useful for saving remote content locally for processing or storage
-
-HOW TO USE:
-
-- Provide the URL to download from
-- Specify the local file path where the content should be saved
-- Optionally set a timeout for the request
-
-FEATURES:
-
-- Downloads any file type (binary or text)
-- Automatically creates parent directories if they don't exist
-- Handles large files efficiently with streaming
-- Sets reasonable timeouts to prevent hanging
-- Validates input parameters before making requests
-
-LIMITATIONS:
-
-- Maximum file size is 100MB
-- Only supports HTTP and HTTPS protocols
-- Cannot handle authentication or cookies
-- Some websites may block automated requests
-- Will overwrite existing files without warning
-
-TIPS:
-
-- Use absolute paths or paths relative to the working directory
-- Set appropriate timeouts for large files or slow connections

internal/llm/tools/edit.go πŸ”—

@@ -1,486 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type EditParams struct {
-	FilePath   string `json:"file_path"`
-	OldString  string `json:"old_string"`
-	NewString  string `json:"new_string"`
-	ReplaceAll bool   `json:"replace_all,omitempty"`
-}
-
-type EditPermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type EditResponseMetadata struct {
-	Additions  int    `json:"additions"`
-	Removals   int    `json:"removals"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type editTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	permissions permission.Service
-	files       history.Service
-	workingDir  string
-}
-
-const EditToolName = "edit"
-
-//go:embed edit.md
-var editDescription []byte
-
-func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
-	return &editTool{
-		lspClients:  lspClients,
-		permissions: permissions,
-		files:       files,
-		workingDir:  workingDir,
-	}
-}
-
-func (e *editTool) Name() string {
-	return EditToolName
-}
-
-func (e *editTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        EditToolName,
-		Description: string(editDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The absolute path to the file to modify",
-			},
-			"old_string": map[string]any{
-				"type":        "string",
-				"description": "The text to replace",
-			},
-			"new_string": map[string]any{
-				"type":        "string",
-				"description": "The text to replace it with",
-			},
-			"replace_all": map[string]any{
-				"type":        "boolean",
-				"description": "Replace all occurrences of old_string (default false)",
-			},
-		},
-		Required: []string{"file_path", "old_string", "new_string"},
-	}
-}
-
-func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params EditParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("invalid parameters"), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	if !filepath.IsAbs(params.FilePath) {
-		params.FilePath = filepath.Join(e.workingDir, params.FilePath)
-	}
-
-	var response ToolResponse
-	var err error
-
-	if params.OldString == "" {
-		response, err = e.createNewFile(ctx, params.FilePath, params.NewString, call)
-		if err != nil {
-			return response, err
-		}
-	}
-
-	if params.NewString == "" {
-		response, err = e.deleteContent(ctx, params.FilePath, params.OldString, params.ReplaceAll, call)
-		if err != nil {
-			return response, err
-		}
-	}
-
-	response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
-	if err != nil {
-		return response, err
-	}
-	if response.IsError {
-		// Return early if there was an error during content replacement
-		// This prevents unnecessary LSP diagnostics processing
-		return response, nil
-	}
-
-	notifyLSPs(ctx, e.lspClients, params.FilePath)
-
-	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-	text += getDiagnostics(params.FilePath, e.lspClients)
-	response.Content = text
-	return response, nil
-}
-
-func (e *editTool) createNewFile(ctx context.Context, filePath, content string, call ToolCall) (ToolResponse, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err == nil {
-		if fileInfo.IsDir() {
-			return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
-		}
-		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
-	} else if !os.IsNotExist(err) {
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	dir := filepath.Dir(filePath)
-	if err = os.MkdirAll(dir, 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	_, additions, removals := diff.GenerateDiff(
-		"",
-		content,
-		strings.TrimPrefix(filePath, e.workingDir),
-	)
-	p := e.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    EditToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Create file %s", filePath),
-			Params: EditPermissionsParams{
-				FilePath:   filePath,
-				OldContent: "",
-				NewContent: content,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	err = os.WriteFile(filePath, []byte(content), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// File can't be in the history so we create a new file history
-	_, err = e.files.Create(ctx, sessionID, filePath, "")
-	if err != nil {
-		// Log error but don't fail the operation
-		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-	}
-
-	// Add the new content to the file history
-	_, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
-	if err != nil {
-		// Log error but don't fail the operation
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	return WithResponseMetadata(
-		NewTextResponse("File created: "+filePath),
-		EditResponseMetadata{
-			OldContent: "",
-			NewContent: content,
-			Additions:  additions,
-			Removals:   removals,
-		},
-	), nil
-}
-
-func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
-	}
-
-	if getLastReadTime(filePath).IsZero() {
-		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
-	}
-
-	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(filePath)
-	if modTime.After(lastRead) {
-		return NewTextErrorResponse(
-			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
-				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
-			)), nil
-	}
-
-	content, err := os.ReadFile(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
-	}
-
-	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
-
-	var newContent string
-	var deletionCount int
-
-	if replaceAll {
-		newContent = strings.ReplaceAll(oldContent, oldString, "")
-		deletionCount = strings.Count(oldContent, oldString)
-		if deletionCount == 0 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-	} else {
-		index := strings.Index(oldContent, oldString)
-		if index == -1 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-
-		lastIndex := strings.LastIndex(oldContent, oldString)
-		if index != lastIndex {
-			return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
-		}
-
-		newContent = oldContent[:index] + oldContent[index+len(oldString):]
-		deletionCount = 1
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	_, additions, removals := diff.GenerateDiff(
-		oldContent,
-		newContent,
-		strings.TrimPrefix(filePath, e.workingDir),
-	)
-
-	p := e.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    EditToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Delete content from file %s", filePath),
-			Params: EditPermissionsParams{
-				FilePath:   filePath,
-				OldContent: oldContent,
-				NewContent: newContent,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	if isCrlf {
-		newContent, _ = fsext.ToWindowsLineEndings(newContent)
-	}
-
-	err = os.WriteFile(filePath, []byte(newContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Check if file exists in history
-	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
-	if err != nil {
-		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			// Log error but don't fail the operation
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User Manually changed the content store an intermediate version
-		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-	// Store the new version
-	_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	return WithResponseMetadata(
-		NewTextResponse("Content deleted from file: "+filePath),
-		EditResponseMetadata{
-			OldContent: oldContent,
-			NewContent: newContent,
-			Additions:  additions,
-			Removals:   removals,
-		},
-	), nil
-}
-
-func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
-	}
-
-	if getLastReadTime(filePath).IsZero() {
-		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
-	}
-
-	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(filePath)
-	if modTime.After(lastRead) {
-		return NewTextErrorResponse(
-			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
-				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
-			)), nil
-	}
-
-	content, err := os.ReadFile(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
-	}
-
-	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
-
-	var newContent string
-	var replacementCount int
-
-	if replaceAll {
-		newContent = strings.ReplaceAll(oldContent, oldString, newString)
-		replacementCount = strings.Count(oldContent, oldString)
-		if replacementCount == 0 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-	} else {
-		index := strings.Index(oldContent, oldString)
-		if index == -1 {
-			return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
-		}
-
-		lastIndex := strings.LastIndex(oldContent, oldString)
-		if index != lastIndex {
-			return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
-		}
-
-		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
-		replacementCount = 1
-	}
-
-	if oldContent == newContent {
-		return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
-	}
-	sessionID, messageID := GetContextValues(ctx)
-
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-	_, additions, removals := diff.GenerateDiff(
-		oldContent,
-		newContent,
-		strings.TrimPrefix(filePath, e.workingDir),
-	)
-
-	p := e.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, e.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    EditToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Replace content in file %s", filePath),
-			Params: EditPermissionsParams{
-				FilePath:   filePath,
-				OldContent: oldContent,
-				NewContent: newContent,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	if isCrlf {
-		newContent, _ = fsext.ToWindowsLineEndings(newContent)
-	}
-
-	err = os.WriteFile(filePath, []byte(newContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Check if file exists in history
-	file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
-	if err != nil {
-		_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			// Log error but don't fail the operation
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User Manually changed the content store an intermediate version
-		_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-	// Store the new version
-	_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	return WithResponseMetadata(
-		NewTextResponse("Content replaced in file: "+filePath),
-		EditResponseMetadata{
-			OldContent: oldContent,
-			NewContent: newContent,
-			Additions:  additions,
-			Removals:   removals,
-		}), nil
-}

internal/llm/tools/edit.md πŸ”—

@@ -1,60 +0,0 @@
-Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
-
-Before using this tool:
-
-1. Use the FileRead tool to understand the file's contents and context
-
-2. Verify the directory path is correct (only applicable when creating new files):
-   - Use the LS tool to verify the parent directory exists and is the correct location
-
-To make a file edit, provide the following:
-
-1. file_path: The absolute path to the file to modify (must be absolute, not relative)
-2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
-3. new_string: The edited text to replace the old_string
-4. replace_all: Replace all occurrences of old_string (default false)
-
-Special cases:
-
-- To create a new file: provide file_path and new_string, leave old_string empty
-- To delete content: provide file_path and old_string, leave new_string empty
-
-The tool will replace ONE occurrence of old_string with new_string in the specified file by default. Set replace_all to true to replace all occurrences.
-
-CRITICAL REQUIREMENTS FOR USING THIS TOOL:
-
-1. UNIQUENESS: When replace_all is false (default), the old_string MUST uniquely identify the specific instance you want to change. This means:
-   - Include AT LEAST 3-5 lines of context BEFORE the change point
-   - Include AT LEAST 3-5 lines of context AFTER the change point
-   - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
-
-2. SINGLE INSTANCE: When replace_all is false, this tool can only change ONE instance at a time. If you need to change multiple instances:
-   - Set replace_all to true to replace all occurrences at once
-   - Or make separate calls to this tool for each instance
-   - Each call must uniquely identify its specific instance using extensive context
-
-3. VERIFICATION: Before using this tool:
-   - Check how many instances of the target text exist in the file
-   - If multiple instances exist and replace_all is false, gather enough context to uniquely identify each one
-   - Plan separate tool calls for each instance or use replace_all
-
-WARNING: If you do not follow these requirements:
-
-- The tool will fail if old_string matches multiple locations and replace_all is false
-- The tool will fail if old_string doesn't match exactly (including whitespace)
-- You may change the wrong instance if you don't include enough context
-
-When making edits:
-
-- Ensure the edit results in idiomatic, correct code
-- Do not leave the code in a broken state
-- Always use absolute file paths (starting with /)
-
-WINDOWS NOTES:
-
-- File paths should use forward slashes (/) for cross-platform compatibility
-- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
-- File permissions are handled automatically by the Go runtime
-- Always assumes \n for line endings. The tool will handle \r\n conversion automatically if needed.
-
-Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.

internal/llm/tools/fetch.go πŸ”—

@@ -1,236 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"time"
-	"unicode/utf8"
-
-	md "github.com/JohannesKaufmann/html-to-markdown"
-	"github.com/PuerkitoBio/goquery"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type FetchParams struct {
-	URL     string `json:"url"`
-	Format  string `json:"format"`
-	Timeout int    `json:"timeout,omitempty"`
-}
-
-type FetchPermissionsParams struct {
-	URL     string `json:"url"`
-	Format  string `json:"format"`
-	Timeout int    `json:"timeout,omitempty"`
-}
-
-type fetchTool struct {
-	client      *http.Client
-	permissions permission.Service
-	workingDir  string
-}
-
-const FetchToolName = "fetch"
-
-//go:embed fetch.md
-var fetchDescription []byte
-
-func NewFetchTool(permissions permission.Service, workingDir string) BaseTool {
-	return &fetchTool{
-		client: &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
-		},
-		permissions: permissions,
-		workingDir:  workingDir,
-	}
-}
-
-func (t *fetchTool) Name() string {
-	return FetchToolName
-}
-
-func (t *fetchTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        FetchToolName,
-		Description: string(fetchDescription),
-		Parameters: map[string]any{
-			"url": map[string]any{
-				"type":        "string",
-				"description": "The URL to fetch content from",
-			},
-			"format": map[string]any{
-				"type":        "string",
-				"description": "The format to return the content in (text, markdown, or html)",
-				"enum":        []string{"text", "markdown", "html"},
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in seconds (max 120)",
-			},
-		},
-		Required: []string{"url", "format"},
-	}
-}
-
-func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params FetchParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("Failed to parse fetch parameters: " + err.Error()), nil
-	}
-
-	if params.URL == "" {
-		return NewTextErrorResponse("URL parameter is required"), nil
-	}
-
-	format := strings.ToLower(params.Format)
-	if format != "text" && format != "markdown" && format != "html" {
-		return NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
-	}
-
-	if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
-		return NewTextErrorResponse("URL must start with http:// or https://"), nil
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	p := t.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        t.workingDir,
-			ToolCallID:  call.ID,
-			ToolName:    FetchToolName,
-			Action:      "fetch",
-			Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
-			Params:      FetchPermissionsParams(params),
-		},
-	)
-
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	// Handle timeout with context
-	requestCtx := ctx
-	if params.Timeout > 0 {
-		maxTimeout := 120 // 2 minutes
-		if params.Timeout > maxTimeout {
-			params.Timeout = maxTimeout
-		}
-		var cancel context.CancelFunc
-		requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
-		defer cancel()
-	}
-
-	req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("User-Agent", "crush/1.0")
-
-	resp, err := t.client.Do(req)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
-	}
-
-	maxSize := int64(5 * 1024 * 1024) // 5MB
-	body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
-	if err != nil {
-		return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
-	}
-
-	content := string(body)
-
-	isValidUt8 := utf8.ValidString(content)
-	if !isValidUt8 {
-		return NewTextErrorResponse("Response content is not valid UTF-8"), nil
-	}
-	contentType := resp.Header.Get("Content-Type")
-
-	switch format {
-	case "text":
-		if strings.Contains(contentType, "text/html") {
-			text, err := extractTextFromHTML(content)
-			if err != nil {
-				return NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
-			}
-			content = text
-		}
-
-	case "markdown":
-		if strings.Contains(contentType, "text/html") {
-			markdown, err := convertHTMLToMarkdown(content)
-			if err != nil {
-				return NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
-			}
-			content = markdown
-		}
-
-		content = "```\n" + content + "\n```"
-
-	case "html":
-		// return only the body of the HTML document
-		if strings.Contains(contentType, "text/html") {
-			doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
-			if err != nil {
-				return NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
-			}
-			body, err := doc.Find("body").Html()
-			if err != nil {
-				return NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
-			}
-			if body == "" {
-				return NewTextErrorResponse("No body content found in HTML"), nil
-			}
-			content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
-		}
-	}
-	// calculate byte size of content
-	contentSize := int64(len(content))
-	if contentSize > MaxReadSize {
-		content = content[:MaxReadSize]
-		content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
-	}
-
-	return NewTextResponse(content), nil
-}
-
-func extractTextFromHTML(html string) (string, error) {
-	doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
-	if err != nil {
-		return "", err
-	}
-
-	text := doc.Find("body").Text()
-	text = strings.Join(strings.Fields(text), " ")
-
-	return text, nil
-}
-
-func convertHTMLToMarkdown(html string) (string, error) {
-	converter := md.NewConverter("", true, nil)
-
-	markdown, err := converter.ConvertString(html)
-	if err != nil {
-		return "", err
-	}
-
-	return markdown, nil
-}

internal/llm/tools/fetch.md πŸ”—

@@ -1,34 +0,0 @@
-Fetches content from a URL and returns it in the specified format.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to download content from a URL
-- Helpful for retrieving documentation, API responses, or web content
-- Useful for getting external information to assist with tasks
-
-HOW TO USE:
-
-- Provide the URL to fetch content from
-- Specify the desired output format (text, markdown, or html)
-- Optionally set a timeout for the request
-
-FEATURES:
-
-- Supports three output formats: text, markdown, and html
-- Automatically handles HTTP redirects
-- Sets reasonable timeouts to prevent hanging
-- Validates input parameters before making requests
-
-LIMITATIONS:
-
-- Maximum response size is 5MB
-- Only supports HTTP and HTTPS protocols
-- Cannot handle authentication or cookies
-- Some websites may block automated requests
-
-TIPS:
-
-- Use text format for plain text content or simple API responses
-- Use markdown format for content that should be rendered with formatting
-- Use html format when you need the raw HTML structure
-- Set appropriate timeouts for potentially slow websites

internal/llm/tools/glob.go πŸ”—

@@ -1,150 +0,0 @@
-package tools
-
-import (
-	"bytes"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os/exec"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"github.com/charmbracelet/crush/internal/fsext"
-)
-
-const GlobToolName = "glob"
-
-//go:embed glob.md
-var globDescription []byte
-
-type GlobParams struct {
-	Pattern string `json:"pattern"`
-	Path    string `json:"path"`
-}
-
-type GlobResponseMetadata struct {
-	NumberOfFiles int  `json:"number_of_files"`
-	Truncated     bool `json:"truncated"`
-}
-
-type globTool struct {
-	workingDir string
-}
-
-func NewGlobTool(workingDir string) BaseTool {
-	return &globTool{
-		workingDir: workingDir,
-	}
-}
-
-func (g *globTool) Name() string {
-	return GlobToolName
-}
-
-func (g *globTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        GlobToolName,
-		Description: string(globDescription),
-		Parameters: map[string]any{
-			"pattern": map[string]any{
-				"type":        "string",
-				"description": "The glob pattern to match files against",
-			},
-			"path": map[string]any{
-				"type":        "string",
-				"description": "The directory to search in. Defaults to the current working directory.",
-			},
-		},
-		Required: []string{"pattern"},
-	}
-}
-
-func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params GlobParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.Pattern == "" {
-		return NewTextErrorResponse("pattern is required"), nil
-	}
-
-	searchPath := params.Path
-	if searchPath == "" {
-		searchPath = g.workingDir
-	}
-
-	files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error finding files: %w", err)
-	}
-
-	var output string
-	if len(files) == 0 {
-		output = "No files found"
-	} else {
-		output = strings.Join(files, "\n")
-		if truncated {
-			output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
-		}
-	}
-
-	return WithResponseMetadata(
-		NewTextResponse(output),
-		GlobResponseMetadata{
-			NumberOfFiles: len(files),
-			Truncated:     truncated,
-		},
-	), nil
-}
-
-func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
-	cmdRg := getRgCmd(ctx, pattern)
-	if cmdRg != nil {
-		cmdRg.Dir = searchPath
-		matches, err := runRipgrep(cmdRg, searchPath, limit)
-		if err == nil {
-			return matches, len(matches) >= limit && limit > 0, nil
-		}
-		slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
-	}
-
-	return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
-}
-
-func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
-	out, err := cmd.CombinedOutput()
-	if err != nil {
-		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
-			return nil, nil
-		}
-		return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
-	}
-
-	var matches []string
-	for p := range bytes.SplitSeq(out, []byte{0}) {
-		if len(p) == 0 {
-			continue
-		}
-		absPath := string(p)
-		if !filepath.IsAbs(absPath) {
-			absPath = filepath.Join(searchRoot, absPath)
-		}
-		if fsext.SkipHidden(absPath) {
-			continue
-		}
-		matches = append(matches, absPath)
-	}
-
-	sort.SliceStable(matches, func(i, j int) bool {
-		return len(matches[i]) < len(matches[j])
-	})
-
-	if limit > 0 && len(matches) > limit {
-		matches = matches[:limit]
-	}
-	return matches, nil
-}

internal/llm/tools/glob.md πŸ”—

@@ -1,46 +0,0 @@
-Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to find files by name patterns or extensions
-- Great for finding specific file types across a directory structure
-- Useful for discovering files that match certain naming conventions
-
-HOW TO USE:
-
-- Provide a glob pattern to match against file paths
-- Optionally specify a starting directory (defaults to current working directory)
-- Results are sorted with most recently modified files first
-
-GLOB PATTERN SYNTAX:
-
-- '\*' matches any sequence of non-separator characters
-- '\*\*' matches any sequence of characters, including separators
-- '?' matches any single non-separator character
-- '[...]' matches any character in the brackets
-- '[!...]' matches any character not in the brackets
-
-COMMON PATTERN EXAMPLES:
-
-- '\*.js' - Find all JavaScript files in the current directory
-- '\*_/_.js' - Find all JavaScript files in any subdirectory
-- 'src/\*_/_.{ts,tsx}' - Find all TypeScript files in the src directory
-- '\*.{html,css,js}' - Find all HTML, CSS, and JS files
-
-LIMITATIONS:
-
-- Results are limited to 100 files (newest first)
-- Does not search file contents (use Grep tool for that)
-- Hidden files (starting with '.') are skipped
-
-WINDOWS NOTES:
-
-- Path separators are handled automatically (both / and \ work)
-- Uses ripgrep (rg) command if available, otherwise falls back to built-in Go implementation
-
-TIPS:
-
-- Patterns should use forward slashes (/) for cross-platform compatibility
-- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
-- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
-- Always check if results are truncated and refine your search pattern if needed

internal/llm/tools/grep.md πŸ”—

@@ -1,54 +0,0 @@
-Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to find files containing specific text or patterns
-- Great for searching code bases for function names, variable declarations, or error messages
-- Useful for finding all files that use a particular API or pattern
-
-HOW TO USE:
-
-- Provide a regex pattern to search for within file contents
-- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
-- Optionally specify a starting directory (defaults to current working directory)
-- Optionally provide an include pattern to filter which files to search
-- Results are sorted with most recently modified files first
-
-REGEX PATTERN SYNTAX (when literal_text=false):
-
-- Supports standard regular expression syntax
-- 'function' searches for the literal text "function"
-- 'log\..\*Error' finds text starting with "log." and ending with "Error"
-- 'import\s+.\*\s+from' finds import statements in JavaScript/TypeScript
-
-COMMON INCLUDE PATTERN EXAMPLES:
-
-- '\*.js' - Only search JavaScript files
-- '\*.{ts,tsx}' - Only search TypeScript files
-- '\*.go' - Only search Go files
-
-LIMITATIONS:
-
-- Results are limited to 100 files (newest first)
-- Performance depends on the number of files being searched
-- Very large binary files may be skipped
-- Hidden files (starting with '.') are skipped
-
-IGNORE FILE SUPPORT:
-
-- Respects .gitignore patterns to skip ignored files and directories
-- Respects .crushignore patterns for additional ignore rules
-- Both ignore files are automatically detected in the search root directory
-
-CROSS-PLATFORM NOTES:
-
-- Uses ripgrep (rg) command if available for better performance
-- Falls back to built-in Go implementation if ripgrep is not available
-- File paths are normalized automatically for cross-platform compatibility
-
-TIPS:
-
-- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
-- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
-- Always check if results are truncated and refine your search pattern if needed
-- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.

internal/llm/tools/ls.md πŸ”—

@@ -1,40 +0,0 @@
-Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to explore the structure of a directory
-- Helpful for understanding the organization of a project
-- Good first step when getting familiar with a new codebase
-
-HOW TO USE:
-
-- Provide a path to list (defaults to current working directory)
-- Optionally specify glob patterns to ignore
-- Results are displayed in a tree structure
-
-FEATURES:
-
-- Displays a hierarchical view of files and directories
-- Automatically skips hidden files/directories (starting with '.')
-- Skips common system directories like **pycache**
-- Can filter out files matching specific patterns
-
-LIMITATIONS:
-
-- Results are limited to 1000 files
-- Very large directories will be truncated
-- Does not show file sizes or permissions
-- Cannot recursively list all directories in a large project
-
-WINDOWS NOTES:
-
-- Hidden file detection uses Unix convention (files starting with '.')
-- Windows-specific hidden files (with hidden attribute) are not automatically skipped
-- Common Windows directories like System32, Program Files are not in default ignore list
-- Path separators are handled automatically (both / and \ work)
-
-TIPS:
-
-- Use Glob tool for finding files by name patterns instead of browsing
-- Use Grep tool for searching file contents
-- Combine with other tools for more effective exploration

internal/llm/tools/multiedit.go πŸ”—

@@ -1,424 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-type MultiEditOperation struct {
-	OldString  string `json:"old_string"`
-	NewString  string `json:"new_string"`
-	ReplaceAll bool   `json:"replace_all,omitempty"`
-}
-
-type MultiEditParams struct {
-	FilePath string               `json:"file_path"`
-	Edits    []MultiEditOperation `json:"edits"`
-}
-
-type MultiEditPermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type MultiEditResponseMetadata struct {
-	Additions    int    `json:"additions"`
-	Removals     int    `json:"removals"`
-	OldContent   string `json:"old_content,omitempty"`
-	NewContent   string `json:"new_content,omitempty"`
-	EditsApplied int    `json:"edits_applied"`
-}
-
-type multiEditTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	permissions permission.Service
-	files       history.Service
-	workingDir  string
-}
-
-const MultiEditToolName = "multiedit"
-
-//go:embed multiedit.md
-var multieditDescription []byte
-
-func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
-	return &multiEditTool{
-		lspClients:  lspClients,
-		permissions: permissions,
-		files:       files,
-		workingDir:  workingDir,
-	}
-}
-
-func (m *multiEditTool) Name() string {
-	return MultiEditToolName
-}
-
-func (m *multiEditTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        MultiEditToolName,
-		Description: string(multieditDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The absolute path to the file to modify",
-			},
-			"edits": map[string]any{
-				"type": "array",
-				"items": map[string]any{
-					"type": "object",
-					"properties": map[string]any{
-						"old_string": map[string]any{
-							"type":        "string",
-							"description": "The text to replace",
-						},
-						"new_string": map[string]any{
-							"type":        "string",
-							"description": "The text to replace it with",
-						},
-						"replace_all": map[string]any{
-							"type":        "boolean",
-							"default":     false,
-							"description": "Replace all occurrences of old_string (default false).",
-						},
-					},
-					"required":             []string{"old_string", "new_string"},
-					"additionalProperties": false,
-				},
-				"minItems":    1,
-				"description": "Array of edit operations to perform sequentially on the file",
-			},
-		},
-		Required: []string{"file_path", "edits"},
-	}
-}
-
-func (m *multiEditTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params MultiEditParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("invalid parameters"), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	if len(params.Edits) == 0 {
-		return NewTextErrorResponse("at least one edit operation is required"), nil
-	}
-
-	if !filepath.IsAbs(params.FilePath) {
-		params.FilePath = filepath.Join(m.workingDir, params.FilePath)
-	}
-
-	// Validate all edits before applying any
-	if err := m.validateEdits(params.Edits); err != nil {
-		return NewTextErrorResponse(err.Error()), nil
-	}
-
-	var response ToolResponse
-	var err error
-
-	// Handle file creation case (first edit has empty old_string)
-	if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
-		response, err = m.processMultiEditWithCreation(ctx, params, call)
-	} else {
-		response, err = m.processMultiEditExistingFile(ctx, params, call)
-	}
-
-	if err != nil {
-		return response, err
-	}
-
-	if response.IsError {
-		return response, nil
-	}
-
-	// Notify LSP clients about the change
-	notifyLSPs(ctx, m.lspClients, params.FilePath)
-
-	// Wait for LSP diagnostics and add them to the response
-	text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
-	text += getDiagnostics(params.FilePath, m.lspClients)
-	response.Content = text
-	return response, nil
-}
-
-func (m *multiEditTool) validateEdits(edits []MultiEditOperation) error {
-	for i, edit := range edits {
-		if edit.OldString == edit.NewString {
-			return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
-		}
-		// Only the first edit can have empty old_string (for file creation)
-		if i > 0 && edit.OldString == "" {
-			return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
-		}
-	}
-	return nil
-}
-
-func (m *multiEditTool) processMultiEditWithCreation(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
-	// First edit creates the file
-	firstEdit := params.Edits[0]
-	if firstEdit.OldString != "" {
-		return NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
-	}
-
-	// Check if file already exists
-	if _, err := os.Stat(params.FilePath); err == nil {
-		return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
-	} else if !os.IsNotExist(err) {
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	// Create parent directories
-	dir := filepath.Dir(params.FilePath)
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
-	}
-
-	// Start with the content from the first edit
-	currentContent := firstEdit.NewString
-
-	// Apply remaining edits to the content
-	for i := 1; i < len(params.Edits); i++ {
-		edit := params.Edits[i]
-		newContent, err := m.applyEditToContent(currentContent, edit)
-		if err != nil {
-			return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
-		}
-		currentContent = newContent
-	}
-
-	// Get session and message IDs
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
-	}
-
-	// Check permissions
-	_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
-
-	p := m.permissions.Request(permission.CreatePermissionRequest{
-		SessionID:   sessionID,
-		Path:        fsext.PathOrPrefix(params.FilePath, m.workingDir),
-		ToolCallID:  call.ID,
-		ToolName:    MultiEditToolName,
-		Action:      "write",
-		Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
-		Params: MultiEditPermissionsParams{
-			FilePath:   params.FilePath,
-			OldContent: "",
-			NewContent: currentContent,
-		},
-	})
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	// Write the file
-	err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Update file history
-	_, err = m.files.Create(ctx, sessionID, params.FilePath, "")
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-	}
-
-	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(params.FilePath)
-	recordFileRead(params.FilePath)
-
-	return WithResponseMetadata(
-		NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
-		MultiEditResponseMetadata{
-			OldContent:   "",
-			NewContent:   currentContent,
-			Additions:    additions,
-			Removals:     removals,
-			EditsApplied: len(params.Edits),
-		},
-	), nil
-}
-
-func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
-	// Validate file exists and is readable
-	fileInfo, err := os.Stat(params.FilePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
-	}
-
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
-	}
-
-	// Check if file was read before editing
-	if getLastReadTime(params.FilePath).IsZero() {
-		return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
-	}
-
-	// Check if file was modified since last read
-	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(params.FilePath)
-	if modTime.After(lastRead) {
-		return NewTextErrorResponse(
-			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
-				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
-			)), nil
-	}
-
-	// Read current file content
-	content, err := os.ReadFile(params.FilePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
-	}
-
-	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
-	currentContent := oldContent
-
-	// Apply all edits sequentially
-	for i, edit := range params.Edits {
-		newContent, err := m.applyEditToContent(currentContent, edit)
-		if err != nil {
-			return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
-		}
-		currentContent = newContent
-	}
-
-	// Check if content actually changed
-	if oldContent == currentContent {
-		return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
-	}
-
-	// Get session and message IDs
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
-	}
-
-	// Generate diff and check permissions
-	_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
-	p := m.permissions.Request(permission.CreatePermissionRequest{
-		SessionID:   sessionID,
-		Path:        fsext.PathOrPrefix(params.FilePath, m.workingDir),
-		ToolCallID:  call.ID,
-		ToolName:    MultiEditToolName,
-		Action:      "write",
-		Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
-		Params: MultiEditPermissionsParams{
-			FilePath:   params.FilePath,
-			OldContent: oldContent,
-			NewContent: currentContent,
-		},
-	})
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	if isCrlf {
-		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
-	}
-
-	// Write the updated content
-	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
-	}
-
-	// Update file history
-	file, err := m.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
-	if err != nil {
-		_, err = m.files.Create(ctx, sessionID, params.FilePath, oldContent)
-		if err != nil {
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User manually changed the content, store an intermediate version
-		_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-
-	// Store the new version
-	_, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(params.FilePath)
-	recordFileRead(params.FilePath)
-
-	return WithResponseMetadata(
-		NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
-		MultiEditResponseMetadata{
-			OldContent:   oldContent,
-			NewContent:   currentContent,
-			Additions:    additions,
-			Removals:     removals,
-			EditsApplied: len(params.Edits),
-		},
-	), nil
-}
-
-func (m *multiEditTool) applyEditToContent(content string, edit MultiEditOperation) (string, error) {
-	if edit.OldString == "" && edit.NewString == "" {
-		return content, nil
-	}
-
-	if edit.OldString == "" {
-		return "", fmt.Errorf("old_string cannot be empty for content replacement")
-	}
-
-	var newContent string
-	var replacementCount int
-
-	if edit.ReplaceAll {
-		newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
-		replacementCount = strings.Count(content, edit.OldString)
-		if replacementCount == 0 {
-			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
-		}
-	} else {
-		index := strings.Index(content, edit.OldString)
-		if index == -1 {
-			return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
-		}
-
-		lastIndex := strings.LastIndex(content, edit.OldString)
-		if index != lastIndex {
-			return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
-		}
-
-		newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
-		replacementCount = 1
-	}
-
-	return newContent, nil
-}

internal/llm/tools/multiedit.md πŸ”—

@@ -1,48 +0,0 @@
-This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
-
-Before using this tool:
-
-1. Use the Read tool to understand the file's contents and context
-
-2. Verify the directory path is correct
-
-To make multiple file edits, provide the following:
-
-1. file_path: The absolute path to the file to modify (must be absolute, not relative)
-2. edits: An array of edit operations to perform, where each edit contains:
-   - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
-   - new_string: The edited text to replace the old_string
-   - replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
-
-IMPORTANT:
-
-- All edits are applied in sequence, in the order they are provided
-- Each edit operates on the result of the previous edit
-- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
-- This tool is ideal when you need to make several changes to different parts of the same file
-
-CRITICAL REQUIREMENTS:
-
-1. All edits follow the same requirements as the single Edit tool
-2. The edits are atomic - either all succeed or none are applied
-3. Plan your edits carefully to avoid conflicts between sequential operations
-
-WARNING:
-
-- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
-- The tool will fail if edits.old_string and edits.new_string are the same
-- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
-
-When making edits:
-
-- Ensure all edits result in idiomatic, correct code
-- Do not leave the code in a broken state
-- Always use absolute file paths (starting with /)
-- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
-- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
-
-If you want to create a new file, use:
-
-- A new file path, including dir name if needed
-- First edit: empty old_string and the new file's contents as new_string
-- Subsequent edits: normal edit operations on the created content

internal/llm/tools/references.md πŸ”—

@@ -1,36 +0,0 @@
-Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
-
-WHEN TO USE THIS TOOL:
-
-- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used
-- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate
-- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.)
-- More accurate than grep because it understands code semantics and scope
-- Finds only actual references, not string matches in comments or unrelated code
-- Helpful for understanding where a symbol is used throughout the codebase
-- Useful for refactoring or analyzing code dependencies
-- Good for finding all call sites of a function, method, type, package, constant, variable, etc.
-
-HOW TO USE:
-
-- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType")
-- Optionally specify a path to narrow the search to a specific directory
-- The tool will automatically find the symbol and locate all references
-
-FEATURES:
-
-- Returns all references grouped by file
-- Shows line and column numbers for each reference
-- Supports multiple programming languages through LSP
-- Automatically finds the symbol without needing exact position
-
-LIMITATIONS:
-
-- May not find references in files that haven't been opened or indexed
-- Results depend on the LSP server's capabilities
-
-TIPS:
-
-- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware
-- Simply provide the symbol name and let the tool find it for you
-- This tool understands code structure, so it won't match unrelated strings or comments

internal/llm/tools/sourcegraph.go πŸ”—

@@ -1,302 +0,0 @@
-package tools
-
-import (
-	"bytes"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"time"
-)
-
-type SourcegraphParams struct {
-	Query         string `json:"query"`
-	Count         int    `json:"count,omitempty"`
-	ContextWindow int    `json:"context_window,omitempty"`
-	Timeout       int    `json:"timeout,omitempty"`
-}
-
-type SourcegraphResponseMetadata struct {
-	NumberOfMatches int  `json:"number_of_matches"`
-	Truncated       bool `json:"truncated"`
-}
-
-type sourcegraphTool struct {
-	client *http.Client
-}
-
-const SourcegraphToolName = "sourcegraph"
-
-//go:embed sourcegraph.md
-var sourcegraphDescription []byte
-
-func NewSourcegraphTool() BaseTool {
-	return &sourcegraphTool{
-		client: &http.Client{
-			Timeout: 30 * time.Second,
-			Transport: &http.Transport{
-				MaxIdleConns:        100,
-				MaxIdleConnsPerHost: 10,
-				IdleConnTimeout:     90 * time.Second,
-			},
-		},
-	}
-}
-
-func (t *sourcegraphTool) Name() string {
-	return SourcegraphToolName
-}
-
-func (t *sourcegraphTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        SourcegraphToolName,
-		Description: string(sourcegraphDescription),
-		Parameters: map[string]any{
-			"query": map[string]any{
-				"type":        "string",
-				"description": "The Sourcegraph search query",
-			},
-			"count": map[string]any{
-				"type":        "number",
-				"description": "Optional number of results to return (default: 10, max: 20)",
-			},
-			"context_window": map[string]any{
-				"type":        "number",
-				"description": "The context around the match to return (default: 10 lines)",
-			},
-			"timeout": map[string]any{
-				"type":        "number",
-				"description": "Optional timeout in seconds (max 120)",
-			},
-		},
-		Required: []string{"query"},
-	}
-}
-
-func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params SourcegraphParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse("Failed to parse sourcegraph parameters: " + err.Error()), nil
-	}
-
-	if params.Query == "" {
-		return NewTextErrorResponse("Query parameter is required"), nil
-	}
-
-	if params.Count <= 0 {
-		params.Count = 10
-	} else if params.Count > 20 {
-		params.Count = 20 // Limit to 20 results
-	}
-
-	if params.ContextWindow <= 0 {
-		params.ContextWindow = 10 // Default context window
-	}
-
-	// Handle timeout with context
-	requestCtx := ctx
-	if params.Timeout > 0 {
-		maxTimeout := 120 // 2 minutes
-		if params.Timeout > maxTimeout {
-			params.Timeout = maxTimeout
-		}
-		var cancel context.CancelFunc
-		requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
-		defer cancel()
-	}
-
-	type graphqlRequest struct {
-		Query     string `json:"query"`
-		Variables struct {
-			Query string `json:"query"`
-		} `json:"variables"`
-	}
-
-	request := graphqlRequest{
-		Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
-	}
-	request.Variables.Query = params.Query
-
-	graphqlQueryBytes, err := json.Marshal(request)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
-	}
-	graphqlQuery := string(graphqlQueryBytes)
-
-	req, err := http.NewRequestWithContext(
-		requestCtx,
-		"POST",
-		"https://sourcegraph.com/.api/graphql",
-		bytes.NewBuffer([]byte(graphqlQuery)),
-	)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("User-Agent", "crush/1.0")
-
-	resp, err := t.client.Do(req)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		body, _ := io.ReadAll(resp.Body)
-		if len(body) > 0 {
-			return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
-		}
-
-		return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
-	}
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
-	}
-
-	var result map[string]any
-	if err = json.Unmarshal(body, &result); err != nil {
-		return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
-	}
-
-	formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
-	if err != nil {
-		return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
-	}
-
-	return NewTextResponse(formattedResults), nil
-}
-
-func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
-	var buffer strings.Builder
-
-	if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
-		buffer.WriteString("## Sourcegraph API Error\n\n")
-		for _, err := range errors {
-			if errMap, ok := err.(map[string]any); ok {
-				if message, ok := errMap["message"].(string); ok {
-					buffer.WriteString(fmt.Sprintf("- %s\n", message))
-				}
-			}
-		}
-		return buffer.String(), nil
-	}
-
-	data, ok := result["data"].(map[string]any)
-	if !ok {
-		return "", fmt.Errorf("invalid response format: missing data field")
-	}
-
-	search, ok := data["search"].(map[string]any)
-	if !ok {
-		return "", fmt.Errorf("invalid response format: missing search field")
-	}
-
-	searchResults, ok := search["results"].(map[string]any)
-	if !ok {
-		return "", fmt.Errorf("invalid response format: missing results field")
-	}
-
-	matchCount, _ := searchResults["matchCount"].(float64)
-	resultCount, _ := searchResults["resultCount"].(float64)
-	limitHit, _ := searchResults["limitHit"].(bool)
-
-	buffer.WriteString("# Sourcegraph Search Results\n\n")
-	buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
-
-	if limitHit {
-		buffer.WriteString("(Result limit reached, try a more specific query)\n")
-	}
-
-	buffer.WriteString("\n")
-
-	results, ok := searchResults["results"].([]any)
-	if !ok || len(results) == 0 {
-		buffer.WriteString("No results found. Try a different query.\n")
-		return buffer.String(), nil
-	}
-
-	maxResults := 10
-	if len(results) > maxResults {
-		results = results[:maxResults]
-	}
-
-	for i, res := range results {
-		fileMatch, ok := res.(map[string]any)
-		if !ok {
-			continue
-		}
-
-		typeName, _ := fileMatch["__typename"].(string)
-		if typeName != "FileMatch" {
-			continue
-		}
-
-		repo, _ := fileMatch["repository"].(map[string]any)
-		file, _ := fileMatch["file"].(map[string]any)
-		lineMatches, _ := fileMatch["lineMatches"].([]any)
-
-		if repo == nil || file == nil {
-			continue
-		}
-
-		repoName, _ := repo["name"].(string)
-		filePath, _ := file["path"].(string)
-		fileURL, _ := file["url"].(string)
-		fileContent, _ := file["content"].(string)
-
-		buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
-
-		if fileURL != "" {
-			buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
-		}
-
-		if len(lineMatches) > 0 {
-			for _, lm := range lineMatches {
-				lineMatch, ok := lm.(map[string]any)
-				if !ok {
-					continue
-				}
-
-				lineNumber, _ := lineMatch["lineNumber"].(float64)
-				preview, _ := lineMatch["preview"].(string)
-
-				if fileContent != "" {
-					lines := strings.Split(fileContent, "\n")
-
-					buffer.WriteString("```\n")
-
-					startLine := max(1, int(lineNumber)-contextWindow)
-
-					for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
-						if j >= 0 {
-							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
-						}
-					}
-
-					buffer.WriteString(fmt.Sprintf("%d|  %s\n", int(lineNumber), preview))
-
-					endLine := int(lineNumber) + contextWindow
-
-					for j := int(lineNumber); j < endLine && j < len(lines); j++ {
-						if j < len(lines) {
-							buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
-						}
-					}
-
-					buffer.WriteString("```\n\n")
-				} else {
-					buffer.WriteString("```\n")
-					buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
-					buffer.WriteString("```\n\n")
-				}
-			}
-		}
-	}
-
-	return buffer.String(), nil
-}

internal/llm/tools/sourcegraph.md πŸ”—

@@ -1,102 +0,0 @@
-Search code across public repositories using Sourcegraph's GraphQL API.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to find code examples or implementations across public repositories
-- Helpful for researching how others have solved similar problems
-- Useful for discovering patterns and best practices in open source code
-
-HOW TO USE:
-
-- Provide a search query using Sourcegraph's query syntax
-- Optionally specify the number of results to return (default: 10)
-- Optionally set a timeout for the request
-
-QUERY SYNTAX:
-
-- Basic search: "fmt.Println" searches for exact matches
-- File filters: "file:.go fmt.Println" limits to Go files
-- Repository filters: "repo:^github\.com/golang/go$ fmt.Println" limits to specific repos
-- Language filters: "lang:go fmt.Println" limits to Go code
-- Boolean operators: "fmt.Println AND log.Fatal" for combined terms
-- Regular expressions: "fmt\.(Print|Printf|Println)" for pattern matching
-- Quoted strings: "\"exact phrase\"" for exact phrase matching
-- Exclude filters: "-file:test" or "-repo:forks" to exclude matches
-
-ADVANCED FILTERS:
-
-- Repository filters:
-  - "repo:name" - Match repositories with name containing "name"
-  - "repo:^github\.com/org/repo$" - Exact repository match
-  - "repo:org/repo@branch" - Search specific branch
-  - "repo:org/repo rev:branch" - Alternative branch syntax
-  - "-repo:name" - Exclude repositories
-  - "fork:yes" or "fork:only" - Include or only show forks
-  - "archived:yes" or "archived:only" - Include or only show archived repos
-  - "visibility:public" or "visibility:private" - Filter by visibility
-
-- File filters:
-  - "file:\.js$" - Files with .js extension
-  - "file:internal/" - Files in internal directory
-  - "-file:test" - Exclude test files
-  - "file:has.content(Copyright)" - Files containing "Copyright"
-  - "file:has.contributor([email protected])" - Files with specific contributor
-
-- Content filters:
-  - "content:\"exact string\"" - Search for exact string
-  - "-content:\"unwanted\"" - Exclude files with unwanted content
-  - "case:yes" - Case-sensitive search
-
-- Type filters:
-  - "type:symbol" - Search for symbols (functions, classes, etc.)
-  - "type:file" - Search file content only
-  - "type:path" - Search filenames only
-  - "type:diff" - Search code changes
-  - "type:commit" - Search commit messages
-
-- Commit/diff search:
-  - "after:\"1 month ago\"" - Commits after date
-  - "before:\"2023-01-01\"" - Commits before date
-  - "author:name" - Commits by author
-  - "message:\"fix bug\"" - Commits with message
-
-- Result selection:
-  - "select:repo" - Show only repository names
-  - "select:file" - Show only file paths
-  - "select:content" - Show only matching content
-  - "select:symbol" - Show only matching symbols
-
-- Result control:
-  - "count:100" - Return up to 100 results
-  - "count:all" - Return all results
-  - "timeout:30s" - Set search timeout
-
-EXAMPLES:
-
-- "file:.go context.WithTimeout" - Find Go code using context.WithTimeout
-- "lang:typescript useState type:symbol" - Find TypeScript React useState hooks
-- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Find Kubernetes files related to pod listing
-- "repo:sourcegraph/sourcegraph$ after:\"3 months ago\" type:diff database" - Recent changes to database code
-- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with specific base images
-- "repo:has.path(\.py) file:requirements.txt tensorflow" - Python projects using TensorFlow
-
-BOOLEAN OPERATORS:
-
-- "term1 AND term2" - Results containing both terms
-- "term1 OR term2" - Results containing either term
-- "term1 NOT term2" - Results with term1 but not term2
-- "term1 and (term2 or term3)" - Grouping with parentheses
-
-LIMITATIONS:
-
-- Only searches public repositories
-- Rate limits may apply
-- Complex queries may take longer to execute
-- Maximum of 20 results per query
-
-TIPS:
-
-- Use specific file extensions to narrow results
-- Add repo: filters for more targeted searches
-- Use type:symbol to find function/method definitions
-- Use type:file to find relevant files

internal/llm/tools/tools.go πŸ”—

@@ -1,85 +0,0 @@
-package tools
-
-import (
-	"context"
-	"encoding/json"
-)
-
-type ToolInfo struct {
-	Name        string
-	Description string
-	Parameters  map[string]any
-	Required    []string
-}
-
-type toolResponseType string
-
-type (
-	sessionIDContextKey string
-	messageIDContextKey string
-)
-
-const (
-	ToolResponseTypeText  toolResponseType = "text"
-	ToolResponseTypeImage toolResponseType = "image"
-
-	SessionIDContextKey sessionIDContextKey = "session_id"
-	MessageIDContextKey messageIDContextKey = "message_id"
-)
-
-type ToolResponse struct {
-	Type     toolResponseType `json:"type"`
-	Content  string           `json:"content"`
-	Metadata string           `json:"metadata,omitempty"`
-	IsError  bool             `json:"is_error"`
-}
-
-func NewTextResponse(content string) ToolResponse {
-	return ToolResponse{
-		Type:    ToolResponseTypeText,
-		Content: content,
-	}
-}
-
-func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
-	if metadata != nil {
-		metadataBytes, err := json.Marshal(metadata)
-		if err != nil {
-			return response
-		}
-		response.Metadata = string(metadataBytes)
-	}
-	return response
-}
-
-func NewTextErrorResponse(content string) ToolResponse {
-	return ToolResponse{
-		Type:    ToolResponseTypeText,
-		Content: content,
-		IsError: true,
-	}
-}
-
-type ToolCall struct {
-	ID    string `json:"id"`
-	Name  string `json:"name"`
-	Input string `json:"input"`
-}
-
-type BaseTool interface {
-	Info() ToolInfo
-	Name() string
-	Run(ctx context.Context, params ToolCall) (ToolResponse, error)
-}
-
-func GetContextValues(ctx context.Context) (string, string) {
-	sessionID := ctx.Value(SessionIDContextKey)
-	messageID := ctx.Value(MessageIDContextKey)
-	if sessionID == nil {
-		return "", ""
-	}
-	if messageID == nil {
-		return sessionID.(string), ""
-	}
-	return sessionID.(string), messageID.(string)
-}

internal/llm/tools/view.go πŸ”—

@@ -1,343 +0,0 @@
-package tools
-
-import (
-	"bufio"
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"io"
-	"os"
-	"path/filepath"
-	"strings"
-	"unicode/utf8"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-//go:embed view.md
-var viewDescription []byte
-
-type ViewParams struct {
-	FilePath string `json:"file_path"`
-	Offset   int    `json:"offset"`
-	Limit    int    `json:"limit"`
-}
-
-type ViewPermissionsParams struct {
-	FilePath string `json:"file_path"`
-	Offset   int    `json:"offset"`
-	Limit    int    `json:"limit"`
-}
-
-type viewTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	workingDir  string
-	permissions permission.Service
-}
-
-type ViewResponseMetadata struct {
-	FilePath string `json:"file_path"`
-	Content  string `json:"content"`
-}
-
-const (
-	ViewToolName     = "view"
-	MaxReadSize      = 250 * 1024
-	DefaultReadLimit = 2000
-	MaxLineLength    = 2000
-)
-
-func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) BaseTool {
-	return &viewTool{
-		lspClients:  lspClients,
-		workingDir:  workingDir,
-		permissions: permissions,
-	}
-}
-
-func (v *viewTool) Name() string {
-	return ViewToolName
-}
-
-func (v *viewTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        ViewToolName,
-		Description: string(viewDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The path to the file to read",
-			},
-			"offset": map[string]any{
-				"type":        "integer",
-				"description": "The line number to start reading from (0-based)",
-			},
-			"limit": map[string]any{
-				"type":        "integer",
-				"description": "The number of lines to read (defaults to 2000)",
-			},
-		},
-		Required: []string{"file_path"},
-	}
-}
-
-// Run implements Tool.
-func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params ViewParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	// Handle relative paths
-	filePath := params.FilePath
-	if !filepath.IsAbs(filePath) {
-		filePath = filepath.Join(v.workingDir, filePath)
-	}
-
-	// Check if file is outside working directory and request permission if needed
-	absWorkingDir, err := filepath.Abs(v.workingDir)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
-	}
-
-	absFilePath, err := filepath.Abs(filePath)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
-	}
-
-	relPath, err := filepath.Rel(absWorkingDir, absFilePath)
-	if err != nil || strings.HasPrefix(relPath, "..") {
-		// File is outside working directory, request permission
-		sessionID, messageID := GetContextValues(ctx)
-		if sessionID == "" || messageID == "" {
-			return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
-		}
-
-		granted := v.permissions.Request(
-			permission.CreatePermissionRequest{
-				SessionID:   sessionID,
-				Path:        absFilePath,
-				ToolCallID:  call.ID,
-				ToolName:    ViewToolName,
-				Action:      "read",
-				Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
-				Params:      ViewPermissionsParams(params),
-			},
-		)
-
-		if !granted {
-			return ToolResponse{}, permission.ErrorPermissionDenied
-		}
-	}
-
-	// Check if file exists
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			// Try to offer suggestions for similarly named files
-			dir := filepath.Dir(filePath)
-			base := filepath.Base(filePath)
-
-			dirEntries, dirErr := os.ReadDir(dir)
-			if dirErr == nil {
-				var suggestions []string
-				for _, entry := range dirEntries {
-					if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
-						strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
-						suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
-						if len(suggestions) >= 3 {
-							break
-						}
-					}
-				}
-
-				if len(suggestions) > 0 {
-					return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
-						filePath, strings.Join(suggestions, "\n"))), nil
-				}
-			}
-
-			return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
-		}
-		return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
-	}
-
-	// Check if it's a directory
-	if fileInfo.IsDir() {
-		return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
-	}
-
-	// Check file size
-	if fileInfo.Size() > MaxReadSize {
-		return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
-			fileInfo.Size(), MaxReadSize)), nil
-	}
-
-	// Set default limit if not provided
-	if params.Limit <= 0 {
-		params.Limit = DefaultReadLimit
-	}
-
-	// Check if it's an image file
-	isImage, imageType := isImageFile(filePath)
-	// TODO: handle images
-	if isImage {
-		return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
-	}
-
-	// Read the file content
-	content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
-	isValidUt8 := utf8.ValidString(content)
-	if !isValidUt8 {
-		return NewTextErrorResponse("File content is not valid UTF-8"), nil
-	}
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
-	}
-
-	notifyLSPs(ctx, v.lspClients, filePath)
-	output := "<file>\n"
-	// Format the output with line numbers
-	output += addLineNumbers(content, params.Offset+1)
-
-	// Add a note if the content was truncated
-	if lineCount > params.Offset+len(strings.Split(content, "\n")) {
-		output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
-			params.Offset+len(strings.Split(content, "\n")))
-	}
-	output += "\n</file>\n"
-	output += getDiagnostics(filePath, v.lspClients)
-	recordFileRead(filePath)
-	return WithResponseMetadata(
-		NewTextResponse(output),
-		ViewResponseMetadata{
-			FilePath: filePath,
-			Content:  content,
-		},
-	), nil
-}
-
-func addLineNumbers(content string, startLine int) string {
-	if content == "" {
-		return ""
-	}
-
-	lines := strings.Split(content, "\n")
-
-	var result []string
-	for i, line := range lines {
-		line = strings.TrimSuffix(line, "\r")
-
-		lineNum := i + startLine
-		numStr := fmt.Sprintf("%d", lineNum)
-
-		if len(numStr) >= 6 {
-			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
-		} else {
-			paddedNum := fmt.Sprintf("%6s", numStr)
-			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
-		}
-	}
-
-	return strings.Join(result, "\n")
-}
-
-func readTextFile(filePath string, offset, limit int) (string, int, error) {
-	file, err := os.Open(filePath)
-	if err != nil {
-		return "", 0, err
-	}
-	defer file.Close()
-
-	lineCount := 0
-
-	scanner := NewLineScanner(file)
-	if offset > 0 {
-		for lineCount < offset && scanner.Scan() {
-			lineCount++
-		}
-		if err = scanner.Err(); err != nil {
-			return "", 0, err
-		}
-	}
-
-	if offset == 0 {
-		_, err = file.Seek(0, io.SeekStart)
-		if err != nil {
-			return "", 0, err
-		}
-	}
-
-	// Pre-allocate slice with expected capacity
-	lines := make([]string, 0, limit)
-	lineCount = offset
-
-	for scanner.Scan() && len(lines) < limit {
-		lineCount++
-		lineText := scanner.Text()
-		if len(lineText) > MaxLineLength {
-			lineText = lineText[:MaxLineLength] + "..."
-		}
-		lines = append(lines, lineText)
-	}
-
-	// Continue scanning to get total line count
-	for scanner.Scan() {
-		lineCount++
-	}
-
-	if err := scanner.Err(); err != nil {
-		return "", 0, err
-	}
-
-	return strings.Join(lines, "\n"), lineCount, nil
-}
-
-func isImageFile(filePath string) (bool, string) {
-	ext := strings.ToLower(filepath.Ext(filePath))
-	switch ext {
-	case ".jpg", ".jpeg":
-		return true, "JPEG"
-	case ".png":
-		return true, "PNG"
-	case ".gif":
-		return true, "GIF"
-	case ".bmp":
-		return true, "BMP"
-	case ".svg":
-		return true, "SVG"
-	case ".webp":
-		return true, "WebP"
-	default:
-		return false, ""
-	}
-}
-
-type LineScanner struct {
-	scanner *bufio.Scanner
-}
-
-func NewLineScanner(r io.Reader) *LineScanner {
-	return &LineScanner{
-		scanner: bufio.NewScanner(r),
-	}
-}
-
-func (s *LineScanner) Scan() bool {
-	return s.scanner.Scan()
-}
-
-func (s *LineScanner) Text() string {
-	return s.scanner.Text()
-}
-
-func (s *LineScanner) Err() error {
-	return s.scanner.Err()
-}

internal/llm/tools/view.md πŸ”—

@@ -1,42 +0,0 @@
-File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to read the contents of a specific file
-- Helpful for examining source code, configuration files, or log files
-- Perfect for looking at text-based file formats
-
-HOW TO USE:
-
-- Provide the path to the file you want to view
-- Optionally specify an offset to start reading from a specific line
-- Optionally specify a limit to control how many lines are read
-- Do not use this for directories use the ls tool instead
-
-FEATURES:
-
-- Displays file contents with line numbers for easy reference
-- Can read from any position in a file using the offset parameter
-- Handles large files by limiting the number of lines read
-- Automatically truncates very long lines for better display
-- Suggests similar file names when the requested file isn't found
-
-LIMITATIONS:
-
-- Maximum file size is 250KB
-- Default reading limit is 2000 lines
-- Lines longer than 2000 characters are truncated
-- Cannot display binary files or images
-- Images can be identified but not displayed
-
-WINDOWS NOTES:
-
-- Handles both Windows (CRLF) and Unix (LF) line endings automatically
-- File paths work with both forward slashes (/) and backslashes (\)
-- Text encoding is detected automatically for most common formats
-
-TIPS:
-
-- Use with Glob tool to first find files you want to view
-- For code exploration, first use Grep to find relevant files, then View to examine them
-- When viewing large files, use the offset parameter to read specific sections

internal/llm/tools/write.go πŸ”—

@@ -1,208 +0,0 @@
-package tools
-
-import (
-	"context"
-	_ "embed"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/diff"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/history"
-
-	"github.com/charmbracelet/crush/internal/lsp"
-	"github.com/charmbracelet/crush/internal/permission"
-)
-
-//go:embed write.md
-var writeDescription []byte
-
-type WriteParams struct {
-	FilePath string `json:"file_path"`
-	Content  string `json:"content"`
-}
-
-type WritePermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
-
-type writeTool struct {
-	lspClients  *csync.Map[string, *lsp.Client]
-	permissions permission.Service
-	files       history.Service
-	workingDir  string
-}
-
-type WriteResponseMetadata struct {
-	Diff      string `json:"diff"`
-	Additions int    `json:"additions"`
-	Removals  int    `json:"removals"`
-}
-
-const WriteToolName = "write"
-
-func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
-	return &writeTool{
-		lspClients:  lspClients,
-		permissions: permissions,
-		files:       files,
-		workingDir:  workingDir,
-	}
-}
-
-func (w *writeTool) Name() string {
-	return WriteToolName
-}
-
-func (w *writeTool) Info() ToolInfo {
-	return ToolInfo{
-		Name:        WriteToolName,
-		Description: string(writeDescription),
-		Parameters: map[string]any{
-			"file_path": map[string]any{
-				"type":        "string",
-				"description": "The path to the file to write",
-			},
-			"content": map[string]any{
-				"type":        "string",
-				"description": "The content to write to the file",
-			},
-		},
-		Required: []string{"file_path", "content"},
-	}
-}
-
-func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
-	var params WriteParams
-	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
-		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
-	}
-
-	if params.FilePath == "" {
-		return NewTextErrorResponse("file_path is required"), nil
-	}
-
-	if params.Content == "" {
-		return NewTextErrorResponse("content is required"), nil
-	}
-
-	filePath := params.FilePath
-	if !filepath.IsAbs(filePath) {
-		filePath = filepath.Join(w.workingDir, filePath)
-	}
-
-	fileInfo, err := os.Stat(filePath)
-	if err == nil {
-		if fileInfo.IsDir() {
-			return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
-		}
-
-		modTime := fileInfo.ModTime()
-		lastRead := getLastReadTime(filePath)
-		if modTime.After(lastRead) {
-			return NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
-				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
-		}
-
-		oldContent, readErr := os.ReadFile(filePath)
-		if readErr == nil && string(oldContent) == params.Content {
-			return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
-		}
-	} else if !os.IsNotExist(err) {
-		return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
-	}
-
-	dir := filepath.Dir(filePath)
-	if err = os.MkdirAll(dir, 0o755); err != nil {
-		return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
-	}
-
-	oldContent := ""
-	if fileInfo != nil && !fileInfo.IsDir() {
-		oldBytes, readErr := os.ReadFile(filePath)
-		if readErr == nil {
-			oldContent = string(oldBytes)
-		}
-	}
-
-	sessionID, messageID := GetContextValues(ctx)
-	if sessionID == "" || messageID == "" {
-		return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
-	}
-
-	diff, additions, removals := diff.GenerateDiff(
-		oldContent,
-		params.Content,
-		strings.TrimPrefix(filePath, w.workingDir),
-	)
-
-	p := w.permissions.Request(
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			Path:        fsext.PathOrPrefix(filePath, w.workingDir),
-			ToolCallID:  call.ID,
-			ToolName:    WriteToolName,
-			Action:      "write",
-			Description: fmt.Sprintf("Create file %s", filePath),
-			Params: WritePermissionsParams{
-				FilePath:   filePath,
-				OldContent: oldContent,
-				NewContent: params.Content,
-			},
-		},
-	)
-	if !p {
-		return ToolResponse{}, permission.ErrorPermissionDenied
-	}
-
-	err = os.WriteFile(filePath, []byte(params.Content), 0o644)
-	if err != nil {
-		return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
-	}
-
-	// Check if file exists in history
-	file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
-	if err != nil {
-		_, err = w.files.Create(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			// Log error but don't fail the operation
-			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
-		}
-	}
-	if file.Content != oldContent {
-		// User Manually changed the content store an intermediate version
-		_, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
-		if err != nil {
-			slog.Debug("Error creating file history version", "error", err)
-		}
-	}
-	// Store the new version
-	_, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
-	if err != nil {
-		slog.Debug("Error creating file history version", "error", err)
-	}
-
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
-
-	notifyLSPs(ctx, w.lspClients, params.FilePath)
-
-	result := fmt.Sprintf("File successfully written: %s", filePath)
-	result = fmt.Sprintf("<result>\n%s\n</result>", result)
-	result += getDiagnostics(filePath, w.lspClients)
-	return WithResponseMetadata(NewTextResponse(result),
-		WriteResponseMetadata{
-			Diff:      diff,
-			Additions: additions,
-			Removals:  removals,
-		},
-	), nil
-}

internal/llm/tools/write.md πŸ”—

@@ -1,38 +0,0 @@
-File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
-
-WHEN TO USE THIS TOOL:
-
-- Use when you need to create a new file
-- Helpful for updating existing files with modified content
-- Perfect for saving generated code, configurations, or text data
-
-HOW TO USE:
-
-- Provide the path to the file you want to write
-- Include the content to be written to the file
-- The tool will create any necessary parent directories
-
-FEATURES:
-
-- Can create new files or overwrite existing ones
-- Creates parent directories automatically if they don't exist
-- Checks if the file has been modified since last read for safety
-- Avoids unnecessary writes when content hasn't changed
-
-LIMITATIONS:
-
-- You should read a file before writing to it to avoid conflicts
-- Cannot append to files (rewrites the entire file)
-
-WINDOWS NOTES:
-
-- File permissions (0o755, 0o644) are Unix-style but work on Windows with appropriate translations
-- Use forward slashes (/) in paths for cross-platform compatibility
-- Windows file attributes and permissions are handled automatically by the Go runtime
-
-TIPS:
-
-- Use the View tool first to examine existing files before modifying them
-- Use the LS tool to verify the correct location when creating new files
-- Combine with Glob and Grep tools to find and modify multiple files
-- Always include descriptive comments when making changes to existing code

internal/message/content.go πŸ”—

@@ -2,9 +2,15 @@ package message
 
 import (
 	"encoding/base64"
+	"errors"
 	"slices"
+	"strings"
 	"time"
 
+	"charm.land/fantasy"
+	"charm.land/fantasy/providers/anthropic"
+	"charm.land/fantasy/providers/google"
+	"charm.land/fantasy/providers/openai"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 )
 
@@ -36,10 +42,12 @@ type ContentPart interface {
 }
 
 type ReasoningContent struct {
-	Thinking   string `json:"thinking"`
-	Signature  string `json:"signature"`
-	StartedAt  int64  `json:"started_at,omitempty"`
-	FinishedAt int64  `json:"finished_at,omitempty"`
+	Thinking         string                             `json:"thinking"`
+	Signature        string                             `json:"signature"`
+	ThoughtSignature string                             `json:"thought_signature"` // Used for google
+	ResponsesData    *openai.ResponsesReasoningMetadata `json:"responses_data"`
+	StartedAt        int64                              `json:"started_at,omitempty"`
+	FinishedAt       int64                              `json:"finished_at,omitempty"`
 }
 
 func (tc ReasoningContent) String() string {
@@ -85,11 +93,11 @@ func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
 func (BinaryContent) isPart() {}
 
 type ToolCall struct {
-	ID       string `json:"id"`
-	Name     string `json:"name"`
-	Input    string `json:"input"`
-	Type     string `json:"type"`
-	Finished bool   `json:"finished"`
+	ID               string `json:"id"`
+	Name             string `json:"name"`
+	Input            string `json:"input"`
+	ProviderExecuted bool   `json:"provider_executed"`
+	Finished         bool   `json:"finished"`
 }
 
 func (ToolCall) isPart() {}
@@ -98,6 +106,8 @@ type ToolResult struct {
 	ToolCallID string `json:"tool_call_id"`
 	Name       string `json:"name"`
 	Content    string `json:"content"`
+	Data       string `json:"data"`
+	MIMEType   string `json:"mime_type"`
 	Metadata   string `json:"metadata"`
 	IsError    bool   `json:"is_error"`
 }
@@ -114,14 +124,15 @@ type Finish struct {
 func (Finish) isPart() {}
 
 type Message struct {
-	ID        string
-	Role      MessageRole
-	SessionID string
-	Parts     []ContentPart
-	Model     string
-	Provider  string
-	CreatedAt int64
-	UpdatedAt int64
+	ID               string
+	Role             MessageRole
+	SessionID        string
+	Parts            []ContentPart
+	Model            string
+	Provider         string
+	CreatedAt        int64
+	UpdatedAt        int64
+	IsSummaryMessage bool
 }
 
 func (m *Message) Content() TextContent {
@@ -250,6 +261,22 @@ func (m *Message) AppendReasoningContent(delta string) {
 	}
 }
 
+func (m *Message) AppendThoughtSignature(signature string) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			m.Parts[i] = ReasoningContent{
+				Thinking:         c.Thinking,
+				ThoughtSignature: c.ThoughtSignature + signature,
+				Signature:        c.Signature,
+				StartedAt:        c.StartedAt,
+				FinishedAt:       c.FinishedAt,
+			}
+			return
+		}
+	}
+	m.Parts = append(m.Parts, ReasoningContent{ThoughtSignature: signature})
+}
+
 func (m *Message) AppendReasoningSignature(signature string) {
 	for i, part := range m.Parts {
 		if c, ok := part.(ReasoningContent); ok {
@@ -265,6 +292,20 @@ func (m *Message) AppendReasoningSignature(signature string) {
 	m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
 }
 
+func (m *Message) SetReasoningResponsesData(data *openai.ResponsesReasoningMetadata) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			m.Parts[i] = ReasoningContent{
+				Thinking:      c.Thinking,
+				ResponsesData: data,
+				StartedAt:     c.StartedAt,
+				FinishedAt:    c.FinishedAt,
+			}
+			return
+		}
+	}
+}
+
 func (m *Message) FinishThinking() {
 	for i, part := range m.Parts {
 		if c, ok := part.(ReasoningContent); ok {
@@ -303,7 +344,6 @@ func (m *Message) FinishToolCall(toolCallID string) {
 					ID:       c.ID,
 					Name:     c.Name,
 					Input:    c.Input,
-					Type:     c.Type,
 					Finished: true,
 				}
 				return
@@ -320,7 +360,6 @@ func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
 					ID:       c.ID,
 					Name:     c.Name,
 					Input:    c.Input + inputDelta,
-					Type:     c.Type,
 					Finished: c.Finished,
 				}
 				return
@@ -384,3 +423,90 @@ func (m *Message) AddImageURL(url, detail string) {
 func (m *Message) AddBinary(mimeType string, data []byte) {
 	m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
 }
+
+func (m *Message) ToAIMessage() []fantasy.Message {
+	var messages []fantasy.Message
+	switch m.Role {
+	case User:
+		var parts []fantasy.MessagePart
+		text := strings.TrimSpace(m.Content().Text)
+		if text != "" {
+			parts = append(parts, fantasy.TextPart{Text: text})
+		}
+		for _, content := range m.BinaryContent() {
+			parts = append(parts, fantasy.FilePart{
+				Filename:  content.Path,
+				Data:      content.Data,
+				MediaType: content.MIMEType,
+			})
+		}
+		messages = append(messages, fantasy.Message{
+			Role:    fantasy.MessageRoleUser,
+			Content: parts,
+		})
+	case Assistant:
+		var parts []fantasy.MessagePart
+		text := strings.TrimSpace(m.Content().Text)
+		if text != "" {
+			parts = append(parts, fantasy.TextPart{Text: text})
+		}
+		reasoning := m.ReasoningContent()
+		if reasoning.Thinking != "" {
+			reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
+			if reasoning.Signature != "" {
+				reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
+					Signature: reasoning.Signature,
+				}
+			}
+			if reasoning.ResponsesData != nil {
+				reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
+			}
+			if reasoning.ThoughtSignature != "" {
+				reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
+					Signature: reasoning.ThoughtSignature,
+				}
+			}
+			parts = append(parts, reasoningPart)
+		}
+		for _, call := range m.ToolCalls() {
+			parts = append(parts, fantasy.ToolCallPart{
+				ToolCallID:       call.ID,
+				ToolName:         call.Name,
+				Input:            call.Input,
+				ProviderExecuted: call.ProviderExecuted,
+			})
+		}
+		messages = append(messages, fantasy.Message{
+			Role:    fantasy.MessageRoleAssistant,
+			Content: parts,
+		})
+	case Tool:
+		var parts []fantasy.MessagePart
+		for _, result := range m.ToolResults() {
+			var content fantasy.ToolResultOutputContent
+			if result.IsError {
+				content = fantasy.ToolResultOutputContentError{
+					Error: errors.New(result.Content),
+				}
+			} else if result.Data != "" {
+				content = fantasy.ToolResultOutputContentMedia{
+					Data:      result.Data,
+					MediaType: result.MIMEType,
+				}
+			} else {
+				content = fantasy.ToolResultOutputContentText{
+					Text: result.Content,
+				}
+			}
+			parts = append(parts, fantasy.ToolResultPart{
+				ToolCallID: result.ToolCallID,
+				Output:     content,
+			})
+		}
+		messages = append(messages, fantasy.Message{
+			Role:    fantasy.MessageRoleTool,
+			Content: parts,
+		})
+	}
+	return messages
+}

internal/message/message.go πŸ”—

@@ -13,10 +13,11 @@ import (
 )
 
 type CreateMessageParams struct {
-	Role     MessageRole
-	Parts    []ContentPart
-	Model    string
-	Provider string
+	Role             MessageRole
+	Parts            []ContentPart
+	Model            string
+	Provider         string
+	IsSummaryMessage bool
 }
 
 type Service interface {
@@ -64,13 +65,18 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
 	if err != nil {
 		return Message{}, err
 	}
+	isSummary := int64(0)
+	if params.IsSummaryMessage {
+		isSummary = 1
+	}
 	dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
-		ID:        uuid.New().String(),
-		SessionID: sessionID,
-		Role:      string(params.Role),
-		Parts:     string(partsJSON),
-		Model:     sql.NullString{String: string(params.Model), Valid: true},
-		Provider:  sql.NullString{String: params.Provider, Valid: params.Provider != ""},
+		ID:               uuid.New().String(),
+		SessionID:        sessionID,
+		Role:             string(params.Role),
+		Parts:            string(partsJSON),
+		Model:            sql.NullString{String: string(params.Model), Valid: true},
+		Provider:         sql.NullString{String: params.Provider, Valid: params.Provider != ""},
+		IsSummaryMessage: isSummary,
 	})
 	if err != nil {
 		return Message{}, err
@@ -151,14 +157,15 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
 		return Message{}, err
 	}
 	return Message{
-		ID:        item.ID,
-		SessionID: item.SessionID,
-		Role:      MessageRole(item.Role),
-		Parts:     parts,
-		Model:     item.Model.String,
-		Provider:  item.Provider.String,
-		CreatedAt: item.CreatedAt,
-		UpdatedAt: item.UpdatedAt,
+		ID:               item.ID,
+		SessionID:        item.SessionID,
+		Role:             MessageRole(item.Role),
+		Parts:            parts,
+		Model:            item.Model.String,
+		Provider:         item.Provider.String,
+		CreatedAt:        item.CreatedAt,
+		UpdatedAt:        item.UpdatedAt,
+		IsSummaryMessage: item.IsSummaryMessage != 0,
 	}, nil
 }
 

internal/session/session.go πŸ”—

@@ -3,6 +3,8 @@ package session
 import (
 	"context"
 	"database/sql"
+	"fmt"
+	"strings"
 
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
@@ -32,6 +34,11 @@ type Service interface {
 	List(ctx context.Context) ([]Session, error)
 	Save(ctx context.Context, session Session) (Session, error)
 	Delete(ctx context.Context, id string) error
+
+	// Agent tool session management
+	CreateAgentToolSessionID(messageID, toolCallID string) string
+	ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
+	IsAgentToolSession(sessionID string) bool
 }
 
 type service struct {
@@ -157,3 +164,23 @@ func NewService(q db.Querier) Service {
 		q,
 	}
 }
+
+// CreateAgentToolSessionID creates a session ID for agent tool sessions using the format "messageID$$toolCallID"
+func (s *service) CreateAgentToolSessionID(messageID, toolCallID string) string {
+	return fmt.Sprintf("%s$$%s", messageID, toolCallID)
+}
+
+// ParseAgentToolSessionID parses an agent tool session ID into its components
+func (s *service) ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool) {
+	parts := strings.Split(sessionID, "$$")
+	if len(parts) != 2 {
+		return "", "", false
+	}
+	return parts[0], parts[1], true
+}
+
+// IsAgentToolSession checks if a session ID follows the agent tool session format
+func (s *service) IsAgentToolSession(sessionID string) bool {
+	_, _, ok := s.ParseAgentToolSessionID(sessionID)
+	return ok
+}

internal/shell/persistent.go πŸ”—

@@ -29,6 +29,12 @@ func GetPersistentShell(cwd string) *PersistentShell {
 	return shellInstance
 }
 
+// INFO: only used for tests
+func Reset(cwd string) {
+	once = sync.Once{}
+	_ = GetPersistentShell(cwd)
+}
+
 // slog.dapter adapts the internal slog.package to the Logger interface
 type loggingAdapter struct{}
 

internal/tui/components/chat/chat.go πŸ”—

@@ -8,8 +8,8 @@ import (
 	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/app"
-	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
@@ -103,8 +103,8 @@ func (m *messageListCmp) Init() tea.Cmd {
 // Update handles incoming messages and updates the component state.
 func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	var cmds []tea.Cmd
-	if m.session.ID != "" && m.app.CoderAgent != nil {
-		queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID)
+	if m.session.ID != "" && m.app.AgentCoordinator != nil {
+		queueSize := m.app.AgentCoordinator.QueuedPrompts(m.session.ID)
 		if queueSize != m.promptQueue {
 			m.promptQueue = queueSize
 			cmds = append(cmds, m.SetSize(m.width, m.height))
@@ -235,7 +235,7 @@ func (m *messageListCmp) View() string {
 				m.listCmp.View(),
 			),
 	}
-	if m.app.CoderAgent != nil && m.promptQueue > 0 {
+	if m.app.AgentCoordinator != nil && m.promptQueue > 0 {
 		queuePill := queuePill(m.promptQueue, t)
 		view = append(view, t.S().Base.PaddingLeft(4).PaddingTop(1).Render(queuePill))
 	}
@@ -261,12 +261,19 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
 	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 		return nil
 	}
+
+	// Check if this is an agent tool session and parse it
+	childSessionID := event.Payload.SessionID
+	parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
+	if !ok {
+		return nil
+	}
 	items := m.listCmp.Items()
 	toolCallInx := NotFound
 	var toolCall messages.ToolCallCmp
 	for i := len(items) - 1; i >= 0; i-- {
 		if msg, ok := items[i].(messages.ToolCallCmp); ok {
-			if msg.GetToolCall().ID == event.Payload.SessionID {
+			if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
 				toolCallInx = i
 				toolCall = msg
 			}
@@ -327,6 +334,11 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
 			return nil
 		}
 		return m.handleNewMessage(event.Payload)
+	case pubsub.DeletedEvent:
+		if event.Payload.SessionID != m.session.ID {
+			return nil
+		}
+		return m.handleDeleteMessage(event.Payload)
 	case pubsub.UpdatedEvent:
 		if event.Payload.SessionID != m.session.ID {
 			return m.handleChildSession(event)
@@ -353,6 +365,18 @@ func (m *messageListCmp) messageExists(messageID string) bool {
 	return false
 }
 
+// handleDeleteMessage removes a message from the list.
+func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
+	items := m.listCmp.Items()
+	for i := len(items) - 1; i >= 0; i-- {
+		if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
+			m.listCmp.DeleteItem(items[i].ID())
+			return nil
+		}
+	}
+	return nil
+}
+
 // handleNewMessage routes new messages to appropriate handlers based on role.
 func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
 	switch msg.Role {
@@ -613,7 +637,8 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
 		// If this tool call is the agent tool, fetch nested tool calls
 		if tc.Name == agent.AgentToolName {
-			nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
+			agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
+			nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
 			nestedToolResultMap := m.buildToolResultMap(nestedMessages)
 			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
 			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))

internal/tui/components/chat/editor/editor.go πŸ”—

@@ -212,7 +212,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 
 	case commands.OpenExternalEditorMsg:
-		if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+		if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
 			return m, util.ReportWarn("Agent is working, please wait...")
 		}
 		return m, m.openEditor(m.textarea.Value())
@@ -298,7 +298,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 		}
 		if key.Matches(msg, m.keyMap.OpenEditor) {
-			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+			if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
 				return m, util.ReportWarn("Agent is working, please wait...")
 			}
 			return m, m.openEditor(m.textarea.Value())
@@ -416,7 +416,7 @@ func (m *editorCmp) randomizePlaceholders() {
 func (m *editorCmp) View() string {
 	t := styles.CurrentTheme()
 	// Update placeholder
-	if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
+	if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
 		m.textarea.Placeholder = m.workingPlaceholder
 	} else {
 		m.textarea.Placeholder = m.readyPlaceholder

internal/tui/components/chat/header/header.go πŸ”—

@@ -119,7 +119,7 @@ func (h *header) details(availWidth int) string {
 		parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
 	}
 
-	agentCfg := config.Get().Agents["coder"]
+	agentCfg := config.Get().Agents[config.AgentCoder]
 	model := config.Get().GetModelByType(agentCfg.Model)
 	percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
 	formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -122,6 +122,9 @@ func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 // Returns different views for spinning, user, and assistant messages.
 func (m *messageCmp) View() string {
 	if m.spinning && m.message.ReasoningContent().Thinking == "" {
+		if m.message.IsSummaryMessage {
+			m.anim.SetLabel("Summarizing")
+		}
 		return m.style().PaddingLeft(1).Render(m.anim.View())
 	}
 	if m.message.ID != "" {
@@ -184,7 +187,7 @@ func (m *messageCmp) renderAssistantMessage() string {
 	finishedData := m.message.FinishPart()
 	thinkingContent := ""
 
-	if thinking || m.message.ReasoningContent().Thinking != "" {
+	if thinking || strings.TrimSpace(m.message.ReasoningContent().Thinking) != "" {
 		m.anim.SetLabel("Thinking")
 		thinkingContent = m.renderThinkingContent()
 	} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
@@ -256,7 +259,7 @@ func (m *messageCmp) toMarkdown(content string) string {
 func (m *messageCmp) renderThinkingContent() string {
 	t := styles.CurrentTheme()
 	reasoningContent := m.message.ReasoningContent()
-	if reasoningContent.Thinking == "" {
+	if strings.TrimSpace(reasoningContent.Thinking) == "" {
 		return ""
 	}
 	lines := strings.Split(reasoningContent.Thinking, "\n")
@@ -310,7 +313,7 @@ func (m *messageCmp) shouldSpin() bool {
 		return false
 	}
 
-	if m.message.Content().Text != "" {
+	if strings.TrimSpace(m.message.Content().Text) != "" {
 		return false
 	}
 	if len(m.message.ToolCalls()) > 0 {

internal/tui/components/chat/messages/renderer.go πŸ”—

@@ -6,10 +6,10 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/ansiext"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/llm/agent"
-	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/highlight"
 	"github.com/charmbracelet/crush/internal/tui/styles"

internal/tui/components/chat/messages/tool.go πŸ”—

@@ -10,10 +10,10 @@ import (
 	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/llm/agent"
-	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"

internal/tui/components/chat/sidebar/sidebar.go πŸ”—

@@ -545,7 +545,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
 
 func (s *sidebarCmp) currentModelBlock() string {
 	cfg := config.Get()
-	agentCfg := cfg.Agents["coder"]
+	agentCfg := cfg.Agents[config.AgentCoder]
 
 	selectedModel := cfg.Models[agentCfg.Model]
 
@@ -563,13 +563,6 @@ func (s *sidebarCmp) currentModelBlock() string {
 	if model.CanReason {
 		reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
 		switch modelProvider.Type {
-		case catwalk.TypeOpenAI:
-			reasoningEffort := model.DefaultReasoningEffort
-			if selectedModel.ReasoningEffort != "" {
-				reasoningEffort = selectedModel.ReasoningEffort
-			}
-			formatter := cases.Title(language.English, cases.NoLower)
-			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
 		case catwalk.TypeAnthropic:
 			formatter := cases.Title(language.English, cases.NoLower)
 			if selectedModel.Think {
@@ -577,6 +570,13 @@ func (s *sidebarCmp) currentModelBlock() string {
 			} else {
 				parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
 			}
+		default:
+			reasoningEffort := model.DefaultReasoningEffort
+			if selectedModel.ReasoningEffort != "" {
+				reasoningEffort = selectedModel.ReasoningEffort
+			}
+			formatter := cases.Title(language.English, cases.NoLower)
+			parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
 		}
 	}
 	if s.session.ID != "" {

internal/tui/components/chat/splash/splash.go πŸ”—

@@ -9,9 +9,9 @@ import (
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -334,7 +334,7 @@ func (s *splashCmp) initializeProject() tea.Cmd {
 		cmds = append(cmds,
 			util.CmdHandler(chat.SessionClearedMsg{}),
 			util.CmdHandler(chat.SendMsg{
-				Text: prompt.Initialize(),
+				Text: agent.InitializePrompt(),
 			}),
 		)
 	}
@@ -695,7 +695,7 @@ func (s *splashCmp) mcpBlock() string {
 
 func (s *splashCmp) currentModelBlock() string {
 	cfg := config.Get()
-	agentCfg := cfg.Agents["coder"]
+	agentCfg := cfg.Agents[config.AgentCoder]
 	model := config.Get().GetModelByType(agentCfg.Model)
 	if model == nil {
 		return ""

internal/tui/components/dialogs/commands/commands.go πŸ”—

@@ -9,8 +9,8 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/lipgloss/v2"
 
+	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -303,7 +303,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 
 	// Add reasoning toggle for models that support it
 	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents["coder"]; ok {
+	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
 		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 		model := cfg.GetModelByType(agentCfg.Model)
 		if providerCfg != nil && model != nil && model.CanReason {
@@ -326,7 +326,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			}
 
 			// OpenAI models: reasoning effort dialog
-			if providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
+			if len(model.ReasoningLevels) > 0 {
 				commands = append(commands, Command{
 					ID:          "select_reasoning_effort",
 					Title:       "Select Reasoning Effort",
@@ -350,7 +350,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 		})
 	}
 	if c.sessionID != "" {
-		agentCfg := config.Get().Agents["coder"]
+		agentCfg := config.Get().Agents[config.AgentCoder]
 		model := config.Get().GetModelByType(agentCfg.Model)
 		if model.SupportsImages {
 			commands = append(commands, Command{
@@ -402,7 +402,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			Description: "Create/Update the CRUSH.md memory file",
 			Handler: func(cmd Command) tea.Cmd {
 				return util.CmdHandler(chat.SendMsg{
-					Text: prompt.Initialize(),
+					Text: agent.InitializePrompt(),
 				})
 			},
 		},

internal/tui/components/dialogs/compact/compact.go πŸ”—

@@ -1,272 +0,0 @@
-package compact
-
-import (
-	"context"
-
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-
-	"github.com/charmbracelet/crush/internal/llm/agent"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const CompactDialogID dialogs.DialogID = "compact"
-
-// CompactDialog interface for the session compact dialog
-type CompactDialog interface {
-	dialogs.DialogModel
-}
-
-type compactDialogCmp struct {
-	wWidth, wHeight int
-	width, height   int
-	selected        int
-	keyMap          KeyMap
-	sessionID       string
-	state           compactState
-	progress        string
-	agent           agent.Service
-	noAsk           bool // If true, skip confirmation dialog
-}
-
-type compactState int
-
-const (
-	stateConfirm compactState = iota
-	stateCompacting
-	stateError
-)
-
-// NewCompactDialogCmp creates a new session compact dialog
-func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) CompactDialog {
-	return &compactDialogCmp{
-		sessionID: sessionID,
-		keyMap:    DefaultKeyMap(),
-		state:     stateConfirm,
-		selected:  0,
-		agent:     agent,
-		noAsk:     noAsk,
-	}
-}
-
-func (c *compactDialogCmp) Init() tea.Cmd {
-	if c.noAsk {
-		// If noAsk is true, skip confirmation and start compaction immediately
-		return c.startCompaction()
-	}
-	return nil
-}
-
-func (c *compactDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		c.wWidth = msg.Width
-		c.wHeight = msg.Height
-		cmd := c.SetSize()
-		return c, cmd
-
-	case tea.KeyPressMsg:
-		switch c.state {
-		case stateConfirm:
-			switch {
-			case key.Matches(msg, c.keyMap.ChangeSelection):
-				c.selected = (c.selected + 1) % 2
-				return c, nil
-			case key.Matches(msg, c.keyMap.Select):
-				if c.selected == 0 {
-					return c, c.startCompaction()
-				} else {
-					return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-				}
-			case key.Matches(msg, c.keyMap.Y):
-				return c, c.startCompaction()
-			case key.Matches(msg, c.keyMap.N):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			case key.Matches(msg, c.keyMap.Close):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			}
-		case stateCompacting:
-			switch {
-			case key.Matches(msg, c.keyMap.Close):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			}
-		case stateError:
-			switch {
-			case key.Matches(msg, c.keyMap.Select):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			case key.Matches(msg, c.keyMap.Close):
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			}
-		}
-
-	case agent.AgentEvent:
-		switch msg.Type {
-		case agent.AgentEventTypeSummarize:
-			if msg.Error != nil {
-				c.state = stateError
-				c.progress = "Error: " + msg.Error.Error()
-			} else if msg.Done {
-				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
-			} else {
-				c.progress = msg.Progress
-			}
-		case agent.AgentEventTypeError:
-			// Handle errors that occur during summarization but are sent as separate error events.
-			c.state = stateError
-			if msg.Error != nil {
-				c.progress = "Error: " + msg.Error.Error()
-			} else {
-				c.progress = "An unknown error occurred"
-			}
-		}
-		return c, nil
-	}
-
-	return c, nil
-}
-
-func (c *compactDialogCmp) startCompaction() tea.Cmd {
-	c.state = stateCompacting
-	c.progress = "Starting summarization..."
-	return func() tea.Msg {
-		err := c.agent.Summarize(context.Background(), c.sessionID)
-		if err != nil {
-			c.state = stateError
-			c.progress = "Error: " + err.Error()
-		}
-		return nil
-	}
-}
-
-func (c *compactDialogCmp) renderButtons() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	buttons := []core.ButtonOpts{
-		{
-			Text:           "Yes",
-			UnderlineIndex: 0, // "Y"
-			Selected:       c.selected == 0,
-		},
-		{
-			Text:           "No",
-			UnderlineIndex: 0, // "N"
-			Selected:       c.selected == 1,
-		},
-	}
-
-	content := core.SelectableButtons(buttons, "  ")
-
-	return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content)
-}
-
-func (c *compactDialogCmp) renderContent() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	switch c.state {
-	case stateConfirm:
-		explanation := t.S().Text.
-			Width(c.width - 4).
-			Render("This will summarize the current session and reset the context. The conversation history will be condensed into a summary to free up context space while preserving important information.")
-
-		question := t.S().Text.
-			Width(c.width - 4).
-			Render("Do you want to continue?")
-
-		return baseStyle.Render(lipgloss.JoinVertical(
-			lipgloss.Left,
-			explanation,
-			"",
-			question,
-		))
-	case stateCompacting:
-		return baseStyle.Render(lipgloss.JoinVertical(
-			lipgloss.Left,
-			c.progress,
-			"",
-			"Please wait...",
-		))
-	case stateError:
-		return baseStyle.Render(lipgloss.JoinVertical(
-			lipgloss.Left,
-			c.progress,
-			"",
-			"Press Enter to close",
-		))
-	}
-	return ""
-}
-
-func (c *compactDialogCmp) render() string {
-	t := styles.CurrentTheme()
-	baseStyle := t.S().Base
-
-	var title string
-	switch c.state {
-	case stateConfirm:
-		title = "Compact Session"
-	case stateCompacting:
-		title = "Compacting Session"
-	case stateError:
-		title = "Compact Failed"
-	}
-
-	titleView := core.Title(title, c.width-4)
-	content := c.renderContent()
-
-	var dialogContent string
-	if c.state == stateConfirm {
-		buttons := c.renderButtons()
-		dialogContent = lipgloss.JoinVertical(
-			lipgloss.Top,
-			titleView,
-			"",
-			content,
-			"",
-			buttons,
-			"",
-		)
-	} else {
-		dialogContent = lipgloss.JoinVertical(
-			lipgloss.Top,
-			titleView,
-			"",
-			content,
-			"",
-		)
-	}
-
-	return baseStyle.
-		Padding(0, 1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.BorderFocus).
-		Width(c.width).
-		Render(dialogContent)
-}
-
-func (c *compactDialogCmp) View() string {
-	return c.render()
-}
-
-// SetSize sets the size of the component.
-func (c *compactDialogCmp) SetSize() tea.Cmd {
-	c.width = min(90, c.wWidth)
-	c.height = min(15, c.wHeight)
-	return nil
-}
-
-func (c *compactDialogCmp) Position() (int, int) {
-	row := (c.wHeight / 2) - (c.height / 2)
-	col := (c.wWidth / 2) - (c.width / 2)
-	return row, col
-}
-
-// ID implements CompactDialog.
-func (c *compactDialogCmp) ID() dialogs.DialogID {
-	return CompactDialogID
-}

internal/tui/components/dialogs/compact/keys.go πŸ”—

@@ -1,71 +0,0 @@
-package compact
-
-import (
-	"github.com/charmbracelet/bubbles/v2/key"
-)
-
-// KeyMap defines the key bindings for the compact dialog.
-type KeyMap struct {
-	ChangeSelection key.Binding
-	Select          key.Binding
-	Y               key.Binding
-	N               key.Binding
-	Close           key.Binding
-}
-
-// DefaultKeyMap returns the default key bindings for the compact dialog.
-func DefaultKeyMap() KeyMap {
-	return KeyMap{
-		ChangeSelection: key.NewBinding(
-			key.WithKeys("tab", "left", "right", "h", "l"),
-			key.WithHelp("tab/←/β†’", "toggle selection"),
-		),
-		Select: key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "confirm"),
-		),
-		Y: key.NewBinding(
-			key.WithKeys("y"),
-			key.WithHelp("y", "yes"),
-		),
-		N: key.NewBinding(
-			key.WithKeys("n"),
-			key.WithHelp("n", "no"),
-		),
-		Close: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.ChangeSelection,
-		k.Select,
-		k.Y,
-		k.N,
-		k.Close,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.ChangeSelection,
-		k.Select,
-		k.Close,
-	}
-}

internal/tui/components/dialogs/models/list.go πŸ”—

@@ -1,6 +1,7 @@
 package models
 
 import (
+	"cmp"
 	"fmt"
 	"slices"
 	"strings"
@@ -151,7 +152,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 					ContextWindow:          model.ContextWindow,
 					DefaultMaxTokens:       model.DefaultMaxTokens,
 					CanReason:              model.CanReason,
-					HasReasoningEffort:     model.HasReasoningEffort,
+					ReasoningLevels:        model.ReasoningLevels,
 					DefaultReasoningEffort: model.DefaultReasoningEffort,
 					SupportsImages:         model.SupportsImages,
 				}
@@ -195,34 +196,59 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 			continue
 		}
 
-		// Check if this provider is configured and not disabled
-		if providerConfig, exists := cfg.Providers.Get(string(provider.ID)); exists && providerConfig.Disable {
+		providerConfig, providerConfigured := cfg.Providers.Get(string(provider.ID))
+		if providerConfigured && providerConfig.Disable {
 			continue
 		}
 
-		name := provider.Name
+		displayProvider := provider
+		if providerConfigured {
+			displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
+			modelIndex := make(map[string]int, len(displayProvider.Models))
+			for i, model := range displayProvider.Models {
+				modelIndex[model.ID] = i
+			}
+			for _, model := range providerConfig.Models {
+				if model.ID == "" {
+					continue
+				}
+				if idx, ok := modelIndex[model.ID]; ok {
+					if model.Name != "" {
+						displayProvider.Models[idx].Name = model.Name
+					}
+					continue
+				}
+				if model.Name == "" {
+					model.Name = model.ID
+				}
+				displayProvider.Models = append(displayProvider.Models, model)
+				modelIndex[model.ID] = len(displayProvider.Models) - 1
+			}
+		}
+
+		name := displayProvider.Name
 		if name == "" {
-			name = string(provider.ID)
+			name = string(displayProvider.ID)
 		}
 
 		section := list.NewItemSection(name)
-		if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
+		if providerConfigured {
 			section.SetInfo(configured)
 		}
 		group := list.Group[list.CompletionItem[ModelOption]]{
 			Section: section,
 		}
-		for _, model := range provider.Models {
+		for _, model := range displayProvider.Models {
 			item := list.NewCompletionItem(model.Name, ModelOption{
-				Provider: provider,
+				Provider: displayProvider,
 				Model:    model,
 			},
 				list.WithCompletionID(
-					fmt.Sprintf("%s:%s", provider.ID, model.ID),
+					fmt.Sprintf("%s:%s", displayProvider.ID, model.ID),
 				),
 			)
 			group.Items = append(group.Items, item)
-			if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
+			if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider {
 				selectedItemID = item.ID()
 			}
 		}

internal/tui/components/dialogs/permissions/permissions.go πŸ”—

@@ -9,8 +9,8 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/viewport"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"

internal/tui/components/dialogs/reasoning/reasoning.go πŸ”—

@@ -5,6 +5,8 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -120,7 +122,7 @@ func (r *reasoningDialogCmp) Init() tea.Cmd {
 
 func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
 	cfg := config.Get()
-	if agentCfg, ok := cfg.Agents["coder"]; ok {
+	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
 		selectedModel := cfg.Models[agentCfg.Model]
 		model := cfg.GetModelByType(agentCfg.Model)
 
@@ -130,19 +132,13 @@ func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
 			currentEffort = model.DefaultReasoningEffort
 		}
 
-		efforts := []EffortOption{
-			{
-				Title:  "Low",
-				Effort: "low",
-			},
-			{
-				Title:  "Medium",
-				Effort: "medium",
-			},
-			{
-				Title:  "High",
-				Effort: "high",
-			},
+		efforts := []EffortOption{}
+		caser := cases.Title(language.Und)
+		for _, level := range model.ReasoningLevels {
+			efforts = append(efforts, EffortOption{
+				Title:  caser.String(level),
+				Effort: level,
+			})
 		}
 
 		effortItems := []list.CompletionItem[EffortOption]{}

internal/tui/components/mcp/mcp.go πŸ”—

@@ -5,8 +5,8 @@ import (
 
 	"github.com/charmbracelet/lipgloss/v2"
 
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 )
@@ -40,7 +40,7 @@ func RenderMCPList(opts RenderOptions) []string {
 	}
 
 	// Get MCP states
-	mcpStates := agent.GetMCPStates()
+	mcpStates := tools.GetMCPStates()
 
 	// Determine how many items to show
 	maxItems := len(mcps)
@@ -60,17 +60,17 @@ func RenderMCPList(opts RenderOptions) []string {
 
 		if state, exists := mcpStates[l.Name]; exists {
 			switch state.State {
-			case agent.MCPStateDisabled:
+			case tools.MCPStateDisabled:
 				description = t.S().Subtle.Render("disabled")
-			case agent.MCPStateStarting:
+			case tools.MCPStateStarting:
 				icon = t.ItemBusyIcon
 				description = t.S().Subtle.Render("starting...")
-			case agent.MCPStateConnected:
+			case tools.MCPStateConnected:
 				icon = t.ItemOnlineIcon
 				if state.ToolCount > 0 {
 					extraContent = t.S().Subtle.Render(fmt.Sprintf("%d tools", state.ToolCount))
 				}
-			case agent.MCPStateError:
+			case tools.MCPStateError:
 				icon = t.ItemErrorIcon
 				if state.Error != nil {
 					description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))

internal/tui/page/chat/chat.go πŸ”—

@@ -2,6 +2,7 @@ package chat
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"time"
 
@@ -9,7 +10,6 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/history"
@@ -331,7 +331,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		return p, tea.Batch(cmds...)
 
 	case commands.CommandRunCustomMsg:
-		if p.app.CoderAgent.IsBusy() {
+		if p.app.AgentCoordinator.IsBusy() {
 			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 		}
 
@@ -346,7 +346,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			p.splashFullScreen = true
 			return p, p.SetSize(p.width, p.height)
 		}
-		err := p.app.InitCoderAgent()
+		err := p.app.InitCoderAgent(context.TODO())
 		if err != nil {
 			return p, util.ReportError(err)
 		}
@@ -355,7 +355,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		p.focusedPane = PanelTypeEditor
 		return p, p.SetSize(p.width, p.height)
 	case commands.NewSessionsMsg:
-		if p.app.CoderAgent.IsBusy() {
+		if p.app.AgentCoordinator.IsBusy() {
 			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 		}
 		return p, p.newSession()
@@ -363,15 +363,15 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		switch {
 		case key.Matches(msg, p.keyMap.NewSession):
 			// if we have no agent do nothing
-			if p.app.CoderAgent == nil {
+			if p.app.AgentCoordinator == nil {
 				return p, nil
 			}
-			if p.app.CoderAgent.IsBusy() {
+			if p.app.AgentCoordinator.IsBusy() {
 				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 			}
 			return p, p.newSession()
 		case key.Matches(msg, p.keyMap.AddAttachment):
-			agentCfg := config.Get().Agents["coder"]
+			agentCfg := config.Get().Agents[config.AgentCoder]
 			model := config.Get().GetModelByType(agentCfg.Model)
 			if model.SupportsImages {
 				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
@@ -387,7 +387,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			p.changeFocus()
 			return p, nil
 		case key.Matches(msg, p.keyMap.Cancel):
-			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
+			if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
 				return p, p.cancel()
 			}
 		case key.Matches(msg, p.keyMap.Details):
@@ -530,21 +530,21 @@ func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 func (p *chatPage) toggleThinking() tea.Cmd {
 	return func() tea.Msg {
 		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
+		agentCfg := cfg.Agents[config.AgentCoder]
 		currentModel := cfg.Models[agentCfg.Model]
 
 		// Toggle the thinking mode
 		currentModel.Think = !currentModel.Think
-		cfg.Models[agentCfg.Model] = currentModel
-
-		// Update the agent with the new configuration
-		if err := p.app.UpdateAgentModel(); err != nil {
+		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update thinking mode: " + err.Error(),
 			}
 		}
 
+		// Update the agent with the new configuration
+		go p.app.UpdateAgentModel(context.TODO())
+
 		status := "disabled"
 		if currentModel.Think {
 			status = "enabled"
@@ -559,12 +559,11 @@ func (p *chatPage) toggleThinking() tea.Cmd {
 func (p *chatPage) openReasoningDialog() tea.Cmd {
 	return func() tea.Msg {
 		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
+		agentCfg := cfg.Agents[config.AgentCoder]
 		model := cfg.GetModelByType(agentCfg.Model)
 		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 
-		if providerCfg != nil && model != nil &&
-			providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
+		if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
 			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 			return dialogs.OpenDialogMsg{
 				Model: reasoning.NewReasoningDialog(),
@@ -577,15 +576,20 @@ func (p *chatPage) openReasoningDialog() tea.Cmd {
 func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 	return func() tea.Msg {
 		cfg := config.Get()
-		agentCfg := cfg.Agents["coder"]
+		agentCfg := cfg.Agents[config.AgentCoder]
 		currentModel := cfg.Models[agentCfg.Model]
 
 		// Update the model configuration
 		currentModel.ReasoningEffort = effort
-		cfg.Models[agentCfg.Model] = currentModel
+		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  "Failed to update reasoning effort: " + err.Error(),
+			}
+		}
 
 		// Update the agent with the new configuration
-		if err := p.app.UpdateAgentModel(); err != nil {
+		if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
 				Msg:  "Failed to update reasoning effort: " + err.Error(),
@@ -706,14 +710,14 @@ func (p *chatPage) changeFocus() {
 func (p *chatPage) cancel() tea.Cmd {
 	if p.isCanceling {
 		p.isCanceling = false
-		if p.app.CoderAgent != nil {
-			p.app.CoderAgent.Cancel(p.session.ID)
+		if p.app.AgentCoordinator != nil {
+			p.app.AgentCoordinator.Cancel(p.session.ID)
 		}
 		return nil
 	}
 
-	if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
-		p.app.CoderAgent.ClearQueue(p.session.ID)
+	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
+		p.app.AgentCoordinator.ClearQueue(p.session.ID)
 		return nil
 	}
 	p.isCanceling = true
@@ -746,14 +750,25 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
 		session = newSession
 		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 	}
-	if p.app.CoderAgent == nil {
+	if p.app.AgentCoordinator == nil {
 		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 	}
-	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
-	if err != nil {
-		return util.ReportError(err)
-	}
 	cmds = append(cmds, p.chat.GoToBottom())
+	cmds = append(cmds, func() tea.Msg {
+		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
+		if err != nil {
+			isCancelErr := errors.Is(err, context.Canceled)
+			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+			if isCancelErr || isPermissionErr {
+				return nil
+			}
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  err.Error(),
+			}
+		}
+		return nil
+	})
 	return tea.Batch(cmds...)
 }
 
@@ -762,7 +777,7 @@ func (p *chatPage) Bindings() []key.Binding {
 		p.keyMap.NewSession,
 		p.keyMap.AddAttachment,
 	}
-	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
+	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 		cancelBinding := p.keyMap.Cancel
 		if p.isCanceling {
 			cancelBinding = key.NewBinding(
@@ -883,7 +898,7 @@ func (p *chatPage) Help() help.KeyMap {
 			}
 			return core.NewSimpleHelp(shortList, fullList)
 		}
-		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 			cancelBinding := key.NewBinding(
 				key.WithKeys("esc", "alt+esc"),
 				key.WithHelp("esc", "cancel"),
@@ -894,7 +909,7 @@ func (p *chatPage) Help() help.KeyMap {
 					key.WithHelp("esc", "press again to cancel"),
 				)
 			}
-			if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 				cancelBinding = key.NewBinding(
 					key.WithKeys("esc", "alt+esc"),
 					key.WithHelp("esc", "clear queue"),

internal/tui/tui.go πŸ”—

@@ -12,7 +12,6 @@ import (
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/event"
-	"github.com/charmbracelet/crush/internal/llm/agent"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -23,7 +22,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core/status"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
@@ -176,9 +174,13 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		)
 	// Compact
 	case commands.CompactMsg:
-		return a, util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
-		})
+		return a, func() tea.Msg {
+			err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+			if err != nil {
+				return util.ReportError(err)()
+			}
+			return nil
+		}
 	case commands.QuitMsg:
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: quit.NewQuitDialog(),
@@ -191,16 +193,13 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, a.handleWindowResize(a.wWidth, a.wHeight)
 	// Model Switch
 	case models.ModelSelectedMsg:
-		if a.app.CoderAgent.IsBusy() {
+		if a.app.AgentCoordinator.IsBusy() {
 			return a, util.ReportWarn("Agent is busy, please wait...")
 		}
 
 		config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
 
-		// Update the agent with the new model/provider configuration
-		if err := a.app.UpdateAgentModel(); err != nil {
-			return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
-		}
+		go a.app.UpdateAgentModel(context.TODO())
 
 		modelTypeName := "large"
 		if msg.ModelType == config.SelectedModelTypeSmall {
@@ -247,37 +246,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.app.Permissions.Deny(msg.Permission)
 		}
 		return a, nil
-	// Agent Events
-	case pubsub.Event[agent.AgentEvent]:
-		payload := msg.Payload
-
-		// Forward agent events to dialogs
-		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
-			u, dialogCmd := a.dialog.Update(payload)
-			if model, ok := u.(dialogs.DialogCmp); ok {
-				a.dialog = model
-			}
-
-			cmds = append(cmds, dialogCmd)
-		}
-
-		// Handle auto-compact logic
-		if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
-			// Get current session to check token usage
-			session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
-			if err == nil {
-				model := a.app.CoderAgent.Model()
-				contextWindow := model.ContextWindow
-				tokens := session.CompletionTokens + session.PromptTokens
-				if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
-					cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
-						Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
-					}))
-				}
-			}
-		}
-
-		return a, tea.Batch(cmds...)
 	case splash.OnboardingCompleteMsg:
 		item, ok := a.pages[a.currentPage]
 		if !ok {
@@ -469,7 +437,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		)
 		return tea.Sequence(cmds...)
 	case key.Matches(msg, a.keyMap.Suspend):
-		if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
 			return util.ReportWarn("Agent is busy, please wait...")
 		}
 		return tea.Suspend
@@ -487,7 +455,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 
 // moveToPage handles navigation between different pages in the application.
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
-	if a.app.CoderAgent.IsBusy() {
+	if a.app.AgentCoordinator.IsBusy() {
 		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
 		return util.ReportWarn("Agent is busy, please wait...")
 	}
@@ -586,7 +554,8 @@ func (a *appModel) View() tea.View {
 	view.Cursor = cursor
 	view.MouseMode = tea.MouseModeCellMotion
 	view.AltScreen = true
-	if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+
+	if a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
 		// HACK: use a random percentage to prevent ghostty from hiding it
 		// after a timeout.
 		view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))