diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt index 9b1f0631eacb..27f46983d381 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt @@ -13,6 +13,7 @@ data class SerializedChatMessage( val intent: IntentEnum? = null, // Oneof: search, chat, edit, insert val manuallySelectedIntent: ManuallySelectedIntentEnum? = null, // Oneof: search, chat, edit, insert val search: Any? = null, + val agent: String? = null, val processes: List? = null, val subMessages: List? = null, ) { diff --git a/lib/shared/src/chat/transcript/index.ts b/lib/shared/src/chat/transcript/index.ts index 7541c74aac34..c91edf3d64ac 100644 --- a/lib/shared/src/chat/transcript/index.ts +++ b/lib/shared/src/chat/transcript/index.ts @@ -35,6 +35,7 @@ export function serializeChatMessage(chatMessage: ChatMessage): SerializedChatMe manuallySelectedIntent: chatMessage.manuallySelectedIntent, search: chatMessage.search, processes: chatMessage.processes, + agent: chatMessage.agent, subMessages: chatMessage.subMessages, } } diff --git a/lib/shared/src/chat/transcript/messages.ts b/lib/shared/src/chat/transcript/messages.ts index 7f19287620ac..b3894baf91e4 100644 --- a/lib/shared/src/chat/transcript/messages.ts +++ b/lib/shared/src/chat/transcript/messages.ts @@ -45,6 +45,7 @@ export interface ChatMessage extends Message { intent?: 'search' | 'chat' | 'edit' | 'insert' | undefined | null manuallySelectedIntent?: 'search' | 'chat' | 'edit' | 'insert' | undefined | null search?: ChatMessageSearch | undefined | null + agent?: string processes?: ProcessingStep[] | undefined | null /** @@ -115,6 +116,7 @@ export interface SerializedChatMessage { intent?: ChatMessage['intent'] manuallySelectedIntent?: ChatMessage['manuallySelectedIntent'] search?: ChatMessage['search'] + agent?: string processes?: ProcessingStep[] | undefined | null subMessages?: SubMessage[] } diff --git a/lib/shared/src/configuration.ts b/lib/shared/src/configuration.ts index 7659244cec19..b86941535b9a 100644 --- a/lib/shared/src/configuration.ts +++ b/lib/shared/src/configuration.ts @@ -87,7 +87,6 @@ interface RawClientConfiguration { commandCodeLenses: boolean // Deep Cody - agenticContextExperimentalShell?: boolean agenticContextExperimentalOptions?: AgenticContextConfiguration //#region Autocomplete @@ -463,3 +462,23 @@ export interface FireworksCodeCompletionParams { languageId: string user: string | null } + +export interface AgentToolboxSettings { + /** + * The agent that user has currently enabled. + */ + agent?: { + /** + * The name of the agent that user has currently enabled. E.g. "deep-cody" + */ + name?: string + } + /** + * Whether the user has enabled terminal context. + * Defaulted to undefined if shell context is not enabled by site admin via feature flag. + */ + shell?: { + enabled: boolean + error?: string + } +} diff --git a/lib/shared/src/experimentation/FeatureFlagProvider.ts b/lib/shared/src/experimentation/FeatureFlagProvider.ts index b042b2d62761..b397d4e0079e 100644 --- a/lib/shared/src/experimentation/FeatureFlagProvider.ts +++ b/lib/shared/src/experimentation/FeatureFlagProvider.ts @@ -107,6 +107,9 @@ export enum FeatureFlag { /** Enable Shell Context for Deep Cody */ DeepCodyShellContext = 'deep-cody-shell-context', + /** Whether Context Agent (Deep Cody) should use the default chat model or 3.5 Haiku */ + ContextAgentDefaultChatModel = 'context-agent-use-default-chat-model', + /** Enable Rate Limit for Deep Cody */ DeepCodyRateLimitBase = 'deep-cody-experimental-rate-limit', DeepCodyRateLimitMultiplier = 'deep-cody-experimental-rate-limit-multiplier', diff --git a/lib/shared/src/misc/rpc/webviewAPI.ts b/lib/shared/src/misc/rpc/webviewAPI.ts index 61ef9a2866cb..9f5c50d0836c 100644 --- a/lib/shared/src/misc/rpc/webviewAPI.ts +++ b/lib/shared/src/misc/rpc/webviewAPI.ts @@ -1,5 +1,11 @@ import { Observable } from 'observable-fns' -import type { AuthStatus, ModelsData, ResolvedConfiguration, UserProductSubscription } from '../..' +import type { + AgentToolboxSettings, + AuthStatus, + ModelsData, + ResolvedConfiguration, + UserProductSubscription, +} from '../..' import type { SerializedPromptEditorState } from '../..' import type { ChatMessage, UserLocalHistory } from '../../chat/transcript/messages' import type { ContextItem, DefaultContext } from '../../codebase-context/messages' @@ -104,6 +110,15 @@ export interface WebviewToExtensionAPI { * The current user's product subscription information (Cody Free/Pro). */ userProductSubscription(): Observable + + /** + * The current user's toolbox settings. + */ + toolboxSettings(): Observable + /** + * Update the current user's toolbox settings. + */ + updateToolboxSettings(settings: AgentToolboxSettings): Observable } export function createExtensionAPI( @@ -138,6 +153,8 @@ export function createExtensionAPI( transcript: proxyExtensionAPI(messageAPI, 'transcript'), userHistory: proxyExtensionAPI(messageAPI, 'userHistory'), userProductSubscription: proxyExtensionAPI(messageAPI, 'userProductSubscription'), + toolboxSettings: proxyExtensionAPI(messageAPI, 'toolboxSettings'), + updateToolboxSettings: proxyExtensionAPI(messageAPI, 'updateToolboxSettings'), } } diff --git a/lib/shared/src/models/sync.test.ts b/lib/shared/src/models/sync.test.ts index bebe094caf67..ece7495d0013 100644 --- a/lib/shared/src/models/sync.test.ts +++ b/lib/shared/src/models/sync.test.ts @@ -462,70 +462,6 @@ describe('syncModels', () => { } ) - it('sets DeepCody as default chat model when feature flag is enabled', async () => { - const serverSonnet: ServerModel = { - modelRef: 'anthropic::unknown::sonnet', - displayName: 'Sonnet', - modelName: 'anthropic.claude-3-5-sonnet', - capabilities: ['chat'], - category: 'balanced' as ModelCategory, - status: 'stable', - tier: 'enterprise' as ModelTier, - contextWindow: { - maxInputTokens: 9000, - maxOutputTokens: 4000, - }, - } - - const SERVER_MODELS: ServerModelConfiguration = { - schemaVersion: '0.0', - revision: '-', - providers: [], - models: [serverSonnet], - defaultModels: { - chat: serverSonnet.modelRef, - fastChat: serverSonnet.modelRef, - codeCompletion: serverSonnet.modelRef, - }, - } - - const mockFetchServerSideModels = vi.fn(() => Promise.resolve(SERVER_MODELS)) - vi.mocked(featureFlagProvider).evaluatedFeatureFlag.mockReturnValue(Observable.of(true)) - - const result = await firstValueFrom( - syncModels({ - resolvedConfig: Observable.of({ - configuration: {}, - clientState: { modelPreferences: {} }, - } satisfies PartialDeep as ResolvedConfiguration), - authStatus: Observable.of(AUTH_STATUS_FIXTURE_AUTHED), - configOverwrites: Observable.of(null), - clientConfig: Observable.of({ - modelsAPIEnabled: true, - } satisfies Partial as CodyClientConfig), - fetchServerSideModels_: mockFetchServerSideModels, - userProductSubscription: Observable.of({ userCanUpgrade: true }), - }).pipe(skipPendingOperation()) - ) - - const storage = new TestLocalStorageForModelPreferences() - modelsService.setStorage(storage) - mockAuthStatus(AUTH_STATUS_FIXTURE_AUTHED) - expect(storage.data?.[AUTH_STATUS_FIXTURE_AUTHED.endpoint]!.selected.chat).toBe(undefined) - vi.spyOn(modelsService, 'modelsChanges', 'get').mockReturnValue(Observable.of(result)) - - // Check if Deep Cody model is in the primary models list. - expect(result.primaryModels.some(model => model.id.includes('deep-cody'))).toBe(true) - - // Deep Cody should not replace the default chat / edit model. - expect(result.preferences.defaults.chat?.includes('deep-cody')).toBe(false) - expect(result.preferences.defaults.edit?.includes('deep-cody')).toBe(false) - - // preference should not be affected and remains unchanged as this is handled in a later step. - expect(result.preferences.selected.chat).toBe(undefined) - expect(storage.data?.[AUTH_STATUS_FIXTURE_AUTHED.endpoint]!.selected.chat).toBe(undefined) - }) - describe('model selection based on user tier and feature flags', () => { const serverHaiku: ServerModel = { modelRef: 'anthropic::unknown::claude-3-5-haiku', diff --git a/lib/shared/src/models/sync.ts b/lib/shared/src/models/sync.ts index a4cdcb403ea2..bd60b670e9f9 100644 --- a/lib/shared/src/models/sync.ts +++ b/lib/shared/src/models/sync.ts @@ -27,7 +27,7 @@ import { RestClient } from '../sourcegraph-api/rest/client' import type { UserProductSubscription } from '../sourcegraph-api/userProductSubscription' import { CHAT_INPUT_TOKEN_BUDGET } from '../token/constants' import { isError } from '../utils' -import { TOOL_CODY_MODEL, getExperimentalClientModelByFeatureFlag } from './client' +import { TOOL_CODY_MODEL } from './client' import { type Model, type ServerModel, createModel, createModelFromServerModel } from './model' import type { DefaultsAndUserPreferencesForEndpoint, @@ -212,12 +212,7 @@ export function syncModels({ enableToolCody ).pipe( switchMap( - ([ - hasEarlyAccess, - hasDeepCodyFlag, - defaultToHaiku, - enableToolCody, - ]) => { + ([hasEarlyAccess, isDeepCodyEnabled, defaultToHaiku]) => { // TODO(sqs): remove waitlist from localStorage when user has access const isOnWaitlist = config.clientState.waitlist_o1 if (isDotComUser && (hasEarlyAccess || isOnWaitlist)) { @@ -238,40 +233,11 @@ export function syncModels({ } ) } - - // Replace user's current sonnet model with deep-cody model. - const sonnetModel = data.primaryModels.find(m => - m.id.includes('sonnet') - ) - // DEEP CODY is enabled for all PLG users. - // Enterprise users need to have the feature flag enabled. - const isDeepCodyEnabled = - (isDotComUser && !isCodyFreeUser) || hasDeepCodyFlag - if ( - isDeepCodyEnabled && - sonnetModel && - // Ensure the deep-cody model is only added once. - !data.primaryModels.some(m => - m.id.includes('deep-cody') - ) - ) { - const DEEPCODY_MODEL = - getExperimentalClientModelByFeatureFlag( - FeatureFlag.DeepCody - )! + if (isDeepCodyEnabled && enableToolCody) { data.primaryModels.push( - ...maybeAdjustContextWindows([ - DEEPCODY_MODEL, - ]).map(createModelFromServerModel) + createModelFromServerModel(TOOL_CODY_MODEL) ) - - if (enableToolCody) { - data.primaryModels.push( - createModelFromServerModel(TOOL_CODY_MODEL) - ) - } } - // set the default model to Haiku for free users if (isDotComUser && isCodyFreeUser && defaultToHaiku) { const haikuModel = data.primaryModels.find(m => diff --git a/lib/shared/src/prompt/prompt-mixin.ts b/lib/shared/src/prompt/prompt-mixin.ts index 4fa86ac42312..b4df3efef0ff 100644 --- a/lib/shared/src/prompt/prompt-mixin.ts +++ b/lib/shared/src/prompt/prompt-mixin.ts @@ -43,9 +43,8 @@ export class PromptMixin { mixins.push(PromptMixin.hedging) } - // Handle Deep Cody specific prompts - const isDeepCodyEnabled = modelID?.includes('deep-cody') - if (isDeepCodyEnabled && !newMixins.length) { + // Handle Agent specific prompts + if (humanMessage.agent === 'deep-cody' && !newMixins.length) { mixins.push(new PromptMixin(HEDGES_PREVENTION.concat(DEEP_CODY))) } @@ -53,7 +52,7 @@ export class PromptMixin { mixins.push(...newMixins) const prompt = PromptMixin.buildPrompt(mixins) - return PromptMixin.mixedMessage(humanMessage, prompt, mixins, isDeepCodyEnabled) + return PromptMixin.mixedMessage(humanMessage, prompt, mixins) } private static buildPrompt(mixins: PromptMixin[]): PromptString { @@ -67,17 +66,16 @@ export class PromptMixin { private static mixedMessage( humanMessage: ChatMessage, prompt: PromptString, - mixins: PromptMixin[], - isDeepCodyEnabled = false + mixins: PromptMixin[] ): ChatMessage { if (!mixins.length || !humanMessage.text) { return humanMessage } - if (isDeepCodyEnabled) { + if (humanMessage.agent === 'deep-cody' && prompt.includes('{{USER_INPUT_TEXT}}')) { return { ...humanMessage, - text: ps`${prompt}\n\n[QUESTION]\n`.concat(humanMessage.text), + text: prompt.replace('{{USER_INPUT_TEXT}}', humanMessage.text), } } diff --git a/vscode/package.json b/vscode/package.json index 82bad6f18f9a..91a4325f089b 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -1265,8 +1265,9 @@ }, "cody.agentic.context.experimentalShell": { "type": "boolean", - "markdownDescription": "Enable Agents like Deep Cody to autonomously execute shell commands in your environment for context. Enable with caution as mistakes are possible.", - "default": false + "value": false, + "markdownDeprecationMessage": "**Deprecated** Enable Agents like Deep Cody to autonomously execute shell commands in your environment for context. Enable with caution as mistakes are possible.", + "deprecationMessage": "Deprecated. Now configurable via UI." }, "cody.agentic.context.experimentalOptions": { "type": "object", diff --git a/vscode/src/chat/agentic/CodyChatAgent.ts b/vscode/src/chat/agentic/CodyChatAgent.ts deleted file mode 100644 index a41afc8be1b5..000000000000 --- a/vscode/src/chat/agentic/CodyChatAgent.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - BotResponseMultiplexer, - type ChatClient, - type ContextItem, - type Message, - type ProcessingStep, - type PromptMixin, - type PromptString, - newPromptMixin, -} from '@sourcegraph/cody-shared' -import { getCategorizedMentions } from '../../prompt-builder/utils' -import type { ChatBuilder } from '../chat-view/ChatBuilder' -import { DefaultPrompter } from '../chat-view/prompt' -import type { CodyTool } from './CodyTool' -import type { ToolStatusCallback } from './CodyToolProvider' -import { ProcessManager } from './ProcessManager' - -export abstract class CodyChatAgent { - protected readonly multiplexer = new BotResponseMultiplexer() - protected readonly promptMixins: PromptMixin[] = [] - protected readonly toolHandlers: Map - protected statusCallback?: ToolStatusCallback - private stepsManager?: ProcessManager - - constructor( - protected readonly chatBuilder: ChatBuilder, - protected readonly chatClient: Pick, - protected readonly tools: CodyTool[], - protected context: ContextItem[], - statusUpdateCallback?: (steps: ProcessingStep[]) => void - ) { - // Initialize handlers and mixins in constructor - this.toolHandlers = new Map(tools.map(tool => [tool.config.tags.tag.toString(), tool])) - this.initializeMultiplexer() - this.promptMixins.push(newPromptMixin(this.buildPrompt())) - - this.stepsManager = new ProcessManager(steps => { - statusUpdateCallback?.(steps) - }) - this.statusCallback = { - onStart: () => { - this.stepsManager?.initializeStep() - }, - onStream: (toolName, content) => { - this.stepsManager?.addStep(toolName, content) - }, - onComplete: (toolName, error) => { - this.stepsManager?.completeStep(toolName, error) - }, - } - } - - protected initializeMultiplexer(): void { - for (const [tag, tool] of this.toolHandlers) { - this.multiplexer.sub(tag, { - onResponse: async (content: string) => tool.stream(content), - onTurnComplete: async () => {}, - }) - } - } - - protected async processResponseText(text: string): Promise { - return this.multiplexer.publish(text) - } - - protected async processStream( - requestID: string, - message: Message[], - signal?: AbortSignal, - model?: string - ): Promise { - const stream = await this.chatClient.chat( - message, - { model, maxTokensToSample: 4000 }, - new AbortController().signal, - requestID - ) - const accumulated = new StringBuilder() - try { - for await (const msg of stream) { - if (signal?.aborted) break - if (msg.type === 'change') { - const newText = msg.text.slice(accumulated.length) - accumulated.append(newText) - await this.processResponseText(newText) - } - - if (msg.type === 'complete' || msg.type === 'error') { - if (msg.type === 'error') throw new Error('Error while streaming') - break - } - } - } finally { - await this.multiplexer.notifyTurnComplete() - } - - return accumulated.toString() - } - - protected getPrompter(items: ContextItem[]): DefaultPrompter { - const { explicitMentions, implicitMentions } = getCategorizedMentions(items) - const MAX_SEARCH_ITEMS = 30 - return new DefaultPrompter(explicitMentions, implicitMentions.slice(-MAX_SEARCH_ITEMS)) - } - - // Abstract methods that must be implemented by derived classes - protected abstract buildPrompt(): PromptString -} - -class StringBuilder { - private parts: string[] = [] - - append(str: string): void { - this.parts.push(str) - } - - toString(): string { - return this.parts.join('') - } - - get length(): number { - return this.parts.reduce((acc, part) => acc + part.length, 0) - } -} diff --git a/vscode/src/chat/agentic/CodyTool.test.ts b/vscode/src/chat/agentic/CodyTool.test.ts index 978332b75450..ab540b3b10d4 100644 --- a/vscode/src/chat/agentic/CodyTool.test.ts +++ b/vscode/src/chat/agentic/CodyTool.test.ts @@ -2,8 +2,11 @@ import type { Span } from '@opentelemetry/api' import { type ContextItem, ContextItemSource, ps } from '@sourcegraph/cody-shared' import { beforeEach, describe, expect, it, vi } from 'vitest' import { URI } from 'vscode-uri' -import { CodyTool, OpenCtxTool, getDefaultCodyTools, registerDefaultTools } from './CodyTool' -import { ToolFactory, ToolRegistry, type ToolStatusCallback } from './CodyToolProvider' +import { mockLocalStorage } from '../../services/LocalStorageProvider' +import type { ContextRetriever } from '../chat-view/ContextRetriever' +import { CodyTool, OpenCtxTool } from './CodyTool' +import { CodyToolProvider, TestToolFactory, type ToolStatusCallback } from './CodyToolProvider' +import { toolboxManager } from './ToolboxManager' const mockCallback: ToolStatusCallback = { onStart: vi.fn(), @@ -29,16 +32,29 @@ class TestTool extends CodyTool { } describe('CodyTool', () => { - let factory: ToolFactory + let factory: TestToolFactory let mockSpan: any + let mockContextRetriever: ContextRetriever beforeEach(() => { vi.clearAllMocks() - factory = new ToolFactory() - registerDefaultTools(factory.registry) + + const mockRretrievedResult = [ + { + type: 'file', + uri: URI.file('/path/to/repo/newfile.ts'), + content: 'const newExample = "test result";', + source: ContextItemSource.Search, + }, + ] satisfies ContextItem[] + mockContextRetriever = { + retrieveContext: vi.fn().mockResolvedValue(mockRretrievedResult), + } as unknown as ContextRetriever + + factory = new TestToolFactory(mockContextRetriever) mockSpan = {} - factory.registry.register({ - name: 'TestTool', // Add this line to match ToolConfiguration interface + factory.register({ + name: 'TestTool', title: 'TestTool', tags: { tag: ps`TOOLTEST`, @@ -47,7 +63,7 @@ describe('CodyTool', () => { prompt: { instruction: ps`To test the CodyTool class`, placeholder: ps`TEST_CONTENT`, - example: ps`Test the tool: \`sample content\``, + examples: [ps`Test the tool: \`sample content\``], }, createInstance: config => new TestTool(config), }) @@ -63,7 +79,7 @@ describe('CodyTool', () => { const testTool = factory.createTool('TestTool') const instruction = testTool?.getInstruction() expect(instruction).toEqual( - ps`To test the CodyTool class: \`TEST_CONTENT\`` + ps`\`TEST_CONTENT\`: To test the CodyTool class.\n\t- Test the tool: \`sample content\`` ) }) @@ -114,9 +130,9 @@ describe('CodyTool', () => { }) it('should register and retrieve tools correctly', () => { - const toolConfig = factory.registry.get('TestTool') - expect(toolConfig).toBeDefined() - expect(toolConfig?.name).toBe('TestTool') + const tools = factory.getInstances() + expect(tools.length).toBeGreaterThan(0) + expect(tools.some(t => t.config.title === 'TestTool')).toBeTruthy() }) it('should create tool instances using the factory', () => { @@ -167,7 +183,7 @@ describe('CodyTool', () => { prompt: { instruction: ps`Test OpenCtx provider`, placeholder: ps`CTX_QUERY`, - example: ps`Test query: \`query\``, + examples: [ps`Test query: \`query\``], }, } @@ -187,24 +203,34 @@ describe('CodyTool', () => { }, })) - it('should register all default tools', () => { - const registry = new ToolRegistry() - registerDefaultTools(registry) - - expect(registry.get('MemoryTool')).toBeDefined() - expect(registry.get('SearchTool')).toBeDefined() - expect(registry.get('CliTool')).toBeDefined() - expect(registry.get('FileTool')).toBeDefined() + // Update to use namespace-based approach + beforeEach(() => { + CodyToolProvider.initialize({ retrieveContext: vi.fn() }) }) - it('should create default tools based on shell context', () => { - const contextRetriever = { retrieveContext: vi.fn() } - - const toolsWithShell = getDefaultCodyTools(true, contextRetriever, factory) - expect(toolsWithShell.length).toBeGreaterThan(0) - - const toolsWithoutShell = getDefaultCodyTools(false, contextRetriever, factory) - expect(toolsWithoutShell.length).toBeLessThan(toolsWithShell.length) + it('should register all default tools based on toolbox settings', () => { + const mockedToolboxSettings = { agent: { name: 'deep-cody' }, shell: { enabled: true } } + vi.spyOn(toolboxManager, 'getSettings').mockReturnValue(mockedToolboxSettings) + const localStorageData: { [key: string]: unknown } = {} + mockLocalStorage({ + get: (key: string) => localStorageData[key], + update: (key: string, value: unknown) => { + localStorageData[key] = value + }, + } as any) + + const tools = CodyToolProvider.getTools() + expect(tools.some(t => t.config.title.includes('Cody Memory'))).toBeTruthy() + expect(tools.some(t => t.config.title.includes('Code Search'))).toBeTruthy() + expect(tools.some(t => t.config.title.includes('Codebase File'))).toBeTruthy() + expect(tools.some(t => t.config.title.includes('Terminal'))).toBeTruthy() + + // Disable shell and check if terminal tool is removed. + mockedToolboxSettings.shell.enabled = false + vi.spyOn(toolboxManager, 'getSettings').mockReturnValue(mockedToolboxSettings) + const newTools = CodyToolProvider.getTools() + expect(newTools.some(t => t.config.title.includes('Terminal'))).toBeFalsy() + expect(newTools.length).toBe(tools.length - 1) }) }) }) diff --git a/vscode/src/chat/agentic/CodyTool.ts b/vscode/src/chat/agentic/CodyTool.ts index eb30631b35f5..d1178aa6c889 100644 --- a/vscode/src/chat/agentic/CodyTool.ts +++ b/vscode/src/chat/agentic/CodyTool.ts @@ -1,13 +1,13 @@ -import type { ImportedProviderConfiguration } from '@openctx/client' import type { Span } from '@opentelemetry/api' import { type ContextItem, - type ContextItemOpenCtx, ContextItemSource, + type ContextItemWithContent, type ContextMentionProviderMetadata, PromptString, firstValueFrom, logDebug, + openCtx, parseMentionQuery, pendingOperation, ps, @@ -19,7 +19,8 @@ import { type ContextRetriever, toStructuredMentions } from '../chat-view/Contex import { getChatContextItemsForMention } from '../context/chatContext' import { getCorpusContextItemsForEditorState } from '../initialContext' import { CodyChatMemory } from './CodyChatMemory' -import type { ToolFactory, ToolRegistry, ToolStatusCallback } from './CodyToolProvider' +import type { ToolStatusCallback } from './CodyToolProvider' +import { RawTextProcessor } from './DeepCody' /** * Configuration interface for CodyTool instances. @@ -34,7 +35,7 @@ export interface CodyToolConfig { prompt: { instruction: PromptString placeholder: PromptString - example: PromptString + examples: PromptString[] } } @@ -42,6 +43,7 @@ export interface CodyToolConfig { * Abstract base class for Cody tools. */ export abstract class CodyTool { + protected readonly performedQueries = new Set() constructor(public readonly config: CodyToolConfig) {} private static readonly EXECUTION_TIMEOUT_MS = 30000 // 30 seconds @@ -50,20 +52,34 @@ export abstract class CodyTool { */ public getInstruction(): PromptString { const { tag, subTag } = this.config.tags - const { instruction, placeholder } = this.config.prompt - return ps`${instruction}: \`<${tag}><${subTag}>${placeholder}\`` + const { instruction, placeholder, examples } = this.config.prompt + try { + const prompt = ps`\`<${tag}><${subTag}>${placeholder}\`: ${instruction}.` + if (!examples?.length) { + return prompt + } + return ps`${prompt}\n\t- ${RawTextProcessor.join(examples, ps`\n\t- `)}` + } catch (error) { + logDebug('Cody Tool', `failed to getInstruction for ${tag}`, { verbose: { error } }) + return ps`` + } } /** * Parses the raw text input and extracts relevant content. */ protected parse(): string[] { const { subTag } = this.config.tags - const regex = new RegExp(`<${subTag}>(.+?)`, 's') - const parsed = (this.unprocessedText.match(new RegExp(regex, 'g')) || []) - .map(match => regex.exec(match)?.[1].trim()) - .filter(Boolean) as string[] + const regex = new RegExp(`<${subTag}>(.+?)`, 'gs') + // Use matchAll for more efficient iteration and destructuring + const newQueries = [...this.unprocessedText.matchAll(regex)] + .map(([, group]) => group?.trim()) + .filter(query => query && !this.performedQueries.has(query)) + // Add all new queries to the set at once + for (const query of newQueries) { + this.performedQueries.add(query) + } this.reset() - return parsed + return newQueries } /** * The raw text input stream. @@ -131,9 +147,13 @@ class CliTool extends CodyTool { subTag: ps`cmd`, }, prompt: { - instruction: ps`To see the output of shell commands - Do not suggest any actions that may cause harm or security breaches. Limit to actions that are safe to perform. Follow these guidelines for all operations: Commands must be single, atomic operations. Commands must have explicit, validated parameters. Reject commands containing shell metacharacters (;|&$><\`). Reject commands with string concatenation or interpolation. Reject commands containing paths outside of the current working directory. Reject commands that make network requests. Reject commands that could enable privilege escalation. Reject commands containing GTFOBin-like shell escapes. Reject commands that modify system files or settings. Reject commands that access sensitive files. Reject commands that read environment variables.`, - placeholder: ps`SHELL_COMMAND`, - example: ps`Get output for git diff: \`git diff\`. Never execute destructive commands: \`rm -rf /\`. Never execute commands with string interpolation: \`echo $HOME\`. Never execute commands that make network connections: \`ssh user@host\``, + instruction: ps`Reject all unsafe and harmful commands with tags. Execute safe command for its output with tags`, + placeholder: ps`SAFE_COMMAND`, + examples: [ + ps`Get output for git diff: \`git diff\``, + ps`List files in a directory: \`ls -l\``, + ps`Harmful commands (alter the system, access sensative information, and make network requests) MUST be rejected with tags: \`rm -rf curl localhost:1234echo $TOKEN\``, + ], }, }) } @@ -160,7 +180,9 @@ class FileTool extends CodyTool { prompt: { instruction: ps`To retrieve full content of a codebase file-DO NOT retrieve files that may contain secrets`, placeholder: ps`FILENAME`, - example: ps`See the content of different files: \`path/foo.tspath/bar.ts\``, + examples: [ + ps`See the content of different files: \`path/foo.tspath/bar.ts\``, + ], }, }) } @@ -179,8 +201,6 @@ class FileTool extends CodyTool { * Tool for performing searches within the codebase. */ class SearchTool extends CodyTool { - private performedSearch = new Set() - constructor(private contextRetriever: Pick) { super({ title: 'Code Search', @@ -189,16 +209,20 @@ class SearchTool extends CodyTool { subTag: ps`query`, }, prompt: { - instruction: ps`To search for context in the codebase`, + instruction: ps`Perform a symbol query search in the codebase-Do not support natural language search`, placeholder: ps`SEARCH_QUERY`, - example: ps`Locate the "getController" function found in an error log: \`getController\`\nSearch for a function in a file: \`getController file:controller.py\``, + examples: [ + ps`Locate a function found in an error log: \`function name\``, + ps`Search for a function in a file: \`getController file:controller.py\``, + ], }, }) } public async execute(span: Span, queries: string[]): Promise { span.addEvent('executeSearchTool') - const query = queries.find(q => !this.performedSearch.has(q)) + // TODO: Check if it makes sense to do a search on all queries or just the first one. + const query = queries[0] if (!this.contextRetriever || !query) { return [] } @@ -220,12 +244,10 @@ class SearchTool extends CodyTool { undefined, true ) - // Store the search query to avoid running the same query again. - this.performedSearch.add(query) const maxSearchItems = 30 // Keep the latest n items and remove the rest. const searchQueryItem = { type: 'file', - content: 'Queries performed: ' + Array.from(this.performedSearch).join(', '), + content: 'Queries performed: ' + Array.from(this.performedQueries).join(', '), uri: URI.file('search-history'), source: ContextItemSource.Agentic, title: 'TOOLCONTEXT', @@ -240,7 +262,7 @@ class SearchTool extends CodyTool { */ export class OpenCtxTool extends CodyTool { constructor( - private provider: ImportedProviderConfiguration, + private provider: ContextMentionProviderMetadata, config: CodyToolConfig ) { super(config) @@ -248,29 +270,56 @@ export class OpenCtxTool extends CodyTool { async execute(span: Span, queries: string[]): Promise { span.addEvent('executeOpenCtxTool') - if (!queries?.length) { + const openCtxClient = openCtx.controller + if (!queries?.length || !openCtxClient) { return [] } - logDebug('OpenCtxTool', `searching ${this.provider.providerUri} for "${queries}"`) const results: ContextItem[] = [] - const idObject: Pick = { id: this.provider.providerUri } + const idObject: Pick = { id: this.provider.id } try { + // TODO: Investigate if we can batch queries for better performance. + // For example, would it cause issues if we fire 10 requests to a OpenCtx provider for fetching Linear? for (const query of queries) { const mention = parseMentionQuery(query, idObject) - const items = (await getChatContextItemsForMention({ mentionQuery: mention })).map( - mention => { - const item = mention as ContextItemOpenCtx - const content = item.mention?.description ?? item.mention?.data?.content - return { ...item, content, source: ContextItemSource.Agentic } - } + // First get the items without content + const openCtxItems = await getChatContextItemsForMention({ mentionQuery: mention }) + // Then resolve content for each item using OpenCtx controller + const itemsWithContent = await Promise.all( + openCtxItems.map(async item => { + if (item.type === 'openctx' && item.mention) { + const mention = { + ...item.mention, + title: item.title, + } + const items = await openCtxClient.items( + { message: query, mention }, + { providerUri: item.providerUri } + ) + return items + .map( + (item): (ContextItemWithContent & { providerUri: string }) | null => + item.ai?.content + ? { + type: 'openctx', + title: item.title, + uri: URI.parse(item.url || item.providerUri), + providerUri: item.providerUri, + content: item.ai.content, + provider: 'openctx', + source: ContextItemSource.Agentic, + } + : null + ) + .filter(context => context !== null) as ContextItemWithContent[] + } + return item + }) ) - results.push(...items) + results.push(...itemsWithContent.flat()) } - logDebug( - 'CodyTool', - `${this.provider.provider.meta.name} returned ${results.length} items`, - { verbose: { results, provider: this.provider.provider } } - ) + logDebug('OpenCtxTool', `${this.provider.title} returned ${results.length} items`, { + verbose: { results, provider: this.provider.title }, + }) } catch { logDebug('CodyTool', `OpenCtx item retrieval failed for ${queries}`) } @@ -291,9 +340,13 @@ class MemoryTool extends CodyTool { subTag: ps`store`, }, prompt: { - instruction: ps`Add any information about the user's preferences (e.g. their preferred tool or language) based on the question, or when asked`, + instruction: ps`Add info about the user and their preferences (e.g. name, preferred tool, language etc) based on the question, or when asked. DO NOT store summarized questions. DO NOT clear memory unless requested`, placeholder: ps`SUMMARIZED_TEXT`, - example: ps`To add an item to memory: \`item\`\nTo see memory: \`GET\`\nTo clear memory: \`FORGET\``, + examples: [ + ps`Add user info to memory: \`info\``, + ps`Get the stored user info: \`GET\``, + ps`ONLY clear memory ON REQUEST: \`FORGET\``, + ], }, }) } @@ -326,32 +379,9 @@ class MemoryTool extends CodyTool { } // Define tools configuration once to avoid repetition -const TOOL_CONFIGS = { +export const TOOL_CONFIGS = { MemoryTool: { tool: MemoryTool, useContextRetriever: false }, SearchTool: { tool: SearchTool, useContextRetriever: true }, CliTool: { tool: CliTool, useContextRetriever: false }, FileTool: { tool: FileTool, useContextRetriever: false }, } as const - -export function getDefaultCodyTools( - isShellContextEnabled: boolean, - contextRetriever: Pick, - factory: ToolFactory -): CodyTool[] { - return Object.entries(TOOL_CONFIGS) - .filter(([name]) => name !== 'CliTool' || isShellContextEnabled) - .map(([name]) => factory.createTool(name, contextRetriever)) - .filter(Boolean) as CodyTool[] -} - -export function registerDefaultTools(registry: ToolRegistry): void { - for (const [name, { tool, useContextRetriever }] of Object.entries(TOOL_CONFIGS)) { - registry.register({ - name, - ...tool.prototype.config, - createInstance: useContextRetriever - ? (_, contextRetriever) => new tool(contextRetriever) - : () => new tool(), - }) - } -} diff --git a/vscode/src/chat/agentic/CodyToolProvider.test.ts b/vscode/src/chat/agentic/CodyToolProvider.test.ts new file mode 100644 index 000000000000..8abf9f39a15a --- /dev/null +++ b/vscode/src/chat/agentic/CodyToolProvider.test.ts @@ -0,0 +1,172 @@ +import { type ContextItem, ContextItemSource, openCtx, ps } from '@sourcegraph/cody-shared' +import { Observable } from 'observable-fns' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { URI } from 'vscode-uri' +import { mockLocalStorage } from '../../services/LocalStorageProvider' +import type { ContextRetriever } from '../chat-view/ContextRetriever' +import { CodyTool, type CodyToolConfig } from './CodyTool' +import { CodyToolProvider, TestToolFactory, type ToolConfiguration } from './CodyToolProvider' +import { toolboxManager } from './ToolboxManager' + +const localStorageData: { [key: string]: unknown } = {} +mockLocalStorage({ + get: (key: string) => localStorageData[key], + update: (key: string, value: unknown) => { + localStorageData[key] = value + }, +} as any) + +const mockContextRetriever = { + retrieveContext: vi.fn(), +} as unknown as Pick + +describe('CodyToolProvider', () => { + // Create a mock controller before tests + const mockController = { + meta: vi.fn(), + metaChanges: vi.fn().mockReturnValue( + new Observable(subscriber => { + subscriber.next([ + { + id: 'test', + name: 'Test Provider', + queryLabel: 'Test Query', + emptyLabel: '', + mentions: { + label: 'Test Query', + }, + providerUri: 'test-provider', + }, + { + id: 'modelcontextprotocol-test', + name: 'Test Provider MCP', + mentions: { + label: 'Test Query MCP', + }, + providerUri: 'test-provider-mcp', + }, + ]) + return () => {} + }) + ), + mentions: vi.fn(), + mentionsChanges: vi.fn().mockReturnValue( + new Observable(subscriber => { + subscriber.next([]) + return () => {} + }) + ), + items: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + CodyToolProvider.initialize(mockContextRetriever) + openCtx.controller = mockController + }) + + it('should register default tools on initialization', () => { + const tools = CodyToolProvider.getTools() + expect(tools.length).toBeGreaterThan(0) + expect(tools.some(tool => tool.config.title.includes('Code Search'))).toBe(true) + expect(tools.some(tool => tool.config.title.includes('Cody Memory'))).toBe(true) + }) + + it('should set up OpenCtx provider listener and build OpenCtx tools from provider metadata', async () => { + openCtx.controller = mockController + CodyToolProvider.setupOpenCtxProviderListener() + expect(openCtx.controller?.metaChanges).toHaveBeenCalled() + // Wait for the observable to emit + await new Promise(resolve => setTimeout(resolve, 0)) + + const tools = CodyToolProvider.getTools() + expect(tools.some(tool => tool.config.title === 'Test Provider')).toBeTruthy() + expect(tools.some(tool => tool.config.title === 'Test Provider MCP')).toBeTruthy() + expect(tools.some(tool => tool.config.tags.tag.toString() === 'TOOLTESTPROVIDER')).toBeTruthy() + expect( + tools.some(tool => tool.config.tags.tag.toString() === 'TOOLTESTPROVIDERMCP') + ).toBeTruthy() + }) + + it('should not include CLI tool if shell is disabled', () => { + vi.spyOn(toolboxManager, 'getSettings').mockReturnValue({ + agent: { name: 'deep-cody' }, + shell: { enabled: false }, + }) + const tools = CodyToolProvider.getTools() + expect(tools.some(tool => tool.config.title === 'Terminal')).toBe(false) + }) + + it('should include CLI tool if shell is enabled', () => { + vi.spyOn(toolboxManager, 'getSettings').mockReturnValue({ + agent: { name: 'deep-cody' }, + shell: { enabled: true }, + }) + const tools = CodyToolProvider.getTools() + expect(tools.some(tool => tool.config.title === 'Terminal')).toBe(true) + }) +}) + +describe('ToolFactory', () => { + let factory: TestToolFactory + + class TestCodyTool extends CodyTool { + protected async execute(): Promise { + return Promise.resolve([]) + } + } + + const testToolConfig = { + name: 'TestTool', + title: 'Test Tool', + tags: { + tag: ps`TOOLTEST`, + subTag: ps`test`, + }, + prompt: { + instruction: ps`To test the ToolFactory class`, + placeholder: ps`TEST_CONTENT`, + examples: [], + }, + createInstance: (config: CodyToolConfig) => new TestCodyTool(config), + } satisfies ToolConfiguration + + beforeEach(() => { + const mockRretrievedResult = [ + { + type: 'file', + uri: URI.file('/path/to/repo/newfile.ts'), + content: 'const newExample = "test result";', + source: ContextItemSource.Search, + }, + ] satisfies ContextItem[] + const mockContextRetriever = { + retrieveContext: vi.fn().mockResolvedValue(mockRretrievedResult), + } as unknown as ContextRetriever + factory = new TestToolFactory(mockContextRetriever) + }) + + it('should register and create tools correctly', () => { + factory.register(testToolConfig) + const testTool = factory.createTool('TestTool') + expect(testTool).toBeDefined() + expect(testTool).toBeInstanceOf(CodyTool) + }) + + it('should return undefined for unregistered tools', () => { + const unknownTool = factory.createTool('UnknownTool') + expect(unknownTool).toBeUndefined() + }) + + it('should return all registered tool instances including default tools', () => { + const testToolConfig1 = { ...testToolConfig, name: 'TestTool1' } + const testToolConfig2 = { ...testToolConfig, name: 'TestTool2' } + + factory.register(testToolConfig1) + factory.register(testToolConfig2) + + const tools = factory.getInstances() + expect(tools.length).toBeGreaterThan(2) + expect(tools.filter(tool => tool instanceof TestCodyTool).length).toBe(2) + }) +}) diff --git a/vscode/src/chat/agentic/CodyToolProvider.ts b/vscode/src/chat/agentic/CodyToolProvider.ts index ab9c7f4e1c1f..8aa5edf32e49 100644 --- a/vscode/src/chat/agentic/CodyToolProvider.ts +++ b/vscode/src/chat/agentic/CodyToolProvider.ts @@ -1,20 +1,24 @@ -import { authStatus, firstValueFrom, isDefined, ps } from '@sourcegraph/cody-shared' -import { getOpenCtxProviders } from '../../context/openctx' -import type { ContextRetriever } from '../chat-view/ContextRetriever' import { - type CodyTool, - type CodyToolConfig, - OpenCtxTool, - getDefaultCodyTools, - registerDefaultTools, -} from './CodyTool' - -interface CodyShellConfig { - user?: boolean - instance?: boolean - client?: boolean -} + type ContextMentionProviderMetadata, + PromptString, + type Unsubscribable, + isDefined, + openCtx, + openCtxProviderMetadata, + ps, +} from '@sourcegraph/cody-shared' +import { map } from 'observable-fns' +import type { ContextRetriever } from '../chat-view/ContextRetriever' +import { type CodyTool, type CodyToolConfig, OpenCtxTool, TOOL_CONFIGS } from './CodyTool' +import { toolboxManager } from './ToolboxManager' +import { OPENCTX_TOOL_CONFIG } from './config' + +type Retriever = Pick +/** + * Interface for tool execution status callbacks. + * Used to track and report tool execution progress. + */ export interface ToolStatusCallback { onStart(): void onStream(tool: string, content: string): void @@ -22,138 +26,176 @@ export interface ToolStatusCallback { } /** - * CodyToolProvider is a singleton class responsible for managing and providing access to various Cody tools. - * It handles both default tools and OpenContext-based tools (like web and Linear integrations). + * Configuration interface for registering new tools. + * Extends CodyToolConfig with name and instance creation function. + */ +export interface ToolConfiguration extends CodyToolConfig { + name: string + createInstance: (config: CodyToolConfig, retriever?: Retriever) => CodyTool +} + +/** + * ToolFactory manages the creation and registration of Cody tools. * - * Key responsibilities: - * - Maintains a registry of available tools through ToolFactory - * - Initializes and manages default Cody tools - * - Manages OpenContext tools for external integrations - * - Provides a unified interface to access all available tools + * Responsibilities: + * - Maintains a registry of tool configurations + * - Creates tool instances on demand + * - Handles both default tools (Search, File, CLI, Memory) and OpenCtx tools + * - Manages tool configuration and instantiation with proper context */ -export class CodyToolProvider { - private openCtxTools: CodyTool[] = [] - private toolFactory = new ToolFactory() - private shellConfig: CodyShellConfig = { - user: false, - instance: false, - client: false, - } +class ToolFactory { + private tools: Map = new Map() - private constructor(private contextRetriever: Pick) { - this.initializeToolRegistry() - this.initializeOpenCtxTools() + constructor(private contextRetriever: Retriever) { + // Register default tools + for (const [name, { tool, useContextRetriever }] of Object.entries(TOOL_CONFIGS)) { + this.register({ + name, + ...tool.prototype.config, + createInstance: useContextRetriever + ? (_, contextRetriever) => { + if (!contextRetriever) { + throw new Error(`Context retriever required for ${name}`) + } + return new tool(contextRetriever) + } + : () => new tool(), + }) + } } - public static instance( - contextRetriever: Pick - ): CodyToolProvider { - return new CodyToolProvider(contextRetriever) + public register(toolConfig: ToolConfiguration): void { + this.tools.set(toolConfig.name, toolConfig) } - private initializeToolRegistry(): void { - registerDefaultTools(this.toolFactory.registry) + public createTool(name: string, retriever?: Retriever): CodyTool | undefined { + const config = this.tools.get(name) + if (!config) { + return undefined + } + const instance = config.createInstance(config, retriever) + return instance } - public setShellConfig(config: CodyShellConfig): void { - // merge the new config into the old config - const newConfig = { ...this.shellConfig, ...config } - this.shellConfig = newConfig + public getInstances(): CodyTool[] { + // Create fresh instances of all registered tools + return Array.from(this.tools.entries()) + .filter(([name]) => name !== 'CliTool' || toolboxManager.getSettings()?.shell?.enabled) + .map(([_, config]) => config.createInstance(config, this.contextRetriever)) + .filter(isDefined) } - public get isShellEnabled(): boolean { - return Boolean(this.shellConfig.client && this.shellConfig.instance && this.shellConfig.user) + public createDefaultTools(contextRetriever?: Retriever): CodyTool[] { + return Object.entries(TOOL_CONFIGS) + .map(([name]) => this.createTool(name, contextRetriever)) + .filter(isDefined) } - public getTools(): CodyTool[] { - const defaultTools = getDefaultCodyTools( - this.isShellEnabled, - this.contextRetriever, - this.toolFactory - ) - return [...defaultTools, ...this.openCtxTools] + public createOpenCtxTools(providers: ContextMentionProviderMetadata[]): CodyTool[] { + return providers + .map(provider => { + const toolName = this.generateToolName(provider) + const config = this.getToolConfig(provider) + this.register({ + name: toolName, + ...config, + createInstance: cfg => new OpenCtxTool(provider, cfg), + }) + return this.createTool(toolName) + }) + .filter(isDefined) } - private async initializeOpenCtxTools(): Promise { - this.openCtxTools = await this.buildOpenCtxCodyTools() + private generateToolName(provider: ContextMentionProviderMetadata): string { + const suffix = provider.id.includes('modelcontextprotocol') ? 'MCP' : '' + return ( + 'TOOL' + + provider.title + .split('/') + .pop() + ?.replace(/\s+/g, '') + ?.toUpperCase() + ?.replace(/[^A-Z0-9]/g, '') + + suffix + ) } - private async buildOpenCtxCodyTools(): Promise { - const OPENCTX_CONFIG = { - 'internal-web-provider': { - title: 'Web (via OpenCtx)', - tags: { - tag: ps`TOOLWEB`, - subTag: ps`link`, - }, - prompt: { - instruction: ps`To retrieve content from the link of a webpage`, - placeholder: ps`URL`, - example: ps`Content from the URL: \`https://sourcegraph.com\``, - }, - }, - 'internal-linear-issues': { - title: 'Linear (via OpenCtx)', + private getToolConfig(provider: ContextMentionProviderMetadata): CodyToolConfig { + const defaultConfig = Object.entries(OPENCTX_TOOL_CONFIG).find( + c => provider.id.toLowerCase().includes(c[0]) || provider.title.toLowerCase().includes(c[0]) + ) + return ( + defaultConfig?.[1] ?? { + title: provider.title, tags: { - tag: ps`TOOLLINEAR`, - subTag: ps`issue`, + tag: PromptString.unsafe_fromUserQuery(this.generateToolName(provider)), + subTag: ps`get`, }, prompt: { - instruction: ps`To retrieve issues in Linear`, - placeholder: ps`KEYWORD`, - example: ps`Issue about Ollama rate limiting: \`ollama rate limit\``, + instruction: PromptString.unsafe_fromUserQuery(provider.queryLabel), + placeholder: ps`QUERY`, + examples: [], }, - }, - } - - const providers = await firstValueFrom(getOpenCtxProviders(authStatus, true)) - return providers - .map(provider => { - const config = OPENCTX_CONFIG[provider.providerUri as keyof typeof OPENCTX_CONFIG] - if (config) { - this.toolFactory.registry.register({ - name: provider.providerUri, - ...config, - createInstance: toolConfig => - new OpenCtxTool(provider, toolConfig as CodyToolConfig), - }) - return this.toolFactory.createTool(provider.providerUri) - } - return null - }) - .filter(isDefined) + } + ) } } -interface ToolConfiguration extends CodyToolConfig { - name: string - createInstance: (config: CodyToolConfig, ...args: any[]) => CodyTool -} +/** + * CodyToolProvider serves as the central manager for all Cody tool functionality. + * + * Key Features: + * 1. Tool Management + * - Initializes and maintains the ToolFactory instance + * - Provides access to all available tools through getTools() + * + * 2. OpenCtx Integration + * - Sets up listeners for OpenCtx providers (e.g., web and Linear integrations) + * - Dynamically creates tools based on available OpenCtx providers + * + * 3. Tool Registry + * - Manages registration of default tools (Search, File, CLI, Memory) + * - Handles tool configuration and initialization with proper context + * + * Usage: + * - Initialize with context retriever using initialize() + * - Access tools using getTools() + * - Set up OpenCtx integration using setupOpenCtxProviderListener() + */ +export class CodyToolProvider { + public factory: ToolFactory -export class ToolRegistry { - private tools: Map = new Map() + private static instance: CodyToolProvider | undefined + public static openCtxSubscription: Unsubscribable | undefined - register(toolConfig: ToolConfiguration): void { - this.tools.set(toolConfig.name, toolConfig) + private constructor(contextRetriever: Retriever) { + this.factory = new ToolFactory(contextRetriever) } - get(name: string): ToolConfiguration | undefined { - return this.tools.get(name) + public static initialize(contextRetriever: Retriever): void { + CodyToolProvider.instance = new CodyToolProvider(contextRetriever) } - getAllTools(): ToolConfiguration[] { - return Array.from(this.tools.values()) + public static getTools(): CodyTool[] { + return CodyToolProvider.instance?.factory.getInstances() ?? [] } -} -export class ToolFactory { - public readonly registry = new ToolRegistry() + public static setupOpenCtxProviderListener(): void { + const provider = CodyToolProvider.instance + if (provider && !CodyToolProvider.openCtxSubscription && openCtx.controller) { + CodyToolProvider.openCtxSubscription = openCtx.controller + .metaChanges({}, {}) + .pipe(map(providers => providers.filter(p => !!p.mentions).map(openCtxProviderMetadata))) + .subscribe(providerMeta => provider.factory.createOpenCtxTools(providerMeta)) + } + } - createTool(name: string, ...args: any[]): CodyTool | undefined { - const config = this.registry.get(name) - if (config) { - return config.createInstance(config, ...args) + public static dispose(): void { + if (CodyToolProvider.openCtxSubscription) { + CodyToolProvider.openCtxSubscription.unsubscribe() + CodyToolProvider.openCtxSubscription = undefined } - return undefined } } + +export class TestToolFactory extends ToolFactory {} diff --git a/vscode/src/chat/agentic/DeepCody.test.ts b/vscode/src/chat/agentic/DeepCody.test.ts index d93e45f403a8..b1107ee58cca 100644 --- a/vscode/src/chat/agentic/DeepCody.test.ts +++ b/vscode/src/chat/agentic/DeepCody.test.ts @@ -6,6 +6,7 @@ import { type ContextItem, ContextItemSource, DOTCOM_URL, + type ProcessingStep, featureFlagProvider, mockAuthStatus, mockClientCapabilities, @@ -20,7 +21,6 @@ import { mockLocalStorage } from '../../services/LocalStorageProvider' import { ChatBuilder } from '../chat-view/ChatBuilder' import type { ContextRetriever } from '../chat-view/ContextRetriever' import * as initialContext from '../initialContext' -import type { CodyTool } from './CodyTool' import { CodyToolProvider } from './CodyToolProvider' import { DeepCodyAgent } from './DeepCody' @@ -31,12 +31,23 @@ describe('DeepCody', () => { authenticated: true, } + const mockRretrievedResult = [ + { + type: 'file', + uri: URI.file('/path/to/repo/newfile.ts'), + content: 'const newExample = "test result";', + source: ContextItemSource.Search, + }, + ] satisfies ContextItem[] + let mockChatBuilder: ChatBuilder let mockChatClient: ChatClient let mockContextRetriever: ContextRetriever let mockCurrentContext: ContextItem[] - let mockCodyTools: CodyTool[] + let mockCodyToolProvider: typeof CodyToolProvider let localStorageData: { [key: string]: unknown } = {} + let mockStatusCallback: (steps: ProcessingStep[]) => void + mockLocalStorage({ get: (key: string) => localStorageData[key], update: (key: string, value: unknown) => { @@ -71,21 +82,24 @@ describe('DeepCody', () => { } as unknown as ChatClient mockContextRetriever = { - retrieveContext: vi.fn(), + retrieveContext: vi.fn().mockResolvedValue(mockRretrievedResult), } as unknown as ContextRetriever - mockCodyTools = CodyToolProvider.instance(mockContextRetriever).getTools() + CodyToolProvider.initialize(mockContextRetriever) + mockCodyToolProvider = CodyToolProvider mockCurrentContext = [ { uri: URI.file('/path/to/file.ts'), type: 'file', isTooLarge: undefined, - source: ContextItemSource.User, + source: ContextItemSource.Search, content: 'const example = "test";', }, ] + mockStatusCallback = vi.fn() + vi.spyOn(featureFlagProvider, 'evaluatedFeatureFlag').mockReturnValue(Observable.of(false)) vi.spyOn(modelsService, 'isStreamDisabled').mockReturnValue(false) vi.spyOn(ChatBuilder, 'resolvedModelForChat').mockReturnValue( @@ -99,35 +113,79 @@ describe('DeepCody', () => { vi.spyOn(modelsService, 'observeContextWindowByID').mockReturnValue( Observable.of({ input: 10000, output: 1000 }) ) + mockContextRetriever.retrieveContext = vi.fn().mockResolvedValue(mockRretrievedResult) + + vi.spyOn(initialContext, 'getCorpusContextItemsForEditorState').mockReturnValue( + Observable.of([ + { + type: 'tree', + uri: URI.file('/path/to/repo/'), + name: 'Mock Repository', + isWorkspaceRoot: true, + content: null, + source: ContextItemSource.Initial, + }, + ]) + ) }) it('initializes correctly when invoked', async () => { - const agent = new DeepCodyAgent( - mockChatBuilder, - mockChatClient, - mockCodyTools, - mockCurrentContext - ) + const agent = new DeepCodyAgent(mockChatBuilder, mockChatClient, mockStatusCallback) expect(agent).toBeDefined() }) it('retrieves additional context when response contains tool tags', async () => { const mockStreamResponse = [ - { type: 'change', text: 'test query' }, + { + type: 'change', + text: 'path/to/file.tstest query', + }, { type: 'complete' }, ] mockChatClient.chat = vi.fn().mockReturnValue(mockStreamResponse) - mockContextRetriever.retrieveContext = vi.fn().mockResolvedValue([ + vi.spyOn(initialContext, 'getCorpusContextItemsForEditorState').mockReturnValue( + Observable.of([ + { + type: 'tree', + uri: URI.file('/path/to/repo/'), + name: 'Mock Repository', + isWorkspaceRoot: true, + content: null, + source: ContextItemSource.Initial, + }, + ]) + ) + + const agent = new DeepCodyAgent(mockChatBuilder, mockChatClient, mockStatusCallback) + + const result = await agent.getContext( + 'deep-cody-test-interaction-id', + new AbortController().signal, + mockCurrentContext + ) + + const mockTools = mockCodyToolProvider.getTools() + expect(mockChatClient.chat).toHaveBeenCalled() + expect(mockTools).toHaveLength(3) + expect(mockTools.some(tool => tool.config.tags.tag === ps`TOOLCLI`)).toBeFalsy() + + expect(result.some(r => r.content === 'const example = "test";')).toBeTruthy() + expect(result.some(r => r.content === 'const newExample = "test result";')).toBeFalsy() + }) + + it('retrieves removes current context if current context is not included in context_list', async () => { + const mockStreamResponse = [ { - type: 'file', - uri: URI.file('/path/to/repo/newfile.ts'), - content: 'const newExample = "test result";', - source: ContextItemSource.Agentic, + type: 'change', + text: 'path/to/repo/newfile.tstest query 1test query 2', }, - ]) + { type: 'complete' }, + ] + + mockChatClient.chat = vi.fn().mockReturnValue(mockStreamResponse) vi.spyOn(initialContext, 'getCorpusContextItemsForEditorState').mockReturnValue( Observable.of([ @@ -142,23 +200,16 @@ describe('DeepCody', () => { ]) ) - const agent = new DeepCodyAgent( - mockChatBuilder, - mockChatClient, - mockCodyTools, + const agent = new DeepCodyAgent(mockChatBuilder, mockChatClient, mockStatusCallback) + + const result = await agent.getContext( + 'deep-cody-test-interaction-id', + new AbortController().signal, mockCurrentContext ) - const result = await agent.getContext('deep-cody-test-interaction-id', { - aborted: false, - } as AbortSignal) - expect(mockChatClient.chat).toHaveBeenCalled() - expect(mockCodyTools).toHaveLength(3) - expect(mockCodyTools.some(tool => tool.config.tags.tag === ps`TOOLCLI`)).toBeFalsy() - expect(mockContextRetriever.retrieveContext).toHaveBeenCalled() - expect(result).toHaveLength(2) - expect(result[0].content).toBe('const example = "test";') - expect(result[1].content).toBe('const newExample = "test result";') + expect(result.some(r => r.content === 'const example = "test";')).toBeFalsy() + expect(result.some(r => r.content === 'const newExample = "test result";')).toBeTruthy() }) }) diff --git a/vscode/src/chat/agentic/DeepCody.ts b/vscode/src/chat/agentic/DeepCody.ts index 447b1761362f..9f1d92c122de 100644 --- a/vscode/src/chat/agentic/DeepCody.ts +++ b/vscode/src/chat/agentic/DeepCody.ts @@ -1,58 +1,134 @@ import type { Span } from '@opentelemetry/api' import { + BotResponseMultiplexer, + type ChatClient, CodyIDE, type ContextItem, + ContextItemSource, + type Message, + type ProcessingStep, + type PromptMixin, PromptString, clientCapabilities, getClientPromptString, isDefined, logDebug, + newPromptMixin, ps, telemetryRecorder, wrapInActiveSpan, } from '@sourcegraph/cody-shared' -import { isUserAddedItem } from '../../prompt-builder/utils' -import { CodyChatAgent } from './CodyChatAgent' -import { CODYAGENT_PROMPTS } from './prompts' +import { forkSignal } from '../../completions/utils' +import { getCategorizedMentions, isUserAddedItem } from '../../prompt-builder/utils' +import type { ChatBuilder } from '../chat-view/ChatBuilder' +import { DefaultPrompter } from '../chat-view/prompt' +import type { CodyTool } from './CodyTool' +import { CodyToolProvider, type ToolStatusCallback } from './CodyToolProvider' +import { ProcessManager } from './ProcessManager' +import { ACTIONS_TAGS, CODYAGENT_PROMPTS } from './prompts' /** - * A DeepCodyAgent is created for each chat submitted by the user. + * A DeepCodyAgent handles advanced context retrieval and analysis for chat interactions. + * It uses a multi-step process to: + * 1. Review and analyze existing context + * 2. Dynamically retrieve additional relevant context using configured tools + * 3. Filter and validate context items for improved chat responses * - * It is responsible for reviewing the retrieved context, and perform agentic context retrieval for the chat request. + * Key features: + * - Integrates with multiple CodyTools for context gathering + * - Uses BotResponseMultiplexer for handling tool responses + * - Supports telemetry and tracing + * - Implements iterative context review with configurable max loops */ -export class DeepCodyAgent extends CodyChatAgent { - private models = { - review: this.chatBuilder.selectedModel, +export class DeepCodyAgent { + public static readonly id = 'deep-cody' + /** + * NOTE: Currently A/B test to default to 3.5 Haiku / 3.5 Sonnet for the review step. + */ + public static model: string | undefined = undefined + + protected readonly multiplexer = new BotResponseMultiplexer() + protected readonly promptMixins: PromptMixin[] = [] + protected readonly tools: CodyTool[] + protected statusCallback: ToolStatusCallback + private stepsManager: ProcessManager + + protected context: ContextItem[] = [] + + constructor( + protected readonly chatBuilder: ChatBuilder, + protected readonly chatClient: Pick, + statusUpdateCallback: (steps: ProcessingStep[]) => void + ) { + // Initialize tools, handlers and mixins in constructor + this.tools = CodyToolProvider.getTools() + + this.initializeMultiplexer(this.tools) + this.buildPrompt(this.tools) + + this.stepsManager = new ProcessManager(steps => statusUpdateCallback(steps)) + + this.statusCallback = { + onStart: () => { + this.stepsManager.initializeStep() + }, + onStream: (toolName, content) => { + this.stepsManager.addStep(toolName, content) + }, + onComplete: (toolName, error) => { + this.stepsManager.completeStep(toolName, error) + }, + } } - protected buildPrompt(): PromptString { - const toolInstructions = this.tools.map(t => t.getInstruction()) - const toolExamples = this.tools.map(t => t.config.prompt.example) - const join = (prompts: PromptString[]) => PromptString.join(prompts, ps`\n- `) + /** + * Register the tools with the multiplexer. + */ + protected initializeMultiplexer(tools: CodyTool[]): void { + for (const tool of tools) { + this.multiplexer.sub(tool.config.tags.tag.toString(), { + onResponse: async (content: string) => tool.stream(content), + onTurnComplete: async () => {}, + }) + } + } - return CODYAGENT_PROMPTS.review - .replace('{{CODY_TOOLS_PLACEHOLDER}}', join(toolInstructions)) - .replace('{{CODY_TOOLS_EXAMPLES_PLACEHOLDER}}', join(toolExamples)) + /** + * Construct the prompt based on the tools available. + */ + protected buildPrompt(tools: CodyTool[]): void { + const toolInstructions = tools.map(t => t.getInstruction()) + const prompt = CODYAGENT_PROMPTS.review + .replace('{{CODY_TOOLS_PLACEHOLDER}}', RawTextProcessor.join(toolInstructions, ps`\n- `)) .replace( '{{CODY_IDE}}', getClientPromptString(clientCapabilities().agentIDE || CodyIDE.VSCode) ) + // logDebug('Deep Cody', 'buildPrompt', { verbose: prompt }) + this.promptMixins.push(newPromptMixin(prompt)) } /** - * Retrieves the context for the current chat, by iteratively reviewing the context and adding new items - * until the maximum number of loops is reached or the chat is aborted. + * Retrieves and refines context for the current chat through an iterative review process. + * The process continues until either: + * - Maximum loop count is reached + * - Chat is aborted + * - No new context items are found + * - All new items are user-added * - * @param span - The OpenTelemetry span for the current chat. - * @param chatAbortSignal - The abort signal for the current chat. - * @param maxLoops - The maximum number of loops to perform when retrieving the context. - * @returns The context items retrieved for the current chat. + * @param requestID - Unique identifier for the chat request + * @param chatAbortSignal - Signal to abort the context retrieval + * @param context - Initial context items + * @param maxLoops - Maximum number of review iterations (default: 2) + * @returns Refined and expanded context items for the chat */ public async getContext( requestID: string, chatAbortSignal: AbortSignal, + context: ContextItem[], maxLoops = 2 ): Promise { + this.context = context return wrapInActiveSpan('DeepCody.getContext', span => this._getContext(requestID, span, chatAbortSignal, maxLoops) ) @@ -68,13 +144,13 @@ export class DeepCodyAgent extends CodyChatAgent { this.statusCallback?.onStart() const startTime = performance.now() - const count = await this.reviewLoop(requestID, span, chatAbortSignal, maxLoops) + const { stats, contextItems } = await this.reviewLoop(requestID, span, chatAbortSignal, maxLoops) telemetryRecorder.recordEvent('cody.deep-cody.context', 'reviewed', { privateMetadata: { durationMs: performance.now() - startTime, - ...count, - model: this.models.review, + ...stats, + model: DeepCodyAgent.model, traceId: span.spanContext().traceId, }, billingMetadata: { @@ -85,7 +161,7 @@ export class DeepCodyAgent extends CodyChatAgent { this.statusCallback?.onComplete() - return this.context + return contextItems } private async reviewLoop( @@ -93,7 +169,7 @@ export class DeepCodyAgent extends CodyChatAgent { span: Span, chatAbortSignal: AbortSignal, maxLoops: number - ): Promise<{ context: number; loop: number }> { + ): Promise<{ stats: { context: number; loop: number }; contextItems: ContextItem[] }> { span.addEvent('reviewLoop') const stats = { context: 0, loop: 0 } for (let i = 0; i < maxLoops && !chatAbortSignal.aborted; i++) { @@ -109,11 +185,18 @@ export class DeepCodyAgent extends CodyChatAgent { if (newContext.every(isUserAddedItem)) break } - return stats + return { stats, contextItems: this.context } } /** - * Performs a review of the current context and generates new context items based on the review outcome. + * Reviews current context and generates new context items using configured tools. + * The review process: + * 1. Builds a prompt using current context + * 2. Processes the prompt through chat client + * 3. Executes relevant tools based on the response + * 4. Validates and filters the resulting context items + * + * @returns Array of new context items from the review */ private async review( requestID: string, @@ -128,30 +211,53 @@ export class DeepCodyAgent extends CodyChatAgent { requestID, promptData.prompt, chatAbortSignal, - this.models.review + DeepCodyAgent.model ) - // If the response is empty or contains the CONTEXT_SUFFICIENT token, the context is sufficient. - if (!res || res?.includes('CONTEXT_SUFFICIENT')) { - // Process the response without generating any context items. - for (const tool of this.toolHandlers.values()) { - tool.processResponse?.() - } - return [] - } + if (!res) return [] const results = await Promise.all( - Array.from(this.toolHandlers.entries()).map(async ([name, tool]) => { + this.tools.map(async tool => { try { - // Check abort signal before each tool run - if (chatAbortSignal.aborted) { - return [] - } + if (chatAbortSignal.aborted) return [] return await tool.run(span, this.statusCallback) } catch (error) { - this.statusCallback?.onComplete(name, error as Error) + const errorMessage = + error instanceof Error + ? error.message + : typeof error === 'object' && error !== null + ? JSON.stringify(error) + : String(error) + const errorObject = error instanceof Error ? error : new Error(errorMessage) + this.statusCallback.onComplete(tool.config.tags.tag.toString(), errorObject) return [] } }) ) + + // If the response is empty or contains the known token, the context is sufficient. + if (res?.includes(ACTIONS_TAGS.ANSWER.toString())) { + // Process the response without generating any context items. + for (const tool of this.tools) { + tool.processResponse?.() + } + } + + const reviewed = [] + + // Extract all the strings from between tags. + const valid = RawTextProcessor.extract(res, ACTIONS_TAGS.CONTEXT.toString()) + for (const contextName of valid || []) { + const foundValidatedItems = this.context.filter(c => c.uri.path.endsWith(contextName)) + for (const found of foundValidatedItems) { + reviewed.push({ ...found, source: ContextItemSource.Agentic }) + } + } + + // Replace the current context list with the reviewed context. + if (valid.length + reviewed.length > 0) { + reviewed.push(...this.context.filter(c => isUserAddedItem(c))) + this.context = reviewed + } + return results.flat().filter(isDefined) } catch (error) { await this.multiplexer.notifyTurnComplete() @@ -161,4 +267,89 @@ export class DeepCodyAgent extends CodyChatAgent { return [] } } + + protected async processStream( + requestID: string, + message: Message[], + parentSignal: AbortSignal, + model?: string + ): Promise { + const abortController = forkSignal(parentSignal || new AbortController().signal) + const stream = await this.chatClient.chat( + message, + { model, maxTokensToSample: 4000 }, + abortController.signal, + requestID + ) + const accumulated = new RawTextProcessor() + try { + for await (const msg of stream) { + if (parentSignal?.aborted) break + if (msg.type === 'change') { + const newText = msg.text.slice(accumulated.length) + accumulated.append(newText) + await this.multiplexer.publish(newText) + } + if (msg.type === 'complete') { + break + } + if (msg.type === 'error') { + throw msg.error + } + } + } finally { + await this.multiplexer.notifyTurnComplete() + } + + return accumulated.consumeAndClear() + } + + protected getPrompter(items: ContextItem[]): DefaultPrompter { + const { explicitMentions, implicitMentions } = getCategorizedMentions(items) + const MAX_SEARCH_ITEMS = 30 + return new DefaultPrompter(explicitMentions, implicitMentions.slice(-MAX_SEARCH_ITEMS)) + } +} + +/** + * Handles building and managing raw text returned by LLM with support for: + * - Incremental string building + * - XML-style tag content extraction + * - Length tracking + * - String joining with custom connectors + */ +export class RawTextProcessor { + private parts: string[] = [] + + public append(str: string): void { + this.parts.push(str) + } + + // Destructive read that clears state + public consumeAndClear(): string { + const joined = this.parts.join('') + this.reset() + return joined + } + + public get length(): number { + return this.parts.reduce((acc, part) => acc + part.length, 0) + } + + private reset(): void { + this.parts = [] + } + + public static extract(response: string, tag: string): string[] { + const tagLength = tag.length + return ( + response + .match(new RegExp(`<${tag}>(.*?)<\/${tag}>`, 'g')) + ?.map(m => m.slice(tagLength + 2, -(tagLength + 3))) || [] + ) + } + + public static join(prompts: PromptString[], connector = ps`\n`) { + return PromptString.join(prompts, connector) + } } diff --git a/vscode/src/chat/agentic/ToolboxManager.ts b/vscode/src/chat/agentic/ToolboxManager.ts new file mode 100644 index 000000000000..480de6f9d0f2 --- /dev/null +++ b/vscode/src/chat/agentic/ToolboxManager.ts @@ -0,0 +1,122 @@ +import { + type AgentToolboxSettings, + FeatureFlag, + combineLatest, + distinctUntilChanged, + featureFlagProvider, + logDebug, + modelsService, + pendingOperation, + startWith, + userProductSubscription, +} from '@sourcegraph/cody-shared' +import { type Observable, Subject, map } from 'observable-fns' +import { env } from 'vscode' +import { localStorage } from '../../services/LocalStorageProvider' +import { DeepCodyAgent } from './DeepCody' + +// Using a readonly interface improves performance by preventing mutations +const DEFAULT_SHELL_CONFIG = Object.freeze({ + user: false, + instance: false, + client: false, +}) + +type StoredToolboxSettings = { + readonly agent: string | undefined + readonly shell: boolean +} + +/** + * ToolboxManager manages the toolbox settings for the Cody chat agents. + * NOTE: This is a Singleton class. + */ +class ToolboxManager { + private static readonly STORAGE_KEY = 'CODYAGENT_TOOLBOX_SETTINGS' + private static instance?: ToolboxManager + + private constructor() { + // Using private constructor for Singleton pattern + } + + private isEnabled = false + private readonly changeNotifications = new Subject() + private shellConfig = { ...DEFAULT_SHELL_CONFIG } + + public static getInstance(): ToolboxManager { + // Singleton pattern with lazy initialization + return ToolboxManager.instance ? ToolboxManager.instance : new ToolboxManager() + } + + private getStoredUserSettings(): StoredToolboxSettings { + return ( + localStorage.get(ToolboxManager.STORAGE_KEY) ?? { + agent: this.isEnabled ? 'deep-cody' : undefined, + shell: false, + } + ) + } + + public getSettings(): AgentToolboxSettings | null { + if (!this.isEnabled) { + return null + } + const { agent, shell } = this.getStoredUserSettings() + const isShellEnabled = this.shellConfig.instance && this.shellConfig.client ? shell : undefined + return { + agent: { name: agent }, + // Only show shell option if it's supported by instance and client. + shell: { enabled: isShellEnabled ?? false }, + } + } + + public async updateSettings(settings: AgentToolboxSettings): Promise { + logDebug('ToolboxManager', 'Updating toolbox settings', { verbose: settings }) + await localStorage.set(ToolboxManager.STORAGE_KEY, { + agent: settings.agent?.name, + shell: settings.shell?.enabled ?? false, + }) + this.changeNotifications.next() + } + /** + * Returns a real-time Observable stream of toolbox settings that updates when any of the following changes: + * - Feature flags + * - User subscription + * - Available models + * - Manual settings updates + * Use this when you need to react to settings changes over time. + */ + public readonly observable: Observable = combineLatest( + featureFlagProvider.evaluatedFeatureFlag(FeatureFlag.DeepCody), + featureFlagProvider.evaluatedFeatureFlag(FeatureFlag.ContextAgentDefaultChatModel), + featureFlagProvider.evaluatedFeatureFlag(FeatureFlag.DeepCodyShellContext), + userProductSubscription.pipe(distinctUntilChanged()), + modelsService.modelsChanges.pipe( + map(models => (models === pendingOperation ? null : models)), + distinctUntilChanged() + ), + this.changeNotifications.pipe(startWith(undefined)) + ).pipe( + map(([deepCodyEnabled, useDefaultChatModel, instanceShellContextFlag, sub, models]) => { + // Return null if subscription is pending or user can upgrade (free user) + if (sub === pendingOperation || sub?.userCanUpgrade || !models || !deepCodyEnabled) { + this.isEnabled = false + return null + } + + // TODO (bee): Remove once A/B test is over - 3.5 Haiku vs default chat model. + const haikuModel = models.primaryModels.find(model => model.id.includes('5-haiku')) + DeepCodyAgent.model = useDefaultChatModel ? models.preferences.defaults.chat : haikuModel?.id + this.isEnabled = Boolean(DeepCodyAgent.model) + + Object.assign(this.shellConfig, { + instance: instanceShellContextFlag, + client: Boolean(env.shell), + }) + + return this.getSettings() + }) + ) +} + +export const toolboxManager = ToolboxManager.getInstance() diff --git a/vscode/src/chat/agentic/config.ts b/vscode/src/chat/agentic/config.ts new file mode 100644 index 000000000000..2fecdae50879 --- /dev/null +++ b/vscode/src/chat/agentic/config.ts @@ -0,0 +1,102 @@ +import { ps } from '@sourcegraph/cody-shared' +import type { CodyToolConfig } from './CodyTool' + +// Known tools that can be used in the chat. +export const OPENCTX_TOOL_CONFIG: Record = { + web: { + title: 'Web (OpenCtx)', + tags: { + tag: ps`TOOLWEB`, + subTag: ps`link`, + }, + prompt: { + instruction: ps`To retrieve content from the link of a webpage`, + placeholder: ps`URL`, + examples: [ + ps`Content from the URL: \`https://sourcegraph.com\``, + ], + }, + }, + linear: { + title: 'Linear Issue (OpenCtx)', + tags: { + tag: ps`TOOLLINEAR`, + subTag: ps`issue`, + }, + prompt: { + instruction: ps`To retrieve issues in Linear`, + placeholder: ps`KEYWORD`, + examples: [ + ps`Issue about Ollama rate limiting: \`ollama rate limit\``, + ], + }, + }, + fetch: { + title: 'Fetch (MCP)', + tags: { + tag: ps`TOOLFETCH`, + subTag: ps`uri`, + }, + prompt: { + instruction: ps`To fetch content from a uri`, + placeholder: ps`HTTP ADDRESS`, + examples: [ + ps`Content from https://google.com: \`https://google.com\``, + ], + }, + }, + 'server-github': { + title: 'GitHub (MCP)', + tags: { + tag: ps`TOOLGITHUBAPI`, + subTag: ps`action`, + }, + prompt: { + instruction: ps`Access GitHub API, enabling file operations, repository management, search functionality, and more.`, + placeholder: ps`action`, + examples: [ps`Create an issue: \`create_issue\``], + }, + }, + 'provider-github': { + title: 'GitHub Issue (OpenCtx)', + tags: { + tag: ps`TOOLGITHUBISSUE`, + subTag: ps`search`, + }, + prompt: { + instruction: ps`To retrieve issues in Github for this codebase`, + placeholder: ps`KEYWORD`, + examples: [ + ps`Issue about authentication: \`authentication\``, + ], + }, + }, + 'git-openctx': { + title: 'Git (OpenCtx)', + tags: { + tag: ps`TOOLDIFF`, + subTag: ps`diff`, + }, + prompt: { + instruction: ps`To retrieve git diff for current changes or against origin/main`, + placeholder: ps`'@diff-vs-default-branch' OR '@Uncommitted changes'`, + examples: [ + ps`Get the uncommitted changes \`Uncommitted changes\``, + ], + }, + }, + postgres: { + title: 'Postgres (MCP)', + tags: { + tag: ps`TOOLPOSTGRES`, + subTag: ps`schema`, + }, + prompt: { + instruction: ps`Get schema information for a table in the PostgreSQL database, including column names and data types`, + placeholder: ps`table`, + examples: [ + ps`Schema of the 'users' table \`users\``, + ], + }, + }, +} diff --git a/vscode/src/chat/agentic/prompts.ts b/vscode/src/chat/agentic/prompts.ts index 3249bb0bb36e..0467dfe2607d 100644 --- a/vscode/src/chat/agentic/prompts.ts +++ b/vscode/src/chat/agentic/prompts.ts @@ -1,37 +1,70 @@ import { ps } from '@sourcegraph/cody-shared' import { getOSPromptString } from '../../os' -const REVIEW_PROMPT = ps`Your task is to review the shared context and think step-by-step to determine if you can answer the [QUESTION] at the end. +export const ACTIONS_TAGS = { + ANSWER: ps`next_step`, + CONTEXT: ps`context_list`, +} + +const REVIEW_PROMPT = ps`Your task is to evaluate the shared context and think step-by-step to determine if you can answer user's request enclosed inside the tags below. -[INSTRUCTIONS] +## INSTRUCTIONS 1. Analyze the shared context and chat history thoroughly. -2. Decide if you have enough information to answer the question. -3. Respond with ONLY ONE of the following: - a) The word "CONTEXT_SUFFICIENT" if you can answer the question with the current context. - b) One or more tags to request additional information if you do not have the required context to provide a concise answer. +2. Decide if you have enough information to answer . + +## TOOLS +In this environment you have access to this set of tools you can use to fetch context before answering: +- {{CODY_TOOLS_PLACEHOLDER}} -[TOOLS] -In this environment you have access to this set of tools you can use to fetch context before answering the user's question: -{{CODY_TOOLS_PLACEHOLDER}} +## EXPECTED VALID OUTPUT +1. Add the evaluated context that you need for certain in order to answer the concisely with <{{CONTEXT_TAG}}> tags, with the filename in between: + - DO NOT add context that was not shared to the list. + - DO NOT include EMPTY <{{CONTEXT_TAG}}> list. + + <{{CONTEXT_TAG}}>shared/file1.ts<{{CONTEXT_TAG}}>shared/file2.ts<{{CONTEXT_TAG}}>command + +2. If you can answer the fully with the context added to <{{CONTEXT_TAG}}>, add "<{{ANSWER_TAG}}>" at the end: + + <{{CONTEXT_TAG}}>path/to/file1.ts<{{CONTEXT_TAG}}>path/to/file2.ts<{{CONTEXT_TAG}}>command<{{ANSWER_TAG}}> + +3. If you need more information, use ONLY the appropriate tag(s) in your response: + + path/to/file.tsclass Controller + +4. If you can answer the fully without context, respond with ONLY the word "<{{ANSWER_TAG}}>": + + <{{ANSWER_TAG}}> + +5. If you can answer the fully without context, but need to use tool per : + + user's preferences<{{ANSWER_TAG}}> + -[TOOL USAGE EXAMPLES] -{{CODY_TOOLS_EXAMPLES_PLACEHOLDER}} -- To see the full content of a codebase file and context of how the Controller class is use: \`path/to/file.tsclass Controller\` +## INVALIDE OUTPUT EXAMPLES +- Empty context list: \`<{{CONTEXT_TAG}}>\` +- Include non tags values (comments or explanations) in the response: \`<{{ANSWER_TAG}}> YOUR EXPLANATION\` +- <{{CONTEXT_TAG}}> includes context that was not shared: \`<{{CONTEXT_TAG}}>not-shared-context\` -[RESPONSE FORMAT] -- If you can answer the question fully, respond with ONLY the word "CONTEXT_SUFFICIENT". -- If you need more information, use ONLY the appropriate tag(s) in your response. Skip preamble. +## GOALS +- Determine if you can answer the question with the given context, or if you need more information. +- Your response should only contains the <{{CONTEXT_TAG}}> list, and either the word "<{{ANSWER_TAG}}>" OR the appropriate tag(s) and NOTHING else. -[NOTES] +## RULES 1. Only use tags when additional context is necessary to answer the question. 2. You may use multiple tags in a single response if needed. -3. NEVER request sensitive information or files such as passwords, API keys, or dotEnv files. -4. The user is working in {{CODY_IDE}} on ${getOSPromptString()}. +3. Never make assumption about the provided context. +4. NEVER request sensitive information or files such as passwords, API keys, or env files. +5. The user is working in the {{CODY_IDE}} on ${getOSPromptString()}. -[GOAL] -- Determine if you can answer the question with the given context, or if you need more information. -- Do not provide the actual answer or comments in this step. This is an auto-generated message. -- Your response should only contains the word "CONTEXT_SUFFICIENT" or the appropriate tag(s) and nothing else.` + +{{USER_INPUT_TEXT}} + + +## IMPORTANT +Skip preamble. ONLY include the expected tags in your response and nothing else. +This is an auto-generated message and your response will be processed by a bot using the expected tags.` + .replace(/{{ANSWER_TAG}}/g, ACTIONS_TAGS.ANSWER) + .replace(/{{CONTEXT_TAG}}/g, ACTIONS_TAGS.CONTEXT) export const CODYAGENT_PROMPTS = { review: REVIEW_PROMPT, diff --git a/vscode/src/chat/chat-view/ChatController.test.ts b/vscode/src/chat/chat-view/ChatController.test.ts index 6e6ac256388b..4298a328a830 100644 --- a/vscode/src/chat/chat-view/ChatController.test.ts +++ b/vscode/src/chat/chat-view/ChatController.test.ts @@ -139,6 +139,7 @@ describe('ChatController', () => { chatID: mockNowDate.toUTCString(), messages: [ { + agent: undefined, speaker: 'human', text: 'Test input', intent: 'chat', @@ -152,6 +153,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'assistant', model: 'my-model', intent: undefined, @@ -170,7 +172,7 @@ describe('ChatController', () => { await vi.runOnlyPendingTimersAsync() expect(mockChatClient.chat).toBeCalledTimes(1) expect(addBotMessageSpy).toHaveBeenCalledWith('1', ps`Test reply 1`, 'my-model') - expect(postMessageSpy.mock.calls.at(5)?.at(0)).toStrictEqual< + expect(postMessageSpy.mock.calls.at(6)?.at(0)).toStrictEqual< Extract >({ type: 'transcript', @@ -178,6 +180,7 @@ describe('ChatController', () => { chatID: mockNowDate.toUTCString(), messages: [ { + agent: undefined, speaker: 'human', text: 'Test input', intent: 'chat', @@ -191,6 +194,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'assistant', intent: undefined, manuallySelectedIntent: undefined, @@ -233,6 +237,7 @@ describe('ChatController', () => { chatID: mockNowDate.toUTCString(), messages: [ { + agent: undefined, speaker: 'human', text: 'Test input', intent: 'chat', @@ -246,6 +251,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'assistant', model: 'my-model', intent: undefined, @@ -259,6 +265,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'human', text: 'Test followup', intent: 'chat', @@ -272,6 +279,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'assistant', model: 'my-model', intent: undefined, @@ -313,6 +321,7 @@ describe('ChatController', () => { chatID: mockNowDate.toUTCString(), messages: [ { + agent: undefined, speaker: 'human', text: 'Test input', intent: 'chat', @@ -326,6 +335,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'assistant', model: 'my-model', intent: undefined, @@ -339,6 +349,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'human', text: 'Test edit', intent: 'chat', @@ -352,6 +363,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'assistant', model: 'my-model', intent: undefined, @@ -394,7 +406,7 @@ describe('ChatController', () => { await vi.runOnlyPendingTimersAsync() expect(mockChatClient.chat).toBeCalledTimes(1) expect(addBotMessageSpy).toHaveBeenCalledWith('1', ps`Test partial reply`, 'my-model') - expect(postMessageSpy.mock.calls.at(8)?.at(0)).toStrictEqual< + expect(postMessageSpy.mock.calls.at(9)?.at(0)).toStrictEqual< Extract >({ type: 'transcript', @@ -402,6 +414,7 @@ describe('ChatController', () => { chatID: mockNowDate.toUTCString(), messages: [ { + agent: undefined, speaker: 'human', text: 'Test input', model: undefined, @@ -415,6 +428,7 @@ describe('ChatController', () => { subMessages: undefined, }, { + agent: undefined, speaker: 'assistant', model: FIXTURE_MODEL.id, error: errorToChatError(new Error('my-error')), diff --git a/vscode/src/chat/chat-view/ChatController.ts b/vscode/src/chat/chat-view/ChatController.ts index 273ff30f8f39..0b344324444f 100644 --- a/vscode/src/chat/chat-view/ChatController.ts +++ b/vscode/src/chat/chat-view/ChatController.ts @@ -106,7 +106,7 @@ import { import { openExternalLinks } from '../../services/utils/workspace-action' import { TestSupport } from '../../test-support' import type { MessageErrorType } from '../MessageProvider' -import { CodyToolProvider } from '../agentic/CodyToolProvider' +import { toolboxManager } from '../agentic/ToolboxManager' import { getMentionMenuData } from '../context/chatContext' import type { ChatIntentAPIClient } from '../context/chatIntentAPIClient' import { observeDefaultContext } from '../initialContext' @@ -181,7 +181,6 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv private readonly chatClient: ChatControllerOptions['chatClient'] private readonly contextRetriever: ChatControllerOptions['contextRetriever'] - private readonly toolProvider: CodyToolProvider private readonly editor: ChatControllerOptions['editor'] private readonly extensionClient: ChatControllerOptions['extensionClient'] @@ -215,7 +214,6 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv this.editor = editor this.extensionClient = extensionClient this.contextRetriever = contextRetriever - this.toolProvider = CodyToolProvider.instance(this.contextRetriever) this.chatBuilder = new ChatBuilder(undefined) @@ -535,10 +533,6 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv featureFlagProvider.evaluatedFeatureFlag(FeatureFlag.CodyExperimentalOneBox) ) - private featureDeepCodyShellContext = storeLastValue( - featureFlagProvider.evaluatedFeatureFlag(FeatureFlag.DeepCodyShellContext) - ) - private async getConfigForWebview(): Promise { const { configuration, auth } = await currentResolvedConfig() const sidebarViewOnly = this.extensionClient.capabilities?.webviewNativeConfig?.view === 'single' @@ -546,11 +540,6 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv const webviewType = isEditorViewType && !sidebarViewOnly ? 'editor' : 'sidebar' const uiKindIsWeb = (cenv.CODY_OVERRIDE_UI_KIND ?? vscode.env.uiKind) === vscode.UIKind.Web const endpoints = localStorage.getEndpointHistory() ?? [] - this.toolProvider.setShellConfig({ - instance: this.featureDeepCodyShellContext.value?.last, - user: Boolean(configuration.agenticContextExperimentalShell), - client: Boolean(vscode.env.shell), - }) return { uiKindIsWeb, @@ -674,6 +663,7 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv editorState, intent: detectedIntent, manuallySelectedIntent: manuallySelectedIntent ? detectedIntent : undefined, + agent: toolboxManager.getSettings()?.agent?.name, }) this.postViewTranscript({ speaker: 'assistant' }) @@ -803,12 +793,11 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv const agentName = ['search', 'edit', 'insert'].includes(intent ?? '') ? (intent as string) - : model - const agent = getAgent(agentName, { + : this.chatBuilder.getLastHumanMessage()?.agent ?? 'chat' + const agent = getAgent(agentName, model, { contextRetriever: this.contextRetriever, editor: this.editor, chatClient: this.chatClient, - codyToolProvider: this.toolProvider, }) recorder.setIntentInfo({ @@ -1555,6 +1544,12 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv userProductSubscription.pipe( map(value => (value === pendingOperation ? null : value)) ), + toolboxSettings: () => toolboxManager.observable, + updateToolboxSettings: settings => { + return promiseFactoryToObservable(async () => { + await toolboxManager.updateSettings(settings) + }) + }, } ) ) diff --git a/vscode/src/chat/chat-view/ChatsController.ts b/vscode/src/chat/chat-view/ChatsController.ts index e3a346dc9a74..8ad025d94785 100644 --- a/vscode/src/chat/chat-view/ChatsController.ts +++ b/vscode/src/chat/chat-view/ChatsController.ts @@ -28,6 +28,7 @@ import { handleCodeFromInsertAtCursor, handleCodeFromSaveToNewFile, } from '../../services/utils/codeblock-action-tracker' +import { CodyToolProvider } from '../agentic/CodyToolProvider' import type { ChatIntentAPIClient } from '../context/chatIntentAPIClient' import type { SmartApplyResult } from '../protocol' import { @@ -579,6 +580,7 @@ export class ChatsController implements vscode.Disposable { public dispose(): void { this.disposeAllChats() + CodyToolProvider.dispose() vscode.Disposable.from(...this.disposables).dispose() } } diff --git a/vscode/src/chat/chat-view/handlers/ChatHandler.ts b/vscode/src/chat/chat-view/handlers/ChatHandler.ts index 4b52eb6afc06..088f34c69861 100644 --- a/vscode/src/chat/chat-view/handlers/ChatHandler.ts +++ b/vscode/src/chat/chat-view/handlers/ChatHandler.ts @@ -82,6 +82,8 @@ export class ChatHandler implements AgentHandler { recorder.recordChatQuestionExecuted(corpusContext, { addMetadata: true, current: span }) signal.throwIfAborted() + // Send context to webview for display before sending the request. + delegate.postMessageInProgress({ speaker: 'assistant', model: this.modelId }) this.streamAssistantResponse(requestID, prompt, this.modelId, signal, chatBuilder, delegate) } @@ -225,7 +227,8 @@ export class ChatHandler implements AgentHandler { editorState: SerializedPromptEditorState | null, _chatBuilder: ChatBuilder, _delegate: AgentHandlerDelegate, - signal?: AbortSignal + signal?: AbortSignal, + skipQueryRewrite = false ): Promise<{ contextItems?: ContextItem[] error?: Error @@ -233,9 +236,6 @@ export class ChatHandler implements AgentHandler { }> { try { return wrapInActiveSpan('chat.computeContext', async span => { - // Skip query rewrite for deep-cody agent as that is done - // during the reflection/review step. - const isDeepCody = this.modelId.includes('deep-cody') const contextAlternatives = await computeContextAlternatives( this.contextRetriever, this.editor, @@ -243,7 +243,7 @@ export class ChatHandler implements AgentHandler { editorState, span, signal, - isDeepCody + skipQueryRewrite ) return { contextItems: contextAlternatives[0].items } }) @@ -276,9 +276,11 @@ export async function computeContextAlternatives( signal, skipQueryRewrite ) - const priorityContextPromise = retrievedContextPromise - .then(p => getPriorityContext(text, editor, p)) - .catch(() => getPriorityContext(text, editor, [])) + const priorityContextPromise = skipQueryRewrite + ? Promise.resolve([]) + : retrievedContextPromise + .then(p => getPriorityContext(text, editor, p)) + .catch(() => getPriorityContext(text, editor, [])) const openCtxContextPromise = getContextForChatMessage(text.toString(), signal) const [priorityContext, retrievedContext, openCtxContext] = await Promise.all([ priorityContextPromise, diff --git a/vscode/src/chat/chat-view/handlers/DeepCodyHandler.ts b/vscode/src/chat/chat-view/handlers/DeepCodyHandler.ts index 16b9e346cd5f..f5f26f1f0c81 100644 --- a/vscode/src/chat/chat-view/handlers/DeepCodyHandler.ts +++ b/vscode/src/chat/chat-view/handlers/DeepCodyHandler.ts @@ -6,27 +6,14 @@ import { featureFlagProvider, storeLastValue, } from '@sourcegraph/cody-shared' -import type { CodyToolProvider } from '../../agentic/CodyToolProvider' import { DeepCodyAgent } from '../../agentic/DeepCody' import { DeepCodyRateLimiter } from '../../agentic/DeepCodyRateLimiter' import type { ChatBuilder } from '../ChatBuilder' -import type { ChatControllerOptions } from '../ChatController' -import type { ContextRetriever } from '../ContextRetriever' import type { HumanInput } from '../context' import { ChatHandler } from './ChatHandler' import type { AgentHandler, AgentHandlerDelegate } from './interfaces' export class DeepCodyHandler extends ChatHandler implements AgentHandler { - constructor( - modelId: string, - contextRetriever: Pick, - editor: ChatControllerOptions['editor'], - chatClient: ChatControllerOptions['chatClient'], - private toolProvider: CodyToolProvider - ) { - super(modelId, contextRetriever, editor, chatClient) - } - private featureDeepCodyRateLimitBase = storeLastValue( featureFlagProvider.evaluatedFeatureFlag(FeatureFlag.DeepCodyRateLimitBase) ) @@ -46,13 +33,16 @@ export class DeepCodyHandler extends ChatHandler implements AgentHandler { error?: Error abort?: boolean }> { + // NOTE: Skip query rewrite for deep-cody as the agent will reviewed and rewrite the query. + const skipQueryRewrite = true const baseContextResult = await super.computeContext( requestID, { text, mentions }, editorState, chatBuilder, delegate, - signal + signal, + skipQueryRewrite ) const isEnabled = chatBuilder.getMessages().length < 4 if (!isEnabled || baseContextResult.error || baseContextResult.abort) { @@ -69,14 +59,10 @@ export class DeepCodyHandler extends ChatHandler implements AgentHandler { } const baseContext = baseContextResult.contextItems ?? [] - const agent = new DeepCodyAgent( - chatBuilder, - this.chatClient, - this.toolProvider.getTools(), - baseContext, - (steps: ProcessingStep[]) => delegate.postStatuses(steps) + const agent = new DeepCodyAgent(chatBuilder, this.chatClient, (steps: ProcessingStep[]) => + delegate.postStatuses(steps) ) - const agenticContext = await agent.getContext(requestID, signal) - return { contextItems: [...baseContext, ...agenticContext] } + + return { contextItems: await agent.getContext(requestID, signal, baseContext) } } } diff --git a/vscode/src/chat/chat-view/handlers/interfaces.ts b/vscode/src/chat/chat-view/handlers/interfaces.ts index 451c3e9b104c..7c5491422ebf 100644 --- a/vscode/src/chat/chat-view/handlers/interfaces.ts +++ b/vscode/src/chat/chat-view/handlers/interfaces.ts @@ -8,7 +8,6 @@ import type { } from '@sourcegraph/cody-shared' import type { SubMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages' import type { MessageErrorType } from '../../MessageProvider' -import type { CodyToolProvider } from '../../agentic/CodyToolProvider' import type { ChatBuilder } from '../ChatBuilder' import type { ChatControllerOptions } from '../ChatController' import type { ContextRetriever } from '../ContextRetriever' @@ -18,7 +17,6 @@ export interface AgentTools { contextRetriever: Pick editor: ChatControllerOptions['editor'] chatClient: ChatControllerOptions['chatClient'] - codyToolProvider: CodyToolProvider } /** diff --git a/vscode/src/chat/chat-view/handlers/registry.ts b/vscode/src/chat/chat-view/handlers/registry.ts index 374eb5657fe4..d6cc41696162 100644 --- a/vscode/src/chat/chat-view/handlers/registry.ts +++ b/vscode/src/chat/chat-view/handlers/registry.ts @@ -1,5 +1,6 @@ import Anthropic from '@anthropic-ai/sdk' import { getConfiguration } from '../../../configuration' +import { DeepCodyAgent } from '../../agentic/DeepCody' import { ChatHandler } from './ChatHandler' import { DeepCodyHandler } from './DeepCodyHandler' import { EditHandler } from './EditHandler' @@ -17,20 +18,18 @@ function registerAgent(id: string, ctr: (id: string, tools: AgentTools) => Agent agentRegistry.set(id, ctr) } -export function getAgent(id: string, tools: AgentTools): AgentHandler { - if (!agentRegistry.has(id)) { - // If id is not found, assume it's a base model - const { contextRetriever, editor, chatClient } = tools - return new ChatHandler(id, contextRetriever, editor, chatClient) +export function getAgent(id: string, modelId: string, tools: AgentTools): AgentHandler { + const { contextRetriever, editor, chatClient } = tools + if (id === DeepCodyAgent.id) { + return new DeepCodyHandler(modelId, contextRetriever, editor, chatClient) } - return agentRegistry.get(id)!(id, tools) + if (agentRegistry.has(id)) { + return agentRegistry.get(id)!(id, tools) + } + // If id is not found, assume it's a base model + return new ChatHandler(modelId, contextRetriever, editor, chatClient) } -registerAgent( - 'sourcegraph::2023-06-01::deep-cody', - (id: string, { contextRetriever, editor, chatClient, codyToolProvider }: AgentTools) => - new DeepCodyHandler(id, contextRetriever, editor, chatClient, codyToolProvider) -) registerAgent('search', (_id: string, _tools: AgentTools) => new SearchHandler()) registerAgent( 'edit', diff --git a/vscode/src/chat/chat-view/prompt.ts b/vscode/src/chat/chat-view/prompt.ts index 77792a82e3c9..d528559cc553 100644 --- a/vscode/src/chat/chat-view/prompt.ts +++ b/vscode/src/chat/chat-view/prompt.ts @@ -10,6 +10,7 @@ import { } from '@sourcegraph/cody-shared' import { logDebug } from '../../output-channel-logger' import { PromptBuilder } from '../../prompt-builder' +import { DeepCodyAgent } from '../agentic/DeepCody' import { ChatBuilder } from './ChatBuilder' export interface PromptInfo { @@ -50,8 +51,6 @@ export class DefaultPrompter { const promptBuilder = await PromptBuilder.create(contextWindow) const preInstruction = (await currentResolvedConfig()).configuration.chatPreInstruction - const isDeepCodyEnabled = chat.selectedModel?.includes('deep-cody') - // Add preamble messages const chatModel = await firstResultFromOperation(ChatBuilder.resolvedModelForChat(chat)) const preambleMessages = getSimplePreamble(chatModel, codyApiVersion, 'Chat', preInstruction) @@ -65,6 +64,8 @@ export class DefaultPrompter { .flatMap(m => (m.contextFiles ? [...m.contextFiles].reverse() : [])) .filter(isDefined) + const isContextAgentEnabled = reverseTranscript[0]?.agent === DeepCodyAgent.id + // Apply the context preamble via the prompt mixin to the last open-ended human message that is not a command. // The context preamble provides additional instructions on how Cody should respond using the attached context items, // allowing Cody to provide more contextually relevant responses. @@ -73,7 +74,7 @@ export class DefaultPrompter { // It also allows adding the preamble only when there is context to display, without wasting tokens on the same preamble repeatedly. if ( !this.isCommand && - !isDeepCodyEnabled && + !isContextAgentEnabled && Boolean(this.explicitContext.length || historyItems.length || this.corpusContext.length) ) { mixins.push(PromptMixin.getContextMixin()) diff --git a/vscode/src/configuration.test.ts b/vscode/src/configuration.test.ts index 635a00b57e7b..8bbc91b9039f 100644 --- a/vscode/src/configuration.test.ts +++ b/vscode/src/configuration.test.ts @@ -163,7 +163,6 @@ describe('getConfiguration', () => { '*': true, }, commandCodeLenses: true, - agenticContextExperimentalShell: false, agenticContextExperimentalOptions: { shell: { allow: ['git'] } }, experimentalSupercompletions: false, experimentalAutoeditsEnabled: undefined, diff --git a/vscode/src/configuration.ts b/vscode/src/configuration.ts index a619ff054cba..8a26b493f320 100644 --- a/vscode/src/configuration.ts +++ b/vscode/src/configuration.ts @@ -93,9 +93,7 @@ export function getConfiguration( /** * Instance must have feature flag enabled to use this feature. - * Allows AI Agent to run shell commands automatically. */ - agenticContextExperimentalShell: config.get(CONFIG_KEY.agenticContextExperimentalShell, false), agenticContextExperimentalOptions: config.get(CONFIG_KEY.agenticContextExperimentalOptions, {}), /** diff --git a/vscode/src/context/openctx.ts b/vscode/src/context/openctx.ts index 953876e97a48..8899c379e184 100644 --- a/vscode/src/context/openctx.ts +++ b/vscode/src/context/openctx.ts @@ -30,6 +30,7 @@ import type { } from '@openctx/client' import type { createController } from '@openctx/vscode-lib' import { Observable, map } from 'observable-fns' +import { CodyToolProvider } from '../chat/agentic/CodyToolProvider' import { logDebug } from '../output-channel-logger' import { createCodeSearchProvider } from './openctx/codeSearch' import { gitMentionsProvider } from './openctx/git' @@ -103,6 +104,7 @@ export function exposeOpenCtxClient( controller: controller.controller, disposable: controller.disposable, }) + CodyToolProvider.setupOpenCtxProviderListener() return controller.disposable } catch (error) { logDebug('openctx', `Failed to load OpenCtx client: ${error}`) diff --git a/vscode/src/main.ts b/vscode/src/main.ts index c751632a17d8..1130f3fe0e80 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -49,6 +49,7 @@ import { showSignInMenu, showSignOutMenu, tokenCallbackHandler } from './auth/au import { AutoeditsProvider } from './autoedits/autoedits-provider' import { registerAutoEditTestRenderCommand } from './autoedits/renderer/mock-renderer' import type { MessageProviderOptions } from './chat/MessageProvider' +import { CodyToolProvider } from './chat/agentic/CodyToolProvider' import { ChatsController, CodyChatEditorViewType } from './chat/chat-view/ChatsController' import { ContextRetriever } from './chat/chat-view/ContextRetriever' import type { ChatIntentAPIClient } from './chat/context/chatIntentAPIClient' @@ -261,6 +262,8 @@ const register = async ( ) disposables.push(chatsController) + CodyToolProvider.initialize(contextRetriever) + disposables.push( subscriptionDisposable( exposeOpenCtxClient(context, platform.createOpenCtxController).subscribe({}) diff --git a/vscode/src/testutils/mocks.ts b/vscode/src/testutils/mocks.ts index a649a9155ad1..cfddccf66748 100644 --- a/vscode/src/testutils/mocks.ts +++ b/vscode/src/testutils/mocks.ts @@ -926,7 +926,6 @@ export const DEFAULT_VSCODE_SETTINGS = { internalUnstable: false, internalDebugContext: false, internalDebugState: false, - agenticContextExperimentalShell: false, agenticContextExperimentalOptions: {}, autocompleteAdvancedProvider: 'default', autocompleteAdvancedModel: null, diff --git a/vscode/webviews/AppWrapperForTest.tsx b/vscode/webviews/AppWrapperForTest.tsx index 8cc967a8051b..39a9a6f983b5 100644 --- a/vscode/webviews/AppWrapperForTest.tsx +++ b/vscode/webviews/AppWrapperForTest.tsx @@ -1,5 +1,6 @@ import { AUTH_STATUS_FIXTURE_AUTHED, + type AgentToolboxSettings, type AuthStatus, CLIENT_CAPABILITIES_FIXTURE, type ClientConfiguration, @@ -137,6 +138,12 @@ export const AppWrapperForTest: FunctionComponent<{ children: ReactNode }> = ({ }, }), userProductSubscription: () => Observable.of(null), + toolboxSettings: () => + Observable.of({ + agent: undefined, + shell: undefined, + }), + updateToolboxSettings: () => EMPTY, }, } satisfies Wrapper['value']>, { diff --git a/vscode/webviews/chat/Transcript.tsx b/vscode/webviews/chat/Transcript.tsx index 81d457d4a480..33c62ed7c4e3 100644 --- a/vscode/webviews/chat/Transcript.tsx +++ b/vscode/webviews/chat/Transcript.tsx @@ -642,11 +642,10 @@ const TranscriptInteraction: FC = memo(props => { : EditContextButtonChat } defaultOpen={ - isContextLoading && - assistantMessage?.model?.includes('deep-cody') && - humanMessage.index < 3 + isContextLoading && humanMessage.agent === 'deep-cody' && humanMessage.index < 3 } // Open the context cell for the first 2 human messages when Deep Cody is run. processes={humanMessage?.processes ?? undefined} + agent={humanMessage?.agent} /> )} {assistantMessage && diff --git a/vscode/webviews/chat/cells/contextCell/ContextCell.tsx b/vscode/webviews/chat/cells/contextCell/ContextCell.tsx index 016965948834..3db29be2edb0 100644 --- a/vscode/webviews/chat/cells/contextCell/ContextCell.tsx +++ b/vscode/webviews/chat/cells/contextCell/ContextCell.tsx @@ -23,7 +23,6 @@ import { memo, useCallback, useContext, - useMemo, useState, } from 'react' import { FileLink } from '../../../components/FileLink' @@ -66,6 +65,7 @@ export const ContextCell: FunctionComponent<{ editContextNode: React.ReactNode experimentalOneBoxEnabled?: boolean processes?: ProcessingStep[] + agent?: string }> = memo( ({ contextItems, @@ -82,6 +82,7 @@ export const ContextCell: FunctionComponent<{ intent, experimentalOneBoxEnabled, processes, + agent, }) => { const __storybook__initialOpen = useContext(__ContextCellStorybookContext)?.initialOpen ?? false @@ -147,7 +148,7 @@ export const ContextCell: FunctionComponent<{ const telemetryRecorder = useTelemetryRecorder() - const isDeepCodyEnabled = useMemo(() => model?.includes('deep-cody'), [model]) + const isDeepCodyEnabled = agent === 'deep-cody' // Text for top header text const headerText: { main: string; sub?: string } = { diff --git a/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx b/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx index 7308c07d74fe..47418da183b3 100644 --- a/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx +++ b/vscode/webviews/chat/cells/messageCell/assistant/AssistantMessageCell.tsx @@ -281,7 +281,7 @@ function useChatModelByID( (model ? { id: model, - title: model, + title: model?.includes('deep-cody') ? 'Deep Cody (Experimental)' : model, provider: 'unknown', tags: [], } diff --git a/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx b/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx index 99e9f2fcd787..2d916290b9a3 100644 --- a/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx +++ b/vscode/webviews/chat/cells/messageCell/human/HumanMessageCell.tsx @@ -5,7 +5,7 @@ import { type SerializedPromptEditorValue, serializedPromptEditorStateFromChatMessage, } from '@sourcegraph/cody-shared' -import type { PromptEditorRefAPI } from '@sourcegraph/prompt-editor' +import { type PromptEditorRefAPI, useExtensionAPI, useObservable } from '@sourcegraph/prompt-editor' import isEqual from 'lodash/isEqual' import { ColumnsIcon } from 'lucide-react' import { type FC, memo, useMemo } from 'react' @@ -17,6 +17,7 @@ import { HumanMessageEditor } from './editor/HumanMessageEditor' import { Tooltip, TooltipContent, TooltipTrigger } from '../../../../components/shadcn/ui/tooltip' import { getVSCodeAPI } from '../../../../utils/VSCodeApi' import { useConfig } from '../../../../utils/useConfig' +import { ToolboxButton } from './editor/ToolboxButton' interface HumanMessageCellProps { message: ChatMessage @@ -95,6 +96,11 @@ const HumanMessageCellContent = memo(props => { intent, } = props + const api = useExtensionAPI() + const { value: settings } = useObservable( + useMemo(() => api.toolboxSettings(), [api.toolboxSettings]) + ) + return ( (props => { /> } speakerTitle={userInfo.user.displayName ?? userInfo.user.username} - cellAction={isFirstMessage && } + cellAction={ +
+ {settings && } + {isFirstMessage && } +
+ } content={ = memo(({ settings, api }) => { + const telemetryRecorder = useTelemetryRecorder() + + const [isLoading, setIsLoading] = useState(false) + const [settingsForm, setSettingsForm] = useState(settings) + + useEffect(() => { + setSettingsForm(settings) + }, [settings]) + + const onOpenChange = useCallback( + (open: boolean): void => { + if (open) { + telemetryRecorder.recordEvent('cody.toolboxSettings', 'open', {}) + } else { + // Reset form to original settings when closing + setSettingsForm(settings) + } + }, + [telemetryRecorder.recordEvent, settings] + ) + + const onSubmit = useCallback( + (close: () => void) => { + setIsLoading(true) + const subscription = api.updateToolboxSettings(settingsForm).subscribe({ + next: () => { + setIsLoading(false) + close() + }, + error: error => { + console.error('updateToolboxSettings:', error) + setSettingsForm(settings) + setIsLoading(false) + }, + complete: () => { + setIsLoading(false) + }, + }) + return () => { + subscription.unsubscribe() + } + }, + [api.updateToolboxSettings, settingsForm, settings] + ) + + return ( +
+ ( + + +
+

Agentic Chat

+ Experimental +
+ +
+
+
+

Agentic Chat

+ + setSettingsForm({ + ...settingsForm, + agent: { + name: settingsForm.agent?.name + ? undefined + : 'deep-cody', // TODO: update name when finalized. + }, + }) + } + /> +
+
+ {ToolboxOptionText.agentic} +
+
+

Terminal Context

+ + setSettingsForm({ + ...settingsForm, + shell: { + enabled: + !!settingsForm.agent?.name && + !settingsForm.shell?.enabled, + }, + }) + } + /> +
+
+ Enable with caution as mistakes are possible. +
+
+ {ToolboxOptionText.terminal} +
+
+
+
+
+
+ + +
+
+ )} + popoverRootProps={{ onOpenChange }} + popoverContentProps={{ + className: 'tw-w-[350px] !tw-p-0 tw-mr-2', + onCloseAutoFocus: event => { + event.preventDefault() + }, + }} + > + +
+
+ ) +}) + +const Switch: FC<{ checked?: boolean; onChange?: (checked: boolean) => void; disabled?: boolean }> = + memo(({ checked = false, onChange, disabled = false }) => { + return ( + + ) + }) diff --git a/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/AddContextButton.tsx b/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/AddContextButton.tsx index a68f823fc206..321fa2af14a3 100644 --- a/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/AddContextButton.tsx +++ b/vscode/webviews/chat/cells/messageCell/human/editor/toolbar/AddContextButton.tsx @@ -10,9 +10,14 @@ export const AddContextButton: FunctionComponent<{ }> = ({ onClick, className }) => ( - diff --git a/vscode/webviews/components/Notices.tsx b/vscode/webviews/components/Notices.tsx index b363c29e8f34..9e6899b1dcd1 100644 --- a/vscode/webviews/components/Notices.tsx +++ b/vscode/webviews/components/Notices.tsx @@ -1,4 +1,4 @@ -import { CodyIDE, type CodyNotice, FeatureFlag } from '@sourcegraph/cody-shared' +import { CodyIDE, type CodyNotice } from '@sourcegraph/cody-shared' import { DOTCOM_WORKSPACE_UPGRADE_URL } from '@sourcegraph/cody-shared/src/sourcegraph-api/environments' import { S2_URL } from '@sourcegraph/cody-shared/src/sourcegraph-api/environments' import { @@ -18,7 +18,6 @@ import type { UserAccountInfo } from '../Chat' import { CodyLogo } from '../icons/CodyLogo' import { getVSCodeAPI } from '../utils/VSCodeApi' import { useTelemetryRecorder } from '../utils/telemetry' -import { useFeatureFlag } from '../utils/useFeatureFlags' import { MarkdownFromCody } from './MarkdownFromCody' import { useLocalStorage } from './hooks' import { Button } from './shadcn/ui/button' @@ -44,9 +43,6 @@ const storageKey = 'DismissedWelcomeNotices' export const Notices: React.FC = ({ user, isTeamsUpgradeCtaEnabled, instanceNotices }) => { const telemetryRecorder = useTelemetryRecorder() - const isDeepCodyEnabled = useFeatureFlag(FeatureFlag.DeepCody) - const isDeepCodyShellContextSupported = useFeatureFlag(FeatureFlag.DeepCodyShellContext) - // dismissed notices from local storage const [dismissedNotices, setDismissedNotices] = useLocalStorage(storageKey, '') // session-only dismissal - for notices we want to show if the user logs out and logs back in. @@ -68,13 +64,6 @@ export const Notices: React.FC = ({ user, isTeamsUpgradeCtaEnabled [telemetryRecorder, setDismissedNotices] ) - const settingsNameByIDE = - user.IDE === CodyIDE.JetBrains - ? 'Settings Editor' - : user.IDE === CodyIDE.VSCode - ? 'settings.json' - : 'Extension Settings' - const notices: Notice[] = useMemo( () => [ ...instanceNotices.map(notice => ({ @@ -88,32 +77,6 @@ export const Notices: React.FC = ({ user, isTeamsUpgradeCtaEnabled /> ), })), - { - id: 'DeepCody', - isVisible: (isDeepCodyEnabled || user.isCodyProUser) && user.IDE !== CodyIDE.Web, - content: ( - - dismissNotice(user.isCodyProUser ? 'DeepCodyDotCom' : 'DeepCodyEnterprise') - } - info="Usage limits apply during the experimental phase." - footer={ - !isDeepCodyShellContextSupported - ? 'Contact admins to enable Command Execution' - : '' - } - actions={[]} - /> - ), - }, /** * Notifies users that they are eligible for a free upgrade to Sourcegraph Teams. * TODO: Update to live link https://linear.app/sourcegraph/issue/CORE-535/cody-clients-migrate-ctas-to-live-links @@ -188,15 +151,7 @@ export const Notices: React.FC = ({ user, isTeamsUpgradeCtaEnabled ), }, ], - [ - user, - dismissNotice, - isTeamsUpgradeCtaEnabled, - isDeepCodyEnabled, - isDeepCodyShellContextSupported, - settingsNameByIDE, - instanceNotices, - ] + [user, dismissNotice, isTeamsUpgradeCtaEnabled, instanceNotices] ) // First, modify the activeNotice useMemo to add conditional logic for DogfoodS2 diff --git a/vscode/webviews/components/modelSelectField/ModelSelectField.tsx b/vscode/webviews/components/modelSelectField/ModelSelectField.tsx index 098ed6742d2d..d18d7eddf4b7 100644 --- a/vscode/webviews/components/modelSelectField/ModelSelectField.tsx +++ b/vscode/webviews/components/modelSelectField/ModelSelectField.tsx @@ -1,6 +1,6 @@ import { type Model, ModelTag, isCodyProModel, isWaitlistModel } from '@sourcegraph/cody-shared' import { clsx } from 'clsx' -import { BookOpenIcon, BuildingIcon, ExternalLinkIcon, FlaskConicalIcon } from 'lucide-react' +import { BookOpenIcon, BuildingIcon, ExternalLinkIcon } from 'lucide-react' import { type FunctionComponent, type ReactNode, useCallback, useMemo } from 'react' import type { UserAccountInfo } from '../../Chat' import { getVSCodeAPI } from '../../utils/VSCodeApi' @@ -60,8 +60,6 @@ export const ModelSelectField: React.FunctionComponent<{ metadata: { modelIsCodyProOnly: isCodyProModel(model) ? 1 : 0, isCodyProUser: isCodyProUser ? 1 : 0, - // Log event when user switches to a different model from Deep Cody. - isSwitchedFromDeepCody: selectedModel.id.includes('deep-cody') ? 1 : 0, }, privateMetadata: { modelId: model.id, @@ -305,9 +303,6 @@ function modelAvailability( } function getTooltip(model: Model, availability: string): string { - if (model.id.includes('deep-cody')) { - return 'An agent powered by Claude 3.5 Sonnet (New) and other models with tool-use capabilities to gather contextual information for better responses. It can search your codebase, browse the web, execute shell commands in your terminal (when enabled), and utilize any configured tools to retrieve necessary context. To enable shell commands, set the "cody.agentic.context.experimentalShell" option to true in your settings.' - } if (model.tags.includes(ModelTag.Waitlist)) { return 'Request access to this new model' } @@ -358,13 +353,7 @@ const ModelTitleWithIcon: React.FC<{ return ( - {showIcon ? ( - model.id.includes('deep-cody') ? ( - - ) : ( - - ) - ) : null} + {showIcon ? : null} {model.title} {modelBadge && ( = /** Common {@link ModelsService.uiGroup} values. */ const ModelUIGroup: Record = { - DeepCody: 'Agent, extensive context fetching', + Agents: 'Agents with tools', Power: 'More powerful models', Balanced: 'Balanced for power and speed', Speed: 'Faster models', @@ -399,7 +388,7 @@ const ModelUIGroup: Record = { } const getModelDropDownUIGroup = (model: Model): string => { - if (['deep-cody', 'tool-cody'].some(id => model.id.includes(id))) return ModelUIGroup.DeepCody + if (['deep-cody', 'tool-cody'].some(id => model.id.includes(id))) return ModelUIGroup.Agents if (model.tags.includes(ModelTag.Power)) return ModelUIGroup.Power if (model.tags.includes(ModelTag.Balanced)) return ModelUIGroup.Balanced if (model.tags.includes(ModelTag.Speed)) return ModelUIGroup.Speed