mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fae0c03b7a | |||
| 6106758e95 | |||
| c78835945f | |||
| 2f0dbb2293 | |||
| 06b05ddfe9 | |||
| 2670a4a171 | |||
| 1699c9a63a | |||
| 808dd42ae9 | |||
| 71af56f493 | |||
| 1167b4f3a6 | |||
| e8d6370b0c | |||
| e243376a39 | |||
| 4f1be9d512 | |||
| 94d36ce719 | |||
| 5df7498f03 | |||
| c2b87252ac | |||
| be353bb3da | |||
| 0a7791de1f | |||
| 7cca102e14 | |||
| 4f591de6a9 | |||
| 60436b3ddf | |||
| df5954052d | |||
| a66a2784c3 | |||
| 47031cea25 | |||
| e29740479e | |||
| 74f607ff8e | |||
| 0edf6d777b | |||
| de630c64d4 | |||
| 3ff63562c8 | |||
| 4d6f908fbd | |||
| 6521fdcc94 | |||
| 1393eace27 | |||
| eebb99c1e3 | |||
| 9ed44f7a83 | |||
| 6a95cc5f19 | |||
| 1d0637f39c | |||
| f2c16bae7a | |||
| 204f15ce1c | |||
| 8118e11596 | |||
| f7b593df35 | |||
| 2e0358a7a1 | |||
| 0fbc10f807 | |||
| d9c6ba57f7 | |||
| 90dae0f820 | |||
| 7e9e714cef | |||
| 4e0d242453 | |||
| c3b31bc225 | |||
| 6775ce0085 | |||
| 603470b1c1 | |||
| 7f1486a975 | |||
| f0af58437b | |||
| 2ad585caea | |||
| ceaa889acf | |||
| 391c07c998 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
improve cline command permission validation logic. add cline command permission man page documentation
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Lock the LiteLLM Api Key input when it's remotely configured
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated welcome card content and added ability to close each card
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Remove deprecated zai-glm-4.6 model from Cerebras provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Wiring-up "conditionals" for Cline Rules files.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix drag & drop files from SSH remote workspaces into chat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Updated switch component to adhere to 3:1 contrast accessibility standard for both light and dark vscode themes
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove hooks feature setting and make it always enabled (except on Windows).
|
||||
@@ -16,13 +16,6 @@
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPENTELEMETRY (Optional - for advanced telemetry)
|
||||
# ============================================================================
|
||||
|
||||
@@ -97,7 +97,6 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
|
||||
@@ -138,7 +138,6 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
|
||||
+39
-2
@@ -1,18 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
- Add new model: Arcee Trinity Large Preview
|
||||
- Add new model: Moonshot Kimi K2.5
|
||||
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
|
||||
|
||||
## [3.54.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Native tool calls support for Ollama provider
|
||||
- Sonnet 4.5 is now the default Amazon Bedrock model id
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
|
||||
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
|
||||
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed Mistral's Devstral-2512 free from the free models list
|
||||
- Removed deprecated zai-glm-4.6 model from Cerebras provider
|
||||
|
||||
## [3.53.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug in responses API
|
||||
|
||||
## [3.53.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed grok model from free tier
|
||||
|
||||
## [3.52.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
- Grok models are now moving out of free tier and into paid plans.
|
||||
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
|
||||
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugs in DiffViewProvider for file editing
|
||||
- Ollama's recommended models to use correct identifiers
|
||||
|
||||
|
||||
## [3.51.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -104,14 +104,18 @@ func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cl
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *EnvService) isTelemetryEnabled() bool {
|
||||
// In CLI mode, check the CLINE_TELEMETRY_DISABLED environment variable
|
||||
return os.Getenv("CLINE_TELEMETRY_DISABLED") != "true"
|
||||
}
|
||||
|
||||
// GetTelemetrySettings returns the telemetry settings for CLI mode
|
||||
func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetTelemetrySettings called")
|
||||
}
|
||||
|
||||
// In CLI mode, check the POSTHOG_TELEMETRY_ENABLED environment variable
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
telemetryEnabled := s.isTelemetryEnabled()
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
@@ -133,8 +137,7 @@ func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, strea
|
||||
log.Printf("SubscribeToTelemetrySettings called")
|
||||
}
|
||||
|
||||
// Send initial telemetry state
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
telemetryEnabled := s.isTelemetryEnabled()
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
|
||||
+15
-1
@@ -117,7 +117,13 @@
|
||||
"features/auto-compact",
|
||||
"features/background-edit",
|
||||
"features/checkpoints",
|
||||
"features/cline-rules",
|
||||
{
|
||||
"group": "Cline Rules",
|
||||
"pages": [
|
||||
"features/cline-rules/overview",
|
||||
"features/cline-rules/conditional-rules"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Commands & Shortcuts",
|
||||
"pages": [
|
||||
@@ -424,6 +430,14 @@
|
||||
{
|
||||
"source": "/enterprise-solutions/team-management/roles-and-permissions",
|
||||
"destination": "/enterprise-solutions/team-management/managing-members"
|
||||
},
|
||||
{
|
||||
"source": "/features/cline-rules",
|
||||
"destination": "/features/cline-rules/overview"
|
||||
},
|
||||
{
|
||||
"source": "/features/conditional-rules",
|
||||
"destination": "/features/cline-rules/conditional-rules"
|
||||
}
|
||||
],
|
||||
"search": {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
Cline Rules allow you to provide Cline with system-level guidance. Think of them as a persistent way to include context and preferences for your projects or globally for every conversation.
|
||||
|
||||
## Creating a Rule
|
||||
|
||||
You can create a rule by clicking the `+` button in the Rules tab. This will open a new file in your IDE which you can use to write your rule.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
|
||||
</Frame>
|
||||
|
||||
Once you save the file:
|
||||
|
||||
- Your rule will be stored in the `.clinerules/` directory in your project (if it's a Workspace Rule)
|
||||
- Or in the Global Rules directory (if it's a Global Rule):
|
||||
|
||||
### Global Rules Directory Location
|
||||
|
||||
The location of your Global Rules directory depends on your operating system:
|
||||
|
||||
| Operating System | Default Location | Notes |
|
||||
|------------------|------------------|-------|
|
||||
| **Windows** | `Documents\Cline\Rules` | Uses system Documents folder |
|
||||
| **macOS** | `~/Documents/Cline/Rules` | Uses user Documents folder |
|
||||
| **Linux/WSL** | `~/Documents/Cline/Rules` | May fall back to `~/Cline/Rules` on some systems |
|
||||
|
||||
> **Note for Linux/WSL users**: If you don't find your global rules in `~/Documents/Cline/Rules`, check `~/Cline/Rules` as the location may vary depending on your system configuration and whether the Documents directory exists.
|
||||
|
||||
You can also have Cline create a rule for you by using the [`/newrule` slash command](/features/slash-commands/new-rule) in the chat.
|
||||
|
||||
```markdown Example Cline Rule Structure [expandable]
|
||||
# Project Guidelines
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
- Update relevant documentation in /docs when modifying features
|
||||
- Keep README.md in sync with new capabilities
|
||||
- Maintain changelog entries in CHANGELOG.md
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Create ADRs in /docs/adr for:
|
||||
|
||||
- Major dependency changes
|
||||
- Architectural pattern changes
|
||||
- New integration patterns
|
||||
- Database schema changes
|
||||
Follow template in /docs/adr/template.md
|
||||
|
||||
## Code Style & Patterns
|
||||
|
||||
- Generate API clients using OpenAPI Generator
|
||||
- Use TypeScript axios template
|
||||
- Place generated code in /src/generated
|
||||
- Prefer composition over inheritance
|
||||
- Use repository pattern for data access
|
||||
- Follow error handling pattern in /src/utils/errors.ts
|
||||
|
||||
## Testing Standards
|
||||
|
||||
- Unit tests required for business logic
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
1. **Version Controlled**: The `.clinerules` file becomes part of your project's source code
|
||||
2. **Team Consistency**: Ensures consistent behavior across all team members
|
||||
3. **Project-Specific**: Rules and standards tailored to each project's needs
|
||||
4. **Institutional Knowledge**: Maintains project standards and practices in code
|
||||
|
||||
Place the `.clinerules` file in your project's root directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules
|
||||
├── src/
|
||||
├── docs/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline's system prompt, on the other hand, is not user-editable ([here's where you can find it](https://github.com/cline/cline/blob/main/src/core/prompts/system.ts)). For a broader look at prompt engineering best practices, check out [this resource](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview).
|
||||
|
||||
### AGENTS.md Standard Support
|
||||
|
||||
Cline also supports the [AGENTS.md](https://agents.md/) standard as a fallback
|
||||
(in addition to Cline Rules) by automatically detecting `AGENTS.md` files in
|
||||
your workspace root. This allows you to use the same rules file across different AI
|
||||
coding tools.
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── AGENTS.md
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Tips for Writing Effective Cline Rules
|
||||
|
||||
- Be Clear and Concise: Use simple language and avoid ambiguity.
|
||||
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
|
||||
- Test and Iterate: Experiment to find what works best for your workflow.
|
||||
|
||||
### .clinerules/ Folder System
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Folder containing active rules
|
||||
│ ├── 01-coding.md # Core coding standards
|
||||
│ ├── 02-documentation.md # Documentation requirements
|
||||
│ └── current-sprint.md # Rules specific to current work
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline automatically processes **all Markdown files** inside the `.clinerules/` directory, combining them into a unified set of rules. The numeric prefixes (optional) help organize files in a logical sequence.
|
||||
|
||||
#### Using a Rules Bank
|
||||
|
||||
For projects with multiple contexts or teams, maintain a rules bank directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Active rules - automatically applied
|
||||
│ ├── 01-coding.md
|
||||
│ └── client-a.md
|
||||
│
|
||||
├── clinerules-bank/ # Repository of available but inactive rules
|
||||
│ ├── clients/ # Client-specific rule sets
|
||||
│ │ ├── client-a.md
|
||||
│ │ └── client-b.md
|
||||
│ ├── frameworks/ # Framework-specific rules
|
||||
│ │ ├── react.md
|
||||
│ │ └── vue.md
|
||||
│ └── project-types/ # Project type standards
|
||||
│ ├── api-service.md
|
||||
│ └── frontend-app.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
#### Benefits of the Folder Approach
|
||||
|
||||
1. **Contextual Activation**: Copy only relevant rules from the bank to the active folder
|
||||
2. **Easier Maintenance**: Update individual rule files without affecting others
|
||||
3. **Team Flexibility**: Different team members can activate rules specific to their current task
|
||||
4. **Reduced Noise**: Keep the active ruleset focused and relevant
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
Switch between client projects:
|
||||
|
||||
```bash
|
||||
# Switch to Client B project
|
||||
rm .clinerules/client-a.md
|
||||
cp clinerules-bank/clients/client-b.md .clinerules/
|
||||
```
|
||||
|
||||
Adapt to different tech stacks:
|
||||
|
||||
```bash
|
||||
# Frontend React project
|
||||
cp clinerules-bank/frameworks/react.md .clinerules/
|
||||
```
|
||||
|
||||
#### Implementation Tips
|
||||
|
||||
- Keep individual rule files focused on specific concerns
|
||||
- Use descriptive filenames that clearly indicate the rule's purpose
|
||||
- Consider git-ignoring the active `.clinerules/` folder while tracking the `clinerules-bank/`
|
||||
- Create team scripts to quickly activate common rule combinations
|
||||
|
||||
The folder system transforms your Cline rules from a static document into a dynamic knowledge system that adapts to your team's changing contexts and requirements.
|
||||
|
||||
### Managing Rules with the Toggleable Popover
|
||||
|
||||
To make managing both single `.clinerules` files and the folder system even easier, Cline v3.13 introduces a dedicated popover UI directly accessible from the chat interface.
|
||||
|
||||
Located conveniently under the chat input field, this popover allows you to:
|
||||
|
||||
- **Instantly See Active Rules:** View which global rules (from your user settings) and workspace rules (`.clinerules` file or folder contents) are currently active.
|
||||
- **Quickly Toggle Rules:** Enable or disable specific rule files within your workspace `.clinerules/` folder with a single click. This is perfect for activating context-specific rules (like `react-rules.md` or `memory-bank.md`) only when needed.
|
||||
- **Easily Add/Manage Rules:** Quickly create a workspace `.clinerules` file or folder if one doesn't exist, or add new rule files to an existing folder.
|
||||
|
||||
This UI significantly simplifies switching contexts and managing different sets of instructions without needing to manually edit files or configurations during a conversation.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Cline Logo" />
|
||||
</Frame>
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
title: "Conditional Rules"
|
||||
sidebarTitle: "Conditional Rules"
|
||||
description: "Activate rules automatically based on which files you're working with"
|
||||
---
|
||||
|
||||
Conditional rules let you scope rules to specific parts of your codebase. Rules activate only when you're working with matching files, keeping your context focused and relevant.
|
||||
|
||||
For an introduction to Cline Rules, see the [Overview](/features/cline-rules/overview).
|
||||
|
||||
- **Without conditionals**: every rule loads for every request.
|
||||
- **With conditionals**, rules activate only when your current files match their defined scope.
|
||||
|
||||
For example, React component rules should appear when you're working with React components, not when you're editing Python or documentation.
|
||||
|
||||
## How It Works
|
||||
|
||||
Conditional rules use YAML frontmatter at the top of your rule files. When Cline processes a request, it gathers context from your current work (open files, visible tabs, mentioned paths, edited files), evaluates each rule's conditions, and activates matching rules.
|
||||
|
||||
<Note>
|
||||
When a conditional rule activates, you'll see a notification: **"Conditional rules applied: workspace:frontend-rules.md"**
|
||||
</Note>
|
||||
|
||||
## Writing Conditional Rules
|
||||
|
||||
Add YAML frontmatter to the top of any rule file in your `.clinerules/` directory:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# React Component Guidelines
|
||||
|
||||
When creating or modifying React components:
|
||||
- Use functional components with React hooks
|
||||
- Extract reusable logic into custom React hooks
|
||||
- Keep components focused on a single responsibility
|
||||
```
|
||||
|
||||
The `---` markers delimit the frontmatter. Everything after the closing `---` is your rule content.
|
||||
|
||||
### The `paths` Conditional
|
||||
|
||||
Currently, `paths` is the supported conditional. It takes an array of glob patterns:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/**" # All files under src/
|
||||
- "*.config.js" # Config files in root
|
||||
- "packages/*/src/" # Monorepo package sources
|
||||
---
|
||||
```
|
||||
|
||||
**Glob pattern syntax:**
|
||||
- `*` matches any characters except `/`
|
||||
- `**` matches any characters including `/` (recursive)
|
||||
- `?` matches a single character
|
||||
- `[abc]` matches any character in the brackets
|
||||
- `{a,b}` matches either pattern
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Pattern | Matches |
|
||||
|---------|---------|
|
||||
| `src/**/*.ts` | All TypeScript files under `src/` |
|
||||
| `*.md` | Markdown files in root only |
|
||||
| `**/*.test.ts` | Test files anywhere in the project |
|
||||
| `packages/{web,api}/**` | Files in web or api packages |
|
||||
| `src/components/*.tsx` | TSX files directly in components (not nested) |
|
||||
|
||||
### Behavior Details
|
||||
|
||||
**Multiple patterns**: A rule activates if any pattern matches any file in your context.
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- "mobile/**"
|
||||
---
|
||||
# Activates when working in frontend OR mobile
|
||||
```
|
||||
|
||||
**No frontmatter**: Rules without frontmatter are always active.
|
||||
|
||||
**Empty paths array**: `paths: []` means the rule never activates. Use this to temporarily disable a rule.
|
||||
|
||||
**Invalid YAML**: If frontmatter can't be parsed, Cline fails open: the rule activates with raw content visible to help debugging.
|
||||
|
||||
## What Counts as "Current Context"
|
||||
|
||||
Cline evaluates rules based on:
|
||||
|
||||
1. **Your message**: File paths mentioned in your prompt (e.g., "update `src/App.tsx`")
|
||||
2. **Open tabs**: Files currently open in your editor
|
||||
3. **Visible files**: Files visible in your active editor panes
|
||||
4. **Edited files**: Files Cline has created, modified, or deleted during the task
|
||||
5. **Pending operations**: Files Cline is about to edit
|
||||
|
||||
Conditional rules can activate on your first message, when relevant files are open, or mid-task when Cline starts working with matching files.
|
||||
|
||||
<Tip>
|
||||
Be explicit about file paths in your prompts. "Update `src/services/user.ts`" reliably triggers path-based rules; "update the user service" may not.
|
||||
</Tip>
|
||||
|
||||
## Practical Examples
|
||||
|
||||
Copy these patterns and adapt them to your project structure.
|
||||
|
||||
### Frontend vs Backend Rules
|
||||
|
||||
Keep frontend and backend rules separate to avoid noise. Frontend rules only load when working with UI code, backend rules only load when working with API or service code.
|
||||
|
||||
```yaml
|
||||
# .clinerules/frontend.md
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/pages/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# Frontend Guidelines
|
||||
|
||||
- Use Tailwind CSS for styling
|
||||
- Prefer server components where possible
|
||||
- Keep client components small and focused
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .clinerules/backend.md
|
||||
---
|
||||
paths:
|
||||
- "src/api/**"
|
||||
- "src/services/**"
|
||||
- "src/db/**"
|
||||
---
|
||||
|
||||
# Backend Guidelines
|
||||
|
||||
- Use dependency injection for services
|
||||
- All database queries go through repositories
|
||||
- Return typed errors, not thrown exceptions
|
||||
```
|
||||
|
||||
### Test File Rules
|
||||
|
||||
Enforce testing standards automatically. This rule activates only when you're writing or modifying tests, so testing guidance appears exactly when you need it.
|
||||
|
||||
```yaml
|
||||
# .clinerules/testing.md
|
||||
---
|
||||
paths:
|
||||
- "**/*.test.ts"
|
||||
- "**/*.spec.ts"
|
||||
- "**/__tests__/**"
|
||||
---
|
||||
|
||||
# Testing Standards
|
||||
|
||||
- Use descriptive test names: "should [expected behavior] when [condition]"
|
||||
- One assertion per test when possible
|
||||
- Mock external dependencies, not internal modules
|
||||
- Use factories for test data, not fixtures
|
||||
```
|
||||
|
||||
### Documentation Rules
|
||||
|
||||
Apply documentation standards only when editing docs. Prevents style rules from cluttering your context when you're writing code.
|
||||
|
||||
```yaml
|
||||
# .clinerules/docs.md
|
||||
---
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
---
|
||||
|
||||
# Documentation Guidelines
|
||||
|
||||
- Use sentence case for headings
|
||||
- Include code examples for all features
|
||||
- Keep paragraphs short (3-4 sentences max)
|
||||
- Link to related documentation
|
||||
```
|
||||
|
||||
## Combining with Rule Toggles
|
||||
|
||||
Conditional rules work alongside the rule toggle UI. Toggle off a conditional rule to disable it entirely (it won't activate even if paths match). Toggle on to let it activate when conditions are met.
|
||||
|
||||
This provides two levels of control: manual toggles and automatic condition-based activation.
|
||||
|
||||
## Tips for Effective Conditional Rules
|
||||
|
||||
### Start Broad, Then Narrow
|
||||
|
||||
Begin with broader patterns and refine as you learn what works:
|
||||
|
||||
```yaml
|
||||
# Start here
|
||||
paths:
|
||||
- "src/**"
|
||||
|
||||
# Then narrow down
|
||||
paths:
|
||||
- "src/features/auth/**"
|
||||
```
|
||||
|
||||
### Use Descriptive Filenames
|
||||
|
||||
Name your rule files to indicate their scope:
|
||||
|
||||
```
|
||||
.clinerules/
|
||||
├── api-endpoints.md # Rules for API code
|
||||
├── database-models.md # Rules for DB layer
|
||||
├── react-components.md # Rules for React
|
||||
└── universal.md # No frontmatter = always active
|
||||
```
|
||||
|
||||
### Keep Universal Rules Separate
|
||||
|
||||
Put always-on rules (coding standards, project conventions) in files without frontmatter. Reserve conditional rules for context-specific guidance.
|
||||
|
||||
### Test Your Patterns
|
||||
|
||||
Not sure if a pattern matches? Create a simple test rule:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "your/pattern/here/**"
|
||||
---
|
||||
|
||||
TEST: This rule should activate for your/pattern/here files.
|
||||
```
|
||||
|
||||
Then work with a file in that path and check if you see the activation notification.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Rule not activating:**
|
||||
- Check that file paths in your context match the glob pattern
|
||||
- Verify the rule is toggled on in the rules panel
|
||||
- Ensure YAML frontmatter has proper `---` delimiters
|
||||
|
||||
**Rule activating unexpectedly:**
|
||||
- Review glob patterns: `**` is recursive and may match more than intended
|
||||
- Check for open files that match the pattern
|
||||
- File paths mentioned in your message also count as context
|
||||
|
||||
**Frontmatter showing in output:**
|
||||
- YAML couldn't be parsed
|
||||
- Check for syntax errors (unquoted special characters, improper indentation)
|
||||
|
||||
## Related
|
||||
|
||||
- [Cline Rules Overview](/features/cline-rules/overview) - Complete rules system guide
|
||||
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
|
||||
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
|
||||
- [@ Mentions](/features/at-mentions/overview) - Add files to context explicitly
|
||||
- [Understanding Context Management](/prompting/understanding-context-management) - How Cline manages context window
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: "Cline Rules"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Add persistent instructions and context to guide Cline's behavior"
|
||||
---
|
||||
|
||||
Cline Rules provide system-level guidance for your projects. Rules persist across conversations, ensuring consistent behavior without repeating instructions in every chat.
|
||||
|
||||
## How It Works
|
||||
|
||||
Rules are loaded when Cline starts a task. Here's what happens:
|
||||
|
||||
**Loading order**: Cline checks for rules in this sequence:
|
||||
1. `.clinerules/` folder (all `.md` files inside)
|
||||
2. Single `.clinerules` file
|
||||
3. `AGENTS.md` file
|
||||
|
||||
**Scope precedence**: Workspace rules override global rules when both define the same guidance.
|
||||
|
||||
**Multiple files**: When using a `.clinerules/` folder, all Markdown files are combined into one ruleset. Numeric prefixes (like `01-`, `02-`) control the order.
|
||||
|
||||
**Conditional activation**: Rules with YAML frontmatter activate only when you're working with matching files. See [Conditional Rules](/features/cline-rules/conditional-rules) for details.
|
||||
|
||||
## Supported Rule Files
|
||||
|
||||
Cline reads rules from multiple file formats in your workspace root, letting you share rules across different AI coding tools:
|
||||
|
||||
| File/Folder | Source | Notes |
|
||||
|-------------|--------|-------|
|
||||
| `.clinerules/` | Cline | Folder with `.md` files (recommended) |
|
||||
| `.cursor/rules/` | Cursor | Folder with `.mdc` files |
|
||||
| `.windsurf/rules` | Windsurf | Folder with multiple `md` files |
|
||||
| `AGENTS.md` | Universal | Follows [agents.md](https://agents.md/) standard, searched recursively |
|
||||
|
||||
Cline prioritizes `.clinerules` when present. Other formats load only if no `.clinerules` exists (except `AGENTS.md`, which always searches subdirectories). All rules appear in the Rules popover where you can toggle them.
|
||||
|
||||
## Creating Rules
|
||||
|
||||
Click the `+` button in the Rules tab to create a new rule. This opens a file in your editor where you write your guidance.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
|
||||
</Frame>
|
||||
|
||||
When you save the file, it's stored in:
|
||||
- **Workspace rules**: `.clinerules/` in your project root
|
||||
- **Global rules**: Platform-specific location (see table below)
|
||||
|
||||
You can also use the [`/newrule` slash command](/features/slash-commands/new-rule) to have Cline generate a rule based on your description.
|
||||
|
||||
### Global Rules Location
|
||||
|
||||
| Operating System | Default Location |
|
||||
|------------------|------------------|
|
||||
| **Windows** | `Documents\Cline\Rules` |
|
||||
| **macOS** | `~/Documents/Cline/Rules` |
|
||||
| **Linux/WSL** | `~/Documents/Cline/Rules` or `~/Cline/Rules` |
|
||||
|
||||
<Note>
|
||||
Linux/WSL users: Check both locations if you don't find global rules in `~/Documents/Cline/Rules`.
|
||||
</Note>
|
||||
|
||||
## Managing Rules
|
||||
|
||||
The Rules popover (below the chat input) shows active rules and lets you toggle them on or off.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Rules Popover" />
|
||||
</Frame>
|
||||
|
||||
The popover displays:
|
||||
- **Global rules**: From your user-level Rules directory
|
||||
- **Workspace rules**: From `.clinerules/` in your project
|
||||
|
||||
Toggle any rule to enable or disable it. Disabled rules won't load, even if they match conditions.
|
||||
|
||||
## When to Use Rules
|
||||
|
||||
Rules work best for persistent project context:
|
||||
|
||||
- **Code standards**: Formatting preferences, naming conventions, project-specific patterns
|
||||
- **Documentation requirements**: Where to add docs, what format to follow
|
||||
- **Architecture decisions**: Design patterns, dependency rules, module boundaries
|
||||
- **Team conventions**: PR processes, branch naming, commit message format
|
||||
- **Technology constraints**: Required libraries, banned APIs, version requirements
|
||||
|
||||
Rules are less effective for:
|
||||
- One-time instructions (just say it in the chat)
|
||||
- Complex multi-step workflows (use [Workflows](/features/slash-commands/workflows/index) instead)
|
||||
- Dynamic decisions that depend on runtime context
|
||||
|
||||
## Example Rule
|
||||
|
||||
```markdown
|
||||
# Backend API Guidelines
|
||||
|
||||
## Route Handlers
|
||||
|
||||
- Use async/await, not callbacks
|
||||
- Validate request bodies with Zod schemas
|
||||
- Return typed errors from `src/errors.ts`
|
||||
- All routes require authentication unless in `publicRoutes` array
|
||||
|
||||
## Database Access
|
||||
|
||||
- All queries go through repository classes in `src/repositories/`
|
||||
- Use transactions for multi-table updates
|
||||
- Never expose raw database errors to clients
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for business logic in `src/services/`
|
||||
- Integration tests for route handlers in `src/routes/`
|
||||
- Mock external APIs, not internal modules
|
||||
```
|
||||
|
||||
This rule provides clear, actionable guidance without explaining obvious concepts or using vague language.
|
||||
|
||||
## Using a Folder Structure
|
||||
|
||||
For projects with many rules, organize them in a `.clinerules/` folder:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/
|
||||
│ ├── 01-coding-standards.md
|
||||
│ ├── 02-documentation.md
|
||||
│ └── 03-testing.md
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline loads all Markdown files in `.clinerules/` automatically. The numeric prefixes help you control ordering, but they're optional.
|
||||
|
||||
### Organizing a Rules Bank
|
||||
|
||||
Maintain a separate folder for rules you might need but don't always want active:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Active rules
|
||||
│ ├── 01-coding.md
|
||||
│ └── client-a.md
|
||||
│
|
||||
├── clinerules-bank/ # Available but inactive
|
||||
│ ├── clients/
|
||||
│ │ ├── client-a.md
|
||||
│ │ └── client-b.md
|
||||
│ └── frameworks/
|
||||
│ ├── react.md
|
||||
│ └── vue.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
Copy files from the bank to `.clinerules/` when you need them. This keeps your active context lean while maintaining a library of reusable guidance.
|
||||
|
||||
Switch contexts with simple file operations:
|
||||
|
||||
```bash
|
||||
# Switch to Client B
|
||||
rm .clinerules/client-a.md
|
||||
cp clinerules-bank/clients/client-b.md .clinerules/
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Consider git-ignoring `.clinerules/` while tracking `clinerules-bank/` so team members can activate the rules relevant to their current work.
|
||||
</Tip>
|
||||
|
||||
## Conditional Rules
|
||||
|
||||
Scope rules to specific file patterns using YAML frontmatter. This keeps React guidance out of Python code and backend rules away from frontend work.
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# React Guidelines
|
||||
|
||||
Use functional components with hooks. Extract reusable logic into custom hooks.
|
||||
```
|
||||
|
||||
This rule activates only when working with files matching those patterns. Read the [Conditional Rules guide](/features/cline-rules/conditional-rules) for pattern syntax, behavior details, and more examples.
|
||||
|
||||
## Tips for Effective Rules
|
||||
|
||||
**Be specific**: "Use async/await for all database calls" beats "write good async code."
|
||||
|
||||
**Show patterns**: Include file paths and real examples. "Follow the error handling in `src/utils/errors.ts`" gives Cline a concrete reference.
|
||||
|
||||
**Focus on outcomes**: Describe what you want, not step-by-step instructions. Let Cline figure out how.
|
||||
|
||||
**Test and refine**: Start with core standards. Add rules when you find yourself repeating the same feedback.
|
||||
|
||||
**Use conditional rules**: Load guidance only when relevant. This keeps context efficient and reduces noise.
|
||||
|
||||
## Related
|
||||
|
||||
- [Conditional Rules](/features/cline-rules/conditional-rules) - Activate rules based on file patterns
|
||||
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
|
||||
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
|
||||
- [New Rule Slash Command](/features/slash-commands/new-rule) - Generate rules with AI assistance
|
||||
- [Plan and Act Mode](/features/plan-and-act) - Use different rules for planning vs execution
|
||||
@@ -144,10 +144,6 @@ if (process.env.ERROR_SERVICE_API_KEY) {
|
||||
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
|
||||
}
|
||||
|
||||
if (process.env.POSTHOG_TELEMETRY_ENABLED) {
|
||||
buildEnvVars["process.env.POSTHOG_TELEMETRY_ENABLED"] = JSON.stringify(process.env.POSTHOG_TELEMETRY_ENABLED)
|
||||
}
|
||||
|
||||
// OpenTelemetry configuration (injected at build time from GitHub secrets)
|
||||
// These provide production defaults that can be overridden at runtime via environment variables
|
||||
if (process.env.OTEL_TELEMETRY_ENABLED) {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.52.0",
|
||||
"version": "3.55.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.52.0",
|
||||
"version": "3.55.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.52.0",
|
||||
"version": "3.55.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -75,6 +75,19 @@ message McpResourceTemplate {
|
||||
optional string description = 4;
|
||||
}
|
||||
|
||||
message McpPromptArgument {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional bool required = 3;
|
||||
}
|
||||
|
||||
message McpPrompt {
|
||||
string name = 1;
|
||||
optional string title = 2;
|
||||
optional string description = 3;
|
||||
repeated McpPromptArgument arguments = 4;
|
||||
}
|
||||
|
||||
enum McpServerStatus {
|
||||
// Protobuf enums (in proto3) must have a zero value defined, which serves as the default if the field isn't explicitly set.
|
||||
// To align with the required nature of the TypeScript type and avoid an unnecessary UNSPECIFIED state, we map one of the existing statuses to this zero value.
|
||||
@@ -95,6 +108,7 @@ message McpServer {
|
||||
optional int32 timeout = 9;
|
||||
optional bool oauth_required = 10;
|
||||
optional string oauth_auth_status = 11;
|
||||
repeated McpPrompt prompts = 12;
|
||||
}
|
||||
|
||||
message McpServers {
|
||||
|
||||
@@ -383,7 +383,7 @@ message UpdateTaskSettingsRequest {
|
||||
|
||||
// Message for updating settings
|
||||
message UpdateSettingsRequest {
|
||||
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
|
||||
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
|
||||
|
||||
Metadata metadata = 1;
|
||||
optional ModelsApiConfiguration api_configuration = 2;
|
||||
|
||||
@@ -33,6 +33,9 @@ service EnvService {
|
||||
|
||||
// Initiates a graceful shutdown of the host bridge service.
|
||||
rpc shutdown(cline.EmptyRequest) returns (cline.Empty);
|
||||
|
||||
// Logs a debug message to the host environment's log/output console.
|
||||
rpc debugLog(cline.StringRequest) returns (cline.Empty);
|
||||
}
|
||||
|
||||
message GetHostVersionResponse {
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
|
||||
|
||||
describe("ClineEndpoint configuration", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let tempDir: string
|
||||
let originalHomedir: typeof os.homedir
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
// Create .cline directory
|
||||
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
|
||||
|
||||
// Stub os.homedir to return our temp directory
|
||||
originalHomedir = os.homedir
|
||||
sandbox
|
||||
.stub(os, "homedir")
|
||||
.returns(tempDir)
|
||||
|
||||
// Reset the singleton state using internal method
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
;(ClineEndpoint as any)._initialized = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
// Reset singleton state
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
;(ClineEndpoint as any)._initialized = false
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("valid config parsing", () => {
|
||||
it("should parse valid endpoints.json with all required fields", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://app.enterprise.com")
|
||||
config.apiBaseUrl.should.equal("https://api.enterprise.com")
|
||||
config.mcpBaseUrl.should.equal("https://mcp.enterprise.com")
|
||||
config.environment.should.equal(Environment.selfHosted)
|
||||
})
|
||||
|
||||
it("should work without endpoints.json (standard mode)", async () => {
|
||||
// No endpoints.json file exists
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.environment.should.not.equal(Environment.selfHosted)
|
||||
// Should use production defaults
|
||||
config.appBaseUrl.should.equal("https://app.cline.bot")
|
||||
config.apiBaseUrl.should.equal("https://api.cline.bot")
|
||||
})
|
||||
|
||||
it("should accept URLs with ports", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
apiBaseUrl: "http://localhost:7777",
|
||||
mcpBaseUrl: "http://localhost:8080/mcp",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("http://localhost:3000")
|
||||
config.apiBaseUrl.should.equal("http://localhost:7777")
|
||||
config.mcpBaseUrl.should.equal("http://localhost:8080/mcp")
|
||||
})
|
||||
|
||||
it("should accept URLs with paths", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "https://proxy.enterprise.com/cline/app",
|
||||
apiBaseUrl: "https://proxy.enterprise.com/cline/api",
|
||||
mcpBaseUrl: "https://proxy.enterprise.com/cline/mcp",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://proxy.enterprise.com/cline/app")
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid JSON handling", () => {
|
||||
it("should throw ClineConfigurationError for invalid JSON syntax", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{ invalid json }", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for truncated JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '{"appBaseUrl": "https://test.com"', "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for empty file", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for non-object JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '"just a string"', "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must contain a JSON object")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for array JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "[]", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
// Arrays pass the object check but fail on required fields
|
||||
error.message.should.containEql("Missing required field")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for null JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "null", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must contain a JSON object")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("missing required fields", () => {
|
||||
it("should throw ClineConfigurationError when appBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "appBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when apiBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "apiBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when mcpBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "mcpBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when all fields are missing", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{}", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Missing required field")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is null", async () => {
|
||||
const config = {
|
||||
appBaseUrl: null,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "appBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is empty string", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("cannot be empty")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is whitespace only", async () => {
|
||||
const config = {
|
||||
appBaseUrl: " ",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("cannot be empty")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is non-string", async () => {
|
||||
const config = {
|
||||
appBaseUrl: 12345,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a string")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid URL detection", () => {
|
||||
it("should throw ClineConfigurationError for invalid URL format", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "not-a-valid-url",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for URL without protocol", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for malformed URL", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should include the invalid URL value in error message", async () => {
|
||||
const invalidUrl = "definitely-not-a-url"
|
||||
const config = {
|
||||
appBaseUrl: invalidUrl,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql(invalidUrl)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("environment switching blocked in self-hosted mode", () => {
|
||||
it("should throw error when trying to change environment in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
// Verify we're in self-hosted mode
|
||||
ClineEndpoint.config.environment.should.equal(Environment.selfHosted)
|
||||
|
||||
// Try to change environment - should throw
|
||||
try {
|
||||
ClineEnv.setEnvironment("staging")
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("Cannot change environment in on-premise mode")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw error for all environment values in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const environments = ["staging", "local", "production", "anything"]
|
||||
for (const env of environments) {
|
||||
try {
|
||||
ClineEnv.setEnvironment(env)
|
||||
throw new Error(`Should have thrown for environment: ${env}`)
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("Cannot change environment in on-premise mode")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should allow environment switching in standard mode", async () => {
|
||||
// No endpoints.json file - standard mode
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
// Verify we're NOT in self-hosted mode
|
||||
ClineEndpoint.config.environment.should.not.equal(Environment.selfHosted)
|
||||
|
||||
// Should be able to change environment
|
||||
ClineEnv.setEnvironment("staging")
|
||||
ClineEnv.getEnvironment().environment.should.equal("staging")
|
||||
|
||||
ClineEnv.setEnvironment("local")
|
||||
ClineEnv.getEnvironment().environment.should.equal("local")
|
||||
|
||||
ClineEnv.setEnvironment("production")
|
||||
ClineEnv.getEnvironment().environment.should.equal("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("self-hosted mode behavior", () => {
|
||||
it("should report selfHosted environment in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const envConfig = ClineEndpoint.config
|
||||
envConfig.environment.should.equal(Environment.selfHosted)
|
||||
})
|
||||
|
||||
it("should use custom endpoints from file", async () => {
|
||||
const customConfig = {
|
||||
appBaseUrl: "https://custom-app.internal",
|
||||
apiBaseUrl: "https://custom-api.internal",
|
||||
mcpBaseUrl: "https://custom-mcp.internal/v1",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(customConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://custom-app.internal")
|
||||
config.apiBaseUrl.should.equal("https://custom-api.internal")
|
||||
config.mcpBaseUrl.should.equal("https://custom-mcp.internal/v1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("initialization behavior", () => {
|
||||
it("should only initialize once", async () => {
|
||||
await ClineEndpoint.initialize()
|
||||
ClineEndpoint.isInitialized().should.be.true()
|
||||
|
||||
// Second initialize should be a no-op
|
||||
await ClineEndpoint.initialize()
|
||||
ClineEndpoint.isInitialized().should.be.true()
|
||||
})
|
||||
|
||||
it("should throw error when accessing config before initialization", async () => {
|
||||
// Already reset in beforeEach, so accessing should throw
|
||||
try {
|
||||
const _ = ClineEndpoint.config
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("not initialized")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSelfHosted() method", () => {
|
||||
it("should return true when not initialized (safety fallback)", async () => {
|
||||
// Reset singleton state - already done in beforeEach, not initialized
|
||||
ClineEndpoint.isInitialized().should.be.false()
|
||||
ClineEndpoint.isSelfHosted().should.be.true()
|
||||
})
|
||||
|
||||
it("should return true when in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
ClineEndpoint.isSelfHosted().should.be.true()
|
||||
})
|
||||
|
||||
it("should return false when in normal mode (no endpoints.json)", async () => {
|
||||
// No endpoints.json file exists
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
ClineEndpoint.isSelfHosted().should.be.false()
|
||||
})
|
||||
})
|
||||
})
|
||||
+15
-4
@@ -27,15 +27,24 @@ import { syncWorker } from "./shared/services/worker/sync"
|
||||
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
|
||||
import { getLatestAnnouncementId } from "./utils/announcements"
|
||||
import { arePathsEqual } from "./utils/path"
|
||||
|
||||
/**
|
||||
* Performs intialization for Cline that is common to all platforms.
|
||||
*
|
||||
* @param context
|
||||
* @returns The webview provider
|
||||
* @throws ClineConfigurationError if endpoints.json exists but is invalid
|
||||
*/
|
||||
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
|
||||
// Configure the shared Logging class to use HostProvider's output channel
|
||||
Logger.setOutput((msg: string) => HostProvider.get().logToChannel(msg))
|
||||
// Configure the shared Logging class to use HostProvider's output channels and debug logger
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
|
||||
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
|
||||
|
||||
// Initialize ClineEndpoint configuration first (reads ~/.cline/endpoints.json if present)
|
||||
// This must be done before any other code that calls ClineEnv.config()
|
||||
// Throws ClineConfigurationError if config file exists but is invalid
|
||||
const { ClineEndpoint } = await import("./config")
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
try {
|
||||
await StateManager.initialize(context)
|
||||
@@ -53,8 +62,10 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
// Set the distinct ID for logging and telemetry
|
||||
await initializeDistinctId(context)
|
||||
|
||||
// Initialize PostHog client provider
|
||||
PostHogClientProvider.getInstance()
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
if (!ClineEndpoint.isSelfHosted()) {
|
||||
PostHogClientProvider.getInstance()
|
||||
}
|
||||
|
||||
// Setup the external services
|
||||
await ErrorService.initialize()
|
||||
|
||||
+227
-13
@@ -1,22 +1,38 @@
|
||||
export enum Environment {
|
||||
production = "production",
|
||||
staging = "staging",
|
||||
local = "local",
|
||||
}
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import { Environment, type EnvironmentConfig } from "./shared/config-types"
|
||||
import { Logger } from "./shared/services/Logger"
|
||||
|
||||
export interface EnvironmentConfig {
|
||||
environment: Environment
|
||||
export { Environment, type EnvironmentConfig }
|
||||
|
||||
/**
|
||||
* Schema for the endpoints.json configuration file used in on-premise deployments.
|
||||
* All fields are required and must be valid URLs.
|
||||
*/
|
||||
interface EndpointsFileSchema {
|
||||
appBaseUrl: string
|
||||
apiBaseUrl: string
|
||||
mcpBaseUrl: string
|
||||
}
|
||||
|
||||
class ClineEndpoint {
|
||||
public static instance = new ClineEndpoint()
|
||||
public static get config() {
|
||||
return ClineEndpoint.instance.config()
|
||||
/**
|
||||
* Error thrown when the Cline configuration file exists but is invalid.
|
||||
* This error prevents Cline from starting to avoid misconfiguration in enterprise environments.
|
||||
*/
|
||||
export class ClineConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "ClineConfigurationError"
|
||||
}
|
||||
}
|
||||
|
||||
class ClineEndpoint {
|
||||
private static _instance: ClineEndpoint | null = null
|
||||
private static _initialized = false
|
||||
|
||||
// On-premise config loaded from file (null if not on-premise)
|
||||
private onPremiseConfig: EndpointsFileSchema | null = null
|
||||
private environment: Environment = Environment.production
|
||||
|
||||
private constructor() {
|
||||
@@ -24,15 +40,189 @@ class ClineEndpoint {
|
||||
const _env = process?.env?.CLINE_ENVIRONMENT_OVERRIDE || process?.env?.CLINE_ENVIRONMENT
|
||||
if (_env && Object.values(Environment).includes(_env as Environment)) {
|
||||
this.environment = _env as Environment
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the ClineEndpoint singleton.
|
||||
* Must be called before any other methods.
|
||||
* Reads the endpoints.json file if it exists and validates its schema.
|
||||
*
|
||||
* @throws ClineConfigurationError if the endpoints.json file exists but is invalid
|
||||
*/
|
||||
public static async initialize(): Promise<void> {
|
||||
if (ClineEndpoint._initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
ClineEndpoint._instance = new ClineEndpoint()
|
||||
|
||||
// Try to load on-premise config from file
|
||||
const endpointsConfig = await ClineEndpoint.loadEndpointsFile()
|
||||
if (endpointsConfig) {
|
||||
ClineEndpoint._instance.onPremiseConfig = endpointsConfig
|
||||
Logger.log("Cline running in self-hosted mode with custom endpoints")
|
||||
}
|
||||
|
||||
ClineEndpoint._initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the ClineEndpoint has been initialized.
|
||||
*/
|
||||
public static isInitialized(): boolean {
|
||||
return ClineEndpoint._initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Cline is running in self-hosted/on-premise mode.
|
||||
* @returns true if in selfHosted mode, or true if not initialized (safety fallback to prevent accidental external calls)
|
||||
*/
|
||||
public static isSelfHosted(): boolean {
|
||||
// Safety fallback: if not initialized, treat as selfHosted
|
||||
// to prevent accidental external service calls before configuration is loaded
|
||||
if (!ClineEndpoint._initialized) {
|
||||
return true
|
||||
}
|
||||
return ClineEndpoint.config.environment === Environment.selfHosted
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the singleton instance.
|
||||
* @throws Error if not initialized
|
||||
*/
|
||||
public static get instance(): ClineEndpoint {
|
||||
if (!ClineEndpoint._initialized || !ClineEndpoint._instance) {
|
||||
throw new Error("ClineEndpoint not initialized. Call ClineEndpoint.initialize() first.")
|
||||
}
|
||||
return ClineEndpoint._instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Static getter for convenient access to the current configuration.
|
||||
* @throws Error if not initialized
|
||||
*/
|
||||
public static get config(): EnvironmentConfig {
|
||||
return ClineEndpoint.instance.config()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the endpoints.json configuration file.
|
||||
* Located at ~/.cline/endpoints.json
|
||||
*/
|
||||
private static getEndpointsFilePath(): string {
|
||||
return path.join(os.homedir(), ".cline", "endpoints.json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates the endpoints.json file.
|
||||
* @returns The validated endpoints config, or null if the file doesn't exist
|
||||
* @throws ClineConfigurationError if the file exists but is invalid
|
||||
*/
|
||||
private static async loadEndpointsFile(): Promise<EndpointsFileSchema | null> {
|
||||
const filePath = ClineEndpoint.getEndpointsFilePath()
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
// File doesn't exist - not on-premise mode
|
||||
return null
|
||||
}
|
||||
|
||||
// File exists, must be valid or we fail
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath, "utf8")
|
||||
let data: unknown
|
||||
|
||||
try {
|
||||
data = JSON.parse(fileContent)
|
||||
} catch (parseError) {
|
||||
throw new ClineConfigurationError(
|
||||
`Invalid JSON in endpoints configuration file (${filePath}): ${parseError instanceof Error ? parseError.message : String(parseError)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return ClineEndpoint.validateEndpointsSchema(data, filePath)
|
||||
} catch (error) {
|
||||
if (error instanceof ClineConfigurationError) {
|
||||
throw error
|
||||
}
|
||||
throw new ClineConfigurationError(
|
||||
`Failed to read endpoints configuration file (${filePath}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the provided data matches the EndpointsFileSchema.
|
||||
* All fields must be present and be valid URLs.
|
||||
*
|
||||
* @param data The parsed JSON data to validate
|
||||
* @param filePath The path to the file (for error messages)
|
||||
* @returns The validated EndpointsFileSchema
|
||||
* @throws ClineConfigurationError if validation fails
|
||||
*/
|
||||
private static validateEndpointsSchema(data: unknown, filePath: string): EndpointsFileSchema {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
throw new ClineConfigurationError(`Endpoints configuration file (${filePath}) must contain a JSON object`)
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>
|
||||
const requiredFields = ["appBaseUrl", "apiBaseUrl", "mcpBaseUrl"] as const
|
||||
const result: Partial<EndpointsFileSchema> = {}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
const value = obj[field]
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
throw new ClineConfigurationError(
|
||||
`Missing required field "${field}" in endpoints configuration file (${filePath})`,
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
throw new ClineConfigurationError(
|
||||
`Field "${field}" in endpoints configuration file (${filePath}) must be a string`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
throw new ClineConfigurationError(
|
||||
`Field "${field}" in endpoints configuration file (${filePath}) cannot be empty`,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(value)
|
||||
} catch {
|
||||
throw new ClineConfigurationError(
|
||||
`Field "${field}" in endpoints configuration file (${filePath}) must be a valid URL. Got: "${value}"`,
|
||||
)
|
||||
}
|
||||
|
||||
result[field] = value
|
||||
}
|
||||
|
||||
return result as EndpointsFileSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current environment configuration.
|
||||
*/
|
||||
public config(): EnvironmentConfig {
|
||||
return this.getEnvironment()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current environment.
|
||||
* @throws Error if in on-premise mode (environment switching is disabled)
|
||||
*/
|
||||
public setEnvironment(env: string) {
|
||||
if (this.onPremiseConfig) {
|
||||
throw new Error("Cannot change environment in on-premise mode. Endpoints are configured via ~/.cline/endpoints.json")
|
||||
}
|
||||
|
||||
switch (env.toLowerCase()) {
|
||||
case "staging":
|
||||
this.environment = Environment.staging
|
||||
@@ -46,7 +236,22 @@ class ClineEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current environment configuration.
|
||||
* If running in on-premise mode, returns the custom endpoints.
|
||||
*/
|
||||
public getEnvironment(): EnvironmentConfig {
|
||||
// On-premise mode: use custom endpoints from file
|
||||
if (this.onPremiseConfig) {
|
||||
return {
|
||||
environment: Environment.selfHosted,
|
||||
appBaseUrl: this.onPremiseConfig.appBaseUrl,
|
||||
apiBaseUrl: this.onPremiseConfig.apiBaseUrl,
|
||||
mcpBaseUrl: this.onPremiseConfig.mcpBaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Standard mode: use built-in environment URLs
|
||||
switch (this.environment) {
|
||||
case Environment.staging:
|
||||
return {
|
||||
@@ -78,5 +283,14 @@ class ClineEndpoint {
|
||||
* Usage:
|
||||
* - ClineEnv.config() to get the current config.
|
||||
* - ClineEnv.setEnvironment(Environment.local) to change the environment.
|
||||
*
|
||||
* IMPORTANT: ClineEndpoint.initialize() must be called before using ClineEnv.
|
||||
*/
|
||||
export const ClineEnv = ClineEndpoint.instance
|
||||
export const ClineEnv = {
|
||||
config: () => ClineEndpoint.config,
|
||||
setEnvironment: (env: string) => ClineEndpoint.instance.setEnvironment(env),
|
||||
getEnvironment: () => ClineEndpoint.instance.getEnvironment(),
|
||||
}
|
||||
|
||||
// Export the class for initialization
|
||||
export { ClineEndpoint }
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
import { ApiHandler } from "@/core/api"
|
||||
import { ClineHandler } from "@/core/api/providers/cline"
|
||||
import type { ApiStream } from "@/core/api/transform/stream"
|
||||
import {
|
||||
AgentActions,
|
||||
AgentContext,
|
||||
AgentIterationUpdate,
|
||||
ClineAgentConfig,
|
||||
GeneralToolResult,
|
||||
SubagentApiHandler,
|
||||
SubagentStatusEntry,
|
||||
} from "@/shared/cline/subagent"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ToolResponse } from "../task"
|
||||
import type { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { SubAgentToolDefinition, SubAgentToolResult } from "./tools"
|
||||
import { extractTagContent } from "./utils"
|
||||
|
||||
/**
|
||||
* Abstract base class for agentic loops using ClineHandler.
|
||||
* Subclasses implement domain-specific logic for context management, tool execution, and result formatting.
|
||||
*/
|
||||
export abstract class ClineAgent {
|
||||
protected readonly client: ApiHandler | SubagentApiHandler
|
||||
protected currentIteration: number = 0
|
||||
protected readonly maxIterations: number
|
||||
protected readonly prompt: string
|
||||
protected cost = 0
|
||||
protected readonly tools: Map<string, SubAgentToolDefinition> = new Map()
|
||||
protected taskConfig?: TaskConfig
|
||||
protected readonly abortSignal?: AbortSignal
|
||||
private statusHistory: SubagentStatusEntry[] = []
|
||||
private toolFormatErrors: string[] = []
|
||||
private static readonly MAX_FORMAT_ERRORS = 3
|
||||
|
||||
private static activeAgents = new Set<ClineAgent>()
|
||||
|
||||
/**
|
||||
* Returns the accumulated status history for this agent
|
||||
*/
|
||||
public getStatusHistory(): SubagentStatusEntry[] {
|
||||
return this.statusHistory
|
||||
}
|
||||
|
||||
constructor(private config: ClineAgentConfig) {
|
||||
const modelClient = new ClineHandler({ openRouterModelId: config.modelId, ...config.apiParams })
|
||||
const mainClient = config.client ?? modelClient
|
||||
this.client = !config.modelId ? modelClient : mainClient
|
||||
this.maxIterations = config.maxIterations ?? 3
|
||||
this.prompt = config.prompt
|
||||
this.abortSignal = config.abortSignal
|
||||
ClineAgent.activeAgents.add(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects and resets costs from all active agents
|
||||
*/
|
||||
static getAllAgentCosts(): number {
|
||||
let totalCost = 0
|
||||
for (const agent of ClineAgent.activeAgents) {
|
||||
totalCost += agent.cost
|
||||
agent.cost = 0
|
||||
}
|
||||
ClineAgent.activeAgents.clear()
|
||||
if (totalCost > 0) {
|
||||
Logger.debug(`Total cost across all agents: $${totalCost.toFixed(4)}`)
|
||||
}
|
||||
return totalCost
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers tools for this agent
|
||||
* @param toolDefinitions - Array of tool definitions to register
|
||||
*/
|
||||
protected registerTools(toolDefinitions: SubAgentToolDefinition[]): void {
|
||||
for (const tool of toolDefinitions) {
|
||||
this.tools.set(tool.title, tool)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the task config for this agent (required for tool execution)
|
||||
* @param taskConfig - The task configuration
|
||||
*/
|
||||
public setTaskConfig(taskConfig: TaskConfig): void {
|
||||
this.taskConfig = taskConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool calls from agent response based on registered tools.
|
||||
* Parses the response for tool tags and extracts subtag values.
|
||||
* Tracks format errors when subtags are missing and provides feedback.
|
||||
* @param response - The agent's response text
|
||||
* @returns Map of tool tag to array of extracted input values
|
||||
*/
|
||||
protected extractToolCalls(response: string): Map<string, string[]> {
|
||||
const toolCallsMap = new Map<string, string[]>()
|
||||
|
||||
for (const [toolTag, toolDef] of this.tools) {
|
||||
const toolPattern = new RegExp(`<${toolTag}>([\\s\\S]*?)</${toolTag}>`, "gs")
|
||||
const subTagPattern = new RegExp(`<${toolDef.tag}>([\\s\\S]*?)</${toolDef.tag}>`, "gs")
|
||||
const inputs: string[] = []
|
||||
|
||||
for (const toolMatch of response.matchAll(toolPattern)) {
|
||||
const toolContent = toolMatch[1]
|
||||
|
||||
// First try to extract from subtag (correct format)
|
||||
const subTagMatches = [...toolContent.matchAll(subTagPattern)]
|
||||
if (subTagMatches.length > 0) {
|
||||
for (const subTagMatch of subTagMatches) {
|
||||
const value = subTagMatch[1].trim()
|
||||
if (value) {
|
||||
inputs.push(value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Missing subtag - record the error but still try to use the content
|
||||
const value = toolContent.trim()
|
||||
if (value) {
|
||||
const errorMsg = `<${toolTag}> missing required <${toolDef.tag}> subtag. You wrote: <${toolTag}>${value.slice(0, 50)}${value.length > 50 ? "..." : ""}</${toolTag}>. Correct format: <${toolTag}><${toolDef.tag}>${value.slice(0, 30)}${value.length > 30 ? "..." : ""}</${toolDef.tag}></${toolTag}>`
|
||||
this.toolFormatErrors.push(errorMsg)
|
||||
Logger.warn(`[ClineAgent] Tool format error: ${errorMsg}`)
|
||||
// Still use the content as fallback so the agent can make progress
|
||||
inputs.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length > 0) {
|
||||
toolCallsMap.set(toolTag, inputs)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCallsMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns accumulated tool format errors and whether max errors reached
|
||||
*/
|
||||
protected getToolFormatErrorFeedback(): { errors: string[]; maxErrorsReached: boolean } {
|
||||
return {
|
||||
errors: [...this.toolFormatErrors],
|
||||
maxErrorsReached: this.toolFormatErrors.length >= ClineAgent.MAX_FORMAT_ERRORS,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears tool format errors (call after providing feedback)
|
||||
*/
|
||||
protected clearToolFormatErrors(): void {
|
||||
this.toolFormatErrors = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a tool by its tag name
|
||||
* @param toolTag - The tool tag (e.g., "TOOLFILE", "TOOLSEARCH")
|
||||
* @param inputs - Array of input values for the tool
|
||||
* @returns Promise resolving to the tool execution result
|
||||
*/
|
||||
protected async executeToolByTag(toolTag: string, inputs: string[]): Promise<SubAgentToolResult> {
|
||||
const tool = this.tools.get(toolTag)
|
||||
if (!tool) {
|
||||
throw new Error(`Tool with tag "${toolTag}" not found in registered tools`)
|
||||
}
|
||||
if (!this.taskConfig) {
|
||||
throw new Error(`TaskConfig not set. Call setTaskConfig() before executing tools.`)
|
||||
}
|
||||
return await tool.execute(inputs, this.taskConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the system prompt for the agent
|
||||
* @param userInput - The user's input/query
|
||||
* @param contextPrompt - The current context prompt
|
||||
*/
|
||||
abstract buildSystemPrompt(userInput: string, contextPrompt: string): string
|
||||
|
||||
/**
|
||||
* Builds the context prompt for the current iteration
|
||||
* @param context - The current agent context
|
||||
* @param iteration - Current iteration number (0-indexed)
|
||||
* @param formatErrorFeedback - Optional feedback about tool format errors
|
||||
*/
|
||||
abstract buildContextPrompt(context: AgentContext, iteration: number, formatErrorFeedback?: string): string
|
||||
|
||||
/**
|
||||
* Generic implementation of extractActions using registered tools and config tags.
|
||||
* Extracts tool calls based on registered tools, context files, and ready-to-answer status.
|
||||
* @param response - The full response text from the agent
|
||||
*/
|
||||
protected extractActions(response: string): AgentActions {
|
||||
// Extract result content if contextTag is configured (this is the agent's answer/result text)
|
||||
const resultContent = this.config.contextTag ? extractTagContent(response, this.config.contextTag) : []
|
||||
|
||||
// Check if ready to answer if answerTag is configured
|
||||
const isReadyToAnswer = this.config.answerTag ? response.includes(`<${this.config.answerTag}>`) : false
|
||||
|
||||
// Extract tool calls based on registered tools
|
||||
const toolCallsMap = this.extractToolCalls(response)
|
||||
|
||||
// Convert tool calls map to array format
|
||||
const toolCalls: unknown[] = []
|
||||
for (const [toolTag, inputs] of toolCallsMap) {
|
||||
for (const input of inputs) {
|
||||
toolCalls.push({ toolTag, input })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
resultContent,
|
||||
isReadyToAnswer,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic tool execution that groups tool calls by tag and executes them in parallel.
|
||||
* This is the recommended implementation for most agents.
|
||||
* @param toolCallsMap - Map of tool tag to array of input values (use extractToolCalls to get this)
|
||||
* @returns Promise resolving to map of tool tag to results
|
||||
*/
|
||||
protected async executeToolsByTag(toolCallsMap: Map<string, string[]>): Promise<Map<string, unknown>> {
|
||||
const startTime = performance.now()
|
||||
const entries = Array.from(toolCallsMap.entries())
|
||||
|
||||
// Execute all tools in parallel
|
||||
const results = await Promise.all(entries.map(([toolTag, inputs]) => this.executeToolByTag(toolTag, inputs)))
|
||||
|
||||
// Build result map
|
||||
const resultsByTag = new Map(entries.map(([toolTag], i) => [toolTag, results[i]]))
|
||||
|
||||
const totalCalls = entries.reduce((sum, [, inputs]) => sum + inputs.length, 0)
|
||||
const duration = performance.now() - startTime
|
||||
await this.onIterationUpdate({
|
||||
message: `Executed ${totalCalls} tool calls in ${duration.toFixed(0)}ms`,
|
||||
})
|
||||
|
||||
return resultsByTag
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes tool calls in parallel
|
||||
* @param toolCalls - Array of tool calls to execute
|
||||
* @returns Promise resolving to array of tool results
|
||||
*/
|
||||
async executeTools(toolCalls: unknown[]): Promise<unknown[]> {
|
||||
try {
|
||||
// Group tool calls by toolTag
|
||||
const toolsByTag = new Map<string, string[]>()
|
||||
for (const toolCall of toolCalls) {
|
||||
if (typeof toolCall === "object" && toolCall !== null) {
|
||||
const { toolTag, input } = toolCall as { toolTag: string; input: string }
|
||||
const existing = toolsByTag.get(toolTag)
|
||||
if (existing) {
|
||||
existing.push(input)
|
||||
} else {
|
||||
toolsByTag.set(toolTag, [input])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute all tools in parallel
|
||||
const resultsByTag = await this.executeToolsByTag(toolsByTag)
|
||||
|
||||
// Reconstruct results in original order
|
||||
const results: unknown[] = []
|
||||
const indexByTag = new Map<string, number>()
|
||||
for (const toolCall of toolCalls) {
|
||||
if (typeof toolCall === "object" && toolCall !== null) {
|
||||
const { toolTag } = toolCall as { toolTag: string; input: string }
|
||||
const index = indexByTag.get(toolTag) ?? 0
|
||||
const toolResults = resultsByTag.get(toolTag) as unknown[]
|
||||
results.push(toolResults[index])
|
||||
indexByTag.set(toolTag, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
Logger.error(`[ClineAgent] failed with ${error.toString()}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads context files in parallel
|
||||
* @param filePaths - Array of file paths to read
|
||||
* @returns Promise resolving to a map of file path to content
|
||||
*/
|
||||
abstract readContextFiles(filePaths: string[]): Promise<Map<string, string>>
|
||||
|
||||
/**
|
||||
* Updates the context with new tool results
|
||||
* @param context - Current context
|
||||
* @param toolCalls - Tool calls that were executed
|
||||
* @param toolResults - Results from tool execution
|
||||
* @returns Whether new context was found
|
||||
*/
|
||||
abstract updateContextWithToolResults(context: AgentContext, toolCalls: unknown[], toolResults: unknown[]): boolean
|
||||
|
||||
/**
|
||||
* Updates the context with file contents
|
||||
* @param context - Current context
|
||||
* @param fileContents - Map of file path to content
|
||||
*/
|
||||
protected updateContextWithFiles(context: AgentContext, fileContents: Map<string, string>): void {
|
||||
for (const [filePath, content] of fileContents) {
|
||||
context.fileContents.set(filePath, content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the agent should continue iterating
|
||||
* @param context - Current context
|
||||
* @param iteration - Current iteration number
|
||||
* @param foundNewContext - Whether new context was found in this iteration
|
||||
* @param isReadyToAnswer - Whether the agent is ready to answer
|
||||
*/
|
||||
abstract shouldContinue(context: AgentContext, foundNewContext: boolean, isReadyToAnswer: boolean): boolean
|
||||
|
||||
/**
|
||||
* Formats the final result from the context
|
||||
* @param context - Final context state
|
||||
*/
|
||||
abstract formatResult(context: AgentContext): ToolResponse
|
||||
|
||||
/**
|
||||
* Creates the initial context for the agent
|
||||
*/
|
||||
private createInitialContext(): AgentContext {
|
||||
return {
|
||||
filePaths: new Set<string>(),
|
||||
searchResults: new Map<string, GeneralToolResult>(),
|
||||
fileContents: new Map<string, string>(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the task has been aborted/cancelled.
|
||||
* Checks both the AbortSignal (if provided) and taskState.abort as fallback.
|
||||
* @returns true if the task should stop execution
|
||||
*/
|
||||
protected isAborted(): boolean {
|
||||
// Check AbortSignal first (preferred method)
|
||||
if (this.abortSignal?.aborted) {
|
||||
return true
|
||||
}
|
||||
// Fallback to taskState.abort for backwards compatibility
|
||||
return this.taskConfig?.taskState.abort ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a streaming response and accumulates text and cost.
|
||||
* Will exit early if task is aborted.
|
||||
*/
|
||||
private async processStream(stream: ApiStream): Promise<string> {
|
||||
const parts: string[] = []
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Check for cancellation during streaming
|
||||
if (this.isAborted()) {
|
||||
break
|
||||
}
|
||||
|
||||
if (msg.type === "text") {
|
||||
parts.push(msg.text)
|
||||
}
|
||||
|
||||
if (msg.type === "usage" && msg.totalCost) {
|
||||
this.cost += msg.totalCost
|
||||
await this.onIterationUpdate({
|
||||
cost: msg.totalCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the agentic loop
|
||||
* @param userInput - The user's input/query
|
||||
* @returns Promise resolving to the final result
|
||||
*/
|
||||
public async execute(userInput: string): Promise<ToolResponse> {
|
||||
const startTime = performance.now()
|
||||
const context = this.createInitialContext()
|
||||
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
// Check for cancellation at the start of each iteration
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("[ClineAgent] execution aborted before iteration " + (iteration + 1))
|
||||
break
|
||||
}
|
||||
|
||||
// Check if max format errors reached
|
||||
const { errors: formatErrors, maxErrorsReached } = this.getToolFormatErrorFeedback()
|
||||
if (maxErrorsReached) {
|
||||
Logger.error(`[ClineAgent] Max tool format errors (${ClineAgent.MAX_FORMAT_ERRORS}) reached, ending loop`)
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
this.currentIteration = iteration
|
||||
|
||||
// Build format error feedback if any
|
||||
let formatErrorFeedback: string | undefined
|
||||
if (formatErrors.length > 0) {
|
||||
formatErrorFeedback = `## ⚠️ TOOL FORMAT ERRORS (${formatErrors.length}/${ClineAgent.MAX_FORMAT_ERRORS} max)\nYour previous tool calls had incorrect format. Fix these issues:\n${formatErrors.map((e, i) => `${i + 1}. ${e}`).join("\n")}\n\nREMEMBER: Always use <TOOL><subtag>value</subtag></TOOL> format!`
|
||||
|
||||
// Add error entries to status history for UI display
|
||||
for (const error of formatErrors) {
|
||||
this.statusHistory.push({
|
||||
iteration: this.currentIteration + 1,
|
||||
maxIterations: this.maxIterations,
|
||||
timestamp: Date.now(),
|
||||
status: `Format error: ${error.slice(0, 100)}${error.length > 100 ? "..." : ""}`,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
this.clearToolFormatErrors()
|
||||
}
|
||||
|
||||
// Build context prompt and system prompt
|
||||
const contextPrompt = this.buildContextPrompt(context, iteration, formatErrorFeedback)
|
||||
const systemPrompt = this.buildSystemPrompt(userInput, contextPrompt)
|
||||
|
||||
// Create messages
|
||||
const messages: ClineStorageMessage[] = this.config.messages
|
||||
? [...(this.config.messages as ClineStorageMessage[]), { role: "user", content: userInput }]
|
||||
: [{ role: "user", content: userInput }]
|
||||
|
||||
// Stream the LLM response
|
||||
const stream = this.client.createMessage(systemPrompt, messages) as ApiStream
|
||||
const fullResponse = await this.processStream(stream)
|
||||
|
||||
// Check for cancellation after streaming completes
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("[ClineAgent] execution aborted after streaming")
|
||||
break
|
||||
}
|
||||
|
||||
Logger.debug(`[ClineAgent] ${this.config.callId} Iteration ${this.currentIteration} response:`, fullResponse)
|
||||
|
||||
// Extract actions from response
|
||||
const actions = this.extractActions(fullResponse)
|
||||
|
||||
// Send iteration update
|
||||
await this.onIterationUpdate({
|
||||
actions,
|
||||
context,
|
||||
})
|
||||
|
||||
// If ready to answer and has context files, read them first
|
||||
if (actions.isReadyToAnswer && actions.resultContent.length > 0) {
|
||||
Logger.debug(
|
||||
`[ClineAgent] Reading ${actions.resultContent.length} files: ${actions.resultContent.join(", ")}`,
|
||||
)
|
||||
// Add context files to filePaths so they're available in formatResult
|
||||
for (const file of actions.resultContent) {
|
||||
context.filePaths.add(file)
|
||||
}
|
||||
const fileContents = await this.readContextFiles(actions.resultContent)
|
||||
this.updateContextWithFiles(context, fileContents)
|
||||
break
|
||||
}
|
||||
|
||||
// If ready to answer without context files, break immediately
|
||||
if (actions.isReadyToAnswer) {
|
||||
Logger.log("Agent determined it has enough context to answer.")
|
||||
break
|
||||
}
|
||||
|
||||
// If no tool calls, end the loop
|
||||
if (actions.toolCalls.length === 0) {
|
||||
Logger.log("No tool calls generated, ending loop.")
|
||||
break
|
||||
}
|
||||
|
||||
// Check for cancellation before tool execution
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("Agent execution aborted before tool execution")
|
||||
break
|
||||
}
|
||||
|
||||
// Execute tools in parallel
|
||||
const toolResults = await this.executeTools(actions.toolCalls)
|
||||
|
||||
// Check for cancellation after tool execution
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("Agent execution aborted after tool execution")
|
||||
break
|
||||
}
|
||||
|
||||
// Update context with tool results
|
||||
const foundNewContext = this.updateContextWithToolResults(context, actions.toolCalls, toolResults)
|
||||
|
||||
// Check if we should continue
|
||||
if (!this.shouldContinue(context, foundNewContext, false)) {
|
||||
Logger.log("Agent determined it should stop iterating.")
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("[ClineAgent] Error during agent iteration: ", error as Error)
|
||||
}
|
||||
}
|
||||
const duration = performance.now() - startTime
|
||||
Logger.debug("Agent completed in " + duration)
|
||||
|
||||
// Format and return final result
|
||||
return this.formatResult(context)
|
||||
}
|
||||
|
||||
private async onIterationUpdate(update: AgentIterationUpdate) {
|
||||
// Create status entries based on the update
|
||||
const entries = this.createStatusEntries(update)
|
||||
this.statusHistory.push(...entries)
|
||||
|
||||
const partialMessage: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content: JSON.stringify(this.statusHistory),
|
||||
regex: undefined,
|
||||
filePattern: this.prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
// Try to replace existing message content by call id
|
||||
const replaced = await this.taskConfig?.callbacks.replaceMessageContentByUid(
|
||||
this.config.callId,
|
||||
JSON.stringify(partialMessage),
|
||||
!update.actions?.isReadyToAnswer,
|
||||
)
|
||||
// Fall back to creating a new partial message if ts not found
|
||||
if (!replaced) {
|
||||
await this.taskConfig?.callbacks.say(
|
||||
"tool",
|
||||
JSON.stringify(partialMessage),
|
||||
undefined,
|
||||
undefined,
|
||||
update.actions?.isReadyToAnswer,
|
||||
this.config.callId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates status entries from an AgentIterationUpdate
|
||||
*/
|
||||
private createStatusEntries(update: AgentIterationUpdate): SubagentStatusEntry[] {
|
||||
const entries: SubagentStatusEntry[] = []
|
||||
const baseEntry = {
|
||||
iteration: this.currentIteration + 1,
|
||||
maxIterations: this.maxIterations,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
if (update.message) {
|
||||
entries.push({
|
||||
...baseEntry,
|
||||
status: update.message,
|
||||
type: "message",
|
||||
})
|
||||
}
|
||||
|
||||
if (update.cost !== undefined) {
|
||||
entries.push({
|
||||
...baseEntry,
|
||||
status: `Cost: $${update.cost.toFixed(4)}`,
|
||||
type: "cost",
|
||||
})
|
||||
}
|
||||
|
||||
if (update.actions) {
|
||||
const contextFileCount = update.actions.resultContent.length
|
||||
|
||||
if (update.actions.isReadyToAnswer) {
|
||||
let status = "Ready to answer"
|
||||
if (contextFileCount > 0) {
|
||||
status += ` with ${contextFileCount} file${contextFileCount > 1 ? "s" : ""}`
|
||||
}
|
||||
entries.push({
|
||||
...baseEntry,
|
||||
status,
|
||||
type: "ready",
|
||||
})
|
||||
} else if (update.actions.toolCalls.length > 0) {
|
||||
// Create individual entries for each tool call type
|
||||
const toolEntries = this.createToolCallEntries(update.actions.toolCalls, baseEntry)
|
||||
entries.push(...toolEntries)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates status entries for tool calls, grouped by type
|
||||
*/
|
||||
private createToolCallEntries(
|
||||
toolCalls: unknown[],
|
||||
baseEntry: { iteration: number; maxIterations: number; timestamp: number },
|
||||
): SubagentStatusEntry[] {
|
||||
const MAX_INPUT_LENGTH = 50
|
||||
const truncate = (str: string, maxLen: number) => (str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str)
|
||||
|
||||
const entries: SubagentStatusEntry[] = []
|
||||
|
||||
// Group tool calls by type
|
||||
const searches: string[] = []
|
||||
const files: string[] = []
|
||||
const commands: string[] = []
|
||||
const webFetches: string[] = []
|
||||
|
||||
for (const call of toolCalls) {
|
||||
if (typeof call === "object" && call !== null) {
|
||||
const { toolTag, input } = call as { toolTag: string; input: string }
|
||||
switch (toolTag) {
|
||||
case "TOOLSEARCH":
|
||||
searches.push(input)
|
||||
break
|
||||
case "TOOLFILE":
|
||||
files.push(input)
|
||||
break
|
||||
case "TOOLBASH":
|
||||
commands.push(input)
|
||||
break
|
||||
case "TOOLWEBFETCH":
|
||||
webFetches.push(input)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searches.length > 0) {
|
||||
const firstQuery = truncate(searches[0], MAX_INPUT_LENGTH)
|
||||
const status =
|
||||
searches.length === 1 ? `Searching: "${firstQuery}"` : `Searching: "${firstQuery}" +${searches.length - 1} more`
|
||||
entries.push({ ...baseEntry, status, type: "searching" })
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const fileName = files[0].split("/").pop() || files[0]
|
||||
const status = files.length === 1 ? `Reading: ${fileName}` : `Reading: ${fileName} +${files.length - 1} more`
|
||||
entries.push({ ...baseEntry, status, type: "reading" })
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
const firstCmd = truncate(commands[0], MAX_INPUT_LENGTH)
|
||||
const status = commands.length === 1 ? `Running: ${firstCmd}` : `Running: ${firstCmd} +${commands.length - 1} more`
|
||||
entries.push({ ...baseEntry, status, type: "running" })
|
||||
}
|
||||
|
||||
if (webFetches.length > 0) {
|
||||
let status: string
|
||||
try {
|
||||
const parsed = JSON.parse(webFetches[0])
|
||||
const url = new URL(parsed.url).hostname
|
||||
status = webFetches.length === 1 ? `Fetching: ${url}` : `Fetching: ${url} +${webFetches.length - 1} more`
|
||||
} catch {
|
||||
status = `Fetching ${webFetches.length} URL${webFetches.length > 1 ? "s" : ""}`
|
||||
}
|
||||
entries.push({ ...baseEntry, status, type: "fetching" })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { ApiHandler } from "@/core/api"
|
||||
import { AgentContext, GeneralToolResult } from "@/shared/cline/subagent"
|
||||
import { ToolResponse } from "../task"
|
||||
import type { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { ClineAgent } from "./ClineAgent"
|
||||
import { TASK_AGENT_TOOLS } from "./tools"
|
||||
import { buildToolsPlaceholder } from "./utils"
|
||||
|
||||
export const TASK_ACTIONS_TAGS = {
|
||||
ANSWER: `task_complete`,
|
||||
RESULT: `task_result`,
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskAgent extends ClineAgent to provide autonomous task execution functionality.
|
||||
* It can perform multi-step research and exploration tasks using search and bash tools,
|
||||
* returning a final result to the calling agent.
|
||||
*/
|
||||
export class Subagent extends ClineAgent {
|
||||
constructor(
|
||||
callId: string,
|
||||
prompt: string,
|
||||
taskConfig: TaskConfig,
|
||||
maxIterations: number = 30,
|
||||
systemPrompt?: string,
|
||||
client?: ApiHandler,
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
super({
|
||||
callId,
|
||||
client,
|
||||
modelId: "moonshotai/kimi-k2.5", // Not used when client is provided
|
||||
maxIterations,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
contextTag: TASK_ACTIONS_TAGS.RESULT,
|
||||
answerTag: TASK_ACTIONS_TAGS.ANSWER,
|
||||
abortSignal,
|
||||
})
|
||||
this.setTaskConfig(taskConfig)
|
||||
this.registerTools(TASK_AGENT_TOOLS)
|
||||
}
|
||||
|
||||
buildSystemPrompt(userInput: string, contextPrompt: string): string {
|
||||
return buildTaskAgentSystemPrompt(userInput, contextPrompt, TASK_ACTIONS_TAGS)
|
||||
}
|
||||
|
||||
buildContextPrompt(context: AgentContext, iteration: number, formatErrorFeedback?: string): string {
|
||||
// Include format error feedback at the top if present
|
||||
const errorSection = formatErrorFeedback ? `${formatErrorFeedback}\n\n` : ""
|
||||
|
||||
if (iteration === 0 || (context.searchResults.size === 0 && context.fileContents.size === 0)) {
|
||||
return errorSection + "No context retrieved yet. Use the available tools to gather information."
|
||||
}
|
||||
|
||||
const MAX_RESULTS_TO_SHOW = 10
|
||||
const contextParts: string[] = []
|
||||
const successfulSearches: string[] = []
|
||||
const failedSearches: { query: string; error?: string }[] = []
|
||||
const successfulCommands: string[] = []
|
||||
const failedCommands: { query: string; error?: string }[] = []
|
||||
|
||||
// Process search results
|
||||
let shownSearchResults = 0
|
||||
for (const [query, result] of context.searchResults) {
|
||||
if (result.agent === "TOOLSEARCH") {
|
||||
if (result.success) {
|
||||
successfulSearches.push(query)
|
||||
if (shownSearchResults < MAX_RESULTS_TO_SHOW) {
|
||||
contextParts.push(`### Search: "${query}"\n${result.result}`)
|
||||
shownSearchResults++
|
||||
} else {
|
||||
contextParts.push(`### Search: "${query}"\nFound results (truncated)`)
|
||||
}
|
||||
} else {
|
||||
failedSearches.push({ query, error: result.error })
|
||||
}
|
||||
} else if (result.agent === "TOOLBASH") {
|
||||
if (result.success) {
|
||||
successfulCommands.push(query)
|
||||
contextParts.push(`### Command: \`${query}\`\n\`\`\`\n${result.result}\n\`\`\``)
|
||||
} else {
|
||||
failedCommands.push({ query, error: result.error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add file contents
|
||||
for (const [filePath, content] of context.fileContents) {
|
||||
contextParts.push(`### File: ${filePath}\n\`\`\`\n${content}\n\`\`\``)
|
||||
}
|
||||
|
||||
// Build header
|
||||
const totalSearches = successfulSearches.length + failedSearches.length
|
||||
const totalFiles = context.fileContents.size
|
||||
const totalCommands = successfulCommands.length + failedCommands.length
|
||||
const parts = [`## Retrieved Context\nSearches: ${totalSearches} | Files: ${totalFiles} | Commands: ${totalCommands}\n`]
|
||||
|
||||
// Add search history
|
||||
if (successfulSearches.length > 0 || failedSearches.length > 0) {
|
||||
parts.push("\n**Previously executed searches (avoid duplicating):**")
|
||||
successfulSearches.forEach((q) => parts.push(`- "${q}" ✓`))
|
||||
failedSearches.forEach(({ query, error }) =>
|
||||
parts.push(`- "${query}" ✗ ${error ? `(error: ${error})` : "(no results)"}`),
|
||||
)
|
||||
parts.push("")
|
||||
}
|
||||
|
||||
// Add command history
|
||||
if (successfulCommands.length > 0 || failedCommands.length > 0) {
|
||||
parts.push("\n**Previously executed commands:**")
|
||||
successfulCommands.forEach((cmd) => parts.push(`- \`${cmd}\` ✓`))
|
||||
failedCommands.forEach(({ query, error }) =>
|
||||
parts.push(`- \`${query}\` ✗ ${error ? `(error: ${error})` : "(failed)"}`),
|
||||
)
|
||||
parts.push("")
|
||||
}
|
||||
|
||||
return errorSection + parts.join("\n") + contextParts.join("\n\n")
|
||||
}
|
||||
|
||||
async readContextFiles(filePaths: string[]): Promise<Map<string, string>> {
|
||||
const fileResults = (await this.executeToolByTag("TOOLFILE", filePaths)) as GeneralToolResult[]
|
||||
const fileContents = new Map<string, string>()
|
||||
|
||||
for (const fileResult of fileResults) {
|
||||
if (fileResult.success) {
|
||||
fileContents.set(fileResult.query, fileResult.result)
|
||||
}
|
||||
}
|
||||
|
||||
return fileContents
|
||||
}
|
||||
|
||||
public updateContextWithToolResults(context: AgentContext, toolCalls: unknown[], toolResults: unknown[]): boolean {
|
||||
const initialSearchCount = context.searchResults.size
|
||||
const initialFileCount = context.fileContents.size
|
||||
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const toolCall = toolCalls[i]
|
||||
const toolResult = toolResults[i]
|
||||
|
||||
if (typeof toolCall === "object" && toolCall !== null) {
|
||||
const { toolTag, input } = toolCall as { toolTag: string; input: string }
|
||||
|
||||
if (toolTag === "TOOLSEARCH" && toolResult) {
|
||||
const result = toolResult as GeneralToolResult
|
||||
// Store both successful and failed results so the agent knows what was tried
|
||||
if (result.success) {
|
||||
this.extractFilePathsFromResult(result).forEach((fp) => context.filePaths.add(fp))
|
||||
}
|
||||
context.searchResults.set(input, result)
|
||||
} else if (toolTag === "TOOLFILE" && toolResult) {
|
||||
const fileResult = toolResult as GeneralToolResult
|
||||
if (fileResult.success && !context.fileContents.has(fileResult.query)) {
|
||||
context.fileContents.set(fileResult.query, fileResult.result)
|
||||
}
|
||||
} else if (toolTag === "TOOLBASH" && toolResult) {
|
||||
const bashResult = toolResult as GeneralToolResult
|
||||
// Store bash results in searchResults map for context tracking
|
||||
context.searchResults.set(input, bashResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return context.searchResults.size > initialSearchCount || context.fileContents.size > initialFileCount
|
||||
}
|
||||
|
||||
public shouldContinue(_context: AgentContext, foundNewContext: boolean, isReadyToAnswer: boolean): boolean {
|
||||
// Continue if not ready to answer and either found new context or haven't exhausted iterations
|
||||
return !isReadyToAnswer && foundNewContext && this.currentIteration < this.maxIterations - 1
|
||||
}
|
||||
|
||||
public formatResult(context: AgentContext): ToolResponse {
|
||||
// If the agent provided a result text, return it directly
|
||||
if (context.resultText) {
|
||||
return context.resultText
|
||||
}
|
||||
|
||||
// Fallback: collect all gathered information
|
||||
const parts: string[] = []
|
||||
|
||||
// Add file contents
|
||||
if (context.fileContents.size > 0) {
|
||||
parts.push(`## Files Read (${context.fileContents.size})`)
|
||||
for (const [filePath, content] of context.fileContents) {
|
||||
parts.push(`### ${filePath}\n\`\`\`\n${content}\n\`\`\``)
|
||||
}
|
||||
}
|
||||
|
||||
// Add search results summary
|
||||
const searchResults = Array.from(context.searchResults.entries()).filter(([_, r]) => r.agent === "TOOLSEARCH")
|
||||
if (searchResults.length > 0) {
|
||||
parts.push(`## Search Results (${searchResults.length})`)
|
||||
for (const [query, result] of searchResults) {
|
||||
if (result.success) {
|
||||
parts.push(`### Search: "${query}"\n${result.result}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add bash command results
|
||||
const bashResults = Array.from(context.searchResults.entries()).filter(([_, r]) => r.agent === "TOOLBASH")
|
||||
if (bashResults.length > 0) {
|
||||
parts.push(`## Command Results (${bashResults.length})`)
|
||||
for (const [cmd, result] of bashResults) {
|
||||
if (result.success) {
|
||||
parts.push(`### \`${cmd}\`\n\`\`\`\n${result.result}\n\`\`\``)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return "Task completed but no results were gathered."
|
||||
}
|
||||
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
private extractFilePathsFromResult(result: GeneralToolResult): string[] {
|
||||
return result.result
|
||||
.split("\n")
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line && !line.startsWith("│") && !line.startsWith("Found ") && !line.startsWith("Showing "))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete system prompt for TaskAgent.
|
||||
*/
|
||||
function buildTaskAgentSystemPrompt(userInput: string, contextPrompt: string, actionsTags: typeof TASK_ACTIONS_TAGS): string {
|
||||
const toolsPlaceholder = buildToolsPlaceholder(TASK_AGENT_TOOLS)
|
||||
|
||||
return `You are an autonomous task execution agent. Your job is to complete the given task by gathering information and performing research using the available tools.
|
||||
|
||||
## YOUR TASK
|
||||
${userInput}
|
||||
|
||||
## CURRENT CONTEXT
|
||||
${contextPrompt}
|
||||
|
||||
## TOOLS
|
||||
Available tools:
|
||||
${toolsPlaceholder}
|
||||
|
||||
## RESPONSE FORMAT - CRITICAL
|
||||
Your response must contain ONLY XML tags. No explanations, no markdown, no text outside tags.
|
||||
|
||||
### Tool Format (REQUIRED):
|
||||
Each tool MUST use its outer tag AND inner subtag. The subtag contains the actual input.
|
||||
|
||||
CORRECT format examples:
|
||||
- <TOOLSEARCH><query>class DatabaseController</query></TOOLSEARCH>
|
||||
- <TOOLFILE><name>src/config.ts</name></TOOLFILE>
|
||||
- <TOOLBASH><command>ls -la</command></TOOLBASH>
|
||||
- <TOOLWEBFETCH><url>https://example.com</url></TOOLWEBFETCH>
|
||||
|
||||
WRONG (missing subtag - will NOT work):
|
||||
- <TOOLSEARCH>class DatabaseController</TOOLSEARCH>
|
||||
- <TOOLBASH>ls -la</TOOLBASH>
|
||||
|
||||
### When task is complete:
|
||||
<${actionsTags.RESULT}>Your detailed findings here</${actionsTags.RESULT}><${actionsTags.ANSWER}>
|
||||
|
||||
### When you need more information:
|
||||
Use one or more tool tags with proper subtags.
|
||||
|
||||
## RULES
|
||||
1. Work autonomously - gather all information needed to complete the task
|
||||
2. Use search to find relevant files, then read them to understand the code
|
||||
3. Use bash commands for system operations, git commands, or exploring the filesystem
|
||||
4. Be thorough - check multiple sources before concluding
|
||||
5. Your final <${actionsTags.RESULT}> should contain a complete, actionable answer
|
||||
6. Response must be ONLY tags with correct subtag structure
|
||||
7. DO NOT repeat searches or commands you've already executed
|
||||
|
||||
## IMPORTANT
|
||||
- You cannot ask questions - work with what you have
|
||||
- Your output will be returned to the calling agent, so be comprehensive
|
||||
- If you cannot complete the task, explain what you found and what's missing
|
||||
- ALWAYS use the correct tag format: <TOOL><subtag>value</subtag></TOOL>
|
||||
|
||||
Remember: Your response will be parsed by a bot. Only include the expected tags with proper subtag structure.`
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { extractFileContent } from "@/integrations/misc/extract-file-content"
|
||||
import { regexSearchFiles } from "@/services/ripgrep"
|
||||
import { GeneralToolResult } from "@/shared/cline/subagent"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { webfetch } from "../task/tools/handlers/WebFetchToolHandler"
|
||||
import { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { resolveWorkspacePath } from "../workspace"
|
||||
|
||||
export type SubAgentToolResult = GeneralToolResult[]
|
||||
|
||||
export interface SubAgentToolDefinition {
|
||||
title: string
|
||||
tag: string
|
||||
instruction: string
|
||||
placeholder: string
|
||||
examples?: string[]
|
||||
execute: (inputs: string[], taskConfig: TaskConfig) => Promise<SubAgentToolResult>
|
||||
}
|
||||
|
||||
const FILE_READ_TIMEOUT_MS = 10_000 // 10 second timeout per file read
|
||||
|
||||
const TOOLFILE: SubAgentToolDefinition = {
|
||||
title: "TOOLFILE",
|
||||
tag: "name",
|
||||
instruction:
|
||||
"To retrieve full content of a codebase file using absolute path filename-DO NOT retrieve files that may contain secrets",
|
||||
placeholder: "ABSOLUTE_PATH",
|
||||
examples: [`See the content of different files: \`<TOOLFILE><name>path/foo.ts</name><name>path/bar.ts</name></TOOLFILE>\``],
|
||||
execute: async (filePaths: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
const fileReadPromises = filePaths.map(async (filePath): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
// Create a timeout promise to prevent hanging on large/problematic files
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error(`File read timed out after ${FILE_READ_TIMEOUT_MS}ms`)),
|
||||
FILE_READ_TIMEOUT_MS,
|
||||
)
|
||||
})
|
||||
|
||||
// Create the actual file read promise
|
||||
const readPromise = (async () => {
|
||||
// Resolve the file path relative to the workspace
|
||||
const pathResult = resolveWorkspacePath(taskConfig, filePath, "SubAgent.executeParallelFileReads")
|
||||
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
|
||||
|
||||
// Read the file content
|
||||
const supportsImages = taskConfig.api.getModel().info.supportsImages ?? false
|
||||
return await extractFileContent(absolutePath, supportsImages)
|
||||
})()
|
||||
|
||||
// Race between file read and timeout
|
||||
const fileContent = await Promise.race([readPromise, timeoutPromise])
|
||||
|
||||
Logger.info(`Read file content for "${filePath}" successfully.`)
|
||||
|
||||
return {
|
||||
agent: "TOOLFILE",
|
||||
query: filePath,
|
||||
result: fileContent.text,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`File read failed for "${filePath}": ${error instanceof Error ? error.message : String(error)}`)
|
||||
return {
|
||||
agent: "TOOLFILE",
|
||||
query: filePath,
|
||||
result: "",
|
||||
error: `Error reading file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return await Promise.all(fileReadPromises)
|
||||
},
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_SEARCHES = 3
|
||||
|
||||
const TOOLSEARCH: SubAgentToolDefinition = {
|
||||
title: "TOOLSEARCH",
|
||||
tag: "query",
|
||||
instruction:
|
||||
"Perform regex pattern searches across the codebase. Supports multiple parallel searches by including multiple query tags. Searches execute with controlled concurrency for optimal performance",
|
||||
placeholder: "SEARCH_QUERY",
|
||||
examples: [
|
||||
`Single search: \`<TOOLSEARCH><query>symbol name</query></TOOLSEARCH>\``,
|
||||
`Single search with REGEX query: \`<TOOLSEARCH><query>class \w+Handler.*ApiHandler|export.*ApiHandler|ApiProvider|ModelProvider</query></TOOLSEARCH>\``,
|
||||
`Multiple parallel searches: \`<TOOLSEARCH><query>getController</query></TOOLSEARCH><TOOLSEARCH><query>AuthService</query></TOOLSEARCH>\``,
|
||||
`Search for a class definition: \`<TOOLSEARCH><query>class UserController</query></TOOLSEARCH>\``,
|
||||
],
|
||||
execute: async (queries: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
const executeSearch = async (absolutePath: string, query: string): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const workspaceResults = await regexSearchFiles(
|
||||
taskConfig.cwd,
|
||||
absolutePath,
|
||||
query,
|
||||
undefined,
|
||||
taskConfig.services.clineIgnoreController,
|
||||
false, // exclude hidden files
|
||||
)
|
||||
|
||||
const firstLine = workspaceResults.split("\n")[0]
|
||||
// Match either "Found X result(s)" or "Showing first X of X+ results"
|
||||
const resultMatch = firstLine.match(/Found (\d+) result|Showing first (\d+) of/)
|
||||
const resultCount = resultMatch ? parseInt(resultMatch[1] || resultMatch[2], 10) : 0
|
||||
Logger.info(`Search for "${query}" found ${resultCount} results in ${absolutePath}`)
|
||||
return {
|
||||
agent: "TOOLSEARCH",
|
||||
query,
|
||||
result: workspaceResults,
|
||||
success: resultCount > 0,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
Logger.error(`Search failed in ${absolutePath}: ${errorMsg}`)
|
||||
return {
|
||||
agent: "TOOLSEARCH",
|
||||
query,
|
||||
result: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute searches with concurrency limit to prevent too many ripgrep processes
|
||||
const results: GeneralToolResult[] = []
|
||||
for (let i = 0; i < queries.length; i += MAX_CONCURRENT_SEARCHES) {
|
||||
const batch = queries.slice(i, i + MAX_CONCURRENT_SEARCHES)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (query): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const searchPath = taskConfig.cwd
|
||||
return await executeSearch(searchPath, query)
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Search failed for query "${query}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
return {
|
||||
agent: "TOOLSEARCH",
|
||||
query,
|
||||
result: "",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
results.push(...batchResults)
|
||||
}
|
||||
return results
|
||||
},
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_BASH_COMMANDS = 3
|
||||
|
||||
const TOOLBASH: SubAgentToolDefinition = {
|
||||
title: "TOOLBASH",
|
||||
tag: "command",
|
||||
instruction:
|
||||
"Run an arbitrary terminal command at the root of the users project. E.g. `ls -la` for listing files, or `find` for searching latest version of the codebase files locally. The command to run in the root of the users project. Must be shell escaped.",
|
||||
placeholder: "COMMAND",
|
||||
examples: [
|
||||
`Single command: \`<TOOLBASH>ls -la</TOOLBASH>\``,
|
||||
`Multiple commands: \`<TOOLBASH>ls -la</TOOLBASH><TOOLBASH>gh pr list</TOOLBASH>\``,
|
||||
],
|
||||
execute: async (commands: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
// Execute commands with concurrency limit to prevent too many parallel processes
|
||||
const results: GeneralToolResult[] = []
|
||||
for (let i = 0; i < commands.length; i += MAX_CONCURRENT_BASH_COMMANDS) {
|
||||
const batch = commands.slice(i, i + MAX_CONCURRENT_BASH_COMMANDS)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (command): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const result = await runShellCommand(command, { cwd: taskConfig.cwd })
|
||||
return {
|
||||
agent: "TOOLBASH",
|
||||
query: command,
|
||||
result: result.stdout,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Bash command failed for "${command}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
return {
|
||||
agent: "TOOLBASH",
|
||||
query: command,
|
||||
result: "",
|
||||
error: `Command failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
results.push(...batchResults)
|
||||
}
|
||||
return results satisfies SubAgentToolResult
|
||||
},
|
||||
}
|
||||
|
||||
export async function runShellCommand(
|
||||
command: string,
|
||||
options: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
} = {},
|
||||
): Promise<{ command: string; stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null }> {
|
||||
const { cwd = process.cwd(), env = process.env } = options
|
||||
const timeout = 15_000
|
||||
const maxBuffer = 1024 * 1024 * 10
|
||||
const encoding = "utf8"
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = spawn(command, [], {
|
||||
shell: true,
|
||||
cwd,
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
let killed = false
|
||||
let sigkillTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
// Cleanup function to properly terminate the process and all its children
|
||||
const cleanup = () => {
|
||||
if (sigkillTimeout) {
|
||||
clearTimeout(sigkillTimeout)
|
||||
sigkillTimeout = null
|
||||
}
|
||||
if (childProcess && !childProcess.killed) {
|
||||
// First try SIGTERM for graceful shutdown
|
||||
childProcess.kill("SIGTERM")
|
||||
// Force kill if still running after a short delay
|
||||
sigkillTimeout = setTimeout(() => {
|
||||
if (childProcess && !childProcess.killed) {
|
||||
childProcess.kill("SIGKILL")
|
||||
}
|
||||
sigkillTimeout = null
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
killed = true
|
||||
cleanup()
|
||||
reject(new Error(`Command timed out after ${timeout}ms`))
|
||||
}, timeout)
|
||||
|
||||
let stdoutLength = 0
|
||||
let stderrLength = 0
|
||||
|
||||
childProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString(encoding)
|
||||
stdoutLength += chunk.length
|
||||
if (stdoutLength > maxBuffer) {
|
||||
killed = true
|
||||
cleanup()
|
||||
reject(new Error("stdout maxBuffer exceeded"))
|
||||
return
|
||||
}
|
||||
stdout += chunk
|
||||
})
|
||||
|
||||
childProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString(encoding)
|
||||
stderrLength += chunk.length
|
||||
if (stderrLength > maxBuffer) {
|
||||
killed = true
|
||||
cleanup()
|
||||
reject(new Error("stderr maxBuffer exceeded"))
|
||||
return
|
||||
}
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
childProcess.on("error", (error: Error) => {
|
||||
clearTimeout(timeoutId)
|
||||
if (sigkillTimeout) {
|
||||
clearTimeout(sigkillTimeout)
|
||||
}
|
||||
reject(new Error(`Failed to start process: ${error.message}`))
|
||||
})
|
||||
|
||||
childProcess.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
clearTimeout(timeoutId)
|
||||
if (sigkillTimeout) {
|
||||
clearTimeout(sigkillTimeout)
|
||||
}
|
||||
if (killed) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = { command, stdout, stderr, code, signal }
|
||||
if (code === 0) {
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(`Command failed with exit code ${code}${stderr ? `: ${stderr}` : result}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface WebFetchInput {
|
||||
url: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const TOOLWEBFETCH: SubAgentToolDefinition = {
|
||||
title: "TOOLWEBFETCH",
|
||||
tag: "request",
|
||||
instruction: `Fetches content from a specified URL and analyzes it using your prompt.
|
||||
- Takes a URL and analysis prompt as input via JSON object
|
||||
- Fetches the URL content and processes based on your prompt
|
||||
- Use this tool when you need to retrieve and analyze web content
|
||||
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead
|
||||
- The URL must be a fully-formed valid URL
|
||||
- The prompt must be at least 2 characters
|
||||
- HTTP URLs will be automatically upgraded to HTTPS
|
||||
- This tool is read-only and does not modify any files`,
|
||||
placeholder: '{"url": "URL", "prompt": "ANALYSIS_PROMPT"}',
|
||||
examples: [
|
||||
`Fetch and analyze a webpage: \`<TOOLWEBFETCH><request>{"url": "https://example.com/docs", "prompt": "Extract the API endpoints"}</request></TOOLWEBFETCH>\``,
|
||||
`Multiple fetches: \`<TOOLWEBFETCH><request>{"url": "https://api.example.com/v1", "prompt": "List all available methods"}</request></TOOLWEBFETCH><TOOLWEBFETCH><request>{"url": "https://docs.example.com", "prompt": "Find authentication instructions"}</request></TOOLWEBFETCH>\``,
|
||||
],
|
||||
execute: async (inputs: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
const fetchPromises = inputs.map(async (input): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const parsed: WebFetchInput = JSON.parse(input)
|
||||
const { url, prompt } = parsed
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new Error("Missing or invalid 'url' field")
|
||||
}
|
||||
if (!prompt || typeof prompt !== "string" || prompt.length < 2) {
|
||||
throw new Error("Missing or invalid 'prompt' field (must be at least 2 characters)")
|
||||
}
|
||||
|
||||
const result = await webfetch(url, prompt, taskConfig.ulid)
|
||||
|
||||
Logger.info(`Fetched web content for "${url}" with prompt "${prompt}" successfully.`)
|
||||
|
||||
return {
|
||||
agent: "TOOLWEBFETCH",
|
||||
query: url,
|
||||
result,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
Logger.error(`Web fetch failed for input "${input}": ${errorMsg}`)
|
||||
return {
|
||||
agent: "TOOLWEBFETCH",
|
||||
query: input,
|
||||
result: "",
|
||||
error: `Error fetching web content: ${errorMsg}`,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return await Promise.all(fetchPromises)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools configuration for SearchAgent.
|
||||
*/
|
||||
export const SEARCH_AGENT_TOOLS: SubAgentToolDefinition[] = [TOOLFILE, TOOLSEARCH]
|
||||
export const TASK_AGENT_TOOLS: SubAgentToolDefinition[] = [TOOLWEBFETCH, TOOLFILE, TOOLSEARCH, TOOLBASH]
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SubAgentToolDefinition } from "./tools"
|
||||
|
||||
/**
|
||||
* Builds the tools placeholder string for the system prompt.
|
||||
* Formats all tool definitions into a readable instruction format.
|
||||
*/
|
||||
export function buildToolsPlaceholder(tools: SubAgentToolDefinition[]): string {
|
||||
const toolsPrompts: string[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
const prompt = `\`<${tool.title}><${tool.tag}>${tool.placeholder}</${tool.tag}></${tool.title}>\`: ${tool.instruction}.`
|
||||
|
||||
if (tool.examples && tool.examples.length > 0) {
|
||||
toolsPrompts.push(`${prompt}\n\t- ${tool.examples.join("\n\t- ")}`)
|
||||
} else {
|
||||
toolsPrompts.push(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
return toolsPrompts.join("\n")
|
||||
}
|
||||
|
||||
export function extractTagContent(response: string, tag: string): string[] {
|
||||
const tagLength = tag.length
|
||||
return response.match(new RegExp(`<${tag}>(.*?)</${tag}>`, "g"))?.map((m) => m.slice(tagLength + 2, -(tagLength + 3))) || []
|
||||
}
|
||||
@@ -110,9 +110,13 @@ interface ProviderChainOptions {
|
||||
profile?: string
|
||||
}
|
||||
|
||||
// a special jp inference profile was created for sonnet 4.5
|
||||
// a special jp inference profile was created for sonnet 4.5 & haiku 4.5
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
|
||||
const JP_SUPPORTED_CRIS_MODELS = ["anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0:1m"]
|
||||
const JP_SUPPORTED_CRIS_MODELS = [
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0:1m",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
]
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
|
||||
@@ -112,10 +112,11 @@ export class CerebrasHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
try {
|
||||
const model = this.getModel()
|
||||
const stream = await client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: model.id,
|
||||
messages: cerebrasMessages,
|
||||
temperature: 0,
|
||||
temperature: model.info.temperature ?? 0,
|
||||
stream: true,
|
||||
max_tokens: CEREBRAS_DEFAULT_MAX_TOKENS,
|
||||
})
|
||||
|
||||
@@ -35,10 +35,13 @@ export class ClineHandler implements ApiHandler {
|
||||
private clineAccountService = ClineAccountService.getInstance()
|
||||
private _authService: AuthService
|
||||
private client: OpenAI | undefined
|
||||
private readonly _baseUrl = ClineEnv.config().apiBaseUrl
|
||||
lastGenerationId?: string
|
||||
private lastRequestId?: string
|
||||
|
||||
private get _baseUrl(): string {
|
||||
return ClineEnv.config().apiBaseUrl
|
||||
}
|
||||
|
||||
constructor(options: ClineHandlerOptions) {
|
||||
this.options = options
|
||||
this._authService = AuthService.getInstance()
|
||||
@@ -199,7 +202,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-ignore-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
|
||||
if (["x-ai/grok-code-fast-1", "kwaipilot/kat-coder-pro"].includes(this.getModel().id)) {
|
||||
if (["kwaipilot/kat-coder-pro"].includes(this.getModel().id)) {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { addReasoningContent } from "../transform/r1-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
@@ -81,14 +81,10 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
const isDeepseekReasoner = model.id.includes("deepseek-reasoner")
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
if (isDeepseekReasoner) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepseekReasoner
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
|
||||
@@ -313,7 +313,7 @@ export class OcaHandler implements ApiHandler {
|
||||
|
||||
// Convert ChatCompletion tools to Responses API format if provided
|
||||
const responseTools = tools
|
||||
?.filter((tool) => tool.type === "function")
|
||||
?.filter((tool) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
type: "function" as const,
|
||||
name: tool.function.name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
|
||||
import { type Config, type Message, Ollama } from "ollama"
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -7,6 +8,7 @@ import type { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
import type { ApiStream } from "../transform/stream"
|
||||
import { ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface OllamaHandlerOptions extends CommonApiHandlerOptions {
|
||||
ollamaBaseUrl?: string
|
||||
@@ -51,7 +53,7 @@ export class OllamaHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
@withRetry({ retryAllErrors: true })
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
@@ -70,20 +72,44 @@ export class OllamaHandler implements ApiHandler {
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum),
|
||||
},
|
||||
tools: tools as any,
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
// Race the API request against the timeout
|
||||
const stream = (await Promise.race([apiPromise, timeoutPromise])) as Awaited<typeof apiPromise>
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.message.content,
|
||||
}
|
||||
Logger.debug("[OllamaHandler] Message Chunk" + JSON.stringify(chunk))
|
||||
|
||||
const delta = chunk.message
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
Logger.debug(`[OllamaHandler] Tool Calls Detected: ${JSON.stringify(delta.tool_calls)}`)
|
||||
yield* toolCallProcessor.processToolCallDeltas(
|
||||
delta.tool_calls?.map((tc, inx) => ({
|
||||
index: inx,
|
||||
id: `ollama-tool-${inx}`,
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments:
|
||||
typeof tc.function.arguments === "string"
|
||||
? tc.function.arguments
|
||||
: JSON.stringify(tc.function.arguments),
|
||||
},
|
||||
type: "function",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof delta.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
// Handle token usage if available
|
||||
if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
|
||||
yield {
|
||||
|
||||
@@ -157,7 +157,7 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
// Pass through strict value from tool (MCP/custom tools have strict: false, built-in tools default to true)
|
||||
if (tools && tools.length > 0) {
|
||||
body.tools = tools
|
||||
.filter((tool: any) => tool.type === "function")
|
||||
.filter((tool: any) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
|
||||
@@ -159,7 +159,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
|
||||
// Convert ChatCompletion tools to Responses API format if provided
|
||||
const responseTools = tools
|
||||
?.filter((tool) => tool.type === "function")
|
||||
?.filter((tool) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
type: "function" as const,
|
||||
name: tool.function.name,
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
|
||||
|
||||
/**
|
||||
* DeepSeek Reasoner message format with reasoning_content support.
|
||||
*/
|
||||
export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
|
||||
reasoning_content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
|
||||
* Per DeepSeek API: reasoning_content should be passed back during tool calling in the same turn,
|
||||
* and omitted when starting a new turn.
|
||||
*/
|
||||
export function addReasoningContent(
|
||||
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
originalMessages: ClineStorageMessage[],
|
||||
): DeepSeekReasonerMessage[] {
|
||||
// Find last user message index (start of current turn)
|
||||
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
|
||||
// so reasoning_content will be added to all assistant messages. This is intentional.
|
||||
let lastUserIndex = -1
|
||||
for (let i = openAiMessages.length - 1; i >= 0; i--) {
|
||||
if (openAiMessages[i].role === "user") {
|
||||
lastUserIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract thinking content from original messages, keyed by assistant index
|
||||
const thinkingByIndex = new Map<number, string>()
|
||||
let assistantIdx = 0
|
||||
for (const msg of originalMessages) {
|
||||
if (msg.role === "assistant") {
|
||||
if (Array.isArray(msg.content)) {
|
||||
const thinking = msg.content
|
||||
.filter((p): p is ClineAssistantThinkingBlock => p.type === "thinking")
|
||||
.map((p) => p.thinking)
|
||||
.join("\n")
|
||||
if (thinking) {
|
||||
thinkingByIndex.set(assistantIdx, thinking)
|
||||
}
|
||||
}
|
||||
assistantIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Add reasoning_content only to assistant messages in current turn
|
||||
let aiIdx = 0
|
||||
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
|
||||
if (msg.role === "assistant") {
|
||||
const thinking = thinkingByIndex.get(aiIdx++)
|
||||
if (thinking && i >= lastUserIndex) {
|
||||
return { ...msg, reasoning_content: thinking }
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Anthropic messages to OpenAI format and merges consecutive messages with the same role.
|
||||
|
||||
@@ -25,7 +25,7 @@ async function handleInstallWithCline(
|
||||
await controller.postStateToWebview()
|
||||
await controller.initTask(installTask)
|
||||
|
||||
HostProvider.get().logToChannel(`Started task to install ${dependencyName}`)
|
||||
Logger.log(`[handleInstallWithCline] Started task to install ${dependencyName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,14 +4,12 @@ import * as pathUtils from "@utils/path"
|
||||
import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as sinon from "sinon"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ifFileExistsRelativePath } from "../ifFileExistsRelativePath"
|
||||
|
||||
describe("ifFileExistsRelativePath", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockController: Controller
|
||||
let getWorkspacePathStub: sinon.SinonStub
|
||||
let consoleErrorStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
@@ -21,9 +19,6 @@ describe("ifFileExistsRelativePath", () => {
|
||||
|
||||
// Stub getWorkspacePath utility
|
||||
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
|
||||
|
||||
// Stub Logger.error to prevent test output pollution
|
||||
consoleErrorStub = sandbox.stub(Logger, "error")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -44,12 +39,11 @@ describe("ifFileExistsRelativePath", () => {
|
||||
expect(typeof result.value).to.equal("boolean")
|
||||
})
|
||||
|
||||
it("should return false and log error when no workspace path is available", async () => {
|
||||
it("should return false when no workspace path is available", async () => {
|
||||
const noWorkspaceScenarios = [null, undefined]
|
||||
|
||||
for (const workspaceValue of noWorkspaceScenarios) {
|
||||
getWorkspacePathStub.resolves(workspaceValue)
|
||||
consoleErrorStub.resetHistory()
|
||||
|
||||
const request = StringRequest.create({
|
||||
value: "src/test.ts",
|
||||
@@ -58,7 +52,6 @@ describe("ifFileExistsRelativePath", () => {
|
||||
const result = await ifFileExistsRelativePath(mockController, request)
|
||||
|
||||
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
|
||||
expect(consoleErrorStub.called).to.be.true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { workspaceResolver } from "@core/workspace"
|
||||
import { BooleanResponse, StringRequest } from "@shared/proto/cline/common"
|
||||
import { getWorkspacePath } from "@utils/path"
|
||||
import * as fs from "fs"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
@@ -16,7 +15,6 @@ export async function ifFileExistsRelativePath(_controller: Controller, request:
|
||||
|
||||
if (!workspacePath) {
|
||||
// If no workspace is open, return false
|
||||
Logger.error("Error in ifFileExistsRelativePath: No workspace path available") // TODO
|
||||
return BooleanResponse.create({ value: false })
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ export class GrpcRequestRegistry {
|
||||
timestamp: new Date(),
|
||||
responseStream,
|
||||
})
|
||||
Logger.log(`[DEBUG] Registered request: ${requestId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,9 +69,8 @@ export class GrpcRequestRegistry {
|
||||
}
|
||||
try {
|
||||
requestInfo.cleanup()
|
||||
Logger.debug(`Cleaned up request: ${requestId}`)
|
||||
} catch (error) {
|
||||
Logger.debug(`Error cleaning up request ${requestId}:`, error)
|
||||
Logger.error(`Error cleaning up request ${requestId}:`, error)
|
||||
}
|
||||
this.activeRequests.delete(requestId)
|
||||
return true
|
||||
|
||||
@@ -119,7 +119,6 @@ export class Controller {
|
||||
|
||||
constructor(readonly context: vscode.ExtensionContext) {
|
||||
PromptRegistry.getInstance() // Ensure prompts and tools are registered
|
||||
HostProvider.get().logToChannel("ClineProvider instantiated")
|
||||
this.stateManager = StateManager.get()
|
||||
StateManager.get().registerCallbacks({
|
||||
onPersistenceError: async ({ error }: PersistenceErrorEvent) => {
|
||||
@@ -154,6 +153,8 @@ export class Controller {
|
||||
|
||||
// Check CLI installation status once on startup
|
||||
checkCliInstallation(this)
|
||||
|
||||
Logger.log("[Controller] ClineProvider instantiated")
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -848,6 +849,7 @@ export class Controller {
|
||||
const lastDismissedInfoBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0
|
||||
const lastDismissedModelBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedModelBannerVersion") || 0
|
||||
const lastDismissedCliBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0
|
||||
const dismissedBanners = this.stateManager.getGlobalStateKey("dismissedBanners")
|
||||
const subagentsEnabled = this.stateManager.getGlobalSettingsKey("subagentsEnabled")
|
||||
const skillsEnabled = this.stateManager.getGlobalSettingsKey("skillsEnabled")
|
||||
|
||||
@@ -859,7 +861,7 @@ export class Controller {
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
const clineMessages = [...(this.task?.messageStateHandler.getClineMessages() || [])]
|
||||
const checkpointManagerErrorMessage = this.task?.taskState.checkpointManagerErrorMessage
|
||||
|
||||
const processedTaskHistory = (taskHistory || [])
|
||||
@@ -872,7 +874,8 @@ export class Controller {
|
||||
const platform = process.platform as Platform
|
||||
const distinctId = getDistinctId()
|
||||
const version = ExtensionRegistryInfo.version
|
||||
const environment = ClineEnv.config().environment
|
||||
const clineConfig = ClineEnv.config()
|
||||
const environment = clineConfig.environment
|
||||
const banners = await this.getBanners()
|
||||
|
||||
// Check OpenAI Codex authentication status
|
||||
@@ -961,6 +964,7 @@ export class Controller {
|
||||
lastDismissedModelBannerVersion,
|
||||
remoteConfigSettings: this.stateManager.getRemoteConfigSettings(),
|
||||
lastDismissedCliBannerVersion,
|
||||
dismissedBanners,
|
||||
subagentsEnabled,
|
||||
nativeToolCallSetting: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
|
||||
enableParallelToolCalling: this.stateManager.getGlobalSettingsKey("enableParallelToolCalling"),
|
||||
|
||||
@@ -242,7 +242,7 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
|
||||
Logger.error("Invalid response from OpenRouter API")
|
||||
}
|
||||
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
|
||||
Logger.log("OpenRouter models fetched and saved", JSON.stringify(models).slice(0, 300))
|
||||
Logger.log("OpenRouter models fetched and saved")
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching OpenRouter models:", error)
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ export async function refreshVercelAiGatewayModels(_controller: Controller): Pro
|
||||
}
|
||||
|
||||
await fs.writeFile(vercelAiGatewayModelsFilePath, JSON.stringify(models))
|
||||
Logger.log("Vercel AI Gateway models fetched and saved", JSON.stringify(models).slice(0, 300))
|
||||
Logger.log("Vercel AI Gateway models fetched and saved")
|
||||
} else {
|
||||
Logger.error("Invalid response from Vercel AI Gateway API")
|
||||
}
|
||||
|
||||
@@ -20,15 +20,12 @@ export async function subscribeToOpenRouterModels(
|
||||
responseStream: StreamingResponseHandler<OpenRouterCompatibleModelInfo>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
Logger.log("[DEBUG] set up OpenRouter models subscription")
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
activeOpenRouterModelsSubscriptions.add(responseStream)
|
||||
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
activeOpenRouterModelsSubscriptions.delete(responseStream)
|
||||
Logger.log("[DEBUG] Cleaned up OpenRouter models subscription")
|
||||
}
|
||||
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Boolean } from "@shared/proto/cline/common"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineCliInstalled } from "@/utils/cli-detector"
|
||||
import { Controller } from ".."
|
||||
|
||||
@@ -12,8 +11,7 @@ export async function checkCliInstallation(_controller: Controller): Promise<Boo
|
||||
try {
|
||||
const isInstalled = await isClineCliInstalled()
|
||||
return Boolean.create({ value: isInstalled })
|
||||
} catch (error) {
|
||||
Logger.error("Failed to check CLI installation:", error)
|
||||
} catch {
|
||||
return Boolean.create({ value: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export async function subscribeToState(
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
activeStateSubscriptions.delete(responseStream)
|
||||
//Logger.log(`[DEBUG] Cleaned up state subscription`)
|
||||
}
|
||||
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
@@ -39,8 +38,6 @@ export async function subscribeToState(
|
||||
const initialState = await controller.getStateToPostToWebview()
|
||||
const initialStateJson = JSON.stringify(initialState)
|
||||
|
||||
//Logger.log(`[DEBUG] set up state subscription`)
|
||||
|
||||
try {
|
||||
await responseStream(
|
||||
{
|
||||
@@ -69,7 +66,6 @@ export async function sendStateUpdate(state: ExtensionState): Promise<void> {
|
||||
},
|
||||
false, // Not the last message
|
||||
)
|
||||
//Logger.log(`[DEBUG] sending followup state`, stateJson.length, "chars")
|
||||
} catch (error) {
|
||||
Logger.error("Error sending state update:", error)
|
||||
// Remove the subscription if there was an error
|
||||
|
||||
@@ -19,8 +19,6 @@ export async function subscribeToAccountButtonClicked(
|
||||
responseStream: StreamingResponseHandler<Empty>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
Logger.log(`[DEBUG] set up accountButtonClicked subscription`)
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
activeAccountButtonClickedSubscriptions.add(responseStream)
|
||||
|
||||
|
||||
@@ -19,15 +19,12 @@ export async function subscribeToAddToInput(
|
||||
responseStream: StreamingResponseHandler<ProtoString>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
Logger.log("[DEBUG] set up addToInput subscription")
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
activeAddToInputSubscriptions.add(responseStream)
|
||||
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
activeAddToInputSubscriptions.delete(responseStream)
|
||||
Logger.log("[DEBUG] Cleaned up addToInput subscription")
|
||||
}
|
||||
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
@@ -51,7 +48,6 @@ export async function sendAddToInputEvent(text: string): Promise<void> {
|
||||
event,
|
||||
false, // Not the last message
|
||||
)
|
||||
Logger.log("[DEBUG] sending addToInput event", text.length, "chars")
|
||||
} catch (error) {
|
||||
Logger.error("Error sending addToInput event:", error)
|
||||
// Remove the subscription if there was an error
|
||||
|
||||
@@ -19,8 +19,6 @@ export async function subscribeToChatButtonClicked(
|
||||
responseStream: StreamingResponseHandler<Empty>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
Logger.log(`[DEBUG] set up chatButtonClicked subscription`)
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
activeChatButtonClickedSubscriptions.add(responseStream)
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ export async function subscribeToMcpButtonClicked(
|
||||
responseStream: StreamingResponseHandler<Empty>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
Logger.log(`[DEBUG] set up mcpButtonClicked subscription`)
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
activeMcpButtonClickedSubscriptions.add(responseStream)
|
||||
|
||||
|
||||
+3
-1
@@ -464,12 +464,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -430,12 +430,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -392,12 +392,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -495,12 +495,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -461,12 +461,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -419,12 +419,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -464,12 +464,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -430,12 +430,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -392,12 +392,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -464,12 +464,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -430,12 +430,14 @@ Example:
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
+3
-1
@@ -392,12 +392,14 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
## test-server (`test`)
|
||||
|
||||
### Available Tools
|
||||
|
||||
@@ -22,12 +22,14 @@ export function hasEnabledMcpServers(context: SystemPromptContext): boolean {
|
||||
|
||||
const MCP_TEMPLATE_TEXT = `MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools, resources, and prompts to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the \`use_mcp_tool\` tool, and access the server's resources via the \`access_mcp_resource\` tool.
|
||||
|
||||
Servers may also provide prompts - predefined templates that can be invoked by users to generate contextual messages.
|
||||
|
||||
{{MCP_SERVERS_LIST}}`
|
||||
|
||||
export async function getMcp(variant: PromptVariant, context: SystemPromptContext): Promise<string | undefined> {
|
||||
@@ -71,6 +73,18 @@ function formatMcpServersList(servers: McpServer[]): string {
|
||||
?.map((resource) => `- ${resource.uri} (${resource.name}): ${resource.description}`)
|
||||
.join("\n")
|
||||
|
||||
const prompts = server.prompts
|
||||
?.map((prompt) => {
|
||||
const argsStr = prompt.arguments?.length
|
||||
? `\n Arguments: ${prompt.arguments
|
||||
.map((arg) => `${arg.name}${arg.required ? " (required)" : ""}${arg.description ? `: ${arg.description}` : ""}`)
|
||||
.join(", ")}`
|
||||
: ""
|
||||
const title = prompt.title ? ` (${prompt.title})` : ""
|
||||
return `- ${prompt.name}${title}: ${prompt.description || "No description"}${argsStr}`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
const config = JSON.parse(server.config)
|
||||
|
||||
return (
|
||||
@@ -80,7 +94,8 @@ function formatMcpServersList(servers: McpServer[]): string {
|
||||
: "") +
|
||||
(tools ? `\n\n### Available Tools\n${tools}` : "") +
|
||||
(templates ? `\n\n### Resource Templates\n${templates}` : "") +
|
||||
(resources ? `\n\n### Direct Resources\n${resources}` : "")
|
||||
(resources ? `\n\n### Direct Resources\n${resources}` : "") +
|
||||
(prompts ? `\n\n### Available Prompts\n${prompts}` : "")
|
||||
)
|
||||
})
|
||||
.join("\n\n")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { isGPT5ModelFamily } from "@/utils/model-utils"
|
||||
import type { ClineToolSpec } from "../spec"
|
||||
import { TASK_PROGRESS_PARAMETER } from "../types"
|
||||
|
||||
@@ -80,7 +81,7 @@ const NATIVE_GPT_5: ClineToolSpec = {
|
||||
id: ClineDefaultTool.APPLY_PATCH,
|
||||
name: "apply_patch",
|
||||
description: APPLY_PATCH_TOOL_DESC,
|
||||
contextRequirements: (context) => context.providerInfo.model.id.includes("gpt-5"),
|
||||
contextRequirements: (context) => isGPT5ModelFamily(context.providerInfo.model.id),
|
||||
parameters: [
|
||||
{
|
||||
name: "input",
|
||||
|
||||
@@ -77,12 +77,12 @@ const NATIVE_NEXT_GEN: ClineToolSpec = {
|
||||
id,
|
||||
name: "attempt_completion",
|
||||
description:
|
||||
"Once you've completed the user's task, use this tool to present the final result to the user, including a brief and very short (1-2 paragraph) summary of the task and what was done to resolve it. Provide the basics, hitting the highlights, but do delve into the specifics. You should only call this tool when you have completed all tasks in the task_progress list, and completed all changes that are necessary to satisfy the user's request. You should not provide the contents of the task_progress list in the result parameter, it must be included in the task_progress parameter.",
|
||||
"Once you've completed the user's task, or have all the information you need to to answer user's question, ALWAYS use this tool to present the final result to the user, including a brief and very short (1-2 paragraph) summary of the task and what was done to resolve it. Provide the basics, hitting the highlights, but do delve into the specifics. You should only call this tool when you have completed all tasks in the task_progress list, and completed all changes that are necessary to satisfy the user's request. You should not provide the contents of the task_progress list in the result parameter, it must be included in the task_progress parameter.",
|
||||
parameters: [
|
||||
{
|
||||
name: "result",
|
||||
required: true,
|
||||
instruction: "A clear, brief and very short (1-2 paragraph) summary of the final result of the task.",
|
||||
instruction: "Summary of the final result of the task, or the final answer to the user's question.",
|
||||
},
|
||||
{
|
||||
name: "command",
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from "./plan_mode_respond"
|
||||
export * from "./read_file"
|
||||
export * from "./replace_in_file"
|
||||
export * from "./search_files"
|
||||
export * from "./subagent"
|
||||
export * from "./use_mcp_tool"
|
||||
export * from "./use_skill"
|
||||
export * from "./web_fetch"
|
||||
|
||||
@@ -17,6 +17,7 @@ import { plan_mode_respond_variants } from "./plan_mode_respond"
|
||||
import { read_file_variants } from "./read_file"
|
||||
import { replace_in_file_variants } from "./replace_in_file"
|
||||
import { search_files_variants } from "./search_files"
|
||||
import { subagent_variants } from "./subagent"
|
||||
import { use_mcp_tool_variants } from "./use_mcp_tool"
|
||||
import { use_skill_variants } from "./use_skill"
|
||||
import { web_fetch_variants } from "./web_fetch"
|
||||
@@ -46,6 +47,7 @@ export function registerClineToolSets(): void {
|
||||
...plan_mode_respond_variants,
|
||||
...read_file_variants,
|
||||
...replace_in_file_variants,
|
||||
...subagent_variants,
|
||||
...search_files_variants,
|
||||
...use_mcp_tool_variants,
|
||||
...use_skill_variants,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ClineToolSpec } from "../spec"
|
||||
import { TASK_PROGRESS_PARAMETER } from "../types"
|
||||
|
||||
const id = ClineDefaultTool.SUBAGENT
|
||||
|
||||
const NATIVE_NEXT_GEN: ClineToolSpec = {
|
||||
variant: ModelFamily.NATIVE_NEXT_GEN,
|
||||
id,
|
||||
name: id,
|
||||
description:
|
||||
"Launch a new agent to handle complex, multi-step tasks autonomously. The agent has access to search and bash tools to gather information from inside and outside the codebase. Use this for tasks that require multiple steps of exploration or research before reaching a conclusion.",
|
||||
parameters: [
|
||||
{
|
||||
name: "prompt",
|
||||
required: true,
|
||||
instruction: `A highly detailed task description for the agent to perform autonomously. The prompt should include:
|
||||
1. What the agent needs to accomplish
|
||||
2. Whether the agent should write code or just do research (search, file reads, etc.)
|
||||
3. Exactly what information should be returned in the agent's final response
|
||||
|
||||
IMPORTANT:
|
||||
- Each agent invocation is stateless - you cannot send follow-up messages. Make your prompt comprehensive and self-contained.
|
||||
- Each agent should have a UNIQUE, non-overlapping mission. Do NOT launch multiple agents that could search for the same things or perform similar work.
|
||||
- Before launching an agent, consider what other agents you're launching in parallel - ensure their tasks are distinct and won't duplicate effort.
|
||||
- Bad example: Agent 1 "find auth code", Agent 2 "find login handlers" - these overlap and will do redundant searches.
|
||||
- Good example: Agent 1 "find frontend auth components", Agent 2 "find backend API auth middleware" - distinct, non-overlapping scopes.`,
|
||||
},
|
||||
TASK_PROGRESS_PARAMETER,
|
||||
],
|
||||
}
|
||||
|
||||
export const subagent_variants = [NATIVE_NEXT_GEN]
|
||||
@@ -50,7 +50,7 @@ export const config = createVariant(ModelFamily.NATIVE_NEXT_GEN)
|
||||
ClineDefaultTool.FILE_READ,
|
||||
ClineDefaultTool.FILE_NEW,
|
||||
ClineDefaultTool.FILE_EDIT,
|
||||
ClineDefaultTool.SEARCH,
|
||||
ClineDefaultTool.SUBAGENT,
|
||||
ClineDefaultTool.LIST_FILES,
|
||||
ClineDefaultTool.LIST_CODE_DEF,
|
||||
ClineDefaultTool.BROWSER,
|
||||
|
||||
@@ -88,15 +88,16 @@ In each user message, the environment_details will specify the current mode. The
|
||||
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
|
||||
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.`
|
||||
|
||||
const OBJECTIVE = (context: SystemPromptContext) => `OBJECTIVE
|
||||
const OBJECTIVE = (_context: SystemPromptContext) => `OBJECTIVE
|
||||
|
||||
You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically.
|
||||
|
||||
1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.
|
||||
2. Work through these goals sequentially, utilizing available tools ${context.enableParallelToolCalling ? "as necessary. You may call multiple independent tools in a single response to work efficiently." : "one at a time as necessary."} Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go.
|
||||
3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Then, think about which of the provided tools is the most relevant tool to accomplish the user's task. Next, go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, close the thinking tag and proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params)${context.yoloModeToggled !== true ? " and instead, ask the user to provide the missing parameters using the ask_followup_question tool" : ""}. DO NOT ask for more information on optional parameters if it is not provided.
|
||||
4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. You may also provide a CLI command to showcase the result of your task; this can be particularly useful for web development tasks, where you can run e.g. \`open index.html\` to show the website you've built.
|
||||
5. If the task is not actionable, you may use the attempt_completion tool to explain to the user why the task cannot be completed, or provide a simple answer if that is what the user is looking for.`
|
||||
1. Always gather all the necessary context before starting to work on a task. For example, if you are generating a unit test or new code, make sure you understand the requirement, the naming conventions, frameworks and libraries used and aligned in the current codebase, and the environment and commands used to run and test the code etc. Always validate the new unit test at the end including running the code if possible for live feedback.
|
||||
2. Review each question carefully and answer it with detailed, accurate information.
|
||||
3. If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.
|
||||
4. If the task is not actionable, you may use the attempt_completion tool to explain to the user why the task cannot be completed, or provide a simple answer if that is what the user is looking for.
|
||||
|
||||
IMPORTANT: Always uses the attempt_completion tool when you've completed all tasks, including giving your answer to the user question.`
|
||||
|
||||
const FEEDBACK = (_context: SystemPromptContext) => `FEEDBACK
|
||||
|
||||
|
||||
@@ -79,4 +79,5 @@ export const rules_template = (context: SystemPromptContext) => `RULES
|
||||
- When using the replace_in_file tool, if you use multiple SEARCH/REPLACE blocks, list them in the order they appear in the file. For example if you need to make changes to both line 10 and line 50, first include the SEARCH/REPLACE block for line 10, followed by the SEARCH/REPLACE block for line 50.
|
||||
- When using the replace_in_file tool, Do NOT add extra characters to the markers (e.g., ------- SEARCH> is INVALID). Do NOT forget to use the closing +++++++ REPLACE marker. Do NOT modify the marker format in any way. Malformed XML will cause complete tool failure and break the entire editing process.
|
||||
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.{{BROWSER_WAIT_RULES}}
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.`
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||
- When user asked a question, always provide the final answer using the attempt_completion tool rather than answering directly in your response.`
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { McpPromptResponse } from "@shared/mcp"
|
||||
import { expect } from "chai"
|
||||
import { formatMcpPromptResponse, McpPromptFetcher, parseSlashCommands } from "../index"
|
||||
|
||||
describe("slash-commands", () => {
|
||||
describe("formatMcpPromptResponse", () => {
|
||||
it("should format text message", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello world" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\nHello world")
|
||||
})
|
||||
|
||||
it("should format assistant message", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "assistant", content: { type: "text", text: "I can help" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[Assistant]\nI can help")
|
||||
})
|
||||
|
||||
it("should include description when provided", () => {
|
||||
const response: McpPromptResponse = {
|
||||
description: "Test description",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.include("Description: Test description")
|
||||
expect(result).to.include("[User]\nHello")
|
||||
})
|
||||
|
||||
it("should format multiple messages", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [
|
||||
{ role: "user", content: { type: "text", text: "Question" } },
|
||||
{ role: "assistant", content: { type: "text", text: "Answer" } },
|
||||
],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.include("[User]\nQuestion")
|
||||
expect(result).to.include("[Assistant]\nAnswer")
|
||||
})
|
||||
|
||||
it("should format image content", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "user", content: { type: "image", data: "base64data", mimeType: "image/png" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\n[Image: image/png]")
|
||||
})
|
||||
|
||||
it("should format audio content", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [{ role: "user", content: { type: "audio", data: "base64data", mimeType: "audio/mp3" } }],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\n[Audio: audio/mp3]")
|
||||
})
|
||||
|
||||
it("should format resource with text", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: { uri: "file:///test.txt", text: "File content" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.include("[Resource: file:///test.txt]")
|
||||
expect(result).to.include("File content")
|
||||
})
|
||||
|
||||
it("should format resource without text", () => {
|
||||
const response: McpPromptResponse = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "resource",
|
||||
resource: { uri: "file:///binary.bin" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = formatMcpPromptResponse(response)
|
||||
expect(result).to.equal("[User]\n[Resource: file:///binary.bin]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSlashCommands MCP handling", () => {
|
||||
const mockMcpPromptFetcher: McpPromptFetcher = async (serverName, promptName) => {
|
||||
if (serverName === "test-server" && promptName === "greet") {
|
||||
return {
|
||||
description: "A greeting prompt",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello from MCP!" } }],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
it("should process MCP prompt command in task tag", async () => {
|
||||
const text = "<task>/mcp:test-server:greet</task>"
|
||||
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
|
||||
|
||||
expect(result.processedText).to.include('<mcp_prompt server="test-server" prompt="greet">')
|
||||
expect(result.processedText).to.include("Hello from MCP!")
|
||||
expect(result.needsClinerulesFileCheck).to.equal(false)
|
||||
})
|
||||
|
||||
it("should process MCP prompt with additional text", async () => {
|
||||
const text = "<task>/mcp:test-server:greet Please expand on this</task>"
|
||||
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
|
||||
|
||||
expect(result.processedText).to.include('<mcp_prompt server="test-server" prompt="greet">')
|
||||
expect(result.processedText).to.include("Please expand on this")
|
||||
})
|
||||
|
||||
it("should handle MCP prompt with colons in prompt name", async () => {
|
||||
const fetcherWithColons: McpPromptFetcher = async (serverName, promptName) => {
|
||||
if (serverName === "server" && promptName === "prompt:with:colons") {
|
||||
return {
|
||||
messages: [{ role: "user", content: { type: "text", text: "Colon prompt" } }],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const text = "<task>/mcp:server:prompt:with:colons</task>"
|
||||
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, fetcherWithColons)
|
||||
|
||||
expect(result.processedText).to.include('prompt="prompt:with:colons"')
|
||||
expect(result.processedText).to.include("Colon prompt")
|
||||
})
|
||||
|
||||
// Note: Tests for "unknown MCP server", "no fetcher", and "fetcher errors"
|
||||
// are skipped because they require StateManager initialization when falling
|
||||
// through to workflow checking. The core MCP functionality is covered above.
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ApiProviderInfo } from "@core/api"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { McpPromptResponse } from "@shared/mcp"
|
||||
import fs from "fs/promises"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -15,6 +16,11 @@ import {
|
||||
} from "../prompts/commands"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
|
||||
/**
|
||||
* Callback type for fetching MCP prompts
|
||||
*/
|
||||
export type McpPromptFetcher = (serverName: string, promptName: string) => Promise<McpPromptResponse | null>
|
||||
|
||||
type FileBasedWorkflow = {
|
||||
fullPath: string
|
||||
fileName: string
|
||||
@@ -42,6 +48,7 @@ export async function parseSlashCommands(
|
||||
focusChainSettings?: { enabled: boolean },
|
||||
enableNativeToolCalls?: boolean,
|
||||
providerInfo?: ApiProviderInfo,
|
||||
mcpPromptFetcher?: McpPromptFetcher,
|
||||
): Promise<{ processedText: string; needsClinerulesFileCheck: boolean }> {
|
||||
const SUPPORTED_DEFAULT_COMMANDS = [
|
||||
"newtask",
|
||||
@@ -79,10 +86,10 @@ export async function parseSlashCommands(
|
||||
// Regex to find slash commands anywhere in text (not just at the beginning).
|
||||
// This mirrors how @ mentions work - they can appear anywhere in a message.
|
||||
//
|
||||
// Pattern breakdown: /(^|\s)\/([a-zA-Z0-9_.-]+)(?=\s|$)/
|
||||
// Pattern breakdown: /(^|\s)\/([a-zA-Z0-9_.:@-]+)(?=\s|$)/
|
||||
// - (^|\s) : Must be at start of string OR preceded by whitespace
|
||||
// - \/ : The literal slash character
|
||||
// - ([a-zA-Z0-9_.-]+) : The command name (letters, numbers, underscore, dot, hyphen)
|
||||
// - ([a-zA-Z0-9_.:@-]+) : The command name (letters, numbers, underscore, dot, hyphen, colon, @)
|
||||
// - (?=\s|$): Must be followed by whitespace or end of string (lookahead)
|
||||
//
|
||||
// This safely avoids false matches in:
|
||||
@@ -91,7 +98,8 @@ export async function parseSlashCommands(
|
||||
// - Partial words: "foo/bar" - same reason
|
||||
//
|
||||
// Only ONE slash command per message is processed (first match found).
|
||||
const slashCommandInTextRegex = /(^|\s)\/([a-zA-Z0-9_.-]+)(?=\s|$)/
|
||||
// Note: Colons are allowed to support MCP prompt commands like /mcp:server:prompt
|
||||
const slashCommandInTextRegex = /(^|\s)\/([a-zA-Z0-9_.:@-]+)(?=\s|$)/
|
||||
|
||||
// Helper function to calculate positions and remove slash command from text
|
||||
const removeSlashCommand = (
|
||||
@@ -144,6 +152,39 @@ export async function parseSlashCommands(
|
||||
return { processedText: processedText, needsClinerulesFileCheck: commandName === "newrule" }
|
||||
}
|
||||
|
||||
// Check for MCP prompt commands (format: mcp:<server>:<prompt>)
|
||||
if (commandName.startsWith("mcp:") && mcpPromptFetcher) {
|
||||
const parts = commandName.split(":")
|
||||
if (parts.length >= 3) {
|
||||
const serverName = parts[1]
|
||||
const promptName = parts.slice(2).join(":") // Allow colons in prompt name
|
||||
|
||||
try {
|
||||
const promptResponse = await mcpPromptFetcher(serverName, promptName)
|
||||
if (promptResponse) {
|
||||
// Format the prompt messages as text
|
||||
const promptContent = formatMcpPromptResponse(promptResponse)
|
||||
|
||||
// Remove the slash command and add the prompt content
|
||||
const textWithoutSlashCommand = removeSlashCommand(text, tagContent, contentStartIndex, slashMatch)
|
||||
const processedText =
|
||||
`<mcp_prompt server="${serverName}" prompt="${promptName}">\n${promptContent}\n</mcp_prompt>\n` +
|
||||
textWithoutSlashCommand
|
||||
|
||||
// Track telemetry for MCP prompt usage
|
||||
telemetryService.captureSlashCommandUsed(ulid, commandName, "mcp_prompt")
|
||||
|
||||
return { processedText, needsClinerulesFileCheck: false }
|
||||
} else {
|
||||
// Prompt not found - log for debugging and fall through to workflow checking
|
||||
Logger.debug(`MCP prompt not found: ${commandName} (server: ${serverName}, prompt: ${promptName})`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Error fetching MCP prompt ${commandName}: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globalWorkflows: Workflow[] = Object.entries(globalWorkflowToggles)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([filePath, _]) => ({
|
||||
@@ -214,3 +255,35 @@ export async function parseSlashCommands(
|
||||
// if no supported commands are found, return the original text
|
||||
return { processedText: text, needsClinerulesFileCheck: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats MCP prompt response messages into a text format for injection
|
||||
*/
|
||||
export function formatMcpPromptResponse(response: McpPromptResponse): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (response.description) {
|
||||
parts.push(`Description: ${response.description}`)
|
||||
}
|
||||
|
||||
for (const message of response.messages) {
|
||||
const roleLabel = message.role === "user" ? "User" : "Assistant"
|
||||
|
||||
if (message.content.type === "text") {
|
||||
parts.push(`[${roleLabel}]\n${message.content.text}`)
|
||||
} else if (message.content.type === "image") {
|
||||
parts.push(`[${roleLabel}]\n[Image: ${message.content.mimeType}]`)
|
||||
} else if (message.content.type === "audio") {
|
||||
parts.push(`[${roleLabel}]\n[Audio: ${message.content.mimeType}]`)
|
||||
} else if (message.content.type === "resource") {
|
||||
const resource = message.content.resource
|
||||
if (resource.text) {
|
||||
parts.push(`[${roleLabel}]\n[Resource: ${resource.uri}]\n${resource.text}`)
|
||||
} else {
|
||||
parts.push(`[${roleLabel}]\n[Resource: ${resource.uri}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
} from "@shared/storage/state-keys"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import {
|
||||
getTaskHistoryStateFilePath,
|
||||
@@ -267,12 +265,7 @@ export class StateManager {
|
||||
Object.assign(this.taskStateCache, taskSettings)
|
||||
} catch (error) {
|
||||
// If reading fails, just use empty cache
|
||||
|
||||
Logger.error("[StateManager] Failed to load task settings:", error)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Failed to load task settings, defaulting to globally selected settings.`,
|
||||
})
|
||||
Logger.error("[StateManager] Failed to load task settings, defaulting to globally selected settings.", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,7 +728,6 @@ export class StateManager {
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("[StateManager] Failed to persist global state batch:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -765,7 +757,6 @@ export class StateManager {
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("[StateManager] Failed to persist task settings batch:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -786,7 +777,6 @@ export class StateManager {
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("Failed to persist secrets batch:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -803,7 +793,6 @@ export class StateManager {
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("Failed to persist workspace state batch:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Controller } from "@/core/controller"
|
||||
import { buildBasicClineHeaders } from "@/services/EnvUtils"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ConfiguredAPIKeys } from "@/shared/storage/state-keys"
|
||||
import { ClineEnv } from "../../../config"
|
||||
import { AuthService } from "../../../services/auth/AuthService"
|
||||
import { CLINE_API_ENDPOINT } from "../../../shared/cline/api"
|
||||
@@ -217,12 +218,14 @@ async function ensureUserInOrgWithRemoteConfig(controller: Controller): Promise<
|
||||
await controller.accountService.switchAccount(organizationId)
|
||||
}
|
||||
|
||||
const configuredApiKeys: ConfiguredAPIKeys = {}
|
||||
// Fetch and store API keys for configured providers
|
||||
const hasConfiguredProviders = config.providerSettings && Object.keys(config.providerSettings).length > 0
|
||||
if (hasConfiguredProviders) {
|
||||
const apiKeys = await fetchApiKeysForOrganization(organizationId)
|
||||
if (config.providerSettings?.LiteLLM) {
|
||||
if (apiKeys.litellm) {
|
||||
configuredApiKeys["litellm"] = true
|
||||
controller.stateManager.setSecret("remoteLiteLlmApiKey", apiKeys.litellm)
|
||||
} else {
|
||||
controller.stateManager.setSecret("remoteLiteLlmApiKey", undefined)
|
||||
@@ -237,7 +240,7 @@ async function ensureUserInOrgWithRemoteConfig(controller: Controller): Promise<
|
||||
// Cache and apply the remote config
|
||||
await writeRemoteConfigToCache(organizationId, config)
|
||||
if (isRemoteConfigEnabled(organizationId)) {
|
||||
await applyRemoteConfig(config, undefined, controller.mcpHub)
|
||||
await applyRemoteConfig(config, configuredApiKeys, controller.mcpHub)
|
||||
} else {
|
||||
clearRemoteConfig()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { synchronizeRemoteRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { RemoteConfig } from "@shared/remote-config/schema"
|
||||
import { GlobalStateAndSettings, RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { ConfiguredAPIKeys, GlobalStateAndSettings, RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { getTelemetryService, telemetryService } from "@/services/telemetry"
|
||||
import { type McpHub } from "@/services/mcp/McpHub"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { OpenTelemetryClientProvider } from "@/services/telemetry/providers/opentelemetry/OpenTelemetryClientProvider"
|
||||
import { OpenTelemetryTelemetryProvider } from "@/services/telemetry/providers/opentelemetry/OpenTelemetryTelemetryProvider"
|
||||
import { type TelemetryService } from "@/services/telemetry/TelemetryService"
|
||||
@@ -266,17 +267,14 @@ export function clearRemoteConfig() {
|
||||
/**
|
||||
* Applies remote config to the StateManager's remote config cache
|
||||
* @param remoteConfig The remote configuration object to apply
|
||||
* @param settingsDirectoryPath Path to the settings directory
|
||||
* @param mcpHub Optional McpHub instance to prevent watcher triggers during sync
|
||||
* @param mcpHub McpHub instance to prevent watcher triggers during sync
|
||||
*/
|
||||
export async function applyRemoteConfig(
|
||||
remoteConfig?: RemoteConfig,
|
||||
settingsDirectoryPath?: string,
|
||||
mcpHub?: any,
|
||||
remoteConfig: RemoteConfig,
|
||||
configuredKeys: ConfiguredAPIKeys,
|
||||
mcpHub: McpHub,
|
||||
): Promise<void> {
|
||||
const stateManager = StateManager.get()
|
||||
const telemetryService = await getTelemetryService()
|
||||
|
||||
// If no remote config provided, clear the cache and relevant state
|
||||
if (!remoteConfig) {
|
||||
clearRemoteConfig()
|
||||
@@ -318,6 +316,7 @@ export async function applyRemoteConfig(
|
||||
for (const [key, value] of Object.entries(transformed)) {
|
||||
stateManager.setRemoteConfigField(key as keyof RemoteConfigFields, value)
|
||||
}
|
||||
stateManager.setRemoteConfigField("configuredApiKeys", configuredKeys)
|
||||
|
||||
// Restore previousRemoteMCPServers across cache clears
|
||||
if (previousRemoteMCPServers !== undefined) {
|
||||
@@ -330,7 +329,7 @@ export async function applyRemoteConfig(
|
||||
if (remoteConfig.remoteMCPServers !== undefined) {
|
||||
try {
|
||||
// Get settings directory path - use provided path or get it from disk helper
|
||||
const settingsPath = settingsDirectoryPath || (await ensureSettingsDirectoryExists())
|
||||
const settingsPath = await ensureSettingsDirectoryExists()
|
||||
await syncRemoteMcpServersToSettings(remoteConfig.remoteMCPServers, settingsPath, mcpHub)
|
||||
// Store current remote servers list for next sync to detect removals
|
||||
stateManager.setRemoteConfigField("previousRemoteMCPServers", remoteConfig.remoteMCPServers)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { JSONParser } from "@streamparser/json"
|
||||
import { nanoid } from "nanoid"
|
||||
import { McpHub } from "@/services/mcp/McpHub"
|
||||
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
|
||||
import {
|
||||
@@ -225,8 +226,9 @@ class ToolUseHandler {
|
||||
this.pendingToolUses.clear()
|
||||
}
|
||||
|
||||
private createPendingToolUse(id: string, name: string, call_id?: string): PendingToolUse {
|
||||
private createPendingToolUse(id: string, name: string, callId?: string): PendingToolUse {
|
||||
const jsonParser = new JSONParser()
|
||||
const call_id = callId || nanoid(8)
|
||||
const pending: PendingToolUse = {
|
||||
id,
|
||||
name,
|
||||
|
||||
@@ -16,8 +16,6 @@ export class TaskState {
|
||||
userMessageContentReady = false
|
||||
// Map of tool names to their tool_use_id for creating proper ToolResultBlockParam
|
||||
toolUseIdMap: Map<string, string> = new Map()
|
||||
// Track tool calls that have already had errors pushed (prevents duplicates during streaming)
|
||||
errorPushedForCallIds: Set<string> = new Set()
|
||||
|
||||
// Presentation locks
|
||||
presentAssistantMessageLocked = false
|
||||
|
||||
@@ -39,6 +39,7 @@ import { PlanModeRespondHandler } from "./tools/handlers/PlanModeRespondHandler"
|
||||
import { ReadFileToolHandler } from "./tools/handlers/ReadFileToolHandler"
|
||||
import { ReportBugHandler } from "./tools/handlers/ReportBugHandler"
|
||||
import { SearchFilesToolHandler } from "./tools/handlers/SearchFilesToolHandler"
|
||||
import { SubagentHandler } from "./tools/handlers/SubagentHandler"
|
||||
import { SummarizeTaskHandler } from "./tools/handlers/SummarizeTaskHandler"
|
||||
import { UseMcpToolHandler } from "./tools/handlers/UseMcpToolHandler"
|
||||
import { UseSkillToolHandler } from "./tools/handlers/UseSkillToolHandler"
|
||||
@@ -192,6 +193,7 @@ export class ToolExecutor {
|
||||
clearActiveHookExecution: this.clearActiveHookExecution,
|
||||
getActiveHookExecution: this.getActiveHookExecution,
|
||||
runUserPromptSubmitHook: this.runUserPromptSubmitHook,
|
||||
replaceMessageContentByUid: this.messageStateHandler.replaceMessageContentByUid.bind(this.messageStateHandler),
|
||||
},
|
||||
coordinator: this.coordinator,
|
||||
}
|
||||
@@ -221,8 +223,10 @@ export class ToolExecutor {
|
||||
this.coordinator.register(new SharedToolHandler(ClineDefaultTool.FILE_EDIT, writeHandler))
|
||||
this.coordinator.register(new SharedToolHandler(ClineDefaultTool.NEW_RULE, writeHandler))
|
||||
|
||||
this.coordinator.register(new ListCodeDefinitionNamesToolHandler(validator))
|
||||
this.coordinator.register(new SubagentHandler())
|
||||
this.coordinator.register(new SearchFilesToolHandler(validator))
|
||||
|
||||
this.coordinator.register(new ListCodeDefinitionNamesToolHandler(validator))
|
||||
this.coordinator.register(new ExecuteCommandToolHandler(validator))
|
||||
this.coordinator.register(new UseMcpToolHandler())
|
||||
this.coordinator.register(new AccessMcpResourceHandler())
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import { TaskState } from "../TaskState"
|
||||
|
||||
describe("TaskState", () => {
|
||||
describe("errorPushedForCallIds", () => {
|
||||
let taskState: TaskState
|
||||
|
||||
beforeEach(() => {
|
||||
taskState = new TaskState()
|
||||
})
|
||||
|
||||
it("should initialize as empty Set", () => {
|
||||
taskState.errorPushedForCallIds.should.be.instanceOf(Set)
|
||||
taskState.errorPushedForCallIds.size.should.equal(0)
|
||||
})
|
||||
|
||||
it("should track call_ids that have had errors pushed", () => {
|
||||
const callId = "call_abc123"
|
||||
|
||||
// Initially not present
|
||||
taskState.errorPushedForCallIds.has(callId).should.be.false()
|
||||
|
||||
// Add call_id
|
||||
taskState.errorPushedForCallIds.add(callId)
|
||||
|
||||
// Now present
|
||||
taskState.errorPushedForCallIds.has(callId).should.be.true()
|
||||
})
|
||||
|
||||
it("should track multiple call_ids independently", () => {
|
||||
const callId1 = "call_abc123"
|
||||
const callId2 = "call_def456"
|
||||
const callId3 = "call_ghi789"
|
||||
|
||||
// Add first two
|
||||
taskState.errorPushedForCallIds.add(callId1)
|
||||
taskState.errorPushedForCallIds.add(callId2)
|
||||
|
||||
// Check tracking
|
||||
taskState.errorPushedForCallIds.has(callId1).should.be.true()
|
||||
taskState.errorPushedForCallIds.has(callId2).should.be.true()
|
||||
taskState.errorPushedForCallIds.has(callId3).should.be.false()
|
||||
})
|
||||
|
||||
it("should clear all tracked call_ids", () => {
|
||||
// Add multiple call_ids
|
||||
taskState.errorPushedForCallIds.add("call_1")
|
||||
taskState.errorPushedForCallIds.add("call_2")
|
||||
taskState.errorPushedForCallIds.add("call_3")
|
||||
taskState.errorPushedForCallIds.size.should.equal(3)
|
||||
|
||||
// Clear
|
||||
taskState.errorPushedForCallIds.clear()
|
||||
|
||||
// Should be empty
|
||||
taskState.errorPushedForCallIds.size.should.equal(0)
|
||||
taskState.errorPushedForCallIds.has("call_1").should.be.false()
|
||||
})
|
||||
|
||||
it("should not add duplicate call_ids (Set behavior)", () => {
|
||||
const callId = "call_abc123"
|
||||
|
||||
taskState.errorPushedForCallIds.add(callId)
|
||||
taskState.errorPushedForCallIds.add(callId)
|
||||
taskState.errorPushedForCallIds.add(callId)
|
||||
|
||||
// Still only one entry
|
||||
taskState.errorPushedForCallIds.size.should.equal(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("duplicate diff error prevention logic", () => {
|
||||
let taskState: TaskState
|
||||
|
||||
beforeEach(() => {
|
||||
taskState = new TaskState()
|
||||
})
|
||||
|
||||
/**
|
||||
* Simulates the duplicate check logic from WriteToFileToolHandler.
|
||||
* This tests the core logic that prevents duplicate error messages
|
||||
* when parallel tool calling is enabled.
|
||||
*/
|
||||
function shouldSkipDuplicateError(callId: string | undefined): boolean {
|
||||
const id = callId || ""
|
||||
if (id && taskState.errorPushedForCallIds.has(id)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function markErrorPushed(callId: string | undefined): void {
|
||||
const id = callId || ""
|
||||
if (id) {
|
||||
taskState.errorPushedForCallIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
it("should not skip first error for a call_id", () => {
|
||||
const callId = "call_abc123"
|
||||
|
||||
shouldSkipDuplicateError(callId).should.be.false()
|
||||
})
|
||||
|
||||
it("should skip subsequent errors for same call_id", () => {
|
||||
const callId = "call_abc123"
|
||||
|
||||
// First call - not skipped
|
||||
shouldSkipDuplicateError(callId).should.be.false()
|
||||
markErrorPushed(callId)
|
||||
|
||||
// Second call - should skip
|
||||
shouldSkipDuplicateError(callId).should.be.true()
|
||||
|
||||
// Third call - still skipped
|
||||
shouldSkipDuplicateError(callId).should.be.true()
|
||||
})
|
||||
|
||||
it("should allow different call_ids to each have their error", () => {
|
||||
const callId1 = "call_tool1"
|
||||
const callId2 = "call_tool2"
|
||||
|
||||
// First tool error
|
||||
shouldSkipDuplicateError(callId1).should.be.false()
|
||||
markErrorPushed(callId1)
|
||||
|
||||
// Second tool error (different call_id) - should NOT skip
|
||||
shouldSkipDuplicateError(callId2).should.be.false()
|
||||
markErrorPushed(callId2)
|
||||
|
||||
// But both should now skip on retry
|
||||
shouldSkipDuplicateError(callId1).should.be.true()
|
||||
shouldSkipDuplicateError(callId2).should.be.true()
|
||||
})
|
||||
|
||||
it("should not skip when call_id is empty (XML tools fallback)", () => {
|
||||
// Empty call_id (typical for XML-based tools)
|
||||
shouldSkipDuplicateError("").should.be.false()
|
||||
markErrorPushed("")
|
||||
|
||||
// Still doesn't skip because empty string is falsy
|
||||
shouldSkipDuplicateError("").should.be.false()
|
||||
})
|
||||
|
||||
it("should not skip when call_id is undefined", () => {
|
||||
shouldSkipDuplicateError(undefined).should.be.false()
|
||||
markErrorPushed(undefined)
|
||||
|
||||
// Still doesn't skip
|
||||
shouldSkipDuplicateError(undefined).should.be.false()
|
||||
})
|
||||
|
||||
it("should reset tracking between API requests (clear)", () => {
|
||||
const callId = "call_abc123"
|
||||
|
||||
// Mark error pushed
|
||||
markErrorPushed(callId)
|
||||
shouldSkipDuplicateError(callId).should.be.true()
|
||||
|
||||
// Simulate reset between API requests
|
||||
taskState.errorPushedForCallIds.clear()
|
||||
|
||||
// Same call_id should not skip after reset
|
||||
shouldSkipDuplicateError(callId).should.be.false()
|
||||
})
|
||||
|
||||
it("should handle rapid streaming chunks (same call_id repeated)", () => {
|
||||
const callId = "call_streaming"
|
||||
|
||||
// Simulate 238 streaming chunks (as seen in the bug)
|
||||
const results: boolean[] = []
|
||||
|
||||
for (let i = 0; i < 238; i++) {
|
||||
const shouldSkip = shouldSkipDuplicateError(callId)
|
||||
results.push(shouldSkip)
|
||||
|
||||
if (!shouldSkip) {
|
||||
markErrorPushed(callId)
|
||||
}
|
||||
}
|
||||
|
||||
// First should not skip, rest should skip
|
||||
results[0].should.be.false()
|
||||
results
|
||||
.slice(1)
|
||||
.every((r) => r)
|
||||
.should.be.true()
|
||||
|
||||
// Should have only added one entry
|
||||
taskState.errorPushedForCallIds.size.should.equal(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
+113
-9
@@ -96,6 +96,7 @@ import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
|
||||
import { ClineAgent } from "../agents/ClineAgent"
|
||||
import { RuleContextBuilder } from "../context/instructions/user-instructions/RuleContextBuilder"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
import { discoverSkills, getAvailableSkills } from "../context/instructions/user-instructions/skills"
|
||||
@@ -718,6 +719,7 @@ export class Task {
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
uid?: string,
|
||||
): Promise<number | undefined> {
|
||||
// Allow hook messages even when aborted to enable proper cleanup
|
||||
if (this.taskState.abort && type !== "hook_status" && type !== "hook_output_stream") {
|
||||
@@ -733,10 +735,32 @@ export class Task {
|
||||
|
||||
if (partial !== undefined) {
|
||||
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
|
||||
const isUpdatingPreviousPartial =
|
||||
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
|
||||
|
||||
// For tool messages, check if the content matches to distinguish between parallel tool executions
|
||||
let isUpdatingPreviousPartial = false
|
||||
if (lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type) {
|
||||
if (type === "tool" && text && lastMessage.text) {
|
||||
// For tool messages, parse and compare the tool identifier (e.g., filePattern for subagents)
|
||||
try {
|
||||
const newToolData = JSON.parse(text)
|
||||
const lastToolData = JSON.parse(lastMessage.text)
|
||||
// Check if this is the same tool execution by comparing identifying fields
|
||||
// For subagents, filePattern is the unique identifier (the prompt)
|
||||
if (newToolData.tool === lastToolData.tool && newToolData.filePattern === lastToolData.filePattern) {
|
||||
isUpdatingPreviousPartial = true
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fall back to basic check (non-tool messages)
|
||||
isUpdatingPreviousPartial = true
|
||||
}
|
||||
} else {
|
||||
// For non-tool messages, use the basic check
|
||||
isUpdatingPreviousPartial = true
|
||||
}
|
||||
}
|
||||
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
if (isUpdatingPreviousPartial && lastMessage) {
|
||||
// existing partial message, so update it
|
||||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
@@ -758,13 +782,14 @@ export class Task {
|
||||
files,
|
||||
partial,
|
||||
modelInfo,
|
||||
uid,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
} else {
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
if (isUpdatingPreviousPartial) {
|
||||
if (isUpdatingPreviousPartial && lastMessage) {
|
||||
// this is the complete version of a previously partial message, so replace the partial with the complete version
|
||||
this.taskState.lastMessageTs = lastMessage.ts
|
||||
// lastMessage.ts = sayTs
|
||||
@@ -791,6 +816,7 @@ export class Task {
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
uid,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
@@ -808,6 +834,7 @@ export class Task {
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
uid,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
@@ -848,6 +875,42 @@ export class Task {
|
||||
return this.stateManager.getGlobalSettingsKey("enableParallelToolCalling") || isGPT5ModelFamily(modelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that can be executed in parallel when multiple appear consecutively.
|
||||
* These are tools that don't have side effects that would conflict with each other.
|
||||
*/
|
||||
private static readonly PARALLELIZABLE_TOOLS: ClineDefaultTool[] = [ClineDefaultTool.SUBAGENT]
|
||||
|
||||
/**
|
||||
* Check if a tool block can be executed in parallel with other parallelizable tools.
|
||||
* Only complete (non-partial) blocks of specific tool types are parallelizable.
|
||||
*/
|
||||
private isParallelizableToolBlock(block: AssistantMessageContent): boolean {
|
||||
return block.type === "tool_use" && !block.partial && Task.PARALLELIZABLE_TOOLS.includes(block.name as ClineDefaultTool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect consecutive parallelizable tool blocks starting from the current index.
|
||||
* Returns an array of tool blocks that can be executed in parallel.
|
||||
*/
|
||||
private collectParallelizableBlocks(): ToolUse[] {
|
||||
const blocks: ToolUse[] = []
|
||||
const startIndex = this.taskState.currentStreamingContentIndex
|
||||
const content = this.taskState.assistantMessageContent
|
||||
|
||||
for (let i = startIndex; i < content.length; i++) {
|
||||
const block = content[i]
|
||||
if (this.isParallelizableToolBlock(block)) {
|
||||
blocks.push(cloneDeep(block) as ToolUse)
|
||||
} else {
|
||||
// Stop at first non-parallelizable block
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private async switchToActModeCallback(): Promise<boolean> {
|
||||
return await this.controller.toggleActModeForYoloMode()
|
||||
}
|
||||
@@ -2131,7 +2194,7 @@ export class Task {
|
||||
await this.say("text", content, undefined, undefined, block.partial)
|
||||
break
|
||||
}
|
||||
case "tool_use":
|
||||
case "tool_use": {
|
||||
// If we have a pending initial commit, we must block unsafe tools until it finishes.
|
||||
// Safe tools (read-only) can run in parallel.
|
||||
if (this.initialCheckpointCommitPromise) {
|
||||
@@ -2140,8 +2203,20 @@ export class Task {
|
||||
this.initialCheckpointCommitPromise = undefined
|
||||
}
|
||||
}
|
||||
await this.toolExecutor.executeTool(block)
|
||||
|
||||
// Check if we can execute multiple parallelizable tool blocks (e.g., subagents) in parallel
|
||||
const parallelBlocks = this.collectParallelizableBlocks()
|
||||
if (parallelBlocks.length > 1) {
|
||||
// Execute all parallelizable blocks concurrently
|
||||
await Promise.all(parallelBlocks.map((b) => this.toolExecutor.executeTool(b)))
|
||||
// Skip past all the blocks we just executed (minus 1 since the normal flow will increment once)
|
||||
this.taskState.currentStreamingContentIndex += parallelBlocks.length - 1
|
||||
} else {
|
||||
// Single tool or non-parallelizable - execute normally
|
||||
await this.toolExecutor.executeTool(block)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -2151,8 +2226,13 @@ export class Task {
|
||||
this.taskState.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
|
||||
// NOTE: when tool is rejected, iterator stream is interrupted and it waits for userMessageContentReady to be true. Future calls to present will skip execution since didRejectTool and iterate until contentIndex is set to message length and it sets userMessageContentReady to true itself (instead of preemptively doing it in iterator)
|
||||
// Also advance when a tool was used and parallel calling is disabled
|
||||
// For parallel blocks, we use the last block in the batch to determine completion
|
||||
const effectiveBlock =
|
||||
block.type === "tool_use"
|
||||
? (this.taskState.assistantMessageContent[this.taskState.currentStreamingContentIndex] ?? block)
|
||||
: block
|
||||
if (
|
||||
!block.partial ||
|
||||
!effectiveBlock.partial ||
|
||||
this.taskState.didRejectTool ||
|
||||
(!this.isParallelToolCallingEnabled() && this.taskState.didAlreadyUseTool)
|
||||
) {
|
||||
@@ -2223,7 +2303,7 @@ export class Task {
|
||||
const { response, text, images, files } = await this.ask(
|
||||
"mistake_limit_reached",
|
||||
this.api.getModel().id.includes("claude")
|
||||
? `This may indicate a failure in his thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
|
||||
? `This may indicate a failure in Cline's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4 Sonnet for its advanced agentic coding capabilities.",
|
||||
)
|
||||
if (response === "messageResponse") {
|
||||
@@ -2496,6 +2576,10 @@ export class Task {
|
||||
Logger.log("updating partial message", lastMessage)
|
||||
// await this.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
const subAgentCosts = ClineAgent.getAllAgentCosts()
|
||||
if (subAgentCosts) {
|
||||
taskMetrics.totalCost = (taskMetrics.totalCost ?? 0) + subAgentCosts
|
||||
}
|
||||
// update api_req_started to have cancelled and cost, so that we can display the cost of the partial stream
|
||||
await updateApiReqMsg({
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
@@ -2571,7 +2655,6 @@ export class Task {
|
||||
await this.diffViewProvider.reset()
|
||||
this.streamHandler.reset()
|
||||
this.taskState.toolUseIdMap.clear()
|
||||
this.taskState.errorPushedForCallIds.clear()
|
||||
|
||||
const { toolUseHandler, reasonsHandler } = this.streamHandler.getHandlers()
|
||||
const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk)
|
||||
@@ -2595,6 +2678,7 @@ export class Task {
|
||||
taskMetrics.cacheWriteTokens += chunk.cacheWriteTokens ?? 0
|
||||
taskMetrics.cacheReadTokens += chunk.cacheReadTokens ?? 0
|
||||
taskMetrics.totalCost = chunk.totalCost ?? taskMetrics.totalCost
|
||||
|
||||
break
|
||||
case "reasoning": {
|
||||
// Process the reasoning delta through the handler
|
||||
@@ -2669,6 +2753,11 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
const subAgentCosts = ClineAgent.getAllAgentCosts()
|
||||
if (subAgentCosts) {
|
||||
taskMetrics.totalCost = (taskMetrics.totalCost ?? 0) + subAgentCosts
|
||||
}
|
||||
|
||||
// present content to user - we don't want the stream to break if present fails, so we catch errors here
|
||||
await this.presentAssistantMessage().catch((error) =>
|
||||
Logger.debug("[Task] Failed to present message: " + error),
|
||||
@@ -2771,6 +2860,11 @@ export class Task {
|
||||
})
|
||||
}
|
||||
|
||||
const subAgentCosts = ClineAgent.getAllAgentCosts()
|
||||
if (subAgentCosts) {
|
||||
taskMetrics.totalCost = (taskMetrics.totalCost ?? 0) + subAgentCosts
|
||||
}
|
||||
|
||||
// Update the api_req_started message with final usage and cost details
|
||||
await updateApiReqMsg({
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
@@ -3045,6 +3139,15 @@ export class Task {
|
||||
this.workspaceManager,
|
||||
)
|
||||
|
||||
// Create MCP prompt fetcher callback that wraps mcpHub.getPrompt
|
||||
const mcpPromptFetcher = async (serverName: string, promptName: string) => {
|
||||
try {
|
||||
return await this.mcpHub.getPrompt(serverName, promptName)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const { processedText, needsClinerulesFileCheck: needsCheck } = await parseSlashCommands(
|
||||
parsedText,
|
||||
localWorkflowToggles,
|
||||
@@ -3053,6 +3156,7 @@ export class Task {
|
||||
focusChainSettings,
|
||||
useNativeToolCalls,
|
||||
providerInfo,
|
||||
mcpPromptFetcher,
|
||||
)
|
||||
|
||||
if (needsCheck) {
|
||||
|
||||
@@ -8,8 +8,10 @@ import { ClineMessage } from "@/shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@/shared/getApiMetrics"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { convertClineMessageToProto } from "@/shared/proto-conversions/cline-message"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { sendPartialMessageEvent } from "../controller/ui/subscribeToPartialMessage"
|
||||
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
|
||||
import { TaskState } from "./TaskState"
|
||||
|
||||
@@ -217,4 +219,34 @@ export class MessageStateHandler {
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the content of a message identified by its timestamp
|
||||
* Finds the message by ts and replaces its text content
|
||||
* The entire operation is atomic to prevent races (RC-4)
|
||||
* @param ts - The timestamp of the message to update
|
||||
* @param content - The new content to set
|
||||
* @returns true if the message was found and updated, false otherwise
|
||||
*/
|
||||
async replaceMessageContentByUid(uid: string, content: string, partial = true): Promise<boolean> {
|
||||
const index = this.clineMessages.findIndex((m) => m.uid === uid)
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Update the message content
|
||||
this.clineMessages[index].text = content
|
||||
this.clineMessages[index].partial = partial
|
||||
|
||||
// // Save changes and update history
|
||||
// await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
|
||||
// Send partial message event to update the webview in real-time
|
||||
// This is necessary because replaceMessageContentByUid is used for streaming updates
|
||||
// (e.g., Subagent progress updates) that need to be reflected in the UI immediately
|
||||
const protoMessage = convertClineMessageToProto(this.clineMessages[index])
|
||||
sendPartialMessageEvent(protoMessage)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ export interface IFullyManagedTool extends IToolHandler, IPartialBlockHandler {
|
||||
// Marker interface for tools that handle their own complete approval flow
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tool handlers that support cancellation via abort.
|
||||
*/
|
||||
export interface IAbortableToolHandler {
|
||||
abort(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper class that allows a single tool handler to be registered under multiple names.
|
||||
* This provides proper typing for tools that share the same implementation logic.
|
||||
@@ -84,4 +91,23 @@ export class ToolExecutorCoordinator {
|
||||
}
|
||||
return handler.execute(config, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort all running abortable tool handlers.
|
||||
* This is called when the task is cancelled to stop any in-progress agent executions.
|
||||
*/
|
||||
abortAll(): void {
|
||||
for (const handler of this.handlers.values()) {
|
||||
if (this.isAbortable(handler)) {
|
||||
handler.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a handler implements IAbortableToolHandler
|
||||
*/
|
||||
private isAbortable(handler: IToolHandler): handler is IToolHandler & IAbortableToolHandler {
|
||||
return "abort" in handler && typeof (handler as IAbortableToolHandler).abort === "function"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { Subagent } from "@/core/agents/Subagent"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IAbortableToolHandler, IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
|
||||
/**
|
||||
* Handler for the task_subagent tool.
|
||||
* Launches a TaskAgent to perform complex, multi-step tasks autonomously.
|
||||
* The agent has access to search and bash tools to gather information.
|
||||
*/
|
||||
export class SubagentHandler implements IFullyManagedTool, IAbortableToolHandler {
|
||||
readonly name = ClineDefaultTool.SUBAGENT
|
||||
private abortController?: AbortController
|
||||
|
||||
getDescription(block: ToolUse): string {
|
||||
return `[${block.name}]`
|
||||
}
|
||||
|
||||
private buildToolMessage(prompt: string, content: string): string {
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content,
|
||||
regex: undefined,
|
||||
filePattern: prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
private buildToolMessageWithHistory(
|
||||
prompt: string,
|
||||
statusHistory: import("@/shared/cline/subagent").SubagentStatusEntry[],
|
||||
): string {
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content: JSON.stringify(statusHistory),
|
||||
regex: undefined,
|
||||
filePattern: prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
buildPartialToolMessage(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): string {
|
||||
const prompt = uiHelpers.removeClosingTag(block, "prompt", block.params.prompt)
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content: "",
|
||||
regex: undefined,
|
||||
filePattern: prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
async handlePartialBlock(block: ToolUse, _uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
if (!block.params.prompt) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
|
||||
const prompt: string | undefined = block.params.prompt
|
||||
|
||||
// Validate required parameter
|
||||
if (!prompt || !block.call_id) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError(block.name, "prompt")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Run PreToolUse hook after approval but before execution
|
||||
try {
|
||||
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
|
||||
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
|
||||
} catch (error) {
|
||||
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
|
||||
if (error instanceof PreToolUseHookCancellationError) {
|
||||
return formatResponse.toolDenied()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Execute the task agent
|
||||
return await this.performTask(config, prompt, block.call_id ?? "")
|
||||
}
|
||||
|
||||
private async performTask(config: TaskConfig, prompt: string, callId: string): Promise<ToolResponse> {
|
||||
try {
|
||||
// Create AbortController for this task execution
|
||||
this.abortController = new AbortController()
|
||||
|
||||
// Create agent with max 50 iterations for complex tasks
|
||||
const agent = new Subagent(callId, prompt, config, 30, undefined, config.api, this.abortController?.signal)
|
||||
|
||||
const taskResults = await agent.execute(prompt)
|
||||
// Check if aborted
|
||||
if (this.abortController?.signal?.aborted) {
|
||||
const abortMessage = "[Subagent] Task was cancelled."
|
||||
const abortToolMessage = this.buildToolMessage(prompt, abortMessage)
|
||||
await config.callbacks.replaceMessageContentByUid(callId, abortToolMessage, false)
|
||||
return abortMessage
|
||||
}
|
||||
|
||||
// Use the agent's status history for the final message to preserve the timeline UI
|
||||
const completeMessage = this.buildToolMessageWithHistory(prompt, agent.getStatusHistory())
|
||||
await config.callbacks.replaceMessageContentByUid(callId, completeMessage, false)
|
||||
|
||||
return taskResults
|
||||
} catch (error) {
|
||||
const errorMessage = `[Subagent] Task Failed ${error instanceof Error ? error.message : String(error)}`
|
||||
Logger.error(errorMessage)
|
||||
const errorToolMessage = this.buildToolMessage(prompt, errorMessage)
|
||||
await config.callbacks.replaceMessageContentByUid(callId, errorToolMessage, false)
|
||||
return errorMessage
|
||||
} finally {
|
||||
this.abortController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the currently running task agent, if any.
|
||||
*/
|
||||
public abort(): void {
|
||||
this.abortController?.abort()
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
@@ -139,35 +138,7 @@ export class WebFetchToolHandler implements IFullyManagedTool {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Execute the actual fetch
|
||||
const baseUrl = ClineEnv.config().apiBaseUrl
|
||||
const authToken = await AuthService.getInstance().getAuthToken()
|
||||
|
||||
if (!authToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/api/v1/search/webfetch`,
|
||||
{
|
||||
Url: url,
|
||||
Prompt: prompt,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Task-ID": config.ulid || "",
|
||||
...(await buildClineExtraHeaders()),
|
||||
},
|
||||
timeout: 15000,
|
||||
...getAxiosSettings(),
|
||||
},
|
||||
)
|
||||
|
||||
// Parse response
|
||||
// Axios will throw on non-200 status, so no need to check fetchStatus
|
||||
const result = response.data.data.result
|
||||
const result = await webfetch(url, prompt, config.ulid)
|
||||
|
||||
return formatResponse.toolResult(result)
|
||||
} catch (error) {
|
||||
@@ -175,3 +146,35 @@ export class WebFetchToolHandler implements IFullyManagedTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function webfetch(url: string, prompt: string, ulid = ""): Promise<string> {
|
||||
try {
|
||||
// Execute the actual fetch
|
||||
const baseUrl = ClineEnv.config().apiBaseUrl
|
||||
const authToken = await AuthService.getInstance().getAuthToken()
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/api/v1/search/webfetch`,
|
||||
{
|
||||
Url: url,
|
||||
Prompt: prompt,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Task-ID": ulid,
|
||||
...(await buildClineExtraHeaders()),
|
||||
},
|
||||
timeout: 15000,
|
||||
...getAxiosSettings(),
|
||||
},
|
||||
)
|
||||
|
||||
// Parse response
|
||||
// Axios will throw on non-200 status, so no need to check fetchStatus
|
||||
return response?.data?.data?.result
|
||||
} catch (error: any) {
|
||||
return `Error fetching web content: ${(error as Error).message}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,8 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
return await config.callbacks.sayAndCreateMissingParamError(block.name, "content")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
// NOTE: Do NOT reset consecutiveMistakeCount here - it should only be reset after successful completion
|
||||
// The reset was moved to after saveChanges() succeeds to properly track consecutive failures
|
||||
|
||||
try {
|
||||
const result = await this.validateAndPrepareFileOperation(config, block, rawRelPath, rawDiff, rawContent)
|
||||
@@ -297,6 +298,9 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
const { newProblemsMessage, userEdits, autoFormattingEdits, finalContent } =
|
||||
await config.services.diffViewProvider.saveChanges()
|
||||
|
||||
// Reset consecutive mistake counter on successful file operation
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
config.taskState.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
|
||||
|
||||
// Track file edit operation
|
||||
@@ -423,13 +427,14 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
newContent = result.newContent
|
||||
matchIndices = result.matchIndices
|
||||
} catch (error) {
|
||||
// Check if we've already pushed an error for this specific tool call (prevents duplicates during streaming)
|
||||
const callId = block.call_id || ""
|
||||
if (callId && config.taskState.errorPushedForCallIds.has(callId)) {
|
||||
// During streaming (block.partial=true), the diff may fail repeatedly as incomplete content streams in.
|
||||
// Skip all error UI handling for partial blocks to prevent flickering.
|
||||
if (block.partial) {
|
||||
return
|
||||
}
|
||||
|
||||
// Full original behavior - comprehensive error handling even for partial blocks
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
|
||||
// Removes any existing diff_error messages to avoid duplicates.
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "diff_error")
|
||||
await config.callbacks.say("diff_error", relPath, undefined, undefined, true)
|
||||
@@ -461,10 +466,6 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
config.taskState.toolUseIdMap,
|
||||
)
|
||||
|
||||
// Mark this call as having had its error pushed (prevents duplicates during streaming)
|
||||
if (callId) {
|
||||
config.taskState.errorPushedForCallIds.add(callId)
|
||||
}
|
||||
if (!config.enableParallelToolCalling) {
|
||||
config.taskState.didAlreadyUseTool = true
|
||||
}
|
||||
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
import { beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { TaskState } from "../../../TaskState"
|
||||
|
||||
/**
|
||||
* Tests for consecutiveMistakeCount behavior in WriteToFileToolHandler.
|
||||
*
|
||||
* These tests verify the fix for the infinite retry loop bug where:
|
||||
* - The counter was being reset to 0 at the START of each operation
|
||||
* - This prevented the tooManyMistakes check from seeing accumulated failures
|
||||
* - The model could retry failing replace_in_file operations indefinitely
|
||||
*
|
||||
* The fix ensures:
|
||||
* - Counter is only reset AFTER successful saveChanges()
|
||||
* - Counter is incremented on diff errors and parameter errors
|
||||
* - Repeated failures accumulate so tooManyMistakes can trigger
|
||||
*/
|
||||
describe("WriteToFileToolHandler consecutiveMistakeCount", () => {
|
||||
let taskState: TaskState
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(() => {
|
||||
taskState = new TaskState()
|
||||
sandbox = sinon.createSandbox()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("counter initialization", () => {
|
||||
it("should start at 0", () => {
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("counter NOT reset at operation start", () => {
|
||||
/**
|
||||
* This is the core bug fix test.
|
||||
* Previously, the counter was reset to 0 at the start of execute(),
|
||||
* which prevented accumulated failures from being detected.
|
||||
*/
|
||||
it("should preserve existing count when starting a new operation", () => {
|
||||
// Simulate previous failures
|
||||
taskState.consecutiveMistakeCount = 2
|
||||
|
||||
// Simulate the START of execute() - counter should NOT be reset here
|
||||
// (In the buggy code, there was: config.taskState.consecutiveMistakeCount = 0)
|
||||
// After the fix, this reset is removed
|
||||
|
||||
// Counter should still be 2 (not reset to 0)
|
||||
taskState.consecutiveMistakeCount.should.equal(2)
|
||||
})
|
||||
|
||||
it("should allow tooManyMistakes check to see accumulated failures", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
|
||||
// Simulate 3 previous failures
|
||||
taskState.consecutiveMistakeCount = 3
|
||||
|
||||
// The tooManyMistakes check happens BEFORE the operation
|
||||
// It should be able to see the accumulated count
|
||||
const shouldTriggerMistakeLimit = taskState.consecutiveMistakeCount >= maxConsecutiveMistakes
|
||||
shouldTriggerMistakeLimit.should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("counter reset after successful operation", () => {
|
||||
it("should reset to 0 only after successful saveChanges()", () => {
|
||||
// Simulate previous failures
|
||||
taskState.consecutiveMistakeCount = 2
|
||||
|
||||
// Simulate successful operation
|
||||
// (In WriteToFileToolHandler, this happens after saveChanges() succeeds)
|
||||
const saveChangesSucceeded = true
|
||||
if (saveChangesSucceeded) {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
}
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
})
|
||||
|
||||
it("should NOT reset if operation fails before saveChanges()", () => {
|
||||
// Simulate previous failures
|
||||
taskState.consecutiveMistakeCount = 2
|
||||
|
||||
// Simulate operation that fails (e.g., user denies, or validation fails)
|
||||
// saveChanges() never gets called
|
||||
const operationReachedSaveChanges = false
|
||||
if (operationReachedSaveChanges) {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
}
|
||||
|
||||
// Counter should still be 2
|
||||
taskState.consecutiveMistakeCount.should.equal(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("counter increment on diff errors", () => {
|
||||
/**
|
||||
* When constructNewFileContent throws (e.g., search string not found),
|
||||
* the counter should be incremented so repeated failures accumulate.
|
||||
*/
|
||||
it("should increment on diff construction error", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate diff error (search string not found)
|
||||
const diffError = new Error("SEARCH block content does not match anything in the file")
|
||||
|
||||
// In validateAndPrepareFileOperation, when diff error occurs:
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should accumulate consecutive diff errors", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate 3 consecutive diff errors
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Each diff error increments the counter
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(3)
|
||||
})
|
||||
|
||||
it("should trigger mistake limit after max consecutive diff errors", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate max consecutive diff errors
|
||||
for (let i = 0; i < maxConsecutiveMistakes; i++) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
const shouldTriggerMistakeLimit = taskState.consecutiveMistakeCount >= maxConsecutiveMistakes
|
||||
shouldTriggerMistakeLimit.should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("counter increment on missing parameter errors", () => {
|
||||
it("should increment when path parameter is missing", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate missing path parameter
|
||||
const pathMissing = true
|
||||
if (pathMissing) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should increment when diff parameter is missing for replace_in_file", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate missing diff parameter
|
||||
const diffMissing = true
|
||||
if (diffMissing) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should increment when content parameter is missing for write_to_file", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate missing content parameter
|
||||
const contentMissing = true
|
||||
if (contentMissing) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("realistic retry scenarios", () => {
|
||||
/**
|
||||
* Simulates the bug scenario: model retries a failing replace_in_file
|
||||
* multiple times. With the bug, counter would reset each time.
|
||||
* With the fix, counter accumulates.
|
||||
*/
|
||||
it("should accumulate failures across multiple retry attempts (bug fix verification)", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
|
||||
// Simulate 5 retry attempts with the FIXED behavior
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
// START of operation - counter should NOT be reset (the fix)
|
||||
// (Previously: taskState.consecutiveMistakeCount = 0 was here - BUG)
|
||||
|
||||
// Check if we should stop due to too many mistakes
|
||||
if (taskState.consecutiveMistakeCount >= maxConsecutiveMistakes) {
|
||||
// This should trigger after 3 failures
|
||||
break
|
||||
}
|
||||
|
||||
// Simulate diff error
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
// With the fix, we should have stopped after 3 failures
|
||||
taskState.consecutiveMistakeCount.should.equal(3)
|
||||
})
|
||||
|
||||
it("should NOT accumulate if buggy reset-at-start behavior existed", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
let attemptCount = 0
|
||||
|
||||
// Simulate the BUGGY behavior for comparison
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
attemptCount++
|
||||
|
||||
// BUGGY: Reset at start of operation (this was the bug)
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Check if we should stop - this will NEVER trigger because counter is always 0!
|
||||
if (taskState.consecutiveMistakeCount >= maxConsecutiveMistakes) {
|
||||
break
|
||||
}
|
||||
|
||||
// Simulate diff error
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
// Prevent infinite loop in test
|
||||
if (attemptCount >= 100) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// With the bug, we would loop 100 times without stopping
|
||||
attemptCount.should.equal(100)
|
||||
})
|
||||
|
||||
it("should reset counter and allow new operations after successful operation", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
|
||||
// Accumulate 2 failures
|
||||
taskState.consecutiveMistakeCount = 2
|
||||
|
||||
// Successful operation resets counter
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// New failures start from 0
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should handle mixed success and failure scenarios", () => {
|
||||
// Failure
|
||||
taskState.consecutiveMistakeCount++
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
|
||||
// Failure
|
||||
taskState.consecutiveMistakeCount++
|
||||
taskState.consecutiveMistakeCount.should.equal(2)
|
||||
|
||||
// Success - reset
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
|
||||
// Failure
|
||||
taskState.consecutiveMistakeCount++
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
|
||||
// Failure
|
||||
taskState.consecutiveMistakeCount++
|
||||
taskState.consecutiveMistakeCount.should.equal(2)
|
||||
|
||||
// Failure
|
||||
taskState.consecutiveMistakeCount++
|
||||
taskState.consecutiveMistakeCount.should.equal(3)
|
||||
|
||||
// Now mistake limit would trigger
|
||||
const maxConsecutiveMistakes = 3
|
||||
const shouldTrigger = taskState.consecutiveMistakeCount >= maxConsecutiveMistakes
|
||||
shouldTrigger.should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("interaction with other tool handlers", () => {
|
||||
/**
|
||||
* Other tool handlers also increment consecutiveMistakeCount on errors.
|
||||
* The counter should accumulate across different tool types.
|
||||
*/
|
||||
it("should accumulate across different tool error types", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate ReadFile missing parameter
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
// Simulate WriteToFile diff error
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
// Simulate another tool error
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(3)
|
||||
})
|
||||
|
||||
it("should reset from any successful tool operation", () => {
|
||||
// Accumulate errors from different tools
|
||||
taskState.consecutiveMistakeCount = 2
|
||||
|
||||
// Successful WriteToFile operation resets counter
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle counter at max value", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
taskState.consecutiveMistakeCount = maxConsecutiveMistakes
|
||||
|
||||
// Additional errors can still increment
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(4)
|
||||
})
|
||||
|
||||
it("should handle counter well above max (late detection)", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
|
||||
// Simulate scenario where check happens after many errors
|
||||
taskState.consecutiveMistakeCount = 10
|
||||
|
||||
const shouldTrigger = taskState.consecutiveMistakeCount >= maxConsecutiveMistakes
|
||||
shouldTrigger.should.be.true()
|
||||
})
|
||||
|
||||
it("should handle max value of 1 (strict mode)", () => {
|
||||
const maxConsecutiveMistakes = 1
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Single error
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
const shouldTrigger = taskState.consecutiveMistakeCount >= maxConsecutiveMistakes
|
||||
shouldTrigger.should.be.true()
|
||||
})
|
||||
|
||||
it("should handle max value of 0 (always trigger)", () => {
|
||||
const maxConsecutiveMistakes = 0
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Even with no errors, should trigger if max is 0
|
||||
const shouldTrigger = taskState.consecutiveMistakeCount >= maxConsecutiveMistakes
|
||||
shouldTrigger.should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("YOLO mode behavior", () => {
|
||||
/**
|
||||
* In YOLO mode (full auto-approval), the task fails when mistake limit is reached.
|
||||
* This tests that the counter works correctly regardless of approval mode.
|
||||
*/
|
||||
it("should trigger failure in YOLO mode after max mistakes", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
const yoloModeEnabled = true
|
||||
|
||||
// Simulate consecutive failures
|
||||
for (let i = 0; i < 3; i++) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
if (taskState.consecutiveMistakeCount >= maxConsecutiveMistakes) {
|
||||
if (yoloModeEnabled) {
|
||||
// In YOLO mode, task would fail
|
||||
const taskShouldFail = true
|
||||
taskShouldFail.should.be.true()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("background edits mode", () => {
|
||||
/**
|
||||
* Background edits mode shouldn't affect counter behavior.
|
||||
* The fix applies regardless of whether background edits are on/off.
|
||||
*/
|
||||
it("should increment on errors with background edits enabled", () => {
|
||||
const backgroundEditsEnabled = true
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate diff error (background edits mode doesn't change this)
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should increment on errors with background edits disabled", () => {
|
||||
const backgroundEditsEnabled = false
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate diff error
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should reset on success regardless of background edits setting", () => {
|
||||
taskState.consecutiveMistakeCount = 2
|
||||
|
||||
// Successful operation resets regardless of background edits mode
|
||||
const backgroundEditsEnabled = true
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("partial block streaming behavior", () => {
|
||||
/**
|
||||
* Tests for the fix that skips error UI handling during streaming.
|
||||
*
|
||||
* During streaming, handlePartialBlock is called repeatedly with block.partial=true.
|
||||
* If a diff error occurs (e.g., search string not found), we should skip all error
|
||||
* handling to prevent:
|
||||
* - consecutiveMistakeCount from rapidly incrementing
|
||||
* - diff_error messages from being added/removed repeatedly
|
||||
* - visual flickering in the diff viewer
|
||||
*
|
||||
* Error handling should only run once on the final block (block.partial=false).
|
||||
*/
|
||||
|
||||
it("should NOT increment counter when error occurs during partial block (streaming)", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate the streaming behavior where diff errors are skipped for partial blocks
|
||||
const isPartialBlock = true
|
||||
const diffError = new Error("SEARCH block content does not match anything in the file")
|
||||
|
||||
// In WriteToFileToolHandler.validateAndPrepareFileOperation, when block.partial=true:
|
||||
// if (block.partial) { return } - early return, no error handling
|
||||
if (!isPartialBlock) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
// Counter should remain 0 because error handling was skipped
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
})
|
||||
|
||||
it("should increment counter when error occurs on final block (not streaming)", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate the final block (streaming complete)
|
||||
const isPartialBlock = false
|
||||
const diffError = new Error("SEARCH block content does not match anything in the file")
|
||||
|
||||
// In WriteToFileToolHandler.validateAndPrepareFileOperation, when block.partial=false:
|
||||
// full error handling runs
|
||||
if (!isPartialBlock) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
// Counter should increment because this is the final block
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should not accumulate errors during streaming even with multiple partial block failures", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate multiple streaming chunks with diff failures
|
||||
// This happens when the search string isn't found because content is still streaming in
|
||||
for (let chunk = 0; chunk < 10; chunk++) {
|
||||
const isPartialBlock = true
|
||||
// Each chunk fails to find the search string
|
||||
|
||||
// With the fix: skip error handling for partial blocks
|
||||
if (!isPartialBlock) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Counter should still be 0 because all errors were during streaming
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
})
|
||||
|
||||
it("should increment exactly once when streaming ends with error on final block", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate streaming: multiple partial blocks with failures, then final block with failure
|
||||
const chunks = [
|
||||
{ partial: true, error: true }, // chunk 1 - fails, skipped
|
||||
{ partial: true, error: true }, // chunk 2 - fails, skipped
|
||||
{ partial: true, error: true }, // chunk 3 - fails, skipped
|
||||
{ partial: false, error: true }, // final block - fails, counted
|
||||
]
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.error && !chunk.partial) {
|
||||
// Only increment on final block errors
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Counter should be exactly 1 (only the final block error counted)
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should allow successful streaming to complete without incrementing counter", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate successful streaming where early chunks fail but final chunk succeeds
|
||||
const chunks = [
|
||||
{ partial: true, error: true }, // chunk 1 - incomplete, fails
|
||||
{ partial: true, error: true }, // chunk 2 - incomplete, fails
|
||||
{ partial: true, error: false }, // chunk 3 - now has enough content, succeeds
|
||||
{ partial: false, error: false }, // final block - succeeds
|
||||
]
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.error && !chunk.partial) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Counter should be 0 because all errors were during streaming (partial)
|
||||
// and the final block succeeded
|
||||
taskState.consecutiveMistakeCount.should.equal(0)
|
||||
})
|
||||
|
||||
it("should preserve existing counter value when partial block errors occur", () => {
|
||||
// Start with some previous failures
|
||||
taskState.consecutiveMistakeCount = 2
|
||||
|
||||
// Simulate partial block with diff error
|
||||
const isPartialBlock = true
|
||||
|
||||
if (!isPartialBlock) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
|
||||
// Counter should remain at 2 (no change during streaming)
|
||||
taskState.consecutiveMistakeCount.should.equal(2)
|
||||
})
|
||||
|
||||
it("should correctly accumulate final block errors across multiple operations", () => {
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate multiple replace_in_file operations, each with streaming then final failure
|
||||
for (let operation = 0; operation < 3; operation++) {
|
||||
// Streaming phase - multiple partial blocks with errors (skipped)
|
||||
for (let chunk = 0; chunk < 5; chunk++) {
|
||||
const isPartialBlock = true
|
||||
if (!isPartialBlock) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Final block with error (counted)
|
||||
const isPartialBlock = false
|
||||
if (!isPartialBlock) {
|
||||
taskState.consecutiveMistakeCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Should be 3: one for each operation's final block failure
|
||||
taskState.consecutiveMistakeCount.should.equal(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auto-approval mode", () => {
|
||||
/**
|
||||
* Auto-approval mode shouldn't affect counter behavior.
|
||||
* The fix applies regardless of whether auto-approval is on/off.
|
||||
*/
|
||||
it("should increment on errors with auto-approval enabled", () => {
|
||||
const autoApprovalEnabled = true
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate diff error
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should increment on errors with auto-approval disabled", () => {
|
||||
const autoApprovalEnabled = false
|
||||
taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Simulate diff error
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
taskState.consecutiveMistakeCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should accumulate failures regardless of approval path taken", () => {
|
||||
const maxConsecutiveMistakes = 3
|
||||
|
||||
// First attempt - auto-approved, but fails
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
// Second attempt - manually approved, but fails
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
// Third attempt - auto-approved, but fails
|
||||
taskState.consecutiveMistakeCount++
|
||||
|
||||
// Mistake limit should trigger
|
||||
const shouldTrigger = taskState.consecutiveMistakeCount >= maxConsecutiveMistakes
|
||||
shouldTrigger.should.be.true()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,7 +84,14 @@ export interface TaskServices {
|
||||
* All callback functions available to tool handlers
|
||||
*/
|
||||
export interface TaskCallbacks {
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
uid?: string,
|
||||
) => Promise<number | undefined>
|
||||
|
||||
ask: (
|
||||
type: ClineAsk,
|
||||
@@ -132,6 +139,9 @@ export interface TaskCallbacks {
|
||||
userContent: ClineContent[],
|
||||
context: "initial_task" | "resume" | "feedback",
|
||||
) => Promise<{ cancel?: boolean; wasCancelled?: boolean; contextModification?: string; errorMessage?: string }>
|
||||
|
||||
// Message content replacement by timestamp
|
||||
replaceMessageContentByUid: (uid: string, content: string, partial?: boolean) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,7 @@ export const TASK_CALLBACKS_KEYS = [
|
||||
"clearActiveHookExecution",
|
||||
"getActiveHookExecution",
|
||||
"runUserPromptSubmitHook",
|
||||
"replaceMessageContentByUid",
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
||||
+4
-13
@@ -1,48 +1,39 @@
|
||||
import { Controller } from "@core/controller"
|
||||
import { sendChatButtonClickedEvent } from "@core/controller/ui/subscribeToChatButtonClicked"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineAPI } from "./cline"
|
||||
|
||||
export function createClineAPI(sidebarController: Controller): ClineAPI {
|
||||
const api: ClineAPI = {
|
||||
startNewTask: async (task?: string, images?: string[]) => {
|
||||
HostProvider.get().logToChannel("Starting new task")
|
||||
await sidebarController.clearTask()
|
||||
await sidebarController.postStateToWebview()
|
||||
|
||||
await sendChatButtonClickedEvent()
|
||||
await sidebarController.initTask(task, images)
|
||||
HostProvider.get().logToChannel(
|
||||
`Task started with message: ${task ? `"${task}"` : "undefined"} and ${images?.length || 0} image(s)`,
|
||||
)
|
||||
},
|
||||
|
||||
sendMessage: async (message?: string, images?: string[]) => {
|
||||
HostProvider.get().logToChannel(
|
||||
`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
|
||||
)
|
||||
if (sidebarController.task) {
|
||||
await sidebarController.task.handleWebviewAskResponse("messageResponse", message || "", images || [])
|
||||
} else {
|
||||
HostProvider.get().logToChannel("No active task to send message to")
|
||||
Logger.error("No active task to send message to")
|
||||
}
|
||||
},
|
||||
|
||||
pressPrimaryButton: async () => {
|
||||
HostProvider.get().logToChannel("Pressing primary button")
|
||||
if (sidebarController.task) {
|
||||
await sidebarController.task.handleWebviewAskResponse("yesButtonClicked", "", [])
|
||||
} else {
|
||||
HostProvider.get().logToChannel("No active task to press button for")
|
||||
Logger.error("No active task to press button for")
|
||||
}
|
||||
},
|
||||
|
||||
pressSecondaryButton: async () => {
|
||||
HostProvider.get().logToChannel("Pressing secondary button")
|
||||
if (sidebarController.task) {
|
||||
await sidebarController.task.handleWebviewAskResponse("noButtonClicked", "", [])
|
||||
} else {
|
||||
HostProvider.get().logToChannel("No active task to press button for")
|
||||
Logger.error("No active task to press button for")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+4
-4
@@ -34,6 +34,7 @@ import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
|
||||
import { workspaceResolver } from "./core/workspace"
|
||||
import { findMatchingNotebookCell, getContextForCommand, showWebview } from "./hosts/vscode/commandUtils"
|
||||
import { abortCommitGeneration, generateCommitMsg } from "./hosts/vscode/commit-message-generator"
|
||||
import { registerClineOutputChannel } from "./hosts/vscode/hostbridge/env/debugLog"
|
||||
import {
|
||||
disposeVscodeCommentReviewController,
|
||||
getVscodeCommentReviewController,
|
||||
@@ -593,14 +594,13 @@ async function showJupyterPromptInput(title: string, placeholder: string): Promi
|
||||
}
|
||||
|
||||
function setupHostProvider(context: ExtensionContext) {
|
||||
Logger.log("Setting up vscode host providers...")
|
||||
const outputChannel = registerClineOutputChannel(context)
|
||||
outputChannel.appendLine("Setting up vscode host providers...")
|
||||
|
||||
const createWebview = () => new VscodeWebviewProvider(context)
|
||||
const createDiffView = () => new VscodeDiffViewProvider()
|
||||
const createCommentReview = () => getVscodeCommentReviewController()
|
||||
const createTerminalManager = () => new VscodeTerminalManager()
|
||||
const outputChannel = vscode.window.createOutputChannel("Cline")
|
||||
context.subscriptions.push(outputChannel)
|
||||
|
||||
const getCallbackUrl = async () => `${vscode.env.uriScheme || "vscode"}://${context.extension.id}`
|
||||
HostProvider.initialize(
|
||||
@@ -609,7 +609,7 @@ function setupHostProvider(context: ExtensionContext) {
|
||||
createCommentReview,
|
||||
createTerminalManager,
|
||||
vscodeHostBridgeClient,
|
||||
outputChannel.appendLine,
|
||||
() => {}, // No-op logger, logging is handled via HostProvider.env.debugLog
|
||||
getCallbackUrl,
|
||||
getBinaryLocation,
|
||||
context.extensionUri.fsPath,
|
||||
|
||||
@@ -113,7 +113,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
// if the extension is starting a new session, clear previous task state
|
||||
this.controller.clearTask()
|
||||
|
||||
HostProvider.get().logToChannel("Webview view resolved")
|
||||
Logger.log("[VscodeWebviewProvider] Webview view resolved")
|
||||
|
||||
// Title setting logic removed to allow VSCode to use the container title primarily.
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Empty, StringRequest } from "@shared/proto/cline/common"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
const CLINE_OUTPUT_CHANNEL = vscode.window.createOutputChannel("Cline")
|
||||
|
||||
// Appends a log message to all Cline output channels.
|
||||
export async function debugLog(request: StringRequest): Promise<Empty> {
|
||||
CLINE_OUTPUT_CHANNEL.appendLine(request.value)
|
||||
return Empty.create({})
|
||||
}
|
||||
|
||||
// Register the Cline output channel within the VSCode extension context.
|
||||
export function registerClineOutputChannel(context: vscode.ExtensionContext): vscode.OutputChannel {
|
||||
context.subscriptions.push(CLINE_OUTPUT_CHANNEL)
|
||||
return CLINE_OUTPUT_CHANNEL
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import { Logger } from "@/shared/services/Logger"
|
||||
*/
|
||||
export async function cleanupLegacyCheckpoints(): Promise<void> {
|
||||
try {
|
||||
HostProvider.get().logToChannel("Checking for legacy checkpoints...")
|
||||
|
||||
const tasksDir = path.join(HostProvider.get().globalStorageFsPath, "tasks")
|
||||
|
||||
// Check if tasks directory exists
|
||||
@@ -45,29 +43,27 @@ export async function cleanupLegacyCheckpoints(): Promise<void> {
|
||||
const checkpointsDir = path.join(mostRecentFolder.path, "checkpoints")
|
||||
|
||||
if (await fileExistsAtPath(checkpointsDir)) {
|
||||
HostProvider.get().logToChannel("Found legacy checkpoints directory, cleaning up...")
|
||||
|
||||
const results = { deleted: [] as string[], failed: [] as string[] }
|
||||
// Legacy checkpoints found, delete checkpoints directories in all task folders
|
||||
for (const folder of folderStats) {
|
||||
const folderCheckpointsDir = path.join(folder.path, "checkpoints")
|
||||
if (await fileExistsAtPath(folderCheckpointsDir)) {
|
||||
HostProvider.get().logToChannel(`Deleting legacy checkpoints in ${folder.folder}`)
|
||||
try {
|
||||
await fs.rm(folderCheckpointsDir, { recursive: true, force: true })
|
||||
results.deleted.push(folder.folder)
|
||||
} catch (_error) {
|
||||
// Ignore error if directory removal fails
|
||||
HostProvider.get().logToChannel(
|
||||
`Warning: Failed to delete checkpoints in ${folder.folder}, continuing...`,
|
||||
)
|
||||
results.failed.push(folder.folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HostProvider.get().logToChannel("Legacy checkpoints cleanup completed")
|
||||
Logger.info(
|
||||
`Legacy checkpoints cleanup completed. Deleted: ${results.deleted.length}, Failed: ${results.failed.length}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
HostProvider.get().logToChannel(`Error cleaning up legacy checkpoints: ${error}`)
|
||||
Logger.error("Error cleaning up legacy checkpoints:", error)
|
||||
throw new Error("Error cleaning up legacy checkpoints.", { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,10 @@ export abstract class DiffViewProvider {
|
||||
// Default no-op - subclasses can override if needed
|
||||
}
|
||||
|
||||
private lastUpdateContentLength = -1
|
||||
private lastUpdateTime = 0
|
||||
private static readonly UPDATE_THROTTLE_MS = 100 // Throttle updates to max 10/second during streaming
|
||||
|
||||
async update(
|
||||
accumulatedContent: string,
|
||||
isFinal: boolean,
|
||||
@@ -192,6 +196,25 @@ export abstract class DiffViewProvider {
|
||||
throw new Error("Not editing any file")
|
||||
}
|
||||
|
||||
// Throttle updates during streaming to prevent performance issues with large files
|
||||
// This is especially important for notebooks where streaming can trigger thousands of calls
|
||||
if (!isFinal) {
|
||||
const now = Date.now()
|
||||
const contentLength = accumulatedContent.length
|
||||
const timeSinceLastUpdate = now - this.lastUpdateTime
|
||||
|
||||
// Skip if: no content, content unchanged, or throttle period not elapsed
|
||||
if (contentLength === 0 || contentLength === this.lastUpdateContentLength) {
|
||||
return
|
||||
}
|
||||
if (timeSinceLastUpdate < DiffViewProvider.UPDATE_THROTTLE_MS) {
|
||||
return // Throttle: too soon since last update
|
||||
}
|
||||
|
||||
this.lastUpdateContentLength = contentLength
|
||||
this.lastUpdateTime = now
|
||||
}
|
||||
|
||||
// --- Fix to prevent duplicate BOM ---
|
||||
// Strip potential BOM from incoming content. VS Code's `applyEdit` might implicitly handle the BOM
|
||||
// when replacing from the start (0,0), and we want to avoid duplication.
|
||||
@@ -478,6 +501,8 @@ export abstract class DiffViewProvider {
|
||||
this.streamedLines = []
|
||||
this.createdDirs = []
|
||||
this.newContent = undefined
|
||||
this.lastUpdateContentLength = -1
|
||||
this.lastUpdateTime = 0
|
||||
|
||||
await this.resetDiffView()
|
||||
}
|
||||
|
||||
@@ -190,6 +190,236 @@ describe("DiffViewProvider content finalization with isFinal=true", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("DiffViewProvider Update Throttling", () => {
|
||||
// Tests for the throttling added in PR #8785 to prevent performance issues
|
||||
// during streaming, especially with large files like notebooks.
|
||||
//
|
||||
// Note: The update() method processes complete lines during streaming.
|
||||
// Content must contain newlines for replaceText to be called during streaming.
|
||||
// Only the final line (without trailing newline) is deferred until isFinal=true.
|
||||
|
||||
class ThrottleTestDiffViewProvider extends DiffViewProvider {
|
||||
public documentText: string = ""
|
||||
public replaceTextCallCount = 0
|
||||
|
||||
async openDiffEditor(): Promise<void> {}
|
||||
async scrollEditorToLine(_line: number): Promise<void> {}
|
||||
async scrollAnimation(_startLine: number, _endLine: number): Promise<void> {}
|
||||
async truncateDocument(_lineNumber: number): Promise<void> {}
|
||||
|
||||
async getDocumentLineCount(): Promise<number> {
|
||||
return this.documentText.split("\n").length
|
||||
}
|
||||
|
||||
async getDocumentText(): Promise<string | undefined> {
|
||||
return this.documentText
|
||||
}
|
||||
|
||||
async saveDocument(): Promise<Boolean> {
|
||||
return true
|
||||
}
|
||||
async closeAllDiffViews(): Promise<void> {}
|
||||
async resetDiffView(): Promise<void> {}
|
||||
|
||||
async replaceText(
|
||||
content: string,
|
||||
_rangeToReplace: { startLine: number; endLine: number },
|
||||
_currentLine: number | undefined,
|
||||
): Promise<void> {
|
||||
this.replaceTextCallCount++
|
||||
this.documentText = content
|
||||
}
|
||||
|
||||
public setup(initialContent: string) {
|
||||
this.isEditing = true
|
||||
this.documentText = initialContent
|
||||
this.originalContent = initialContent
|
||||
this.replaceTextCallCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
it("should skip empty content during streaming", async () => {
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial content\n")
|
||||
|
||||
await provider.update("", false) // empty, not final
|
||||
|
||||
assert.strictEqual(provider.replaceTextCallCount, 0, "Should not call replaceText for empty content")
|
||||
})
|
||||
|
||||
it("should skip unchanged content length during streaming", async () => {
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial\n")
|
||||
|
||||
// First update with complete line goes through
|
||||
await provider.update("line1\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1)
|
||||
|
||||
// Second update with same length should be skipped
|
||||
await provider.update("abcde\n", false) // same length (6)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "Should skip update with same content length")
|
||||
})
|
||||
|
||||
it("should throttle rapid updates during streaming", async () => {
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial\n")
|
||||
|
||||
// First update with complete line goes through
|
||||
await provider.update("line1\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1)
|
||||
|
||||
// Rapid subsequent updates with increasing length should be throttled
|
||||
await provider.update("line1\nab", false)
|
||||
await provider.update("line1\nabc", false)
|
||||
await provider.update("line1\nabcd", false)
|
||||
|
||||
// All should be throttled since they happen within 100ms
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "Rapid updates should be throttled")
|
||||
})
|
||||
|
||||
it("should allow update after throttle period", async function () {
|
||||
this.timeout(500) // Allow time for the delay
|
||||
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial\n")
|
||||
|
||||
// First update
|
||||
await provider.update("line1\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1)
|
||||
|
||||
// Wait for throttle period to elapse
|
||||
await new Promise((resolve) => setTimeout(resolve, 110))
|
||||
|
||||
// Next update should go through
|
||||
await provider.update("line1\nline2\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 2, "Update after throttle period should go through")
|
||||
})
|
||||
|
||||
it("should always process final updates regardless of throttling", async () => {
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial\n")
|
||||
|
||||
// First update
|
||||
await provider.update("line1\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1)
|
||||
|
||||
// Final update should bypass throttling even with same length
|
||||
await provider.update("line1\nend", true)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 2, "Final update should bypass throttling")
|
||||
})
|
||||
|
||||
it("should process final update even with empty content", async () => {
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial content\n")
|
||||
|
||||
// Empty final update still processes - it replaces with empty content
|
||||
// (file cleared). The throttle check is bypassed for isFinal=true.
|
||||
await provider.update("", true)
|
||||
|
||||
// Empty content splits to [""], which is still processed as a line
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "Empty final update should be processed")
|
||||
})
|
||||
|
||||
it("should process final update even with same content length", async () => {
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial\n")
|
||||
|
||||
// First update
|
||||
await provider.update("line1\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1)
|
||||
|
||||
// Final update with same length should still go through
|
||||
await provider.update("abcde\n", true) // same length (6)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 2, "Final update should bypass length check")
|
||||
})
|
||||
|
||||
it("should reset throttle state on reset()", async function () {
|
||||
this.timeout(500)
|
||||
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial\n")
|
||||
|
||||
// First update
|
||||
await provider.update("line1\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1)
|
||||
|
||||
// Rapid update with larger content should be throttled
|
||||
await provider.update("line1\nline2\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "Should be throttled before reset")
|
||||
|
||||
// Reset clears throttle state
|
||||
await provider.reset()
|
||||
|
||||
// Re-setup for next test
|
||||
provider.setup("initial\n")
|
||||
|
||||
// Update immediately after reset should go through (throttle state cleared)
|
||||
await provider.update("newcontent\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "Update after reset should go through immediately")
|
||||
})
|
||||
|
||||
it("should allow first update to go through immediately", async () => {
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("initial\n")
|
||||
|
||||
// Very first update should always go through
|
||||
await provider.update("content\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "First update should not be throttled")
|
||||
})
|
||||
|
||||
it("should handle streaming simulation with many rapid updates", async function () {
|
||||
this.timeout(500)
|
||||
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("")
|
||||
|
||||
// Simulate rapid streaming with complete lines (like notebook editing)
|
||||
// Each iteration adds a new line
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const content = Array.from({ length: i }, (_, j) => `line${j + 1}`).join("\n") + "\n"
|
||||
await provider.update(content, false)
|
||||
}
|
||||
|
||||
// Due to throttling, should have far fewer than 100 replaceText calls
|
||||
// First call always happens, rest are throttled
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "Should throttle rapid streaming updates")
|
||||
|
||||
// Wait for throttle to elapse
|
||||
await new Promise((resolve) => setTimeout(resolve, 110))
|
||||
|
||||
// Next update goes through
|
||||
const contentAfterWait = Array.from({ length: 100 }, (_, j) => `line${j + 1}`).join("\n") + "\nfinal line\n"
|
||||
await provider.update(contentAfterWait, false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 2, "Update after throttle should go through")
|
||||
|
||||
// Final update always goes through
|
||||
await provider.update(contentAfterWait + "end", true)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 3, "Final update should go through")
|
||||
})
|
||||
|
||||
it("should throttle by time regardless of content length changes", async function () {
|
||||
this.timeout(500)
|
||||
|
||||
const provider = new ThrottleTestDiffViewProvider()
|
||||
provider.setup("")
|
||||
|
||||
// First update
|
||||
await provider.update("line1\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1)
|
||||
|
||||
// Immediate update with longer content - still throttled by time
|
||||
await provider.update("line1\nline2\nline3\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 1, "Should still throttle by time even with different length")
|
||||
|
||||
// Wait for throttle
|
||||
await new Promise((resolve) => setTimeout(resolve, 110))
|
||||
|
||||
// Now should go through
|
||||
await provider.update("line1\nline2\nline3\nline4\n", false)
|
||||
assert.strictEqual(provider.replaceTextCallCount, 2, "Should update after throttle period")
|
||||
})
|
||||
})
|
||||
|
||||
describe("DiffViewProvider Newline Preservation", () => {
|
||||
it("preserves trailing newline when content ends with newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
|
||||
@@ -79,8 +79,10 @@ async function extractTextFromIPYNB(filePath: string): Promise<string> {
|
||||
const encoding = await detectEncoding(fileBuffer)
|
||||
const data = iconv.decode(fileBuffer, encoding)
|
||||
|
||||
// Return sanitized JSON for proper editing (enhanced notebook behavior is now always enabled)
|
||||
return sanitizeNotebookForLLM(data)
|
||||
// Strip all outputs to reduce context size - outputs aren't needed for understanding
|
||||
// notebook structure. For Jupyter commands, the specific cell's outputs are included
|
||||
// separately via sanitizeCellForLLM which preserves text outputs.
|
||||
return sanitizeNotebookForLLM(data, true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user