Skip to content

Commit

Permalink
DeepCody: clean up and fix stream (sourcegraph#5815)
Browse files Browse the repository at this point in the history
FIX:
https://sourcegraph.slack.com/archives/C07CNJTGB3Q/p1727983740644479

Updated to wait for streaming is done before starting the context
retrieval process.

Also included some minor refactoring on namings

## Test plan

<!-- Required. See
https://docs-legacy.sourcegraph.com/dev/background-information/testing_principles.
-->

Check the output channel to verify each tool is only run once after the
streaming is completed.

## Changelog

<!-- OPTIONAL; info at
https://www.notion.so/sourcegraph/Writing-a-changelog-entry-dd997f411d524caabf0d8d38a24a878c
-->
  • Loading branch information
abeatrix authored Oct 4, 2024
1 parent 21d11b0 commit dedc573
Show file tree
Hide file tree
Showing 8 changed files with 270 additions and 250 deletions.
275 changes: 143 additions & 132 deletions agent/recordings/edit_1541920145/recording.har.yaml

Large diffs are not rendered by default.

27 changes: 15 additions & 12 deletions vscode/src/chat/agentic/CodyTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type ContextItem,
PromptString,
firstValueFrom,
logDebug,
pendingOperation,
ps,
} from '@sourcegraph/cody-shared'
Expand All @@ -20,10 +21,9 @@ export interface PromptConfig {
export abstract class CodyTool {
abstract readonly tag: PromptString
abstract readonly subTag: PromptString

abstract readonly prompt: PromptConfig

protected content = ''
protected rawText = ''

public getInstruction(): PromptString {
const { instruction, placeholder } = this.prompt
Expand All @@ -33,13 +33,18 @@ export abstract class CodyTool {

public parse(): string[] {
const regex = new RegExp(`<${this.subTag}>(.+?)</?${this.subTag}>`, 's')
return (this.content.match(new RegExp(regex, 'g')) || [])
const parsed = (this.rawText.match(new RegExp(regex, 'g')) || [])
.map(match => regex.exec(match)?.[1].trim())
.filter(Boolean) as string[]
if (parsed.length) {
logDebug('CodyTool', this.tag.toString(), { verbose: parsed })
}
this.rawText = ''
return parsed
}

public process(content: string): void {
this.content += content
public stream(text: string): void {
this.rawText += text
}

abstract execute(): Promise<ContextItem[]>
Expand All @@ -57,7 +62,7 @@ class CliTool extends CodyTool {

async execute(): Promise<ContextItem[]> {
const commands = this.parse()
this.content = ''
logDebug('CodyTool', `executing ${commands.length} commands`)
return Promise.all(commands.map(getContextFileFromShell)).then(results => results.flat())
}
}
Expand All @@ -68,13 +73,13 @@ class FileTool extends CodyTool {

public readonly prompt = {
instruction: ps`To retrieve full content of a codebase file`,
placeholder: ps`FILEPATH`,
placeholder: ps`FILENAME`,
example: ps`See the content of different files: <TOOLFILE><name>path/foo.ts</name><name>path/bar.ts</name></TOOLFILE>`,
}

async execute(): Promise<ContextItem[]> {
const filePaths = this.parse()
this.content = ''
logDebug('CodyTool', `requesting ${filePaths.length} files`)
return Promise.all(filePaths.map(getContextFromRelativePath)).then(results =>
results.filter((item): item is ContextItem => item !== null)
)
Expand Down Expand Up @@ -102,7 +107,6 @@ class SearchTool extends CodyTool {

async execute(): Promise<ContextItem[]> {
const queries = this.parse()
this.content = ''
const query = queries[0] // There should only be one query.
if (!this.contextRetriever || !query || this.performedSearch.has(query)) {
return []
Expand All @@ -124,9 +128,8 @@ class SearchTool extends CodyTool {
)
// Store the search query to avoid running the same query again.
this.performedSearch.add(query)
// Limit the number of the new context items to 20 items to avoid long processing time
// during the next thinking / reflection process.
return context.slice(-20)
logDebug('CodyTool', `searching codebase for ${query}`)
return context
}
}

Expand Down
2 changes: 1 addition & 1 deletion vscode/src/chat/agentic/DeepCody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe('DeepCody', () => {

expect(mockChatClient.chat).toHaveBeenCalled()
expect(mockContextRetriever.retrieveContext).toHaveBeenCalled()
expect(result).toHaveLength(1)
expect(result).toHaveLength(2)
expect(result[0].content).toBe('const newExample = "test result";')
})

Expand Down
182 changes: 106 additions & 76 deletions vscode/src/chat/agentic/DeepCody.ts
Original file line number Diff line number Diff line change
@@ -1,150 +1,180 @@
import {
BotResponseMultiplexer,
type ChatClient,
type CompletionParameters,
type ContextItem,
FeatureFlag,
type Model,
type PromptMixin,
PromptString,
firstResultFromOperation,
logDebug,
modelsService,
newPromptMixin,
ps,
} from '@sourcegraph/cody-shared'
import { getOSPromptString } from '../../os'
import { getCategorizedMentions } from '../../prompt-builder/utils'
import { logFirstEnrollmentEvent } from '../../services/utils/enrollment-event'
import { ChatBuilder } from '../chat-view/ChatBuilder'
import type { ChatBuilder } from '../chat-view/ChatBuilder'
import { DefaultPrompter } from '../chat-view/prompt'
import type { CodyTool } from './CodyTool'
import { multiplexerStream } from './utils'

type AgenticContext = {
explicit: ContextItem[]
implicit: ContextItem[]
}

/**
* A DeepCodyAgent is created for each chat submitted by the user.
* It is responsible for reviewing the retrieved context, and perform agentic context retrieval for the chat request.
*/
export class DeepCodyAgent {
public static readonly ModelRef = 'sourcegraph::2023-06-01::deep-cody'

private static hasEnrolled = false
/**
* Return modelRef for first time enrollment of Deep Cody.
*/
public static isEnrolled(models: Model[]): string | undefined {
// Only enrolled user has access to the Deep Cody model.
const hasAccess = models.some(m => m.id === DeepCodyAgent.ModelRef)
const enrolled = DeepCodyAgent.hasEnrolled || logFirstEnrollmentEvent(FeatureFlag.DeepCody, true)
// Is first time enrollment.
//Return modelRef for first time enrollment of Deep Cody.
if (hasAccess && !enrolled) {
DeepCodyAgent.hasEnrolled = true
logDebug('DeepCody', 'First time enrollment detected.')
return 'sourcegraph::2023-06-01::deep-cody'
return DeepCodyAgent.ModelRef
}
// Does not have access or not enrolled.
return undefined
}

private readonly promptMixins: PromptMixin[] = []
private readonly multiplexer = new BotResponseMultiplexer()
private context: AgenticContext = { explicit: [], implicit: [] }

constructor(
private readonly chatBuilder: ChatBuilder,
private readonly chatClient: ChatClient,
private readonly tools: CodyTool[],
private currentContext: ContextItem[]
mentions: ContextItem[] = []
) {
this.sort(mentions)
this.promptMixins.push(newPromptMixin(this.buildPrompt()))
this.initializeMultiplexer()
}

private initializeMultiplexer(): void {
for (const tool of this.tools) {
this.multiplexer.sub(tool.tag.toString(), {
onResponse: async (c: string) => {
tool.process(c)
tool.stream(c)
},
onTurnComplete: async () => Promise.resolve(),
onTurnComplete: async () => tool.stream(''),
})
}
}

/**
* Retrieves the context for the specified model, with additional agentic context retrieval
* if the last requested context contains codebase search results.
*
* Start the context retrieval process for the loop count.
* @param userAbortSignal - An AbortSignal to cancel the operation.
* @param loop - The number of times to review the context.
* @param maxItem - The maximum number of codebase context items to retrieve.
* @returns The context items for the specified model.
*/
public async getContext(chatAbortSignal: AbortSignal): Promise<ContextItem[]> {
// Review current chat and context to get the agentic context.
const agenticContext = await this.review(chatAbortSignal)
// Run review again if the last requested context contains codebase search results.
if (agenticContext?.some(c => c.type === 'file')) {
this.currentContext.push(...agenticContext)
const additionalContext = await this.review(chatAbortSignal)
agenticContext.push(...additionalContext)
public async getContext(
chatAbortSignal: AbortSignal,
// TODO (bee) investigate on the right numbers for these params.
// Currently limiting to these numbers to avoid long processing time during reviewing step.
loop = 2,
// Keep the last n amount of the search items to override old items with new ones.
maxSearchItems = 30
): Promise<ContextItem[]> {
const start = performance.now()
const count = { context: 0, loop: 0 }

for (let i = 0; i < loop && !chatAbortSignal.aborted; i++) {
const newContext = await this.review(chatAbortSignal)
this.sort(newContext)
count.context += newContext.length
count.loop++
if (!newContext.length || count.context) {
break
}
}
logDebug('DeepCody', 'getContext', { verbose: { agenticContext } })
return agenticContext

const duration = performance.now() - start
logDebug('DeepCody', 'Aagentic context retrieval completed', { verbose: { count, duration } })
return [...this.context.implicit.slice(-maxSearchItems), ...this.context.explicit]
}

private async review(chatAbortSignal: AbortSignal): Promise<ContextItem[]> {
if (chatAbortSignal.aborted) {
return []
}
// Create a seperate AbortController to ensure this process will not affect the original chat request.
const agentAbortController = new AbortController()
if (chatAbortSignal.aborted) return []

const { explicitMentions, implicitMentions } = getCategorizedMentions(this.currentContext)
const prompter = new DefaultPrompter(this.context.explicit, this.context.implicit)
const promptData = await prompter.makePrompt(this.chatBuilder, 1, this.promptMixins)

const prompter = new DefaultPrompter(explicitMentions, implicitMentions.slice(-20))
const promptText = this.buildPrompt()
const { prompt } = await prompter.makePrompt(this.chatBuilder, 1, [newPromptMixin(promptText)])

const model = this.chatBuilder.selectedModel
const contextWindow = await firstResultFromOperation(
ChatBuilder.contextWindowForChat(this.chatBuilder)
)
const params = { model, maxTokensToSample: contextWindow.output } as CompletionParameters
if (model && modelsService.isStreamDisabled(model)) {
params.stream = false
if (promptData.context.ignored.length) {
// TODO: Decide if Cody needs more context.
}

logDebug('DeepCody', 'reviewing...')
const agentAbortController = new AbortController()

try {
const stream = this.chatClient.chat(prompt, params, agentAbortController.signal)
await multiplexerStream(stream, this.multiplexer, agentAbortController.signal)

const context = await Promise.all(this.tools.map(t => t.execute()))
return context.flat()
} catch (error: unknown) {
logDebug('DeepCody', `failed: ${error}`, { verbose: { prompt, error } })
return []
const stream = this.chatClient.chat(
promptData.prompt,
{ model: DeepCodyAgent.ModelRef, maxTokensToSample: 2000 },
agentAbortController.signal
)

let streamedText = ''
for await (const message of stream) {
if (message.type === 'change') {
const text = message.text.slice(streamedText.length)
streamedText += text
await this.multiplexer.publish(text)
} else if (message.type === 'complete' || message.type === 'error') {
if (message.type === 'error') throw new Error('Error while streaming')
await this.multiplexer.notifyTurnComplete()
break
}
}
} catch (error) {
await this.multiplexer.notifyTurnComplete()
logDebug('DeepCody', `review failed: ${error}`, { verbose: { prompt, error } })
}

return (await Promise.all(this.tools.map(t => t.execute()))).flat()
}

private sort(items: ContextItem[]): void {
const sorted = getCategorizedMentions(items)
this.context.explicit.push(...sorted.explicitMentions)
this.context.implicit.push(...sorted.implicitMentions)
}

private buildPrompt(): PromptString {
const tools = PromptString.join(
this.tools.map(t => t.getInstruction()),
ps`\n- `
)
const examples = PromptString.join(
this.tools.map(t => t.prompt.example),
ps`\n- `
)

return PROMPT.replace('{{CODY_TOOL_LIST}}', tools).replace('{{CODY_TOOL_EXAMPLE}}', examples)
const join = (prompts: PromptString[]) => PromptString.join(prompts, ps`\n- `)

return 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.
[INSTRUCTIONS]
1. Analyze the shared context 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 <TOOL*> tags to request additional information if needed.
[TOOLS]
${join(this.tools.map(t => t.getInstruction()))}
[TOOL USAGE EXAMPLES]
${join(this.tools.map(t => t.prompt.example))}
- To see the full content of a codebase file and context of how the Controller class is use: \`<TOOLFILE><name>path/to/file.ts</name></TOOLFILE><TOOLSEARCH><query>class Controller</query></TOOLSEARCH>\`
[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 <TOOL*> tag(s) in your response.
[NOTES]
1. Only use <TOOL*> tags when additional context is necessary to answer the question.
2. You may use multiple <TOOL*> tags in a single response if needed.
3. Never request sensitive information such as passwords or API keys.
4. The user is working with ${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 in this step.`
}
}

const PROMPT = ps`Your task is to review all shared context, then think step-by-step about whether you can provide me with a helpful answer for the "Question:" based on the shared context. If more information from my codebase is needed for the answer, you can request the following context using these action tags:
- {{CODY_TOOL_LIST}}
Examples:
- {{CODY_TOOL_EXAMPLE}}
Notes:
- If you can answer my question without extra codebase context, reply me with a single word: "Review".
- Only reply with <TOOL*> tags if additional context is required for someone to provide a helpful answer.
- Do not request sensitive information such as password or API keys from any source.
- You can include multiple action tags in a single response.
- I am working with ${getOSPromptString()}.`
26 changes: 0 additions & 26 deletions vscode/src/chat/agentic/utils.ts

This file was deleted.

2 changes: 1 addition & 1 deletion vscode/src/prompt-builder/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ describe('PromptBuilder', () => {
const partialFile: ContextItem = {
...fileWithSameUri,
range: { start: { line: 1, character: 0 }, end: { line: 2, character: 1 } },
source: ContextItemSource.Terminal,
source: ContextItemSource.Search,
}

const fullFile: ContextItem = {
Expand Down
Loading

0 comments on commit dedc573

Please sign in to comment.