mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e073277d70 | |||
| 01547ba1f4 | |||
| 7f1632f09f | |||
| 5a2756d778 | |||
| 1b0ab3d01b | |||
| a41753c9a1 | |||
| dce0902596 | |||
| 9e7a30bd34 | |||
| 2b1b1d1cf2 | |||
| fcf3792f63 | |||
| 0e833ade82 | |||
| 4455db5198 | |||
| 03ab2968a6 | |||
| a0d52d4d59 | |||
| 75fbeb4aad | |||
| 70db6bde34 | |||
| 02c2601e0e | |||
| 94692b5091 | |||
| a2794c680f | |||
| 6d3f8e1d5d | |||
| 3e5847890b | |||
| 0eab54ab12 | |||
| 7fa0a4924b | |||
| 8680218e0e | |||
| 8787ab35b9 | |||
| 6ed3944f04 | |||
| 1dd8e763d1 | |||
| 28e6297769 | |||
| 5a2a5d1c0a | |||
| 113039a259 | |||
| 4023c18257 | |||
| 7c95b53892 | |||
| 9fd2b99be4 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": minor
|
||||
---
|
||||
|
||||
Adds Messages API support to Oracle Code Assist, adding functionality for Claude models
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add /q command to quit CLI
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix a streaming crash when a chunk has usage data but no `delta` by guarding reasoning field checks in provider handlers. Add regression tests for OpenRouter, Cline, Vercel AI Gateway, and Fireworks handlers to cover usage-only chunks.
|
||||
@@ -0,0 +1,4 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add missing smoke evaluation npm scripts so documented commands like `npm run eval:smoke:run` work from the repository root.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix acp auth check so acp mode can be used with more providers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Update SambaNova Provider models list and add temperature for models
|
||||
@@ -0,0 +1,64 @@
|
||||
# Storage Architecture
|
||||
|
||||
Global settings, secrets and workspace state are stored in **file-backed JSON stores** under `~/.cline/data/`. This is the shared storage layer used by VSCode, CLI, and JetBrains.
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
### `StorageContext` (src/shared/storage/storage-context.ts)
|
||||
The entry point. Created via `createStorageContext()` and passed to `StateManager.initialize()`. Contains three `ClineFileStorage` instances:
|
||||
- `globalState` → `~/.cline/data/globalState.json`
|
||||
- `secrets` → `~/.cline/data/secrets.json` (mode 0o600)
|
||||
- `workspaceState` → `~/.cline/data/workspaces/<hash>/workspaceState.json`
|
||||
|
||||
### `ClineFileStorage` (src/shared/storage/ClineFileStorage.ts)
|
||||
Synchronous JSON key-value store backed by a single file. Supports `get()`, `set()`, `setBatch()`, `delete()`. Writes are atomic (write-then-rename).
|
||||
|
||||
### `StateManager` (src/core/storage/StateManager.ts)
|
||||
In-memory cache on top of `StorageContext`. All runtime reads hit the cache; writes update cache immediately and debounce-flush to disk.
|
||||
|
||||
## ⚠️ Do NOT Use VSCode's ExtensionContext for Storage
|
||||
|
||||
**Do not** read from or write to `context.globalState`, `context.workspaceState`, or `context.secrets` for persistent data. These are VSCode-specific and not available on CLI or JetBrains.
|
||||
|
||||
Instead, use:
|
||||
```typescript
|
||||
// Reading state
|
||||
StateManager.get().getGlobalStateKey("myKey")
|
||||
StateManager.get().getSecretKey("mySecretKey")
|
||||
StateManager.get().getWorkspaceStateKey("myWsKey")
|
||||
|
||||
// Writing state
|
||||
StateManager.get().setGlobalState("myKey", value)
|
||||
StateManager.get().setSecret("mySecretKey", value)
|
||||
StateManager.get().setWorkspaceState("myWsKey", value)
|
||||
```
|
||||
|
||||
Remember that your data may be read by a different client than the one that wrote it. For example, a value written by Cline in JetBrains may be read by Cline CLI.
|
||||
|
||||
## VSCode Migration (src/hosts/vscode/vscode-to-file-migration.ts)
|
||||
|
||||
On VSCode startup, a migration copies data from VSCode's `ExtensionContext` storage into the file-backed stores. This runs in `src/common.ts` before `StateManager.initialize()`.
|
||||
|
||||
- **Sentinel**: `__vscodeMigrationVersion` key in global state and workspace state — prevents re-migration.
|
||||
- **Merge strategy**: File store wins. Existing values are never overwritten.
|
||||
- **Safe downgrade**: VSCode storage is NOT cleared, so older extension versions still work.
|
||||
|
||||
## Adding New Storage Keys
|
||||
|
||||
1. Add to `src/shared/storage/state-keys.ts` (see existing patterns)
|
||||
2. Read/write via `StateManager` (NOT via `context.globalState`)
|
||||
3. If adding a secret, add to `SecretKeys` array in `state-keys.ts`
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
~/.cline/
|
||||
data/
|
||||
globalState.json # Global settings & state
|
||||
secrets.json # API keys (mode 0o600)
|
||||
tasks/
|
||||
taskHistory.json # Task history (separate file)
|
||||
workspaces/
|
||||
<hash>/
|
||||
workspaceState.json # Per-workspace toggles
|
||||
```
|
||||
@@ -36,7 +36,9 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm install --include=optional
|
||||
|
||||
Vendored
+2
-1
@@ -16,7 +16,8 @@
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
"${workspaceFolder}",
|
||||
"--disable-extensions"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [3.66.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
|
||||
## [3.65.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# cline
|
||||
|
||||
## 2.4.2
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- VSCode uses shared files for global, workspace and secret state.
|
||||
|
||||
## [2.4.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -56,6 +56,8 @@ Run a new task with a prompt.
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-i**, **\--images** *paths...* : Image file paths to include with the task
|
||||
@@ -144,6 +146,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.2",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
} from "@shared/api"
|
||||
import type { ClineAsk, ClineMessage as ClineMessageType } from "@shared/ExtensionMessage"
|
||||
import { CLI_ONLY_COMMANDS, VSCODE_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
||||
import { ClineEndpoint } from "@/config.js"
|
||||
import { Controller } from "@/core/controller"
|
||||
@@ -53,12 +52,12 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/index.js"
|
||||
import { AuthService } from "@/services/auth/AuthService.js"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import type { Mode } from "@/shared/storage/types"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
|
||||
import { ACPHostBridgeClientProvider } from "../acp/ACPHostBridgeClientProvider.js"
|
||||
import { AcpTerminalManager } from "../acp/AcpTerminalManager.js"
|
||||
import { isAuthConfigured } from "../index.js"
|
||||
import { fetchOpenRouterModels, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { CliContextResult, initializeCliContext } from "../vscode-context.js"
|
||||
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
|
||||
@@ -177,7 +176,7 @@ export class ClineAgent implements acp.Agent {
|
||||
this.clientCapabilities = params.clientCapabilities
|
||||
this.initializeHostProvider(this.clientCapabilities, connection)
|
||||
await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR)
|
||||
await StateManager.initialize(this.ctx.extensionContext)
|
||||
await StateManager.initialize(this.ctx.storageContext)
|
||||
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
@@ -266,7 +265,7 @@ export class ClineAgent implements acp.Agent {
|
||||
*/
|
||||
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
// Check if authentication is required
|
||||
const isAuthenticated = await this.isAuthConfigured()
|
||||
const isAuthenticated = await isAuthConfigured()
|
||||
if (!isAuthenticated) {
|
||||
throw RequestError.authRequired()
|
||||
}
|
||||
@@ -1007,13 +1006,14 @@ export class ClineAgent implements acp.Agent {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Check if auth data has been stored
|
||||
const authData = await secretStorage.get("cline:clineAccountId")
|
||||
const authData = stateManager.getSecretKey("cline:clineAccountId")
|
||||
if (authData) {
|
||||
Logger.debug("[ClineAgent] Authentication successful")
|
||||
|
||||
// Set up the provider configuration for cline
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("actModeApiProvider", "cline")
|
||||
stateManager.setGlobalState("planModeApiProvider", "cline")
|
||||
await stateManager.flushPendingState()
|
||||
@@ -1146,48 +1146,6 @@ export class ClineAgent implements acp.Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has authentication configured.
|
||||
* Returns true if they have either:
|
||||
* - Cline provider with stored auth data
|
||||
* - OpenAI Codex provider with OAuth credentials
|
||||
* - BYO provider with an API key configured
|
||||
*/
|
||||
private async isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
|
||||
|
||||
if (currentProvider === "cline") {
|
||||
// For Cline provider, check if we have stored auth data
|
||||
const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key)))
|
||||
return values.some(Boolean)
|
||||
}
|
||||
|
||||
// For OpenAI Codex provider, check OAuth credentials
|
||||
if (currentProvider === "openai-codex") {
|
||||
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
||||
return await openAiCodexOAuthManager.isAuthenticated()
|
||||
}
|
||||
|
||||
// For BYO providers, check if the API key is configured
|
||||
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (!keyField) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
for (const field of fields) {
|
||||
const value = await secretStorage.get(field)
|
||||
if (value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OpenAI Codex OAuth authentication flow.
|
||||
*
|
||||
@@ -1201,9 +1159,6 @@ export class ClineAgent implements acp.Agent {
|
||||
Logger.debug("[ClineAgent] Starting OpenAI Codex OAuth flow...")
|
||||
|
||||
try {
|
||||
// Initialize the OAuth manager with extension context
|
||||
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
||||
|
||||
// Get the authorization URL and start the callback server
|
||||
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-confi
|
||||
import { useValidProviders } from "../utils/providers"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { CUSTOM_MODEL_ID, getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { getProviderLabel } from "./ProviderPicker"
|
||||
|
||||
@@ -51,6 +52,7 @@ type AuthStep =
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "import"
|
||||
| "bedrock_custom"
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
@@ -76,7 +78,7 @@ const Select: React.FC<{
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
(_, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
@@ -142,7 +144,11 @@ const TextInput: React.FC<{
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="white">{displayValue || placeholder || ""}</Text>
|
||||
{!displayValue && placeholder ? (
|
||||
<Text color="gray">e.g. {placeholder}</Text>
|
||||
) : (
|
||||
<Text color="white">{displayValue || ""}</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -248,6 +254,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}, [])
|
||||
|
||||
// Reset provider index when search changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to reset here
|
||||
useEffect(() => {
|
||||
setProviderIndex(0)
|
||||
}, [providerSearch])
|
||||
@@ -273,7 +280,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
return
|
||||
}
|
||||
|
||||
if (authState.user && authState.user.email) {
|
||||
if (authState.user?.email) {
|
||||
// Auth succeeded - save configuration and transition to model selection
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setSelectedProvider("cline")
|
||||
@@ -389,6 +396,33 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
// Save custom Bedrock ARN configuration with base model for capability detection
|
||||
const saveCustomBedrockConfiguration = useCallback(
|
||||
async (arn: string, baseModelId: string) => {
|
||||
try {
|
||||
if (!bedrockConfig) {
|
||||
throw new Error("Bedrock configuration is missing")
|
||||
}
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: arn,
|
||||
customModelBaseId: baseModelId,
|
||||
controller,
|
||||
})
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[bedrockConfig, controller],
|
||||
)
|
||||
|
||||
const saveConfiguration = useCallback(
|
||||
async (model: string, base: string) => {
|
||||
try {
|
||||
@@ -423,6 +457,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
(value: string) => {
|
||||
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
|
||||
if (value === CUSTOM_MODEL_ID && selectedProvider === "bedrock") {
|
||||
setStep("bedrock_custom")
|
||||
return
|
||||
}
|
||||
|
||||
if (value.trim()) {
|
||||
setModelId(value)
|
||||
}
|
||||
@@ -530,6 +570,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
// Go back to cline_model if we came from there (Cline provider)
|
||||
if (selectedProvider === "cline") {
|
||||
setStep("cline_model")
|
||||
} else if (selectedProvider === "bedrock") {
|
||||
// Bedrock skips the API key step — go back to Bedrock setup
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
@@ -741,6 +784,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
/>
|
||||
)
|
||||
|
||||
case "bedrock_custom":
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={step === "bedrock_custom"}
|
||||
onCancel={() => setStep("modelid")}
|
||||
onComplete={(arn, baseModelId) => {
|
||||
setStep("saving")
|
||||
saveCustomBedrockConfiguration(arn, baseModelId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case "import":
|
||||
if (!importSource) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Bedrock Custom Model Flow component
|
||||
* Two-step flow: ARN/custom model ID input → base model selection for capability detection.
|
||||
* Used by both AuthView (onboarding) and SettingsPanelContent (/settings).
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
type FlowStep = "arn_input" | "base_model"
|
||||
|
||||
interface BedrockCustomModelFlowProps {
|
||||
/** Whether this component should capture keyboard input */
|
||||
isActive: boolean
|
||||
/** Called when the user completes both steps (ARN + base model selection) */
|
||||
onComplete: (arn: string, baseModelId: string) => void
|
||||
/** Called when the user presses Escape on the first step (ARN input) */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [step, setStep] = useState<FlowStep>("arn_input")
|
||||
const [customArn, setCustomArn] = useState("")
|
||||
|
||||
const handleArnSubmit = useCallback(() => {
|
||||
if (customArn.trim()) {
|
||||
setStep("base_model")
|
||||
}
|
||||
}, [customArn])
|
||||
|
||||
const handleBaseModelCancel = useCallback(() => {
|
||||
setStep("arn_input")
|
||||
}, [])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setCustomArn((prev) => prev + input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "base_model") {
|
||||
if (key.escape) {
|
||||
handleBaseModelCancel()
|
||||
}
|
||||
// Other input is handled by SearchableList
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
if (step === "arn_input") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Custom Model ID
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter your Application Inference Profile ARN or custom model ID</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
{customArn ? (
|
||||
<Text color="white">{customArn}</Text>
|
||||
) : (
|
||||
<Text color="gray">e.g. arn:aws:bedrock:region:account:application-inference-profile/...</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// step === "base_model"
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Base Inference Model
|
||||
</Text>
|
||||
<Text color="gray">Select the base model your inference profile uses (for capability detection)</Text>
|
||||
<Box marginTop={1}>
|
||||
<SearchableList
|
||||
isActive={isActive && step === "base_model"}
|
||||
items={getModelList("bedrock").map((id) => ({ id, label: id }))}
|
||||
onSelect={(item) => {
|
||||
onComplete(customArn, item.id)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1172,7 +1172,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "exit") {
|
||||
if (cmd.name === "exit" || cmd.name === "q") {
|
||||
handleExit()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,6 +88,10 @@ export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) =
|
||||
{" "}
|
||||
<Text color="white">/clear</Text> - Start a fresh task
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">/q</Text> - Quit Cline
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text>
|
||||
|
||||
@@ -71,6 +71,9 @@ import { COLORS } from "../constants/colors"
|
||||
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Special ID used to indicate the user wants to enter a custom model ID / ARN
|
||||
export const CUSTOM_MODEL_ID = "__custom__"
|
||||
|
||||
// Map providers to their static model lists and defaults
|
||||
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
|
||||
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
|
||||
@@ -169,12 +172,23 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
|
||||
return getModelList(provider)
|
||||
}, [provider, asyncModels])
|
||||
|
||||
// Providers that support custom model IDs (e.g., Bedrock Application Inference Profiles)
|
||||
const supportsCustomModel = provider === "bedrock"
|
||||
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
return modelList.map((modelId) => ({
|
||||
const list = modelList.map((modelId) => ({
|
||||
id: modelId,
|
||||
label: modelId,
|
||||
}))
|
||||
}, [modelList])
|
||||
// Add "Custom" option at the end for providers that support it
|
||||
if (supportsCustomModel) {
|
||||
list.push({
|
||||
id: CUSTOM_MODEL_ID,
|
||||
label: "Custom (ARN / Inference Profile)",
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [modelList, supportsCustomModel])
|
||||
|
||||
// For providers without a model picker, render nothing
|
||||
if (!hasModelPicker(provider)) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Mock ink's useApp
|
||||
const mockExit = vi.fn()
|
||||
vi.mock("ink", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ink")>()
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({ exit: mockExit }),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock child_process
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn().mockReturnValue(""),
|
||||
exec: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
|
||||
getGlobalStateKey: vi.fn().mockReturnValue([]),
|
||||
getApiConfiguration: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@shared/services/Session", () => ({
|
||||
Session: {
|
||||
get: () => ({
|
||||
getStats: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskContext: () => ({
|
||||
controller: {},
|
||||
clearState: vi.fn(),
|
||||
}),
|
||||
useTaskState: () => ({
|
||||
clineMessages: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
|
||||
}))
|
||||
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("Quit Command (/q and /exit)", () => {
|
||||
const mockOnExit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should exit the application when /q is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
|
||||
// Type /q
|
||||
stdin.write("/q")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should exit the application when /exit is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
|
||||
// Type /exit
|
||||
stdin.write("/exit")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { Checkbox } from "./Checkbox"
|
||||
import {
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { LanguagePicker } from "./LanguagePicker"
|
||||
import { hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { CUSTOM_MODEL_ID, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { OrganizationPicker } from "./OrganizationPicker"
|
||||
import { Panel, PanelTab } from "./Panel"
|
||||
@@ -171,6 +172,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [apiKeyValue, setApiKeyValue] = useState("")
|
||||
const [editValue, setEditValue] = useState("")
|
||||
|
||||
// Bedrock custom ARN flow state
|
||||
const [isBedrockCustomFlow, setIsBedrockCustomFlow] = useState(false)
|
||||
|
||||
// Settings state - single object for feature toggles
|
||||
const [features, setFeatures] = useState<Record<FeatureKey, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {}
|
||||
@@ -944,10 +948,56 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
setReasoningEffortForMode,
|
||||
])
|
||||
|
||||
// Handle completion of the Bedrock custom ARN flow (ARN + base model selected)
|
||||
const handleBedrockCustomFlowComplete = useCallback(
|
||||
async (arn: string, baseModelId: string) => {
|
||||
if (!pickingModelKey) return
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
|
||||
// Build a minimal BedrockConfig from current state for applyBedrockConfig
|
||||
const bedrockConfig: BedrockConfig = {
|
||||
awsRegion: apiConfig.awsRegion ?? "us-east-1",
|
||||
awsAuthentication: apiConfig.awsUseProfile ? "profile" : "credentials",
|
||||
awsUseCrossRegionInference: Boolean(apiConfig.awsUseCrossRegionInference),
|
||||
}
|
||||
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: arn,
|
||||
customModelBaseId: baseModelId,
|
||||
controller,
|
||||
})
|
||||
|
||||
// Flush pending state to ensure everything is persisted
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler if there's an active task
|
||||
rebuildTaskApi()
|
||||
|
||||
refreshModelIds()
|
||||
setIsBedrockCustomFlow(false)
|
||||
setPickingModelKey(null)
|
||||
|
||||
// If opened from /models command, close the entire settings panel
|
||||
if (initialMode) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[pickingModelKey, stateManager, controller, rebuildTaskApi, refreshModelIds, initialMode, onClose],
|
||||
)
|
||||
|
||||
// Handle model selection from picker
|
||||
const handleModelSelect = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!pickingModelKey) return
|
||||
|
||||
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
|
||||
if (modelId === CUSTOM_MODEL_ID && provider === "bedrock") {
|
||||
setIsPickingModel(false)
|
||||
setIsBedrockCustomFlow(true)
|
||||
return
|
||||
}
|
||||
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
@@ -1008,7 +1058,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[pickingModelKey, separateModels, stateManager, controller, refreshModelIds, initialMode, onClose],
|
||||
[pickingModelKey, separateModels, stateManager, controller, provider, refreshModelIds, initialMode, onClose],
|
||||
)
|
||||
|
||||
// Handle language selection from picker
|
||||
@@ -1332,6 +1382,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Bedrock custom flow - input handled by BedrockCustomModelFlow component
|
||||
if (isBedrockCustomFlow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (key.escape) {
|
||||
setIsEditing(false)
|
||||
@@ -1584,6 +1639,20 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Bedrock custom model flow (ARN input + base model selection)
|
||||
if (isBedrockCustomFlow) {
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={isBedrockCustomFlow}
|
||||
onCancel={() => {
|
||||
setIsBedrockCustomFlow(false)
|
||||
setIsPickingModel(true)
|
||||
}}
|
||||
onComplete={handleBedrockCustomFlowComplete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Account tab - loading state
|
||||
if (currentTab === "account" && isAccountLoading) {
|
||||
return (
|
||||
@@ -1748,6 +1817,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
isWaitingForClineAuth ||
|
||||
isShowingOcaEmployeeCheck ||
|
||||
isWaitingForOcaAuth ||
|
||||
isBedrockCustomFlow ||
|
||||
isEditing
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,16 +13,22 @@ export interface FeaturedModel {
|
||||
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
description: "Best balance of speed, cost, and quality",
|
||||
labels: ["BEST"],
|
||||
id: "google/gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro Preview",
|
||||
description: "Latest Gemini release with 1m ctx window and strong coding performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
description: "Latest Sonnet release with strong coding and agent performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
description: "State-of-the-art for complex coding",
|
||||
labels: ["NEW"],
|
||||
description: "Most intelligent model for agents and coding",
|
||||
labels: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
|
||||
+9
-40
@@ -8,12 +8,11 @@ import { Command } from "commander"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -74,24 +73,7 @@ async function disposeTelemetryServices(): Promise<void> {
|
||||
await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()])
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore yoloModeToggled to its original value from before this CLI session.
|
||||
* This ensures the --yolo flag is session-only and doesn't leak into future runs.
|
||||
* Must be called before flushPendingState so the restored value gets persisted.
|
||||
*/
|
||||
function restoreYoloState(): void {
|
||||
if (savedYoloModeToggled !== null) {
|
||||
try {
|
||||
StateManager.get().setGlobalState("yoloModeToggled", savedYoloModeToggled)
|
||||
savedYoloModeToggled = null
|
||||
} catch {
|
||||
// StateManager may not be initialized (e.g., early exit before init)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disposeCliContext(ctx: CliContext): Promise<void> {
|
||||
restoreYoloState()
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
@@ -204,12 +186,10 @@ function applyTaskOptions(options: TaskOptions): void {
|
||||
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
|
||||
}
|
||||
|
||||
// Override yolo mode only if --yolo flag is explicitly passed.
|
||||
// The original value is saved in initializeCli and restored on exit.
|
||||
// Set yolo mode as a session-scoped override so AutoApprove picks it up,
|
||||
// but it is never persisted to disk (setSessionOverride never touches pendingGlobalState).
|
||||
if (options.yolo) {
|
||||
const state = StateManager.get()
|
||||
savedYoloModeToggled = state.getGlobalSettingsKey("yoloModeToggled") ?? false
|
||||
state.setGlobalState("yoloModeToggled", true)
|
||||
StateManager.get().setSessionOverride("yoloModeToggled", true)
|
||||
telemetryService.captureHostEvent("yolo_flag", "true")
|
||||
}
|
||||
|
||||
@@ -314,9 +294,6 @@ let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
|
||||
let isPlainTextMode = false
|
||||
// Track the original yoloModeToggled value from before this CLI session so we can restore it on exit.
|
||||
// The --yolo flag should only affect the current invocation, not persist across runs.
|
||||
let savedYoloModeToggled: boolean | null = null
|
||||
|
||||
/**
|
||||
* Wait for stdout to fully drain before exiting.
|
||||
@@ -358,10 +335,6 @@ function setupSignalHandlers() {
|
||||
printWarning(`${signal} received, shutting down...`)
|
||||
|
||||
try {
|
||||
// Restore yolo state before any cleanup - this is idempotent and safe
|
||||
// even if disposeCliContext also calls it (restoreYoloState checks savedYoloModeToggled !== null)
|
||||
restoreYoloState()
|
||||
|
||||
if (activeContext) {
|
||||
const task = activeContext.controller.task
|
||||
if (task) {
|
||||
@@ -425,7 +398,7 @@ interface InitOptions {
|
||||
*/
|
||||
async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
const workspacePath = options.cwd || process.cwd()
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: workspacePath,
|
||||
})
|
||||
@@ -466,13 +439,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
DATA_DIR,
|
||||
)
|
||||
|
||||
await StateManager.initialize(extensionContext as any)
|
||||
|
||||
await StateManager.initialize(storageContext)
|
||||
await ErrorService.initialize()
|
||||
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(extensionContext)
|
||||
|
||||
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
|
||||
const controller = webview.controller
|
||||
|
||||
@@ -754,7 +723,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
@@ -828,7 +797,7 @@ devCommand
|
||||
* If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials
|
||||
* and sets the flag accordingly.
|
||||
*/
|
||||
async function isAuthConfigured(): Promise<boolean> {
|
||||
export async function isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Check welcomeViewCompleted first - this is the single source of truth
|
||||
@@ -983,7 +952,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface PlainTextTaskOptions {
|
||||
imageDataUrls?: string[]
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
/** Timeout in seconds (default: 600 = 10 minutes) */
|
||||
/** Timeout in seconds (only applied when explicitly provided) */
|
||||
timeoutSeconds?: number
|
||||
/** Task ID to resume an existing task */
|
||||
taskId?: string
|
||||
@@ -153,10 +153,14 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
throw new Error("Either taskId or prompt must be provided")
|
||||
}
|
||||
|
||||
// Normal mode: wait for task completion
|
||||
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
// Wait for task completion, with optional timeout only when explicitly configured
|
||||
if (options.timeoutSeconds) {
|
||||
const timeoutMs = options.timeoutSeconds * 1000
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
} else {
|
||||
await completionPromise
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error)
|
||||
if (jsonOutput) {
|
||||
|
||||
@@ -80,15 +80,18 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
|
||||
export interface ApplyBedrockConfigOptions {
|
||||
bedrockConfig: BedrockConfig
|
||||
modelId?: string
|
||||
customModelBaseId?: string // Base model ID for custom ARN/Inference Profile (for capability detection)
|
||||
controller?: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Bedrock provider configuration to state
|
||||
* Handles AWS-specific fields (authentication, region, credentials)
|
||||
* When customModelBaseId is provided, sets the custom model flags so the system
|
||||
* knows to use the ARN as the model ID and the base model for capability detection.
|
||||
*/
|
||||
export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Promise<void> {
|
||||
const { bedrockConfig, modelId, controller } = options
|
||||
const { bedrockConfig, modelId, customModelBaseId, controller } = options
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
@@ -108,6 +111,18 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
|
||||
if (planModelKey) config[planModelKey] = finalModelId
|
||||
}
|
||||
|
||||
// Handle custom model (Application Inference Profile ARN)
|
||||
if (customModelBaseId) {
|
||||
config.actModeAwsBedrockCustomSelected = true
|
||||
config.planModeAwsBedrockCustomSelected = true
|
||||
config.actModeAwsBedrockCustomModelBaseId = customModelBaseId
|
||||
config.planModeAwsBedrockCustomModelBaseId = customModelBaseId
|
||||
} else {
|
||||
// Ensure custom flags are cleared when using a standard model
|
||||
config.actModeAwsBedrockCustomSelected = false
|
||||
config.planModeAwsBedrockCustomSelected = false
|
||||
}
|
||||
|
||||
// Add optional AWS credentials
|
||||
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
|
||||
+56
-92
@@ -1,23 +1,21 @@
|
||||
/**
|
||||
* VSCode context stub for CLI mode
|
||||
* Provides mock implementations of VSCode extension context
|
||||
* Provides mock implementations of VSCode extension context.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ClineFileStorage } from "@/shared/storage"
|
||||
import type { ClineMemento } from "@/shared/storage/ClineStorage"
|
||||
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
|
||||
import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
/**
|
||||
* CLI-specific state overrides.
|
||||
* These values are always returned regardless of what's stored,
|
||||
@@ -35,33 +33,43 @@ const CLI_STATE_OVERRIDES: Record<string, any> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based Memento store with optional key overrides.
|
||||
* Implements VSCode's Memento interface using SyncJsonFileStorage.
|
||||
* Memento adapter that wraps a ClineFileStorage with optional key overrides.
|
||||
* Used for globalState where CLI needs to inject hardcoded overrides.
|
||||
*/
|
||||
class MementoStore extends ClineFileStorage {
|
||||
private overrides: Record<string, any>
|
||||
class MementoAdapter implements ClineMemento {
|
||||
constructor(
|
||||
private readonly store: ClineMemento,
|
||||
private readonly overrides: Record<string, any> = {},
|
||||
) {}
|
||||
|
||||
constructor(filePath: string, overrides: Record<string, any> = {}) {
|
||||
super(filePath, "MementoStore")
|
||||
this.overrides = overrides
|
||||
}
|
||||
|
||||
// VSCode Memento interface - override base class get() with overload support
|
||||
override get<T>(key: string): T | undefined
|
||||
override get<T>(key: string, defaultValue: T): T
|
||||
override get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
get<T>(key: string): T | undefined
|
||||
get<T>(key: string, defaultValue: T): T
|
||||
get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
if (key in this.overrides) {
|
||||
return this.overrides[key] as T
|
||||
}
|
||||
const value = super.get<T>(key)
|
||||
const value = this.store.get<T>(key)
|
||||
return value !== undefined ? value : defaultValue
|
||||
}
|
||||
|
||||
override async update(key: string, value: any): Promise<void> {
|
||||
if (key in this.overrides) {
|
||||
return
|
||||
update(key: string, value: any): Thenable<void> {
|
||||
return this.setBatch({ [key]: value })
|
||||
}
|
||||
|
||||
keys(): readonly string[] {
|
||||
return this.store.keys()
|
||||
}
|
||||
|
||||
setBatch(entries: Record<string, any>): Thenable<void> {
|
||||
// Filter out overridden keys and delegate to underlying store
|
||||
const filteredEntries: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!(key in this.overrides)) {
|
||||
filteredEntries[key] = value
|
||||
}
|
||||
}
|
||||
this.set(key, value)
|
||||
this.store.setBatch(filteredEntries)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
setKeysForSync(_keys: readonly string[]): void {
|
||||
@@ -69,81 +77,45 @@ class MementoStore extends ClineFileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based secret storage implementing VSCode's SecretStorage interface.
|
||||
* Uses sync storage internally but exposes async API for VSCode compatibility.
|
||||
*/
|
||||
class SecretStore {
|
||||
private storage: ClineFileStorage<string>
|
||||
private onDidChangeEmitter = {
|
||||
event: () => ({ dispose: () => {} }),
|
||||
fire: (_e: any) => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
|
||||
onDidChange = this.onDidChangeEmitter.event
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.storage = new ClineFileStorage<string>(filePath, "SecretStore")
|
||||
}
|
||||
|
||||
get(key: string): Promise<string | undefined> {
|
||||
return Promise.resolve(this.storage.get(key))
|
||||
}
|
||||
|
||||
store(key: string, value: string): Promise<void> {
|
||||
this.storage.set(key, value)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
this.storage.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export interface CliContextConfig {
|
||||
clineDir?: string
|
||||
/** The workspace directory being worked in (for hashing into storage path) */
|
||||
/** The workspace directory being worked in (used to compute workspace storage hash) */
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short hash of a string for use in directory names
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16).substring(0, 8)
|
||||
}
|
||||
|
||||
export interface CliContextResult {
|
||||
extensionContext: ClineExtensionContext
|
||||
storageContext: StorageContext
|
||||
DATA_DIR: string
|
||||
EXTENSION_DIR: string
|
||||
WORKSPACE_STORAGE_DIR: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VSCode-like context for CLI mode
|
||||
* Initialize the VSCode-like context for CLI mode.
|
||||
*
|
||||
* Creates a shared StorageContext (the single source of truth for all storage)
|
||||
* and wraps it in a ClineExtensionContext shell for legacy APIs that still
|
||||
* expect the VSCode ExtensionContext shape.
|
||||
*/
|
||||
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
|
||||
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
|
||||
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
|
||||
|
||||
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
|
||||
// where hash is derived from the workspace path to keep workspaces isolated
|
||||
const workspacePath = config.workspaceDir || process.cwd()
|
||||
const workspaceHash = hashString(workspacePath)
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
|
||||
// Create the shared StorageContext — this owns all ClineFileStorage instances.
|
||||
// CLI, JetBrains, and VSCode all share this same file-backed implementation.
|
||||
let storageContext = createStorageContext({
|
||||
clineDir: CLINE_DIR,
|
||||
workspacePath: config.workspaceDir || process.cwd(),
|
||||
workspaceStorageDir: process.env.WORKSPACE_STORAGE_DIR || undefined,
|
||||
})
|
||||
storageContext = {
|
||||
...storageContext,
|
||||
// Storage — delegates to storageContext stores (with CLI overrides for globalState)
|
||||
globalState: new MementoAdapter(storageContext.globalState, CLI_STATE_OVERRIDES),
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
const DATA_DIR = storageContext.dataDir
|
||||
const WORKSPACE_STORAGE_DIR = storageContext.workspaceStoragePath
|
||||
|
||||
// For CLI, extension dir is the package root (one level up from dist/)
|
||||
const EXTENSION_DIR = path.resolve(__dirname, "..")
|
||||
@@ -160,38 +132,30 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR
|
||||
extensionKind: ExtensionKind.UI,
|
||||
}
|
||||
|
||||
// Build the ClineExtensionContext shell. All storage delegates to storageContext —
|
||||
// there are NO separate ClineFileStorage instances here.
|
||||
const extensionContext: ClineExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
|
||||
// Set up KV stores (globalState has CLI-specific overrides)
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json"), CLI_STATE_OVERRIDES),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
|
||||
// Set up URIs
|
||||
// URIs / paths
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR,
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR,
|
||||
|
||||
// Logs
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR,
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR,
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
subscriptions: [],
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection() as any,
|
||||
|
||||
// Workspace state
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
return {
|
||||
extensionContext,
|
||||
storageContext,
|
||||
DATA_DIR,
|
||||
EXTENSION_DIR,
|
||||
WORKSPACE_STORAGE_DIR,
|
||||
|
||||
@@ -73,6 +73,8 @@ Cline stores configuration in `~/.cline/data/`:
|
||||
├── data/ # Configuration directory
|
||||
│ ├── globalState.json # Global settings
|
||||
│ ├── secrets.json # API keys (encrypted)
|
||||
│ ├── settings/ # Settings files
|
||||
│ │ └── cline_mcp_settings.json # MCP server configuration
|
||||
│ ├── workspace/ # Workspace-specific state
|
||||
│ └── tasks/ # Task history and data
|
||||
└── log/ # Log files
|
||||
@@ -172,6 +174,46 @@ cline --config ~/.cline-work "review this PR"
|
||||
cline --config ~/.cline-personal "help me with this side project"
|
||||
```
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, giving you access to external tools and data sources directly from the terminal. The CLI uses the same MCP configuration format as the VS Code extension.
|
||||
|
||||
### Setting Up MCP Servers
|
||||
|
||||
To configure MCP servers for the CLI, create or edit the settings file at:
|
||||
|
||||
```
|
||||
~/.cline/data/settings/cline_mcp_settings.json
|
||||
```
|
||||
|
||||
The file uses the same JSON format as the VS Code extension:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/server.js"],
|
||||
"env": {
|
||||
"API_KEY": "your_api_key"
|
||||
},
|
||||
"alwaysAllow": ["tool1", "tool2"],
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
|
||||
|
||||
<Note>
|
||||
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
|
||||
</Note>
|
||||
|
||||
### Custom Config Directory
|
||||
|
||||
If you use the `CLINE_DIR` environment variable or `--config` flag, the MCP settings file will be located at `<your-config-dir>/data/settings/cline_mcp_settings.json` instead.
|
||||
|
||||
## Configuration for Local Providers
|
||||
|
||||
### Ollama
|
||||
|
||||
@@ -207,6 +207,15 @@ Chains multiple Cline invocations together for creative multi-step workflows.
|
||||
| Session summary | ✓ | - |
|
||||
| JSON output | - | `--json` |
|
||||
| Piped input | - | ✓ |
|
||||
| [MCP servers](/cline-cli/configuration#mcp-server-configuration) | ✓ | ✓ |
|
||||
|
||||
## MCP Server Support
|
||||
|
||||
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, the same extensibility system available in the VS Code extension. MCP servers give Cline access to external tools and data sources, from databases and APIs to browser automation and project management.
|
||||
|
||||
To use MCP servers with the CLI, add your server configuration to `~/.cline/data/settings/cline_mcp_settings.json`. The format is identical to the VS Code extension.
|
||||
|
||||
[Configure MCP servers for the CLI →](/cline-cli/configuration#mcp-server-configuration)
|
||||
|
||||
## Learn More
|
||||
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
---
|
||||
title: "CVE Vulnerability Scanner"
|
||||
description: "Automatically scan dependencies for CVEs and get AI-powered security reports using Cline CLI in GitHub Actions."
|
||||
---
|
||||
|
||||
Turn noisy dependency audit output into actionable, prioritized security intelligence. This sample uses Cline CLI in GitHub Actions to scan for CVEs automatically — on every PR, on a weekly schedule, or on-demand — and post clear, prioritized reports with exact fix commands.
|
||||
|
||||
<Note>
|
||||
**New to Cline CLI?** This sample assumes you understand Cline CLI basics and have completed the [Installation Guide](https://docs.cline.bot/cline-cli/installation). Start with the [GitHub RCA sample](./github-issue-rca) if you're looking for something simpler.
|
||||
</Note>
|
||||
|
||||
## What It Does
|
||||
|
||||
| Trigger | What happens |
|
||||
|---------|-------------|
|
||||
| **PR opened** (dependency files changed) | Scans for CVEs, posts analysis as a PR comment |
|
||||
| **Weekly schedule** (Monday 9am UTC) | Scans for newly disclosed CVEs, creates a GitHub Issue |
|
||||
| **Manual trigger** | Scan with custom severity filter and optional auto-fix |
|
||||
|
||||
For each vulnerability found, Cline provides:
|
||||
- **Plain-English impact** — what an attacker could actually do
|
||||
- **Exploitability assessment** — is this theoretical or actively exploited?
|
||||
- **Exact fix commands** — copy-paste remediation
|
||||
- **Auto-fix safety** — which fixes are safe to apply without breaking changes
|
||||
|
||||
## Quick Start — Local Usage
|
||||
|
||||
Before setting up CI/CD, try it locally:
|
||||
|
||||
```bash
|
||||
# Download the script
|
||||
curl -O https://raw.githubusercontent.com/cline/cline/main/src/samples/cli/cve-scan/scan-cves.sh
|
||||
chmod +x scan-cves.sh
|
||||
|
||||
# Run it (auto-detects npm/yarn/pnpm/pip)
|
||||
./scan-cves.sh
|
||||
```
|
||||
|
||||
Or skip the script and pipe directly:
|
||||
|
||||
```bash
|
||||
npm audit --json | cline --yolo "Analyze these CVEs. For each: explain impact, assess exploitability, give exact fix commands. Prioritize by severity."
|
||||
```
|
||||
|
||||
<Tip>
|
||||
The `--yolo` flag (or `-y` for short) runs Cline in fully autonomous mode — it executes commands without waiting for approval. This is what makes piping and CI/CD workflows possible.
|
||||
</Tip>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Cline CLI** installed and authenticated ([Installation Guide](https://docs.cline.bot/cline-cli/installation))
|
||||
- **GitHub repository** with Actions enabled
|
||||
- **API provider account** (Anthropic, OpenRouter, etc.) with API key added as a repository secret
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Copy the Workflow File
|
||||
|
||||
```bash
|
||||
mkdir -p .github/workflows
|
||||
curl -o .github/workflows/cline-cve-scan.yml \
|
||||
https://raw.githubusercontent.com/cline/cline/main/src/samples/cli/cve-scan/cline-cve-scan.yml
|
||||
```
|
||||
|
||||
<Accordion title="Click to view the complete cline-cve-scan.yml workflow">
|
||||
```yaml
|
||||
name: Cline CVE Scanner
|
||||
|
||||
on:
|
||||
# Weekly scheduled scan — catches new CVEs in existing dependencies
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # Every Monday at 9am UTC
|
||||
|
||||
# PR scan — catch vulnerable dependencies before they merge
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "yarn.lock"
|
||||
- "pnpm-lock.yaml"
|
||||
- "requirements.txt"
|
||||
- "Pipfile.lock"
|
||||
- "pyproject.toml"
|
||||
|
||||
# Manual trigger with options
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
severity:
|
||||
description: "Minimum severity to report"
|
||||
required: false
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- low
|
||||
- medium
|
||||
- high
|
||||
- critical
|
||||
auto_fix:
|
||||
description: "Attempt safe auto-fixes"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: cve-scan-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cve-scan:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Cline CLI
|
||||
run: npm install -g cline
|
||||
|
||||
- name: Configure Cline Authentication
|
||||
run: |
|
||||
cline auth --provider anthropic \
|
||||
--apikey "${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||
--modelid claude-sonnet-4-5-20250929
|
||||
|
||||
- name: Determine scan parameters
|
||||
id: params
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "severity=${{ inputs.severity }}" >> $GITHUB_OUTPUT
|
||||
echo "auto_fix=${{ inputs.auto_fix }}" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||
echo "severity=high" >> $GITHUB_OUTPUT
|
||||
echo "auto_fix=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "severity=all" >> $GITHUB_OUTPUT
|
||||
echo "auto_fix=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||
echo "output=pr-comment" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "output=github-issue" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download CVE scan script
|
||||
run: |
|
||||
curl -sL https://raw.githubusercontent.com/${{ github.repository }}/main/scan-cves.sh -o scan-cves.sh \
|
||||
|| cp src/samples/cli/cve-scan/scan-cves.sh scan-cves.sh 2>/dev/null \
|
||||
|| true
|
||||
chmod +x scan-cves.sh
|
||||
|
||||
- name: Run CVE scan with Cline
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
CLINE_COMMAND_PERMISSIONS: |
|
||||
{
|
||||
"allow": [
|
||||
"npm audit *",
|
||||
"yarn audit *",
|
||||
"pnpm audit *",
|
||||
"pip-audit *",
|
||||
"gh issue create *",
|
||||
"gh issue list *",
|
||||
"gh pr comment *",
|
||||
"cat *",
|
||||
"echo *"
|
||||
],
|
||||
"deny": [
|
||||
"rm *",
|
||||
"sudo *",
|
||||
"npm install *",
|
||||
"npm publish *"
|
||||
]
|
||||
}
|
||||
run: |
|
||||
PR_FLAG=""
|
||||
if [ -n "${{ steps.params.outputs.pr_number }}" ]; then
|
||||
PR_FLAG="--pr ${{ steps.params.outputs.pr_number }}"
|
||||
fi
|
||||
|
||||
AUTO_FIX_FLAG=""
|
||||
if [ "${{ steps.params.outputs.auto_fix }}" == "true" ]; then
|
||||
AUTO_FIX_FLAG="--auto-fix"
|
||||
fi
|
||||
|
||||
./scan-cves.sh \
|
||||
--scanner npm \
|
||||
--output ${{ steps.params.outputs.output }} \
|
||||
--severity ${{ steps.params.outputs.severity }} \
|
||||
$PR_FLAG \
|
||||
$AUTO_FIX_FLAG
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
### 2. Add the Scan Script
|
||||
|
||||
Add `scan-cves.sh` to your repository root (or wherever the workflow downloads it from):
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/cline/cline/main/src/samples/cli/cve-scan/scan-cves.sh
|
||||
chmod +x scan-cves.sh
|
||||
```
|
||||
|
||||
<Accordion title="Click to view scan-cves.sh (simplified — see source for full version)">
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scan-cves.sh — CVE vulnerability scanner powered by Cline CLI
|
||||
#
|
||||
# Usage:
|
||||
# ./scan-cves.sh # Auto-detect scanner, stdout
|
||||
# ./scan-cves.sh --output github-issue # Post as GitHub Issue
|
||||
# ./scan-cves.sh --output pr-comment --pr 42 # Post as PR comment
|
||||
# ./scan-cves.sh --scanner npm --severity critical # Filter by severity
|
||||
# cat audit.json | ./scan-cves.sh --scanner custom # Custom scanner input
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCANNER=""
|
||||
OUTPUT="stdout"
|
||||
SEVERITY="all"
|
||||
PR_NUMBER=""
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
AUTO_FIX="false"
|
||||
CLINE_EXTRA_FLAGS=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--scanner) SCANNER="$2"; shift 2 ;;
|
||||
--output) OUTPUT="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--pr) PR_NUMBER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--auto-fix) AUTO_FIX="true"; shift ;;
|
||||
--config) CLINE_EXTRA_FLAGS="$CLINE_EXTRA_FLAGS --config $2"; shift 2 ;;
|
||||
--model) CLINE_EXTRA_FLAGS="$CLINE_EXTRA_FLAGS -m $2"; shift 2 ;;
|
||||
-h|--help) echo "Usage: scan-cves.sh [--scanner npm|yarn|pnpm|pip|custom] [--output stdout|github-issue|pr-comment|file] [--severity all|critical|high|medium|low] [--pr N] [--repo owner/repo] [--auto-fix] [--config path] [--model id]"; exit 0 ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auto-detect scanner from lockfiles
|
||||
if [[ -z "$SCANNER" ]]; then
|
||||
if [[ -f "package-lock.json" ]]; then SCANNER="npm"
|
||||
elif [[ -f "yarn.lock" ]]; then SCANNER="yarn"
|
||||
elif [[ -f "pnpm-lock.yaml" ]]; then SCANNER="pnpm"
|
||||
elif [[ -f "requirements.txt" ]] || [[ -f "Pipfile.lock" ]]; then SCANNER="pip"
|
||||
else echo "Error: Could not detect package manager." >&2; exit 1; fi
|
||||
echo "Auto-detected scanner: $SCANNER" >&2
|
||||
fi
|
||||
|
||||
# Run the scan
|
||||
case "$SCANNER" in
|
||||
npm) SCAN_OUTPUT=$(npm audit --json 2>/dev/null || true) ;;
|
||||
yarn) SCAN_OUTPUT=$(yarn audit --json 2>/dev/null || true) ;;
|
||||
pnpm) SCAN_OUTPUT=$(pnpm audit --json 2>/dev/null || true) ;;
|
||||
pip) SCAN_OUTPUT=$(pip-audit --format json 2>/dev/null || true) ;;
|
||||
custom) SCAN_OUTPUT=$(cat) ;;
|
||||
*) echo "Unknown scanner: $SCANNER" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [[ -z "$SCAN_OUTPUT" ]]; then echo "✅ No vulnerabilities found!" >&2; exit 0; fi
|
||||
|
||||
# Build the security analyst prompt
|
||||
PROMPT='You are a senior security analyst. Analyze these vulnerability scan results.
|
||||
For EACH vulnerability: provide CVE ID, severity, affected package with versions,
|
||||
plain-English impact, exploitability assessment, exact fix commands, and auto-fix safety.
|
||||
Format as markdown with sections: 🔴 Critical, 🟠 High, 🟡 Medium, 🔵 Low,
|
||||
Summary & Recommended Actions, Risk Assessment.
|
||||
Omit empty severity sections. Flag actively exploited CVEs with ⚠️.'
|
||||
|
||||
if [[ "$SEVERITY" != "all" ]]; then
|
||||
PROMPT="$PROMPT Focus ONLY on $SEVERITY severity or higher."
|
||||
fi
|
||||
|
||||
# Run Cline analysis
|
||||
echo "Analyzing vulnerabilities with Cline..." >&2
|
||||
REPORT=$(echo "$SCAN_OUTPUT" | cline -y $CLINE_EXTRA_FLAGS "$PROMPT" 2>/dev/null)
|
||||
|
||||
# Output results
|
||||
case "$OUTPUT" in
|
||||
stdout) echo "$REPORT" ;;
|
||||
github-issue) gh issue create --repo "$REPO" --title "🔒 CVE Report — $(date +%Y-%m-%d)" --body "$REPORT" --label "security,automated" ;;
|
||||
pr-comment) gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$REPORT" ;;
|
||||
file) echo "$REPORT" > "cve-report-$(date +%Y%m%d-%H%M%S).md" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
The [full source script](https://github.com/cline/cline/blob/main/src/samples/cli/cve-scan/scan-cves.sh) includes additional features: a `--help` usage guide, `detect_scanner()` and `run_scan()` helper functions, a detailed heredoc security prompt with auto-fix instructions, and JSON output extraction via `jq`.
|
||||
</Accordion>
|
||||
|
||||
### 3. Configure Secrets
|
||||
|
||||
1. Go to your repository **Settings** → **Secrets and variables** → **Actions**
|
||||
2. Add a **New repository secret**:
|
||||
- **Name:** `ANTHROPIC_API_KEY` (or match the provider in your workflow)
|
||||
- **Value:** Your API key
|
||||
|
||||
### 4. Commit and Push
|
||||
|
||||
```bash
|
||||
git add .github/workflows/cline-cve-scan.yml scan-cves.sh
|
||||
git commit -m "Add Cline CVE scanner workflow"
|
||||
git push
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Automatic Triggers
|
||||
|
||||
Once set up, the scanner runs automatically:
|
||||
|
||||
- **Weekly (Monday 9am UTC):** Creates a GitHub Issue with a full vulnerability report
|
||||
- **On PR:** Posts a comment on PRs that modify dependency files (only high+ severity)
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
Go to **Actions** → **Cline CVE Scanner** → **Run workflow** to trigger a scan with custom options:
|
||||
- Choose minimum severity level
|
||||
- Optionally enable auto-fix for safe updates
|
||||
|
||||
### Local Usage
|
||||
|
||||
```bash
|
||||
# Basic scan (auto-detects package manager)
|
||||
./scan-cves.sh
|
||||
|
||||
# Save to file
|
||||
./scan-cves.sh --output file
|
||||
|
||||
# Only critical CVEs
|
||||
./scan-cves.sh --severity critical
|
||||
|
||||
# Post as GitHub Issue
|
||||
./scan-cves.sh --output github-issue --repo myorg/myrepo
|
||||
|
||||
# Use a specific model
|
||||
./scan-cves.sh --model claude-opus-4-5-20251101
|
||||
|
||||
# Pipe from any scanner (Trivy, Snyk, Grype, etc.)
|
||||
trivy fs --format json . | ./scan-cves.sh --scanner custom
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Architecture
|
||||
|
||||
The scanner follows a three-layer design that keeps each concern separate and extensible:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Layer 1: Scanner Adapter (pluggable) │
|
||||
│ npm audit | yarn audit | pip-audit | custom │
|
||||
└────────────────────┬────────────────────────────┘
|
||||
│ JSON vulnerability data
|
||||
┌────────────────────▼────────────────────────────┐
|
||||
│ Layer 2: Cline Security Analyst (reusable) │
|
||||
│ AI-powered analysis via cline --yolo │
|
||||
└────────────────────┬────────────────────────────┘
|
||||
│ Markdown report
|
||||
┌────────────────────▼────────────────────────────┐
|
||||
│ Layer 3: Output Adapter (pluggable) │
|
||||
│ stdout | GitHub Issue | PR comment | file │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Layer 1 (Scanner)** runs the appropriate audit command and produces JSON. You can swap scanners without touching the analysis logic.
|
||||
|
||||
**Layer 2 (Cline)** receives the raw vulnerability JSON and produces a prioritized, human-readable report. The security analyst prompt is self-contained and could be extracted into a Prompts Library entry.
|
||||
|
||||
**Layer 3 (Output)** delivers the report to its destination. Adding a new output target (e.g., Slack webhook) requires only a few lines in the output case statement.
|
||||
|
||||
### The Security Analyst Prompt
|
||||
|
||||
The core prompt instructs Cline to act as a senior security analyst. For each CVE, it provides:
|
||||
|
||||
1. **CVE ID & Severity** with color-coded sections
|
||||
2. **Impact Assessment** in plain English (not just "RCE" — the actual attack vector)
|
||||
3. **Exploitability** — is this a real-world risk or theoretical?
|
||||
4. **Exact Fix** — copy-paste commands specific to your package manager
|
||||
5. **Auto-fix Safety** — whether a simple version bump is safe
|
||||
|
||||
This prompt is **reusable** — it works with any JSON vulnerability data, not just npm audit. It could be published to the Cline Prompts Library for broader use.
|
||||
|
||||
### Security: Command Permissions
|
||||
|
||||
The workflow uses `CLINE_COMMAND_PERMISSIONS` to restrict Cline to safe, read-only operations:
|
||||
|
||||
```json
|
||||
{
|
||||
"allow": ["npm audit *", "gh issue create *", "gh pr comment *"],
|
||||
"deny": ["rm *", "sudo *", "npm install *", "npm publish *"]
|
||||
}
|
||||
```
|
||||
|
||||
This ensures Cline can scan and report, but cannot modify your codebase or install packages — even in YOLO mode.
|
||||
|
||||
## Customization
|
||||
|
||||
### Different Package Managers
|
||||
|
||||
The script auto-detects from lockfiles, or you can specify explicitly:
|
||||
|
||||
```bash
|
||||
./scan-cves.sh --scanner yarn
|
||||
./scan-cves.sh --scanner pnpm
|
||||
./scan-cves.sh --scanner pip
|
||||
```
|
||||
|
||||
### Model Orchestration
|
||||
|
||||
Combine with [Model Orchestration](./model-orchestration) patterns for cost optimization:
|
||||
|
||||
```bash
|
||||
# Cheap model for weekly triage
|
||||
./scan-cves.sh --config ~/.cline-haiku --severity all
|
||||
|
||||
# Expensive model only for critical CVEs
|
||||
./scan-cves.sh --config ~/.cline-opus --severity critical
|
||||
```
|
||||
|
||||
### Custom Scanners
|
||||
|
||||
Pipe output from any scanner that produces JSON:
|
||||
|
||||
```bash
|
||||
# Trivy (container/filesystem scanner)
|
||||
trivy fs --format json . | ./scan-cves.sh --scanner custom
|
||||
|
||||
# Snyk
|
||||
snyk test --json | ./scan-cves.sh --scanner custom
|
||||
|
||||
# Grype
|
||||
grype dir:. -o json | ./scan-cves.sh --scanner custom
|
||||
```
|
||||
|
||||
### Slack Notifications
|
||||
|
||||
Extend the output adapter by piping stdout to a Slack webhook:
|
||||
|
||||
```bash
|
||||
REPORT=$(./scan-cves.sh)
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data "{\"text\": \"$REPORT\"}" \
|
||||
"$SLACK_WEBHOOK_URL"
|
||||
```
|
||||
|
||||
## Sample Output
|
||||
|
||||
Here's an example of a Cline-generated CVE report:
|
||||
|
||||
```markdown
|
||||
# 🔒 CVE Vulnerability Report
|
||||
|
||||
**Scan Date:** 2026-02-11
|
||||
**Scanner:** npm
|
||||
**Total Vulnerabilities:** 4
|
||||
|
||||
## 🔴 Critical Vulnerabilities (1)
|
||||
|
||||
### CVE-2022-24999: qs
|
||||
- **Severity:** Critical
|
||||
- **Package:** `qs@6.7.0` → fix in `qs@6.11.0`
|
||||
- **Impact:** Prototype pollution via crafted query strings. An attacker can inject
|
||||
properties into Object.prototype, which in Express.js apps can lead to remote code
|
||||
execution or denial of service.
|
||||
- **Exploitability:** ⚠️ ACTIVELY EXPLOITED — public exploits available, any Express
|
||||
app using query parsing is vulnerable.
|
||||
- **Fix:** `npm install qs@6.11.0`
|
||||
- **Auto-fix safe:** Yes
|
||||
|
||||
## 🟠 High Vulnerabilities (2)
|
||||
|
||||
### CVE-2023-28155: jsonwebtoken
|
||||
- **Severity:** High
|
||||
- **Package:** `jsonwebtoken@8.5.1` → fix in `jsonwebtoken@9.0.0`
|
||||
- **Impact:** Insecure default algorithm allows an attacker to forge tokens if the
|
||||
server doesn't explicitly set the algorithm. Could lead to authentication bypass.
|
||||
- **Exploitability:** Medium — requires the server to not specify algorithms explicitly.
|
||||
- **Fix:** `npm install jsonwebtoken@9.0.0`
|
||||
- **Auto-fix safe:** No (major version bump, verify API compatibility)
|
||||
|
||||
### CVE-2023-45857: axios
|
||||
- **Severity:** High
|
||||
- **Package:** `axios@0.21.1` → fix in `axios@1.6.0`
|
||||
- **Impact:** SSRF vulnerability allows specially crafted requests to access internal
|
||||
services. An attacker controlling request URLs could probe internal infrastructure.
|
||||
- **Exploitability:** Medium — requires user-controlled URL input.
|
||||
- **Fix:** `npm install axios@1.6.0`
|
||||
- **Auto-fix safe:** No (major version bump)
|
||||
|
||||
## 📋 Summary & Recommended Actions
|
||||
|
||||
1. **Immediate:** Update qs to 6.11.0 — critical, actively exploited, safe auto-fix
|
||||
2. **This sprint:** Update jsonwebtoken to 9.0.0 and axios to 1.6.0 (test for breaking changes)
|
||||
3. **Safe auto-fix command:** `npm audit fix`
|
||||
|
||||
## 📊 Risk Assessment
|
||||
|
||||
This project has 1 critical and 2 high severity vulnerabilities. The critical qs
|
||||
vulnerability is actively exploited and should be fixed immediately — it's a safe
|
||||
auto-fix with no breaking changes. The jsonwebtoken and axios updates are major
|
||||
version bumps that require testing but should be scheduled for the current sprint.
|
||||
Overall dependency hygiene needs improvement — consider running automated CVE scans
|
||||
weekly to catch issues earlier.
|
||||
```
|
||||
|
||||
## Related Samples
|
||||
|
||||
- **[GitHub PR Review](./github-pr-review)** — Automated code review on PRs
|
||||
- **[GitHub Integration](./github-integration)** — Respond to issues with @cline
|
||||
- **[Model Orchestration](./model-orchestration)** — Multi-model workflows for cost optimization
|
||||
@@ -47,6 +47,14 @@ This section provides sample implementations that demonstrate various Cline CLI
|
||||
>
|
||||
Automatically review Pull Requests with AI. Configures Cline in GitHub Actions to analyze diffs, check for security issues, and post detailed reviews with inline code suggestions.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="CVE Vulnerability Scanner (Actions)"
|
||||
icon="shield-halved"
|
||||
href="/cline-cli/samples/cve-scan"
|
||||
>
|
||||
Automatically scan dependencies for CVEs and get AI-powered security reports. Runs on PRs, weekly schedules, or on-demand. Supports npm, yarn, pnpm, pip, and custom scanners like Trivy and Snyk.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Additional Resources
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
"cline-cli/samples/github-issue-rca",
|
||||
"cline-cli/samples/github-integration",
|
||||
"cline-cli/samples/github-pr-review",
|
||||
"cline-cli/samples/cve-scan",
|
||||
"cline-cli/samples/model-orchestration",
|
||||
"cline-cli/samples/worktree-workflows"
|
||||
]
|
||||
|
||||
@@ -70,6 +70,8 @@ Cline does not come with any pre-installed MCP servers. You'll need to find and
|
||||
|
||||
## Integration with Cline
|
||||
|
||||
MCP servers work with both the **Cline VS Code extension** and the **[Cline CLI](/cline-cli/overview)**. If you use the CLI, see [MCP Server Configuration for the CLI](/cline-cli/configuration#mcp-server-configuration) to get set up.
|
||||
|
||||
Cline simplifies the building and use of MCP servers through its AI capabilities.
|
||||
|
||||
### Building MCP Servers
|
||||
|
||||
@@ -19,7 +19,7 @@ Google Gemini is Google's family of multimodal AI models, offering some of the l
|
||||
Cline supports the following Google Gemini models:
|
||||
|
||||
#### Gemini 3 Series (Latest)
|
||||
- `gemini-3-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
|
||||
- `gemini-3.1-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
|
||||
- `gemini-3-flash-preview` - Fast model with 1M context and thinking level support ($0.30-$0.50/M input)
|
||||
|
||||
#### Gemini 2.5 Series
|
||||
|
||||
@@ -14,29 +14,6 @@ SambaNova provides fast AI inference on custom-built hardware, hosting popular o
|
||||
3. **Create a Key:** Generate a new API key.
|
||||
4. **Copy the Key:** Copy the API key immediately and store it securely.
|
||||
|
||||
### Supported Models
|
||||
|
||||
Cline supports the following SambaNova models:
|
||||
|
||||
#### Meta Llama Models
|
||||
- `Llama-4-Maverick-17B-128E-Instruct` - Llama 4 Maverick with vision support ($0.63/$1.80 per 1M tokens)
|
||||
- `Llama-4-Scout-17B-16E-Instruct` - Llama 4 Scout ($0.40/$0.70 per 1M tokens)
|
||||
- `Meta-Llama-3.3-70B-Instruct` (Default) - Versatile 70B model with 128K context ($0.60/$1.20 per 1M tokens)
|
||||
- `Meta-Llama-3.1-405B-Instruct` - Largest Llama model ($5.00/$10.00 per 1M tokens)
|
||||
- `Meta-Llama-3.1-8B-Instruct` - Compact 8B model ($0.10/$0.20 per 1M tokens)
|
||||
- `Meta-Llama-3.2-1B-Instruct` - Ultra-compact 1B model ($0.04/$0.08 per 1M tokens)
|
||||
- `Meta-Llama-3.2-3B-Instruct` - Small 3B model ($0.08/$0.16 per 1M tokens)
|
||||
|
||||
#### DeepSeek Models
|
||||
- `DeepSeek-R1` - Reasoning model ($5.00/$7.00 per 1M tokens)
|
||||
- `DeepSeek-R1-Distill-Llama-70B` - Distilled reasoning model ($0.70/$1.40 per 1M tokens)
|
||||
- `DeepSeek-V3-0324` - General-purpose model ($3.00/$4.50 per 1M tokens)
|
||||
- `DeepSeek-V3.1` - Latest DeepSeek with hybrid reasoning ($3.00/$4.50 per 1M tokens)
|
||||
|
||||
#### Qwen Models
|
||||
- `Qwen3-32B` - Dense 32B model ($0.40/$0.80 per 1M tokens)
|
||||
- `QwQ-32B` - Reasoning-focused Qwen model ($0.50/$1.00 per 1M tokens)
|
||||
|
||||
### Configuration in Cline
|
||||
|
||||
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
|
||||
|
||||
Generated
+22
-23
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.65.0",
|
||||
"version": "3.66.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.65.0",
|
||||
"version": "3.66.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -46,8 +46,8 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.1.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.1.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.2.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
@@ -162,7 +162,7 @@
|
||||
},
|
||||
"cli": {
|
||||
"name": "cline",
|
||||
"version": "2.2.1",
|
||||
"version": "2.4.1",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
@@ -4033,7 +4033,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -6115,20 +6114,20 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/ai-api": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.6.0.tgz",
|
||||
"integrity": "sha512-HdQ3T6FSD/jlPnsDKEGsNg+SQONpHBvxfu6OVwW/5nLdlvPmWpysirYvOc0eqj7e+X3zBHGIgoGWKSxIcYOjNg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.7.0.tgz",
|
||||
"integrity": "sha512-dDc2Si5Mu62dVZkJ/f55fBIlbIjVjCKmPi9tt2jWix59o/f9z2Zkw7l7Il+H0RbgANyyQmZsCkXDHRUmzAKKDw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.4.0",
|
||||
"@sap-cloud-sdk/util": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/core": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.6.0.tgz",
|
||||
"integrity": "sha512-HgB2xtjx6iFwAq+a5cEH6rss4sBXAkkW6Vas07OxoZihc7BijjNFm+H19rkEAhBYQmkA415zJG3imHd6/slooQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.7.0.tgz",
|
||||
"integrity": "sha512-NSuCrEFinMR8/EYKJcACh71a1kj1H3pNfpnJH1R4Z+cxnlr0CtiFDzz9hKwsXFtf+ixO6WnIf7xMVmXkoeGNSQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-cloud-sdk/connectivity": "^4.4.0",
|
||||
@@ -6138,25 +6137,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/orchestration": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.6.0.tgz",
|
||||
"integrity": "sha512-FGBZ0oiRbbZdL9UUytsJ7QBiVUmrg4eD/i61aHVeBGvaCvsgSlx7nJM07CAT5JHhJC8ss97+EYPXXzVNPATcyQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.7.0.tgz",
|
||||
"integrity": "sha512-/aCRwb3o5yJu5/8jCDVatMGBIxPO5rKtr0sT+zAl5w3hD/PqAqbFZXNztQ263xK1ooW8YAFite8PpXJgdjnFZg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/ai-api": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/prompt-registry": "^2.6.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"@sap-ai-sdk/prompt-registry": "^2.7.0",
|
||||
"@sap-cloud-sdk/util": "^4.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/prompt-registry": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.6.0.tgz",
|
||||
"integrity": "sha512-vSyBCx437Cba3AsBHf1G0KCldv4dPi60kNgp2rpF/kDZjf3Rga3VwZ6OnETaKtbJJbM8G8itR0USHgviMU8UUg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.7.0.tgz",
|
||||
"integrity": "sha512-hbfb75CD67dgKl/hLLCXdzhsR80FHFqKCpVUjiULs3KW1Rauxc+i6ZKNRcc+uzdb8kgsdhKizFFCtsz90ay44g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
},
|
||||
|
||||
+8
-4
@@ -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.65.0",
|
||||
"version": "3.66.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -444,7 +444,11 @@
|
||||
"docs:rename-file": "cd docs && npm run rename",
|
||||
"report-issue": "node scripts/report-issue.js",
|
||||
"storybook": "cd webview-ui && npm run storybook",
|
||||
"cli:unlink": "cd cli && npm run unlink"
|
||||
"cli:unlink": "cd cli && npm run unlink",
|
||||
"eval:smoke:build": "npm run cli:build && npm run cli:link",
|
||||
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts",
|
||||
"eval:smoke": "npm run eval:smoke:build && npm run eval:smoke:run",
|
||||
"eval:smoke:ci": "npm run eval:smoke:build && npm run eval:smoke:run -- --trials 1 --parallel"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
@@ -536,8 +540,8 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.1.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.1.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.2.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -19,6 +19,8 @@ service ModelsService {
|
||||
rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
|
||||
// Refreshes and returns OpenRouter models
|
||||
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns recommended and free Cline models
|
||||
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
|
||||
// Refreshes and returns Hugging Face models
|
||||
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns OpenAI models
|
||||
@@ -113,6 +115,18 @@ message OpenRouterCompatibleModelInfo {
|
||||
map<string, OpenRouterModelInfo> models = 1;
|
||||
}
|
||||
|
||||
message ClineRecommendedModel {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string description = 3;
|
||||
repeated string tags = 4;
|
||||
}
|
||||
|
||||
message ClineRecommendedModelsResponse {
|
||||
repeated ClineRecommendedModel recommended = 1;
|
||||
repeated ClineRecommendedModel free = 2;
|
||||
}
|
||||
|
||||
// Request for fetching OpenAI models
|
||||
message OpenAiModelsRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -448,6 +462,7 @@ enum ApiFormat {
|
||||
OPENAI_CHAT = 2;
|
||||
R1_CHAT = 3;
|
||||
OPENAI_RESPONSES = 4;
|
||||
OPENAI_RESPONSES_WEBSOCKET_MODE = 5;
|
||||
}
|
||||
|
||||
// Model info for OpenAI-compatible models
|
||||
|
||||
@@ -104,15 +104,13 @@ message Secrets {
|
||||
optional string oca_refresh_token = 42;
|
||||
optional string mcp_o_auth_secrets = 43;
|
||||
optional string cline_api_key = 44;
|
||||
optional string openai_codex_oauth_credentials = 46;
|
||||
optional string openai_codex_oauth_credentials = 48;
|
||||
}
|
||||
|
||||
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
|
||||
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
|
||||
// script to regenerate this list.
|
||||
message Settings {
|
||||
reserved 146; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
|
||||
|
||||
optional string lite_llm_base_url = 1;
|
||||
optional bool lite_llm_use_prompt_cache = 2;
|
||||
optional string anthropic_base_url = 4;
|
||||
@@ -251,7 +249,6 @@ message Settings {
|
||||
optional string default_terminal_profile = 137;
|
||||
optional int32 terminal_output_line_limit = 138;
|
||||
optional int32 max_consecutive_mistakes = 139;
|
||||
optional int32 subagent_terminal_output_line_limit = 140;
|
||||
optional bool strict_plan_mode_enabled = 141;
|
||||
optional bool yolo_mode_toggled = 142;
|
||||
optional bool use_auto_condense = 143;
|
||||
@@ -261,7 +258,6 @@ message Settings {
|
||||
optional DictationSettings dictation_settings = 148;
|
||||
optional FocusChainSettings focus_chain_settings = 149;
|
||||
optional string custom_prompt = 150;
|
||||
optional double auto_condense_threshold = 151;
|
||||
optional bool subagents_enabled = 153;
|
||||
optional bool enable_parallel_tool_calling = 154;
|
||||
optional bool background_edit_enabled = 155;
|
||||
@@ -282,8 +278,8 @@ message Settings {
|
||||
optional int32 open_telemetry_log_max_queue_size = 171;
|
||||
optional bool worktrees_enabled = 172;
|
||||
optional bool auto_approve_all_toggled = 174;
|
||||
map<string, string> open_ai_headers = 175;
|
||||
optional bool double_check_completion_enabled = 176;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
}
|
||||
|
||||
message DictationSettings {
|
||||
@@ -415,7 +411,6 @@ message UpdateSettingsRequest {
|
||||
optional string default_terminal_profile = 21;
|
||||
optional bool yolo_mode_toggled = 22;
|
||||
optional DictationSettings dictation_settings = 23;
|
||||
optional double auto_condense_threshold = 24;
|
||||
optional bool multi_root_enabled = 25;
|
||||
optional string vscode_terminal_execution_mode = 27;
|
||||
optional int32 max_consecutive_mistakes = 28;
|
||||
|
||||
@@ -30,7 +30,7 @@ function toProtoFieldName(str) {
|
||||
const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"])
|
||||
|
||||
// Fields that should use double instead of int32
|
||||
const DOUBLE_FIELDS = new Set(["autoCondenseThreshold"])
|
||||
const DOUBLE_FIELDS = new Set()
|
||||
|
||||
/**
|
||||
* Infer proto type from TypeScript type expression
|
||||
@@ -254,7 +254,7 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
|
||||
|
||||
for (const fieldMatch of matches) {
|
||||
const snakeName = fieldMatch[1]
|
||||
const fieldNum = parseInt(fieldMatch[2], 10)
|
||||
const fieldNum = Number.parseInt(fieldMatch[2], 10)
|
||||
const camelName = snakeToCamel(snakeName)
|
||||
fieldNumbers[camelName] = fieldNum
|
||||
}
|
||||
@@ -354,11 +354,10 @@ function replaceMessage(protoContent, messageName, newMessageContent) {
|
||||
|
||||
if (messageRegex.test(protoContent)) {
|
||||
return protoContent.replace(messageRegex, newMessageContent)
|
||||
} else {
|
||||
// Message doesn't exist, append before the first message or at end
|
||||
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
|
||||
return protoContent + "\n\n" + newMessageContent
|
||||
}
|
||||
// Message doesn't exist, append before the first message or at end
|
||||
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
|
||||
return protoContent + "\n\n" + newMessageContent
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -56,6 +56,7 @@ const log = {
|
||||
const config = {
|
||||
// The name and display name for the nightly version
|
||||
nightlyName: "cline-nightly",
|
||||
originalName: "claude-dev",
|
||||
nightlyDisplayName: "Cline (Nightly)",
|
||||
projectRoot: path.join(__dirname, ".."),
|
||||
get packageJsonPath() {
|
||||
@@ -70,6 +71,15 @@ const config = {
|
||||
get vsixPath() {
|
||||
return path.join(this.distDir, "cline-nightly.vsix")
|
||||
},
|
||||
get nodeModulesPath() {
|
||||
return path.join(this.projectRoot, "node_modules")
|
||||
},
|
||||
get originalWorkspaceLinkPath() {
|
||||
return path.join(this.nodeModulesPath, this.originalName)
|
||||
},
|
||||
get nightlyWorkspaceLinkPath() {
|
||||
return path.join(this.nodeModulesPath, this.nightlyName)
|
||||
},
|
||||
}
|
||||
|
||||
// Utility class for managing the publish process
|
||||
@@ -77,6 +87,31 @@ class NightlyPublisher {
|
||||
constructor() {
|
||||
this.originalPackageJson = null
|
||||
this.hasBackup = false
|
||||
this.didRenameWorkspaceLink = false
|
||||
this.didCreateNightlyWorkspaceLink = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve symlink target to an absolute path.
|
||||
*/
|
||||
resolveSymlinkTarget(linkPath) {
|
||||
const target = fs.readlinkSync(linkPath)
|
||||
return path.resolve(path.dirname(linkPath), target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is the expected workspace self-link to project root.
|
||||
*/
|
||||
isExpectedWorkspaceSelfLink(linkPath) {
|
||||
try {
|
||||
if (!fs.lstatSync(linkPath).isSymbolicLink()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.resolveSymlinkTarget(linkPath) === path.resolve(config.projectRoot)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,6 +180,98 @@ class NightlyPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep workspace self-link consistent with package name during nightly packaging.
|
||||
*
|
||||
* The repo root is a workspace package ("."). When npm installs dependencies,
|
||||
* it creates a self-link at node_modules/<package-name>. Nightly packaging
|
||||
* changes package.json name from "claude-dev" to "cline-nightly". If we don't
|
||||
* align this link, vsce's dependency detection (`npm list --production`) fails
|
||||
* with ELSPROBLEMS (missing cline-nightly + extraneous claude-dev).
|
||||
*/
|
||||
reconcileWorkspaceSelfLinkForNightly() {
|
||||
const originalPath = config.originalWorkspaceLinkPath
|
||||
const nightlyPath = config.nightlyWorkspaceLinkPath
|
||||
|
||||
if (!fs.existsSync(config.nodeModulesPath)) {
|
||||
log.warn("node_modules not found, skipping workspace self-link reconciliation")
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(nightlyPath)) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(
|
||||
`Refusing to continue: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info("Nightly workspace self-link already exists")
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(originalPath)) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(originalPath)) {
|
||||
throw new Error(
|
||||
`Refusing to continue: unexpected path at ${originalPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`Renaming workspace self-link: ${config.originalName} -> ${config.nightlyName}`)
|
||||
fs.renameSync(originalPath, nightlyPath)
|
||||
this.didRenameWorkspaceLink = true
|
||||
return
|
||||
}
|
||||
|
||||
// In some environments npm may not have created the workspace self-link yet.
|
||||
// Create it explicitly so `npm list --production` can resolve the renamed
|
||||
// package name during vsce dependency detection.
|
||||
log.warn("Original workspace self-link not found, creating nightly workspace self-link")
|
||||
fs.symlinkSync(config.projectRoot, nightlyPath, "dir")
|
||||
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(`Failed to create expected workspace symlink at ${nightlyPath}`)
|
||||
}
|
||||
|
||||
this.didCreateNightlyWorkspaceLink = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore workspace self-link after packaging.
|
||||
*/
|
||||
restoreWorkspaceSelfLink() {
|
||||
if (!this.didRenameWorkspaceLink && !this.didCreateNightlyWorkspaceLink) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalPath = config.originalWorkspaceLinkPath
|
||||
const nightlyPath = config.nightlyWorkspaceLinkPath
|
||||
|
||||
if (fs.existsSync(nightlyPath) && !fs.existsSync(originalPath)) {
|
||||
if (this.didRenameWorkspaceLink) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(
|
||||
`Refusing to restore: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`Restoring workspace self-link: ${config.nightlyName} -> ${config.originalName}`)
|
||||
fs.renameSync(nightlyPath, originalPath)
|
||||
} else if (this.didCreateNightlyWorkspaceLink) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(
|
||||
`Refusing to remove: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`Removing temporary workspace self-link: ${config.nightlyName}`)
|
||||
fs.unlinkSync(nightlyPath)
|
||||
}
|
||||
}
|
||||
|
||||
this.didRenameWorkspaceLink = false
|
||||
this.didCreateNightlyWorkspaceLink = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new version with timestamp
|
||||
* Format: major.minor.timestamp
|
||||
@@ -300,6 +427,9 @@ class NightlyPublisher {
|
||||
// Step 3: Update package.json
|
||||
const newVersion = this.updatePackageJson()
|
||||
|
||||
// Step 3.5: Keep npm workspace self-link aligned with nightly package name
|
||||
this.reconcileWorkspaceSelfLinkForNightly()
|
||||
|
||||
// Step 4: Package extension
|
||||
this.packageExtension()
|
||||
|
||||
@@ -326,6 +456,9 @@ class NightlyPublisher {
|
||||
log.error(`Publish failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
// Always restore workspace link first
|
||||
this.restoreWorkspaceSelfLink()
|
||||
|
||||
// Always restore package.json
|
||||
this.restorePackageJson()
|
||||
}
|
||||
@@ -336,17 +469,20 @@ class NightlyPublisher {
|
||||
const publisher = new NightlyPublisher()
|
||||
|
||||
process.on("exit", () => {
|
||||
publisher.restoreWorkspaceSelfLink()
|
||||
publisher.restorePackageJson()
|
||||
})
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
log.info("\nInterrupted, cleaning up...")
|
||||
publisher.restoreWorkspaceSelfLink()
|
||||
publisher.restorePackageJson()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
log.info("\nTerminated, cleaning up...")
|
||||
publisher.restoreWorkspaceSelfLink()
|
||||
publisher.restorePackageJson()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
+11
-12
@@ -1,15 +1,15 @@
|
||||
import * as vscode from "vscode"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
|
||||
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
|
||||
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
|
||||
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
|
||||
import { ErrorService } from "./services/error"
|
||||
@@ -32,7 +32,7 @@ import { arePathsEqual } from "./utils/path"
|
||||
* @returns The webview provider
|
||||
* @throws ClineConfigurationError if endpoints.json exists but is invalid
|
||||
*/
|
||||
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
|
||||
export async function initialize(storageContext: StorageContext): Promise<WebviewProvider> {
|
||||
// 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
|
||||
@@ -44,7 +44,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
|
||||
|
||||
try {
|
||||
await StateManager.initialize(context)
|
||||
await StateManager.initialize(storageContext)
|
||||
} catch (error) {
|
||||
Logger.error("[Cline] CRITICAL: Failed to initialize StateManager:", error)
|
||||
HostProvider.window.showMessage({
|
||||
@@ -55,8 +55,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
|
||||
// =============== External services ===============
|
||||
await ErrorService.initialize()
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(context)
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
if (!ClineEndpoint.isSelfHosted()) {
|
||||
PostHogClientProvider.getInstance()
|
||||
@@ -67,7 +65,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
// Non-blocking announcement check and display
|
||||
showVersionUpdateAnnouncement(context)
|
||||
showVersionUpdateAnnouncement(stateManager)
|
||||
// Check if this workspace was opened from worktree quick launch
|
||||
await checkWorktreeAutoOpen(stateManager)
|
||||
|
||||
@@ -78,24 +76,24 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
|
||||
ClineTempManager.startPeriodicCleanup()
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
FileContextTracker.cleanupOrphanedWarnings(context)
|
||||
FileContextTracker.cleanupOrphanedWarnings(stateManager)
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
return webview
|
||||
}
|
||||
|
||||
async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
async function showVersionUpdateAnnouncement(stateManager: StateManager) {
|
||||
// Version checking for autoupdate notification
|
||||
const currentVersion = ExtensionRegistryInfo.version
|
||||
const previousVersion = context.globalState.get<string>("clineVersion")
|
||||
const previousVersion = stateManager.getGlobalStateKey("clineVersion")
|
||||
// Perform post-update actions if necessary
|
||||
try {
|
||||
if (!previousVersion || currentVersion !== previousVersion) {
|
||||
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
|
||||
|
||||
// Check if there's a new announcement to show
|
||||
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
|
||||
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
|
||||
const latestAnnouncementId = getLatestAnnouncementId()
|
||||
|
||||
if (lastShownAnnouncementId !== latestAnnouncementId) {
|
||||
@@ -109,7 +107,7 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
await context.globalState.update("clineVersion", currentVersion)
|
||||
await stateManager.setGlobalState("clineVersion", currentVersion)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
@@ -157,6 +155,7 @@ export async function tearDown(): Promise<void> {
|
||||
// Clean up audio recording service to ensure no orphaned processes
|
||||
audioRecordingService.cleanup()
|
||||
|
||||
AgentConfigLoader.getInstance()?.dispose()
|
||||
PostHogClientProvider.getInstance().dispose()
|
||||
telemetryService.dispose()
|
||||
ErrorService.get().dispose()
|
||||
|
||||
@@ -66,7 +66,6 @@ export interface ApiProviderInfo {
|
||||
model: ApiHandlerModel
|
||||
mode: Mode
|
||||
customPrompt?: string // "compact"
|
||||
autoCondenseThreshold?: number // 0-1 range
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { ClineHandler } from "../cline"
|
||||
|
||||
describe("ClineHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = Object.create(ClineHandler.prototype) as ClineHandler
|
||||
;(handler as any).options = {}
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 17,
|
||||
completion_tokens: 9,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
inputTokens: 17,
|
||||
outputTokens: 9,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { FireworksHandler } from "../fireworks"
|
||||
|
||||
describe("FireworksHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new FireworksHandler({
|
||||
fireworksApiKey: "test-api-key",
|
||||
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 19,
|
||||
completion_tokens: 4,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 19,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,8 @@ describe("LiteLlmHandler", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeClient.chat.completions.create.resetHistory()
|
||||
|
||||
mockFetchForTesting(mockFetch, () => {
|
||||
return new Promise((resolve) => {
|
||||
doneMockingFetch = resolve
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { OpenRouterHandler } from "../openrouter"
|
||||
|
||||
describe("OpenRouterHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 13,
|
||||
completion_tokens: 5,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
inputTokens: 13,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,19 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { VercelAIGatewayHandler } from "../vercel-ai-gateway"
|
||||
|
||||
describe("VercelAIGatewayHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return configured model and info when both are provided", () => {
|
||||
const customModelInfo = {
|
||||
@@ -11,22 +22,22 @@ describe("VercelAIGatewayHandler", () => {
|
||||
}
|
||||
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
openRouterModelId: "google/gemini-3-pro-preview",
|
||||
openRouterModelId: "google/gemini-3.1-pro-preview",
|
||||
openRouterModelInfo: customModelInfo,
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
result.id.should.equal("google/gemini-3-pro-preview")
|
||||
result.id.should.equal("google/gemini-3.1-pro-preview")
|
||||
result.info.should.deepEqual(customModelInfo)
|
||||
})
|
||||
|
||||
it("should preserve configured model ID when model info is missing", () => {
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
openRouterModelId: "google/gemini-3-pro-preview",
|
||||
openRouterModelId: "google/gemini-3.1-pro-preview",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
result.id.should.equal("google/gemini-3-pro-preview")
|
||||
result.id.should.equal("google/gemini-3.1-pro-preview")
|
||||
result.info.should.deepEqual(openRouterDefaultModelInfo)
|
||||
})
|
||||
|
||||
@@ -38,4 +49,46 @@ describe("VercelAIGatewayHandler", () => {
|
||||
result.info.should.deepEqual(openRouterDefaultModelInfo)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
vercelAiGatewayApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,7 +84,8 @@ export class CerebrasHandler implements ApiHandler {
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.text
|
||||
} else if (block.type === "image") {
|
||||
}
|
||||
if (block.type === "image") {
|
||||
return "[Image content not supported in Cerebras]"
|
||||
}
|
||||
return ""
|
||||
@@ -195,14 +196,18 @@ export class CerebrasHandler implements ApiHandler {
|
||||
// Rate limit error - will be handled by retry decorator with patient backoff
|
||||
const _limits = this.getRateLimits()
|
||||
throw new Error(`Cerebras API rate limit exceeded.`)
|
||||
} else if (error?.status === 401) {
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
throw new Error("Cerebras API authentication failed. Please check your API key.")
|
||||
} else if (error?.status === 403) {
|
||||
}
|
||||
if (error?.status === 403) {
|
||||
throw new Error("Cerebras API access denied. Please check your API key permissions.")
|
||||
} else if (error?.status >= 500) {
|
||||
}
|
||||
if (error?.status >= 500) {
|
||||
// Server errors - retryable
|
||||
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
|
||||
} else if (error?.status === 400) {
|
||||
}
|
||||
if (error?.status === 400) {
|
||||
// Client errors - not retryable
|
||||
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
|
||||
}
|
||||
|
||||
@@ -168,7 +168,12 @@ export class ClineHandler implements ApiHandler {
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
|
||||
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
|
||||
if (
|
||||
delta &&
|
||||
"reasoning" in delta &&
|
||||
delta.reasoning &&
|
||||
!shouldSkipReasoningForModel(this.options.openRouterModelId)
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
|
||||
@@ -183,6 +188,7 @@ export class ClineHandler implements ApiHandler {
|
||||
See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
|
||||
*/
|
||||
if (
|
||||
delta &&
|
||||
"reasoning_details" in delta &&
|
||||
delta.reasoning_details &&
|
||||
// @ts-expect-error-next-line
|
||||
|
||||
@@ -71,7 +71,7 @@ export class FireworksHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (reasoning || ("reasoning_content" in delta && delta.reasoning_content)) {
|
||||
if (reasoning || (delta && "reasoning_content" in delta && delta.reasoning_content)) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: delta.content || ((delta as any).reasoning_content as string | undefined) || "",
|
||||
|
||||
@@ -157,7 +157,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
|
||||
systemInstruction: systemPrompt,
|
||||
// Set temperature (default to 0)
|
||||
// Gemini 3.0 recommends 1.0
|
||||
// Gemini 3 recommends 1.0
|
||||
temperature: info.temperature ?? 1,
|
||||
}
|
||||
|
||||
|
||||
+129
-15
@@ -1,5 +1,6 @@
|
||||
import { Anthropic, APIError as AnthropicAPIError } from "@anthropic-ai/sdk"
|
||||
import { liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults, ModelInfo } from "@shared/api"
|
||||
import OpenAI, { APIError, OpenAIError } from "openai"
|
||||
import OpenAI, { APIError as OpenAIAPIError, OpenAIError } from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
|
||||
import {
|
||||
@@ -16,10 +17,12 @@ import { ApiFormat } from "@/shared/proto/index.cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, type CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
import { convertOpenAIToolsToAnthropicTools, handleAnthropicMessagesApiStreamResponse } from "../utils/messages_api_support"
|
||||
import { handleResponsesApiStreamResponse } from "../utils/responses_api_support"
|
||||
|
||||
export interface OcaHandlerOptions extends CommonApiHandlerOptions {
|
||||
@@ -35,14 +38,15 @@ export interface OcaHandlerOptions extends CommonApiHandlerOptions {
|
||||
|
||||
export class OcaHandler implements ApiHandler {
|
||||
protected options: OcaHandlerOptions
|
||||
protected client: OpenAI | undefined
|
||||
protected openAIClient: OpenAI | undefined
|
||||
protected anthropicClient: Anthropic | undefined
|
||||
protected externalHeaders: Record<string, string> = {}
|
||||
|
||||
constructor(options: OcaHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
protected initializeClient(options: OcaHandlerOptions): OpenAI {
|
||||
protected initializeOpenAIClient(options: OcaHandlerOptions): OpenAI {
|
||||
const externalHeaders = buildExternalBasicHeaders()
|
||||
return new (class OCIOpenAI extends OpenAI {
|
||||
protected override async prepareOptions(opts: any): Promise<void> {
|
||||
@@ -63,7 +67,7 @@ export class OcaHandler implements ApiHandler {
|
||||
error: Object | undefined,
|
||||
message: string | undefined,
|
||||
headers: any | undefined,
|
||||
): APIError {
|
||||
): OpenAIAPIError {
|
||||
interface OciError {
|
||||
code?: string
|
||||
message?: string
|
||||
@@ -94,23 +98,89 @@ export class OcaHandler implements ApiHandler {
|
||||
})
|
||||
}
|
||||
|
||||
protected ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
protected initializeAnthropicClient(options: OcaHandlerOptions): Anthropic {
|
||||
const externalHeaders = buildExternalBasicHeaders()
|
||||
return new (class OCIAnthropic extends Anthropic {
|
||||
protected override async prepareOptions(opts: any): Promise<void> {
|
||||
const token = await OcaAuthService.getInstance().getAuthToken()
|
||||
if (!token) {
|
||||
throw new OpenAIError("Unable to handle auth, Oracle Code Assist (OCA) access token is not available")
|
||||
}
|
||||
opts.headers ??= {}
|
||||
// OCA Headers
|
||||
const ociHeaders = await createOcaHeaders(token, options.taskId!)
|
||||
opts.headers = { ...opts.headers, ...externalHeaders, ...ociHeaders }
|
||||
Logger.log(`Making request with customer opc-request-id: ${opts.headers?.["opc-request-id"]}`)
|
||||
return super.prepareOptions(opts)
|
||||
}
|
||||
|
||||
protected override makeStatusError(
|
||||
status: number | undefined,
|
||||
error: Object | undefined,
|
||||
message: string | undefined,
|
||||
headers: any | undefined,
|
||||
): AnthropicAPIError {
|
||||
interface OciError {
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
let ociErrorMessage = message
|
||||
if (typeof error === "object" && error !== null) {
|
||||
try {
|
||||
ociErrorMessage = JSON.stringify(error)
|
||||
const ociErr = error as OciError
|
||||
if (ociErr.code !== undefined && ociErr.message !== undefined) {
|
||||
ociErrorMessage = `${ociErr.code}: ${ociErr.message}`
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const opcRequestId = headers?.[OCI_HEADER_OPC_REQUEST_ID]
|
||||
if (opcRequestId) {
|
||||
ociErrorMessage += `\n(${OCI_HEADER_OPC_REQUEST_ID}: ${opcRequestId})`
|
||||
}
|
||||
const statusCode = typeof status === "number" ? status : 500
|
||||
return super.makeStatusError(statusCode, error ?? {}, ociErrorMessage, headers)
|
||||
}
|
||||
})({
|
||||
baseURL:
|
||||
options.ocaBaseUrl ||
|
||||
(options.ocaMode === "internal" ? DEFAULT_INTERNAL_OCA_BASE_URL : DEFAULT_EXTERNAL_OCA_BASE_URL),
|
||||
apiKey: "noop",
|
||||
fetch, // Use configured fetch with proxy support
|
||||
})
|
||||
}
|
||||
|
||||
protected ensureOpenAIClient(): OpenAI {
|
||||
if (!this.openAIClient) {
|
||||
if (!this.options.ocaModelId) {
|
||||
throw new Error("Oracle Code Assist (OCA) model is not selected")
|
||||
}
|
||||
try {
|
||||
this.client = this.initializeClient(this.options)
|
||||
this.openAIClient = this.initializeOpenAIClient(this.options)
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Oracle Code Assist (OCA) client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
return this.openAIClient
|
||||
}
|
||||
|
||||
protected ensureAnthropicClient(): Anthropic {
|
||||
if (!this.anthropicClient) {
|
||||
if (!this.options.ocaModelId) {
|
||||
throw new Error("Oracle Code Assist (OCA) model is not selected")
|
||||
}
|
||||
try {
|
||||
this.anthropicClient = this.initializeAnthropicClient(this.options)
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Oracle Code Assist (OCA) client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.anthropicClient
|
||||
}
|
||||
|
||||
async getApiCosts(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
|
||||
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
|
||||
const client = this.ensureClient()
|
||||
const client = this.ensureOpenAIClient()
|
||||
const modelId = this.options.ocaModelId || liteLlmDefaultModelId
|
||||
const token = await OcaAuthService.getInstance().getAuthToken()
|
||||
if (!token) {
|
||||
@@ -163,13 +233,15 @@ export class OcaHandler implements ApiHandler {
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
if (this.options.ocaModelInfo?.apiFormat == ApiFormat.OPENAI_RESPONSES) {
|
||||
yield* this.createMessageResponsesApi(systemPrompt, messages, tools)
|
||||
} else if (this.options.ocaModelInfo?.apiFormat == ApiFormat.ANTHROPIC_CHAT) {
|
||||
yield* this.createMessageMessagesApi(systemPrompt, messages, tools)
|
||||
} else {
|
||||
yield* this.createMessageChatApi(systemPrompt, messages, tools)
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessageChatApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const client = this.ensureOpenAIClient()
|
||||
const formattedMessages = convertToOpenAiMessages(messages)
|
||||
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
|
||||
role: "system",
|
||||
@@ -306,8 +378,8 @@ export class OcaHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
async *createMessageResponsesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const inputMessages = convertToOpenAIResponsesInput(messages).input
|
||||
const client = this.ensureOpenAIClient()
|
||||
const inputMessages = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: false }).input
|
||||
// Convert messages to Responses API input format
|
||||
const input: OpenAI.Responses.ResponseInputItem[] = [{ role: "system", content: systemPrompt }, ...inputMessages]
|
||||
|
||||
@@ -329,14 +401,56 @@ export class OcaHandler implements ApiHandler {
|
||||
tools: responseTools,
|
||||
}
|
||||
|
||||
if (this.options.ocaModelInfo && this.options.ocaModelInfo.supportsReasoning) {
|
||||
responsesParams["reasoning"] = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
|
||||
const ocaModelInfo = this.options.ocaModelInfo
|
||||
if (!ocaModelInfo) {
|
||||
throw new Error("Oracle Code Assist (OCA) model info is required for Responses API")
|
||||
}
|
||||
if (ocaModelInfo.supportsReasoning) {
|
||||
responsesParams.reasoning = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
|
||||
}
|
||||
|
||||
// Create the response using Responses API
|
||||
const stream = await client.responses.create(responsesParams)
|
||||
|
||||
yield* handleResponsesApiStreamResponse(stream, this.options.ocaModelInfo!, this.calculateCost.bind(this))
|
||||
yield* handleResponsesApiStreamResponse(stream, ocaModelInfo, this.calculateCost.bind(this))
|
||||
}
|
||||
|
||||
async *createMessageMessagesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureAnthropicClient()
|
||||
|
||||
const modelId = this.options.ocaModelId || liteLlmDefaultModelId
|
||||
|
||||
const budgetTokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = this.options.ocaModelInfo?.supportsReasoning && budgetTokens !== 0
|
||||
|
||||
let temperature: number | undefined = this.options.ocaModelInfo?.temperature ?? 0
|
||||
const maxTokens: number | undefined = this.options.ocaModelInfo?.maxTokens || 8192
|
||||
|
||||
if (reasoningOn) {
|
||||
temperature = 0
|
||||
}
|
||||
|
||||
const anthropicTools = convertOpenAIToolsToAnthropicTools(tools)
|
||||
const anthropicMessages = sanitizeAnthropicMessages(messages, this.options.ocaUsePromptCache ?? false)
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: maxTokens,
|
||||
temperature: reasoningOn ? undefined : temperature,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: this.options.ocaUsePromptCache ? { type: "ephemeral" } : undefined,
|
||||
},
|
||||
],
|
||||
messages: anthropicMessages,
|
||||
stream: true,
|
||||
tools: anthropicTools,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined,
|
||||
})
|
||||
|
||||
yield* handleAnthropicMessagesApiStreamResponse(stream)
|
||||
}
|
||||
|
||||
getModel() {
|
||||
|
||||
@@ -3,11 +3,16 @@ import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import * as os from "os"
|
||||
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
|
||||
import { v7 as uuidv7 } from "uuid"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
@@ -17,6 +22,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
* Routes to chatgpt.com/backend-api/codex
|
||||
*/
|
||||
const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
const CODEX_RESPONSES_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses"
|
||||
|
||||
interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
|
||||
reasoningEffort?: string
|
||||
@@ -36,6 +42,8 @@ interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
|
||||
export class OpenAiCodexHandler implements ApiHandler {
|
||||
private options: OpenAiCodexHandlerOptions
|
||||
private client?: OpenAI
|
||||
private responsesWs: UndiciWebSocket | undefined
|
||||
private websocketRequestInFlight = false
|
||||
// Session ID for the Codex API (persists for the lifetime of the handler)
|
||||
private readonly sessionId: string
|
||||
// Abort controller for cancelling ongoing requests
|
||||
@@ -49,7 +57,7 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
this.sessionId = uuidv7()
|
||||
}
|
||||
|
||||
private normalizeUsage(usage: any, model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
|
||||
private normalizeUsage(usage: any, _model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
|
||||
if (!usage) {
|
||||
return undefined
|
||||
}
|
||||
@@ -101,17 +109,18 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
if (!accessToken) {
|
||||
throw new Error("Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.")
|
||||
}
|
||||
|
||||
// Format conversation for Responses API
|
||||
const formattedInput = convertToOpenAIResponsesInput(messages).input
|
||||
const useWebsocketMode = this.useWebsocketMode(model.info.apiFormat)
|
||||
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: useWebsocketMode })
|
||||
const usePreviousResponseId = useWebsocketMode && !!previousResponseId
|
||||
|
||||
// Build request body
|
||||
const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, tools)
|
||||
const requestBody = this.buildRequestBody(model, input, systemPrompt, tools, previousResponseId)
|
||||
const fallbackRequestBody = this.buildRequestBody(model, input, systemPrompt, tools)
|
||||
|
||||
// Make the request with retry on auth failure
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
yield* this.executeRequest(requestBody, model, accessToken)
|
||||
yield* this.executeRequest(requestBody, fallbackRequestBody, model, accessToken, usePreviousResponseId)
|
||||
return
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -133,11 +142,19 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
|
||||
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
|
||||
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private buildRequestBody(
|
||||
model: { id: string; info: ModelInfo },
|
||||
formattedInput: any,
|
||||
systemPrompt: string,
|
||||
tools?: ChatCompletionTool[],
|
||||
previousResponseId?: string,
|
||||
): any {
|
||||
// Determine reasoning effort
|
||||
const reasoningEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
|
||||
@@ -147,8 +164,9 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
model: model.id,
|
||||
input: formattedInput,
|
||||
stream: true,
|
||||
store: false,
|
||||
store: !previousResponseId,
|
||||
instructions: systemPrompt,
|
||||
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
|
||||
...(includeReasoning ? { include: ["reasoning.encrypted_content"] } : {}),
|
||||
...(includeReasoning
|
||||
? {
|
||||
@@ -177,7 +195,13 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
return body
|
||||
}
|
||||
|
||||
private async *executeRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
|
||||
private async *executeRequest(
|
||||
requestBody: any,
|
||||
fallbackRequestBody: any,
|
||||
model: { id: string; info: ModelInfo },
|
||||
accessToken: string,
|
||||
useWebsocketMode: boolean,
|
||||
): ApiStream {
|
||||
// Create AbortController for cancellation
|
||||
this.abortController = new AbortController()
|
||||
|
||||
@@ -194,6 +218,16 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
...buildExternalBasicHeaders(),
|
||||
}
|
||||
|
||||
if (useWebsocketMode) {
|
||||
try {
|
||||
yield* this.createResponseStreamWebsocket(requestBody, fallbackRequestBody, accessToken, codexHeaders, model)
|
||||
return
|
||||
} catch (error) {
|
||||
Logger.error("OpenAI Codex websocket mode failed, falling back to HTTP Responses API:", error)
|
||||
this.closeResponsesWebsocket()
|
||||
}
|
||||
}
|
||||
|
||||
// Try using OpenAI SDK first
|
||||
try {
|
||||
const client =
|
||||
@@ -232,6 +266,223 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseStreamWebsocket(
|
||||
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
accessToken: string,
|
||||
codexHeaders: Record<string, string>,
|
||||
model: { id: string; info: ModelInfo },
|
||||
): ApiStream {
|
||||
try {
|
||||
for await (const event of this.createResponseEventsViaWebsocket(primaryParams, accessToken, codexHeaders)) {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
return
|
||||
}
|
||||
yield* this.processEvent(event, model)
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
|
||||
Logger.log(
|
||||
"Retrying Codex websocket response with full context after previous_response_not_found or socket reset",
|
||||
)
|
||||
this.closeResponsesWebsocket()
|
||||
for await (const event of this.createResponseEventsViaWebsocket(fallbackParams, accessToken, codexHeaders)) {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
return
|
||||
}
|
||||
yield* this.processEvent(event, model)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
|
||||
const errorCode =
|
||||
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: undefined
|
||||
|
||||
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
|
||||
return true
|
||||
}
|
||||
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async ensureResponsesWebsocket(accessToken: string, codexHeaders: Record<string, string>): Promise<UndiciWebSocket> {
|
||||
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
|
||||
return this.responsesWs
|
||||
}
|
||||
|
||||
this.closeResponsesWebsocket()
|
||||
|
||||
const ws = new UndiciWebSocket(CODEX_RESPONSES_WEBSOCKET_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"OpenAI-Beta": "responses_websockets=2026-02-06",
|
||||
...codexHeaders,
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
ws.removeEventListener("open", handleOpen)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
}
|
||||
const handleOpen = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const handleError = () => {
|
||||
cleanup()
|
||||
reject(new Error("Failed to open Codex Responses websocket"))
|
||||
}
|
||||
const handleClose = () => {
|
||||
cleanup()
|
||||
reject(new Error("Codex Responses websocket closed before opening"))
|
||||
}
|
||||
ws.addEventListener("open", handleOpen)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
})
|
||||
|
||||
this.responsesWs = ws
|
||||
return ws
|
||||
}
|
||||
|
||||
private closeResponsesWebsocket() {
|
||||
if (this.responsesWs) {
|
||||
try {
|
||||
this.responsesWs.close()
|
||||
} catch {}
|
||||
this.responsesWs = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseEventsViaWebsocket(
|
||||
params: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
accessToken: string,
|
||||
codexHeaders: Record<string, string>,
|
||||
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
|
||||
if (this.websocketRequestInFlight) {
|
||||
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
|
||||
error.code = "websocket_concurrency_limit"
|
||||
throw error
|
||||
}
|
||||
|
||||
const ws = await this.ensureResponsesWebsocket(accessToken, codexHeaders)
|
||||
this.websocketRequestInFlight = true
|
||||
|
||||
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
|
||||
let resolver: (() => void) | undefined
|
||||
let completed = false
|
||||
let failure: (Error & { code?: string }) | undefined
|
||||
|
||||
const wake = () => {
|
||||
const next = resolver
|
||||
resolver = undefined
|
||||
next?.()
|
||||
}
|
||||
|
||||
const handleMessage = (evt: UndiciMessageEvent) => {
|
||||
try {
|
||||
let raw = ""
|
||||
if (typeof evt.data === "string") {
|
||||
raw = evt.data
|
||||
} else if (evt.data instanceof ArrayBuffer) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data))
|
||||
} else if (ArrayBuffer.isView(evt.data)) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
|
||||
} else {
|
||||
raw = String(evt.data)
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.type === "error" && parsed?.error) {
|
||||
const error: Error & { code?: string } = new Error(parsed.error.message || "Codex Responses websocket error")
|
||||
error.code = parsed.error.code
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
return
|
||||
}
|
||||
|
||||
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
|
||||
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
|
||||
completed = true
|
||||
}
|
||||
wake()
|
||||
} catch (error) {
|
||||
const parseError: Error & { code?: string } = new Error(
|
||||
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
parseError.code = "websocket_parse_error"
|
||||
failure = parseError
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
const error: Error & { code?: string } = new Error("Codex Responses websocket emitted an error event")
|
||||
error.code = "websocket_error"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!completed) {
|
||||
const error: Error & { code?: string } = new Error("Codex Responses websocket closed during response stream")
|
||||
error.code = "websocket_closed"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener("message", handleMessage)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "response.create",
|
||||
...params,
|
||||
}),
|
||||
)
|
||||
|
||||
while (!completed || eventQueue.length > 0) {
|
||||
if (eventQueue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const event = eventQueue.shift()
|
||||
if (event) {
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
} finally {
|
||||
ws.removeEventListener("message", handleMessage)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
this.websocketRequestInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private async *makeCodexRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
|
||||
const url = `${CODEX_API_BASE_URL}/responses`
|
||||
|
||||
@@ -465,6 +716,7 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.closeResponsesWebsocket()
|
||||
this.abortController?.abort()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,18 @@ import {
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { calculateApiCostOpenAI } from "@utils/cost"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import type {
|
||||
ChatCompletionFunctionTool,
|
||||
ChatCompletionReasoningEffort,
|
||||
ChatCompletionTool,
|
||||
} from "openai/resources/chat/completions"
|
||||
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isGPT5ModelFamily } from "@/utils/model-utils"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
@@ -26,12 +34,16 @@ interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
|
||||
reasoningEffort?: string
|
||||
thinkingBudgetTokens?: number
|
||||
apiModelId?: string
|
||||
store?: boolean
|
||||
openAiNativeUseResponsesWebsocket?: boolean
|
||||
}
|
||||
|
||||
export class OpenAiNativeHandler implements ApiHandler {
|
||||
private options: OpenAiNativeHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
private responsesWs: UndiciWebSocket | undefined
|
||||
private responsesWsReadyPromise: Promise<UndiciWebSocket> | undefined
|
||||
private websocketRequestInFlight = false
|
||||
private abortController?: AbortController
|
||||
|
||||
constructor(options: OpenAiNativeHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -73,7 +85,8 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
|
||||
// Responses API requires tool format to be set to OPENAI_RESPONSES with native tools calling enabled
|
||||
if (this.getModel()?.info?.apiFormat === ApiFormat.OPENAI_RESPONSES) {
|
||||
const apiFormat = this.getModel()?.info?.apiFormat
|
||||
if (apiFormat === ApiFormat.OPENAI_RESPONSES || apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE) {
|
||||
if (!tools?.length) {
|
||||
throw new Error("Native Tool Call must be enabled in your setting for OpenAI Responses API")
|
||||
}
|
||||
@@ -91,13 +104,17 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
this.abortController = new AbortController()
|
||||
|
||||
// Handle o1 models separately as they don't support streaming
|
||||
if (model.info.supportsStreaming === false) {
|
||||
const response = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
})
|
||||
const response = await client.chat.completions.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages, "openai-native")],
|
||||
},
|
||||
{ signal: this.abortController?.signal },
|
||||
)
|
||||
yield {
|
||||
type: "text",
|
||||
text: response.choices[0]?.message.content || "",
|
||||
@@ -115,7 +132,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: systemRole, content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
messages: [{ role: systemRole, content: systemPrompt }, ...convertToOpenAiMessages(messages, "openai-native")],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
reasoning_effort: reasoningEffort,
|
||||
@@ -152,26 +169,79 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
messages: ClineStorageMessage[],
|
||||
tools: ChatCompletionTool[],
|
||||
): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const usePreviousResponseId = this.useWebsocketMode(model.info.apiFormat)
|
||||
|
||||
// Convert messages to Responses API input format
|
||||
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages)
|
||||
// Warm websocket connection early in websocket mode so the first response.create avoids handshake latency.
|
||||
if (usePreviousResponseId) {
|
||||
this.preconnectResponsesWebsocket()
|
||||
}
|
||||
|
||||
// Convert ChatCompletion tools to Responses API format if provided
|
||||
const responseTools = tools
|
||||
?.filter((tool) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId })
|
||||
const responseTools = this.mapResponseTools(tools)
|
||||
this.abortController = new AbortController()
|
||||
|
||||
const params = this.buildResponseCreateParams({
|
||||
modelId: model.id,
|
||||
systemPrompt,
|
||||
input,
|
||||
previousResponseId,
|
||||
tools: responseTools,
|
||||
})
|
||||
|
||||
const fallbackParams = this.buildResponseCreateParams({
|
||||
modelId: model.id,
|
||||
systemPrompt,
|
||||
input,
|
||||
tools: responseTools,
|
||||
})
|
||||
|
||||
if (usePreviousResponseId && previousResponseId) {
|
||||
try {
|
||||
yield* this.createResponseStreamWebsocket(model.info, params, fallbackParams)
|
||||
return
|
||||
} catch (error) {
|
||||
Logger.error("OpenAI websocket mode failed, falling back to HTTP Responses API:", error)
|
||||
this.closeResponsesWebsocket()
|
||||
}
|
||||
}
|
||||
|
||||
yield* this.createResponseStreamHttp(model.info, params)
|
||||
}
|
||||
|
||||
private preconnectResponsesWebsocket(): void {
|
||||
void this.ensureResponsesWebsocket().catch((error) => {
|
||||
Logger.debug("OpenAI websocket preconnect failed:", error)
|
||||
this.closeResponsesWebsocket()
|
||||
})
|
||||
}
|
||||
|
||||
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
|
||||
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
|
||||
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private mapResponseTools(tools: ChatCompletionTool[]): OpenAI.Responses.Tool[] {
|
||||
return tools
|
||||
?.filter((tool): tool is ChatCompletionFunctionTool => tool?.type === "function")
|
||||
.map((tool) => ({
|
||||
type: "function" as const,
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
|
||||
parameters: tool.function.parameters ?? null,
|
||||
strict: tool.function.strict ?? true,
|
||||
}))
|
||||
}
|
||||
|
||||
Logger.debug(`OpenAI Responses Input: ${JSON.stringify(input)}`)
|
||||
|
||||
// Create the response using Responses API
|
||||
private buildResponseCreateParams(args: {
|
||||
modelId: string
|
||||
systemPrompt: string
|
||||
input: OpenAI.Responses.ResponseInput
|
||||
tools: OpenAI.Responses.Tool[]
|
||||
previousResponseId?: string
|
||||
}): OpenAI.Responses.ResponseCreateParamsStreaming {
|
||||
const requestedEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
|
||||
const reasoning: { effort: ChatCompletionReasoningEffort; summary: "auto" } | undefined =
|
||||
requestedEffort === "none"
|
||||
@@ -181,25 +251,261 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
summary: "auto",
|
||||
}
|
||||
|
||||
const stream = await client.responses.create({
|
||||
model: model.id,
|
||||
instructions: systemPrompt,
|
||||
input,
|
||||
return {
|
||||
model: args.modelId,
|
||||
instructions: args.systemPrompt,
|
||||
input: args.input,
|
||||
stream: true,
|
||||
tools: responseTools,
|
||||
store: this.options.store ?? false,
|
||||
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
|
||||
tools: args.tools,
|
||||
store: !args.previousResponseId, // Do not use store when websocket mode is enabled.
|
||||
...(args.previousResponseId ? { previous_response_id: args.previousResponseId } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
// include: ["reasoning.encrypted_content"],
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseStreamHttp(
|
||||
modelInfo: ModelInfo,
|
||||
params: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
Logger.debug(`OpenAI Responses Input (HTTP): ${JSON.stringify(params.input)}`)
|
||||
const stream = await client.responses.create(params, { signal: this.abortController?.signal })
|
||||
yield* this.processResponsesEvents(stream, modelInfo)
|
||||
}
|
||||
|
||||
private async *createResponseStreamWebsocket(
|
||||
modelInfo: ModelInfo,
|
||||
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
): ApiStream {
|
||||
Logger.debug(`OpenAI Responses Input (WebSocket): ${JSON.stringify(primaryParams.input)}`)
|
||||
try {
|
||||
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(primaryParams), modelInfo)
|
||||
} catch (error) {
|
||||
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
|
||||
Logger.log("Retrying websocket response with full context after previous_response_not_found or socket reset")
|
||||
this.closeResponsesWebsocket()
|
||||
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(fallbackParams), modelInfo)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
|
||||
const errorCode =
|
||||
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: undefined
|
||||
|
||||
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
|
||||
return true
|
||||
}
|
||||
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async ensureResponsesWebsocket(): Promise<UndiciWebSocket> {
|
||||
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
|
||||
return this.responsesWs
|
||||
}
|
||||
|
||||
if (this.responsesWsReadyPromise) {
|
||||
return this.responsesWsReadyPromise
|
||||
}
|
||||
|
||||
this.closeResponsesWebsocket()
|
||||
|
||||
if (!this.options.openAiNativeApiKey) {
|
||||
throw new Error("OpenAI API key is required")
|
||||
}
|
||||
|
||||
const ws = new UndiciWebSocket("wss://api.openai.com/v1/responses", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.openAiNativeApiKey}`,
|
||||
"OpenAI-Beta": "responses_websockets=2026-02-06",
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
|
||||
this.responsesWs = ws
|
||||
const readyPromise = new Promise<UndiciWebSocket>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
ws.removeEventListener("open", handleOpen)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
}
|
||||
const handleOpen = () => {
|
||||
cleanup()
|
||||
resolve(ws)
|
||||
}
|
||||
const handleError = () => {
|
||||
cleanup()
|
||||
reject(new Error("Failed to open Responses websocket"))
|
||||
}
|
||||
const handleClose = () => {
|
||||
cleanup()
|
||||
reject(new Error("Responses websocket closed before opening"))
|
||||
}
|
||||
ws.addEventListener("open", handleOpen)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
})
|
||||
|
||||
this.responsesWsReadyPromise = readyPromise
|
||||
|
||||
try {
|
||||
return await readyPromise
|
||||
} catch (error) {
|
||||
if (this.responsesWs === ws) {
|
||||
this.responsesWs = undefined
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (this.responsesWsReadyPromise === readyPromise) {
|
||||
this.responsesWsReadyPromise = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private closeResponsesWebsocket() {
|
||||
this.responsesWsReadyPromise = undefined
|
||||
if (this.responsesWs) {
|
||||
try {
|
||||
this.responsesWs.close()
|
||||
} catch {}
|
||||
this.responsesWs = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseEventsViaWebsocket(
|
||||
params: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
|
||||
if (this.websocketRequestInFlight) {
|
||||
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
|
||||
error.code = "websocket_concurrency_limit"
|
||||
throw error
|
||||
}
|
||||
|
||||
const ws = await this.ensureResponsesWebsocket()
|
||||
this.websocketRequestInFlight = true
|
||||
|
||||
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
|
||||
let resolver: (() => void) | undefined
|
||||
let completed = false
|
||||
let failure: (Error & { code?: string }) | undefined
|
||||
|
||||
const wake = () => {
|
||||
const next = resolver
|
||||
resolver = undefined
|
||||
next?.()
|
||||
}
|
||||
|
||||
const handleMessage = (evt: UndiciMessageEvent) => {
|
||||
try {
|
||||
let raw = ""
|
||||
if (typeof evt.data === "string") {
|
||||
raw = evt.data
|
||||
} else if (evt.data instanceof ArrayBuffer) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data))
|
||||
} else if (ArrayBuffer.isView(evt.data)) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
|
||||
} else {
|
||||
raw = String(evt.data)
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (parsed?.type === "error" && parsed?.error) {
|
||||
const error: Error & { code?: string } = new Error(parsed.error.message || "Responses websocket error")
|
||||
error.code = parsed.error.code
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
return
|
||||
}
|
||||
|
||||
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
|
||||
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
|
||||
completed = true
|
||||
}
|
||||
wake()
|
||||
} catch (error) {
|
||||
const parseError: Error & { code?: string } = new Error(
|
||||
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
parseError.code = "websocket_parse_error"
|
||||
failure = parseError
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
const error: Error & { code?: string } = new Error("Responses websocket emitted an error event")
|
||||
error.code = "websocket_error"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!completed) {
|
||||
const error: Error & { code?: string } = new Error("Responses websocket closed during response stream")
|
||||
error.code = "websocket_closed"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener("message", handleMessage)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "response.create",
|
||||
...params,
|
||||
}),
|
||||
)
|
||||
|
||||
while (!completed || eventQueue.length > 0) {
|
||||
if (eventQueue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const event = eventQueue.shift()
|
||||
if (event) {
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
} finally {
|
||||
ws.removeEventListener("message", handleMessage)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
this.websocketRequestInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private async *processResponsesEvents(
|
||||
stream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent>,
|
||||
modelInfo: ModelInfo,
|
||||
): ApiStream {
|
||||
const functionCallByItemId = new Map<string, { call_id?: string; name?: string; id?: string }>()
|
||||
|
||||
// Process the response stream
|
||||
for await (const chunk of stream) {
|
||||
Logger.debug(`OpenAI Responses Chunk: ${JSON.stringify(chunk)}`)
|
||||
|
||||
// Handle different event types from Responses API
|
||||
if (chunk.type === "response.output_item.added") {
|
||||
const item = chunk.item
|
||||
if (item.type === "function_call" && item.id) {
|
||||
@@ -277,7 +583,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
if (chunk.type === "response.output_text.delta") {
|
||||
// Handle text content deltas
|
||||
if (chunk.delta) {
|
||||
yield {
|
||||
id: chunk.item_id,
|
||||
@@ -287,7 +592,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
if (chunk.type === "response.reasoning_text.delta") {
|
||||
// Handle reasoning content deltas
|
||||
if (chunk.delta) {
|
||||
yield {
|
||||
id: chunk.item_id,
|
||||
@@ -315,7 +619,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
if (chunk.type === "response.function_call_arguments.done") {
|
||||
// Handle completed function call
|
||||
if (chunk.item_id && chunk.name && chunk.arguments) {
|
||||
const pendingCall = functionCallByItemId.get(chunk.item_id)
|
||||
const callId = pendingCall?.call_id
|
||||
@@ -348,7 +651,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.type === "response.completed" && chunk.response?.usage) {
|
||||
// Handle usage information when response is complete
|
||||
const usage = chunk.response.usage
|
||||
const inputTokens = usage.input_tokens || 0
|
||||
const outputTokens = usage.output_tokens || 0
|
||||
@@ -357,7 +659,13 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0
|
||||
const totalTokens = usage.total_tokens || 0
|
||||
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
|
||||
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens + reasoningTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const totalCost = calculateApiCostOpenAI(
|
||||
modelInfo,
|
||||
inputTokens,
|
||||
outputTokens + reasoningTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
)
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
|
||||
yield {
|
||||
type: "usage",
|
||||
@@ -373,6 +681,12 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.closeResponsesWebsocket()
|
||||
this.abortController?.abort()
|
||||
this.abortController = undefined
|
||||
}
|
||||
|
||||
getModel(): { id: OpenAiNativeModelId; info: OpenAiCompatibleModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in openAiNativeModels) {
|
||||
|
||||
@@ -122,7 +122,12 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
|
||||
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
|
||||
if (
|
||||
delta &&
|
||||
"reasoning" in delta &&
|
||||
delta.reasoning &&
|
||||
!shouldSkipReasoningForModel(this.options.openRouterModelId)
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
|
||||
@@ -132,6 +137,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
// OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
|
||||
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
|
||||
if (
|
||||
delta &&
|
||||
"reasoning_details" in delta &&
|
||||
delta.reasoning_details &&
|
||||
// @ts-expect-error-next-line
|
||||
|
||||
@@ -52,7 +52,7 @@ export class SambanovaHandler implements ApiHandler {
|
||||
|
||||
const modelId = model.id.toLowerCase()
|
||||
|
||||
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
|
||||
if (modelId.includes("deepseek") || modelId.includes("qwen3")) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export class SambanovaHandler implements ApiHandler {
|
||||
const stream = await client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
temperature: model.info.temperature ?? 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...getOpenAIToolParams(tools),
|
||||
|
||||
@@ -372,12 +372,12 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
private chunkToString(chunk: any): string {
|
||||
if (Buffer.isBuffer(chunk)) {
|
||||
return chunk.toString("utf-8")
|
||||
} else if (typeof chunk === "string") {
|
||||
return chunk
|
||||
} else {
|
||||
// Handle comma-separated byte values or other array-like formats
|
||||
return Buffer.from(chunk).toString("utf-8")
|
||||
}
|
||||
if (typeof chunk === "string") {
|
||||
return chunk
|
||||
}
|
||||
// Handle comma-separated byte values or other array-like formats
|
||||
return Buffer.from(chunk).toString("utf-8")
|
||||
}
|
||||
|
||||
private validateCredentials(): void {
|
||||
@@ -526,7 +526,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
if (!expiresIn) {
|
||||
throw new Error("Destination is missing required authTokens with expiresIn")
|
||||
}
|
||||
this.destinationExpiresAt = Date.now() + parseInt(expiresIn, 10) * 1000
|
||||
this.destinationExpiresAt = Date.now() + Number.parseInt(expiresIn, 10) * 1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,20 +849,21 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
if (error.response.status === 404) {
|
||||
throw new Error(`404 Not Found: ${errorMessage}`)
|
||||
} else if (error.response.status === 400) {
|
||||
}
|
||||
if (error.response.status === 400) {
|
||||
throw new Error(`400 Bad Request: ${errorMessage}`)
|
||||
}
|
||||
|
||||
throw new Error(`HTTP ${error.response.status}: ${errorMessage}`)
|
||||
} else if (error.request) {
|
||||
}
|
||||
if (error.request) {
|
||||
// The request was made but no response was received
|
||||
Logger.error("Error request:", error.request)
|
||||
throw new Error("No response received from server")
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
Logger.error("Error message:", error.message)
|
||||
throw new Error(`Error setting up request: ${error.message}`)
|
||||
}
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
Logger.error("Error message:", error.message)
|
||||
throw new Error(`Error setting up request: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,12 @@ export class VercelAIGatewayHandler implements ApiHandler {
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
// Skip reasoning content for models that don't support it (e.g., devstral, grok-4)
|
||||
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
|
||||
if (
|
||||
delta &&
|
||||
"reasoning" in delta &&
|
||||
delta.reasoning &&
|
||||
!shouldSkipReasoningForModel(this.options.openRouterModelId)
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
|
||||
@@ -93,6 +98,7 @@ export class VercelAIGatewayHandler implements ApiHandler {
|
||||
|
||||
// Reasoning details that can be passed back in API requests to preserve reasoning traces
|
||||
if (
|
||||
delta &&
|
||||
"reasoning_details" in delta &&
|
||||
delta.reasoning_details &&
|
||||
// @ts-expect-error-next-line
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("Tool Call Parsing", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToOpenAiMessages(messages)
|
||||
const result = convertToOpenAiMessages(messages, "openai-native")
|
||||
|
||||
result.should.have.length(1)
|
||||
const msg = result[0] as any
|
||||
@@ -64,7 +64,7 @@ describe("Tool Call Parsing", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToOpenAiMessages(messages)
|
||||
const result = convertToOpenAiMessages(messages, "openai-native")
|
||||
|
||||
const msg = result[0] as any
|
||||
msg.tool_calls[0].id.length.should.be.belowOrEqual(40)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiProvider } from "@/shared/api"
|
||||
import {
|
||||
ClineAssistantRedactedThinkingBlock,
|
||||
ClineAssistantThinkingBlock,
|
||||
@@ -27,20 +28,25 @@ function isOpenAIResponseToolId(callId: string): boolean {
|
||||
|
||||
/**
|
||||
* Transforms a tool ID to a consistent format for OpenAI's Chat Completions API.
|
||||
* NOTE: We do not want to transform tool IDs for non-OpenAI providers that may have different requirements.
|
||||
* This function MUST be used for both tool_calls[].id (assistant) and tool_call_id (tool result)
|
||||
* to ensure they match - otherwise OpenAI will reject the request with:
|
||||
* "Invalid parameter: 'tool_call_id' of 'xxx' not found in 'tool_calls' of previous message."
|
||||
*
|
||||
* @param toolId - The original tool ID from Cline/Anthropic format
|
||||
* @param provider - The API provider that the OpenAI formatted messages will be sent to
|
||||
* @returns The transformed ID suitable for OpenAI API
|
||||
*/
|
||||
function transformToolCallId(toolId: string): string {
|
||||
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): string {
|
||||
// OpenAI Responses API uses "fc_" prefix with 53 char length
|
||||
// Convert these to "call_" prefix format for Chat Completions API
|
||||
if (isOpenAIResponseToolId(toolId)) {
|
||||
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
|
||||
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
|
||||
}
|
||||
if (provider !== "openai-native") {
|
||||
return toolId
|
||||
}
|
||||
// Ensure ID doesn't exceed max length
|
||||
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
|
||||
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
|
||||
@@ -55,10 +61,12 @@ function transformToolCallId(toolId: string): string {
|
||||
* into OpenAI's expected message structure, including tool_calls and tool_call_id fields.
|
||||
*
|
||||
* @param anthropicMessages - Array of ClineStorageMessage objects to be converted
|
||||
* @param provider - Optional parameter to indicate the API provider, which may affect ID transformation logic
|
||||
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
|
||||
*/
|
||||
export function convertToOpenAiMessages(
|
||||
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
|
||||
provider?: ApiProvider,
|
||||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
||||
|
||||
@@ -120,7 +128,7 @@ export function convertToOpenAiMessages(
|
||||
role: "tool",
|
||||
// The tool_call_id must match the id used in the assistant's tool_calls array.
|
||||
// Use the same transformation logic as tool_calls to ensure IDs match.
|
||||
tool_call_id: transformToolCallId(toolMessage.tool_use_id),
|
||||
tool_call_id: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
|
||||
content: content,
|
||||
})
|
||||
})
|
||||
@@ -233,7 +241,7 @@ export function convertToOpenAiMessages(
|
||||
|
||||
return {
|
||||
// Use the same transformation as tool_call_id to ensure IDs match
|
||||
id: transformToolCallId(toolId),
|
||||
id: transformToolCallIdForNativeApi(toolId, provider),
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolMessage.name,
|
||||
|
||||
@@ -85,7 +85,10 @@ export function convertToOpenAIResponsesInput(
|
||||
if (options?.usePreviousResponseId) {
|
||||
for (let i = _messages.length - 1; i >= 0; i--) {
|
||||
const msg = _messages[i]
|
||||
if (msg.role === "assistant" && msg.id) {
|
||||
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
|
||||
// Set to 23 hours to account for any potential delays in processing.
|
||||
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
|
||||
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
|
||||
previousResponseId = msg.id
|
||||
messages = _messages.slice(i + 1)
|
||||
break
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
OPENROUTER_PROVIDER_PREFERENCES,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
|
||||
@@ -160,7 +160,7 @@ export async function createOpenRouterStream(
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
|
||||
if (model.id.startsWith("google/gemini-3")) {
|
||||
// Recommended value from google
|
||||
temperature = 1.0
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
ModelInfo,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
|
||||
@@ -100,7 +100,7 @@ export async function createVercelAIGatewayStream(
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
|
||||
if (model.id.startsWith("google/gemini-3")) {
|
||||
// Recommended value from google
|
||||
temperature = 1.0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
|
||||
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export async function* handleAnthropicMessagesApiStreamResponse(
|
||||
stream: AnthropicStream<Anthropic.RawMessageStreamEvent>,
|
||||
): ApiStream {
|
||||
const lastStartedToolCall = { id: "", name: "", arguments: "" }
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk?.type) {
|
||||
case "message_start": {
|
||||
const usage = chunk.message.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message_delta":
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
case "message_stop":
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
signature: chunk.content_block.signature,
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Content is encrypted, and we don't want to pass placeholder text back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
redacted_data: chunk.content_block.data,
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
if (chunk.content_block.id && chunk.content_block.name) {
|
||||
lastStartedToolCall.id = chunk.content_block.id
|
||||
lastStartedToolCall.name = chunk.content_block.name
|
||||
lastStartedToolCall.arguments = ""
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.content_block.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "signature_delta":
|
||||
if (chunk.delta.signature) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "",
|
||||
signature: chunk.delta.signature,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
case "input_json_delta":
|
||||
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
...lastStartedToolCall,
|
||||
function: {
|
||||
...lastStartedToolCall,
|
||||
id: lastStartedToolCall.id,
|
||||
name: lastStartedToolCall.name,
|
||||
arguments: chunk.delta.partial_json,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_stop":
|
||||
lastStartedToolCall.id = ""
|
||||
lastStartedToolCall.name = ""
|
||||
lastStartedToolCall.arguments = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function convertOpenAIToolsToAnthropicTools(tools?: OpenAITool[]): AnthropicTool[] | undefined {
|
||||
if (!tools?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const anthropicTools: AnthropicTool[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool?.type !== "function" || !tool.function?.name) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fn = tool.function
|
||||
|
||||
const hasSchemaObject = fn.parameters && typeof fn.parameters === "object"
|
||||
const inputSchema = hasSchemaObject ? { ...fn.parameters } : {}
|
||||
if (typeof (inputSchema as { type?: unknown }).type !== "string") {
|
||||
;(inputSchema as { type: string }).type = "object"
|
||||
}
|
||||
|
||||
anthropicTools.push({
|
||||
name: fn.name,
|
||||
description: fn.description || undefined,
|
||||
input_schema: inputSchema as AnthropicTool["input_schema"],
|
||||
})
|
||||
}
|
||||
|
||||
return anthropicTools.length > 0 ? anthropicTools : undefined
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
|
||||
import { ClineDefaultTool, getToolUseNames } from "@shared/tools"
|
||||
import { nanoid } from "nanoid"
|
||||
import { AssistantMessageContent, TextStreamContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
|
||||
|
||||
@@ -35,9 +35,9 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
|
||||
let currentParamName: ToolParamName | undefined
|
||||
|
||||
// Precompute tags for faster lookups
|
||||
const toolUseOpenTags = new Map<string, ClineDefaultTool>()
|
||||
const toolUseOpenTags = new Map<string, string>()
|
||||
const toolParamOpenTags = new Map<string, ToolParamName>()
|
||||
for (const name of toolUseNames) {
|
||||
for (const name of getToolUseNames()) {
|
||||
toolUseOpenTags.set(`<${name}>`, name)
|
||||
}
|
||||
for (const name of toolParamNames) {
|
||||
@@ -173,7 +173,7 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
|
||||
// Start the new tool use
|
||||
currentToolUse = {
|
||||
type: "tool_use",
|
||||
name: toolName,
|
||||
name: toolName as ClineDefaultTool,
|
||||
params: {},
|
||||
partial: true, // Assume partial until closing tag is found
|
||||
call_id: nanoid(8),
|
||||
|
||||
@@ -154,15 +154,21 @@ export class ContextManager {
|
||||
thresholdPercentage?: number,
|
||||
): boolean {
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const previousRequestText = clineMessages[previousApiReqIndex]?.text
|
||||
if (previousRequestText) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage
|
||||
? Math.floor(contextWindow * thresholdPercentage)
|
||||
: maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -195,10 +201,10 @@ export class ContextManager {
|
||||
}
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
const targetRequest = clineMessages[targetIndex]
|
||||
if (targetRequest && targetRequest.text) {
|
||||
const targetRequestText = clineMessages[targetIndex]?.text
|
||||
if (targetRequestText) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequest.text)
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequestText)
|
||||
const tokensUsed = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { contextWindow } = getContextWindowInfo(api)
|
||||
@@ -232,10 +238,10 @@ export class ContextManager {
|
||||
if (!useAutoCondense) {
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const timestamp = previousRequest.ts
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const previousRequestText = clineMessages[previousApiReqIndex]?.text
|
||||
if (previousRequestText) {
|
||||
const timestamp = clineMessages[previousApiReqIndex].ts
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
@@ -846,9 +852,8 @@ export class ContextManager {
|
||||
}
|
||||
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
|
||||
// to do so we need to keep track of which files we've already replaced so we don't replace them again
|
||||
else {
|
||||
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
|
||||
}
|
||||
|
||||
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
|
||||
}
|
||||
} else {
|
||||
// for all other cases we can assume that we dont need to check this again
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getTaskMetadata, readTaskHistoryFromState, saveTaskMetadata } from "@co
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
@@ -250,6 +250,8 @@ export class FileContextTracker {
|
||||
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
|
||||
try {
|
||||
const key = `pendingFileContextWarning_${this.taskId}`
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
|
||||
return files
|
||||
} catch (error) {
|
||||
@@ -265,6 +267,8 @@ export class FileContextTracker {
|
||||
try {
|
||||
const files = await this.retrievePendingFileContextWarning()
|
||||
if (files) {
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
|
||||
return files
|
||||
}
|
||||
@@ -278,12 +282,12 @@ export class FileContextTracker {
|
||||
* Static method to clean up orphaned pending file context warnings at startup
|
||||
* This removes warnings for tasks that may no longer exist
|
||||
*/
|
||||
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
|
||||
static async cleanupOrphanedWarnings(stateManager: StateManager): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
|
||||
const allStateKeys = context.workspaceState.keys()
|
||||
const allStateKeys = Object.keys(stateManager.getAllWorkspaceStateEntries())
|
||||
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
|
||||
|
||||
const orphanedPendingContextTasks: string[] = []
|
||||
@@ -296,8 +300,9 @@ export class FileContextTracker {
|
||||
|
||||
if (orphanedPendingContextTasks.length > 0) {
|
||||
for (const key of orphanedPendingContextTasks) {
|
||||
// eslint-disable-next-line eslint-rules/no-direct-vscode-state-api
|
||||
await context.workspaceState.update(key, undefined)
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
await stateManager.setWorkspaceState(key as any, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ChatContent } from "@shared/ChatContent"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
|
||||
import { SETTINGS_DEFAULTS, type Settings } from "@shared/storage/state-keys"
|
||||
import { type Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import type { UserInfo } from "@shared/UserInfo"
|
||||
@@ -23,7 +23,6 @@ import fs from "fs/promises"
|
||||
import open from "open"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import type * as vscode from "vscode"
|
||||
import { ClineEnv } from "@/config"
|
||||
import type { FolderLockWithRetryResult } from "@/core/locks/types"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -35,6 +34,7 @@ import { BannerService } from "@/services/banner/BannerService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -117,7 +117,7 @@ export class Controller {
|
||||
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 3600000) // 1 hour
|
||||
}
|
||||
|
||||
constructor(readonly context: vscode.ExtensionContext) {
|
||||
constructor(readonly context: ClineExtensionContext) {
|
||||
Session.reset() // Reset session on controller initialization
|
||||
PromptRegistry.getInstance() // Ensure prompts and tools are registered
|
||||
this.stateManager = StateManager.get()
|
||||
@@ -894,9 +894,6 @@ export class Controller {
|
||||
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
const localAgentsRulesToggles = this.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
|
||||
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
|
||||
// See: https://github.com/cline/cline/pull/9348
|
||||
const autoCondenseThreshold = SETTINGS_DEFAULTS.autoCondenseThreshold
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
|
||||
@@ -916,6 +913,7 @@ export class Controller {
|
||||
const clineConfig = ClineEnv.config()
|
||||
const environment = clineConfig.environment
|
||||
const banners = BannerService.get().getActiveBanners() ?? []
|
||||
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
|
||||
|
||||
// Check OpenAI Codex authentication status
|
||||
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
|
||||
@@ -978,7 +976,6 @@ export class Controller {
|
||||
taskHistory: processedTaskHistory,
|
||||
shouldShowAnnouncement,
|
||||
favoritedModelIds,
|
||||
autoCondenseThreshold,
|
||||
backgroundCommandRunning: this.backgroundCommandRunning,
|
||||
backgroundCommandTaskId: this.backgroundCommandTaskId,
|
||||
// NEW: Add workspace information
|
||||
@@ -1009,6 +1006,7 @@ export class Controller {
|
||||
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
|
||||
doubleCheckCompletionEnabled,
|
||||
banners,
|
||||
welcomeBanners,
|
||||
openAiCodexIsAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import axios from "axios"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from ".."
|
||||
|
||||
export interface ClineRecommendedModelData {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface ClineRecommendedModelsData {
|
||||
recommended: ClineRecommendedModelData[]
|
||||
free: ClineRecommendedModelData[]
|
||||
}
|
||||
|
||||
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
|
||||
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
|
||||
|
||||
function normalizeRecommendedModel(raw: unknown): ClineRecommendedModelData | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>
|
||||
if (typeof data.id !== "string" || data.id.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: typeof data.name === "string" && data.name.length > 0 ? data.name : data.id,
|
||||
description: typeof data.description === "string" ? data.description : "",
|
||||
tags: Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModelsData | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>
|
||||
if (
|
||||
(data.recommended !== undefined && !Array.isArray(data.recommended)) ||
|
||||
(data.free !== undefined && !Array.isArray(data.free))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const recommendedRaw = Array.isArray(data.recommended) ? data.recommended : []
|
||||
const freeRaw = Array.isArray(data.free) ? data.free : []
|
||||
|
||||
const recommended = recommendedRaw
|
||||
.map((model) => normalizeRecommendedModel(model))
|
||||
.filter((model): model is ClineRecommendedModelData => model !== null)
|
||||
|
||||
const free = freeRaw
|
||||
.map((model) => normalizeRecommendedModel(model))
|
||||
.filter((model): model is ClineRecommendedModelData => model !== null)
|
||||
|
||||
return { recommended, free }
|
||||
}
|
||||
|
||||
export async function refreshClineRecommendedModels(_controller: Controller): Promise<ClineRecommendedModelsData> {
|
||||
if (inMemoryCache && Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS) {
|
||||
return inMemoryCache.data
|
||||
}
|
||||
|
||||
if (pendingRefresh) {
|
||||
return pendingRefresh
|
||||
}
|
||||
|
||||
pendingRefresh = (async () => {
|
||||
try {
|
||||
return await fetchAndCacheClineRecommendedModels()
|
||||
} finally {
|
||||
pendingRefresh = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pendingRefresh
|
||||
}
|
||||
|
||||
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
|
||||
const clineRecommendedModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineRecommendedModels)
|
||||
let result: ClineRecommendedModelsData = { recommended: [], free: [] }
|
||||
|
||||
try {
|
||||
const apiBaseUrl = ClineEnv.config().apiBaseUrl
|
||||
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/recommended-models`, getAxiosSettings())
|
||||
const normalized = normalizeRecommendedModelsResponse(response.data)
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid response data when fetching Cline recommended models")
|
||||
}
|
||||
|
||||
result = normalized
|
||||
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result))
|
||||
Logger.log("Cline recommended models fetched and saved")
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching Cline recommended models:", error)
|
||||
|
||||
try {
|
||||
const fileExists = await fs
|
||||
.access(clineRecommendedModelsFilePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(clineRecommendedModelsFilePath, "utf8")
|
||||
const parsed = JSON.parse(fileContents)
|
||||
if (parsed) {
|
||||
result = parsed
|
||||
Logger.log("Loaded Cline recommended models from cache")
|
||||
}
|
||||
}
|
||||
} catch (cacheError) {
|
||||
Logger.error("Error reading Cline recommended models from cache:", cacheError)
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid pinning empty results in memory for the full TTL after a transient API/cache miss.
|
||||
if (result.recommended.length > 0 || result.free.length > 0) {
|
||||
inMemoryCache = { data: result, timestamp: Date.now() }
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
|
||||
import type { Controller } from "../index"
|
||||
import { refreshClineRecommendedModels } from "./refreshClineRecommendedModels"
|
||||
|
||||
export async function refreshClineRecommendedModelsRpc(
|
||||
controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
): Promise<ClineRecommendedModelsResponse> {
|
||||
const models = await refreshClineRecommendedModels(controller)
|
||||
return ClineRecommendedModelsResponse.create({
|
||||
recommended: models.recommended.map((model) =>
|
||||
ClineRecommendedModel.create({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
tags: model.tags,
|
||||
}),
|
||||
),
|
||||
free: models.free.map((model) =>
|
||||
ClineRecommendedModel.create({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
tags: model.tags,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CHAT_COMPLETIONS_API,
|
||||
DEFAULT_EXTERNAL_OCA_BASE_URL,
|
||||
DEFAULT_INTERNAL_OCA_BASE_URL,
|
||||
MESSAGES_API,
|
||||
RESPONSES_API,
|
||||
} from "@/services/auth/oca/utils/constants"
|
||||
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
|
||||
@@ -63,9 +64,16 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
|
||||
}
|
||||
const modelInfo = model.model_info
|
||||
const supportedApiList = modelInfo.supported_api_list ?? [CHAT_COMPLETIONS_API]
|
||||
const apiFormat: ApiFormat = supportedApiList.includes(RESPONSES_API)
|
||||
? ApiFormat.OPENAI_RESPONSES
|
||||
: ApiFormat.OPENAI_CHAT
|
||||
|
||||
let apiFormat: ApiFormat = ApiFormat.OPENAI_CHAT
|
||||
if (supportsChatCompletions(supportedApiList)) {
|
||||
apiFormat = ApiFormat.OPENAI_CHAT
|
||||
} else if (supportsResponses(supportedApiList)) {
|
||||
apiFormat = ApiFormat.OPENAI_RESPONSES
|
||||
} else if (supportsMessages(supportedApiList)) {
|
||||
apiFormat = ApiFormat.ANTHROPIC_CHAT
|
||||
}
|
||||
|
||||
models[modelId] = OcaModelInfo.create({
|
||||
maxTokens: model.litellm_params?.max_tokens || -1,
|
||||
contextWindow: modelInfo.context_window,
|
||||
@@ -179,3 +187,15 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
|
||||
}
|
||||
return OcaCompatibleModelInfo.create({ models })
|
||||
}
|
||||
|
||||
function supportsChatCompletions(modelSupportedApiList: any): boolean {
|
||||
return modelSupportedApiList.includes(CHAT_COMPLETIONS_API)
|
||||
}
|
||||
|
||||
function supportsResponses(modelSupportedApiList: any): boolean {
|
||||
return modelSupportedApiList.includes(RESPONSES_API)
|
||||
}
|
||||
|
||||
function supportsMessages(modelSupportedApiList: any): boolean {
|
||||
return modelSupportedApiList.includes(MESSAGES_API)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@/shared/api"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -119,7 +119,7 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
const rawModels = response.data.data
|
||||
const parsePrice = (price: any) => {
|
||||
if (price) {
|
||||
return parseFloat(price) * 1_000_000
|
||||
return Number.parseFloat(price) * 1_000_000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ function deriveTemperature(modelId: string): number | undefined {
|
||||
return 0.7
|
||||
}
|
||||
|
||||
// Gemini 3.0 recommends temperature 1.0
|
||||
if (modelId.startsWith("google/gemini-3.0") || modelId === "google/gemini-3.0") {
|
||||
// Gemini 3 models recommend temperature 1.0
|
||||
if (modelId.startsWith("google/gemini-3")) {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ export async function resetState(controller: Controller, request: ResetStateRequ
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Resetting global state...",
|
||||
})
|
||||
await resetGlobalState(controller)
|
||||
await resetGlobalState()
|
||||
} else {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Resetting workspace state...",
|
||||
})
|
||||
await resetWorkspaceState(controller)
|
||||
await resetWorkspaceState()
|
||||
}
|
||||
|
||||
if (controller.task) {
|
||||
|
||||
@@ -301,11 +301,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("backgroundEditEnabled", !!request.backgroundEditEnabled)
|
||||
}
|
||||
|
||||
if (request.autoCondenseThreshold !== undefined) {
|
||||
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
|
||||
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
|
||||
}
|
||||
|
||||
if (request.multiRootEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentConfigLoader } from "@core/task/tools/subagent/AgentConfigLoader"
|
||||
import { CLINE_MCP_TOOL_IDENTIFIER, McpServer } from "@/shared/mcp"
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
@@ -104,6 +105,46 @@ export class ClineToolSet {
|
||||
return enabledTools
|
||||
}
|
||||
|
||||
private static getDynamicSubagentToolSpecs(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
|
||||
if (context.subagentsEnabled !== true || context.isSubagentRun) {
|
||||
return []
|
||||
}
|
||||
|
||||
const requestedIds = variant.tools ? [...variant.tools] : []
|
||||
const shouldIncludeSubagentTools = requestedIds.length === 0 || requestedIds.includes(ClineDefaultTool.USE_SUBAGENTS)
|
||||
if (!shouldIncludeSubagentTools) {
|
||||
return []
|
||||
}
|
||||
|
||||
const agentConfigs = AgentConfigLoader.getInstance().getAllCachedConfigsWithToolNames()
|
||||
return agentConfigs.map(({ toolName, config }) => ({
|
||||
variant: variant.family,
|
||||
id: ClineDefaultTool.USE_SUBAGENTS,
|
||||
name: toolName,
|
||||
description: `Use the "${config.name}" subagent: ${config.description}`,
|
||||
contextRequirements: (ctx) => ctx.subagentsEnabled === true && !ctx.isSubagentRun,
|
||||
parameters: [
|
||||
{
|
||||
name: "prompt",
|
||||
required: true,
|
||||
instruction: "Helpful instruction for the task that the subagent will perform.",
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
public static getEnabledToolSpecs(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
|
||||
const registeredTools = ClineToolSet.getEnabledTools(variant, context).map((tool) => tool.config)
|
||||
const dynamicSubagentTools = ClineToolSet.getDynamicSubagentToolSpecs(variant, context)
|
||||
|
||||
const includesDynamicSubagents = dynamicSubagentTools.length > 0
|
||||
const filteredRegistered = includesDynamicSubagents
|
||||
? registeredTools.filter((tool) => tool.id !== ClineDefaultTool.USE_SUBAGENTS)
|
||||
: registeredTools
|
||||
|
||||
return [...filteredRegistered, ...dynamicSubagentTools]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate native tool converter for the given provider
|
||||
*/
|
||||
@@ -136,8 +177,7 @@ export class ClineToolSet {
|
||||
}
|
||||
|
||||
// Base set
|
||||
const toolsets = ClineToolSet.getEnabledTools(variant, context)
|
||||
const toolConfigs = toolsets.map((tool) => tool.config)
|
||||
const toolConfigs = ClineToolSet.getEnabledToolSpecs(variant, context)
|
||||
|
||||
// MCP tools
|
||||
const mcpServers = context.mcpHub?.getServers()?.filter((s) => s.disabled !== true) || []
|
||||
|
||||
@@ -132,38 +132,15 @@ export class PromptBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private static getEnabledTools(variant: PromptVariant, context: SystemPromptContext) {
|
||||
let resolvedTools: ReturnType<typeof ClineToolSet.getTools> = []
|
||||
|
||||
// If the variant explicitly lists tools, resolve each by id with fallback to GENERIC
|
||||
if (variant?.tools?.length) {
|
||||
const requestedIds = [...variant.tools]
|
||||
resolvedTools = ClineToolSet.getToolsForVariantWithFallback(variant.family, requestedIds)
|
||||
|
||||
// Preserve requested order
|
||||
resolvedTools = requestedIds
|
||||
.map((id) => resolvedTools.find((t) => t.config.id === id))
|
||||
.filter((t): t is NonNullable<typeof t> => Boolean(t))
|
||||
} else {
|
||||
// Otherwise, use all tools registered for the variant, or generic if none
|
||||
resolvedTools = ClineToolSet.getTools(variant.family)
|
||||
// Sort by id for stable ordering
|
||||
resolvedTools = resolvedTools.sort((a, b) => a.config.id.localeCompare(b.config.id))
|
||||
}
|
||||
|
||||
// Filter by context requirements
|
||||
const enabledTools = resolvedTools.filter(
|
||||
(tool) => !tool.config.contextRequirements || tool.config.contextRequirements(context),
|
||||
)
|
||||
|
||||
return enabledTools
|
||||
private static getEnabledTools(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
|
||||
return ClineToolSet.getEnabledToolSpecs(variant, context)
|
||||
}
|
||||
|
||||
public static async getToolsPrompts(variant: PromptVariant, context: SystemPromptContext) {
|
||||
const enabledTools = PromptBuilder.getEnabledTools(variant, context)
|
||||
|
||||
const ids = enabledTools.map((tool) => tool.config.id)
|
||||
return Promise.all(enabledTools.map((tool) => PromptBuilder.tool(tool.config, ids, context)))
|
||||
const ids = enabledTools.map((tool) => tool.id)
|
||||
return Promise.all(enabledTools.map((tool) => PromptBuilder.tool(tool, ids, context)))
|
||||
}
|
||||
|
||||
public static tool(config: ClineToolSpec, registry: ClineDefaultTool[], context: SystemPromptContext): string {
|
||||
@@ -171,7 +148,8 @@ export class PromptBuilder {
|
||||
if (!config.parameters?.length && !config.description?.length) {
|
||||
return ""
|
||||
}
|
||||
const title = `## ${config.id}`
|
||||
const displayName = config.name || config.id
|
||||
const title = `## ${displayName}`
|
||||
const description = [`Description: ${config.description}`]
|
||||
|
||||
if (!config.parameters?.length) {
|
||||
@@ -209,7 +187,7 @@ export class PromptBuilder {
|
||||
title,
|
||||
description.join("\n"),
|
||||
PromptBuilder.buildParametersSection(filteredParams, context),
|
||||
PromptBuilder.buildUsageSection(config.id, filteredParams),
|
||||
PromptBuilder.buildUsageSection(displayName, filteredParams),
|
||||
]
|
||||
|
||||
return sections.filter(Boolean).join("\n")
|
||||
|
||||
@@ -13,11 +13,11 @@ export class PromptRegistry {
|
||||
private static instance: PromptRegistry
|
||||
private variants: Map<string, PromptVariant> = new Map()
|
||||
private components: ComponentRegistry = {}
|
||||
private loaded: boolean = false
|
||||
public nativeTools: ClineTool[] | undefined = undefined
|
||||
|
||||
private constructor() {
|
||||
registerClineToolSets()
|
||||
this.load()
|
||||
}
|
||||
|
||||
static getInstance(): PromptRegistry {
|
||||
@@ -30,42 +30,9 @@ export class PromptRegistry {
|
||||
/**
|
||||
* Load all prompts and components on initialization
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all([this.loadVariants(), this.loadComponents()])
|
||||
|
||||
// Perform health check to ensure critical variants are available
|
||||
this.performHealthCheck()
|
||||
|
||||
this.loaded = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform health check to ensure registry is in a valid state
|
||||
*/
|
||||
private performHealthCheck(): void {
|
||||
const criticalVariants = [ModelFamily.GENERIC]
|
||||
const missingVariants = criticalVariants.filter((variant) => !this.variants.has(variant))
|
||||
|
||||
if (missingVariants.length > 0) {
|
||||
Logger.error(`Registry health check failed: Missing critical variants: ${missingVariants.join(", ")}`)
|
||||
Logger.error(`Available variants: ${Array.from(this.variants.keys()).join(", ")}`)
|
||||
}
|
||||
|
||||
if (this.variants.size === 0) {
|
||||
Logger.error("Registry health check failed: No variants loaded at all")
|
||||
}
|
||||
|
||||
if (Object.keys(this.components).length === 0) {
|
||||
Logger.warn("Registry health check warning: No components loaded")
|
||||
}
|
||||
|
||||
Logger.log(
|
||||
`Registry health check: ${this.variants.size} variants, ${Object.keys(this.components).length} components loaded`,
|
||||
)
|
||||
load(): void {
|
||||
this.loadVariants()
|
||||
this.loadComponents()
|
||||
}
|
||||
|
||||
getModelFamily(context: SystemPromptContext) {
|
||||
@@ -89,19 +56,10 @@ export class PromptRegistry {
|
||||
Logger.log(`[Prompt variant] No matching variant found for model: ${modelId}, falling back to generic`)
|
||||
return ModelFamily.GENERIC
|
||||
}
|
||||
/**
|
||||
* Get prompt by matching against all registered variants
|
||||
*/
|
||||
async get(context: SystemPromptContext): Promise<string> {
|
||||
await this.load()
|
||||
|
||||
// Loop through all registered variants to find the first one that matches
|
||||
getVariant(context: SystemPromptContext): PromptVariant {
|
||||
const family = this.getModelFamily(context)
|
||||
|
||||
// Fallback to generic variant if no match found
|
||||
|
||||
const variant = this.variants.get(family)
|
||||
|
||||
const variant = this.variants.get(family) || this.variants.get(ModelFamily.GENERIC)
|
||||
if (!variant) {
|
||||
// Enhanced error with debugging information
|
||||
const availableVariants = Array.from(this.variants.keys())
|
||||
@@ -110,7 +68,6 @@ export class PromptRegistry {
|
||||
availableVariants,
|
||||
variantsCount: this.variants.size,
|
||||
componentsCount: Object.keys(this.components).length,
|
||||
isLoaded: this.loaded,
|
||||
}
|
||||
|
||||
Logger.error("Prompt variant lookup failed:", errorDetails)
|
||||
@@ -118,9 +75,16 @@ export class PromptRegistry {
|
||||
throw new Error(
|
||||
`No prompt variant found for model '${context.providerInfo.model.id}' and no generic fallback available. ` +
|
||||
`Available variants: [${availableVariants.join(", ")}]. ` +
|
||||
`Registry state: loaded=${this.loaded}, variants=${this.variants.size}, components=${Object.keys(this.components).length}`,
|
||||
`Registry state: variants=${this.variants.size}, components=${Object.keys(this.components).length}`,
|
||||
)
|
||||
}
|
||||
return variant
|
||||
}
|
||||
/**
|
||||
* Get prompt by matching against all registered variants
|
||||
*/
|
||||
async get(context: SystemPromptContext): Promise<string> {
|
||||
const variant = this.getVariant(context)
|
||||
|
||||
// Hacky way to get native tools for the current variant - it's bad and ugly
|
||||
this.nativeTools = ClineToolSet.getNativeTools(variant, context)
|
||||
@@ -138,8 +102,6 @@ export class PromptRegistry {
|
||||
context: SystemPromptContext,
|
||||
isNextGenModelFamily?: boolean,
|
||||
): Promise<string> {
|
||||
await this.load()
|
||||
|
||||
// If isNextGenModelFamily is true, prioritize next-gen variant with the specified version
|
||||
if (isNextGenModelFamily) {
|
||||
const nextGenVariant = this.variants.get(ModelFamily.NEXT_GEN)
|
||||
@@ -181,8 +143,6 @@ export class PromptRegistry {
|
||||
context?: SystemPromptContext,
|
||||
isNextGenModelFamily?: boolean,
|
||||
): Promise<string> {
|
||||
await this.load()
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context is required for prompt building")
|
||||
}
|
||||
@@ -316,7 +276,7 @@ export class PromptRegistry {
|
||||
/**
|
||||
* Load all components from the components directory
|
||||
*/
|
||||
private async loadComponents(): Promise<void> {
|
||||
private loadComponents(): void {
|
||||
try {
|
||||
// Register each component function
|
||||
const componentMappings = getSystemPromptComponents()
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import type { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import {
|
||||
ApiHandlerSettingsKeys,
|
||||
GlobalState,
|
||||
GlobalStateAndSettings,
|
||||
GlobalStateAndSettingsKey,
|
||||
type GlobalState,
|
||||
type GlobalStateAndSettings,
|
||||
type GlobalStateAndSettingsKey,
|
||||
isSecretKey,
|
||||
isSettingsKey,
|
||||
LocalState,
|
||||
LocalStateKey,
|
||||
RemoteConfigFields,
|
||||
SecretKey,
|
||||
type LocalState,
|
||||
type LocalStateKey,
|
||||
type RemoteConfigFields,
|
||||
type SecretKey,
|
||||
SecretKeys,
|
||||
Secrets,
|
||||
Settings,
|
||||
SettingsKey,
|
||||
type Secrets,
|
||||
type Settings,
|
||||
type SettingsKey,
|
||||
} from "@shared/storage/state-keys"
|
||||
import type { StorageContext } from "@shared/storage/storage-context"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import { AgentConfigLoader } from "../task/tools/subagent/AgentConfigLoader"
|
||||
import {
|
||||
getTaskHistoryStateFilePath,
|
||||
readTaskHistoryFromState,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "./disk"
|
||||
import { STATE_MANAGER_NOT_INITIALIZED } from "./error-messages"
|
||||
import { filterAllowedRemoteConfigFields } from "./remote-config/utils"
|
||||
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
|
||||
import { readGlobalStateFromStorage, readSecretsFromStorage, readWorkspaceStateFromStorage } from "./utils/state-helpers"
|
||||
export interface PersistenceErrorEvent {
|
||||
error: Error
|
||||
}
|
||||
@@ -38,6 +38,9 @@ export interface PersistenceErrorEvent {
|
||||
* In-memory state manager for fast state access.
|
||||
* Provides immediate reads/writes with async disk persistence.
|
||||
*
|
||||
* All persistent storage is backed by file-based stores via StorageContext.
|
||||
* This is shared across all platforms (VSCode, CLI, JetBrains).
|
||||
*
|
||||
* MULTI-INSTANCE BEHAVIOR:
|
||||
* StateManager reads from disk ONLY during initialize(). After that, all reads come from
|
||||
* the in-memory cache. Writes update both the cache and disk, but other running instances
|
||||
@@ -57,10 +60,16 @@ export class StateManager {
|
||||
|
||||
private globalStateCache: GlobalStateAndSettings = {} as GlobalStateAndSettings
|
||||
private taskStateCache: Partial<Settings> = {}
|
||||
private sessionOverrideCache: Partial<Settings> = {}
|
||||
private remoteConfigCache: Partial<RemoteConfigFields> = {} as RemoteConfigFields
|
||||
private secretsCache: Secrets = {} as Secrets
|
||||
private workspaceStateCache: LocalState = {} as LocalState
|
||||
private context: ExtensionContext
|
||||
|
||||
/**
|
||||
* File-backed storage context. All reads/writes to persistent state go through here.
|
||||
* Do NOT access VSCode's ExtensionContext for storage — use this instead.
|
||||
*/
|
||||
private storage: StorageContext
|
||||
private isInitialized = false
|
||||
|
||||
// Cache TTL: 1 hour - long enough to prevent duplicate fetches, short enough to see new models
|
||||
@@ -107,17 +116,16 @@ export class StateManager {
|
||||
// Callback to sync external state changes with the UI client
|
||||
onSyncExternalChange?: () => void | Promise<void>
|
||||
|
||||
private constructor(context: ExtensionContext) {
|
||||
this.context = context
|
||||
secretStorage.init(context.secrets)
|
||||
private constructor(storage: StorageContext) {
|
||||
this.storage = storage
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cache by loading data from disk
|
||||
* Initialize the cache by loading data from the file-backed StorageContext.
|
||||
*/
|
||||
public static async initialize(context: ExtensionContext): Promise<StateManager> {
|
||||
public static async initialize(storage: StorageContext): Promise<StateManager> {
|
||||
if (!StateManager.instance) {
|
||||
StateManager.instance = new StateManager(context)
|
||||
StateManager.instance = new StateManager(storage)
|
||||
}
|
||||
|
||||
if (StateManager.instance.isInitialized) {
|
||||
@@ -125,11 +133,12 @@ export class StateManager {
|
||||
}
|
||||
|
||||
try {
|
||||
await initializeDistinctId(context)
|
||||
// Load all extension state from disk
|
||||
const globalState = await readGlobalStateFromDisk(context)
|
||||
const secrets = await readSecretsFromDisk()
|
||||
const workspaceState = await readWorkspaceStateFromDisk(context)
|
||||
await initializeDistinctId(storage)
|
||||
|
||||
// Load all extension state from file-backed stores
|
||||
const globalState = await readGlobalStateFromStorage(storage.globalState)
|
||||
const secrets = readSecretsFromStorage(storage.secrets)
|
||||
const workspaceState = readWorkspaceStateFromStorage(storage.workspaceState)
|
||||
|
||||
// Populate the cache with all extension state and secrets fields
|
||||
// Use populate method to avoid triggering persistence during initialization
|
||||
@@ -139,6 +148,8 @@ export class StateManager {
|
||||
await StateManager.instance.setupTaskHistoryWatcher()
|
||||
|
||||
StateManager.instance.isInitialized = true
|
||||
|
||||
await AgentConfigLoader.getInstance().ready()
|
||||
} catch (error) {
|
||||
Logger.error("[StateManager] Failed to initialize:", error)
|
||||
throw error
|
||||
@@ -291,8 +302,6 @@ export class StateManager {
|
||||
this.pendingTaskState.clear()
|
||||
} catch (error) {
|
||||
Logger.error("[StateManager] Failed to persist task settings before clearing:", error)
|
||||
// If persistence fails, we just move on with clearing the in-memory state.
|
||||
// clearTaskSettings realistically probably won't be called in the small window of time between task settings being set and their persistence anyways
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +383,21 @@ export class StateManager {
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a session-scoped override for a settings key.
|
||||
* Session overrides are in-memory only and are NEVER persisted to disk.
|
||||
* They take precedence after remote config but before task-specific and global settings.
|
||||
*
|
||||
* Use this for CLI flags like --yolo that should apply for the current
|
||||
* process lifetime only, without modifying the user's saved settings.
|
||||
*/
|
||||
setSessionOverride<K extends keyof Settings>(key: K, value: Settings[K]): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
this.sessionOverrideCache[key] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Set method for remote config field - updates cache immediately (no persistence)
|
||||
* Remote config is read-only from the extension's perspective and only stored in memory
|
||||
@@ -599,17 +623,18 @@ export class StateManager {
|
||||
|
||||
/**
|
||||
* Get method for global settings keys - reads from in-memory cache
|
||||
* Precedence: remote config > task settings > global settings
|
||||
* Precedence: remote config > session override > task settings > global settings
|
||||
*/
|
||||
getGlobalSettingsKey<K extends keyof Settings>(key: K): Settings[K] {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
if (this.remoteConfigCache[key] !== undefined) {
|
||||
// type casting here, TS cannot infer that the key will ONLY be one of Settings
|
||||
|
||||
return this.remoteConfigCache[key] as Settings[K]
|
||||
}
|
||||
if (this.sessionOverrideCache[key] !== undefined) {
|
||||
return this.sessionOverrideCache[key] as Settings[K]
|
||||
}
|
||||
if (this.taskStateCache[key] !== undefined) {
|
||||
return this.taskStateCache[key]
|
||||
}
|
||||
@@ -624,7 +649,6 @@ export class StateManager {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
if (this.remoteConfigCache[key] !== undefined) {
|
||||
// type casting here, TS cannot infer that the key will ONLY be one of GlobalState
|
||||
return this.remoteConfigCache[key] as GlobalState[K]
|
||||
}
|
||||
return this.globalStateCache[key]
|
||||
@@ -655,11 +679,14 @@ export class StateManager {
|
||||
* Used for error recovery when write operations fail
|
||||
*/
|
||||
async reInitialize(currentTaskId?: string): Promise<void> {
|
||||
if (this.persistenceTimeout) {
|
||||
await this.persistPendingState()
|
||||
}
|
||||
// Clear all cached data and pending state
|
||||
this.dispose()
|
||||
|
||||
// Reinitialize from disk
|
||||
await StateManager.initialize(this.context)
|
||||
// Reinitialize from the same storage context
|
||||
await StateManager.initialize(this.storage)
|
||||
|
||||
// If there's an active task, reload its settings
|
||||
if (currentTaskId) {
|
||||
@@ -691,6 +718,7 @@ export class StateManager {
|
||||
this.workspaceStateCache = {} as LocalState
|
||||
this.taskStateCache = {}
|
||||
this.remoteConfigCache = {} as GlobalStateAndSettings
|
||||
this.sessionOverrideCache = {}
|
||||
|
||||
this.isInitialized = false
|
||||
}
|
||||
@@ -765,21 +793,25 @@ export class StateManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist global state keys with Promise.all
|
||||
* Persist global state keys to the file-backed store.
|
||||
* Uses setBatch for efficiency (single disk write).
|
||||
*/
|
||||
private async persistGlobalStateBatch(keys: Set<GlobalStateAndSettingsKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
if (key === "taskHistory") {
|
||||
// Route task history persistence to file, not VS Code globalState
|
||||
return writeTaskHistoryToState(this.globalStateCache[key])
|
||||
}
|
||||
return this.context.globalState.update(key, this.globalStateCache[key])
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
// Separate taskHistory (goes to its own file) from regular global state
|
||||
const regularEntries: Record<string, any> = {}
|
||||
|
||||
for (const key of keys) {
|
||||
if (key === "taskHistory") {
|
||||
// Route task history persistence to its own file
|
||||
await writeTaskHistoryToState(this.globalStateCache[key])
|
||||
} else {
|
||||
regularEntries[key] = this.globalStateCache[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Batch write all regular keys in a single disk operation
|
||||
if (Object.keys(regularEntries).length > 0) {
|
||||
this.storage.globalStateBackingStore.setBatch(regularEntries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,61 +822,47 @@ export class StateManager {
|
||||
if (pendingTaskStates.size === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Persist each task's settings
|
||||
await Promise.all(
|
||||
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
|
||||
if (keys.size === 0) {
|
||||
return Promise.resolve()
|
||||
// Persist each task's settings
|
||||
await Promise.all(
|
||||
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
|
||||
if (keys.size === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const settingsToWrite: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
const value = this.taskStateCache[key]
|
||||
if (value !== undefined) {
|
||||
settingsToWrite[key] = value
|
||||
}
|
||||
const settingsToWrite: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
const value = this.taskStateCache[key]
|
||||
if (value !== undefined) {
|
||||
settingsToWrite[key] = value
|
||||
}
|
||||
}
|
||||
return writeTaskSettingsToStorage(taskId, settingsToWrite)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return writeTaskSettingsToStorage(taskId, settingsToWrite)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist secrets with Promise.all
|
||||
* Persist secrets to the file-backed store.
|
||||
* Uses setBatch for efficiency (single disk write).
|
||||
*/
|
||||
private async persistSecretsBatch(keys: Set<SecretKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.secretsCache[key]
|
||||
if (value) {
|
||||
return this.context.secrets.store(key, value)
|
||||
}
|
||||
return this.context.secrets.delete(key)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
const entries: Record<string, string | undefined> = {}
|
||||
for (const key of keys) {
|
||||
const value = this.secretsCache[key]
|
||||
entries[key] = value || undefined // Convert empty strings to undefined (delete)
|
||||
}
|
||||
this.storage.secrets.setBatch(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist workspace state keys with Promise.all
|
||||
* Persist workspace state to the file-backed store.
|
||||
* Uses setBatch for efficiency (single disk write).
|
||||
*/
|
||||
private async persistWorkspaceStateBatch(keys: Set<LocalStateKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.workspaceStateCache[key]
|
||||
return this.context.workspaceState.update(key, value)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
const entries: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
entries[key] = this.workspaceStateCache[key]
|
||||
}
|
||||
this.storage.workspaceState.setBatch(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,7 @@ export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
contextHistory: "context_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
clineRecommendedModels: "cline_recommended_models.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
vercelAiGatewayModels: "vercel_ai_gateway_models.json",
|
||||
groqModels: "groq_models.json",
|
||||
@@ -261,14 +262,13 @@ export async function getSavedClineMessages(taskId: string): Promise<ClineMessag
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
} else {
|
||||
// check old location
|
||||
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
|
||||
await fs.unlink(oldPath) // remove old file
|
||||
return data
|
||||
}
|
||||
}
|
||||
// check old location
|
||||
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
|
||||
await fs.unlink(oldPath) // remove old file
|
||||
return data
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as vscode from "vscode"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ensureRulesDirectoryExists, readTaskHistoryFromState, writeTaskHistoryToState } from "./disk"
|
||||
import { StateManager } from "./StateManager"
|
||||
|
||||
export async function migrateWorkspaceToGlobalStorage(context: vscode.ExtensionContext) {
|
||||
// Keys to migrate from workspace storage back to global storage
|
||||
@@ -665,12 +664,12 @@ export async function cleanupMcpMarketplaceCatalogFromGlobalState(context: vscod
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupOldApiKey() {
|
||||
export async function cleanupOldApiKey(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
// Old API Keys were introduced in March 2025 and later replaced with tokens
|
||||
// Now that we have new API keys that are prefixed with `sk_`,
|
||||
// we need to clean up the old ones to free the secret storage
|
||||
StateManager.get().setSecret("clineApiKey", undefined)
|
||||
await context.secrets.delete("clineApiKey")
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cleanup old clineApiKey", error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProvider } from "@shared/api"
|
||||
import type { ClineFileStorage } from "@shared/storage/ClineFileStorage"
|
||||
import {
|
||||
applyTransform,
|
||||
GlobalStateAndSettingKeys,
|
||||
@@ -11,88 +12,81 @@ import {
|
||||
SecretKeys,
|
||||
Secrets,
|
||||
} from "@shared/storage/state-keys"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { ClineRulesToggles } from "@/shared/cline-rules"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { secretStorage } from "@/shared/storage"
|
||||
import { ClineMemento } from "@/shared/storage"
|
||||
import { readTaskHistoryFromState } from "../disk"
|
||||
import { StateManager } from "../StateManager"
|
||||
|
||||
export async function readSecretsFromDisk(): Promise<Secrets> {
|
||||
const secrets = await Promise.all(SecretKeys.map((key) => secretStorage.get(key)))
|
||||
// ─── File-backed storage readers (used by StateManager) ────────────────────
|
||||
|
||||
return SecretKeys.reduce((acc, key, index) => {
|
||||
acc[key] = secrets[index]
|
||||
/**
|
||||
* Read secrets from a ClineFileStorage instance.
|
||||
*/
|
||||
export function readSecretsFromStorage(store: ClineFileStorage<string>): Secrets {
|
||||
return SecretKeys.reduce((acc, key) => {
|
||||
acc[key] = store.get(key)
|
||||
return acc
|
||||
}, {} as Secrets)
|
||||
}
|
||||
|
||||
export async function readWorkspaceStateFromDisk(context: ExtensionContext): Promise<LocalState> {
|
||||
const states = LocalStateKeys.map((key) => context.workspaceState.get<ClineRulesToggles | undefined>(key))
|
||||
|
||||
return LocalStateKeys.reduce((acc, key, index) => {
|
||||
acc[key] = states[index] || {}
|
||||
/**
|
||||
* Read workspace state from a ClineFileStorage instance.
|
||||
*/
|
||||
export function readWorkspaceStateFromStorage(store: ClineFileStorage): LocalState {
|
||||
return LocalStateKeys.reduce((acc, key) => {
|
||||
acc[key] = store.get(key) || {}
|
||||
return acc
|
||||
}, {} as LocalState)
|
||||
}
|
||||
|
||||
export async function readGlobalStateFromDisk(context: ExtensionContext): Promise<GlobalStateAndSettings> {
|
||||
/**
|
||||
* Read global state from a ClineFileStorage instance.
|
||||
*/
|
||||
export async function readGlobalStateFromStorage(store: ClineMemento): Promise<GlobalStateAndSettings> {
|
||||
try {
|
||||
// Batch read all state values in a single optimized pass
|
||||
const stateValues = new Map<string, any>()
|
||||
// Read all values at once for better performance
|
||||
for (const key of GlobalStateAndSettingKeys) {
|
||||
const value = context.globalState.get(key as string)
|
||||
const value = store.get(key as string)
|
||||
stateValues.set(key, value)
|
||||
}
|
||||
|
||||
// Build result object with proper typing
|
||||
const result = {} as any // Use any for assignment, but return proper type
|
||||
const result = {} as any
|
||||
|
||||
// Process each state property using optimized approach
|
||||
for (const key of GlobalStateAndSettingKeys) {
|
||||
const stateKey = key as keyof GlobalStateAndSettings
|
||||
let value = stateValues.get(stateKey)
|
||||
|
||||
// Skip async properties - they need special handling
|
||||
if (isAsyncProperty(stateKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip computed properties - they need special handling
|
||||
if (isComputedProperty(stateKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply default value if needed
|
||||
if (value === undefined) {
|
||||
const defaultValue = getDefaultValue(stateKey)
|
||||
if (defaultValue !== undefined) {
|
||||
value = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// Apply transformation if provided
|
||||
if (value !== undefined) {
|
||||
value = applyTransform(stateKey, value)
|
||||
}
|
||||
// Set the processed value
|
||||
result[stateKey] = value
|
||||
}
|
||||
|
||||
// Handle computed properties with special logic
|
||||
await handleComputedProperties(result, stateValues)
|
||||
|
||||
// Handle async properties
|
||||
await handleAsyncProperties(result)
|
||||
|
||||
return result as GlobalStateAndSettings
|
||||
} catch (error) {
|
||||
Logger.error("[StateHelpers] Failed to read global state:", error)
|
||||
Logger.error("[StateHelpers] Failed to read global state from storage:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legacy readers (for VSCode migration — reads from ExtensionContext) ────
|
||||
|
||||
/**
|
||||
* Handle properties that require computed logic
|
||||
*/
|
||||
@@ -120,19 +114,16 @@ async function handleAsyncProperties(result: any): Promise<void> {
|
||||
result.taskHistory = await readTaskHistoryFromState()
|
||||
}
|
||||
|
||||
export async function resetWorkspaceState(controller: Controller) {
|
||||
await Promise.all(LocalStateKeys.map((key) => controller.context.workspaceState.update(key, undefined)))
|
||||
|
||||
await controller.stateManager.reInitialize()
|
||||
export async function resetWorkspaceState() {
|
||||
const stateManager = StateManager.get()
|
||||
LocalStateKeys.map((key) => stateManager.setWorkspaceState(key, {}))
|
||||
await stateManager.reInitialize()
|
||||
}
|
||||
|
||||
export async function resetGlobalState(controller: Controller) {
|
||||
export async function resetGlobalState() {
|
||||
// TODO: Reset all workspace states?
|
||||
const context = controller.context
|
||||
|
||||
await Promise.all(GlobalStateAndSettingKeys.map((key) => context.globalState.update(key, undefined)))
|
||||
|
||||
await Promise.all(SecretKeys.map((key) => secretStorage.delete(key)))
|
||||
|
||||
await controller.stateManager.reInitialize()
|
||||
const stateManager = StateManager.get()
|
||||
GlobalStateAndSettingKeys.map((key) => stateManager.setGlobalState(key, undefined))
|
||||
SecretKeys.map((key) => stateManager.setSecret(key, undefined))
|
||||
await stateManager.reInitialize()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import type { HookExecution } from "./types/HookExecution"
|
||||
|
||||
export class TaskState {
|
||||
// Task-level timing
|
||||
taskStartTimeMs = Date.now()
|
||||
taskFirstTokenTimeMs?: number
|
||||
|
||||
// Streaming flags
|
||||
isStreaming = false
|
||||
isWaitingForFirstChunk = false
|
||||
|
||||
@@ -10,9 +10,8 @@ import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { ClineContent } from "@shared/messages/content"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import * as vscode from "vscode"
|
||||
import { isParallelToolCallingEnabled, modelDoesntSupportWebp } from "@/utils/model-utils"
|
||||
import { ToolUse } from "../assistant-message"
|
||||
import { ContextManager } from "../context/context-management/ContextManager"
|
||||
@@ -23,31 +22,7 @@ import { ToolResponse } from "."
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { AutoApprove } from "./tools/autoApprove"
|
||||
import { AccessMcpResourceHandler } from "./tools/handlers/AccessMcpResourceHandler"
|
||||
import { ActModeRespondHandler } from "./tools/handlers/ActModeRespondHandler"
|
||||
import { ApplyPatchHandler } from "./tools/handlers/ApplyPatchHandler"
|
||||
import { AskFollowupQuestionToolHandler } from "./tools/handlers/AskFollowupQuestionToolHandler"
|
||||
import { AttemptCompletionHandler } from "./tools/handlers/AttemptCompletionHandler"
|
||||
import { BrowserToolHandler } from "./tools/handlers/BrowserToolHandler"
|
||||
import { CondenseHandler } from "./tools/handlers/CondenseHandler"
|
||||
import { ExecuteCommandToolHandler } from "./tools/handlers/ExecuteCommandToolHandler"
|
||||
import { GenerateExplanationToolHandler } from "./tools/handlers/GenerateExplanationToolHandler"
|
||||
import { ListCodeDefinitionNamesToolHandler } from "./tools/handlers/ListCodeDefinitionNamesToolHandler"
|
||||
import { ListFilesToolHandler } from "./tools/handlers/ListFilesToolHandler"
|
||||
import { LoadMcpDocumentationHandler } from "./tools/handlers/LoadMcpDocumentationHandler"
|
||||
import { NewTaskHandler } from "./tools/handlers/NewTaskHandler"
|
||||
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 { UseSubagentsToolHandler } from "./tools/handlers/SubagentToolHandler"
|
||||
import { SummarizeTaskHandler } from "./tools/handlers/SummarizeTaskHandler"
|
||||
import { UseMcpToolHandler } from "./tools/handlers/UseMcpToolHandler"
|
||||
import { UseSkillToolHandler } from "./tools/handlers/UseSkillToolHandler"
|
||||
import { WebFetchToolHandler } from "./tools/handlers/WebFetchToolHandler"
|
||||
import { WebSearchToolHandler } from "./tools/handlers/WebSearchToolHandler"
|
||||
import { WriteToFileToolHandler } from "./tools/handlers/WriteToFileToolHandler"
|
||||
import { IPartialBlockHandler, SharedToolHandler, ToolExecutorCoordinator } from "./tools/ToolExecutorCoordinator"
|
||||
import { IPartialBlockHandler, ToolExecutorCoordinator } from "./tools/ToolExecutorCoordinator"
|
||||
import { ToolValidator } from "./tools/ToolValidator"
|
||||
import { TaskConfig, validateTaskConfig } from "./tools/types/TaskConfig"
|
||||
import { createUIHelpers } from "./tools/types/UIHelpers"
|
||||
@@ -81,7 +56,6 @@ export class ToolExecutor {
|
||||
|
||||
constructor(
|
||||
// Core Services & Managers
|
||||
private context: vscode.ExtensionContext,
|
||||
private taskState: TaskState,
|
||||
private messageStateHandler: MessageStateHandler,
|
||||
private api: ApiHandler,
|
||||
@@ -160,7 +134,6 @@ export class ToolExecutor {
|
||||
const config: TaskConfig = {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
context: this.context,
|
||||
mode: this.stateManager.getGlobalSettingsKey("mode"),
|
||||
strictPlanModeEnabled: this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled"),
|
||||
yoloModeToggled: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
|
||||
@@ -196,7 +169,7 @@ export class ToolExecutor {
|
||||
postStateToWebview: async () => {},
|
||||
reinitExistingTaskFromId: async () => {},
|
||||
cancelTask: this.cancelTask,
|
||||
updateTaskHistory: async (_: any) => [],
|
||||
updateTaskHistory: async () => [],
|
||||
executeCommandTool: this.executeCommandTool,
|
||||
cancelRunningCommandTool: this.cancelRunningCommandTool,
|
||||
doesLatestTaskCompletionHaveNewChanges: this.doesLatestTaskCompletionHaveNewChanges,
|
||||
@@ -225,38 +198,10 @@ export class ToolExecutor {
|
||||
*/
|
||||
private registerToolHandlers(): void {
|
||||
const validator = new ToolValidator(this.clineIgnoreController)
|
||||
|
||||
// Register all tool handlers
|
||||
this.coordinator.register(new ListFilesToolHandler(validator))
|
||||
this.coordinator.register(new ReadFileToolHandler(validator))
|
||||
this.coordinator.register(new BrowserToolHandler())
|
||||
this.coordinator.register(new AskFollowupQuestionToolHandler())
|
||||
this.coordinator.register(new WebFetchToolHandler())
|
||||
this.coordinator.register(new WebSearchToolHandler())
|
||||
|
||||
// Register WriteToFileToolHandler for all three file tools with proper typing
|
||||
const writeHandler = new WriteToFileToolHandler(validator)
|
||||
this.coordinator.register(writeHandler) // registers as "write_to_file" (ClineDefaultTool.FILE_NEW)
|
||||
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 SearchFilesToolHandler(validator))
|
||||
this.coordinator.register(new ExecuteCommandToolHandler(validator))
|
||||
this.coordinator.register(new UseMcpToolHandler())
|
||||
this.coordinator.register(new AccessMcpResourceHandler())
|
||||
this.coordinator.register(new LoadMcpDocumentationHandler())
|
||||
this.coordinator.register(new UseSkillToolHandler())
|
||||
this.coordinator.register(new PlanModeRespondHandler())
|
||||
this.coordinator.register(new ActModeRespondHandler())
|
||||
this.coordinator.register(new NewTaskHandler())
|
||||
this.coordinator.register(new AttemptCompletionHandler())
|
||||
this.coordinator.register(new CondenseHandler())
|
||||
this.coordinator.register(new SummarizeTaskHandler(validator))
|
||||
this.coordinator.register(new ReportBugHandler())
|
||||
this.coordinator.register(new ApplyPatchHandler(validator))
|
||||
this.coordinator.register(new GenerateExplanationToolHandler())
|
||||
this.coordinator.register(new UseSubagentsToolHandler())
|
||||
// Register all tools via toolUseNames
|
||||
for (const tool of toolUseNames) {
|
||||
this.coordinator.registerByName(tool, validator)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-12
@@ -60,7 +60,6 @@ import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "@shared/Languages"
|
||||
import { USER_CONTENT_TAGS } from "@shared/messages/constants"
|
||||
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
|
||||
import { SETTINGS_DEFAULTS } from "@shared/storage/state-keys"
|
||||
import { ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import {
|
||||
@@ -310,7 +309,7 @@ export class Task {
|
||||
this.terminalManager.setTerminalOutputLineLimit(terminalOutputLineLimit)
|
||||
this.terminalManager.setDefaultTerminalProfile(defaultTerminalProfile)
|
||||
|
||||
this.urlContentFetcher = new UrlContentFetcher(controller.context)
|
||||
this.urlContentFetcher = new UrlContentFetcher()
|
||||
this.browserSession = new BrowserSession(stateManager)
|
||||
this.contextManager = new ContextManager()
|
||||
this.streamHandler = new StreamResponseHandler()
|
||||
@@ -531,7 +530,6 @@ export class Task {
|
||||
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
|
||||
|
||||
this.toolExecutor = new ToolExecutor(
|
||||
this.controller.context,
|
||||
this.taskState,
|
||||
this.messageStateHandler,
|
||||
this.api,
|
||||
@@ -2390,14 +2388,10 @@ export class Task {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
|
||||
// See: https://github.com/cline/cline/pull/9348
|
||||
const autoCondenseThreshold = SETTINGS_DEFAULTS.autoCondenseThreshold
|
||||
shouldCompact = this.contextManager.shouldCompactContextWindow(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
this.api,
|
||||
previousApiReqIndex,
|
||||
autoCondenseThreshold,
|
||||
)
|
||||
|
||||
// Edge case: summarize_task tool call completes but user cancels next request before it finishes.
|
||||
@@ -2488,6 +2482,7 @@ export class Task {
|
||||
await this.messageStateHandler.addToApiConversationHistory({
|
||||
role: "user",
|
||||
content: userContent,
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.ulid, providerId, model.id, "user", modelInfo.mode)
|
||||
@@ -2530,7 +2525,7 @@ export class Task {
|
||||
|
||||
// if last message is a partial we need to update and save it
|
||||
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
|
||||
if (lastMessage && lastMessage.partial) {
|
||||
if (lastMessage?.partial) {
|
||||
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
|
||||
lastMessage.partial = false
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
@@ -2576,6 +2571,7 @@ export class Task {
|
||||
},
|
||||
cost: taskMetrics.totalCost,
|
||||
},
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
telemetryService.captureConversationTurnEvent(
|
||||
@@ -2651,6 +2647,13 @@ export class Task {
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (
|
||||
!this.taskState.taskFirstTokenTimeMs &&
|
||||
(chunk.type === "text" || chunk.type === "reasoning" || chunk.type === "tool_calls")
|
||||
) {
|
||||
this.taskState.taskFirstTokenTimeMs = Math.max(0, Date.now() - this.taskState.taskStartTimeMs)
|
||||
}
|
||||
|
||||
switch (chunk.type) {
|
||||
case "usage":
|
||||
this.streamHandler.setRequestId(chunk.id)
|
||||
@@ -2946,6 +2949,7 @@ export class Task {
|
||||
},
|
||||
cost: taskMetrics.totalCost,
|
||||
},
|
||||
ts: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3038,6 +3042,7 @@ export class Task {
|
||||
},
|
||||
cost: taskMetrics.totalCost,
|
||||
},
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
let response: ClineAskResponse
|
||||
@@ -3518,9 +3523,7 @@ export class Task {
|
||||
let shouldShowContextWindow = true
|
||||
// For next-gen models, only show context window usage if it exceeds a certain threshold
|
||||
if (isNextGenModel) {
|
||||
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
|
||||
// See: https://github.com/cline/cline/pull/9348
|
||||
const autoCondenseThreshold = SETTINGS_DEFAULTS.autoCondenseThreshold ?? 0.75
|
||||
const autoCondenseThreshold = 0.75
|
||||
const displayThreshold = autoCondenseThreshold - 0.15
|
||||
const currentUsageRatio = lastApiReqTotalTokens / contextWindow
|
||||
shouldShowContextWindow = currentUsageRatio >= displayThreshold
|
||||
@@ -3534,7 +3537,7 @@ export class Task {
|
||||
details += "\n\n# Current Mode"
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode")
|
||||
if (mode === "plan") {
|
||||
details += "\nPLAN MODE\n" + formatResponse.planModeInstructions()
|
||||
details += `\nPLAN MODE\n${formatResponse.planModeInstructions()}`
|
||||
} else {
|
||||
details += "\nACT MODE"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,32 @@ import type { ToolUse } from "@core/assistant-message"
|
||||
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../index"
|
||||
import { AccessMcpResourceHandler } from "./handlers/AccessMcpResourceHandler"
|
||||
import { ActModeRespondHandler } from "./handlers/ActModeRespondHandler"
|
||||
import { ApplyPatchHandler } from "./handlers/ApplyPatchHandler"
|
||||
import { AskFollowupQuestionToolHandler } from "./handlers/AskFollowupQuestionToolHandler"
|
||||
import { AttemptCompletionHandler } from "./handlers/AttemptCompletionHandler"
|
||||
import { BrowserToolHandler } from "./handlers/BrowserToolHandler"
|
||||
import { CondenseHandler } from "./handlers/CondenseHandler"
|
||||
import { ExecuteCommandToolHandler } from "./handlers/ExecuteCommandToolHandler"
|
||||
import { GenerateExplanationToolHandler } from "./handlers/GenerateExplanationToolHandler"
|
||||
import { ListCodeDefinitionNamesToolHandler } from "./handlers/ListCodeDefinitionNamesToolHandler"
|
||||
import { ListFilesToolHandler } from "./handlers/ListFilesToolHandler"
|
||||
import { LoadMcpDocumentationHandler } from "./handlers/LoadMcpDocumentationHandler"
|
||||
import { NewTaskHandler } from "./handlers/NewTaskHandler"
|
||||
import { PlanModeRespondHandler } from "./handlers/PlanModeRespondHandler"
|
||||
import { ReadFileToolHandler } from "./handlers/ReadFileToolHandler"
|
||||
import { ReportBugHandler } from "./handlers/ReportBugHandler"
|
||||
import { SearchFilesToolHandler } from "./handlers/SearchFilesToolHandler"
|
||||
import { UseSubagentsToolHandler } from "./handlers/SubagentToolHandler"
|
||||
import { SummarizeTaskHandler } from "./handlers/SummarizeTaskHandler"
|
||||
import { UseMcpToolHandler } from "./handlers/UseMcpToolHandler"
|
||||
import { UseSkillToolHandler } from "./handlers/UseSkillToolHandler"
|
||||
import { WebFetchToolHandler } from "./handlers/WebFetchToolHandler"
|
||||
import { WebSearchToolHandler } from "./handlers/WebSearchToolHandler"
|
||||
import { WriteToFileToolHandler } from "./handlers/WriteToFileToolHandler"
|
||||
import { AgentConfigLoader } from "./subagent/AgentConfigLoader"
|
||||
import { ToolValidator } from "./ToolValidator"
|
||||
import type { TaskConfig } from "./types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "./types/UIHelpers"
|
||||
|
||||
@@ -48,6 +74,39 @@ export class SharedToolHandler implements IFullyManagedTool {
|
||||
*/
|
||||
export class ToolExecutorCoordinator {
|
||||
private handlers = new Map<string, IToolHandler>()
|
||||
private dynamicSubagentHandlers = new Map<string, IToolHandler>()
|
||||
|
||||
private readonly toolHandlersMap: Record<ClineDefaultTool, (v: ToolValidator) => IToolHandler | undefined> = {
|
||||
[ClineDefaultTool.ASK]: (_v: ToolValidator) => new AskFollowupQuestionToolHandler(),
|
||||
[ClineDefaultTool.ATTEMPT]: (_v: ToolValidator) => new AttemptCompletionHandler(),
|
||||
[ClineDefaultTool.BASH]: (v: ToolValidator) => new ExecuteCommandToolHandler(v),
|
||||
[ClineDefaultTool.FILE_EDIT]: (v: ToolValidator) =>
|
||||
new SharedToolHandler(ClineDefaultTool.FILE_EDIT, new WriteToFileToolHandler(v)),
|
||||
[ClineDefaultTool.FILE_READ]: (v: ToolValidator) => new ReadFileToolHandler(v),
|
||||
[ClineDefaultTool.FILE_NEW]: (v: ToolValidator) => new WriteToFileToolHandler(v),
|
||||
[ClineDefaultTool.SEARCH]: (v: ToolValidator) => new SearchFilesToolHandler(v),
|
||||
[ClineDefaultTool.LIST_FILES]: (v: ToolValidator) => new ListFilesToolHandler(v),
|
||||
[ClineDefaultTool.LIST_CODE_DEF]: (v: ToolValidator) => new ListCodeDefinitionNamesToolHandler(v),
|
||||
[ClineDefaultTool.BROWSER]: (_v: ToolValidator) => new BrowserToolHandler(),
|
||||
[ClineDefaultTool.MCP_USE]: (_v: ToolValidator) => new UseMcpToolHandler(),
|
||||
[ClineDefaultTool.MCP_ACCESS]: (_v: ToolValidator) => new AccessMcpResourceHandler(),
|
||||
[ClineDefaultTool.MCP_DOCS]: (_v: ToolValidator) => new LoadMcpDocumentationHandler(),
|
||||
[ClineDefaultTool.NEW_TASK]: (_v: ToolValidator) => new NewTaskHandler(),
|
||||
[ClineDefaultTool.PLAN_MODE]: (_v: ToolValidator) => new PlanModeRespondHandler(),
|
||||
[ClineDefaultTool.ACT_MODE]: (_v: ToolValidator) => new ActModeRespondHandler(),
|
||||
[ClineDefaultTool.TODO]: (_v: ToolValidator) => undefined,
|
||||
[ClineDefaultTool.WEB_FETCH]: (_v: ToolValidator) => new WebFetchToolHandler(),
|
||||
[ClineDefaultTool.WEB_SEARCH]: (_v: ToolValidator) => new WebSearchToolHandler(),
|
||||
[ClineDefaultTool.CONDENSE]: (_v: ToolValidator) => new CondenseHandler(),
|
||||
[ClineDefaultTool.SUMMARIZE_TASK]: (_v: ToolValidator) => new SummarizeTaskHandler(_v),
|
||||
[ClineDefaultTool.REPORT_BUG]: (_v: ToolValidator) => new ReportBugHandler(),
|
||||
[ClineDefaultTool.NEW_RULE]: (v: ToolValidator) =>
|
||||
new SharedToolHandler(ClineDefaultTool.NEW_RULE, new WriteToFileToolHandler(v)),
|
||||
[ClineDefaultTool.APPLY_PATCH]: (_v: ToolValidator) => new ApplyPatchHandler(_v),
|
||||
[ClineDefaultTool.GENERATE_EXPLANATION]: (_v: ToolValidator) => new GenerateExplanationToolHandler(),
|
||||
[ClineDefaultTool.USE_SKILL]: (_v: ToolValidator) => new UseSkillToolHandler(),
|
||||
[ClineDefaultTool.USE_SUBAGENTS]: (_v: ToolValidator) => new UseSubagentsToolHandler(),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool handler
|
||||
@@ -56,11 +115,18 @@ export class ToolExecutorCoordinator {
|
||||
this.handlers.set(handler.name, handler)
|
||||
}
|
||||
|
||||
registerByName(toolName: ClineDefaultTool, validator: ToolValidator): void {
|
||||
const handler = this.toolHandlersMap[toolName]?.(validator)
|
||||
if (handler) {
|
||||
this.register(handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler is registered for the given tool
|
||||
*/
|
||||
has(toolName: string): boolean {
|
||||
return this.handlers.has(toolName)
|
||||
return this.getHandler(toolName) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,14 +137,30 @@ export class ToolExecutorCoordinator {
|
||||
if (toolName.includes(CLINE_MCP_TOOL_IDENTIFIER)) {
|
||||
toolName = ClineDefaultTool.MCP_USE
|
||||
}
|
||||
return this.handlers.get(toolName)
|
||||
|
||||
const staticHandler = this.handlers.get(toolName)
|
||||
if (staticHandler) {
|
||||
return staticHandler
|
||||
}
|
||||
|
||||
if (AgentConfigLoader.getInstance().isDynamicSubagentTool(toolName)) {
|
||||
const existingHandler = this.dynamicSubagentHandlers.get(toolName)
|
||||
if (existingHandler) {
|
||||
return existingHandler
|
||||
}
|
||||
const handler = new SharedToolHandler(toolName as ClineDefaultTool, new UseSubagentsToolHandler())
|
||||
this.dynamicSubagentHandlers.set(toolName, handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool through its registered handler
|
||||
*/
|
||||
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
|
||||
const handler = this.handlers.get(block.name)
|
||||
const handler = this.getHandler(block.name)
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for tool: ${block.name}`)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { buildUserFeedbackContent } from "../../utils/buildUserFeedbackContent"
|
||||
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
import { getTaskCompletionTelemetry } from "../utils"
|
||||
import { ToolResultUtils } from "../utils/ToolResultUtils"
|
||||
|
||||
const TASK_PREVIEW_MAX_CHARS = 8000
|
||||
@@ -152,7 +153,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
|
||||
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
|
||||
await config.callbacks.saveCheckpoint(true, completionMessageTs)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(config.ulid)
|
||||
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
|
||||
} else {
|
||||
// we already sent a command message, meaning the complete completion message has also been sent
|
||||
await config.callbacks.saveCheckpoint(true)
|
||||
@@ -199,7 +200,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
|
||||
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
|
||||
await config.callbacks.saveCheckpoint(true, completionMessageTs)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(config.ulid)
|
||||
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
|
||||
}
|
||||
|
||||
// we already sent completion_result says, an empty string asks relinquishes control over button and field
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { ToolResponse } from "../../index"
|
||||
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
import { getTaskCompletionTelemetry } from "../utils"
|
||||
|
||||
export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandler {
|
||||
readonly name = ClineDefaultTool.PLAN_MODE
|
||||
|
||||
constructor() {}
|
||||
|
||||
getDescription(block: ToolUse): string {
|
||||
return `[${block.name}]`
|
||||
}
|
||||
@@ -85,9 +84,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
|
||||
|
||||
// we dont need to process any text, options, files or other content here
|
||||
return formatResponse.toolResult(`[The user has switched to ACT MODE, so you may now proceed with the task.]`)
|
||||
} else {
|
||||
Logger.warn("YOLO MODE: Failed to switch to ACT MODE, continuing with normal plan mode")
|
||||
}
|
||||
Logger.warn("YOLO MODE: Failed to switch to ACT MODE, continuing with normal plan mode")
|
||||
}
|
||||
|
||||
// Set awaiting plan response state
|
||||
@@ -134,6 +132,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
|
||||
fileContentString = await processFilesIntoText(planResponseFiles)
|
||||
}
|
||||
|
||||
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
|
||||
|
||||
// Handle mode switching response
|
||||
if (config.taskState.didRespondToPlanAskBySwitchingMode) {
|
||||
const result = formatResponse.toolResult(
|
||||
@@ -147,9 +147,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
|
||||
// Reset the flag after using it to prevent it from persisting
|
||||
config.taskState.didRespondToPlanAskBySwitchingMode = false
|
||||
return result
|
||||
} else {
|
||||
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
|
||||
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
|
||||
}
|
||||
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
|
||||
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import { AgentConfigLoader } from "../subagent/AgentConfigLoader"
|
||||
import { SubagentRunner } from "../subagent/SubagentRunner"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -19,6 +20,19 @@ import { ToolResultUtils } from "../utils/ToolResultUtils"
|
||||
const MAX_SUBAGENT_PROMPTS = 5
|
||||
const PROMPT_KEYS = ["prompt_1", "prompt_2", "prompt_3", "prompt_4", "prompt_5"] as const
|
||||
|
||||
function resolveConfiguredSubagentName(toolName: string): string | undefined {
|
||||
return AgentConfigLoader.getInstance().resolveSubagentNameForTool(toolName)
|
||||
}
|
||||
|
||||
function collectPrompts(block: ToolUse, configuredSubagentName?: string): string[] {
|
||||
if (configuredSubagentName) {
|
||||
const dynamicPrompt = block.params.prompt?.trim() || block.params.prompt_1?.trim()
|
||||
return dynamicPrompt ? [dynamicPrompt] : []
|
||||
}
|
||||
|
||||
return PROMPT_KEYS.map((key) => block.params[key]?.trim()).filter((prompt): prompt is string => !!prompt)
|
||||
}
|
||||
|
||||
function excerpt(text: string | undefined, maxChars = 1200): string {
|
||||
if (!text) {
|
||||
return ""
|
||||
@@ -36,13 +50,21 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
|
||||
readonly name = ClineDefaultTool.USE_SUBAGENTS
|
||||
|
||||
getDescription(_block: ToolUse): string {
|
||||
return "[subagents]"
|
||||
const configuredSubagentName = resolveConfiguredSubagentName(_block.name)
|
||||
return configuredSubagentName ? `[subagent: ${configuredSubagentName}]` : "[subagents]"
|
||||
}
|
||||
|
||||
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
const prompts = PROMPT_KEYS.map((key) => uiHelpers.removeClosingTag(block, key, block.params[key]?.trim()))
|
||||
.map((prompt) => prompt?.trim())
|
||||
.filter((prompt): prompt is string => !!prompt)
|
||||
const configuredSubagentName = resolveConfiguredSubagentName(block.name)
|
||||
const prompts = configuredSubagentName
|
||||
? [
|
||||
uiHelpers
|
||||
.removeClosingTag(block, "prompt", block.params.prompt?.trim() || block.params.prompt_1?.trim())
|
||||
?.trim(),
|
||||
].filter((prompt): prompt is string => !!prompt)
|
||||
: PROMPT_KEYS.map((key) => uiHelpers.removeClosingTag(block, key, block.params[key]?.trim()))
|
||||
.map((prompt) => prompt?.trim())
|
||||
.filter((prompt): prompt is string => !!prompt)
|
||||
|
||||
if (prompts.length === 0) {
|
||||
return
|
||||
@@ -67,14 +89,15 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
|
||||
return formatResponse.toolError("Subagents are disabled. Enable them in Settings > Features to use this tool.")
|
||||
}
|
||||
|
||||
const prompts = PROMPT_KEYS.map((key) => block.params[key]?.trim()).filter((prompt): prompt is string => !!prompt)
|
||||
const configuredSubagentName = resolveConfiguredSubagentName(block.name)
|
||||
const prompts = collectPrompts(block, configuredSubagentName)
|
||||
|
||||
if (prompts.length === 0) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError(this.name, "prompt_1")
|
||||
return await config.callbacks.sayAndCreateMissingParamError(this.name, configuredSubagentName ? "prompt" : "prompt_1")
|
||||
}
|
||||
|
||||
if (prompts.length > MAX_SUBAGENT_PROMPTS) {
|
||||
if (!configuredSubagentName && prompts.length > MAX_SUBAGENT_PROMPTS) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return formatResponse.toolError(
|
||||
`Too many subagent prompts provided (${prompts.length}). Maximum is ${MAX_SUBAGENT_PROMPTS}.`,
|
||||
@@ -104,7 +127,9 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
|
||||
)
|
||||
} else {
|
||||
showNotificationForApproval(
|
||||
prompts.length === 1 ? "Cline wants to use a subagent" : `Cline wants to use ${prompts.length} subagents`,
|
||||
prompts.length === 1
|
||||
? `Cline wants to use ${configuredSubagentName ? `the '${configuredSubagentName}' subagent` : "a subagent"}`
|
||||
: `Cline wants to use ${prompts.length} subagents`,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_subagents", approvalBody, config)
|
||||
@@ -187,7 +212,7 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "subagent")
|
||||
await queueStatusUpdate("running", true)
|
||||
|
||||
const runners = prompts.map(() => new SubagentRunner(config))
|
||||
const runners = prompts.map(() => new SubagentRunner(config, configuredSubagentName))
|
||||
const abortPollInterval = setInterval(() => {
|
||||
if (!config.taskState.abort) {
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ClineDefaultTool } from "@shared/tools"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { TaskState } from "../../../TaskState"
|
||||
import { AgentConfigLoader } from "../../subagent/AgentConfigLoader"
|
||||
import { SubagentRunner } from "../../subagent/SubagentRunner"
|
||||
import type { TaskConfig } from "../../types/TaskConfig"
|
||||
import { createUIHelpers } from "../../types/UIHelpers"
|
||||
@@ -380,4 +381,62 @@ describe("SubagentToolHandler", () => {
|
||||
assert.ok((result as string).includes("Failed: 1"))
|
||||
assert.ok((result as string).includes("boom"))
|
||||
})
|
||||
|
||||
it("runs configured subagent tools using the prompt parameter", async () => {
|
||||
const { config } = createConfig({ autoApproveSafe: true, autoApproveAll: true })
|
||||
const handler = new UseSubagentsToolHandler()
|
||||
const dynamicToolName = "use_subagent_code_reviewer"
|
||||
sinon.stub(AgentConfigLoader, "getInstance").returns({
|
||||
resolveSubagentNameForTool: (toolName: string) => (toolName === dynamicToolName ? "code-reviewer" : undefined),
|
||||
getCachedConfig: () => undefined,
|
||||
} as unknown as AgentConfigLoader)
|
||||
|
||||
const runStub = sinon.stub(SubagentRunner.prototype, "run").resolves({
|
||||
status: "completed",
|
||||
result: "dynamic done",
|
||||
stats: {
|
||||
toolCalls: 1,
|
||||
inputTokens: 2,
|
||||
outputTokens: 3,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: 0.1,
|
||||
contextTokens: 100,
|
||||
contextWindow: 200000,
|
||||
contextUsagePercentage: 0.05,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await handler.execute(config, {
|
||||
type: "tool_use",
|
||||
name: dynamicToolName as ClineDefaultTool,
|
||||
params: { prompt: "review this PR" },
|
||||
partial: false,
|
||||
})
|
||||
|
||||
assert.match(String(result), /dynamic done/)
|
||||
sinon.assert.calledOnce(runStub)
|
||||
assert.equal(runStub.firstCall.args[0], "review this PR")
|
||||
})
|
||||
|
||||
it("requires prompt for configured subagent tools", async () => {
|
||||
const { config, callbacks, taskState } = createConfig()
|
||||
const handler = new UseSubagentsToolHandler()
|
||||
const dynamicToolName = "use_subagent_code_reviewer"
|
||||
sinon.stub(AgentConfigLoader, "getInstance").returns({
|
||||
resolveSubagentNameForTool: (toolName: string) => (toolName === dynamicToolName ? "code-reviewer" : undefined),
|
||||
getCachedConfig: () => undefined,
|
||||
} as unknown as AgentConfigLoader)
|
||||
|
||||
const result = await handler.execute(config, {
|
||||
type: "tool_use",
|
||||
name: dynamicToolName as ClineDefaultTool,
|
||||
params: {},
|
||||
partial: false,
|
||||
})
|
||||
|
||||
assert.equal(result, "missing")
|
||||
assert.equal(taskState.consecutiveMistakeCount, 1)
|
||||
sinon.assert.calledWithExactly(callbacks.sayAndCreateMissingParamError, ClineDefaultTool.USE_SUBAGENTS, "prompt")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { parseYamlFrontmatter } from "@core/context/instructions/user-instructions/frontmatter"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import { ClineDefaultTool, setDynamicToolUseNames } from "@shared/tools"
|
||||
import chokidar, { type FSWatcher } from "chokidar"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import { z } from "zod"
|
||||
import { buildSubagentToolName } from "./SubagentToolName"
|
||||
|
||||
/** Default Directory for agent configurations: ~/Documents/Cline/Agents */
|
||||
export const AGENTS_CONFIG_DIRECTORY_NAME = "Agents"
|
||||
const SUBAGENT_DYNAMIC_TOOL_NAMESPACE = "subagent"
|
||||
|
||||
const AgentBaseConfigSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().min(1),
|
||||
tools: z.array(z.nativeEnum(ClineDefaultTool)).default([]),
|
||||
modelId: z.string().trim().min(1),
|
||||
systemPrompt: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const AgentConfigFrontmatterSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().min(1),
|
||||
modelId: z.string().trim().min(1),
|
||||
tools: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
})
|
||||
|
||||
export type AgentBaseConfig = z.infer<typeof AgentBaseConfigSchema>
|
||||
|
||||
function normalizeToolName(toolName: string): ClineDefaultTool {
|
||||
const trimmed = toolName.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error("Tool name cannot be empty.")
|
||||
}
|
||||
|
||||
const asDefaultTool = trimmed as ClineDefaultTool
|
||||
if (Object.values(ClineDefaultTool).includes(asDefaultTool)) {
|
||||
return asDefaultTool
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown tool '${trimmed}'. Expected a ClineDefaultTool value (for example: read_file, list_files, search_files).`,
|
||||
)
|
||||
}
|
||||
|
||||
function parseTools(tools: string | string[] | undefined): ClineDefaultTool[] {
|
||||
if (!tools) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rawTools = Array.isArray(tools) ? tools : tools.split(",")
|
||||
if (rawTools.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.from(new Set(rawTools.map(normalizeToolName)))
|
||||
}
|
||||
|
||||
export function parseAgentConfigFromYaml(content: string): AgentBaseConfig {
|
||||
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(content)
|
||||
if (parseError) {
|
||||
throw new Error(`Failed to parse YAML frontmatter: ${parseError}`)
|
||||
}
|
||||
if (!hadFrontmatter) {
|
||||
throw new Error("Missing YAML frontmatter block in agent config file.")
|
||||
}
|
||||
|
||||
const parsedFrontmatter = AgentConfigFrontmatterSchema.parse(data)
|
||||
const systemPrompt = body.trim()
|
||||
if (!systemPrompt) {
|
||||
throw new Error("Missing system prompt body in agent config file.")
|
||||
}
|
||||
|
||||
return AgentBaseConfigSchema.parse({
|
||||
name: parsedFrontmatter.name,
|
||||
description: parsedFrontmatter.description,
|
||||
modelId: parsedFrontmatter.modelId,
|
||||
tools: parseTools(parsedFrontmatter.tools),
|
||||
systemPrompt,
|
||||
}) as AgentBaseConfig
|
||||
}
|
||||
|
||||
export function getAgentsConfigPath(homeDir = os.homedir()): string {
|
||||
return path.join(homeDir, "Documents", "Cline", AGENTS_CONFIG_DIRECTORY_NAME)
|
||||
}
|
||||
|
||||
function normalizeAgentName(name: string): string {
|
||||
return name.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isYamlFile(filePath: string): boolean {
|
||||
return /\.(yaml|yml)$/i.test(filePath)
|
||||
}
|
||||
|
||||
export async function readAgentConfigsFromDisk(homeDir = os.homedir()): Promise<Map<string, AgentBaseConfig>> {
|
||||
const agentsDirectoryPath = getAgentsConfigPath(homeDir)
|
||||
const configs = new Map<string, AgentBaseConfig>()
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(agentsDirectoryPath, { withFileTypes: true })
|
||||
const yamlFiles = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter(isYamlFile)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
Logger.debug(`[AgentConfigLoader] Found ${yamlFiles.length} YAML file(s).`)
|
||||
|
||||
await Promise.all(
|
||||
yamlFiles.map(async (fileName) => {
|
||||
const filePath = path.join(agentsDirectoryPath, fileName)
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf8")
|
||||
const parsed = parseAgentConfigFromYaml(content)
|
||||
Logger.debug(`[AgentConfigLoader] Loaded agent config '${fileName}'`, parsed)
|
||||
configs.set(normalizeAgentName(parsed.name), parsed)
|
||||
} catch (error) {
|
||||
Logger.error(`[AgentConfigLoader] Failed to parse agent config '${fileName}'`, error)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return configs
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
if (nodeError.code === "ENOENT") {
|
||||
return configs
|
||||
}
|
||||
Logger.error("[AgentConfigLoader] Failed to read agent configs from disk", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentConfigChangeListener = (configs: ReadonlyMap<string, AgentBaseConfig>, error?: Error) => void
|
||||
|
||||
export class AgentConfigLoader {
|
||||
private static instance?: AgentConfigLoader
|
||||
|
||||
private readonly homeDir: string
|
||||
private readonly directoryPath: string
|
||||
private readonly initialLoadPromise: Promise<void>
|
||||
private watcher?: FSWatcher
|
||||
private cachedConfigs = new Map<string, AgentBaseConfig>()
|
||||
private cachedAgentToolNames = new Map<string, string>()
|
||||
private cachedToolNameToAgentName = new Map<string, string>()
|
||||
private listeners = new Set<AgentConfigChangeListener>()
|
||||
|
||||
private constructor(homeDir = os.homedir()) {
|
||||
this.homeDir = homeDir
|
||||
this.directoryPath = getAgentsConfigPath(homeDir)
|
||||
this.initialLoadPromise = this.load()
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
Logger.error("[AgentConfigLoader] Failed to load initial agent configs", error)
|
||||
})
|
||||
.finally(() =>
|
||||
this.watch().catch((error) => Logger.error("[AgentConfigLoader] Failed to start watching agent configs", error)),
|
||||
)
|
||||
}
|
||||
|
||||
public static getInstance(homeDir = os.homedir()): AgentConfigLoader {
|
||||
if (!AgentConfigLoader.instance) {
|
||||
AgentConfigLoader.instance = new AgentConfigLoader(homeDir)
|
||||
}
|
||||
return AgentConfigLoader.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only helper to clear singleton state between unit tests.
|
||||
*/
|
||||
public static async resetInstanceForTests(): Promise<void> {
|
||||
if (!AgentConfigLoader.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
await AgentConfigLoader.instance.dispose()
|
||||
AgentConfigLoader.instance = undefined
|
||||
}
|
||||
|
||||
public getConfigPath(): string {
|
||||
return this.directoryPath
|
||||
}
|
||||
|
||||
public async ready(): Promise<void> {
|
||||
await this.initialLoadPromise
|
||||
}
|
||||
|
||||
public getCachedConfig(subagentName?: string): AgentBaseConfig | undefined {
|
||||
if (!subagentName?.trim()) {
|
||||
return undefined
|
||||
}
|
||||
return this.cachedConfigs.get(normalizeAgentName(subagentName))
|
||||
}
|
||||
|
||||
public getAllCachedConfigs(): ReadonlyMap<string, AgentBaseConfig> {
|
||||
return new Map(this.cachedConfigs)
|
||||
}
|
||||
|
||||
public getAllCachedConfigsWithToolNames(): Array<{ toolName: string; config: AgentBaseConfig }> {
|
||||
const result: Array<{ toolName: string; config: AgentBaseConfig }> = []
|
||||
for (const [normalizedName, config] of this.cachedConfigs.entries()) {
|
||||
const toolName = this.cachedAgentToolNames.get(normalizedName)
|
||||
if (toolName) {
|
||||
result.push({ toolName, config })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public resolveSubagentNameForTool(toolName?: string): string | undefined {
|
||||
if (!toolName?.trim()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizedName = this.cachedToolNameToAgentName.get(toolName.trim())
|
||||
if (!normalizedName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.cachedConfigs.get(normalizedName)?.name
|
||||
}
|
||||
|
||||
public isDynamicSubagentTool(toolName?: string): boolean {
|
||||
if (!toolName?.trim()) {
|
||||
return false
|
||||
}
|
||||
return this.cachedToolNameToAgentName.has(toolName.trim())
|
||||
}
|
||||
|
||||
public async load(): Promise<ReadonlyMap<string, AgentBaseConfig>> {
|
||||
const configs = await readAgentConfigsFromDisk(this.homeDir)
|
||||
this.cachedConfigs = configs
|
||||
this.rebuildDynamicToolMappings()
|
||||
Logger.debug(`[AgentConfigLoader] Loaded ${configs.size} agent config(s) from disk.`)
|
||||
return this.getAllCachedConfigs()
|
||||
}
|
||||
|
||||
public async watch(listener?: AgentConfigChangeListener): Promise<void> {
|
||||
if (listener) {
|
||||
this.listeners.add(listener)
|
||||
}
|
||||
|
||||
if (this.watcher) {
|
||||
return
|
||||
}
|
||||
|
||||
this.watcher = chokidar.watch(this.directoryPath, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 300,
|
||||
pollInterval: 100,
|
||||
},
|
||||
})
|
||||
|
||||
this.watcher
|
||||
.on("add", (filePath) => {
|
||||
if (isYamlFile(filePath)) {
|
||||
void this.reloadAndNotify()
|
||||
}
|
||||
})
|
||||
.on("change", (filePath) => {
|
||||
if (isYamlFile(filePath)) {
|
||||
void this.reloadAndNotify()
|
||||
}
|
||||
})
|
||||
.on("unlink", (filePath) => {
|
||||
if (isYamlFile(filePath)) {
|
||||
void this.reloadAndNotify()
|
||||
}
|
||||
})
|
||||
.on("error", (error) => {
|
||||
const watcherError = error instanceof Error ? error : new Error(String(error))
|
||||
Logger.error("[AgentConfigLoader] Failed to watch agent configs directory", watcherError)
|
||||
this.notify(this.cachedConfigs, watcherError)
|
||||
})
|
||||
}
|
||||
|
||||
public unwatch(listener: AgentConfigChangeListener): void {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
if (!this.watcher) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.watcher.close()
|
||||
this.watcher = undefined
|
||||
}
|
||||
|
||||
private async reloadAndNotify(): Promise<void> {
|
||||
try {
|
||||
await this.load()
|
||||
this.notify(this.cachedConfigs)
|
||||
} catch (error) {
|
||||
const parseError = error instanceof Error ? error : new Error(String(error))
|
||||
Logger.error("[AgentConfigLoader] Failed to reload agent configs", parseError)
|
||||
this.notify(this.cachedConfigs, parseError)
|
||||
}
|
||||
}
|
||||
|
||||
private notify(configs: ReadonlyMap<string, AgentBaseConfig>, error?: Error): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(new Map(configs), error)
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildDynamicToolMappings(): void {
|
||||
const sortedConfigs = Array.from(this.cachedConfigs.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const usedToolNames = new Set<string>()
|
||||
const agentToolNames = new Map<string, string>()
|
||||
const toolNameToAgentName = new Map<string, string>()
|
||||
|
||||
for (const [normalizedName, config] of sortedConfigs) {
|
||||
const baseName = buildSubagentToolName(config.name)
|
||||
let candidate = baseName
|
||||
let suffix = 2
|
||||
while (usedToolNames.has(candidate)) {
|
||||
const suffixText = `_${suffix++}`
|
||||
const maxBaseLength = Math.max(1, 64 - suffixText.length)
|
||||
candidate = `${baseName.slice(0, maxBaseLength)}${suffixText}`
|
||||
}
|
||||
|
||||
usedToolNames.add(candidate)
|
||||
agentToolNames.set(normalizedName, candidate)
|
||||
toolNameToAgentName.set(candidate, normalizedName)
|
||||
}
|
||||
|
||||
this.cachedAgentToolNames = agentToolNames
|
||||
this.cachedToolNameToAgentName = toolNameToAgentName
|
||||
setDynamicToolUseNames(SUBAGENT_DYNAMIC_TOOL_NAMESPACE, Array.from(toolNameToAgentName.keys()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import { PromptRegistry } from "@core/prompts/system-prompt"
|
||||
import { ClineToolSet } from "@core/prompts/system-prompt/registry/ClineToolSet"
|
||||
import type { SystemPromptContext } from "@core/prompts/system-prompt/types"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { ApiProvider } from "@/shared/api"
|
||||
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { AgentBaseConfig } from "./AgentConfigLoader"
|
||||
import { AgentConfigLoader } from "./AgentConfigLoader"
|
||||
|
||||
export type AgentConfig = Partial<AgentBaseConfig>
|
||||
|
||||
export const SUBAGENT_DEFAULT_ALLOWED_TOOLS: ClineDefaultTool[] = [
|
||||
ClineDefaultTool.FILE_READ,
|
||||
ClineDefaultTool.LIST_FILES,
|
||||
ClineDefaultTool.SEARCH,
|
||||
ClineDefaultTool.LIST_CODE_DEF,
|
||||
ClineDefaultTool.BASH,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.ATTEMPT,
|
||||
]
|
||||
|
||||
export const SUBAGENT_SYSTEM_SUFFIX = `\n\n# Subagent Execution Mode
|
||||
You are running as a research subagent. Your job is to explore the codebase and gather information to answer the question.
|
||||
Explore, read related files, trace through call chains, and build a complete picture before reporting back.
|
||||
You can read files, list directories, search for patterns, list code definitions, and run commands.
|
||||
Only use execute_command for readonly operations like ls, grep, git log, git diff, gh, etc.
|
||||
When it makes sense, be clever about chaining commands or in-command scripting in execute_command to quickly get relevant context - and using pipes / filters to help narrow results.
|
||||
Do not run commands that modify files or system state.
|
||||
When you have a comprehensive answer, call the attempt_completion tool.
|
||||
The attempt_completion result field is sent directly to the main agent, so put your full final findings there.
|
||||
Unless the subagent prompt explicitly asks for detailed analysis, keep the result concise and focus on the files the main agent should read next.
|
||||
Include a section titled "Relevant file paths" and list only file paths, one per line.
|
||||
Do not include line numbers, summaries, or per-file explanations unless explicitly requested.
|
||||
`
|
||||
|
||||
export class SubagentBuilder {
|
||||
private readonly agentConfig: AgentConfig = {}
|
||||
private readonly allowedTools: ClineDefaultTool[]
|
||||
private readonly apiHandler: ReturnType<typeof buildApiHandler>
|
||||
|
||||
constructor(
|
||||
private readonly baseConfig: TaskConfig,
|
||||
subagentName?: string,
|
||||
) {
|
||||
const subagentConfig = AgentConfigLoader.getInstance().getCachedConfig(subagentName)
|
||||
this.agentConfig = subagentConfig ?? {}
|
||||
this.allowedTools = this.resolveAllowedTools(this.agentConfig.tools)
|
||||
|
||||
const mode = this.baseConfig.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfiguration = this.baseConfig.services.stateManager.getApiConfiguration()
|
||||
const effectiveApiConfiguration = {
|
||||
...apiConfiguration,
|
||||
ulid: this.baseConfig.ulid,
|
||||
} as Record<string, unknown>
|
||||
this.applyModelOverride(effectiveApiConfiguration, mode, this.agentConfig.modelId)
|
||||
this.apiHandler = buildApiHandler(effectiveApiConfiguration as typeof apiConfiguration, mode)
|
||||
}
|
||||
|
||||
getApiHandler(): ReturnType<typeof buildApiHandler> {
|
||||
return this.apiHandler
|
||||
}
|
||||
|
||||
getAllowedTools(): ClineDefaultTool[] {
|
||||
return this.allowedTools
|
||||
}
|
||||
|
||||
buildSystemPrompt(generatedSystemPrompt: string): string {
|
||||
const configuredSystemPrompt = this.agentConfig?.systemPrompt?.trim()
|
||||
const systemPrompt = configuredSystemPrompt || generatedSystemPrompt
|
||||
return `${systemPrompt}${this.buildAgentIdentitySystemPrefix()}${SUBAGENT_SYSTEM_SUFFIX}`
|
||||
}
|
||||
|
||||
buildNativeTools(context: SystemPromptContext) {
|
||||
const family = PromptRegistry.getInstance().getModelFamily(context)
|
||||
const toolSets = ClineToolSet.getToolsForVariantWithFallback(family, this.allowedTools)
|
||||
const filteredToolSpecs = toolSets
|
||||
.map((toolSet) => toolSet.config)
|
||||
.filter(
|
||||
(toolSpec) =>
|
||||
this.allowedTools.includes(toolSpec.id) &&
|
||||
(!toolSpec.contextRequirements || toolSpec.contextRequirements(context)),
|
||||
)
|
||||
|
||||
const converter = ClineToolSet.getNativeConverter(context.providerInfo.providerId, context.providerInfo.model.id)
|
||||
return filteredToolSpecs.map((tool) => converter(tool, context))
|
||||
}
|
||||
|
||||
private resolveAllowedTools(configuredTools?: ClineDefaultTool[]): ClineDefaultTool[] {
|
||||
const sourceTools = configuredTools && configuredTools.length > 0 ? configuredTools : SUBAGENT_DEFAULT_ALLOWED_TOOLS
|
||||
return Array.from(new Set([...sourceTools, ClineDefaultTool.ATTEMPT]))
|
||||
}
|
||||
|
||||
private buildAgentIdentitySystemPrefix(): string {
|
||||
const name = this.agentConfig?.name?.trim()
|
||||
const description = this.agentConfig?.description?.trim()
|
||||
if (!name && !description) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lines = ["# Agent Profile"]
|
||||
if (name) {
|
||||
lines.push(`Name: ${name}`)
|
||||
}
|
||||
if (description) {
|
||||
lines.push(`Description: ${description}`)
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n\n`
|
||||
}
|
||||
|
||||
private applyModelOverride(apiConfiguration: Record<string, unknown>, _mode: string, modelId?: string): void {
|
||||
const trimmedModelId = modelId?.trim()
|
||||
if (!trimmedModelId) {
|
||||
return
|
||||
}
|
||||
|
||||
const mode = _mode === "plan" ? "plan" : "act"
|
||||
const provider = apiConfiguration[_mode === "plan" ? "planModeApiProvider" : "actModeApiProvider"] as ApiProvider
|
||||
apiConfiguration[getProviderModelIdKey(provider as ApiProvider, mode)] = trimmedModelId
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,29 @@
|
||||
import * as path from "node:path"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import type { ApiHandler, buildApiHandler } from "@core/api"
|
||||
import { parseAssistantMessageV2, ToolUse } from "@core/assistant-message"
|
||||
import { ContextManager } from "@core/context/context-management/ContextManager"
|
||||
import { checkContextWindowExceededError } from "@core/context/context-management/context-error-handling"
|
||||
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
|
||||
import { discoverSkills, getAvailableSkills } from "@core/context/instructions/user-instructions/skills"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { PromptRegistry } from "@core/prompts/system-prompt"
|
||||
import { ClineToolSet } from "@core/prompts/system-prompt/registry/ClineToolSet"
|
||||
import type { SystemPromptContext } from "@core/prompts/system-prompt/types"
|
||||
import { StreamResponseHandler } from "@core/task/StreamResponseHandler"
|
||||
import { ClineAssistantToolUseBlock, ClineStorageMessage, ClineTextContentBlock } from "@shared/messages"
|
||||
import { ClineAssistantToolUseBlock, ClineStorageMessage, ClineTextContentBlock, ClineUserContent } from "@shared/messages"
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import { SETTINGS_DEFAULTS } from "@shared/storage/state-keys"
|
||||
import type { ClineTool } from "@shared/tools"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { isNextGenModelFamily } from "@utils/model-utils"
|
||||
import * as path from "path"
|
||||
import { ClineDefaultTool, ClineTool } from "@shared/tools"
|
||||
import { ContextManager } from "@/core/context/context-management/ContextManager"
|
||||
import { checkContextWindowExceededError } from "@/core/context/context-management/context-error-handling"
|
||||
import { getContextWindowInfo } from "@/core/context/context-management/context-window-utils"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
|
||||
import { ClineError, ClineErrorType } from "@/services/error"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { calculateApiCostAnthropic } from "@/utils/cost"
|
||||
import { isNextGenModelFamily } from "@/utils/model-utils"
|
||||
import { TaskState } from "../../TaskState"
|
||||
import { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
|
||||
import { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import { SubagentBuilder } from "./SubagentBuilder"
|
||||
|
||||
const SUBAGENT_ALLOWED_TOOLS: ClineDefaultTool[] = [
|
||||
ClineDefaultTool.FILE_READ,
|
||||
ClineDefaultTool.LIST_FILES,
|
||||
ClineDefaultTool.SEARCH,
|
||||
ClineDefaultTool.LIST_CODE_DEF,
|
||||
ClineDefaultTool.BASH,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.ATTEMPT,
|
||||
]
|
||||
const MAX_EMPTY_ASSISTANT_RETRIES = 3
|
||||
const MAX_INITIAL_STREAM_ATTEMPTS = 3
|
||||
const INITIAL_STREAM_RETRY_BASE_DELAY_MS = 250
|
||||
@@ -76,20 +67,6 @@ interface SubagentToolCall {
|
||||
isNativeToolCall: boolean
|
||||
}
|
||||
|
||||
const SUBAGENT_SYSTEM_SUFFIX = `\n\n# Subagent Execution Mode
|
||||
You are running as a research subagent. Your job is to explore the codebase and gather information to answer the question.
|
||||
Explore, read related files, trace through call chains, and build a complete picture before reporting back.
|
||||
You can read files, list directories, search for patterns, list code definitions, and run commands.
|
||||
Only use execute_command for readonly operations like ls, grep, git log, git diff, gh, etc.
|
||||
When it makes sense, be clever about chaining commands or in-command scripting in execute_command to quickly get relevant context - and using pipes / filters to help narrow results.
|
||||
Do not run commands that modify files or system state.
|
||||
When you have a comprehensive answer, call the attempt_completion tool.
|
||||
The attempt_completion result field is sent directly to the main agent, so put your full final findings there.
|
||||
Unless the subagent prompt explicitly asks for detailed analysis, keep the result concise and focus on the files the main agent should read next.
|
||||
Include a section titled "Relevant file paths" and list only file paths, one per line.
|
||||
Do not include line numbers, summaries, or per-file explanations unless explicitly requested.
|
||||
`
|
||||
|
||||
function serializeToolResult(result: unknown): string {
|
||||
if (typeof result === "string") {
|
||||
return result
|
||||
@@ -222,12 +199,22 @@ function pushSubagentToolResultBlock(toolResultBlocks: any[], call: SubagentTool
|
||||
}
|
||||
|
||||
export class SubagentRunner {
|
||||
private readonly agent: SubagentBuilder
|
||||
private readonly apiHandler: ApiHandler
|
||||
private readonly allowedTools: ClineDefaultTool[]
|
||||
private activeApiAbort: (() => void) | undefined
|
||||
private abortRequested = false
|
||||
private activeCommandExecutions = 0
|
||||
private abortingCommands = false
|
||||
|
||||
constructor(private baseConfig: TaskConfig) {}
|
||||
constructor(
|
||||
private baseConfig: TaskConfig,
|
||||
subagentName = "subagent",
|
||||
) {
|
||||
this.agent = new SubagentBuilder(baseConfig, subagentName)
|
||||
this.apiHandler = this.agent.getApiHandler()
|
||||
this.allowedTools = this.agent.getAllowedTools()
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortRequested = true
|
||||
@@ -299,11 +286,7 @@ export class SubagentRunner {
|
||||
try {
|
||||
const mode = this.baseConfig.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfiguration = this.baseConfig.services.stateManager.getApiConfiguration()
|
||||
const effectiveApiConfiguration = {
|
||||
...apiConfiguration,
|
||||
ulid: this.baseConfig.ulid,
|
||||
}
|
||||
const api = buildApiHandler(effectiveApiConfiguration, mode)
|
||||
const api = this.apiHandler
|
||||
this.activeApiAbort = api.abort?.bind(api)
|
||||
|
||||
const providerId = (
|
||||
@@ -338,9 +321,10 @@ export class SubagentRunner {
|
||||
}
|
||||
|
||||
const promptRegistry = PromptRegistry.getInstance()
|
||||
const systemPrompt = (await promptRegistry.get(context)) + SUBAGENT_SYSTEM_SUFFIX
|
||||
const generatedSystemPrompt = await promptRegistry.get(context)
|
||||
const systemPrompt = this.agent.buildSystemPrompt(generatedSystemPrompt)
|
||||
const useNativeToolCalls = !!promptRegistry.nativeTools?.length
|
||||
const nativeTools = useNativeToolCalls ? this.buildNativeTools(context) : undefined
|
||||
const nativeTools = useNativeToolCalls ? this.agent.buildNativeTools(context) : undefined
|
||||
const workspaceMetadataEnvironmentBlock = await this.getWorkspaceMetadataEnvironmentBlock()
|
||||
|
||||
if (useNativeToolCalls && (!nativeTools || nativeTools.length === 0)) {
|
||||
@@ -559,7 +543,7 @@ export class SubagentRunner {
|
||||
}
|
||||
emptyAssistantResponseRetries = 0
|
||||
|
||||
const toolResultBlocks = [] as any[]
|
||||
const toolResultBlocks = [] as ClineUserContent[]
|
||||
for (const call of finalizedToolCalls) {
|
||||
const toolName = call.name as ClineDefaultTool
|
||||
const toolCallParams = toToolUseParams(call.input)
|
||||
@@ -578,7 +562,7 @@ export class SubagentRunner {
|
||||
return { status: "completed", result: completionResult, stats }
|
||||
}
|
||||
|
||||
if (!SUBAGENT_ALLOWED_TOOLS.includes(toolName)) {
|
||||
if (!this.allowedTools.includes(toolName)) {
|
||||
const deniedResult = formatResponse.toolError(`Tool '${toolName}' is not available inside subagent runs.`)
|
||||
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, deniedResult)
|
||||
continue
|
||||
@@ -648,9 +632,17 @@ export class SubagentRunner {
|
||||
|
||||
private createSubagentTaskConfig(state: TaskState): TaskConfig {
|
||||
const baseCallbacks = this.baseConfig.callbacks
|
||||
const coordinator = new ToolExecutorCoordinator()
|
||||
const validator = new ToolValidator(this.baseConfig.services.clineIgnoreController)
|
||||
|
||||
for (const tool of this.allowedTools) {
|
||||
coordinator.registerByName(tool, validator)
|
||||
}
|
||||
|
||||
return {
|
||||
...this.baseConfig,
|
||||
api: this.apiHandler,
|
||||
coordinator,
|
||||
taskState: state,
|
||||
isSubagentExecution: true,
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
@@ -738,9 +730,7 @@ export class SubagentRunner {
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const useAutoCondense = this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense")
|
||||
if (useAutoCondense && isNextGenModelFamily(modelId)) {
|
||||
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
|
||||
// See: https://github.com/cline/cline/pull/9348
|
||||
const autoCondenseThreshold = SETTINGS_DEFAULTS.autoCondenseThreshold
|
||||
const autoCondenseThreshold = 0.75
|
||||
const roundedThreshold = autoCondenseThreshold ? Math.floor(contextWindow * autoCondenseThreshold) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return previousRequestTotalTokens >= thresholdTokens
|
||||
@@ -795,16 +785,4 @@ export class SubagentRunner {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildNativeTools(context: SystemPromptContext): ClineTool[] {
|
||||
const family = PromptRegistry.getInstance().getModelFamily(context)
|
||||
const toolSets = ClineToolSet.getToolsForVariantWithFallback(family, SUBAGENT_ALLOWED_TOOLS)
|
||||
const filteredToolSpecs = toolSets
|
||||
.map((toolSet) => toolSet.config)
|
||||
.filter((toolSpec) => !toolSpec.contextRequirements || toolSpec.contextRequirements(context))
|
||||
|
||||
const converter = ClineToolSet.getNativeConverter(context.providerInfo.providerId, context.providerInfo.model.id)
|
||||
|
||||
return filteredToolSpecs.map((tool) => converter(tool, context))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const SUBAGENT_TOOL_NAME_PREFIX = "use_subagent_"
|
||||
const SUBAGENT_TOOL_NAME_MAX_LENGTH = 64
|
||||
|
||||
function sanitizeAgentName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
function trimToolNameToMax(value: string): string {
|
||||
if (value.length <= SUBAGENT_TOOL_NAME_MAX_LENGTH) {
|
||||
return value
|
||||
}
|
||||
return value.slice(0, SUBAGENT_TOOL_NAME_MAX_LENGTH)
|
||||
}
|
||||
|
||||
export function buildSubagentToolName(agentName: string): string {
|
||||
const sanitized = sanitizeAgentName(agentName) || "agent"
|
||||
const hashSuffix = hashString(agentName).slice(0, 6)
|
||||
const base = `${SUBAGENT_TOOL_NAME_PREFIX}${sanitized}`
|
||||
|
||||
if (base.length <= SUBAGENT_TOOL_NAME_MAX_LENGTH) {
|
||||
return base
|
||||
}
|
||||
|
||||
const maxBodyLength = SUBAGENT_TOOL_NAME_MAX_LENGTH - SUBAGENT_TOOL_NAME_PREFIX.length - hashSuffix.length - 1
|
||||
const body = sanitized.slice(0, Math.max(1, maxBodyLength))
|
||||
return trimToolNameToMax(`${SUBAGENT_TOOL_NAME_PREFIX}${body}_${hashSuffix}`)
|
||||
}
|
||||
|
||||
export function isSubagentToolName(toolName: string): boolean {
|
||||
return toolName.startsWith(SUBAGENT_TOOL_NAME_PREFIX)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import fs from "fs/promises"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import { ClineDefaultTool, getToolUseNames } from "@/shared/tools"
|
||||
import { AgentConfigLoader, getAgentsConfigPath, parseAgentConfigFromYaml, readAgentConfigsFromDisk } from "../AgentConfigLoader"
|
||||
|
||||
async function createTempHomeDir(): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), "agent-config-loader-"))
|
||||
}
|
||||
|
||||
describe("AgentConfigLoader", () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await AgentConfigLoader.resetInstanceForTests()
|
||||
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })))
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it("parses an Agents.yaml frontmatter config and system prompt body", () => {
|
||||
const content = `---
|
||||
name: code-reviewer
|
||||
description: Reviews code for quality and best practices
|
||||
tools: read_file, list_files, search_files
|
||||
modelId: sonnet
|
||||
---
|
||||
|
||||
You are a code reviewer.`
|
||||
|
||||
const parsed = parseAgentConfigFromYaml(content)
|
||||
|
||||
assert.equal(parsed.name, "code-reviewer")
|
||||
assert.equal(parsed.description, "Reviews code for quality and best practices")
|
||||
assert.equal(parsed.modelId, "sonnet")
|
||||
assert.deepEqual(parsed.tools, [ClineDefaultTool.FILE_READ, ClineDefaultTool.LIST_FILES, ClineDefaultTool.SEARCH])
|
||||
assert.equal(parsed.systemPrompt, "You are a code reviewer.")
|
||||
})
|
||||
|
||||
it("supports raw Cline tool ids in tools", () => {
|
||||
const content = `---
|
||||
name: cli-agent
|
||||
description: Uses internal ids
|
||||
tools:
|
||||
- read_file
|
||||
- list_files
|
||||
modelId: sonnet
|
||||
---
|
||||
|
||||
Prompt body`
|
||||
|
||||
const parsed = parseAgentConfigFromYaml(content)
|
||||
assert.deepEqual(parsed.tools, [ClineDefaultTool.FILE_READ, ClineDefaultTool.LIST_FILES])
|
||||
})
|
||||
|
||||
it("throws for unknown tools", () => {
|
||||
const content = `---
|
||||
name: bad-agent
|
||||
description: bad
|
||||
tools: Read, NotARealTool
|
||||
modelId: sonnet
|
||||
---
|
||||
|
||||
Prompt body`
|
||||
|
||||
assert.throws(() => parseAgentConfigFromYaml(content), /Unknown tool/)
|
||||
})
|
||||
|
||||
it("returns an empty config map when the agents directory does not exist", async () => {
|
||||
const tempHome = await createTempHomeDir()
|
||||
tempDirs.push(tempHome)
|
||||
|
||||
const result = await readAgentConfigsFromDisk(tempHome)
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
|
||||
it("loads all yaml/yml files from homeDir/.cline/data/agents", async () => {
|
||||
const tempHome = await createTempHomeDir()
|
||||
tempDirs.push(tempHome)
|
||||
|
||||
const directoryPath = getAgentsConfigPath(tempHome)
|
||||
await fs.mkdir(directoryPath, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(directoryPath, "local-agent.yaml"),
|
||||
`---
|
||||
name: local-agent
|
||||
description: local agent
|
||||
tools: read_file
|
||||
modelId: sonnet
|
||||
---
|
||||
|
||||
Prompt body`,
|
||||
"utf8",
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(directoryPath, "reviewer.yml"),
|
||||
`---
|
||||
name: reviewer
|
||||
description: reviewer agent
|
||||
tools: list_files
|
||||
modelId: sonnet
|
||||
---
|
||||
|
||||
Reviewer prompt`,
|
||||
"utf8",
|
||||
)
|
||||
await fs.writeFile(path.join(directoryPath, "ignored.txt"), "not yaml", "utf8")
|
||||
|
||||
const loader = AgentConfigLoader.getInstance(tempHome)
|
||||
await loader.load()
|
||||
|
||||
const localAgent = loader.getCachedConfig("local-agent")
|
||||
const reviewer = loader.getCachedConfig("reviewer")
|
||||
assert.equal(localAgent?.name, "local-agent")
|
||||
assert.deepEqual(localAgent?.tools, [ClineDefaultTool.FILE_READ])
|
||||
assert.equal(localAgent?.systemPrompt, "Prompt body")
|
||||
assert.equal(reviewer?.name, "reviewer")
|
||||
assert.deepEqual(reviewer?.tools, [ClineDefaultTool.LIST_FILES])
|
||||
assert.equal(loader.getAllCachedConfigs().size, 2)
|
||||
})
|
||||
|
||||
it("creates dynamic subagent tool mappings after loading configs", async () => {
|
||||
const tempHome = await createTempHomeDir()
|
||||
tempDirs.push(tempHome)
|
||||
|
||||
const directoryPath = getAgentsConfigPath(tempHome)
|
||||
await fs.mkdir(directoryPath, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(directoryPath, "code-reviewer.yaml"),
|
||||
`---
|
||||
name: code reviewer
|
||||
description: reviewer agent
|
||||
tools: read_file
|
||||
modelId: sonnet
|
||||
---
|
||||
|
||||
Reviewer prompt`,
|
||||
"utf8",
|
||||
)
|
||||
|
||||
const loader = AgentConfigLoader.getInstance(tempHome)
|
||||
await loader.load()
|
||||
|
||||
const withToolNames = loader.getAllCachedConfigsWithToolNames()
|
||||
assert.equal(withToolNames.length, 1)
|
||||
assert.equal(withToolNames[0].config.name, "code reviewer")
|
||||
assert.equal(loader.resolveSubagentNameForTool(withToolNames[0].toolName), "code reviewer")
|
||||
assert.equal(loader.isDynamicSubagentTool(withToolNames[0].toolName), true)
|
||||
assert.ok(getToolUseNames().includes(withToolNames[0].toolName))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import * as api from "@core/api"
|
||||
import { PromptRegistry } from "@core/prompts/system-prompt"
|
||||
import { ClineToolSet } from "@core/prompts/system-prompt/registry/ClineToolSet"
|
||||
import type { TaskConfig } from "@core/task/tools/types/TaskConfig"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { AgentConfigLoader } from "../AgentConfigLoader"
|
||||
import { SUBAGENT_DEFAULT_ALLOWED_TOOLS, SUBAGENT_SYSTEM_SUFFIX, SubagentBuilder } from "../SubagentBuilder"
|
||||
|
||||
function createTaskConfig(mode: "act" | "plan", provider: string): TaskConfig {
|
||||
return {
|
||||
ulid: "ulid-123",
|
||||
services: {
|
||||
stateManager: {
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? mode : undefined),
|
||||
getApiConfiguration: () => ({
|
||||
actModeApiProvider: provider,
|
||||
planModeApiProvider: provider,
|
||||
actModeApiModelId: "act-default",
|
||||
planModeApiModelId: "plan-default",
|
||||
actModeOpenAiModelId: "openai-act-default",
|
||||
planModeOpenRouterModelId: "openrouter-plan-default",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as TaskConfig
|
||||
}
|
||||
|
||||
describe("SubagentBuilder", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it("uses cached config by subagent name and applies act-mode provider model override", () => {
|
||||
sinon.stub(AgentConfigLoader, "getInstance").returns({
|
||||
getCachedConfig: (subagentName?: string) =>
|
||||
subagentName === "cached-agent"
|
||||
? {
|
||||
name: "cached-agent",
|
||||
description: "cached description",
|
||||
tools: [ClineDefaultTool.LIST_FILES],
|
||||
modelId: "gpt-5",
|
||||
systemPrompt: "cached system prompt",
|
||||
}
|
||||
: undefined,
|
||||
} as unknown as AgentConfigLoader)
|
||||
|
||||
const fakeHandler = { getModel: sinon.stub(), createMessage: sinon.stub() }
|
||||
const buildApiHandlerStub = sinon.stub(api, "buildApiHandler").returns(fakeHandler as never)
|
||||
|
||||
const builder = new SubagentBuilder(createTaskConfig("act", "openai"), "cached-agent")
|
||||
|
||||
assert.equal(buildApiHandlerStub.callCount, 1)
|
||||
const [effectiveApiConfig, selectedMode] = buildApiHandlerStub.firstCall.args
|
||||
assert.equal(selectedMode, "act")
|
||||
assert.equal((effectiveApiConfig as Record<string, unknown>).ulid, "ulid-123")
|
||||
assert.equal((effectiveApiConfig as Record<string, unknown>).actModeOpenAiModelId, "gpt-5")
|
||||
assert.equal((effectiveApiConfig as Record<string, unknown>).actModeApiModelId, "act-default")
|
||||
|
||||
assert.deepEqual(builder.getAllowedTools(), [ClineDefaultTool.LIST_FILES, ClineDefaultTool.ATTEMPT])
|
||||
const prompt = builder.buildSystemPrompt("generated system prompt")
|
||||
assert.match(prompt, /# Agent Profile/)
|
||||
assert.match(prompt, /Name: cached-agent/)
|
||||
assert.match(prompt, /Description: cached description/)
|
||||
assert.match(prompt, /cached system prompt/)
|
||||
assert.match(prompt, new RegExp(SUBAGENT_SYSTEM_SUFFIX.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
||||
})
|
||||
|
||||
it("uses defaults when no cached config is provided", () => {
|
||||
sinon.stub(AgentConfigLoader, "getInstance").returns({
|
||||
getCachedConfig: () => undefined,
|
||||
} as unknown as AgentConfigLoader)
|
||||
|
||||
sinon.stub(api, "buildApiHandler").returns({ getModel: sinon.stub(), createMessage: sinon.stub() } as never)
|
||||
const builder = new SubagentBuilder(createTaskConfig("act", "anthropic"))
|
||||
|
||||
assert.deepEqual(builder.getAllowedTools(), SUBAGENT_DEFAULT_ALLOWED_TOOLS)
|
||||
const prompt = builder.buildSystemPrompt("generated prompt")
|
||||
assert.equal(prompt, `generated prompt${SUBAGENT_SYSTEM_SUFFIX}`)
|
||||
})
|
||||
|
||||
it("applies plan-mode openrouter model override fields", () => {
|
||||
sinon.stub(AgentConfigLoader, "getInstance").returns({
|
||||
getCachedConfig: (subagentName?: string) =>
|
||||
subagentName === "openrouter-agent"
|
||||
? {
|
||||
name: "openrouter-agent",
|
||||
description: "openrouter plan agent",
|
||||
tools: [ClineDefaultTool.FILE_READ],
|
||||
modelId: "openrouter/custom-model",
|
||||
systemPrompt: "plan system",
|
||||
}
|
||||
: undefined,
|
||||
} as unknown as AgentConfigLoader)
|
||||
|
||||
const buildApiHandlerStub = sinon.stub(api, "buildApiHandler").returns({
|
||||
getModel: sinon.stub(),
|
||||
createMessage: sinon.stub(),
|
||||
} as never)
|
||||
|
||||
new SubagentBuilder(createTaskConfig("plan", "openrouter"), "openrouter-agent")
|
||||
|
||||
const [effectiveApiConfig, selectedMode] = buildApiHandlerStub.firstCall.args
|
||||
assert.equal(selectedMode, "plan")
|
||||
assert.equal((effectiveApiConfig as Record<string, unknown>).planModeOpenRouterModelId, "openrouter/custom-model")
|
||||
assert.equal((effectiveApiConfig as Record<string, unknown>).planModeApiModelId, "plan-default")
|
||||
assert.equal((effectiveApiConfig as Record<string, unknown>).actModeApiModelId, "act-default")
|
||||
})
|
||||
|
||||
it("builds native tools by filtering allowed ids and context requirements then converting", () => {
|
||||
sinon.stub(AgentConfigLoader, "getInstance").returns({
|
||||
getCachedConfig: (subagentName?: string) =>
|
||||
subagentName === "tools-agent"
|
||||
? {
|
||||
name: "tools-agent",
|
||||
description: "tool-limited",
|
||||
tools: [ClineDefaultTool.LIST_FILES],
|
||||
modelId: "sonnet",
|
||||
systemPrompt: "tool prompt",
|
||||
}
|
||||
: undefined,
|
||||
} as unknown as AgentConfigLoader)
|
||||
sinon.stub(api, "buildApiHandler").returns({ getModel: sinon.stub(), createMessage: sinon.stub() } as never)
|
||||
|
||||
const getModelFamilyStub = sinon.stub(PromptRegistry.getInstance(), "getModelFamily").returns("test-family" as never)
|
||||
const getToolsStub = sinon.stub(ClineToolSet, "getToolsForVariantWithFallback").returns([
|
||||
{
|
||||
config: {
|
||||
id: ClineDefaultTool.LIST_FILES,
|
||||
contextRequirements: () => true,
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
id: ClineDefaultTool.SEARCH,
|
||||
contextRequirements: () => true,
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
id: ClineDefaultTool.ATTEMPT,
|
||||
contextRequirements: () => false,
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
const converter = sinon.stub().callsFake((tool: { id: string }) => ({ converted: tool.id }))
|
||||
const getConverterStub = sinon.stub(ClineToolSet, "getNativeConverter").returns(converter as never)
|
||||
|
||||
const builder = new SubagentBuilder(createTaskConfig("act", "anthropic"), "tools-agent")
|
||||
|
||||
const context = {
|
||||
providerInfo: {
|
||||
providerId: "anthropic",
|
||||
model: { id: "m1" },
|
||||
},
|
||||
} as never
|
||||
|
||||
const result = builder.buildNativeTools(context)
|
||||
assert.equal(getModelFamilyStub.callCount, 1)
|
||||
assert.equal(getToolsStub.callCount, 1)
|
||||
assert.equal(getConverterStub.callCount, 1)
|
||||
assert.deepEqual(result, [{ converted: ClineDefaultTool.LIST_FILES }])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import * as coreApi from "@core/api"
|
||||
import { ContextManager } from "@core/context/context-management/ContextManager"
|
||||
import * as skills from "@core/context/instructions/user-instructions/skills"
|
||||
import { PromptRegistry } from "@core/prompts/system-prompt"
|
||||
import type { TaskConfig } from "@core/task/tools/types/TaskConfig"
|
||||
@@ -10,6 +9,7 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { TaskState } from "../../../TaskState"
|
||||
import { SubagentBuilder } from "../SubagentBuilder"
|
||||
import { SubagentRunner } from "../SubagentRunner"
|
||||
|
||||
function initializeHostProvider() {
|
||||
@@ -35,16 +35,7 @@ function initializeHostProvider() {
|
||||
)
|
||||
}
|
||||
|
||||
function createTaskConfig(
|
||||
nativeToolCallEnabled: boolean,
|
||||
options?: {
|
||||
useAutoCondense?: boolean
|
||||
autoCondenseThreshold?: number
|
||||
},
|
||||
): TaskConfig {
|
||||
const useAutoCondense = options?.useAutoCondense ?? false
|
||||
const autoCondenseThreshold = options?.autoCondenseThreshold ?? 0.75
|
||||
|
||||
function createTaskConfig(nativeToolCallEnabled: boolean): TaskConfig {
|
||||
return {
|
||||
taskId: "task-1",
|
||||
ulid: "ulid-1",
|
||||
@@ -52,6 +43,7 @@ function createTaskConfig(
|
||||
mode: "act",
|
||||
strictPlanModeEnabled: false,
|
||||
yoloModeToggled: false,
|
||||
doubleCheckCompletionEnabled: false,
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
enableParallelToolCalling: false,
|
||||
isSubagentExecution: false,
|
||||
@@ -67,6 +59,7 @@ function createTaskConfig(
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage: sinon.stub().callsFake(async function* () {}),
|
||||
},
|
||||
services: {
|
||||
stateManager: {
|
||||
@@ -77,12 +70,6 @@ function createTaskConfig(
|
||||
if (key === "customPrompt") {
|
||||
return undefined
|
||||
}
|
||||
if (key === "useAutoCondense") {
|
||||
return useAutoCondense
|
||||
}
|
||||
if (key === "autoCondenseThreshold") {
|
||||
return autoCondenseThreshold
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
getGlobalStateKey: (key: string) => (key === "nativeToolCallEnabled" ? nativeToolCallEnabled : undefined),
|
||||
@@ -137,6 +124,21 @@ function createTaskConfig(
|
||||
} as unknown as TaskConfig
|
||||
}
|
||||
|
||||
function stubApiHandler(createMessage: sinon.SinonStub) {
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
} as never)
|
||||
}
|
||||
|
||||
describe("SubagentRunner", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
@@ -163,19 +165,16 @@ describe("SubagentRunner", () => {
|
||||
content: Array<{ type?: string; [key: string]: unknown }>
|
||||
}
|
||||
assert.equal(assistantMessage.role, "assistant")
|
||||
assert.ok(Array.isArray(assistantMessage.content))
|
||||
|
||||
const toolUse = assistantMessage.content.find((block) => block.type === "tool_use")
|
||||
assert.ok(toolUse, "assistant message should include tool_use block")
|
||||
assert.ok(toolUse)
|
||||
assert.equal(toolUse.id, "toolu_subagent_1")
|
||||
assert.equal(toolUse.name, ClineDefaultTool.LIST_FILES)
|
||||
|
||||
const userMessage = conversation[2] as { role: string; content: Array<{ type?: string; [key: string]: unknown }> }
|
||||
assert.equal(userMessage.role, "user")
|
||||
assert.ok(Array.isArray(userMessage.content))
|
||||
|
||||
const toolResult = userMessage.content.find((block) => block.type === "tool_result")
|
||||
assert.ok(toolResult, "user message should include tool_result block")
|
||||
assert.ok(toolResult)
|
||||
assert.equal(toolResult.tool_use_id, "toolu_subagent_1")
|
||||
|
||||
yield {
|
||||
@@ -195,29 +194,13 @@ describe("SubagentRunner", () => {
|
||||
promptRegistry.nativeTools = [{ name: "list_files" } as any]
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(SubagentBuilder.prototype, "buildNativeTools").returns([{ name: "list_files" }] as any)
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
stubApiHandler(createMessage)
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(true)
|
||||
|
||||
const runner = new SubagentRunner(config)
|
||||
sinon
|
||||
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
|
||||
.returns([{ name: "list_files" }])
|
||||
|
||||
const runner = new SubagentRunner(createTaskConfig(true))
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
@@ -246,7 +229,6 @@ describe("SubagentRunner", () => {
|
||||
}
|
||||
|
||||
assert.equal(lastMessage.role, "user")
|
||||
assert.ok(Array.isArray(lastMessage.content))
|
||||
assert.ok(lastMessage.content.every((block) => block.type === "text"))
|
||||
assert.equal(
|
||||
lastMessage.content.some((block) => block.type === "tool_result"),
|
||||
@@ -254,8 +236,14 @@ describe("SubagentRunner", () => {
|
||||
)
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: "<attempt_completion><result>done</result></attempt_completion>",
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_complete_2",
|
||||
name: ClineDefaultTool.ATTEMPT,
|
||||
arguments: JSON.stringify({ result: "done" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -264,25 +252,12 @@ describe("SubagentRunner", () => {
|
||||
promptRegistry.nativeTools = undefined
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
stubApiHandler(createMessage)
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(false)
|
||||
const runner = new SubagentRunner(config)
|
||||
|
||||
const runner = new SubagentRunner(createTaskConfig(false))
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
@@ -292,9 +267,7 @@ describe("SubagentRunner", () => {
|
||||
|
||||
it("retries empty assistant turns with a no-tools-used nudge before failing", async () => {
|
||||
const createMessage = sinon.stub()
|
||||
createMessage.onFirstCall().callsFake(async function* () {
|
||||
// Empty response turn
|
||||
})
|
||||
createMessage.onFirstCall().callsFake(async function* () {})
|
||||
createMessage.onSecondCall().callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
|
||||
const lastAssistant = conversation[1] as {
|
||||
role: string
|
||||
@@ -313,8 +286,14 @@ describe("SubagentRunner", () => {
|
||||
assert.match(lastUser.content[0]?.text || "", /You did not use a tool in your previous response/i)
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: "<attempt_completion><result>done</result></attempt_completion>",
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_complete_3",
|
||||
name: ClineDefaultTool.ATTEMPT,
|
||||
arguments: JSON.stringify({ result: "done" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -323,25 +302,12 @@ describe("SubagentRunner", () => {
|
||||
promptRegistry.nativeTools = undefined
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
stubApiHandler(createMessage)
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(false)
|
||||
const runner = new SubagentRunner(config)
|
||||
|
||||
const runner = new SubagentRunner(createTaskConfig(false))
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
@@ -349,7 +315,7 @@ describe("SubagentRunner", () => {
|
||||
assert.equal(createMessage.callCount, 2)
|
||||
})
|
||||
|
||||
it("retries initial stream failures before failing the subagent", async () => {
|
||||
it("retries initial stream failures before failing", async () => {
|
||||
const createMessage = sinon.stub()
|
||||
createMessage.onFirstCall().callsFake(async function* () {
|
||||
yield* []
|
||||
@@ -358,10 +324,16 @@ describe("SubagentRunner", () => {
|
||||
)
|
||||
})
|
||||
createMessage.onSecondCall().callsFake(async function* () {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "<attempt_completion><result>done</result></attempt_completion>",
|
||||
}
|
||||
yield* []
|
||||
throw new Error(
|
||||
'{"code":"stream_initialization_failed","message":"Failed to create stream: failed to generate stream from Vercel: failed to send request"}',
|
||||
)
|
||||
})
|
||||
createMessage.onThirdCall().callsFake(async function* () {
|
||||
yield* []
|
||||
throw new Error(
|
||||
'{"code":"stream_initialization_failed","message":"Failed to create stream: failed to generate stream from Vercel: failed to send request"}',
|
||||
)
|
||||
})
|
||||
|
||||
const promptRegistry = PromptRegistry.getInstance()
|
||||
@@ -369,119 +341,19 @@ describe("SubagentRunner", () => {
|
||||
promptRegistry.nativeTools = undefined
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
stubApiHandler(createMessage)
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(false)
|
||||
const runner = new SubagentRunner(config)
|
||||
|
||||
const runner = new SubagentRunner(createTaskConfig(false))
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
assert.equal(result.result, "done")
|
||||
assert.equal(createMessage.callCount, 2)
|
||||
assert.equal(result.status, "failed")
|
||||
assert.equal(createMessage.callCount, 3)
|
||||
})
|
||||
|
||||
it("compacts context and retries when initial stream fails with context window exceeded", async () => {
|
||||
const createMessage = sinon.stub()
|
||||
let compactedConversation: unknown[] | undefined
|
||||
let preCompactionLength = 0
|
||||
createMessage.onCall(0).callsFake(async function* () {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_ctx_1",
|
||||
name: ClineDefaultTool.LIST_FILES,
|
||||
arguments: JSON.stringify({ path: ".", recursive: false }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
createMessage.onCall(1).callsFake(async function* () {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_ctx_2",
|
||||
name: ClineDefaultTool.LIST_FILES,
|
||||
arguments: JSON.stringify({ path: ".", recursive: false }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
createMessage.onCall(2).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
|
||||
preCompactionLength = conversation.length
|
||||
yield* []
|
||||
const contextError = new Error("context length exceeded")
|
||||
;(contextError as Error & { status: number }).status = 400
|
||||
throw contextError
|
||||
})
|
||||
createMessage.onCall(3).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
|
||||
compactedConversation = conversation
|
||||
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_ctx_complete",
|
||||
name: ClineDefaultTool.ATTEMPT,
|
||||
arguments: JSON.stringify({ result: "done" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const promptRegistry = PromptRegistry.getInstance()
|
||||
sinon.stub(promptRegistry, "get").callsFake(async () => {
|
||||
promptRegistry.nativeTools = [{ name: "list_files" } as any]
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(true)
|
||||
const runner = new SubagentRunner(config)
|
||||
sinon
|
||||
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
|
||||
.returns([{ name: "list_files" }])
|
||||
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
assert.equal(result.result, "done")
|
||||
assert.equal(createMessage.callCount, 4)
|
||||
assert.ok(compactedConversation)
|
||||
assert.ok(compactedConversation.length < preCompactionLength)
|
||||
})
|
||||
|
||||
it("fails context window errors when there is no compactable subagent context", async () => {
|
||||
it("fails context window errors", async () => {
|
||||
const createMessage = sinon.stub()
|
||||
createMessage.onFirstCall().callsFake(async function* () {
|
||||
yield* []
|
||||
@@ -495,217 +367,25 @@ describe("SubagentRunner", () => {
|
||||
promptRegistry.nativeTools = undefined
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
stubApiHandler(createMessage)
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(false)
|
||||
const runner = new SubagentRunner(config)
|
||||
|
||||
const runner = new SubagentRunner(createTaskConfig(false))
|
||||
const result = await runner.run("Huge prompt", () => {})
|
||||
|
||||
assert.equal(result.status, "failed")
|
||||
assert.equal(createMessage.callCount, 1)
|
||||
})
|
||||
|
||||
it("proactively compacts before next request when prior usage exceeds threshold", async () => {
|
||||
const createMessage = sinon.stub()
|
||||
let postCompactionConversationLength = 0
|
||||
|
||||
createMessage.onCall(0).callsFake(async function* () {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 160_000,
|
||||
outputTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
}
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_threshold_1",
|
||||
name: ClineDefaultTool.LIST_FILES,
|
||||
arguments: JSON.stringify({ path: ".", recursive: false }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
createMessage.onCall(1).callsFake(async function* () {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 160_000,
|
||||
outputTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
}
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_threshold_2",
|
||||
name: ClineDefaultTool.LIST_FILES,
|
||||
arguments: JSON.stringify({ path: ".", recursive: false }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
createMessage.onCall(2).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
|
||||
postCompactionConversationLength = conversation.length
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_threshold_complete",
|
||||
name: ClineDefaultTool.ATTEMPT,
|
||||
arguments: JSON.stringify({ result: "done" }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const promptRegistry = PromptRegistry.getInstance()
|
||||
sinon.stub(promptRegistry, "get").callsFake(async () => {
|
||||
promptRegistry.nativeTools = undefined
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(false, { useAutoCondense: true, autoCondenseThreshold: 0.75 })
|
||||
const runner = new SubagentRunner(config)
|
||||
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
assert.equal(result.result, "done")
|
||||
assert.equal(createMessage.callCount, 3)
|
||||
assert.equal(postCompactionConversationLength, 3)
|
||||
})
|
||||
|
||||
it("skips truncation when file-read optimization is sufficient", () => {
|
||||
const config = createTaskConfig(false)
|
||||
const runner = new SubagentRunner(config)
|
||||
const conversation = [{ role: "user", content: [{ type: "text", text: "hello" }] }] as any[]
|
||||
|
||||
const optimizeStub = sinon
|
||||
.stub(
|
||||
runner as unknown as {
|
||||
optimizeConversationForContextWindow: () => { didOptimize: boolean; needToTruncate: boolean }
|
||||
},
|
||||
"optimizeConversationForContextWindow",
|
||||
)
|
||||
.returns({ didOptimize: true, needToTruncate: false })
|
||||
const getNextTruncationRangeSpy = sinon.spy(ContextManager.prototype, "getNextTruncationRange")
|
||||
|
||||
const didCompact = (
|
||||
runner as unknown as { compactConversationForContextWindow: (value: unknown[]) => boolean }
|
||||
).compactConversationForContextWindow(conversation)
|
||||
|
||||
assert.equal(didCompact, true)
|
||||
assert.equal(optimizeStub.calledOnce, true)
|
||||
assert.equal(getNextTruncationRangeSpy.called, false)
|
||||
})
|
||||
|
||||
it("falls back to non-native mode when native settings are enabled but variant has no native tools", async () => {
|
||||
const createMessage = sinon.stub()
|
||||
createMessage.onFirstCall().callsFake(async function* () {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_3",
|
||||
name: ClineDefaultTool.LIST_FILES,
|
||||
arguments: JSON.stringify({ path: ".", recursive: false }),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
createMessage.onSecondCall().callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
|
||||
const lastMessage = conversation[conversation.length - 1] as {
|
||||
role: string
|
||||
content: Array<{ type?: string; [key: string]: unknown }>
|
||||
}
|
||||
|
||||
assert.equal(lastMessage.role, "user")
|
||||
assert.ok(Array.isArray(lastMessage.content))
|
||||
assert.ok(lastMessage.content.every((block) => block.type === "text"))
|
||||
assert.equal(
|
||||
lastMessage.content.some((block) => block.type === "tool_result"),
|
||||
false,
|
||||
)
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: "<attempt_completion><result>done</result></attempt_completion>",
|
||||
}
|
||||
})
|
||||
|
||||
const promptRegistry = PromptRegistry.getInstance()
|
||||
sinon.stub(promptRegistry, "get").callsFake(async () => {
|
||||
promptRegistry.nativeTools = undefined
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(true)
|
||||
const runner = new SubagentRunner(config)
|
||||
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
assert.equal(result.result, "done")
|
||||
assert.equal(createMessage.callCount, 2)
|
||||
})
|
||||
|
||||
it("builds subagent api handler with the parent task ulid", async () => {
|
||||
it("uses the configured task api handler for subagent requests", async () => {
|
||||
const createMessage = sinon.stub().callsFake(async function* () {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
function: {
|
||||
id: "toolu_subagent_complete_2",
|
||||
id: "toolu_subagent_complete_4",
|
||||
name: ClineDefaultTool.ATTEMPT,
|
||||
arguments: JSON.stringify({ result: "done" }),
|
||||
},
|
||||
@@ -718,33 +398,17 @@ describe("SubagentRunner", () => {
|
||||
promptRegistry.nativeTools = [{ name: "list_files" } as any]
|
||||
return "system prompt"
|
||||
})
|
||||
const buildApiHandlerStub = sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(SubagentBuilder.prototype, "buildNativeTools").returns([{ name: "list_files" }] as any)
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
stubApiHandler(createMessage)
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(true)
|
||||
const runner = new SubagentRunner(config)
|
||||
sinon
|
||||
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
|
||||
.returns([{ name: "list_files" }])
|
||||
|
||||
const runner = new SubagentRunner(createTaskConfig(true))
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
assert.equal(buildApiHandlerStub.called, true)
|
||||
sinon.assert.calledWithMatch(buildApiHandlerStub, sinon.match({ ulid: "ulid-1" }), "act")
|
||||
assert.equal(createMessage.callCount, 1)
|
||||
})
|
||||
|
||||
it("includes workspace metadata only in the initial user message", async () => {
|
||||
@@ -801,28 +465,13 @@ describe("SubagentRunner", () => {
|
||||
promptRegistry.nativeTools = [{ name: "list_files" } as any]
|
||||
return "system prompt"
|
||||
})
|
||||
sinon.stub(coreApi, "buildApiHandler").returns({
|
||||
abort: sinon.stub(),
|
||||
getModel: () => ({
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: {
|
||||
contextWindow: 200_000,
|
||||
apiFormat: ApiFormat.ANTHROPIC_CHAT,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}),
|
||||
createMessage,
|
||||
})
|
||||
sinon.stub(SubagentBuilder.prototype, "buildNativeTools").returns([{ name: "list_files" }] as any)
|
||||
sinon.stub(skills, "discoverSkills").resolves([])
|
||||
sinon.stub(skills, "getAvailableSkills").returns([])
|
||||
stubApiHandler(createMessage)
|
||||
initializeHostProvider()
|
||||
|
||||
const config = createTaskConfig(true)
|
||||
const runner = new SubagentRunner(config)
|
||||
sinon
|
||||
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
|
||||
.returns([{ name: "list_files" }])
|
||||
|
||||
const runner = new SubagentRunner(createTaskConfig(true))
|
||||
const result = await runner.run("List files", () => {})
|
||||
|
||||
assert.equal(result.status, "completed")
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { ClineContent } from "@shared/messages/content"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { ClineDefaultTool } from "@shared/tools"
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import * as vscode from "vscode"
|
||||
import { WorkspaceRootManager } from "@/core/workspace"
|
||||
import type { ContextManager } from "../../../context/context-management/ContextManager"
|
||||
import type { StateManager } from "../../../storage/StateManager"
|
||||
@@ -41,7 +40,6 @@ export interface TaskConfig {
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal" | "backgroundExec"
|
||||
enableParallelToolCalling: boolean
|
||||
isSubagentExecution: boolean
|
||||
context: vscode.ExtensionContext
|
||||
|
||||
// Multi-workspace support (optional for backward compatibility)
|
||||
workspaceManager?: WorkspaceRootManager
|
||||
|
||||
@@ -62,11 +62,11 @@ export class PatchParser {
|
||||
private parseNextAction(): void {
|
||||
const line = this.lines[this.index]
|
||||
|
||||
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
|
||||
if (line?.startsWith(PATCH_MARKERS.UPDATE)) {
|
||||
this.parseUpdate(line.substring(PATCH_MARKERS.UPDATE.length).trim())
|
||||
} else if (line.startsWith(PATCH_MARKERS.DELETE)) {
|
||||
} else if (line?.startsWith(PATCH_MARKERS.DELETE)) {
|
||||
this.parseDelete(line.substring(PATCH_MARKERS.DELETE.length).trim())
|
||||
} else if (line.startsWith(PATCH_MARKERS.ADD)) {
|
||||
} else if (line?.startsWith(PATCH_MARKERS.ADD)) {
|
||||
this.parseAdd(line.substring(PATCH_MARKERS.ADD.length).trim())
|
||||
} else {
|
||||
throw new DiffError(`Unknown line while parsing: ${line}`)
|
||||
@@ -85,14 +85,14 @@ export class PatchParser {
|
||||
|
||||
this.index++
|
||||
const movePath = this.lines[this.index]?.startsWith(PATCH_MARKERS.MOVE)
|
||||
? this.lines[this.index++].substring(PATCH_MARKERS.MOVE.length).trim()
|
||||
? (this.lines[this.index++] ?? "").substring(PATCH_MARKERS.MOVE.length).trim()
|
||||
: undefined
|
||||
|
||||
if (!(path in this.currentFiles)) {
|
||||
throw new DiffError(`Update File Error: Missing File: ${path}`)
|
||||
}
|
||||
|
||||
const text = this.currentFiles[path]!
|
||||
const text = this.currentFiles[path] ?? ""
|
||||
const action = this.parseUpdateFile(text, path)
|
||||
action.movePath = movePath
|
||||
|
||||
@@ -114,8 +114,9 @@ export class PatchParser {
|
||||
]
|
||||
|
||||
while (!stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
|
||||
const defStr = this.lines[this.index]?.startsWith("@@ ") ? this.lines[this.index]!.substring(3) : undefined
|
||||
const sectionStr = this.lines[this.index] === "@@" ? this.lines[this.index] : undefined
|
||||
const currentLine = this.lines[this.index]
|
||||
const defStr = currentLine?.startsWith("@@ ") ? currentLine.substring(3) : undefined
|
||||
const sectionStr = currentLine === "@@" ? currentLine : undefined
|
||||
|
||||
if (defStr !== undefined || sectionStr !== undefined) {
|
||||
this.index++
|
||||
@@ -127,9 +128,10 @@ export class PatchParser {
|
||||
if (defStr?.trim()) {
|
||||
const canonDefStr = canonicalize(defStr.trim())
|
||||
for (let i = index; i < fileLines.length; i++) {
|
||||
if (canonicalize(fileLines[i]!) === canonDefStr || canonicalize(fileLines[i]!.trim()) === canonDefStr) {
|
||||
const fileLine = fileLines[i]
|
||||
if (fileLine && (canonicalize(fileLine) === canonDefStr || canonicalize(fileLine.trim()) === canonDefStr)) {
|
||||
index = i + 1
|
||||
if (canonicalize(fileLines[i]!.trim()) === canonDefStr && canonicalize(fileLines[i]!) !== canonDefStr) {
|
||||
if (canonicalize(fileLine.trim()) === canonDefStr && canonicalize(fileLine) !== canonDefStr) {
|
||||
this.fuzz++
|
||||
}
|
||||
break
|
||||
@@ -192,8 +194,11 @@ export class PatchParser {
|
||||
|
||||
const stopMarkers = [PATCH_MARKERS.END, PATCH_MARKERS.UPDATE, PATCH_MARKERS.DELETE, PATCH_MARKERS.ADD]
|
||||
|
||||
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index].startsWith(m.trim()))) {
|
||||
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
|
||||
const line = this.lines[this.index++]
|
||||
if (line === undefined) {
|
||||
break
|
||||
}
|
||||
if (!line.startsWith("+")) {
|
||||
throw new DiffError(`Invalid Add File line (missing '+'): ${line}`)
|
||||
}
|
||||
@@ -222,31 +227,29 @@ function calculateSimilarity(str1: string, str2: string): number {
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix: number[][] = []
|
||||
const rows = str2.length + 1
|
||||
const cols = str1.length + 1
|
||||
const matrix = new Array<number>(rows * cols).fill(0) // avoid undefined access with flat array
|
||||
|
||||
for (let i = 0; i <= str2.length; i++) {
|
||||
matrix[i] = [i]
|
||||
const at = (r: number, c: number): number => matrix[r * cols + c] ?? 0
|
||||
const set = (r: number, c: number, v: number) => {
|
||||
matrix[r * cols + c] = v
|
||||
}
|
||||
|
||||
for (let j = 0; j <= str1.length; j++) {
|
||||
matrix[0]![j] = j
|
||||
}
|
||||
for (let i = 0; i <= str2.length; i++) set(i, 0, i)
|
||||
for (let j = 0; j <= str1.length; j++) set(0, j, j)
|
||||
|
||||
for (let i = 1; i <= str2.length; i++) {
|
||||
for (let j = 1; j <= str1.length; j++) {
|
||||
if (str2[i - 1] === str1[j - 1]) {
|
||||
matrix[i]![j] = matrix[i - 1]![j - 1]!
|
||||
set(i, j, at(i - 1, j - 1))
|
||||
} else {
|
||||
matrix[i]![j] = Math.min(
|
||||
matrix[i - 1]![j - 1]! + 1, // substitution
|
||||
matrix[i]![j - 1]! + 1, // insertion
|
||||
matrix[i - 1]![j]! + 1, // deletion
|
||||
)
|
||||
set(i, j, 1 + Math.min(at(i - 1, j - 1), at(i, j - 1), at(i - 1, j)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length]![str1.length]!
|
||||
return at(str2.length, str1.length)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,8 +359,8 @@ function peek(lines: string[], initialIndex: number): PeekResult {
|
||||
]
|
||||
|
||||
while (index < lines.length) {
|
||||
const s = lines[index]!
|
||||
if (stopMarkers.some((m) => s.startsWith(m.trim()))) {
|
||||
const s = lines[index]
|
||||
if (!s || stopMarkers.some((m) => s.startsWith(m.trim()))) {
|
||||
break
|
||||
}
|
||||
if (s === "***") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user