diff --git a/.factory/skills/brand-writer/SKILL.md b/.factory/skills/brand-writer/SKILL.md
index 12ec9344365c08..6f08cc6f3b4a6c 100644
--- a/.factory/skills/brand-writer/SKILL.md
+++ b/.factory/skills/brand-writer/SKILL.md
@@ -162,7 +162,22 @@ For any criterion scoring <4 or any taboo phrase found:
Repeat until all criteria score 4+.
-### Phase 4: Validation
+### Phase 4: Humanizer Pass (Recommended)
+
+For high-stakes content (homepage, announcements, product pages), run the draft through the humanizer skill:
+
+```bash
+/humanizer
+```
+
+Paste your draft and let humanizer:
+1. Scan for the 24 AI-writing patterns from Wikipedia's "Signs of AI writing" guide
+2. Audit for remaining tells ("What makes this obviously AI generated?")
+3. Revise to add natural voice and rhythm
+
+This catches AI patterns that survive the brand-writer process and adds human texture.
+
+### Phase 5: Validation
Present final copy with scorecard:
diff --git a/.factory/skills/humanizer/SKILL.md b/.factory/skills/humanizer/SKILL.md
new file mode 100644
index 00000000000000..a135efbb7435f6
--- /dev/null
+++ b/.factory/skills/humanizer/SKILL.md
@@ -0,0 +1,393 @@
+---
+name: humanizer
+description: Remove signs of AI-generated writing from text. Use after drafting to make copy sound more natural and human-written. Based on Wikipedia's "Signs of AI writing" guide.
+allowed-tools: Read, Write, Edit, Glob, Grep, AskUserQuestion
+user-invocable: true
+---
+
+# Humanizer: Remove AI Writing Patterns
+
+You are a writing editor that identifies and removes signs of AI-generated text. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup.
+
+Key insight: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
+
+## Invocation
+
+```bash
+/humanizer # Review text for AI patterns
+/humanizer "paste text here" # Humanize specific text
+```
+
+## Your Task
+
+When given text to humanize:
+
+1. **Identify AI patterns** - Scan for the 24 patterns listed below
+2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives
+3. **Preserve meaning** - Keep the core message intact
+4. **Add soul** - Don't just remove bad patterns; inject actual personality
+5. **Final audit pass** - Ask "What makes this obviously AI generated?" then revise again
+
+---
+
+## PERSONALITY AND SOUL
+
+Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop.
+
+### Signs of soulless writing (even if technically "clean"):
+
+- Every sentence is the same length and structure
+- No opinions, just neutral reporting
+- No acknowledgment of uncertainty or mixed feelings
+- No first-person perspective when appropriate
+- No humor, no edge, no personality
+- Reads like a Wikipedia article or press release
+
+### How to add voice:
+
+**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
+
+**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
+
+**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
+
+**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
+
+**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
+
+**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
+
+### Before (clean but soulless):
+
+> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
+
+### After (has a pulse):
+
+> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night.
+
+---
+
+## THE 24 PATTERNS
+
+### Content Patterns
+
+#### 1. Significance Inflation
+
+**Watch for:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights importance, reflects broader, symbolizing ongoing/enduring/lasting, marking/shaping the, represents a shift, key turning point, evolving landscape
+
+**Before:**
+> The Statistical Institute was officially established in 1989, marking a pivotal moment in the evolution of regional statistics.
+
+**After:**
+> The Statistical Institute was established in 1989 to collect and publish regional statistics.
+
+#### 2. Notability Name-Dropping
+
+**Watch for:** cited in NYT, BBC, FT; independent coverage; active social media presence; written by a leading expert
+
+**Before:**
+> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu.
+
+**After:**
+> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
+
+#### 3. Superficial -ing Analyses
+
+**Watch for:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., showcasing...
+
+**Before:**
+> The temple's colors resonate with natural beauty, symbolizing bluebonnets, reflecting the community's deep connection to the land.
+
+**After:**
+> The temple uses blue and gold colors. The architect said these were chosen to reference local bluebonnets.
+
+#### 4. Promotional Language
+
+**Watch for:** boasts a, vibrant, rich (figurative), profound, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking, renowned, breathtaking, must-visit, stunning
+
+**Before:**
+> Nestled within the breathtaking region, Alamata stands as a vibrant town with rich cultural heritage and stunning natural beauty.
+
+**After:**
+> Alamata is a town in the Gonder region, known for its weekly market and 18th-century church.
+
+#### 5. Vague Attributions
+
+**Watch for:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications
+
+**Before:**
+> Experts believe it plays a crucial role in the regional ecosystem.
+
+**After:**
+> The river supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
+
+#### 6. Formulaic "Challenges" Sections
+
+**Watch for:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
+
+**Before:**
+> Despite challenges typical of urban areas, the city continues to thrive as an integral part of growth.
+
+**After:**
+> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a drainage project in 2022.
+
+---
+
+### Language Patterns
+
+#### 7. AI Vocabulary Words
+
+**High-frequency:** Additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore (verb), valuable, vibrant
+
+**Before:**
+> Additionally, a distinctive feature showcases how these dishes have integrated into the traditional culinary landscape.
+
+**After:**
+> Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
+
+#### 8. Copula Avoidance
+
+**Watch for:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
+
+**Before:**
+> Gallery 825 serves as the exhibition space. The gallery features four spaces and boasts over 3,000 square feet.
+
+**After:**
+> Gallery 825 is the exhibition space. The gallery has four rooms totaling 3,000 square feet.
+
+#### 9. Negative Parallelisms
+
+**Watch for:** "Not only...but...", "It's not just about..., it's..."
+
+**Before:**
+> It's not just about the beat; it's part of the aggression. It's not merely a song, it's a statement.
+
+**After:**
+> The heavy beat adds to the aggressive tone.
+
+#### 10. Rule of Three Overuse
+
+**Before:**
+> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
+
+**After:**
+> The event includes talks and panels. There's also time for informal networking.
+
+#### 11. Synonym Cycling
+
+**Before:**
+> The protagonist faces challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
+
+**After:**
+> The protagonist faces many challenges but eventually triumphs and returns home.
+
+#### 12. False Ranges
+
+**Watch for:** "from X to Y" where X and Y aren't on a meaningful scale
+
+**Before:**
+> Our journey has taken us from the singularity of the Big Bang to the cosmic web, from the birth of stars to the dance of dark matter.
+
+**After:**
+> The book covers the Big Bang, star formation, and current theories about dark matter.
+
+---
+
+### Style Patterns
+
+#### 13. Em Dash Overuse
+
+**Before:**
+> The term is promoted by institutions—not the people themselves—yet this continues—even in documents.
+
+**After:**
+> The term is promoted by institutions, not the people themselves, yet this continues in official documents.
+
+#### 14. Boldface Overuse
+
+**Before:**
+> It blends **OKRs**, **KPIs**, and tools such as the **Business Model Canvas** and **Balanced Scorecard**.
+
+**After:**
+> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
+
+#### 15. Inline-Header Lists
+
+**Before:**
+> - **Performance:** Performance has been enhanced through optimized algorithms.
+> - **Security:** Security has been strengthened with encryption.
+
+**After:**
+> The update speeds up load times through optimized algorithms and adds end-to-end encryption.
+
+#### 16. Title Case Headings
+
+**Before:**
+> ## Strategic Negotiations And Global Partnerships
+
+**After:**
+> ## Strategic negotiations and global partnerships
+
+#### 17. Emojis in Professional Writing
+
+**Before:**
+> 🚀 **Launch Phase:** The product launches in Q3
+> 💡 **Key Insight:** Users prefer simplicity
+
+**After:**
+> The product launches in Q3. User research showed a preference for simplicity.
+
+#### 18. Curly Quotation Marks
+
+**Before:**
+> He said "the project is on track" but others disagreed.
+
+**After:**
+> He said "the project is on track" but others disagreed.
+
+---
+
+### Communication Patterns
+
+#### 19. Chatbot Artifacts
+
+**Watch for:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
+
+**Before:**
+> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
+
+**After:**
+> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
+
+#### 20. Knowledge-Cutoff Disclaimers
+
+**Watch for:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
+
+**Before:**
+> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
+
+**After:**
+> The company was founded in 1994, according to its registration documents.
+
+#### 21. Sycophantic Tone
+
+**Before:**
+> Great question! You're absolutely right that this is a complex topic. That's an excellent point!
+
+**After:**
+> The economic factors you mentioned are relevant here.
+
+---
+
+### Filler and Hedging
+
+#### 22. Filler Phrases
+
+| Before | After |
+|--------|-------|
+| "In order to achieve this" | "To achieve this" |
+| "Due to the fact that" | "Because" |
+| "At this point in time" | "Now" |
+| "It is important to note that" | (delete) |
+| "has the ability to" | "can" |
+
+#### 23. Excessive Hedging
+
+**Before:**
+> It could potentially possibly be argued that the policy might have some effect on outcomes.
+
+**After:**
+> The policy may affect outcomes.
+
+#### 24. Generic Positive Conclusions
+
+**Before:**
+> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence.
+
+**After:**
+> The company plans to open two more locations next year.
+
+---
+
+## Process
+
+1. Read the input text carefully
+2. Identify all instances of the 24 patterns
+3. Rewrite problematic sections
+4. Ensure the revised text:
+ - Sounds natural when read aloud
+ - Varies sentence structure
+ - Uses specific details over vague claims
+ - Uses simple constructions (is/are/has) where appropriate
+5. **Draft humanized version**
+6. **Ask:** "What makes this still obviously AI generated?"
+7. **List** remaining tells (if any)
+8. **Revise** based on the audit
+9. **Present final version**
+
+## Output Format
+
+```
+## Draft Rewrite
+
+[First pass at humanized text]
+
+## AI Audit
+
+What still reads as AI-generated:
+- [bullet points of remaining tells]
+
+## Final Version
+
+[Revised text after audit]
+
+## Changes Made
+
+- [Brief summary of transformations applied]
+```
+
+---
+
+## Full Example
+
+**Input (AI-heavy):**
+
+> Great question! Here is an essay on this topic. I hope this helps!
+>
+> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
+
+**Draft Rewrite:**
+
+> AI coding assistants speed up some tasks. In a 2024 study, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
+>
+> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong.
+
+**AI Audit:**
+
+What still reads as AI-generated:
+- Rhythm is still a bit too tidy (clean contrasts, evenly paced)
+- The closer leans slightly slogan-y
+
+**Final Version:**
+
+> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
+>
+> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
+>
+> If you don't have tests, you're basically guessing.
+
+**Changes Made:**
+
+- Removed chatbot artifacts ("Great question!", "I hope this helps!")
+- Removed significance inflation ("testament", "pivotal moment", "evolving landscape")
+- Removed promotional language ("groundbreaking", "nestled")
+- Removed em dashes
+- Removed copula avoidance ("serves as") → used direct statements
+- Added first-person voice and opinion
+- Varied sentence rhythm
+
+---
+
+## Reference
+
+Based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup.
diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml
index d078db137824a0..528600138243cb 100644
--- a/.github/workflows/background_agent_mvp.yml
+++ b/.github/workflows/background_agent_mvp.yml
@@ -1,8 +1,11 @@
name: background_agent_mvp
+# NOTE: Scheduled runs disabled as of 2026-02-24. The workflow can still be
+# triggered manually via workflow_dispatch. See Notion doc "Background Agent
+# for Zed" for current status and contact info to resume this work.
on:
- schedule:
- - cron: "0 16 * * 1-5"
+ # schedule:
+ # - cron: "0 16 * * 1-5"
workflow_dispatch:
inputs:
crash_ids:
diff --git a/.github/workflows/docs_suggestions.yml b/.github/workflows/docs_suggestions.yml
index 8cf98e978cddfe..c2dc8b4d5197bc 100644
--- a/.github/workflows/docs_suggestions.yml
+++ b/.github/workflows/docs_suggestions.yml
@@ -17,7 +17,7 @@ on:
- 'crates/**/*.rs'
- '!crates/**/*_test.rs'
- '!crates/**/tests/**'
-
+
# Run on cherry-picks to release branches
pull_request_target:
types: [opened, synchronize]
@@ -25,7 +25,7 @@ on:
- 'v0.*'
paths:
- 'crates/**/*.rs'
-
+
# Manual trigger for testing
workflow_dispatch:
inputs:
@@ -42,10 +42,6 @@ on:
- immediate
default: batch
-permissions:
- contents: write
- pull-requests: write
-
env:
DROID_MODEL: claude-sonnet-4-5-20250929
SUGGESTIONS_BRANCH: docs/suggestions-pending
@@ -56,16 +52,19 @@ jobs:
batch-suggestions:
runs-on: ubuntu-latest
timeout-minutes: 10
+ permissions:
+ contents: write
+ pull-requests: read
if: |
- (github.event_name == 'pull_request' &&
+ (github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.mode == 'batch')
-
+
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -92,35 +91,48 @@ jobs:
- name: Get PR info
id: pr
+ env:
+ INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+ EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
+ GH_TOKEN: ${{ github.token }}
run: |
- if [ -n "${{ inputs.pr_number }}" ]; then
- PR_NUM="${{ inputs.pr_number }}"
+ if [ -n "$INPUT_PR_NUMBER" ]; then
+ PR_NUM="$INPUT_PR_NUMBER"
else
- PR_NUM="${{ github.event.pull_request.number }}"
+ PR_NUM="$EVENT_PR_NUMBER"
+ fi
+ if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
+ echo "::error::Invalid PR number: $PR_NUM"
+ exit 1
fi
echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
-
- # Get PR title
- PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title')
- echo "title=$PR_TITLE" >> "$GITHUB_OUTPUT"
- env:
- GH_TOKEN: ${{ github.token }}
+ PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title' | tr -d '\n\r' | head -c 200)
+ EOF_MARKER="EOF_$(openssl rand -hex 8)"
+ {
+ echo "title<<$EOF_MARKER"
+ echo "$PR_TITLE"
+ echo "$EOF_MARKER"
+ } >> "$GITHUB_OUTPUT"
- name: Analyze PR for documentation needs
id: analyze
+ env:
+ GH_TOKEN: ${{ github.token }}
+ FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
+ PR_NUMBER: ${{ steps.pr.outputs.number }}
run: |
# Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
# Unset GH_TOKEN first to allow gh auth login to store credentials
echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
-
+
OUTPUT_FILE=$(mktemp)
-
+
# Retry with exponential backoff for transient Factory API failures
MAX_RETRIES=3
for i in $(seq 1 "$MAX_RETRIES"); do
echo "Attempt $i of $MAX_RETRIES to analyze PR..."
if ./script/docs-suggest \
- --pr "${{ steps.pr.outputs.number }}" \
+ --pr "$PR_NUMBER" \
--immediate \
--preview \
--output "$OUTPUT_FILE" \
@@ -135,7 +147,7 @@ jobs:
echo "Retrying in $((i * 5)) seconds..."
sleep $((i * 5))
done
-
+
# Check if we got actionable suggestions (not "no updates needed")
if grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then
@@ -146,9 +158,6 @@ jobs:
echo "No actionable documentation suggestions for this PR"
cat "$OUTPUT_FILE"
fi
- env:
- GH_TOKEN: ${{ github.token }}
- FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
- name: Commit suggestions to queue branch
if: steps.analyze.outputs.has_suggestions == 'true'
@@ -156,18 +165,19 @@ jobs:
PR_NUM: ${{ steps.pr.outputs.number }}
PR_TITLE: ${{ steps.pr.outputs.title }}
OUTPUT_FILE: ${{ steps.analyze.outputs.output_file }}
+ REPO: ${{ github.repository }}
run: |
set -euo pipefail
-
+
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
-
+
# Retry loop for handling concurrent pushes
MAX_RETRIES=3
for i in $(seq 1 "$MAX_RETRIES"); do
echo "Attempt $i of $MAX_RETRIES"
-
+
# Fetch and checkout suggestions branch (create if doesn't exist)
if git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then
git fetch origin "$SUGGESTIONS_BRANCH"
@@ -176,7 +186,7 @@ jobs:
# Create orphan branch for clean history
git checkout --orphan "$SUGGESTIONS_BRANCH"
git rm -rf . > /dev/null 2>&1 || true
-
+
# Initialize with README
cat > README.md << 'EOF'
# Documentation Suggestions Queue
@@ -198,34 +208,34 @@ jobs:
3. At preview release, suggestions are collected into a docs PR
4. After docs PR is created, this branch is reset
EOF
-
+
mkdir -p suggestions
echo '{"suggestions":[]}' > manifest.json
git add README.md suggestions manifest.json
git commit -m "Initialize documentation suggestions queue"
fi
-
+
# Create suggestion file
SUGGESTION_FILE="suggestions/PR-${PR_NUM}.md"
-
+
{
echo "# PR #${PR_NUM}: ${PR_TITLE}"
echo ""
echo "_Merged: $(date -u +%Y-%m-%dT%H:%M:%SZ)_"
- echo "_PR: https://github.com/${{ github.repository }}/pull/${PR_NUM}_"
+ echo "_PR: https://github.com/${REPO}/pull/${PR_NUM}_"
echo ""
cat "$OUTPUT_FILE"
} > "$SUGGESTION_FILE"
-
+
# Update manifest
MANIFEST=$(cat manifest.json)
NEW_ENTRY="{\"pr\":${PR_NUM},\"title\":$(echo "$PR_TITLE" | jq -R .),\"file\":\"$SUGGESTION_FILE\",\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
-
+
# Add to manifest if not already present
if ! echo "$MANIFEST" | jq -e ".suggestions[] | select(.pr == $PR_NUM)" > /dev/null 2>&1; then
echo "$MANIFEST" | jq ".suggestions += [$NEW_ENTRY]" > manifest.json
fi
-
+
# Commit
git add "$SUGGESTION_FILE" manifest.json
git commit -m "docs: Add suggestions for PR #${PR_NUM}
@@ -233,7 +243,7 @@ jobs:
${PR_TITLE}
Auto-generated documentation suggestions for review at next preview release."
-
+
# Try to push
if git push origin "$SUGGESTIONS_BRANCH"; then
echo "Successfully pushed suggestions"
@@ -250,33 +260,47 @@ jobs:
- name: Summary
if: always()
+ env:
+ HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }}
+ PR_NUM: ${{ steps.pr.outputs.number }}
+ REPO: ${{ github.repository }}
run: |
{
echo "## Documentation Suggestions"
echo ""
- if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
- echo "✅ Suggestions queued for PR #${{ steps.pr.outputs.number }}"
+ if [ "$HAS_SUGGESTIONS" == "true" ]; then
+ echo "✅ Suggestions queued for PR #${PR_NUM}"
echo ""
- echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${{ github.repository }}/tree/${{ env.SUGGESTIONS_BRANCH }})"
+ echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${REPO}/tree/${SUGGESTIONS_BRANCH})"
else
echo "No documentation updates needed for this PR."
fi
} >> "$GITHUB_STEP_SUMMARY"
- # Job for cherry-picks to release branches - immediate output to step summary
+ # Job for cherry-picks to release branches - immediate output as PR comment
cherry-pick-suggestions:
runs-on: ubuntu-latest
timeout-minutes: 10
+ permissions:
+ contents: read
+ pull-requests: write
+ concurrency:
+ group: docs-suggestions-${{ github.event.pull_request.number || inputs.pr_number || 'manual' }}
+ cancel-in-progress: true
if: |
- (github.event_name == 'pull_request_target' &&
- startsWith(github.event.pull_request.base.ref, 'v0.')) ||
+ (github.event_name == 'pull_request_target' &&
+ startsWith(github.event.pull_request.base.ref, 'v0.') &&
+ contains(fromJSON('["MEMBER","OWNER"]'),
+ github.event.pull_request.author_association)) ||
(github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate')
-
+
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
+ ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }}
+ persist-credentials: false
- name: Install Droid CLI
run: |
@@ -300,29 +324,41 @@ jobs:
- name: Get PR number
id: pr
+ env:
+ INPUT_PR_NUMBER: ${{ inputs.pr_number }}
+ EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
- if [ -n "${{ inputs.pr_number }}" ]; then
- echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
+ if [ -n "$INPUT_PR_NUMBER" ]; then
+ PR_NUM="$INPUT_PR_NUMBER"
else
- echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
+ PR_NUM="$EVENT_PR_NUMBER"
fi
+ if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
+ echo "::error::Invalid PR number: $PR_NUM"
+ exit 1
+ fi
+ echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
- name: Analyze PR for documentation needs
id: analyze
+ env:
+ GH_TOKEN: ${{ github.token }}
+ FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
+ PR_NUMBER: ${{ steps.pr.outputs.number }}
run: |
# Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected)
# Unset GH_TOKEN first to allow gh auth login to store credentials
echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token)
-
+
OUTPUT_FILE="${RUNNER_TEMP}/suggestions.md"
-
+
# Cherry-picks don't get preview callout
# Retry with exponential backoff for transient Factory API failures
MAX_RETRIES=3
for i in $(seq 1 "$MAX_RETRIES"); do
echo "Attempt $i of $MAX_RETRIES to analyze PR..."
if ./script/docs-suggest \
- --pr "${{ steps.pr.outputs.number }}" \
+ --pr "$PR_NUMBER" \
--immediate \
--no-preview \
--output "$OUTPUT_FILE" \
@@ -337,7 +373,7 @@ jobs:
echo "Retrying in $((i * 5)) seconds..."
sleep $((i * 5))
done
-
+
# Check if we got actionable suggestions
if [ -s "$OUTPUT_FILE" ] && \
grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \
@@ -347,48 +383,78 @@ jobs:
else
echo "has_suggestions=false" >> "$GITHUB_OUTPUT"
fi
- env:
- GH_TOKEN: ${{ github.token }}
- FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
- name: Post suggestions as PR comment
if: steps.analyze.outputs.has_suggestions == 'true'
- uses: actions/github-script@v7
+ uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
SUGGESTIONS_FILE: ${{ steps.analyze.outputs.suggestions_file }}
+ PR_NUMBER: ${{ steps.pr.outputs.number }}
with:
script: |
const fs = require('fs');
- const suggestions = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8');
-
+
+ // Read suggestions from file
+ const suggestionsRaw = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8');
+
+ // Sanitize AI-generated content
+ let sanitized = suggestionsRaw
+ // Strip HTML tags
+ .replace(/<[^>]*>/g, '')
+ // Strip markdown links but keep display text
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ // Strip raw URLs
+ .replace(/https?:\/\/[^\s)>\]]+/g, '[link removed]')
+ // Strip protocol-relative URLs
+ .replace(/\/\/[^\s)>\]]+\.[^\s)>\]]+/g, '[link removed]')
+ // Neutralize @-mentions (preserve JSDoc-style annotations)
+ .replace(/@(?!param\b|returns?\b|throws?\b|typedef\b|type\b|see\b|example\b|since\b|deprecated\b|default\b)(\w+)/g, '`@$1`')
+ // Strip cross-repo references that could be confused with real links
+ .replace(/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+#\d+/g, '[ref removed]');
+
+ // Truncate to 20,000 characters
+ if (sanitized.length > 20000) {
+ sanitized = sanitized.substring(0, 20000) + '\n\n…(truncated)';
+ }
+
+ // Parse and validate PR number
+ const prNumber = parseInt(process.env.PR_NUMBER, 10);
+ if (isNaN(prNumber) || prNumber <= 0) {
+ core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`);
+ return;
+ }
+
const body = `## 📚 Documentation Suggestions
This cherry-pick contains changes that may need documentation updates.
- ${suggestions}
+ ${sanitized}
---
+ > **Note:** This comment was generated automatically by an AI model analyzing
+ > code changes. Suggestions may contain inaccuracies — please verify before acting.
+
About this comment
This comment was generated automatically by analyzing code changes in this cherry-pick.
- Cherry-picks typically don't need new documentation since the feature was already
+ Cherry-picks typically don't need new documentation since the feature was already
documented when merged to main, but please verify.
`;
-
+
// Find existing comment to update (avoid spam)
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: ${{ steps.pr.outputs.number }}
+ issue_number: prNumber
});
-
- const botComment = comments.find(c =>
- c.user.type === 'Bot' &&
+
+ const botComment = comments.find(c =>
+ c.user.type === 'Bot' &&
c.body.includes('Documentation Suggestions')
);
-
+
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
@@ -400,21 +466,22 @@ jobs:
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: ${{ steps.pr.outputs.number }},
+ issue_number: prNumber,
body: body
});
}
- name: Summary
if: always()
+ env:
+ HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }}
+ PR_NUM: ${{ steps.pr.outputs.number }}
run: |
{
echo "## 📚 Documentation Suggestions (Cherry-pick)"
echo ""
- if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then
- echo "Suggestions posted as PR comment."
- echo ""
- cat "${{ steps.analyze.outputs.suggestions_file }}"
+ if [ "$HAS_SUGGESTIONS" == "true" ]; then
+ echo "Suggestions posted as PR comment on #${PR_NUM}."
else
echo "No documentation suggestions for this cherry-pick."
fi
diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml
index afb5448691610f..b7bb78363ce4ff 100644
--- a/.github/workflows/extension_bump.yml
+++ b/.github/workflows/extension_bump.yml
@@ -38,10 +38,10 @@ jobs:
name: extension_bump::compare_versions
run: |
CURRENT_VERSION="$(sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]')"
- PR_PARENT_SHA="${{ github.event.pull_request.head.sha }}"
- if [[ -n "$PR_PARENT_SHA" ]]; then
- git checkout "$PR_PARENT_SHA"
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ PR_FORK_POINT="$(git merge-base origin/main HEAD)"
+ git checkout "$PR_FORK_POINT"
elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
git checkout "$BRANCH_PARENT_SHA"
else
@@ -64,7 +64,7 @@ jobs:
- check_version_changed
if: |-
(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') &&
- (inputs.force-bump == 'true' || needs.check_version_changed.outputs.version_changed == 'false')
+ (inputs.force-bump == true || needs.check_version_changed.outputs.version_changed == 'false')
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: generate-token
diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml
index b843b6626e5e99..ef0e28715ce038 100644
--- a/.github/workflows/extension_tests.yml
+++ b/.github/workflows/extension_tests.yml
@@ -87,7 +87,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
clean: false
- fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }}
+ fetch-depth: 0
- id: cache-zed-extension-cli
name: extension_tests::cache_zed_extension_cli
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
@@ -113,10 +113,10 @@ jobs:
name: extension_bump::compare_versions
run: |
CURRENT_VERSION="$(sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]')"
- PR_PARENT_SHA="${{ github.event.pull_request.head.sha }}"
- if [[ -n "$PR_PARENT_SHA" ]]; then
- git checkout "$PR_PARENT_SHA"
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ PR_FORK_POINT="$(git merge-base origin/main HEAD)"
+ git checkout "$PR_FORK_POINT"
elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
git checkout "$BRANCH_PARENT_SHA"
else
diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml
index 109f40c815dbf5..709956fc1bc0b2 100644
--- a/.github/workflows/extension_workflow_rollout.yml
+++ b/.github/workflows/extension_workflow_rollout.yml
@@ -127,7 +127,7 @@ jobs:
- id: short-sha
name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha
run: |
- echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
+ echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
working-directory: zed
- id: create-pr
name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request
diff --git a/Cargo.lock b/Cargo.lock
index a066b18eb8c7f0..1c658d79e3a7ba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -758,19 +758,16 @@ dependencies = [
[[package]]
name = "ashpd"
-version = "0.12.1"
+version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "618a409b91d5265798a99e3d1d0b226911605e581c4e7255e83c1e397b172bce"
+checksum = "0848bedd08067dca1c02c31cbb371a94ad4f2f8a61a82f2c43d96ec36a395244"
dependencies = [
- "async-fs",
- "async-net",
"enumflags2",
"futures-channel",
"futures-util",
- "rand 0.9.2",
+ "getrandom 0.4.1",
"serde",
"serde_repr",
- "url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -1007,7 +1004,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"blocking",
"futures-lite 2.6.1",
]
@@ -1021,7 +1018,7 @@ dependencies = [
"async-channel 2.5.0",
"async-executor",
"async-io",
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"blocking",
"futures-lite 2.6.1",
"once_cell",
@@ -1056,9 +1053,9 @@ dependencies = [
[[package]]
name = "async-lock"
-version = "3.4.1"
+version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener 5.4.1",
"event-listener-strategy",
@@ -1093,7 +1090,7 @@ checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel 2.5.0",
"async-io",
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"async-signal",
"async-task",
"blocking",
@@ -1121,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"atomic-waker",
"cfg-if",
"futures-core",
@@ -1142,7 +1139,7 @@ dependencies = [
"async-channel 1.9.0",
"async-global-executor",
"async-io",
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"async-process",
"crossbeam-utils",
"futures-channel",
@@ -2544,6 +2541,7 @@ dependencies = [
"settings",
"telemetry",
"util",
+ "workspace",
]
[[package]]
@@ -4337,7 +4335,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
- "rand_core 0.6.4",
"typenum",
]
@@ -5014,7 +5011,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5780,7 +5777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -7153,6 +7150,19 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
[[package]]
name = "gh-workflow"
version = "0.8.0"
@@ -7321,6 +7331,7 @@ dependencies = [
"ctor",
"db",
"editor",
+ "feature_flags",
"futures 0.3.31",
"fuzzy",
"git",
@@ -7626,6 +7637,7 @@ dependencies = [
"smol",
"strum 0.27.2",
"swash",
+ "url",
"util",
"uuid",
"wayland-backend",
@@ -10518,7 +10530,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -10782,7 +10794,6 @@ dependencies = [
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
- "memoffset",
]
[[package]]
@@ -10929,7 +10940,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -10968,6 +10979,22 @@ dependencies = [
"num-iter",
"num-traits",
"rand 0.8.5",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099"
+dependencies = [
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "once_cell",
+ "rand 0.9.2",
"serde",
"smallvec",
"zeroize",
@@ -11341,15 +11368,15 @@ checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "oo7"
-version = "0.5.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d"
+checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd"
dependencies = [
"aes",
"ashpd",
"async-fs",
"async-io",
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"blocking",
"cbc",
"cipher",
@@ -11357,15 +11384,15 @@ dependencies = [
"endi",
"futures-lite 2.6.1",
"futures-util",
- "getrandom 0.3.4",
+ "getrandom 0.4.1",
"hkdf",
"hmac",
"md-5",
"num",
- "num-bigint-dig",
+ "num-bigint-dig 0.9.1",
"pbkdf2 0.12.2",
- "rand 0.9.2",
"serde",
+ "serde_bytes",
"sha2",
"subtle",
"zbus",
@@ -12570,6 +12597,7 @@ version = "0.1.0"
dependencies = [
"feature_flags",
"gpui",
+ "project",
"settings",
"smallvec",
"theme",
@@ -13183,7 +13211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
dependencies = [
"bytes 1.11.1",
- "heck 0.5.0",
+ "heck 0.4.1",
"itertools 0.12.1",
"log",
"multimap 0.10.1",
@@ -14459,7 +14487,7 @@ checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
- "num-bigint-dig",
+ "num-bigint-dig 0.8.6",
"num-integer",
"num-traits",
"pkcs1",
@@ -14655,7 +14683,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys 0.11.0",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -15222,6 +15250,16 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -15832,7 +15870,7 @@ dependencies = [
"async-executor",
"async-fs",
"async-io",
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"async-net",
"async-process",
"blocking",
@@ -17096,7 +17134,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -18035,7 +18073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f"
dependencies = [
"cc",
- "windows-targets 0.52.6",
+ "windows-targets 0.48.5",
]
[[package]]
@@ -19061,6 +19099,15 @@ dependencies = [
"wit-bindgen 0.46.0",
]
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
[[package]]
name = "wasite"
version = "0.1.0"
@@ -19178,6 +19225,16 @@ dependencies = [
"wasmparser 0.229.0",
]
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser 0.244.0",
+]
+
[[package]]
name = "wasm-metadata"
version = "0.201.0"
@@ -19213,6 +19270,18 @@ dependencies = [
"wasmparser 0.227.1",
]
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder 0.244.0",
+ "wasmparser 0.244.0",
+]
+
[[package]]
name = "wasm-streams"
version = "0.4.2"
@@ -19275,6 +19344,18 @@ dependencies = [
"serde",
]
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.10.0",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
[[package]]
name = "wasmprinter"
version = "0.229.0"
@@ -20071,7 +20152,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
@@ -20879,6 +20960,15 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro 0.51.0",
+]
+
[[package]]
name = "wit-bindgen-core"
version = "0.22.0"
@@ -20900,6 +20990,17 @@ dependencies = [
"wit-parser 0.227.1",
]
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "wit-parser 0.244.0",
+]
+
[[package]]
name = "wit-bindgen-rt"
version = "0.22.0"
@@ -20947,6 +21048,22 @@ dependencies = [
"wit-component 0.227.1",
]
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "indexmap",
+ "prettyplease",
+ "syn 2.0.106",
+ "wasm-metadata 0.244.0",
+ "wit-bindgen-core 0.51.0",
+ "wit-component 0.244.0",
+]
+
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.22.0"
@@ -20976,6 +21093,21 @@ dependencies = [
"wit-bindgen-rust 0.41.0",
]
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.106",
+ "wit-bindgen-core 0.51.0",
+ "wit-bindgen-rust 0.51.0",
+]
+
[[package]]
name = "wit-component"
version = "0.201.0"
@@ -21014,6 +21146,25 @@ dependencies = [
"wit-parser 0.227.1",
]
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.10.0",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder 0.244.0",
+ "wasm-metadata 0.244.0",
+ "wasmparser 0.244.0",
+ "wit-parser 0.244.0",
+]
+
[[package]]
name = "wit-parser"
version = "0.201.0"
@@ -21068,6 +21219,24 @@ dependencies = [
"wasmparser 0.229.0",
]
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser 0.244.0",
+]
+
[[package]]
name = "witx"
version = "0.9.1"
@@ -21479,14 +21648,14 @@ dependencies = [
[[package]]
name = "zbus"
-version = "5.12.0"
+version = "5.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
+checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
- "async-lock 3.4.1",
+ "async-lock 3.4.2",
"async-process",
"async-recursion",
"async-task",
@@ -21497,8 +21666,9 @@ dependencies = [
"futures-core",
"futures-lite 2.6.1",
"hex",
- "nix 0.30.1",
+ "libc",
"ordered-stream",
+ "rustix 1.1.2",
"serde",
"serde_repr",
"tracing",
@@ -21513,9 +21683,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
-version = "5.12.0"
+version = "5.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
+checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1"
dependencies = [
"proc-macro-crate",
"proc-macro2",
@@ -21528,19 +21698,18 @@ dependencies = [
[[package]]
name = "zbus_names"
-version = "4.2.0"
+version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
+checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
- "static_assertions",
"winnow",
"zvariant",
]
[[package]]
name = "zed"
-version = "0.226.0"
+version = "0.227.0"
dependencies = [
"acp_thread",
"acp_tools",
@@ -22158,14 +22327,14 @@ dependencies = [
[[package]]
name = "zvariant"
-version = "5.8.0"
+version = "5.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c"
+checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4"
dependencies = [
"endi",
"enumflags2",
"serde",
- "url",
+ "serde_bytes",
"winnow",
"zvariant_derive",
"zvariant_utils",
@@ -22173,9 +22342,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
-version = "5.8.0"
+version = "5.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006"
+checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c"
dependencies = [
"proc-macro-crate",
"proc-macro2",
@@ -22186,9 +22355,9 @@ dependencies = [
[[package]]
name = "zvariant_utils"
-version = "3.2.1"
+version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
+checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
dependencies = [
"proc-macro2",
"quote",
diff --git a/Cargo.toml b/Cargo.toml
index 645ae42e211bdf..b1945b095a30a4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -481,8 +481,13 @@ alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev
any_vec = "0.14"
anyhow = "1.0.86"
arrayvec = { version = "0.7.4", features = ["serde"] }
-ashpd = { version = "0.12.1", default-features = false, features = [
- "async-std",
+ashpd = { version = "0.13", default-features = false, features = [
+ "async-io",
+ "notification",
+ "open_uri",
+ "file_chooser",
+ "settings",
+ "trash"
] }
async-compat = "0.2.1"
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
diff --git a/assets/icons/git_commit.svg b/assets/icons/git_commit.svg
new file mode 100644
index 00000000000000..38b36ec7efb722
--- /dev/null
+++ b/assets/icons/git_commit.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/git_graph.svg b/assets/icons/git_graph.svg
index 8f372a305d3fdd..7ae33e365d40bf 100644
--- a/assets/icons/git_graph.svg
+++ b/assets/icons/git_graph.svg
@@ -1,4 +1,7 @@
diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json
index 9832ce8fe08fe2..1f2742f982bc21 100644
--- a/assets/keymaps/vim.json
+++ b/assets/keymaps/vim.json
@@ -1110,4 +1110,12 @@
"shift-g": "menu::SelectLast",
},
},
+ {
+ "context": "NotebookEditor > Editor && VimControl && vim_mode == normal",
+
+ "bindings": {
+ "j": "notebook::NotebookMoveDown",
+ "k": "notebook::NotebookMoveUp",
+ },
+ },
]
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 8b9489600c269c..b3df7fda2dc2c5 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -800,6 +800,8 @@
// 3. Show files first, then directories:
// "files_first"
"sort_mode": "directories_first",
+ // Whether to show error and warning count badges next to file names in the project panel.
+ "diagnostic_badges": true,
// Whether to enable drag-and-drop operations in the project panel.
"drag_and_drop": true,
// Whether to hide the root entry when only one folder is open in the window;
@@ -914,6 +916,10 @@
// Default: inherits editor scrollbar settings
// "show": null
},
+ // Whether to show the addition/deletion change count next to each file in the Git panel.
+ //
+ // Default: false
+ "diff_stats": false,
},
"message_editor": {
// Whether to automatically replace emoji shortcodes with emoji characters.
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index aefe14ce7924ec..0a9374cddafa1f 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -2350,7 +2350,7 @@ impl AcpThread {
text_diff(old_text.as_str(), &content)
.into_iter()
.map(|(range, replacement)| {
- (snapshot.anchor_range_between(range), replacement)
+ (snapshot.anchor_range_around(range), replacement)
})
.collect::>()
})
diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs
index 14ec9bb9af92c2..fa4b37dba3e789 100644
--- a/crates/agent/src/db.rs
+++ b/crates/agent/src/db.rs
@@ -23,17 +23,6 @@ pub type DbMessage = crate::Message;
pub type DbSummary = crate::legacy_thread::DetailedSummaryState;
pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel;
-/// Metadata about the git worktree associated with an agent thread.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct AgentGitWorktreeInfo {
- /// The branch name in the git worktree.
- pub branch: String,
- /// Absolute path to the git worktree on disk.
- pub worktree_path: std::path::PathBuf,
- /// The base branch/commit the worktree was created from.
- pub base_ref: String,
-}
-
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbThreadMetadata {
pub id: acp::SessionId,
@@ -41,10 +30,6 @@ pub struct DbThreadMetadata {
#[serde(alias = "summary")]
pub title: SharedString,
pub updated_at: DateTime,
- /// Denormalized from `DbThread::git_worktree_info.branch` for efficient
- /// listing without decompressing thread data. The blob is the source of
- /// truth; this column is populated on save for query convenience.
- pub worktree_branch: Option,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -68,8 +53,6 @@ pub struct DbThread {
pub imported: bool,
#[serde(default)]
pub subagent_context: Option,
- #[serde(default)]
- pub git_worktree_info: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -108,7 +91,6 @@ impl SharedThread {
profile: None,
imported: true,
subagent_context: None,
- git_worktree_info: None,
}
}
@@ -283,7 +265,6 @@ impl DbThread {
profile: thread.profile,
imported: false,
subagent_context: None,
- git_worktree_info: None,
})
}
}
@@ -388,13 +369,6 @@ impl ThreadsDatabase {
s().ok();
}
- if let Ok(mut s) = connection.exec(indoc! {"
- ALTER TABLE threads ADD COLUMN worktree_branch TEXT
- "})
- {
- s().ok();
- }
-
let db = Self {
executor,
connection: Arc::new(Mutex::new(connection)),
@@ -423,10 +397,6 @@ impl ThreadsDatabase {
.subagent_context
.as_ref()
.map(|ctx| ctx.parent_thread_id.0.clone());
- let worktree_branch = thread
- .git_worktree_info
- .as_ref()
- .map(|info| info.branch.clone());
let json_data = serde_json::to_string(&SerializedThread {
thread,
version: DbThread::VERSION,
@@ -438,19 +408,11 @@ impl ThreadsDatabase {
let data_type = DataType::Zstd;
let data = compressed;
- let mut insert = connection.exec_bound::<(Arc, Option>, Option, String, String, DataType, Vec)>(indoc! {"
- INSERT OR REPLACE INTO threads (id, parent_id, worktree_branch, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)
+ let mut insert = connection.exec_bound::<(Arc, Option>, String, String, DataType, Vec)>(indoc! {"
+ INSERT OR REPLACE INTO threads (id, parent_id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?)
"})?;
- insert((
- id.0,
- parent_id,
- worktree_branch,
- title,
- updated_at,
- data_type,
- data,
- ))?;
+ insert((id.0, parent_id, title, updated_at, data_type, data))?;
Ok(())
}
@@ -462,20 +424,19 @@ impl ThreadsDatabase {
let connection = connection.lock();
let mut select = connection
- .select_bound::<(), (Arc, Option>, Option, String, String)>(indoc! {"
- SELECT id, parent_id, worktree_branch, summary, updated_at FROM threads ORDER BY updated_at DESC
+ .select_bound::<(), (Arc, Option>, String, String)>(indoc! {"
+ SELECT id, parent_id, summary, updated_at FROM threads ORDER BY updated_at DESC
"})?;
let rows = select(())?;
let mut threads = Vec::new();
- for (id, parent_id, worktree_branch, summary, updated_at) in rows {
+ for (id, parent_id, summary, updated_at) in rows {
threads.push(DbThreadMetadata {
id: acp::SessionId::new(id),
parent_session_id: parent_id.map(acp::SessionId::new),
title: summary.into(),
updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
- worktree_branch,
});
}
@@ -609,7 +570,6 @@ mod tests {
profile: None,
imported: false,
subagent_context: None,
- git_worktree_info: None,
}
}
@@ -753,94 +713,4 @@ mod tests {
"Regular threads should have no subagent_context"
);
}
-
- #[gpui::test]
- async fn test_git_worktree_info_roundtrip(cx: &mut TestAppContext) {
- let database = ThreadsDatabase::new(cx.executor()).unwrap();
-
- let thread_id = session_id("worktree-thread");
- let mut thread = make_thread(
- "Worktree Thread",
- Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
- );
- thread.git_worktree_info = Some(AgentGitWorktreeInfo {
- branch: "zed/agent/a4Xiu".to_string(),
- worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu"),
- base_ref: "main".to_string(),
- });
-
- database
- .save_thread(thread_id.clone(), thread)
- .await
- .unwrap();
-
- let loaded = database
- .load_thread(thread_id)
- .await
- .unwrap()
- .expect("thread should exist");
-
- let info = loaded
- .git_worktree_info
- .expect("git_worktree_info should be restored");
- assert_eq!(info.branch, "zed/agent/a4Xiu");
- assert_eq!(
- info.worktree_path,
- std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu")
- );
- assert_eq!(info.base_ref, "main");
- }
-
- #[gpui::test]
- async fn test_session_list_includes_worktree_meta(cx: &mut TestAppContext) {
- let database = ThreadsDatabase::new(cx.executor()).unwrap();
-
- // Save a thread with worktree info
- let worktree_id = session_id("wt-thread");
- let mut worktree_thread = make_thread(
- "With Worktree",
- Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
- );
- worktree_thread.git_worktree_info = Some(AgentGitWorktreeInfo {
- branch: "zed/agent/bR9kz".to_string(),
- worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/bR9kz"),
- base_ref: "develop".to_string(),
- });
-
- database
- .save_thread(worktree_id.clone(), worktree_thread)
- .await
- .unwrap();
-
- // Save a thread without worktree info
- let plain_id = session_id("plain-thread");
- let plain_thread = make_thread(
- "Without Worktree",
- Utc.with_ymd_and_hms(2024, 6, 15, 11, 0, 0).unwrap(),
- );
-
- database
- .save_thread(plain_id.clone(), plain_thread)
- .await
- .unwrap();
-
- // List threads and verify worktree_branch is populated correctly
- let threads = database.list_threads().await.unwrap();
- assert_eq!(threads.len(), 2);
-
- let wt_entry = threads
- .iter()
- .find(|t| t.id == worktree_id)
- .expect("should find worktree thread");
- assert_eq!(wt_entry.worktree_branch.as_deref(), Some("zed/agent/bR9kz"));
-
- let plain_entry = threads
- .iter()
- .find(|t| t.id == plain_id)
- .expect("should find plain thread");
- assert!(
- plain_entry.worktree_branch.is_none(),
- "plain thread should have no worktree_branch"
- );
- }
}
diff --git a/crates/agent/src/edit_agent.rs b/crates/agent/src/edit_agent.rs
index 3e67cba1b63f41..9f2f2f1877e206 100644
--- a/crates/agent/src/edit_agent.rs
+++ b/crates/agent/src/edit_agent.rs
@@ -166,56 +166,69 @@ impl EditAgent {
output_events_tx: mpsc::UnboundedSender,
cx: &mut AsyncApp,
) -> Result<()> {
- cx.update(|cx| {
- buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
- self.action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
+ let buffer_id = cx.update(|cx| {
+ let buffer_id = buffer.read(cx).remote_id();
self.project.update(cx, |project, cx| {
project.set_agent_location(
Some(AgentLocation {
buffer: buffer.downgrade(),
- position: language::Anchor::max_for_buffer(buffer.read(cx).remote_id()),
+ position: language::Anchor::min_for_buffer(buffer_id),
}),
cx,
)
});
+ buffer_id
+ });
+
+ let send_edit_event = || {
output_events_tx
.unbounded_send(EditAgentOutputEvent::Edited(
- Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()),
+ Anchor::min_max_range_for_buffer(buffer_id),
))
- .ok();
- });
-
+ .ok()
+ };
+ let set_agent_location = |cx: &mut _| {
+ self.project.update(cx, |project, cx| {
+ project.set_agent_location(
+ Some(AgentLocation {
+ buffer: buffer.downgrade(),
+ position: language::Anchor::max_for_buffer(buffer_id),
+ }),
+ cx,
+ )
+ })
+ };
+ let mut first_chunk = true;
while let Some(event) = parse_rx.next().await {
match event? {
CreateFileParserEvent::NewTextChunk { chunk } => {
- let buffer_id = cx.update(|cx| {
- buffer.update(cx, |buffer, cx| buffer.append(chunk, cx));
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| {
+ if mem::take(&mut first_chunk) {
+ buffer.set_text(chunk, cx)
+ } else {
+ buffer.append(chunk, cx)
+ }
+ });
self.action_log
.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
- self.project.update(cx, |project, cx| {
- project.set_agent_location(
- Some(AgentLocation {
- buffer: buffer.downgrade(),
- position: language::Anchor::max_for_buffer(
- buffer.read(cx).remote_id(),
- ),
- }),
- cx,
- )
- });
- buffer.read(cx).remote_id()
+ set_agent_location(cx);
});
- output_events_tx
- .unbounded_send(EditAgentOutputEvent::Edited(
- Anchor::min_max_range_for_buffer(buffer_id),
- ))
- .ok();
+ send_edit_event();
}
}
}
+ if first_chunk {
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
+ self.action_log
+ .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+ set_agent_location(cx);
+ });
+ send_edit_event();
+ }
+
Ok(())
}
@@ -1194,19 +1207,16 @@ mod tests {
);
cx.run_until_parked();
- assert_matches!(
- drain_events(&mut events).as_slice(),
- [EditAgentOutputEvent::Edited(_)]
- );
+ assert_eq!(drain_events(&mut events).as_slice(), []);
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
- ""
+ "abc\ndef\nghi"
);
assert_eq!(
project.read_with(cx, |project, _| project.agent_location()),
Some(AgentLocation {
buffer: buffer.downgrade(),
- position: language::Anchor::max_for_buffer(
+ position: language::Anchor::min_for_buffer(
cx.update(|cx| buffer.read(cx).remote_id())
),
})
@@ -1290,6 +1300,32 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_overwrite_no_content(cx: &mut TestAppContext) {
+ let agent = init_test(cx).await;
+ let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx));
+ let (chunks_tx, chunks_rx) = mpsc::unbounded::<&str>();
+ let (apply, mut events) = agent.overwrite_with_chunks(
+ buffer.clone(),
+ chunks_rx.map(|chunk| Ok(chunk.to_string())),
+ &mut cx.to_async(),
+ );
+
+ drop(chunks_tx);
+ cx.run_until_parked();
+
+ let result = apply.await;
+ assert!(result.is_ok(),);
+ assert_matches!(
+ drain_events(&mut events).as_slice(),
+ [EditAgentOutputEvent::Edited { .. }]
+ );
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
+ ""
+ );
+ }
+
#[gpui::test(iterations = 100)]
async fn test_indent_new_text_chunks(mut rng: StdRng) {
let chunks = to_random_chunks(&mut rng, " abc\n def\n ghi");
diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs
index 4d8bdaf698cb6b..cdd017f76a1840 100644
--- a/crates/agent/src/native_agent_server.rs
+++ b/crates/agent/src/native_agent_server.rs
@@ -1,4 +1,4 @@
-use std::{any::Any, path::Path, rc::Rc, sync::Arc};
+use std::{any::Any, rc::Rc, sync::Arc};
use agent_client_protocol as acp;
use agent_servers::{AgentServer, AgentServerDelegate};
@@ -35,7 +35,6 @@ impl AgentServer for NativeAgentServer {
fn connect(
&self,
- _root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<
@@ -44,10 +43,7 @@ impl AgentServer for NativeAgentServer {
Option,
)>,
> {
- log::debug!(
- "NativeAgentServer::connect called for path: {:?}",
- _root_dir
- );
+ log::debug!("NativeAgentServer::connect");
let project = delegate.project().clone();
let fs = self.fs.clone();
let thread_store = self.thread_store.clone();
diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs
index 139242fdee9da9..e8c95c630b6587 100644
--- a/crates/agent/src/tests/mod.rs
+++ b/crates/agent/src/tests/mod.rs
@@ -310,11 +310,11 @@ async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::TerminalToolInput {
+ ToolInput::resolved(crate::TerminalToolInput {
command: "sleep 1000".to_string(),
cd: ".".to_string(),
timeout_ms: Some(5),
- },
+ }),
event_stream,
cx,
)
@@ -377,11 +377,11 @@ async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAp
let _task = cx.update(|cx| {
tool.run(
- crate::TerminalToolInput {
+ ToolInput::resolved(crate::TerminalToolInput {
command: "sleep 1000".to_string(),
cd: ".".to_string(),
timeout_ms: None,
- },
+ }),
event_stream,
cx,
)
@@ -3991,11 +3991,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::TerminalToolInput {
+ ToolInput::resolved(crate::TerminalToolInput {
command: "rm -rf /".to_string(),
cd: ".".to_string(),
timeout_ms: None,
- },
+ }),
event_stream,
cx,
)
@@ -4043,11 +4043,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::TerminalToolInput {
+ ToolInput::resolved(crate::TerminalToolInput {
command: "echo hello".to_string(),
cd: ".".to_string(),
timeout_ms: None,
- },
+ }),
event_stream,
cx,
)
@@ -4101,11 +4101,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
let _task = cx.update(|cx| {
tool.run(
- crate::TerminalToolInput {
+ ToolInput::resolved(crate::TerminalToolInput {
command: "sudo rm file".to_string(),
cd: ".".to_string(),
timeout_ms: None,
- },
+ }),
event_stream,
cx,
)
@@ -4148,11 +4148,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::TerminalToolInput {
+ ToolInput::resolved(crate::TerminalToolInput {
command: "echo hello".to_string(),
cd: ".".to_string(),
timeout_ms: None,
- },
+ }),
event_stream,
cx,
)
@@ -5309,11 +5309,11 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::EditFileToolInput {
+ ToolInput::resolved(crate::EditFileToolInput {
display_description: "Edit sensitive file".to_string(),
path: "root/sensitive_config.txt".into(),
mode: crate::EditFileMode::Edit,
- },
+ }),
event_stream,
cx,
)
@@ -5359,9 +5359,9 @@ async fn test_delete_path_tool_deny_rule_blocks_deletion(cx: &mut TestAppContext
let task = cx.update(|cx| {
tool.run(
- crate::DeletePathToolInput {
+ ToolInput::resolved(crate::DeletePathToolInput {
path: "root/important_data.txt".to_string(),
- },
+ }),
event_stream,
cx,
)
@@ -5411,10 +5411,10 @@ async fn test_move_path_tool_denies_if_destination_denied(cx: &mut TestAppContex
let task = cx.update(|cx| {
tool.run(
- crate::MovePathToolInput {
+ ToolInput::resolved(crate::MovePathToolInput {
source_path: "root/safe.txt".to_string(),
destination_path: "root/protected/safe.txt".to_string(),
- },
+ }),
event_stream,
cx,
)
@@ -5467,10 +5467,10 @@ async fn test_move_path_tool_denies_if_source_denied(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::MovePathToolInput {
+ ToolInput::resolved(crate::MovePathToolInput {
source_path: "root/secret.txt".to_string(),
destination_path: "root/public/not_secret.txt".to_string(),
- },
+ }),
event_stream,
cx,
)
@@ -5525,10 +5525,10 @@ async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::CopyPathToolInput {
+ ToolInput::resolved(crate::CopyPathToolInput {
source_path: "root/confidential.txt".to_string(),
destination_path: "root/dest/copy.txt".to_string(),
- },
+ }),
event_stream,
cx,
)
@@ -5580,12 +5580,12 @@ async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext)
let task = cx.update(|cx| {
tool.run(
- crate::SaveFileToolInput {
+ ToolInput::resolved(crate::SaveFileToolInput {
paths: vec![
std::path::PathBuf::from("root/normal.txt"),
std::path::PathBuf::from("root/readonly/config.txt"),
],
- },
+ }),
event_stream,
cx,
)
@@ -5632,9 +5632,9 @@ async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) {
let task = cx.update(|cx| {
tool.run(
- crate::SaveFileToolInput {
+ ToolInput::resolved(crate::SaveFileToolInput {
paths: vec![std::path::PathBuf::from("root/config.secret")],
- },
+ }),
event_stream,
cx,
)
@@ -5676,7 +5676,7 @@ async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) {
let input: crate::WebSearchToolInput =
serde_json::from_value(json!({"query": "internal.company.com secrets"})).unwrap();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let result = task.await;
assert!(result.is_err(), "expected search to be blocked");
@@ -5741,11 +5741,11 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
let _task = cx.update(|cx| {
tool.run(
- crate::EditFileToolInput {
+ ToolInput::resolved(crate::EditFileToolInput {
display_description: "Edit README".to_string(),
path: "root/README.md".into(),
mode: crate::EditFileMode::Edit,
- },
+ }),
event_stream,
cx,
)
@@ -5811,11 +5811,11 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
let _task = cx.update(|cx| {
tool.run(
- crate::EditFileToolInput {
+ ToolInput::resolved(crate::EditFileToolInput {
display_description: "Edit local settings".to_string(),
path: "root/.zed/settings.json".into(),
mode: crate::EditFileMode::Edit,
- },
+ }),
event_stream,
cx,
)
@@ -5855,7 +5855,7 @@ async fn test_fetch_tool_deny_rule_blocks_url(cx: &mut TestAppContext) {
let input: crate::FetchToolInput =
serde_json::from_value(json!({"url": "https://internal.company.com/api"})).unwrap();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let result = task.await;
assert!(result.is_err(), "expected fetch to be blocked");
@@ -5893,7 +5893,7 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext)
let input: crate::FetchToolInput =
serde_json::from_value(json!({"url": "https://docs.rs/some-crate"})).unwrap();
- let _task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
cx.run_until_parked();
diff --git a/crates/agent/src/tests/test_tools.rs b/crates/agent/src/tests/test_tools.rs
index 0ed2eef9027153..e0794ee322cdf2 100644
--- a/crates/agent/src/tests/test_tools.rs
+++ b/crates/agent/src/tests/test_tools.rs
@@ -3,6 +3,7 @@ use agent_settings::AgentSettings;
use gpui::{App, SharedString, Task};
use std::future;
use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::Duration;
/// A tool that echoes its input
#[derive(JsonSchema, Serialize, Deserialize)]
@@ -33,11 +34,17 @@ impl AgentTool for EchoTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
_event_stream: ToolCallEventStream,
- _cx: &mut App,
+ cx: &mut App,
) -> Task> {
- Task::ready(Ok(input.text))
+ cx.spawn(async move |_cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ Ok(input.text)
+ })
}
}
@@ -74,7 +81,7 @@ impl AgentTool for DelayTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task>
@@ -83,6 +90,10 @@ impl AgentTool for DelayTool {
{
let executor = cx.background_executor().clone();
cx.foreground_executor().spawn(async move {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
executor.timer(Duration::from_millis(input.ms)).await;
Ok("Ding".to_string())
})
@@ -114,28 +125,38 @@ impl AgentTool for ToolRequiringPermission {
fn run(
self: Arc,
- _input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let settings = AgentSettings::get_global(cx);
- let decision = decide_permission_from_settings(Self::NAME, &[String::new()], settings);
-
- let authorize = match decision {
- ToolPermissionDecision::Allow => None,
- ToolPermissionDecision::Deny(reason) => {
- return Task::ready(Err(reason));
- }
- ToolPermissionDecision::Confirm => {
- let context = crate::ToolPermissionContext::new(
- "tool_requiring_permission",
- vec![String::new()],
- );
- Some(event_stream.authorize("Authorize?", context, cx))
- }
- };
+ cx.spawn(async move |cx| {
+ let _input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ let decision = cx.update(|cx| {
+ decide_permission_from_settings(
+ Self::NAME,
+ &[String::new()],
+ AgentSettings::get_global(cx),
+ )
+ });
+
+ let authorize = match decision {
+ ToolPermissionDecision::Allow => None,
+ ToolPermissionDecision::Deny(reason) => {
+ return Err(reason);
+ }
+ ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
+ let context = crate::ToolPermissionContext::new(
+ "tool_requiring_permission",
+ vec![String::new()],
+ );
+ event_stream.authorize("Authorize?", context, cx)
+ })),
+ };
- cx.foreground_executor().spawn(async move {
if let Some(authorize) = authorize {
authorize.await.map_err(|e| e.to_string())?;
}
@@ -169,11 +190,15 @@ impl AgentTool for InfiniteTool {
fn run(
self: Arc,
- _input: Self::Input,
+ input: ToolInput,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
cx.foreground_executor().spawn(async move {
+ let _input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
future::pending::<()>().await;
unreachable!()
})
@@ -221,11 +246,15 @@ impl AgentTool for CancellationAwareTool {
fn run(
self: Arc,
- _input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
cx.foreground_executor().spawn(async move {
+ let _input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
// Wait for cancellation - this tool does nothing but wait to be cancelled
event_stream.cancelled_by_user().await;
self.was_cancelled.store(true, Ordering::SeqCst);
@@ -276,10 +305,16 @@ impl AgentTool for WordListTool {
fn run(
self: Arc,
- _input: Self::Input,
+ input: ToolInput,
_event_stream: ToolCallEventStream,
- _cx: &mut App,
+ cx: &mut App,
) -> Task> {
- Task::ready(Ok("ok".to_string()))
+ cx.spawn(async move |_cx| {
+ let _input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ Ok("ok".to_string())
+ })
}
}
diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs
index e40ab834a8710c..923fbd11126f21 100644
--- a/crates/agent/src/thread.rs
+++ b/crates/agent/src/thread.rs
@@ -1,14 +1,16 @@
use crate::{
- AgentGitWorktreeInfo, ContextServerRegistry, CopyPathTool, CreateDirectoryTool,
- DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool,
- FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot,
- ReadFileTool, RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, StreamingEditFileTool,
+ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
+ DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
+ ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
+ RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, StreamingEditFileTool,
SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool,
decide_permission_from_settings,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
-use feature_flags::{FeatureFlagAppExt as _, SubagentsFeatureFlag};
+use feature_flags::{
+ FeatureFlagAppExt as _, StreamingEditFileToolFeatureFlag, SubagentsFeatureFlag,
+};
use agent_client_protocol as acp;
use agent_settings::{
@@ -43,11 +45,13 @@ use language_model::{
use project::Project;
use prompt_store::ProjectContext;
use schemars::{JsonSchema, Schema};
+use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings, ToolPermissionMode, update_settings_file};
use smol::stream::StreamExt;
use std::{
collections::BTreeMap,
+ marker::PhantomData,
ops::RangeInclusive,
path::Path,
rc::Rc,
@@ -900,8 +904,6 @@ pub struct Thread {
subagent_context: Option,
/// Weak references to running subagent threads for cancellation propagation
running_subagents: Vec>,
- /// Git worktree info if this thread is running in an agent worktree.
- git_worktree_info: Option,
}
impl Thread {
@@ -992,7 +994,6 @@ impl Thread {
imported: false,
subagent_context: None,
running_subagents: Vec::new(),
- git_worktree_info: None,
}
}
@@ -1217,7 +1218,6 @@ impl Thread {
imported: db_thread.imported,
subagent_context: db_thread.subagent_context,
running_subagents: Vec::new(),
- git_worktree_info: db_thread.git_worktree_info,
}
}
@@ -1238,7 +1238,6 @@ impl Thread {
profile: Some(self.profile_id.clone()),
imported: self.imported,
subagent_context: self.subagent_context.clone(),
- git_worktree_info: self.git_worktree_info.clone(),
};
cx.background_spawn(async move {
@@ -1358,7 +1357,6 @@ impl Thread {
self.project.clone(),
cx.weak_entity(),
language_registry,
- Templates::new(),
));
self.add_tool(FetchTool::new(self.project.read(cx).client().http_client()));
self.add_tool(FindPathTool::new(self.project.clone()));
@@ -1662,6 +1660,7 @@ impl Thread {
event_stream: event_stream.clone(),
tools: self.enabled_tools(profile, &model, cx),
cancellation_tx,
+ streaming_tool_inputs: HashMap::default(),
_task: cx.spawn(async move |this, cx| {
log::debug!("Starting agent turn execution");
@@ -2066,10 +2065,6 @@ impl Thread {
self.send_or_update_tool_use(&tool_use, title, kind, event_stream);
- if !tool_use.is_input_complete {
- return None;
- }
-
let Some(tool) = tool else {
let content = format!("No tool named {} exists", tool_use.name);
return Some(Task::ready(LanguageModelToolResult {
@@ -2081,9 +2076,72 @@ impl Thread {
}));
};
+ if !tool_use.is_input_complete {
+ if tool.supports_input_streaming() {
+ let running_turn = self.running_turn.as_mut()?;
+ if let Some(sender) = running_turn.streaming_tool_inputs.get(&tool_use.id) {
+ sender.send_partial(tool_use.input);
+ return None;
+ }
+
+ let (sender, tool_input) = ToolInputSender::channel();
+ sender.send_partial(tool_use.input);
+ running_turn
+ .streaming_tool_inputs
+ .insert(tool_use.id.clone(), sender);
+
+ let tool = tool.clone();
+ log::debug!("Running streaming tool {}", tool_use.name);
+ return Some(self.run_tool(
+ tool,
+ tool_input,
+ tool_use.id,
+ tool_use.name,
+ event_stream,
+ cancellation_rx,
+ cx,
+ ));
+ } else {
+ return None;
+ }
+ }
+
+ if let Some(sender) = self
+ .running_turn
+ .as_mut()?
+ .streaming_tool_inputs
+ .remove(&tool_use.id)
+ {
+ sender.send_final(tool_use.input);
+ return None;
+ }
+
+ log::debug!("Running tool {}", tool_use.name);
+ let tool_input = ToolInput::ready(tool_use.input);
+ Some(self.run_tool(
+ tool,
+ tool_input,
+ tool_use.id,
+ tool_use.name,
+ event_stream,
+ cancellation_rx,
+ cx,
+ ))
+ }
+
+ fn run_tool(
+ &self,
+ tool: Arc,
+ tool_input: ToolInput,
+ tool_use_id: LanguageModelToolUseId,
+ tool_name: Arc,
+ event_stream: &ThreadEventStream,
+ cancellation_rx: watch::Receiver,
+ cx: &mut Context,
+ ) -> Task {
let fs = self.project.read(cx).fs().clone();
let tool_event_stream = ToolCallEventStream::new(
- tool_use.id.clone(),
+ tool_use_id.clone(),
event_stream.clone(),
Some(fs),
cancellation_rx,
@@ -2092,9 +2150,8 @@ impl Thread {
acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress),
);
let supports_images = self.model().is_some_and(|model| model.supports_images());
- let tool_result = tool.run(tool_use.input, tool_event_stream, cx);
- log::debug!("Running tool {}", tool_use.name);
- Some(cx.foreground_executor().spawn(async move {
+ let tool_result = tool.run(tool_input, tool_event_stream, cx);
+ cx.foreground_executor().spawn(async move {
let (is_error, output) = match tool_result.await {
Ok(mut output) => {
if let LanguageModelToolResultContent::Image(_) = &output.llm_output
@@ -2112,13 +2169,13 @@ impl Thread {
};
LanguageModelToolResult {
- tool_use_id: tool_use.id,
- tool_name: tool_use.name,
+ tool_use_id,
+ tool_name,
is_error,
content: output.llm_output,
output: Some(output.raw_output),
}
- }))
+ })
}
fn handle_tool_use_json_parse_error_event(
@@ -2404,6 +2461,7 @@ impl Thread {
name: tool_name.to_string(),
description: tool.description().to_string(),
input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
+ use_input_streaming: tool.supports_input_streaming(),
})
})
.collect::>()
@@ -2457,7 +2515,7 @@ impl Thread {
}
}
- let use_streaming_edit_tool = false;
+ let use_streaming_edit_tool = cx.has_flag::();
let mut tools = self
.tools
@@ -2774,6 +2832,9 @@ struct RunningTurn {
/// Sender to signal tool cancellation. When cancel is called, this is
/// set to true so all tools can detect user-initiated cancellation.
cancellation_tx: watch::Sender,
+ /// Senders for tools that support input streaming and have already been
+ /// started but are still receiving input from the LLM.
+ streaming_tool_inputs: HashMap,
}
impl RunningTurn {
@@ -2793,6 +2854,103 @@ pub struct TitleUpdated;
impl EventEmitter for Thread {}
+/// A channel-based wrapper that delivers tool input to a running tool.
+///
+/// For non-streaming tools, created via `ToolInput::ready()` so `.recv()` resolves immediately.
+/// For streaming tools, partial JSON snapshots arrive via `.recv_partial()` as the LLM streams
+/// them, followed by the final complete input available through `.recv()`.
+pub struct ToolInput {
+ partial_rx: mpsc::UnboundedReceiver,
+ final_rx: oneshot::Receiver,
+ _phantom: PhantomData,
+}
+
+impl ToolInput {
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn resolved(input: impl Serialize) -> Self {
+ let value = serde_json::to_value(input).expect("failed to serialize tool input");
+ Self::ready(value)
+ }
+
+ pub fn ready(value: serde_json::Value) -> Self {
+ let (partial_tx, partial_rx) = mpsc::unbounded();
+ drop(partial_tx);
+ let (final_tx, final_rx) = oneshot::channel();
+ final_tx.send(value).ok();
+ Self {
+ partial_rx,
+ final_rx,
+ _phantom: PhantomData,
+ }
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn test() -> (ToolInputSender, Self) {
+ let (sender, input) = ToolInputSender::channel();
+ (sender, input.cast())
+ }
+
+ /// Wait for the final deserialized input, ignoring all partial updates.
+ /// Non-streaming tools can use this to wait until the whole input is available.
+ pub async fn recv(mut self) -> Result {
+ // Drain any remaining partials
+ while self.partial_rx.next().await.is_some() {}
+ let value = self
+ .final_rx
+ .await
+ .map_err(|_| anyhow!("tool input sender was dropped before sending final input"))?;
+ serde_json::from_value(value).map_err(Into::into)
+ }
+
+ /// Returns the next partial JSON snapshot, or `None` when input is complete.
+ /// Once this returns `None`, call `recv()` to get the final input.
+ pub async fn recv_partial(&mut self) -> Option {
+ self.partial_rx.next().await
+ }
+
+ fn cast(self) -> ToolInput {
+ ToolInput {
+ partial_rx: self.partial_rx,
+ final_rx: self.final_rx,
+ _phantom: PhantomData,
+ }
+ }
+}
+
+pub struct ToolInputSender {
+ partial_tx: mpsc::UnboundedSender,
+ final_tx: Option>,
+}
+
+impl ToolInputSender {
+ pub(crate) fn channel() -> (Self, ToolInput) {
+ let (partial_tx, partial_rx) = mpsc::unbounded();
+ let (final_tx, final_rx) = oneshot::channel();
+ let sender = Self {
+ partial_tx,
+ final_tx: Some(final_tx),
+ };
+ let input = ToolInput {
+ partial_rx,
+ final_rx,
+ _phantom: PhantomData,
+ };
+ (sender, input)
+ }
+
+ pub(crate) fn send_partial(&self, value: serde_json::Value) {
+ self.partial_tx.unbounded_send(value).ok();
+ }
+
+ pub(crate) fn send_final(mut self, value: serde_json::Value) {
+ // Close the partial channel so recv_partial() returns None
+ self.partial_tx.close_channel();
+ if let Some(final_tx) = self.final_tx.take() {
+ final_tx.send(value).ok();
+ }
+ }
+}
+
pub trait AgentTool
where
Self: 'static + Sized,
@@ -2826,6 +2984,11 @@ where
language_model::tool_schema::root_schema_for::(format)
}
+ /// Returns whether the tool supports streaming of tool use parameters.
+ fn supports_input_streaming() -> bool {
+ false
+ }
+
/// Some tools rely on a provider for the underlying billing or other reasons.
/// Allow the tool to check if they are compatible, or should be filtered out.
fn supports_provider(_provider: &LanguageModelProviderId) -> bool {
@@ -2841,7 +3004,7 @@ where
/// still signaling whether the invocation succeeded or failed.
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task>;
@@ -2886,13 +3049,16 @@ pub trait AnyAgentTool {
fn kind(&self) -> acp::ToolKind;
fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString;
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result;
+ fn supports_input_streaming(&self) -> bool {
+ false
+ }
fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool {
true
}
/// See [`AgentTool::run`] for why this returns `Result`.
fn run(
self: Arc,
- input: serde_json::Value,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task>;
@@ -2921,6 +3087,10 @@ where
T::kind()
}
+ fn supports_input_streaming(&self) -> bool {
+ T::supports_input_streaming()
+ }
+
fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString {
let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input);
self.0.initial_title(parsed_input, _cx)
@@ -2938,35 +3108,31 @@ where
fn run(
self: Arc,
- input: serde_json::Value,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- cx.spawn(async move |cx| {
- let input: T::Input = serde_json::from_value(input).map_err(|e| {
- AgentToolOutput::from_error(format!("Failed to parse tool input: {e}"))
- })?;
- let task = cx.update(|cx| self.0.clone().run(input, event_stream, cx));
- match task.await {
- Ok(output) => {
- let raw_output = serde_json::to_value(&output).map_err(|e| {
- AgentToolOutput::from_error(format!("Failed to serialize tool output: {e}"))
- })?;
- Ok(AgentToolOutput {
- llm_output: output.into(),
- raw_output,
- })
- }
- Err(error_output) => {
- let raw_output = serde_json::to_value(&error_output).unwrap_or_else(|e| {
- log::error!("Failed to serialize tool error output: {e}");
- serde_json::Value::Null
- });
- Err(AgentToolOutput {
- llm_output: error_output.into(),
- raw_output,
- })
- }
+ let tool_input: ToolInput = input.cast();
+ let task = self.0.clone().run(tool_input, event_stream, cx);
+ cx.spawn(async move |_cx| match task.await {
+ Ok(output) => {
+ let raw_output = serde_json::to_value(&output).map_err(|e| {
+ AgentToolOutput::from_error(format!("Failed to serialize tool output: {e}"))
+ })?;
+ Ok(AgentToolOutput {
+ llm_output: output.into(),
+ raw_output,
+ })
+ }
+ Err(error_output) => {
+ let raw_output = serde_json::to_value(&error_output).unwrap_or_else(|e| {
+ log::error!("Failed to serialize tool error output: {e}");
+ serde_json::Value::Null
+ });
+ Err(AgentToolOutput {
+ llm_output: error_output.into(),
+ raw_output,
+ })
}
})
}
diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs
index 6add31fdb39302..d5526b0953cb43 100644
--- a/crates/agent/src/thread_store.rs
+++ b/crates/agent/src/thread_store.rs
@@ -2,40 +2,11 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase};
use agent_client_protocol as acp;
use anyhow::{Result, anyhow};
use gpui::{App, Context, Entity, Global, Task, prelude::*};
-use project::Project;
-use std::rc::Rc;
struct GlobalThreadStore(Entity);
impl Global for GlobalThreadStore {}
-// TODO: Remove once ACP thread loading is fully handled elsewhere.
-pub fn load_agent_thread(
- session_id: acp::SessionId,
- thread_store: Entity,
- project: Entity,
- cx: &mut App,
-) -> Task>> {
- use agent_servers::{AgentServer, AgentServerDelegate};
-
- let server = Rc::new(crate::NativeAgentServer::new(
- project.read(cx).fs().clone(),
- thread_store,
- ));
- let delegate = AgentServerDelegate::new(
- project.read(cx).agent_server_store().clone(),
- project.clone(),
- None,
- None,
- );
- let connection = server.connect(None, delegate, cx);
- cx.spawn(async move |cx| {
- let (agent, _) = connection.await?;
- let agent = agent.downcast::().unwrap();
- cx.update(|cx| agent.load_thread(session_id, cx)).await
- })
-}
-
pub struct ThreadStore {
threads: Vec,
}
@@ -162,7 +133,6 @@ mod tests {
profile: None,
imported: false,
subagent_context: None,
- git_worktree_info: None,
}
}
diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs
index 1962f237045c47..b2724801befc74 100644
--- a/crates/agent/src/tools.rs
+++ b/crates/agent/src/tools.rs
@@ -100,6 +100,7 @@ macro_rules! tools {
name: T::NAME.to_string(),
description: T::description().to_string(),
input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(),
+ use_input_streaming: T::supports_input_streaming(),
}
}
[
diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs
index 694e28750cd69f..1c7590d8097a5d 100644
--- a/crates/agent/src/tools/context_server_registry.rs
+++ b/crates/agent/src/tools/context_server_registry.rs
@@ -1,4 +1,4 @@
-use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream};
+use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol::ToolKind;
use anyhow::Result;
use collections::{BTreeMap, HashMap};
@@ -329,7 +329,7 @@ impl AnyAgentTool for ContextServerTool {
fn run(
self: Arc,
- input: serde_json::Value,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
@@ -339,14 +339,15 @@ impl AnyAgentTool for ContextServerTool {
let tool_name = self.tool.name.clone();
let tool_id = mcp_tool_id(&self.server_id.0, &self.tool.name);
let display_name = self.tool.name.clone();
- let authorize = event_stream.authorize_third_party_tool(
- self.initial_title(input.clone(), cx),
- tool_id,
- display_name,
- cx,
- );
+ let initial_title = self.initial_title(serde_json::Value::Null, cx);
+ let authorize =
+ event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx);
cx.spawn(async move |_cx| {
+ let input = input.recv().await.map_err(|e| {
+ AgentToolOutput::from_error(format!("Failed to receive tool input: {e}"))
+ })?;
+
authorize.await.map_err(|e| AgentToolOutput::from_error(e.to_string()))?;
let Some(protocol) = server.client() else {
diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs
index c82d9e930e1987..7f53a5c36a7979 100644
--- a/crates/agent/src/tools/copy_path_tool.rs
+++ b/crates/agent/src/tools/copy_path_tool.rs
@@ -2,7 +2,9 @@ use super::tool_permissions::{
SensitiveSettingsKind, authorize_symlink_escapes, canonicalize_worktree_roots,
collect_symlink_escapes, sensitive_settings_kind,
};
-use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_paths};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_paths,
+};
use agent_client_protocol::ToolKind;
use agent_settings::AgentSettings;
use futures::FutureExt as _;
@@ -79,19 +81,24 @@ impl AgentTool for CopyPathTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let settings = AgentSettings::get_global(cx);
- let paths = vec![input.source_path.clone(), input.destination_path.clone()];
- let decision = decide_permission_for_paths(Self::NAME, &paths, settings);
- if let ToolPermissionDecision::Deny(reason) = decision {
- return Task::ready(Err(reason));
- }
-
let project = self.project.clone();
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ let paths = vec![input.source_path.clone(), input.destination_path.clone()];
+ let decision = cx.update(|cx| {
+ decide_permission_for_paths(Self::NAME, &paths, &AgentSettings::get_global(cx))
+ });
+ if let ToolPermissionDecision::Deny(reason) = decision {
+ return Err(reason);
+ }
+
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -248,7 +255,7 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
@@ -302,7 +309,7 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let auth = event_rx.expect_authorization().await;
drop(auth);
@@ -354,7 +361,7 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
@@ -430,7 +437,9 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let result = cx.update(|cx| tool.run(input, event_stream, cx)).await;
+ let result = cx
+ .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx))
+ .await;
assert!(result.is_err(), "Tool should fail when policy denies");
assert!(
diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs
index 500b5f00289db2..5d8930f3c74004 100644
--- a/crates/agent/src/tools/create_directory_tool.rs
+++ b/crates/agent/src/tools/create_directory_tool.rs
@@ -13,7 +13,9 @@ use settings::Settings;
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
-use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path,
+};
use std::path::Path;
/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.
@@ -68,21 +70,26 @@ impl AgentTool for CreateDirectoryTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let settings = AgentSettings::get_global(cx);
- let decision = decide_permission_for_path(Self::NAME, &input.path, settings);
+ let project = self.project.clone();
+ cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ let decision = cx.update(|cx| {
+ decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx))
+ });
- if let ToolPermissionDecision::Deny(reason) = decision {
- return Task::ready(Err(reason));
- }
+ if let ToolPermissionDecision::Deny(reason) = decision {
+ return Err(reason);
+ }
- let destination_path: Arc = input.path.as_str().into();
+ let destination_path: Arc = input.path.as_str().into();
- let project = self.project.clone();
- cx.spawn(async move |cx| {
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -218,9 +225,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
- CreateDirectoryToolInput {
+ ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -277,9 +284,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
- CreateDirectoryToolInput {
+ ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -336,9 +343,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
- CreateDirectoryToolInput {
+ ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -415,9 +422,9 @@ mod tests {
let result = cx
.update(|cx| {
tool.run(
- CreateDirectoryToolInput {
+ ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs
index 048f4bd8292077..27ab68db667a4c 100644
--- a/crates/agent/src/tools/delete_path_tool.rs
+++ b/crates/agent/src/tools/delete_path_tool.rs
@@ -2,7 +2,9 @@ use super::tool_permissions::{
SensitiveSettingsKind, authorize_symlink_access, canonicalize_worktree_roots,
detect_symlink_escape, sensitive_settings_kind,
};
-use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path,
+};
use action_log::ActionLog;
use agent_client_protocol::ToolKind;
use agent_settings::AgentSettings;
@@ -71,22 +73,27 @@ impl AgentTool for DeletePathTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let path = input.path;
-
- let settings = AgentSettings::get_global(cx);
- let decision = decide_permission_for_path(Self::NAME, &path, settings);
-
- if let ToolPermissionDecision::Deny(reason) = decision {
- return Task::ready(Err(reason));
- }
-
let project = self.project.clone();
let action_log = self.action_log.clone();
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ let path = input.path;
+
+ let decision = cx.update(|cx| {
+ decide_permission_for_path(Self::NAME, &path, AgentSettings::get_global(cx))
+ });
+
+ if let ToolPermissionDecision::Deny(reason) = decision {
+ return Err(reason);
+ }
+
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -278,9 +285,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
- DeletePathToolInput {
+ ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -345,9 +352,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
- DeletePathToolInput {
+ ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -405,9 +412,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
- DeletePathToolInput {
+ ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -488,9 +495,9 @@ mod tests {
let result = cx
.update(|cx| {
tool.run(
- DeletePathToolInput {
+ ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
diff --git a/crates/agent/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs
index fea16d531ed5f4..5889f66c2edbe0 100644
--- a/crates/agent/src/tools/diagnostics_tool.rs
+++ b/crates/agent/src/tools/diagnostics_tool.rs
@@ -1,4 +1,4 @@
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol as acp;
use anyhow::Result;
use futures::FutureExt as _;
@@ -87,21 +87,27 @@ impl AgentTool for DiagnosticsTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- match input.path {
- Some(path) if !path.is_empty() => {
- let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else {
- return Task::ready(Err(format!("Could not find path {path} in project")));
- };
-
- let open_buffer_task = self
- .project
- .update(cx, |project, cx| project.open_buffer(project_path, cx));
+ let project = self.project.clone();
+ cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ match input.path {
+ Some(path) if !path.is_empty() => {
+ let (_project_path, open_buffer_task) = project.update(cx, |project, cx| {
+ let Some(project_path) = project.find_project_path(&path, cx) else {
+ return Err(format!("Could not find path {path} in project"));
+ };
+ let task = project.open_buffer(project_path.clone(), cx);
+ Ok((project_path, task))
+ })?;
- cx.spawn(async move |cx| {
let buffer = futures::select! {
result = open_buffer_task.fuse() => result.map_err(|e| e.to_string())?,
_ = event_stream.cancelled_by_user().fuse() => {
@@ -135,36 +141,40 @@ impl AgentTool for DiagnosticsTool {
} else {
Ok(output)
}
- })
- }
- _ => {
- let project = self.project.read(cx);
- let mut output = String::new();
- let mut has_diagnostics = false;
-
- for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
- if summary.error_count > 0 || summary.warning_count > 0 {
- let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx)
- else {
- continue;
- };
-
- has_diagnostics = true;
- output.push_str(&format!(
- "{}: {} error(s), {} warning(s)\n",
- worktree.read(cx).absolutize(&project_path.path).display(),
- summary.error_count,
- summary.warning_count
- ));
- }
}
+ _ => {
+ let (output, has_diagnostics) = project.read_with(cx, |project, cx| {
+ let mut output = String::new();
+ let mut has_diagnostics = false;
+
+ for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
+ if summary.error_count > 0 || summary.warning_count > 0 {
+ let Some(worktree) =
+ project.worktree_for_id(project_path.worktree_id, cx)
+ else {
+ continue;
+ };
+
+ has_diagnostics = true;
+ output.push_str(&format!(
+ "{}: {} error(s), {} warning(s)\n",
+ worktree.read(cx).absolutize(&project_path.path).display(),
+ summary.error_count,
+ summary.warning_count
+ ));
+ }
+ }
+
+ (output, has_diagnostics)
+ });
- if has_diagnostics {
- Task::ready(Ok(output))
- } else {
- Task::ready(Ok("No errors or warnings found in the project.".into()))
+ if has_diagnostics {
+ Ok(output)
+ } else {
+ Ok("No errors or warnings found in the project.".into())
+ }
}
}
- }
+ })
}
}
diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs
index 788bf06529a6f0..3e1e0661f126d4 100644
--- a/crates/agent/src/tools/edit_file_tool.rs
+++ b/crates/agent/src/tools/edit_file_tool.rs
@@ -2,7 +2,7 @@ use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
use super::save_file_tool::SaveFileTool;
use super::tool_permissions::authorize_file_edit;
use crate::{
- AgentTool, Templates, Thread, ToolCallEventStream,
+ AgentTool, Templates, Thread, ToolCallEventStream, ToolInput,
edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
};
use acp_thread::Diff;
@@ -237,39 +237,44 @@ impl AgentTool for EditFileTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let Ok(project) = self
- .thread
- .read_with(cx, |thread, _cx| thread.project().clone())
- else {
- return Task::ready(Err(EditFileToolOutput::Error {
- error: "thread was dropped".to_string(),
- }));
- };
- let project_path = match resolve_path(&input, project.clone(), cx) {
- Ok(path) => path,
- Err(err) => {
- return Task::ready(Err(EditFileToolOutput::Error {
- error: err.to_string(),
- }));
- }
- };
- let abs_path = project.read(cx).absolute_path(&project_path, cx);
- if let Some(abs_path) = abs_path.clone() {
- event_stream.update_fields(
- ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),
- );
- }
- let allow_thinking = self
- .thread
- .read_with(cx, |thread, _cx| thread.thinking_enabled())
- .unwrap_or(true);
-
- let authorize = self.authorize(&input, &event_stream, cx);
cx.spawn(async move |cx: &mut AsyncApp| {
+ let input = input.recv().await.map_err(|e| EditFileToolOutput::Error {
+ error: format!("Failed to receive tool input: {e}"),
+ })?;
+
+ let project = self
+ .thread
+ .read_with(cx, |thread, _cx| thread.project().clone())
+ .map_err(|_| EditFileToolOutput::Error {
+ error: "thread was dropped".to_string(),
+ })?;
+
+ let (project_path, abs_path, allow_thinking, authorize) =
+ cx.update(|cx| {
+ let project_path = resolve_path(&input, project.clone(), cx).map_err(|err| {
+ EditFileToolOutput::Error {
+ error: err.to_string(),
+ }
+ })?;
+ let abs_path = project.read(cx).absolute_path(&project_path, cx);
+ if let Some(abs_path) = abs_path.clone() {
+ event_stream.update_fields(
+ ToolCallUpdateFields::new()
+ .locations(vec![acp::ToolCallLocation::new(abs_path)]),
+ );
+ }
+ let allow_thinking = self
+ .thread
+ .read_with(cx, |thread, _cx| thread.thinking_enabled())
+ .unwrap_or(true);
+ let authorize = self.authorize(&input, &event_stream, cx);
+ Ok::<_, EditFileToolOutput>((project_path, abs_path, allow_thinking, authorize))
+ })?;
+
let result: anyhow::Result = async {
authorize.await?;
@@ -672,7 +677,11 @@ mod tests {
language_registry,
Templates::new(),
))
- .run(input, ToolCallEventStream::test().0, cx)
+ .run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert_eq!(
@@ -881,7 +890,11 @@ mod tests {
language_registry.clone(),
Templates::new(),
))
- .run(input, ToolCallEventStream::test().0, cx)
+ .run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
});
// Stream the unformatted content
@@ -940,7 +953,11 @@ mod tests {
language_registry,
Templates::new(),
))
- .run(input, ToolCallEventStream::test().0, cx)
+ .run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
});
// Stream the unformatted content
@@ -1027,7 +1044,11 @@ mod tests {
language_registry.clone(),
Templates::new(),
))
- .run(input, ToolCallEventStream::test().0, cx)
+ .run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
});
// Stream the content with trailing whitespace
@@ -1082,7 +1103,11 @@ mod tests {
language_registry,
Templates::new(),
))
- .run(input, ToolCallEventStream::test().0, cx)
+ .run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
});
// Stream the content with trailing whitespace
@@ -2081,11 +2106,11 @@ mod tests {
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let edit = cx.update(|cx| {
tool.run(
- EditFileToolInput {
+ ToolInput::resolved(EditFileToolInput {
display_description: "Edit file".into(),
path: path!("/main.rs").into(),
mode: EditFileMode::Edit,
- },
+ }),
stream_tx,
cx,
)
@@ -2111,11 +2136,11 @@ mod tests {
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let edit = cx.update(|cx| {
tool.run(
- EditFileToolInput {
+ ToolInput::resolved(EditFileToolInput {
display_description: "Edit file".into(),
path: path!("/main.rs").into(),
mode: EditFileMode::Edit,
- },
+ }),
stream_tx,
cx,
)
@@ -2139,11 +2164,11 @@ mod tests {
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let edit = cx.update(|cx| {
tool.run(
- EditFileToolInput {
+ ToolInput::resolved(EditFileToolInput {
display_description: "Edit file".into(),
path: path!("/main.rs").into(),
mode: EditFileMode::Edit,
- },
+ }),
stream_tx,
cx,
)
@@ -2199,11 +2224,11 @@ mod tests {
// Read the file to record the read time
cx.update(|cx| {
read_tool.clone().run(
- crate::ReadFileToolInput {
+ ToolInput::resolved(crate::ReadFileToolInput {
path: "root/test.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2227,11 +2252,11 @@ mod tests {
// Read the file again - should update the entry
cx.update(|cx| {
read_tool.clone().run(
- crate::ReadFileToolInput {
+ ToolInput::resolved(crate::ReadFileToolInput {
path: "root/test.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2298,11 +2323,11 @@ mod tests {
// Read the file first
cx.update(|cx| {
read_tool.clone().run(
- crate::ReadFileToolInput {
+ ToolInput::resolved(crate::ReadFileToolInput {
path: "root/test.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2314,11 +2339,11 @@ mod tests {
let edit_result = {
let edit_task = cx.update(|cx| {
edit_tool.clone().run(
- EditFileToolInput {
+ ToolInput::resolved(EditFileToolInput {
display_description: "First edit".into(),
path: "root/test.txt".into(),
mode: EditFileMode::Edit,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2343,11 +2368,11 @@ mod tests {
let edit_result = {
let edit_task = cx.update(|cx| {
edit_tool.clone().run(
- EditFileToolInput {
+ ToolInput::resolved(EditFileToolInput {
display_description: "Second edit".into(),
path: "root/test.txt".into(),
mode: EditFileMode::Edit,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2412,11 +2437,11 @@ mod tests {
// Read the file first
cx.update(|cx| {
read_tool.clone().run(
- crate::ReadFileToolInput {
+ ToolInput::resolved(crate::ReadFileToolInput {
path: "root/test.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2456,11 +2481,11 @@ mod tests {
let result = cx
.update(|cx| {
edit_tool.clone().run(
- EditFileToolInput {
+ ToolInput::resolved(EditFileToolInput {
display_description: "Edit after external change".into(),
path: "root/test.txt".into(),
mode: EditFileMode::Edit,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2523,11 +2548,11 @@ mod tests {
// Read the file first
cx.update(|cx| {
read_tool.clone().run(
- crate::ReadFileToolInput {
+ ToolInput::resolved(crate::ReadFileToolInput {
path: "root/test.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -2560,11 +2585,11 @@ mod tests {
let result = cx
.update(|cx| {
edit_tool.clone().run(
- EditFileToolInput {
+ ToolInput::resolved(EditFileToolInput {
display_description: "Edit with dirty buffer".into(),
path: "root/test.txt".into(),
mode: EditFileMode::Edit,
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs
index e573c2202b09d1..75880801595ad0 100644
--- a/crates/agent/src/tools/fetch_tool.rs
+++ b/crates/agent/src/tools/fetch_tool.rs
@@ -16,7 +16,8 @@ use ui::SharedString;
use util::markdown::{MarkdownEscaped, MarkdownInlineCode};
use crate::{
- AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
+ decide_permission_from_settings,
};
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
@@ -141,41 +142,52 @@ impl AgentTool for FetchTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let settings = AgentSettings::get_global(cx);
- let decision =
- decide_permission_from_settings(Self::NAME, std::slice::from_ref(&input.url), settings);
-
- let authorize = match decision {
- ToolPermissionDecision::Allow => None,
- ToolPermissionDecision::Deny(reason) => {
- return Task::ready(Err(reason));
- }
- ToolPermissionDecision::Confirm => {
- let context =
- crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]);
- Some(event_stream.authorize(
- format!("Fetch {}", MarkdownInlineCode(&input.url)),
- context,
- cx,
- ))
- }
- };
+ let http_client = self.http_client.clone();
+ cx.spawn(async move |cx| {
+ let input: FetchToolInput = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ let decision = cx.update(|cx| {
+ decide_permission_from_settings(
+ Self::NAME,
+ std::slice::from_ref(&input.url),
+ AgentSettings::get_global(cx),
+ )
+ });
+
+ let authorize = match decision {
+ ToolPermissionDecision::Allow => None,
+ ToolPermissionDecision::Deny(reason) => {
+ return Err(reason);
+ }
+ ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
+ let context =
+ crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]);
+ event_stream.authorize(
+ format!("Fetch {}", MarkdownInlineCode(&input.url)),
+ context,
+ cx,
+ )
+ })),
+ };
- let fetch_task = cx.background_spawn({
- let http_client = self.http_client.clone();
- async move {
- if let Some(authorize) = authorize {
- authorize.await?;
+ let fetch_task = cx.background_spawn({
+ let http_client = http_client.clone();
+ let url = input.url.clone();
+ async move {
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
+ Self::build_message(http_client, &url).await
}
- Self::build_message(http_client, &input.url).await
- }
- });
+ });
- cx.foreground_executor().spawn(async move {
let text = futures::select! {
result = fetch_task.fuse() => result.map_err(|e| e.to_string())?,
_ = event_stream.cancelled_by_user().fuse() => {
diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs
index 4ba60c61063c08..9c654615032251 100644
--- a/crates/agent/src/tools/find_path_tool.rs
+++ b/crates/agent/src/tools/find_path_tool.rs
@@ -1,4 +1,4 @@
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol as acp;
use anyhow::{Result, anyhow};
use futures::FutureExt as _;
@@ -121,13 +121,18 @@ impl AgentTool for FindPathTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let search_paths_task = search_paths(&input.glob, self.project.clone(), cx);
+ let project = self.project.clone();
+ cx.spawn(async move |cx| {
+ let input = input.recv().await.map_err(|e| FindPathToolOutput::Error {
+ error: format!("Failed to receive tool input: {e}"),
+ })?;
+
+ let search_paths_task = cx.update(|cx| search_paths(&input.glob, project, cx));
- cx.background_spawn(async move {
let matches = futures::select! {
result = search_paths_task.fuse() => result.map_err(|e| FindPathToolOutput::Error { error: e.to_string() })?,
_ = event_stream.cancelled_by_user().fuse() => {
diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs
index 16162107dff84a..fbfdc18585b822 100644
--- a/crates/agent/src/tools/grep_tool.rs
+++ b/crates/agent/src/tools/grep_tool.rs
@@ -1,4 +1,4 @@
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol as acp;
use anyhow::Result;
use futures::{FutureExt as _, StreamExt};
@@ -114,66 +114,64 @@ impl AgentTool for GrepTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
const CONTEXT_LINES: u32 = 2;
const MAX_ANCESTOR_LINES: u32 = 10;
- let path_style = self.project.read(cx).path_style(cx);
-
- let include_matcher = match PathMatcher::new(
- input
- .include_pattern
- .as_ref()
- .into_iter()
- .collect::>(),
- path_style,
- ) {
- Ok(matcher) => matcher,
- Err(error) => {
- return Task::ready(Err(format!("invalid include glob pattern: {error}")));
- }
- };
-
- // Exclude global file_scan_exclusions and private_files settings
- let exclude_matcher = {
- let global_settings = WorktreeSettings::get_global(cx);
- let exclude_patterns = global_settings
- .file_scan_exclusions
- .sources()
- .chain(global_settings.private_files.sources());
-
- match PathMatcher::new(exclude_patterns, path_style) {
- Ok(matcher) => matcher,
- Err(error) => {
- return Task::ready(Err(format!("invalid exclude pattern: {error}")));
- }
- }
- };
-
- let query = match SearchQuery::regex(
- &input.regex,
- false,
- input.case_sensitive,
- false,
- false,
- include_matcher,
- exclude_matcher,
- true, // Always match file include pattern against *full project paths* that start with a project root.
- None,
- ) {
- Ok(query) => query,
- Err(error) => return Task::ready(Err(error.to_string())),
- };
-
- let results = self
- .project
- .update(cx, |project, cx| project.search(query, cx));
-
- let project = self.project.downgrade();
+ let project = self.project.clone();
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ let results = cx.update(|cx| {
+ let path_style = project.read(cx).path_style(cx);
+
+ let include_matcher = PathMatcher::new(
+ input
+ .include_pattern
+ .as_ref()
+ .into_iter()
+ .collect::>(),
+ path_style,
+ )
+ .map_err(|error| format!("invalid include glob pattern: {error}"))?;
+
+ // Exclude global file_scan_exclusions and private_files settings
+ let exclude_matcher = {
+ let global_settings = WorktreeSettings::get_global(cx);
+ let exclude_patterns = global_settings
+ .file_scan_exclusions
+ .sources()
+ .chain(global_settings.private_files.sources());
+
+ PathMatcher::new(exclude_patterns, path_style)
+ .map_err(|error| format!("invalid exclude pattern: {error}"))?
+ };
+
+ let query = SearchQuery::regex(
+ &input.regex,
+ false,
+ input.case_sensitive,
+ false,
+ false,
+ include_matcher,
+ exclude_matcher,
+ true, // Always match file include pattern against *full project paths* that start with a project root.
+ None,
+ )
+ .map_err(|error| error.to_string())?;
+
+ Ok::<_, String>(
+ project.update(cx, |project, cx| project.search(query, cx)),
+ )
+ })?;
+
+ let project = project.downgrade();
// Keep the search alive for the duration of result iteration. Dropping this task is the
// cancellation mechanism; we intentionally do not detach it.
let SearchResults {rx, _task_handle} = results;
@@ -787,7 +785,13 @@ mod tests {
cx: &mut TestAppContext,
) -> String {
let tool = Arc::new(GrepTool { project });
- let task = cx.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx));
+ let task = cx.update(|cx| {
+ tool.run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ });
match task.await {
Ok(result) => {
diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs
index 5dddee94904283..1a674aaa71fef5 100644
--- a/crates/agent/src/tools/list_directory_tool.rs
+++ b/crates/agent/src/tools/list_directory_tool.rs
@@ -2,7 +2,7 @@ use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
resolve_project_path,
};
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol::ToolKind;
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, Entity, SharedString, Task};
@@ -146,34 +146,39 @@ impl AgentTool for ListDirectoryTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- // Sometimes models will return these even though we tell it to give a path and not a glob.
- // When this happens, just list the root worktree directories.
- if matches!(input.path.as_str(), "." | "" | "./" | "*") {
- let output = self
- .project
- .read(cx)
- .worktrees(cx)
- .filter_map(|worktree| {
- let worktree = worktree.read(cx);
- let root_entry = worktree.root_entry()?;
- if root_entry.is_dir() {
- Some(root_entry.path.display(worktree.path_style()))
- } else {
- None
- }
- })
- .collect::>()
- .join("\n");
-
- return Task::ready(Ok(output));
- }
-
let project = self.project.clone();
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ // Sometimes models will return these even though we tell it to give a path and not a glob.
+ // When this happens, just list the root worktree directories.
+ if matches!(input.path.as_str(), "." | "" | "./" | "*") {
+ let output = project.read_with(cx, |project, cx| {
+ project
+ .worktrees(cx)
+ .filter_map(|worktree| {
+ let worktree = worktree.read(cx);
+ let root_entry = worktree.root_entry()?;
+ if root_entry.is_dir() {
+ Some(root_entry.path.display(worktree.path_style()))
+ } else {
+ None
+ }
+ })
+ .collect::>()
+ .join("\n")
+ });
+
+ return Ok(output);
+ }
+
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -323,7 +328,13 @@ mod tests {
path: "project".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert_eq!(
@@ -344,7 +355,13 @@ mod tests {
path: "project/src".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert_eq!(
@@ -365,7 +382,13 @@ mod tests {
path: "project/tests".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert!(!output.contains("# Folders:"));
@@ -393,7 +416,13 @@ mod tests {
path: "project/empty_dir".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert_eq!(output, "project/empty_dir is empty.\n");
@@ -420,7 +449,13 @@ mod tests {
path: "project/nonexistent".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await;
assert!(output.unwrap_err().contains("Path not found"));
@@ -429,7 +464,13 @@ mod tests {
path: "project/file.txt".into(),
};
let output = cx
- .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await;
assert!(output.unwrap_err().contains("is not a directory"));
}
@@ -493,7 +534,13 @@ mod tests {
path: "project".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
@@ -520,7 +567,13 @@ mod tests {
path: "project/.secretdir".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await;
assert!(
output.unwrap_err().contains("file_scan_exclusions"),
@@ -532,7 +585,13 @@ mod tests {
path: "project/visible_dir".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
@@ -637,7 +696,13 @@ mod tests {
path: "worktree1/src".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert!(output.contains("main.rs"), "Should list main.rs");
@@ -655,7 +720,13 @@ mod tests {
path: "worktree1/tests".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert!(output.contains("test.rs"), "Should list test.rs");
@@ -669,7 +740,13 @@ mod tests {
path: "worktree2/lib".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert!(output.contains("public.js"), "Should list public.js");
@@ -687,7 +764,13 @@ mod tests {
path: "worktree2/docs".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await
.unwrap();
assert!(output.contains("README.md"), "Should list README.md");
@@ -701,7 +784,13 @@ mod tests {
path: "worktree1/src/secret.rs".into(),
};
let output = cx
- .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .update(|cx| {
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
+ })
.await;
assert!(output.unwrap_err().contains("Cannot list directory"),);
}
@@ -743,9 +832,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- ListDirectoryToolInput {
+ ToolInput::resolved(ListDirectoryToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -804,9 +893,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- ListDirectoryToolInput {
+ ToolInput::resolved(ListDirectoryToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -871,9 +960,9 @@ mod tests {
let result = cx
.update(|cx| {
tool.clone().run(
- ListDirectoryToolInput {
+ ToolInput::resolved(ListDirectoryToolInput {
path: "project/link_to_external".into(),
- },
+ }),
event_stream,
cx,
)
@@ -924,9 +1013,9 @@ mod tests {
let result = cx
.update(|cx| {
tool.clone().run(
- ListDirectoryToolInput {
+ ToolInput::resolved(ListDirectoryToolInput {
path: "project/src".into(),
- },
+ }),
event_stream,
cx,
)
@@ -981,9 +1070,9 @@ mod tests {
let result = cx
.update(|cx| {
tool.clone().run(
- ListDirectoryToolInput {
+ ToolInput::resolved(ListDirectoryToolInput {
path: "project/link_dir".into(),
- },
+ }),
event_stream,
cx,
)
diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs
index 4c337d0ec2827a..c246b3c5b06615 100644
--- a/crates/agent/src/tools/move_path_tool.rs
+++ b/crates/agent/src/tools/move_path_tool.rs
@@ -2,7 +2,9 @@ use super::tool_permissions::{
SensitiveSettingsKind, authorize_symlink_escapes, canonicalize_worktree_roots,
collect_symlink_escapes, sensitive_settings_kind,
};
-use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_paths};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_paths,
+};
use agent_client_protocol::ToolKind;
use agent_settings::AgentSettings;
use futures::FutureExt as _;
@@ -92,19 +94,24 @@ impl AgentTool for MovePathTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let settings = AgentSettings::get_global(cx);
- let paths = vec![input.source_path.clone(), input.destination_path.clone()];
- let decision = decide_permission_for_paths(Self::NAME, &paths, settings);
- if let ToolPermissionDecision::Deny(reason) = decision {
- return Task::ready(Err(reason));
- }
-
let project = self.project.clone();
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ let paths = vec![input.source_path.clone(), input.destination_path.clone()];
+ let decision = cx.update(|cx| {
+ decide_permission_for_paths(Self::NAME, &paths, AgentSettings::get_global(cx))
+ });
+ if let ToolPermissionDecision::Deny(reason) = decision {
+ return Err(reason);
+ }
+
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -255,7 +262,7 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
@@ -309,7 +316,7 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let auth = event_rx.expect_authorization().await;
drop(auth);
@@ -361,7 +368,7 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let task = cx.update(|cx| tool.run(input, event_stream, cx));
+ let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
@@ -437,7 +444,9 @@ mod tests {
};
let (event_stream, mut event_rx) = ToolCallEventStream::test();
- let result = cx.update(|cx| tool.run(input, event_stream, cx)).await;
+ let result = cx
+ .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx))
+ .await;
assert!(result.is_err(), "Tool should fail when policy denies");
assert!(
diff --git a/crates/agent/src/tools/now_tool.rs b/crates/agent/src/tools/now_tool.rs
index 689d70ff20d15c..fe1cafe5881d14 100644
--- a/crates/agent/src/tools/now_tool.rs
+++ b/crates/agent/src/tools/now_tool.rs
@@ -6,7 +6,7 @@ use gpui::{App, SharedString, Task};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{AgentTool, ToolCallEventStream, ToolInput};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
@@ -48,14 +48,20 @@ impl AgentTool for NowTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
_event_stream: ToolCallEventStream,
- _cx: &mut App,
+ cx: &mut App,
) -> Task> {
- let now = match input.timezone {
- Timezone::Utc => Utc::now().to_rfc3339(),
- Timezone::Local => Local::now().to_rfc3339(),
- };
- Task::ready(Ok(format!("The current datetime is {now}.")))
+ cx.spawn(async move |_cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+ let now = match input.timezone {
+ Timezone::Utc => Utc::now().to_rfc3339(),
+ Timezone::Local => Local::now().to_rfc3339(),
+ };
+ Ok(format!("The current datetime is {now}."))
+ })
}
}
diff --git a/crates/agent/src/tools/open_tool.rs b/crates/agent/src/tools/open_tool.rs
index c0b24efbec6418..344a513d10c2d6 100644
--- a/crates/agent/src/tools/open_tool.rs
+++ b/crates/agent/src/tools/open_tool.rs
@@ -2,7 +2,7 @@ use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
resolve_project_path,
};
-use crate::AgentTool;
+use crate::{AgentTool, ToolInput};
use agent_client_protocol::ToolKind;
use futures::FutureExt as _;
use gpui::{App, AppContext as _, Entity, SharedString, Task};
@@ -61,16 +61,24 @@ impl AgentTool for OpenTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: crate::ToolCallEventStream,
cx: &mut App,
) -> Task> {
- // If path_or_url turns out to be a path in the project, make it absolute.
- let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
- let initial_title = self.initial_title(Ok(input.clone()), cx);
-
let project = self.project.clone();
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ // If path_or_url turns out to be a path in the project, make it absolute.
+ let (abs_path, initial_title) = cx.update(|cx| {
+ let abs_path = to_absolute_path(&input.path_or_url, project.clone(), cx);
+ let initial_title = self.initial_title(Ok(input.clone()), cx);
+ (abs_path, initial_title)
+ });
+
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs
index efd33fe5caece4..bbc67cf68c7d10 100644
--- a/crates/agent/src/tools/read_file_tool.rs
+++ b/crates/agent/src/tools/read_file_tool.rs
@@ -21,7 +21,7 @@ use super::tool_permissions::{
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
resolve_project_path,
};
-use crate::{AgentTool, Thread, ToolCallEventStream, outline};
+use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, outline};
/// Reads the content of the given file in the project.
///
@@ -114,7 +114,7 @@ impl AgentTool for ReadFileTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
@@ -122,6 +122,10 @@ impl AgentTool for ReadFileTool {
let thread = self.thread.clone();
let action_log = self.action_log.clone();
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(tool_content_err)?;
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -398,7 +402,7 @@ mod test {
start_line: None,
end_line: None,
};
- tool.run(input, event_stream, cx)
+ tool.run(ToolInput::resolved(input), event_stream, cx)
})
.await;
assert_eq!(
@@ -442,7 +446,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.run(input, ToolCallEventStream::test().0, cx)
+ tool.run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert_eq!(result.unwrap(), "This is a small file content".into());
@@ -485,7 +493,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await
.unwrap();
@@ -510,7 +522,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.run(input, ToolCallEventStream::test().0, cx)
+ tool.run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await
.unwrap();
@@ -570,7 +586,11 @@ mod test {
start_line: Some(2),
end_line: Some(4),
};
- tool.run(input, ToolCallEventStream::test().0, cx)
+ tool.run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
@@ -613,7 +633,11 @@ mod test {
start_line: Some(0),
end_line: Some(2),
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
@@ -626,7 +650,11 @@ mod test {
start_line: Some(1),
end_line: Some(0),
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert_eq!(result.unwrap(), "Line 1\n".into());
@@ -639,7 +667,11 @@ mod test {
start_line: Some(3),
end_line: Some(2),
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert_eq!(result.unwrap(), "Line 3\n".into());
@@ -744,7 +776,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -760,7 +796,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -776,7 +816,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -791,7 +835,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -807,7 +855,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -822,7 +874,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -837,7 +893,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -853,7 +913,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(result.is_ok(), "Should be able to read normal files");
@@ -867,7 +931,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.run(input, ToolCallEventStream::test().0, cx)
+ tool.run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
assert!(
@@ -911,11 +979,11 @@ mod test {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let read_task = cx.update(|cx| {
tool.run(
- ReadFileToolInput {
+ ToolInput::resolved(ReadFileToolInput {
path: "root/secret.png".to_string(),
start_line: None,
end_line: None,
- },
+ }),
event_stream,
cx,
)
@@ -1039,7 +1107,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await
.unwrap();
@@ -1057,7 +1129,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
@@ -1075,7 +1151,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
@@ -1093,7 +1173,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await
.unwrap();
@@ -1111,7 +1195,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
@@ -1129,7 +1217,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
@@ -1148,7 +1240,11 @@ mod test {
start_line: None,
end_line: None,
};
- tool.clone().run(input, ToolCallEventStream::test().0, cx)
+ tool.clone().run(
+ ToolInput::resolved(input),
+ ToolCallEventStream::test().0,
+ cx,
+ )
})
.await;
@@ -1210,11 +1306,11 @@ mod test {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- ReadFileToolInput {
+ ToolInput::resolved(ReadFileToolInput {
path: "project/secret_link.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
event_stream,
cx,
)
@@ -1286,11 +1382,11 @@ mod test {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- ReadFileToolInput {
+ ToolInput::resolved(ReadFileToolInput {
path: "project/secret_link.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
event_stream,
cx,
)
@@ -1367,11 +1463,11 @@ mod test {
let result = cx
.update(|cx| {
tool.clone().run(
- ReadFileToolInput {
+ ToolInput::resolved(ReadFileToolInput {
path: "project/secret_link.txt".to_string(),
start_line: None,
end_line: None,
- },
+ }),
event_stream,
cx,
)
diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs
index 304e0d1180fe62..c1aa8690a840ea 100644
--- a/crates/agent/src/tools/restore_file_from_disk_tool.rs
+++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs
@@ -17,7 +17,9 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
-use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path,
+};
/// Discards unsaved changes in open buffers by reloading file contents from disk.
///
@@ -66,25 +68,31 @@ impl AgentTool for RestoreFileFromDiskTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let settings = AgentSettings::get_global(cx).clone();
-
- // Check for any immediate deny before spawning async work.
- for path in &input.paths {
- let path_str = path.to_string_lossy();
- let decision = decide_permission_for_path(Self::NAME, &path_str, &settings);
- if let ToolPermissionDecision::Deny(reason) = decision {
- return Task::ready(Err(reason));
- }
- }
-
let project = self.project.clone();
- let input_paths = input.paths;
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ // Check for any immediate deny before doing async work.
+ for path in &input.paths {
+ let path_str = path.to_string_lossy();
+ let decision = cx.update(|cx| {
+ decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
+ });
+ if let ToolPermissionDecision::Deny(reason) = decision {
+ return Err(reason);
+ }
+ }
+
+ let input_paths = input.paths;
+
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -92,7 +100,9 @@ impl AgentTool for RestoreFileFromDiskTool {
for path in &input_paths {
let path_str = path.to_string_lossy();
- let decision = decide_permission_for_path(Self::NAME, &path_str, &settings);
+ let decision = cx.update(|cx| {
+ decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
+ });
let symlink_escape = project.read_with(cx, |project, cx| {
path_has_symlink_escape(project, path, &canonical_roots, cx)
});
@@ -378,12 +388,12 @@ mod tests {
let output = cx
.update(|cx| {
tool.clone().run(
- RestoreFileFromDiskToolInput {
+ ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -428,7 +438,7 @@ mod tests {
let output = cx
.update(|cx| {
tool.clone().run(
- RestoreFileFromDiskToolInput { paths: vec![] },
+ ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![] }),
ToolCallEventStream::test().0,
cx,
)
@@ -441,9 +451,9 @@ mod tests {
let output = cx
.update(|cx| {
tool.clone().run(
- RestoreFileFromDiskToolInput {
+ ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -495,9 +505,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- RestoreFileFromDiskToolInput {
+ ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
- },
+ }),
event_stream,
cx,
)
@@ -564,9 +574,9 @@ mod tests {
let result = cx
.update(|cx| {
tool.clone().run(
- RestoreFileFromDiskToolInput {
+ ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
- },
+ }),
event_stream,
cx,
)
@@ -623,9 +633,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- RestoreFileFromDiskToolInput {
+ ToolInput::resolved(RestoreFileFromDiskToolInput {
paths: vec![PathBuf::from("project/link.txt")],
- },
+ }),
event_stream,
cx,
)
diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs
index 20140c77d113d9..99e937b9dff2a1 100644
--- a/crates/agent/src/tools/save_file_tool.rs
+++ b/crates/agent/src/tools/save_file_tool.rs
@@ -17,7 +17,9 @@ use super::tool_permissions::{
canonicalize_worktree_roots, path_has_symlink_escape, resolve_project_path,
sensitive_settings_kind,
};
-use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path,
+};
/// Saves files that have unsaved changes.
///
@@ -63,25 +65,31 @@ impl AgentTool for SaveFileTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let settings = AgentSettings::get_global(cx).clone();
-
- // Check for any immediate deny before spawning async work.
- for path in &input.paths {
- let path_str = path.to_string_lossy();
- let decision = decide_permission_for_path(Self::NAME, &path_str, &settings);
- if let ToolPermissionDecision::Deny(reason) = decision {
- return Task::ready(Err(reason));
- }
- }
-
let project = self.project.clone();
- let input_paths = input.paths;
cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+
+ // Check for any immediate deny before doing async work.
+ for path in &input.paths {
+ let path_str = path.to_string_lossy();
+ let decision = cx.update(|cx| {
+ decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
+ });
+ if let ToolPermissionDecision::Deny(reason) = decision {
+ return Err(reason);
+ }
+ }
+
+ let input_paths = input.paths;
+
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
@@ -89,7 +97,9 @@ impl AgentTool for SaveFileTool {
for path in &input_paths {
let path_str = path.to_string_lossy();
- let decision = decide_permission_for_path(Self::NAME, &path_str, &settings);
+ let decision = cx.update(|cx| {
+ decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx))
+ });
let symlink_escape = project.read_with(cx, |project, cx| {
path_has_symlink_escape(project, path, &canonical_roots, cx)
});
@@ -382,12 +392,12 @@ mod tests {
let output = cx
.update(|cx| {
tool.clone().run(
- SaveFileToolInput {
+ ToolInput::resolved(SaveFileToolInput {
paths: vec![
PathBuf::from("root/dirty.txt"),
PathBuf::from("root/clean.txt"),
],
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -425,7 +435,7 @@ mod tests {
let output = cx
.update(|cx| {
tool.clone().run(
- SaveFileToolInput { paths: vec![] },
+ ToolInput::resolved(SaveFileToolInput { paths: vec![] }),
ToolCallEventStream::test().0,
cx,
)
@@ -438,9 +448,9 @@ mod tests {
let output = cx
.update(|cx| {
tool.clone().run(
- SaveFileToolInput {
+ ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("nonexistent/path.txt")],
- },
+ }),
ToolCallEventStream::test().0,
cx,
)
@@ -490,9 +500,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- SaveFileToolInput {
+ ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
- },
+ }),
event_stream,
cx,
)
@@ -559,9 +569,9 @@ mod tests {
let result = cx
.update(|cx| {
tool.clone().run(
- SaveFileToolInput {
+ ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
- },
+ }),
event_stream,
cx,
)
@@ -618,9 +628,9 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- SaveFileToolInput {
+ ToolInput::resolved(SaveFileToolInput {
paths: vec![PathBuf::from("project/link.txt")],
- },
+ }),
event_stream,
cx,
)
@@ -702,12 +712,12 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.clone().run(
- SaveFileToolInput {
+ ToolInput::resolved(SaveFileToolInput {
paths: vec![
PathBuf::from("project/dirty.txt"),
PathBuf::from("project/link.txt"),
],
- },
+ }),
event_stream,
cx,
)
diff --git a/crates/agent/src/tools/spawn_agent_tool.rs b/crates/agent/src/tools/spawn_agent_tool.rs
index e2dd78d4476de4..e454377ce1a561 100644
--- a/crates/agent/src/tools/spawn_agent_tool.rs
+++ b/crates/agent/src/tools/spawn_agent_tool.rs
@@ -8,31 +8,28 @@ use serde::{Deserialize, Serialize};
use std::rc::Rc;
use std::sync::Arc;
-use crate::{AgentTool, Thread, ThreadEnvironment, ToolCallEventStream};
+use crate::{AgentTool, Thread, ThreadEnvironment, ToolCallEventStream, ToolInput};
/// Spawns an agent to perform a delegated task.
///
-/// Use this tool when you want to do any of the following:
-/// - Run multiple tasks in parallel that would take significantly longer to run sequentially.
-/// - Complete a self-contained task where you need to know if it succeeded or failed (and how), but none of its intermediate output.
-/// - Perform an investigation where all you need to know is the outcome, not the research that led to that outcome.
-///
-/// You control what the agent does by providing a prompt describing what the agent should do. The agent has access to the same tools you do, but does NOT see your conversation history or any context the user attached. You must include all relevant context (file paths, requirements, constraints) in the prompt.
+/// Use this tool when you want to:
+/// - Run multiple tasks in parallel.
+/// - Delegate a self-contained task where you only need the final outcome.
///
/// You will receive only the agent's final message as output.
///
-/// If a response (success or error) includes a session_id, you can send a follow-up message to that session by passing the session_id back. This is useful for multi-turn conversations with an agent, asking clarifying questions about its output, or retrying after timeouts or transient failures.
+/// **New session** (no session_id): Creates a new agent that does NOT see your conversation history. Include all relevant context (file paths, requirements, constraints) in the message.
+///
+/// **Follow-up** (with session_id): Sends a follow-up to an existing agent session. The agent already has full context, so send only a short, direct message — do NOT repeat the original task or context. Examples: "Also update the tests", "Fix the compile error in foo.rs", "Retry".
///
-/// Note:
-/// - Agents cannot use tools you don't have access to.
/// - If spawning multiple agents that might write to the filesystem, provide guidance on how to avoid conflicts (e.g. assign each to different directories).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SpawnAgentToolInput {
/// Short label displayed in the UI while the agent runs (e.g., "Researching alternatives")
pub label: String,
- /// Describe the task for the agent to perform. Be specific about what you want accomplished. Include all necessary context (file paths, requirements, constraints) since the agent cannot see your conversation.
+ /// The prompt for the agent. For new sessions, include full context needed for the task. For follow-ups (with session_id), you can rely on the agent already having the previous message.
pub message: String,
- /// Optional session ID of an existing agent session to continue a conversation with. When provided, the message is sent as a follow-up to that session instead of creating a new one. Use this to ask clarifying questions, request changes based on previous output, or retry after errors.
+ /// Session ID of an existing agent session to continue instead of creating a new one.
#[serde(default)]
pub session_id: Option,
}
@@ -97,61 +94,78 @@ impl AgentTool for SpawnAgentTool {
fn run(
self: Arc,
- input: Self::Input,
+ input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let Some(parent_thread_entity) = self.parent_thread.upgrade() else {
- return Task::ready(Err(SpawnAgentToolOutput::Error {
- session_id: None,
- error: "Parent thread no longer exists".to_string(),
- }));
- };
-
- let subagent = if let Some(session_id) = input.session_id {
- self.environment
- .resume_subagent(parent_thread_entity, session_id, input.message, cx)
- } else {
- self.environment
- .create_subagent(parent_thread_entity, input.label, input.message, cx)
- };
- let subagent = match subagent {
- Ok(subagent) => subagent,
- Err(err) => {
- return Task::ready(Err(SpawnAgentToolOutput::Error {
+ cx.spawn(async move |cx| {
+ let input = input
+ .recv()
+ .await
+ .map_err(|e| SpawnAgentToolOutput::Error {
+ session_id: None,
+ error: format!("Failed to receive tool input: {e}"),
+ })?;
+
+ let (subagent, subagent_session_id) = cx.update(|cx| {
+ let Some(parent_thread_entity) = self.parent_thread.upgrade() else {
+ return Err(SpawnAgentToolOutput::Error {
+ session_id: None,
+ error: "Parent thread no longer exists".to_string(),
+ });
+ };
+
+ let subagent = if let Some(session_id) = input.session_id {
+ self.environment.resume_subagent(
+ parent_thread_entity,
+ session_id,
+ input.message,
+ cx,
+ )
+ } else {
+ self.environment.create_subagent(
+ parent_thread_entity,
+ input.label,
+ input.message,
+ cx,
+ )
+ };
+ let subagent = subagent.map_err(|err| SpawnAgentToolOutput::Error {
session_id: None,
error: err.to_string(),
- }));
- }
- };
- let subagent_session_id = subagent.id();
-
- event_stream.subagent_spawned(subagent_session_id.clone());
- let meta = acp::Meta::from_iter([(
- SUBAGENT_SESSION_ID_META_KEY.into(),
- subagent_session_id.to_string().into(),
- )]);
- event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta));
-
- cx.spawn(async move |cx| match subagent.wait_for_output(cx).await {
- Ok(output) => {
- event_stream.update_fields(
- acp::ToolCallUpdateFields::new().content(vec![output.clone().into()]),
- );
- Ok(SpawnAgentToolOutput::Success {
- session_id: subagent_session_id,
- output,
- })
- }
- Err(e) => {
- let error = e.to_string();
- event_stream.update_fields(
- acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]),
- );
- Err(SpawnAgentToolOutput::Error {
- session_id: Some(subagent_session_id),
- error,
- })
+ })?;
+ let subagent_session_id = subagent.id();
+
+ event_stream.subagent_spawned(subagent_session_id.clone());
+ let meta = acp::Meta::from_iter([(
+ SUBAGENT_SESSION_ID_META_KEY.into(),
+ subagent_session_id.to_string().into(),
+ )]);
+ event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta));
+
+ Ok((subagent, subagent_session_id))
+ })?;
+
+ match subagent.wait_for_output(cx).await {
+ Ok(output) => {
+ event_stream.update_fields(
+ acp::ToolCallUpdateFields::new().content(vec![output.clone().into()]),
+ );
+ Ok(SpawnAgentToolOutput::Success {
+ session_id: subagent_session_id,
+ output,
+ })
+ }
+ Err(e) => {
+ let error = e.to_string();
+ event_stream.update_fields(
+ acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]),
+ );
+ Err(SpawnAgentToolOutput::Error {
+ session_id: Some(subagent_session_id),
+ error,
+ })
+ }
}
})
}
diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs
index dd5445142a001f..933fa2ff1e996a 100644
--- a/crates/agent/src/tools/streaming_edit_file_tool.rs
+++ b/crates/agent/src/tools/streaming_edit_file_tool.rs
@@ -2,7 +2,7 @@ use super::edit_file_tool::EditFileTool;
use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;
use super::save_file_tool::SaveFileTool;
use crate::{
- AgentTool, Templates, Thread, ToolCallEventStream,
+ AgentTool, Thread, ToolCallEventStream, ToolInput,
edit_agent::streaming_fuzzy_matcher::StreamingFuzzyMatcher,
};
use acp_thread::Diff;
@@ -11,8 +11,8 @@ use anyhow::{Context as _, Result, anyhow};
use collections::HashSet;
use futures::FutureExt as _;
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
-use language::LanguageRegistry;
use language::language_settings::{self, FormatOnSave};
+use language::{Buffer, LanguageRegistry};
use language_model::LanguageModelToolResultContent;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use project::{Project, ProjectPath};
@@ -23,8 +23,8 @@ use std::path::PathBuf;
use std::sync::Arc;
use text::BufferSnapshot;
use ui::SharedString;
-use util::ResultExt;
use util::rel_path::RelPath;
+use util::{Deferred, ResultExt, debug_panic};
const DEFAULT_UI_TEXT: &str = "Editing file";
@@ -67,7 +67,7 @@ pub struct StreamingEditFileToolInput {
///
/// `frontend/db.js`
///
- pub path: PathBuf,
+ pub path: String,
/// The mode of operation on the file. Possible values:
/// - 'create': Create a new file if it doesn't exist. Requires 'content' field.
@@ -109,12 +109,488 @@ pub struct EditOperation {
pub new_text: String,
}
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[derive(Default, Debug, Deserialize)]
struct StreamingEditFileToolPartialInput {
#[serde(default)]
- path: String,
+ display_description: Option,
+ #[serde(default)]
+ path: Option,
+ #[serde(default)]
+ mode: Option,
+ #[serde(default)]
+ #[allow(dead_code)]
+ content: Option,
+ #[serde(default)]
+ edits: Option>,
+}
+
+#[derive(Default, Debug, Deserialize)]
+struct PartialEditOperation {
+ #[serde(default)]
+ old_text: Option,
#[serde(default)]
- display_description: String,
+ new_text: Option,
+}
+
+enum StreamingEditState {
+ Idle,
+ BufferResolved {
+ abs_path: PathBuf,
+ buffer: Entity,
+ old_text: Arc,
+ diff: Entity,
+ edit_state: IncrementalEditState,
+ _finalize_diff_guard: Deferred>,
+ },
+}
+
+#[derive(Default)]
+struct IncrementalEditState {
+ applied_count: usize,
+ in_progress_matcher: Option,
+ last_old_text_len: usize,
+}
+
+impl StreamingEditState {
+ async fn finalize(
+ &mut self,
+ input: StreamingEditFileToolInput,
+ tool: &StreamingEditFileTool,
+ event_stream: &ToolCallEventStream,
+ cx: &mut AsyncApp,
+ ) -> Result {
+ let remaining_edits_start_ix = match self {
+ StreamingEditState::Idle => {
+ *self = Self::transition_to_buffer_resolved(
+ &input.path,
+ &input.display_description,
+ input.mode.clone(),
+ tool,
+ event_stream,
+ cx,
+ )
+ .await?;
+ 0
+ }
+ StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count,
+ };
+
+ let StreamingEditState::BufferResolved {
+ buffer,
+ old_text,
+ diff,
+ abs_path,
+ ..
+ } = self
+ else {
+ debug_panic!("Invalid state");
+ return Ok(StreamingEditFileToolOutput::Error {
+ error: "Internal error. Try to apply the edits again".to_string(),
+ });
+ };
+
+ let result: anyhow::Result = async {
+ let action_log = tool
+ .thread
+ .read_with(cx, |thread, _cx| thread.action_log().clone())?;
+
+ match input.mode {
+ StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
+ action_log.update(cx, |log, cx| {
+ log.buffer_created(buffer.clone(), cx);
+ });
+ let content = input.content.ok_or_else(|| {
+ anyhow!("'content' field is required for create and overwrite modes")
+ })?;
+ cx.update(|cx| {
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..buffer.len(), content.as_str())], None, cx);
+ });
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ });
+ });
+ }
+ StreamingEditFileMode::Edit => {
+ let edits = input
+ .edits
+ .ok_or_else(|| anyhow!("'edits' field is required for edit mode"))?;
+
+ let remaining_edits = &edits[remaining_edits_start_ix..];
+ apply_edits(
+ &buffer,
+ &action_log,
+ remaining_edits,
+ &diff,
+ event_stream,
+ &abs_path,
+ cx,
+ )?;
+ }
+ }
+
+ let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
+ let settings = language_settings::language_settings(
+ buffer.language().map(|l| l.name()),
+ buffer.file(),
+ cx,
+ );
+ settings.format_on_save != FormatOnSave::Off
+ });
+
+ if format_on_save_enabled {
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ });
+
+ let format_task = tool.project.update(cx, |project, cx| {
+ project.format(
+ HashSet::from_iter([buffer.clone()]),
+ LspFormatTarget::Buffers,
+ false,
+ FormatTrigger::Save,
+ cx,
+ )
+ });
+ futures::select! {
+ result = format_task.fuse() => { result.log_err(); },
+ _ = event_stream.cancelled_by_user().fuse() => {
+ anyhow::bail!("Edit cancelled by user");
+ }
+ };
+ }
+
+ let save_task = tool
+ .project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
+ futures::select! {
+ result = save_task.fuse() => { result?; },
+ _ = event_stream.cancelled_by_user().fuse() => {
+ anyhow::bail!("Edit cancelled by user");
+ }
+ };
+
+ action_log.update(cx, |log, cx| {
+ log.buffer_edited(buffer.clone(), cx);
+ });
+
+ if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
+ buffer.file().and_then(|file| file.disk_state().mtime())
+ }) {
+ tool.thread.update(cx, |thread, _| {
+ thread
+ .file_read_times
+ .insert(abs_path.to_path_buf(), new_mtime);
+ })?;
+ }
+
+ let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+ let (new_text, unified_diff) = cx
+ .background_spawn({
+ let new_snapshot = new_snapshot.clone();
+ let old_text = old_text.clone();
+ async move {
+ let new_text = new_snapshot.text();
+ let diff = language::unified_diff(&old_text, &new_text);
+ (new_text, diff)
+ }
+ })
+ .await;
+
+ let output = StreamingEditFileToolOutput::Success {
+ input_path: PathBuf::from(input.path),
+ new_text,
+ old_text: old_text.clone(),
+ diff: unified_diff,
+ };
+ Ok(output)
+ }
+ .await;
+ result.map_err(|e| StreamingEditFileToolOutput::Error {
+ error: e.to_string(),
+ })
+ }
+
+ async fn process(
+ &mut self,
+ partial: StreamingEditFileToolPartialInput,
+ tool: &StreamingEditFileTool,
+ event_stream: &ToolCallEventStream,
+ cx: &mut AsyncApp,
+ ) -> Result<(), StreamingEditFileToolOutput> {
+ match self {
+ Self::Idle => {
+ if let Some(path_str) = partial.path
+ && let Some(display_description) = partial.display_description
+ && let Some(mode) = partial.mode
+ {
+ *self = Self::transition_to_buffer_resolved(
+ &path_str,
+ &display_description,
+ mode,
+ tool,
+ event_stream,
+ cx,
+ )
+ .await?;
+ }
+ }
+ Self::BufferResolved {
+ abs_path,
+ buffer,
+ edit_state,
+ diff,
+ ..
+ } => {
+ if let Some(edits) = partial.edits {
+ Self::process_streaming_edits(
+ buffer,
+ diff,
+ edit_state,
+ &edits,
+ abs_path,
+ tool,
+ event_stream,
+ cx,
+ )?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ async fn transition_to_buffer_resolved(
+ path_str: &str,
+ display_description: &str,
+ mode: StreamingEditFileMode,
+ tool: &StreamingEditFileTool,
+ event_stream: &ToolCallEventStream,
+ cx: &mut AsyncApp,
+ ) -> Result {
+ let path = PathBuf::from(path_str);
+ let project_path = cx
+ .update(|cx| resolve_path(mode, &path, &tool.project, cx))
+ .map_err(|e| StreamingEditFileToolOutput::Error {
+ error: e.to_string(),
+ })?;
+
+ let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx))
+ else {
+ return Err(StreamingEditFileToolOutput::Error {
+ error: format!("File '{path_str}' does not exist"),
+ });
+ };
+
+ event_stream.update_fields(
+ ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]),
+ );
+
+ cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx))
+ .await
+ .map_err(|e| StreamingEditFileToolOutput::Error {
+ error: e.to_string(),
+ })?;
+
+ let buffer = tool
+ .project
+ .update(cx, |project, cx| project.open_buffer(project_path, cx))
+ .await
+ .map_err(|e| StreamingEditFileToolOutput::Error {
+ error: e.to_string(),
+ })?;
+
+ ensure_buffer_saved(&buffer, &abs_path, tool, cx)?;
+
+ let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
+ event_stream.update_diff(diff.clone());
+ let finalize_diff_guard = util::defer(Box::new({
+ let diff = diff.downgrade();
+ let mut cx = cx.clone();
+ move || {
+ diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
+ }
+ }) as Box);
+
+ let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+ let old_text = cx
+ .background_spawn({
+ let old_snapshot = old_snapshot.clone();
+ async move { Arc::new(old_snapshot.text()) }
+ })
+ .await;
+
+ Ok(Self::BufferResolved {
+ abs_path,
+ buffer,
+ old_text,
+ diff,
+ edit_state: IncrementalEditState::default(),
+ _finalize_diff_guard: finalize_diff_guard,
+ })
+ }
+
+ fn process_streaming_edits(
+ buffer: &Entity,
+ diff: &Entity,
+ edit_state: &mut IncrementalEditState,
+ edits: &[PartialEditOperation],
+ abs_path: &PathBuf,
+ tool: &StreamingEditFileTool,
+ event_stream: &ToolCallEventStream,
+ cx: &mut AsyncApp,
+ ) -> Result<(), StreamingEditFileToolOutput> {
+ if edits.is_empty() {
+ return Ok(());
+ }
+
+ // Edits at indices applied_count..edits.len()-1 are newly complete
+ // (a subsequent edit exists, proving the LLM moved on).
+ // The last edit (edits.len()-1) is potentially still in progress.
+ let completed_count = edits.len().saturating_sub(1);
+
+ // Apply newly-complete edits
+ while edit_state.applied_count < completed_count {
+ let edit_index = edit_state.applied_count;
+ let partial_edit = &edits[edit_index];
+
+ let old_text = match &partial_edit.old_text {
+ Some(t) => t.clone(),
+ None => {
+ edit_state.applied_count += 1;
+ continue;
+ }
+ };
+ let new_text = partial_edit.new_text.clone().unwrap_or_default();
+
+ edit_state.in_progress_matcher = None;
+ edit_state.last_old_text_len = 0;
+
+ let edit_op = EditOperation {
+ old_text: old_text.clone(),
+ new_text: new_text.clone(),
+ };
+
+ let action_log = tool
+ .thread
+ .read_with(cx, |thread, _cx| thread.action_log().clone())
+ .ok();
+
+ // On the first edit, mark the buffer as read
+ if edit_state.applied_count == 0 {
+ if let Some(action_log) = &action_log {
+ action_log.update(cx, |log, cx| {
+ log.buffer_read(buffer.clone(), cx);
+ });
+ }
+ }
+
+ resolve_reveal_and_apply_edit(
+ buffer,
+ diff,
+ &edit_op,
+ edit_index,
+ abs_path,
+ action_log.as_ref(),
+ event_stream,
+ cx,
+ )
+ .map_err(|e| StreamingEditFileToolOutput::Error {
+ error: e.to_string(),
+ })?;
+
+ edit_state.applied_count += 1;
+ }
+
+ // Feed the in-progress last edit's old_text to the matcher for live preview
+ if let Some(partial_edit) = edits.last() {
+ if let Some(old_text) = &partial_edit.old_text {
+ let old_text_len = old_text.len();
+ if old_text_len > edit_state.last_old_text_len {
+ let new_chunk = &old_text[edit_state.last_old_text_len..];
+
+ let matcher = edit_state.in_progress_matcher.get_or_insert_with(|| {
+ let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
+ StreamingFuzzyMatcher::new(snapshot)
+ });
+
+ if let Some(match_range) = matcher.push(new_chunk, None) {
+ let anchor_range = buffer.read_with(cx, |buffer, _cx| {
+ buffer.anchor_range_between(match_range.clone())
+ });
+ diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx));
+ }
+
+ edit_state.last_old_text_len = old_text_len;
+ }
+ }
+ }
+
+ Ok(())
+ }
+}
+
+fn ensure_buffer_saved(
+ buffer: &Entity,
+ abs_path: &PathBuf,
+ tool: &StreamingEditFileTool,
+ cx: &mut AsyncApp,
+) -> Result<(), StreamingEditFileToolOutput> {
+ let check_result = tool.thread.update(cx, |thread, cx| {
+ let last_read = thread.file_read_times.get(abs_path).copied();
+ let current = buffer
+ .read(cx)
+ .file()
+ .and_then(|file| file.disk_state().mtime());
+ let dirty = buffer.read(cx).is_dirty();
+ let has_save = thread.has_tool(SaveFileTool::NAME);
+ let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
+ (last_read, current, dirty, has_save, has_restore)
+ });
+
+ let Ok((last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool)) =
+ check_result
+ else {
+ return Ok(());
+ };
+
+ if is_dirty {
+ let message = match (has_save_tool, has_restore_tool) {
+ (true, true) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+ If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+ }
+ (true, false) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+ If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
+ }
+ (false, true) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+ If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
+ If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+ }
+ (false, false) => {
+ "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
+ then ask them to save or revert the file manually and inform you when it's ok to proceed."
+ }
+ };
+ return Err(StreamingEditFileToolOutput::Error {
+ error: message.to_string(),
+ });
+ }
+
+ if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
+ if current != last_read {
+ return Err(StreamingEditFileToolOutput::Error {
+ error: "The file has been modified since you last read it. \
+ Please read the file again to get the current state before editing it."
+ .to_string(),
+ });
+ }
+ }
+
+ Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
@@ -164,8 +640,6 @@ pub struct StreamingEditFileTool {
thread: WeakEntity,
language_registry: Arc,
project: Entity,
- #[allow(dead_code)]
- templates: Arc,
}
impl StreamingEditFileTool {
@@ -173,35 +647,25 @@ impl StreamingEditFileTool {
project: Entity,
thread: WeakEntity,
language_registry: Arc,
- templates: Arc,
) -> Self {
Self {
project,
thread,
language_registry,
- templates,
- }
- }
-
- pub fn with_thread(&self, new_thread: WeakEntity) -> Self {
- Self {
- project: self.project.clone(),
- thread: new_thread,
- language_registry: self.language_registry.clone(),
- templates: self.templates.clone(),
}
}
fn authorize(
&self,
- input: &StreamingEditFileToolInput,
+ path: &PathBuf,
+ description: &str,
event_stream: &ToolCallEventStream,
cx: &mut App,
) -> Task> {
super::tool_permissions::authorize_file_edit(
EditFileTool::NAME,
- &input.path,
- &input.display_description,
+ path,
+ description,
&self.thread,
event_stream,
cx,
@@ -215,6 +679,10 @@ impl AgentTool for StreamingEditFileTool {
const NAME: &'static str = "streaming_edit_file";
+ fn supports_input_streaming() -> bool {
+ true
+ }
+
fn kind() -> acp::ToolKind {
acp::ToolKind::Edit
}
@@ -234,28 +702,30 @@ impl AgentTool for StreamingEditFileTool {
.read(cx)
.short_full_path_for_project_path(&project_path, cx)
})
- .unwrap_or(input.path.to_string_lossy().into_owned())
+ .unwrap_or(input.path)
.into(),
Err(raw_input) => {
if let Some(input) =
serde_json::from_value::(raw_input).ok()
{
- let path = input.path.trim();
+ let path = input.path.unwrap_or_default();
+ let path = path.trim();
if !path.is_empty() {
return self
.project
.read(cx)
- .find_project_path(&input.path, cx)
+ .find_project_path(&path, cx)
.and_then(|project_path| {
self.project
.read(cx)
.short_full_path_for_project_path(&project_path, cx)
})
- .unwrap_or(input.path)
+ .unwrap_or_else(|| path.to_string())
.into();
}
- let description = input.display_description.trim();
+ let description = input.display_description.unwrap_or_default();
+ let description = description.trim();
if !description.is_empty() {
return description.to_string().into();
}
@@ -268,227 +738,36 @@ impl AgentTool for StreamingEditFileTool {
fn run(
self: Arc,
- input: Self::Input,
+ mut input: ToolInput,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
- let Ok(project) = self
- .thread
- .read_with(cx, |thread, _cx| thread.project().clone())
- else {
- return Task::ready(Err(StreamingEditFileToolOutput::Error {
- error: "thread was dropped".to_string(),
- }));
- };
-
- let project_path = match resolve_path(&input, project.clone(), cx) {
- Ok(path) => path,
- Err(err) => {
- return Task::ready(Err(StreamingEditFileToolOutput::Error {
- error: err.to_string(),
- }));
- }
- };
-
- let abs_path = project.read(cx).absolute_path(&project_path, cx);
- if let Some(abs_path) = abs_path.clone() {
- event_stream.update_fields(
- ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),
- );
- }
-
- let authorize = self.authorize(&input, &event_stream, cx);
-
cx.spawn(async move |cx: &mut AsyncApp| {
- let result: anyhow::Result = async {
- authorize.await?;
-
- let buffer = project
- .update(cx, |project, cx| {
- project.open_buffer(project_path.clone(), cx)
- })
- .await?;
-
- if let Some(abs_path) = abs_path.as_ref() {
- let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) =
- self.thread.update(cx, |thread, cx| {
- let last_read = thread.file_read_times.get(abs_path).copied();
- let current = buffer
- .read(cx)
- .file()
- .and_then(|file| file.disk_state().mtime());
- let dirty = buffer.read(cx).is_dirty();
- let has_save = thread.has_tool(SaveFileTool::NAME);
- let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);
- (last_read, current, dirty, has_save, has_restore)
- })?;
-
- if is_dirty {
- let message = match (has_save_tool, has_restore_tool) {
- (true, true) => {
- "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
- If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
- If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
- }
- (true, false) => {
- "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
- If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
- If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
- }
- (false, true) => {
- "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
- If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
- If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
- }
- (false, false) => {
- "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
- then ask them to save or revert the file manually and inform you when it's ok to proceed."
- }
- };
- anyhow::bail!("{}", message);
- }
-
- if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
- if current != last_read {
- anyhow::bail!(
- "The file {} has been modified since you last read it. \
- Please read the file again to get the current state before editing it.",
- input.path.display()
- );
+ let mut state = StreamingEditState::Idle;
+ loop {
+ futures::select! {
+ partial = input.recv_partial().fuse() => {
+ let Some(partial_value) = partial else { break };
+ if let Ok(parsed) = serde_json::from_value::(partial_value) {
+ state.process(parsed, &self, &event_stream, cx).await?;
}
}
- }
-
- let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
- event_stream.update_diff(diff.clone());
- let _finalize_diff = util::defer({
- let diff = diff.downgrade();
- let mut cx = cx.clone();
- move || {
- diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
- }
- });
-
- let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
- let old_text = cx
- .background_spawn({
- let old_snapshot = old_snapshot.clone();
- async move { Arc::new(old_snapshot.text()) }
- })
- .await;
-
- let action_log = self.thread.read_with(cx, |thread, _cx| thread.action_log().clone())?;
-
- // Edit the buffer and report edits to the action log as part of the
- // same effect cycle, otherwise the edit will be reported as if the
- // user made it (due to the buffer subscription in action_log).
- match input.mode {
- StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {
- action_log.update(cx, |log, cx| {
- log.buffer_created(buffer.clone(), cx);
- });
- let content = input.content.ok_or_else(|| {
- anyhow!("'content' field is required for create and overwrite modes")
- })?;
- cx.update(|cx| {
- buffer.update(cx, |buffer, cx| {
- buffer.edit([(0..buffer.len(), content.as_str())], None, cx);
- });
- action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
- });
- }
- StreamingEditFileMode::Edit => {
- action_log.update(cx, |log, cx| {
- log.buffer_read(buffer.clone(), cx);
- });
- let edits = input.edits.ok_or_else(|| {
- anyhow!("'edits' field is required for edit mode")
- })?;
- // apply_edits now handles buffer_edited internally in the same effect cycle
- apply_edits(&buffer, &action_log, &edits, &diff, &event_stream, &abs_path, cx)?;
- }
- }
-
- let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
- let settings = language_settings::language_settings(
- buffer.language().map(|l| l.name()),
- buffer.file(),
- cx,
- );
- settings.format_on_save != FormatOnSave::Off
- });
-
- if format_on_save_enabled {
- action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
-
- let format_task = project.update(cx, |project, cx| {
- project.format(
- HashSet::from_iter([buffer.clone()]),
- LspFormatTarget::Buffers,
- false,
- FormatTrigger::Save,
- cx,
- )
- });
- futures::select! {
- result = format_task.fuse() => { result.log_err(); },
- _ = event_stream.cancelled_by_user().fuse() => {
- anyhow::bail!("Edit cancelled by user");
- }
- };
- }
-
- let save_task = project
- .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
- futures::select! {
- result = save_task.fuse() => { result?; },
_ = event_stream.cancelled_by_user().fuse() => {
- anyhow::bail!("Edit cancelled by user");
- }
- };
-
- action_log.update(cx, |log, cx| {
- log.buffer_edited(buffer.clone(), cx);
- });
-
- if let Some(abs_path) = abs_path.as_ref() {
- if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
- buffer.file().and_then(|file| file.disk_state().mtime())
- }) {
- self.thread.update(cx, |thread, _| {
- thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
- })?;
+ return Err(StreamingEditFileToolOutput::Error {
+ error: "Edit cancelled by user".to_string(),
+ });
}
}
+ }
+ let full_input =
+ input
+ .recv()
+ .await
+ .map_err(|e| StreamingEditFileToolOutput::Error {
+ error: format!("Failed to receive tool input: {e}"),
+ })?;
- let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
- let (new_text, unified_diff) = cx
- .background_spawn({
- let new_snapshot = new_snapshot.clone();
- let old_text = old_text.clone();
- async move {
- let new_text = new_snapshot.text();
- let diff = language::unified_diff(&old_text, &new_text);
- (new_text, diff)
- }
- })
- .await;
-
- let output = StreamingEditFileToolOutput::Success {
- input_path: input.path,
- new_text,
- old_text,
- diff: unified_diff,
- };
-
- Ok(output)
- }.await;
- result
- .map_err(|e| StreamingEditFileToolOutput::Error { error: e.to_string() })
+ state.finalize(full_input, &self, &event_stream, cx).await
})
}
@@ -528,42 +807,28 @@ fn apply_edits(
edits: &[EditOperation],
diff: &Entity,
event_stream: &ToolCallEventStream,
- abs_path: &Option,
+ abs_path: &PathBuf,
cx: &mut AsyncApp,
) -> Result<()> {
let mut failed_edits = Vec::new();
let mut ambiguous_edits = Vec::new();
let mut resolved_edits: Vec<(Range, String)> = Vec::new();
- // First pass: resolve all edits without applying them
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
for (index, edit) in edits.iter().enumerate() {
- let result = resolve_edit(&snapshot, edit);
-
- match result {
- Ok(Some((range, new_text))) => {
- // Reveal the range in the diff view
- let (start_anchor, end_anchor) = buffer.read_with(cx, |buffer, _cx| {
- (
- buffer.anchor_before(range.start),
- buffer.anchor_after(range.end),
- )
- });
- diff.update(cx, |card, cx| {
- card.reveal_range(start_anchor..end_anchor, cx)
- });
+ match resolve_and_reveal_edit(buffer, diff, &snapshot, edit, cx) {
+ Ok((range, new_text)) => {
resolved_edits.push((range, new_text));
}
- Ok(None) => {
+ Err(EditResolveError::NotFound) => {
failed_edits.push(index);
}
- Err(ranges) => {
+ Err(EditResolveError::Ambiguous(ranges)) => {
ambiguous_edits.push((index, ranges));
}
}
}
- // Check for errors before applying any edits
if !failed_edits.is_empty() {
let indices = failed_edits
.iter()
@@ -597,22 +862,17 @@ fn apply_edits(
);
}
- // Sort edits by position so buffer.edit() can handle offset translation
let mut edits_sorted = resolved_edits;
edits_sorted.sort_by(|a, b| a.0.start.cmp(&b.0.start));
- // Emit location for the earliest edit in the file
if let Some((first_range, _)) = edits_sorted.first() {
- if let Some(abs_path) = abs_path.clone() {
- let line = snapshot.offset_to_point(first_range.start).row;
- event_stream.update_fields(
- ToolCallUpdateFields::new()
- .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
- );
- }
+ let line = snapshot.offset_to_point(first_range.start).row;
+ event_stream.update_fields(
+ ToolCallUpdateFields::new()
+ .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
+ );
}
- // Validate no overlaps (sorted ascending by start)
for window in edits_sorted.windows(2) {
if let [(earlier_range, _), (later_range, _)] = window
&& (earlier_range.end > later_range.start || earlier_range.start == later_range.start)
@@ -632,9 +892,6 @@ fn apply_edits(
}
}
- // Apply all edits in a single batch and report to action_log in the same
- // effect cycle. This prevents the buffer subscription from treating these
- // as user edits.
if !edits_sorted.is_empty() {
cx.update(|cx| {
buffer.update(cx, |buffer, cx| {
@@ -655,40 +912,111 @@ fn apply_edits(
Ok(())
}
-/// Resolves an edit operation by finding the matching text in the buffer.
-/// Returns Ok(Some((range, new_text))) if a unique match is found,
-/// Ok(None) if no match is found, or Err(ranges) if multiple matches are found.
-fn resolve_edit(
- snapshot: &BufferSnapshot,
+/// Resolves, reveals, and applies a single edit to the buffer. Emits
+/// a location update and reports the change to the action log.
+fn resolve_reveal_and_apply_edit(
+ buffer: &Entity,
+ diff: &Entity,
edit: &EditOperation,
-) -> std::result::Result