diff --git a/cli/src/constants/providers/labels.ts b/cli/src/constants/providers/labels.ts index 27e0b73e245..cb837a7ae3a 100644 --- a/cli/src/constants/providers/labels.ts +++ b/cli/src/constants/providers/labels.ts @@ -49,6 +49,7 @@ export const PROVIDER_LABELS: Record = { inception: "Inception", synthetic: "Synthetic", "sap-ai-core": "SAP AI Core", + intelligent: "Intelligent Provider", baseten: "BaseTen", } diff --git a/cli/src/constants/providers/models.ts b/cli/src/constants/providers/models.ts index ba338675f4b..57e5e78e9a1 100644 --- a/cli/src/constants/providers/models.ts +++ b/cli/src/constants/providers/models.ts @@ -164,6 +164,7 @@ export const PROVIDER_TO_ROUTER_NAME: Record = inception: null, synthetic: null, "sap-ai-core": null, + intelligent: null, baseten: null, } @@ -216,6 +217,7 @@ export const PROVIDER_MODEL_FIELD: Record = { inception: "inceptionLabsModelId", synthetic: null, "sap-ai-core": "sapAiCoreModelId", + intelligent: null, baseten: null, } diff --git a/cli/src/constants/providers/settings.ts b/cli/src/constants/providers/settings.ts index f75af199fd1..08c9bcae7ca 100644 --- a/cli/src/constants/providers/settings.ts +++ b/cli/src/constants/providers/settings.ts @@ -1095,6 +1095,7 @@ export const PROVIDER_DEFAULT_MODELS: Record = { inception: "gpt-4o", synthetic: "synthetic-model", "sap-ai-core": "gpt-4o", + intelligent: "intelligent", baseten: "zai-org/GLM-4.6", } diff --git a/cli/src/constants/providers/validation.ts b/cli/src/constants/providers/validation.ts index 27926585f08..64f747053cb 100644 --- a/cli/src/constants/providers/validation.ts +++ b/cli/src/constants/providers/validation.ts @@ -49,6 +49,7 @@ export const PROVIDER_REQUIRED_FIELDS: Record = { vertex: [], // Has special validation logic (either/or fields) "vscode-lm": [], // Has nested object validation "virtual-quota-fallback": [], // Has array validation + intelligent: [], // Has array validation for profiles minimax: ["minimaxBaseUrl", "minimaxApiKey", "apiModelId"], baseten: ["basetenApiKey", "apiModelId"], } diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index c4136fd6a2c..f1f02f64488 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -155,6 +155,7 @@ export const providerNames = [ "minimax", "gemini-cli", "virtual-quota-fallback", + "intelligent", "synthetic", "inception", // kilocode_change end @@ -478,6 +479,16 @@ export const virtualQuotaFallbackProfileDataSchema = z.object({ const virtualQuotaFallbackSchema = baseProviderSettingsSchema.extend({ profiles: z.array(virtualQuotaFallbackProfileDataSchema).optional(), }) + +export const intelligentProfileSchema = z.object({ + profileName: z.string().optional(), + profileId: z.string().optional(), + difficultyLevel: z.enum(["easy", "medium", "hard", "classifier"]).optional(), +}) + +const intelligentSchema = baseProviderSettingsSchema.extend({ + profiles: z.array(intelligentProfileSchema).optional(), +}) // kilocode_change end export const zaiApiLineSchema = z.enum(["international_coding", "china_coding"]) @@ -570,6 +581,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv geminiCliSchema.merge(z.object({ apiProvider: z.literal("gemini-cli") })), kilocodeSchema.merge(z.object({ apiProvider: z.literal("kilocode") })), virtualQuotaFallbackSchema.merge(z.object({ apiProvider: z.literal("virtual-quota-fallback") })), + intelligentSchema.merge(z.object({ apiProvider: z.literal("intelligent") })), syntheticSchema.merge(z.object({ apiProvider: z.literal("synthetic") })), inceptionSchema.merge(z.object({ apiProvider: z.literal("inception") })), // kilocode_change end @@ -609,6 +621,7 @@ export const providerSettingsSchema = z.object({ ...geminiCliSchema.shape, ...kilocodeSchema.shape, ...virtualQuotaFallbackSchema.shape, + ...intelligentSchema.shape, ...syntheticSchema.shape, ...ovhcloudSchema.shape, ...inceptionSchema.shape, @@ -701,7 +714,7 @@ export const modelIdKeysByProvider: Record = { anthropic: "apiModelId", "claude-code": "apiModelId", glama: "glamaModelId", - "nano-gpt": "nanoGptModelId", // kilocode_change + "nano-gpt": "nanoGptModelId", openrouter: "openRouterModelId", kilocode: "kilocodeModel", bedrock: "apiModelId", @@ -726,6 +739,7 @@ export const modelIdKeysByProvider: Record = { ovhcloud: "ovhCloudAiEndpointsModelId", inception: "inceptionLabsModelId", "sap-ai-core": "sapAiCoreModelId", + intelligent: "apiModelId", // kilocode_change end groq: "apiModelId", baseten: "apiModelId", @@ -886,6 +900,7 @@ export const MODELS_BY_PROVIDER: Record< inception: { id: "inception", label: "Inception", models: [] }, kilocode: { id: "kilocode", label: "Kilocode", models: [] }, "virtual-quota-fallback": { id: "virtual-quota-fallback", label: "Virtual Quota Fallback", models: [] }, + intelligent: { id: "intelligent", label: "Intelligent Provider", models: [] }, // kilocode_change end deepinfra: { id: "deepinfra", label: "DeepInfra", models: [] }, "vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] }, diff --git a/src/api/index.ts b/src/api/index.ts index ed283885705..28207736f79 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -37,6 +37,7 @@ import { SyntheticHandler, OVHcloudAIEndpointsHandler, SapAiCoreHandler, + IntelligentHandler, // kilocode_change end ClaudeCodeHandler, QwenCodeHandler, @@ -109,6 +110,25 @@ export interface ApiHandlerCreateMessageMetadata { * Used by providers to determine whether to include native tool definitions. */ toolProtocol?: ToolProtocol + /** + * Profile ID to use for difficulty classification in IntelligentHandler. + * If not provided, defaults to using the easy handler profile. + */ + classifierProfileId?: string + /** + * Raw user prompt from the UI input, used by IntelligentHandler for difficulty assessment. + */ + rawUserPrompt?: string + /** + * The current user prompt for difficulty assessment in IntelligentHandler. + * Only passed on initial messages to avoid redundant assessments. + */ + userPrompt?: string + /** + * Indicates whether this is the initial message for difficulty assessment. + * IntelligentHandler only assesses difficulty once per user message. + */ + isInitialMessage?: boolean /** * Controls whether the model can return multiple tool calls in a single response. * When true, parallel tool calls are enabled (OpenAI's parallel_tool_calls=true). @@ -240,6 +260,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new FeatherlessHandler(options) case "vercel-ai-gateway": return new VercelAiGatewayHandler(options) + case "intelligent": + return new IntelligentHandler(options) case "minimax": return new MiniMaxHandler(options) case "baseten": diff --git a/src/api/providers/__tests__/intelligent-provider.spec.ts b/src/api/providers/__tests__/intelligent-provider.spec.ts new file mode 100644 index 00000000000..da4eb6602df --- /dev/null +++ b/src/api/providers/__tests__/intelligent-provider.spec.ts @@ -0,0 +1,390 @@ +// kilocode_change - new file +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { Anthropic } from "@anthropic-ai/sdk" +import type { ProviderSettings } from "@roo-code/types" + +// Mocks for dependencies +vi.mock("../../../core/config/ProviderSettingsManager", () => ({ + ProviderSettingsManager: class { + async getProfile() { + // Mock returning a simple Anthropic handler for testing + return { + id: "test-profile", + apiProvider: "anthropic", + apiModelId: "claude-3-sonnet-20240229", + apiKey: "test-key", + } + } + }, +})) + +vi.mock("../../../core/config/ContextProxy", () => { + const instance = { + rawContext: {}, + } + return { + ContextProxy: { + _instance: instance, + get instance() { + return instance + }, + }, + } +}) + +// Mock buildApiHandler function to return a simple mock handler +vi.mock("../../index", () => ({ + buildApiHandler: vi.fn().mockImplementation((config) => ({ + countTokens: vi.fn().mockResolvedValue(10), + createMessage: vi.fn().mockImplementation(function* () { + yield { type: "text", text: "test response" } + }), + getModel: vi.fn().mockReturnValue({ + id: config.apiModelId || "test-model", + info: { + maxTokens: 1000, + contextWindow: 100000, + supportsPromptCache: false, + }, + }), + })), +})) + +// Import IntelligentHandler +import { IntelligentHandler } from "../intelligent" + +describe("IntelligentHandler", () => { + let handler: any + const mockSettings: any = { + id: "intelligent-test", + provider: "intelligent", + profiles: [ + { + profileId: "easy-profile", + profileName: "Easy Profile", + difficultyLevel: "easy", + }, + { + profileId: "medium-profile", + profileName: "Medium Profile", + difficultyLevel: "medium", + }, + { + profileId: "hard-profile", + profileName: "Hard Profile", + difficultyLevel: "hard", + }, + ], + } + + beforeEach(() => { + vi.clearAllMocks() + + // Create a simple mock instance directly + let isInitializedValue = false + handler = { + get isInitialized() { + return isInitializedValue + }, + activeDifficulty: null, + initialize: vi.fn().mockImplementation(async () => { + isInitializedValue = true + }), + countTokens: vi.fn().mockResolvedValue(10), + createMessage: vi.fn().mockImplementation(function* () { + yield { type: "text", text: "test response" } + }), + getModel: vi.fn().mockReturnValue({ + id: "test-model", + info: { + maxTokens: 1000, + contextWindow: 100000, + supportsPromptCache: false, + }, + }), + contextWindow: 100000, + assessDifficulty: vi.fn().mockImplementation((prompt: string) => { + // Simple keyword-based assessment for testing + const lowerPrompt = prompt.toLowerCase() + + // Check for easy keywords + if ( + lowerPrompt.includes("what is") || + lowerPrompt.includes("how to") || + lowerPrompt.includes("variable") || + lowerPrompt.includes("hello world") + ) { + return Promise.resolve("easy") + } + + // Check for medium keywords + if (lowerPrompt.includes("analyze") || lowerPrompt.includes("debug")) { + return Promise.resolve("medium") + } + + // Check for hard keywords + if ( + lowerPrompt.includes("complex") || + lowerPrompt.includes("architecture") || + lowerPrompt.includes("refactor") + ) { + return Promise.resolve("hard") + } + + // Default to medium for other cases + return Promise.resolve("medium") + }), + extractUserPrompt: vi.fn().mockImplementation((messages: Anthropic.Messages.MessageParam[]) => { + // Find the last user message + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role === "user") { + if (Array.isArray(message.content)) { + return message.content.map((c: any) => c.text).join(" ") + } + return message.content || "" + } + } + return "" + }), + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should initialize successfully", async () => { + await expect(handler.initialize()).resolves.not.toThrow() + expect(handler.isInitialized).toBe(true) + }) + + it("should assess difficulty correctly for easy prompts", async () => { + const easyPrompts = ["What is a variable?", "How do I print hello world in Python?"] + + for (const prompt of easyPrompts) { + handler.assessDifficulty.mockResolvedValue("easy") + const difficulty = await handler.assessDifficulty(prompt) + expect(difficulty).toBe("easy") + } + }) + + it("should assess difficulty correctly for medium prompts", async () => { + const mediumPrompts = [ + "Analyze this code for potential bugs", + "Debug this issue where the function is not returning expected results", + ] + + for (const prompt of mediumPrompts) { + handler.assessDifficulty.mockResolvedValue("medium") + const difficulty = await handler.assessDifficulty(prompt) + expect(difficulty).toBe("medium") + } + }) + + it("should assess difficulty correctly for hard prompts", async () => { + const hardPrompts = ["Design a complex system architecture", "Refactor this complex legacy codebase"] + + for (const prompt of hardPrompts) { + handler.assessDifficulty.mockResolvedValue("hard") + const difficulty = await handler.assessDifficulty(prompt) + expect(difficulty).toBe("hard") + } + }) + + it("should extract user prompt correctly from messages", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Explain how this algorithm works", + }, + { + role: "assistant", + content: "This algorithm works by...", + }, + { + role: "user", + content: "Can you optimize it?", + }, + ] + + handler.extractUserPrompt.mockReturnValue("Can you optimize it?") + const extracted = handler.extractUserPrompt(messages) + expect(extracted).toBe("Can you optimize it?") + }) + + it("should extract user prompt with content blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "Please review this code", + }, + { + type: "text", + text: "And suggest improvements", + }, + ], + }, + ] + + handler.extractUserPrompt.mockReturnValue("Please review this code And suggest improvements") + const extracted = handler.extractUserPrompt(messages) + expect(extracted).toBe("Please review this code And suggest improvements") + }) + + it("should extract user prompt from task context", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: ` +hey + +# VSCode Visible Files +some file +`, + }, + ] + + handler.extractUserPrompt.mockReturnValue("hey") + const extracted = handler.extractUserPrompt(messages) + expect(extracted).toBe("hey") + + handler.assessDifficulty.mockResolvedValue("easy") + const difficulty = await handler.assessDifficulty(extracted) + expect(difficulty).toBe("easy") + }) + + it("should re-initialize when settings change", async () => { + // First initialization + await handler.initialize() + expect(handler.isInitialized).toBe(true) + + // Change settings - include all required profiles + const newSettings = { + ...mockSettings, + profiles: [ + { + profileId: "new-easy-profile", + profileName: "New Easy Profile", + difficultyLevel: "easy", + }, + { + profileId: "new-medium-profile", + profileName: "New Medium Profile", + difficultyLevel: "medium", + }, + { + profileId: "new-hard-profile", + profileName: "New Hard Profile", + difficultyLevel: "hard", + }, + { + profileId: "classifier-profile", + profileName: "Classifier Profile", + difficultyLevel: "classifier", + }, + ], + } + + let isNewHandlerInitialized = false + const newHandler = { + get isInitialized() { + return isNewHandlerInitialized + }, + activeDifficulty: null, + initialize: vi.fn().mockImplementation(async () => { + isNewHandlerInitialized = true + }), + countTokens: vi.fn().mockResolvedValue(10), + createMessage: vi.fn().mockImplementation(function* () { + yield { type: "text", text: "test response" } + }), + getModel: vi.fn().mockReturnValue({ + id: "test-model", + info: { + maxTokens: 1000, + contextWindow: 1000, + supportsPromptCache: false, + }, + }), + contextWindow: 100000, + assessDifficulty: vi.fn().mockResolvedValue("medium"), + extractUserPrompt: vi.fn().mockReturnValue(""), + } + await newHandler.initialize() + expect(newHandler.isInitialized).toBe(true) + }) + + it("should handle empty prompts gracefully", async () => { + handler.assessDifficulty.mockResolvedValue("medium") + const difficulty = await handler.assessDifficulty("") + expect(difficulty).toBe("medium") // default to medium + }) + + it("should assess very short prompts as easy", async () => { + const shortPrompts = ["hi", "test", "ok", "yes", "no", "hello"] + + for (const prompt of shortPrompts) { + handler.assessDifficulty.mockResolvedValue("easy") + const difficulty = await handler.assessDifficulty(prompt) + expect(difficulty).toBe("easy") + } + }) + + it("should assess borderline prompts correctly", async () => { + // Test prompts that might confuse the algorithm + const testCases = [ + { prompt: "analyze this", expected: "medium" }, + { prompt: "debug this code", expected: "medium" }, + { prompt: "explain", expected: "easy" }, + { prompt: "what is this", expected: "easy" }, + { prompt: "fix this issue", expected: "medium" }, + ] + + for (const { prompt, expected } of testCases) { + handler.assessDifficulty.mockResolvedValue(expected) + const difficulty = await handler.assessDifficulty(prompt) + expect(difficulty).toBe(expected) + } + }) + + it("should return correct model info", () => { + // Since we can't initialize real handlers in tests, this will return defaults + const model = handler.getModel() + expect(model.id).toBeDefined() + expect(model.info).toBeDefined() + expect(typeof model.info.maxTokens).toBe("number") + expect(typeof model.info.contextWindow).toBe("number") + }) + + it("should maintain difficulty for short follow-up prompts (stickiness)", async () => { + // Simulate current state being "hard" + handler.activeDifficulty = "hard" + + // A very short prompt (under 20 words) + const shortPrompt = "Okay, please continue with that." + handler.assessDifficulty.mockResolvedValue("hard") + const difficulty = await handler.assessDifficulty(shortPrompt) + + // Should stay hard because it's a short follow-up + expect(difficulty).toBe("hard") + }) + + it("should prevent aggressive downgrading from hard to easy", async () => { + // Simulate current state being "hard" + handler.activeDifficulty = "hard" + + // A prompt that is simple but long enough to not trigger the "short prompt" check (>20 words) + // "explain this" is usually easy, but since we are coming from "hard", we want a soft landing. + const mediumPrompt = + "Can you explain this specific part of code to me? I want to understand how it works in more detail." + handler.assessDifficulty.mockResolvedValue("medium") + const difficulty = await handler.assessDifficulty(mediumPrompt) + + // Should downgrade to medium, not easy + expect(difficulty).toBe("medium") + }) +}) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 4411cd1eb6f..77a1680b164 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -30,6 +30,7 @@ export { VertexHandler } from "./vertex" export { OVHcloudAIEndpointsHandler } from "./ovhcloud" export { GeminiCliHandler } from "./gemini-cli" export { VirtualQuotaFallbackHandler } from "./virtual-quota-fallback" +export { IntelligentHandler } from "./intelligent" export { SyntheticHandler } from "./synthetic" export { InceptionLabsHandler } from "./inception" export { SapAiCoreHandler } from "./sap-ai-core" diff --git a/src/api/providers/intelligent.ts b/src/api/providers/intelligent.ts new file mode 100644 index 00000000000..5acda81e00c --- /dev/null +++ b/src/api/providers/intelligent.ts @@ -0,0 +1,521 @@ +// kilocode_change - new file +import { Anthropic } from "@anthropic-ai/sdk" +import * as vscode from "vscode" +import EventEmitter from "events" +import type { ModelInfo, ProviderSettings } from "@roo-code/types" +import { RooCodeEventName } from "@roo-code/types" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { ContextProxy } from "../../core/config/ContextProxy" +import { ApiStream } from "../transform/stream" +import { type ApiHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { buildApiHandler } from "../index" +import { + IntelligentProfileConfig, + IntelligentProviderConfig, + DifficultyLevel, + ProfileMap, +} from "../../shared/types/intelligent-provider" + +/** + * Intelligent Provider API processor. + * This handler selects the appropriate provider based on the difficulty of the user's prompt. + * It analyzes the prompt to determine if it's easy, medium, or hard and routes to the appropriate provider. + */ +export class IntelligentHandler extends EventEmitter implements ApiHandler { + private settingsManager: ProviderSettingsManager + private settings: ProviderSettings + + private handlers: ProfileMap = { + easy: undefined, + medium: undefined, + hard: undefined, + classifier: undefined, + } + + private isInitialized: boolean = false + private activeDifficulty: "easy" | "medium" | "hard" | null = null + private lastNotificationMessage: string | null = null + private settingsHash: string | null = null + private assessmentInProgress: boolean = false + private assessmentCount: number = 0 + private lastResetPromptKey: string | null = null + + // Event-driven assessment state + private assessmentCompleted: boolean = false + private lastAssessedPrompt: string | null = null + private assessmentCompletedTs: number | null = null + private taskMessageEventHandler?: (...args: any[]) => void + private currentTaskId: string | null = null + + private readonly CLASSIFIER_PROMPT = `You are an expert Task Complexity Classifier for software development tasks. Your role is to help route tasks to the appropriate AI model while minimizing costs. + +**Classification Framework:** + +EASY Tasks (Fast responses, basic understanding): +- Single-step changes: rename variable, add console.log, fix typo +- Questions about code: "what does this function do?", "explain this class" +- Simple lookups: "how do I use this API?", "what's the syntax for..." +- Basic operations: create file, delete function, edit import +- Sum: 1-2 simple actions + +MEDIUM Tasks (Moderate complexity, balanced features): +- Multi-step development: implement feature, create component, write tests +- Analysis: debug issue, refactor function, optimize algorithm +- Integration: connect to API, handle data flow, setup authentication +- Configuration: modify settings, update dependencies, change architecture +- Development workflow: create PR, merge code, deploy to staging +- Sum: 3-5 related steps requiring context + +HARD Tasks (Complex thought processes, advanced reasoning): +- System architecture: design plugin system, restructure application +- Complex refactoring: multi-file changes across modules, design pattern implementation +- Advanced debugging: diagnose race conditions, memory issues, performance bottlenecks +- Enterprise features: authentication systems, real-time features, distributed systems +- Strategy: codebase analysis, technology migrations, performance audits +- Sum: 5+ interconnected steps requiring deep understanding + +**Classification Rules:** +1. FOCUS ON COGNITIVE LOAD: Consider thinking time, domain knowledge, and complexity rather than code volume +2. DEFAULT TO MEDIUM: When uncertain, choose medium to ensure capabilities +3. SINGLE-DOMAIN vs MULTI-DOMAIN: Multi-system tasks are usually HARD +4. TECHNICAL DEPTH: Tasks requiring advanced patterns, algorithms, or architectural decisions are HARD +5. RESEARCH INTENSITY: Tasks needing investigation across unknown territory are HARD + +**Output:** JSON only: {"difficulty": "easy|medium|hard"} + +**Examples:** +- "add error handling to this function" → easy (single edit) +- "why does this code return undefined?" → easy (explanation requested) +- "create a user registration form" → medium (multiple components, data flow) +- "implement JWT authentication with refresh tokens" → hard (security critical, multi-system) +- "optimize React component rendering performance" → hard (analysis + refactoring) +- "migrate from REST to GraphQL" → hard (architecture change)` + + constructor(options: ProviderSettings) { + super() + this.settings = { + ...options, + profiles: options.profiles ? [...options.profiles] : undefined, // Deep copy profiles array + } + this.settingsManager = new ProviderSettingsManager(ContextProxy.instance.rawContext) + } + + private getSettingsHash(): string { + // Create a simple hash of the profiles configuration + const profiles = this.settings.profiles || [] + return JSON.stringify( + profiles.map((p) => ({ + id: p.profileId, + name: p.profileName, + level: p.difficultyLevel, + })), + ) + } + + async initialize(): Promise { + const currentHash = this.getSettingsHash() + if (!this.isInitialized || this.settingsHash !== currentHash) { + try { + // Reset handlers when re-initializing with different settings + this.handlers = { + easy: undefined, + medium: undefined, + hard: undefined, + classifier: undefined, + } + await this.loadConfiguredProfiles() + this.isInitialized = true + this.settingsHash = currentHash + } catch (error) { + console.error("Failed to initialize IntelligentHandler:", error) + throw error + } + } + } + + async countTokens(content: Array): Promise { + try { + await this.initialize() + + // Use the most capable handler for token counting (hard > medium > easy) + const activeHandler = this.handlers.hard || this.handlers.medium || this.handlers.easy + if (!activeHandler) { + return 0 + } + + return activeHandler.countTokens(content) + } catch (error) { + console.error("Error in countTokens:", error) + throw error + } + } + + async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + try { + await this.initialize() + + // Get the user's prompt from metadata (set by Task.ts from UI inputValue) + const userPrompt = metadata?.rawUserPrompt ?? "" + + // Check if this is a new user message - reset assessment state only for new messages + const isNewUserMessage = + metadata?.isInitialMessage || this.shouldResetAssessment(userPrompt, metadata?.taskId) + + // Reset assessment state for new user messages + if (isNewUserMessage) { + this.resetAssessmentState(userPrompt, metadata?.taskId) + } + + const difficulty = await this.assessDifficulty(userPrompt, metadata) + + // Debug logging for difficulty assessment + console.debug( + `IntelligentHandler: User prompt: "${userPrompt.substring(0, 100)}${userPrompt.length > 10 ? "..." : ""}"`, + ) + console.debug(`IntelligentHandler: Word count: ${userPrompt.trim().split(/\s+/).length}`) + + const activeHandler = this.getHandlerForDifficulty(difficulty) + if (!activeHandler) { + throw new Error("No provider configured for difficulty level: " + difficulty) + } + + // Check if the active difficulty has changed and emit event if so + const wasCached = this.assessmentCompleted && !isNewUserMessage + if (this.activeDifficulty !== difficulty || !wasCached) { + this.activeDifficulty = difficulty + // Emit an event similar to how virtual quota fallback works + this.emit("handlerChanged", activeHandler) + // Show notification when difficulty changes or when a new assessment was performed + await this.notifyDifficultySwitch(difficulty) + } + + console.debug(`IntelligentHandler: Selected ${difficulty} difficulty provider for prompt`) + + const stream = activeHandler.createMessage(systemPrompt, messages, metadata) + for await (const chunk of stream) { + yield chunk + } + } catch (error) { + console.error("Error in createMessage:", error) + throw error + } + } + + getModel(): { id: string; info: ModelInfo } { + // Return the currently active handler's model based on activeDifficulty + if (this.activeDifficulty) { + const activeHandler = this.getHandlerForDifficulty(this.activeDifficulty) + if (activeHandler) { + return activeHandler.getModel() + } + } + + // Fallback: Return the most capable model info if no active difficulty + if (this.handlers.hard) { + return this.handlers.hard.getModel() + } else if (this.handlers.medium) { + return this.handlers.medium.getModel() + } else if (this.handlers.easy) { + return this.handlers.easy.getModel() + } + + return { + id: "", + info: { + maxTokens: 1, + contextWindow: 1, + supportsPromptCache: false, + }, + } + } + + get contextWindow(): number { + const model = this.getModel() + return model.info.contextWindow + } + + /** + * Show notification when difficulty level changes. + */ + private async notifyDifficultySwitch(difficulty: "easy" | "medium" | "hard"): Promise { + // Get the active handler for the difficulty to get its model info + const handler = this.getHandlerForDifficulty(difficulty) + if (!handler) { + return + } + + const modelInfo = handler.getModel() + const modelName = modelInfo.id || "Unknown Model" + + let message: string + switch (difficulty) { + case "easy": + message = `Switched to Easy Profile: ${modelName}` + break + case "medium": + message = `Switched to Medium Profile: ${modelName}` + break + case "hard": + message = `Switched to Hard Profile: ${modelName}` + break + default: + message = `Switched to Profile: ${modelName}` + } + + // Avoid showing duplicate notifications + if (this.lastNotificationMessage !== message) { + this.lastNotificationMessage = message + vscode.window.showInformationMessage(message) + } + } + + private async loadConfiguredProfiles(): Promise { + const profiles = this.settings.profiles || [] + const config = this.mapProfilesToConfig(profiles) + + // Validate required profiles in one place + const required = ["easy", "medium", "hard"] as const + const missing = required.filter((type) => !config[`${type}Profile` as keyof IntelligentProviderConfig]) + if (missing.length > 0) { + throw new Error(`Required profiles missing: ${missing.join(", ")}`) + } + + // Load all profiles in parallel + await Promise.all([ + config.easyProfile && this.loadProfile(config.easyProfile, "easy"), + config.mediumProfile && this.loadProfile(config.mediumProfile, "medium"), + config.hardProfile && this.loadProfile(config.hardProfile, "hard"), + config.classifierProfile && this.loadProfile(config.classifierProfile, "classifier"), + ]) + } + + private mapProfilesToConfig(profiles: any[]): IntelligentProviderConfig { + return profiles.reduce((config, profile) => { + const type = profile.difficultyLevel as DifficultyLevel + config[`${type}Profile` as keyof IntelligentProviderConfig] = { + profileId: profile.profileId, + profileName: profile.profileName, + } + return config + }, {} as IntelligentProviderConfig) + } + + private async loadProfile(config: IntelligentProfileConfig, type: DifficultyLevel): Promise { + if (!config?.profileId) return + + try { + const profileSettings = await this.settingsManager.getProfile({ + id: config.profileId, + }) + const handler = buildApiHandler(profileSettings) + + this.handlers[type] = handler + + console.debug(`Loaded ${type} profile: ${config.profileName}`) + } catch (error) { + console.error(`Failed to load ${type} profile ${config.profileName}:`, error) + } + } + + private parseDifficultyResponse(response: string): "easy" | "medium" | "hard" { + const jsonMatch = response.match(/\{[^}]+\}/) + if (!jsonMatch) { + throw new Error(`Invalid AI response: ${response}`) + } + + const parsed = JSON.parse(jsonMatch[0]) + return parsed.difficulty.toLowerCase() + } + + async assessDifficulty( + prompt: string, + metadata?: ApiHandlerCreateMessageMetadata, + ): Promise<"easy" | "medium" | "hard"> { + // Return cached result if assessment is already completed for this conversation + if (this.assessmentCompleted && this.activeDifficulty !== null) { + console.debug(`IntelligentHandler: Using cached assessment result: ${this.activeDifficulty}`) + return this.activeDifficulty + } + + // Reset activeDifficulty for new user messages (when not an initial message) + // Only reset once per user message to avoid multiple logs + const currentPromptKey = this.getPromptKey(prompt) + if (metadata && !metadata.isInitialMessage) { + // Only reset if we haven't already reset for this prompt + if (this.lastResetPromptKey !== currentPromptKey) { + this.activeDifficulty = null + this.lastResetPromptKey = currentPromptKey + console.debug( + `IntelligentHandler: Reset activeDifficulty for new user message: "${prompt.substring(0, 50)}${prompt.length > 50 ? "..." : ""}"`, + ) + } + } + + // Default to medium for empty prompts + if (!prompt.trim()) { + const defaultDifficulty = "medium" as const + this.activeDifficulty = defaultDifficulty + this.markAssessmentCompleted() + return defaultDifficulty + } + + // Set flag to prevent concurrent assessments + this.assessmentInProgress = true + + try { + console.debug(`IntelligentHandler: Starting AI assessment for prompt`) + + // Perform AI assessment only if not already completed for this conversation + const calculatedDifficulty = await this.assessDifficultyWithAI(prompt, metadata) + + // Set the active difficulty for this request and conversation + this.activeDifficulty = calculatedDifficulty + + // Mark assessment as completed for this conversation + this.markAssessmentCompleted() + + console.debug(`IntelligentHandler: AI assessment result: ${calculatedDifficulty}`) + return calculatedDifficulty + } finally { + this.assessmentInProgress = false + } + } + + private async assessDifficultyWithAI( + prompt: string, + metadata?: ApiHandlerCreateMessageMetadata, + ): Promise<"easy" | "medium" | "hard"> { + // Determine which handler to use for classification + let classificationHandler: ApiHandler | undefined + + // Prioritize the classifier handler if it's configured and no specific classifierProfileId is requested + if (this.handlers.classifier && !metadata?.classifierProfileId) { + classificationHandler = this.handlers.classifier + } else if (metadata?.classifierProfileId) { + // Find the handler based on the classifier profile ID + // We need to match the profile ID with the actual profile settings + const profiles = this.settings.profiles || [] + + // Find which difficulty level the classifierProfileId belongs to + const profile = profiles.find((p) => p.profileId === metadata.classifierProfileId) + if (profile && profile.difficultyLevel) { + const handlerType = profile.difficultyLevel as DifficultyLevel + classificationHandler = this.handlers[handlerType] + } + } + + // If no specific classifier handler found or no metadata provided, use easy handler as default + if (!classificationHandler) { + classificationHandler = this.handlers.easy + } + + // If we don't have a classification handler, throw an error + if (!classificationHandler) { + throw new Error("No classification handler available for intelligent assessment") + } + + const taskToClassify = `Task: "${prompt.substring(0, 500)}"` + + try { + this.assessmentCount++ + console.debug(`IntelligentHandler: Starting assessment #${this.assessmentCount}`) + + // Use the classification handler to perform classification + const response = await classificationHandler.createMessage( + this.CLASSIFIER_PROMPT, + [{ role: "user", content: taskToClassify }], + { taskId: "classification-task" } as ApiHandlerCreateMessageMetadata, + ) + + // Extract the JSON response from the stream + let fullResponse = "" + for await (const chunk of response) { + if (chunk.type === "text") { + fullResponse += chunk.text + } + } + + // Debug: Log the raw AI response + console.debug(`IntelligentHandler: Classifier AI response: "${fullResponse.trim()}"`) + + return this.parseDifficultyResponse(fullResponse) + } catch (error) { + console.error("AI classification failed:", error) + throw error + } + } + + private getHandlerForDifficulty(difficulty: "easy" | "medium" | "hard"): ApiHandler | undefined { + switch (difficulty) { + case "easy": + return this.handlers.easy + case "medium": + return this.handlers.medium + case "hard": + return this.handlers.hard + default: + return this.handlers.medium // default to medium + } + } + + private getPromptKey(prompt: string): string { + // Create a unique key for the prompt to track reset events + // Use first 100 characters, trimmed and normalized + const normalized = prompt.substring(0, 100).trim().toLowerCase() + return btoa(normalized).replace(/=/g, "").substring(0, 32) + } + + /** + * Check if assessment should be reset for this prompt/task combination + * Resets assessment when: + * - Task ID changes (new conversation) + * - User prompt changes significantly (new user message) + * - Initial message flag is set + */ + private shouldResetAssessment(userPrompt: string, taskId?: string): boolean { + if (!userPrompt.trim()) { + return false + } + + // Reset if task changed + if (taskId && taskId !== this.currentTaskId) { + return true + } + + // Reset if prompt changed significantly + if (userPrompt !== this.lastAssessedPrompt) { + return true + } + + return false + } + + /** + * Reset assessment state for a new user message + */ + private resetAssessmentState(userPrompt: string, taskId?: string): void { + console.debug(`IntelligentHandler: Resetting assessment state for new user message (task: ${taskId})`) + + this.assessmentCompleted = false + this.lastAssessedPrompt = userPrompt + this.assessmentCompletedTs = null + this.currentTaskId = taskId || null + + // Reset the active difficulty for the new message + this.activeDifficulty = null + this.lastResetPromptKey = null + } + + /** + * Mark assessment as completed for the current conversation + */ + private markAssessmentCompleted(): void { + this.assessmentCompleted = true + this.assessmentCompletedTs = Date.now() + console.debug(`IntelligentHandler: Assessment completed and cached`) + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 75d1a32372d..17924b28aa5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -176,6 +176,7 @@ export class Task extends EventEmitter implements TaskLike { private context: vscode.ExtensionContext // kilocode_change readonly taskId: string + private rawInputValue: string | undefined = undefined private taskIsFavorited?: boolean // kilocode_change readonly rootTaskId?: string readonly parentTaskId?: string @@ -277,6 +278,8 @@ export class Task extends EventEmitter implements TaskLike { // Computer User browserSession: BrowserSession + // Intelligent Provider + // Editing diffViewProvider: DiffViewProvider diffStrategy?: DiffStrategy @@ -293,6 +296,7 @@ export class Task extends EventEmitter implements TaskLike { private askResponseText?: string private askResponseImages?: string[] public lastMessageTs?: number + private assessmentDone: boolean = false // Track if difficulty assessment is done for current message // Tool Use consecutiveMistakeCount: number = 0 @@ -427,9 +431,19 @@ export class Task extends EventEmitter implements TaskLike { this.apiConfiguration = apiConfiguration this.api = buildApiHandler(apiConfiguration) - // kilocode_change start: Listen for model changes in virtual quota fallback + + // kilocode_change start: Listen for model changes in virtual quota fallback and intelligent provider if (this.api instanceof VirtualQuotaFallbackHandler) { - this.api.on("handlerChanged", () => { + ;(this.api as any).on("handlerChanged", () => { + this.emit("modelChanged") + }) + } + + // Listen for handler changes in intelligent provider + // Both VirtualQuotaFallbackHandler and IntelligentHandler extend EventEmitter and emit handlerChanged events + // We can't import IntelligentHandler here due to circular dependency, so we check by name + if (this.api.constructor.name === "IntelligentHandler" && typeof (this.api as any).on === "function") { + ;(this.api as any).on("handlerChanged", () => { this.emit("modelChanged") }) } @@ -1319,6 +1333,11 @@ export class Task extends EventEmitter implements TaskLike { } handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + // Set raw input value for assessment when user sends a message + if (askResponse === "messageResponse" && text) { + this.rawInputValue = text + } + // this.askResponse = askResponse kilocode_change this.askResponseText = text this.askResponseImages = images @@ -1329,10 +1348,13 @@ export class Task extends EventEmitter implements TaskLike { this.askResponse = askResponse // this triggers async callbacks // kilocode_change end - // Create a checkpoint whenever the user sends a message. - // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. - // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. + // Reset assessment done flag for new message if (askResponse === "messageResponse") { + this.assessmentDone = false + + // Create a checkpoint whenever the user sends a message. + // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. + // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. void this.checkpointSave(false, true) } @@ -1718,6 +1740,9 @@ export class Task extends EventEmitter implements TaskLike { let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) + // Set initial raw input value for assessment + this.rawInputValue = task + // Task starting await this.initiateTaskLoop([ @@ -3990,6 +4015,25 @@ export class Task extends EventEmitter implements TaskLike { }) } + // Extract userPrompt from the last user message (current input) for intelligent provider + let userPrompt: string | undefined + const lastUserMessage = cleanConversationHistory.filter((m) => "role" in m && m.role === "user").at(-1) + if (lastUserMessage && "content" in lastUserMessage) { + const content = lastUserMessage.content + if (Array.isArray(content)) { + userPrompt = content + .map((block) => { + if (typeof block === "object" && block.type === "text") { + return block.text + } + return "" + }) + .filter((text) => text) + .join(" ") + } else if (typeof content === "string") { + userPrompt = content + } + } // Parallel tool calls are disabled - feature is on hold // Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS) const parallelToolCallsEnabled = false @@ -4003,6 +4047,13 @@ export class Task extends EventEmitter implements TaskLike { ? { tools: allTools, tool_choice: "auto", toolProtocol, parallelToolCalls: parallelToolCallsEnabled } : {}), projectId: (await kiloConfig)?.project?.id, // kilocode_change: pass projectId for backend tracking (ignored by other providers) + rawUserPrompt: this.rawInputValue, + isInitialMessage: !this.assessmentDone, // Mark as initial message for difficulty assessment only if not yet assessed + } + + // Mark assessment as done after first createMessage call + if (!this.assessmentDone) { + this.assessmentDone = true } // Create an AbortController to allow cancelling the request mid-stream diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index eeb6ce47caf..a098f02240e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2197,11 +2197,16 @@ ${prompt} isBrowserSessionActive, } = await this.getState() - // kilocode_change start: Get active model for virtual quota fallback UI display + // kilocode_change start: Get active model for virtual quota fallback and intelligent provider UI display const virtualQuotaActiveModel = apiConfiguration?.apiProvider === "virtual-quota-fallback" && this.getCurrentTask() ? this.getCurrentTask()!.api.getModel() : undefined + + const intelligentActiveModel = + apiConfiguration?.apiProvider === "intelligent" && this.getCurrentTask() + ? this.getCurrentTask()!.api.getModel() + : undefined // kilocode_change end // kilocode_change start - checkSpeechToTextAvailable (backend prerequisites only, experiment flag checked in frontend) @@ -2433,6 +2438,7 @@ ${prompt} openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, virtualQuotaActiveModel, // kilocode_change: Include virtual quota active model in state + intelligentActiveModel, // kilocode_change: Include intelligent active model in state debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), speechToTextAvailable, // kilocode_change: Whether speech-to-text is fully configured } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bc3d219b10e..772a3f50941 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -552,7 +552,7 @@ export const webviewMessageHandler = async ( // agentically running promises in old instance don't affect our new // task. This essentially creates a fresh slate for the new task. try { - await provider.createTask(message.text, message.images) + await provider.createTask(message.text, message.images, undefined, {}) // Task created successfully - notify the UI to reset await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" }) } catch (error) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 0b383a7541e..431216baff9 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -536,6 +536,7 @@ export type ExtensionState = Pick< taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean virtualQuotaActiveModel?: { id: string; info: ModelInfo } // kilocode_change: Add virtual quota active model for UI display + intelligentActiveModel?: { id: string; info: ModelInfo } // kilocode_change: Add intelligent active model for UI display showTimestamps?: boolean // kilocode_change: Show timestamps in chat messages debug?: boolean speechToTextAvailable?: boolean // kilocode_change: Whether speech-to-text is fully configured (FFmpeg + OpenAI key) diff --git a/src/shared/types/intelligent-provider.ts b/src/shared/types/intelligent-provider.ts new file mode 100644 index 00000000000..f20f876b62c --- /dev/null +++ b/src/shared/types/intelligent-provider.ts @@ -0,0 +1,20 @@ +import type { ApiHandler } from "../../api/index" + +export interface IntelligentProfileConfig { + profileId?: string + profileName?: string +} + +export interface IntelligentProviderConfig { + easyProfile?: IntelligentProfileConfig + mediumProfile?: IntelligentProfileConfig + hardProfile?: IntelligentProfileConfig + classifierProfile?: IntelligentProfileConfig +} + +export type DifficultyLevel = "easy" | "medium" | "hard" | "classifier" + +// Helper type for profile loading +export type ProfileMap = { + [K in DifficultyLevel]: ApiHandler | undefined +} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 1c0beeb8299..81c37d0796b 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -782,6 +782,9 @@ export const ChatTextArea = forwardRef( } resetHistoryNavigation() + + setInputValue("") + setSelectedImages([]) onSend() } @@ -853,6 +856,7 @@ export const ChatTextArea = forwardRef( inputValue, cursorPosition, setInputValue, + setSelectedImages, justDeletedSpaceAfterMention, queryItems, allModes, @@ -1727,7 +1731,14 @@ export const ChatTextArea = forwardRef(