Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LLM-enhanced keyword context #52815

Merged
merged 12 commits into from
Jun 12, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/cody-cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ async function startCLI() {
}
}

const finalPrompt = await transcript.toPrompt(getPreamble(codebase))
const { prompt: finalPrompt, contextFiles } = await transcript.getPromptForLastInteraction(getPreamble(codebase))
transcript.setUsedContextFilesForLastInteraction(contextFiles)

let text = ''
streamCompletions(completionsClient, finalPrompt, {
Expand Down
5 changes: 4 additions & 1 deletion client/cody-shared/BUILD.bazel

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion client/cody-shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
},
"dependencies": {
"@sourcegraph/common": "workspace:*",
"@sourcegraph/http-client": "workspace:*"
"@sourcegraph/http-client": "workspace:*",
"xml2js": "^0.6.0"
},
"devDependencies": {
"@types/xml2js": "^0.4.11"
}
}
15 changes: 12 additions & 3 deletions client/cody-shared/src/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Message } from '../sourcegraph-api'
import type { SourcegraphCompletionsClient } from '../sourcegraph-api/completions/client'
import type { CompletionParameters, CompletionCallbacks } from '../sourcegraph-api/completions/types'

const DEFAULT_CHAT_COMPLETION_PARAMETERS: Omit<CompletionParameters, 'messages'> = {
type ChatParameters = Omit<CompletionParameters, 'messages'>

const DEFAULT_CHAT_COMPLETION_PARAMETERS: ChatParameters = {
temperature: 0.2,
maxTokensToSample: SOLUTION_TOKEN_LENGTH,
topK: -1,
Expand All @@ -13,10 +15,17 @@ const DEFAULT_CHAT_COMPLETION_PARAMETERS: Omit<CompletionParameters, 'messages'>
export class ChatClient {
constructor(private completions: SourcegraphCompletionsClient) {}

public chat(messages: Message[], cb: CompletionCallbacks): () => void {
public chat(messages: Message[], cb: CompletionCallbacks, params?: Partial<ChatParameters>): () => void {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass through the underlying completions parameters, so we can set things like temperature.

const isLastMessageFromHuman = messages.length > 0 && messages[messages.length - 1].speaker === 'human'
const augmentedMessages = isLastMessageFromHuman ? messages.concat([{ speaker: 'assistant' }]) : messages

return this.completions.stream({ messages: augmentedMessages, ...DEFAULT_CHAT_COMPLETION_PARAMETERS }, cb)
return this.completions.stream(
{
...DEFAULT_CHAT_COMPLETION_PARAMETERS,
...(params ? params : {}),
messages: augmentedMessages,
},
cb
)
}
}
7 changes: 4 additions & 3 deletions client/cody-shared/src/chat/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function createClient({

const embeddingsSearch = repoId ? new SourcegraphEmbeddingsSearchClient(graphqlClient, repoId, true) : null

const codebaseContext = new CodebaseContext(config, config.codebase, embeddingsSearch, null)
const codebaseContext = new CodebaseContext(config, config.codebase, embeddingsSearch, null, null)

const intentDetector = new SourcegraphIntentDetectorClient(graphqlClient)

Expand Down Expand Up @@ -116,10 +116,11 @@ export async function createClient({

sendTranscript()

const prompt = await transcript.toPrompt(getPreamble(config.codebase))
const { prompt, contextFiles } = await transcript.getPromptForLastInteraction(getPreamble(config.codebase))
transcript.setUsedContextFilesForLastInteraction(contextFiles)

const responsePrefix = interaction.getAssistantMessage().prefix ?? ''
let rawText = ''

chatClient.chat(prompt, {
onChange(_rawText) {
rawText = _rawText
Expand Down
55 changes: 44 additions & 11 deletions client/cody-shared/src/chat/transcript/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { OldContextMessage } from '../../codebase-context/messages'
import { ContextFile, ContextMessage, OldContextMessage } from '../../codebase-context/messages'
import { CHARS_PER_TOKEN, MAX_AVAILABLE_PROMPT_LENGTH } from '../../prompt/constants'
import { PromptMixin } from '../../prompt/prompt-mixin'
import { Message } from '../../sourcegraph-api'

import { Interaction, InteractionJSON } from './interaction'
Expand All @@ -20,18 +21,19 @@ export interface TranscriptJSON {
}

/**
* A transcript of a conversation between a human and an assistant.
* The "model" class that tracks the call and response of the Cody chat box.
* Any "controller" logic belongs outside of this class.
*/
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We simplify the Transcript class to be a pure model class and avoid having seemingly non-mutative methods like toPrompt (below) trigger surprising mutations of internal state.

export class Transcript {
public static fromJSON(json: TranscriptJSON): Transcript {
return new Transcript(
json.interactions.map(
({ humanMessage, assistantMessage, context, timestamp }) =>
({ humanMessage, assistantMessage, fullContext, usedContextFiles, timestamp }) =>
new Interaction(
humanMessage,
assistantMessage,
Promise.resolve(
context.map(message => {
fullContext.map(message => {
if (message.file) {
return message
}
Expand All @@ -44,6 +46,7 @@ export class Transcript {
return message
})
),
usedContextFiles,
timestamp || new Date().toISOString()
)
),
Expand Down Expand Up @@ -144,20 +147,50 @@ export class Transcript {
return -1
}

public async toPrompt(preamble: Message[] = []): Promise<Message[]> {
public async getPromptForLastInteraction(
preamble: Message[] = []
): Promise<{ prompt: Message[]; contextFiles: ContextFile[] }> {
if (this.interactions.length == 0) {
return { prompt: [], contextFiles: [] }
}

const lastInteractionWithContextIndex = await this.getLastInteractionWithContextIndex()
const messages: Message[] = []
for (let index = 0; index < this.interactions.length; index++) {
// Include context messages for the last interaction that has a non-empty context.
const interactionMessages = await this.interactions[index].toPrompt(
index === lastInteractionWithContextIndex
)
messages.push(...interactionMessages)
const interaction = this.interactions[index]
const humanMessage = PromptMixin.mixInto(interaction.getHumanMessage())
const assistantMessage = interaction.getAssistantMessage()
const contextMessages = await interaction.getFullContext()
if (index === lastInteractionWithContextIndex) {
messages.push(...contextMessages, humanMessage, assistantMessage)
} else {
messages.push(humanMessage, assistantMessage)
}
}

const preambleTokensUsage = preamble.reduce((acc, message) => acc + estimateTokensUsage(message), 0)
const truncatedMessages = truncatePrompt(messages, MAX_AVAILABLE_PROMPT_LENGTH - preambleTokensUsage)
return [...preamble, ...truncatedMessages]

// Return what context fits in the window
const contextFiles: ContextFile[] = []
for (const msg of truncatedMessages) {
const contextFile = (msg as ContextMessage).file
if (contextFile) {
contextFiles.push(contextFile)
}
}

return {
prompt: [...preamble, ...truncatedMessages],
contextFiles,
}
}

public async setUsedContextFilesForLastInteraction(contextFiles: ContextFile[]) {
if (this.interactions.length === 0) {
throw new Error('Cannot set context files for empty transcript')
}
this.interactions[this.interactions.length - 1].setUsedContext(contextFiles)
}

public toChat(): ChatMessage[] {
Expand Down
79 changes: 25 additions & 54 deletions client/cody-shared/src/chat/transcript/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,97 +1,68 @@
import { ContextMessage, ContextFile } from '../../codebase-context/messages'
import { PromptMixin } from '../../prompt/prompt-mixin'
import { Message } from '../../sourcegraph-api'

import { ChatMessage, InteractionMessage } from './messages'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise, we also make the Interaction class a simple model class, without the need to hackily trigger the computation of cachedContextFiles on creation.

export interface InteractionJSON {
humanMessage: InteractionMessage
assistantMessage: InteractionMessage
context: ContextMessage[]
fullContext: ContextMessage[]
usedContextFiles: ContextFile[]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having context and cachedContextFiles, where it's confusing when to use either field, we have the following fields:

  • fullContext: the complete set of context messages we'd read if we had an infinite context window. This is set when the interaction is first created, before the prompt is computed.
  • usedContextFiles is the set of context files we actually read into the actual finite context window. This is set after we've computed the prompt (and therefore determined how many context files fit into the prompt context window).

timestamp: string
}

export class Interaction {
private readonly humanMessage: InteractionMessage
private assistantMessage: InteractionMessage
private cachedContextFiles: ContextFile[] = []
public readonly timestamp: string
private readonly context: Promise<ContextMessage[]>

constructor(
humanMessage: InteractionMessage,
assistantMessage: InteractionMessage,
context: Promise<ContextMessage[]>,
timestamp: string = new Date().toISOString()
) {
this.humanMessage = humanMessage
this.assistantMessage = assistantMessage
this.timestamp = timestamp

// This is some hacky behavior: returns a promise that resolves to the same array that was passed,
// but also caches the context file names in memory as a side effect.
this.context = context.then(messages => {
const contextFilesMap = messages.reduce((map, { file }) => {
if (!file?.fileName) {
return map
}
map[`${file.repoName || 'repo'}@${file?.revision || 'HEAD'}/${file.fileName}`] = file
return map
}, {} as { [key: string]: ContextFile })

// Cache the context files so we don't have to block the UI when calling `toChat` by waiting for the context to resolve.
this.cachedContextFiles = [
...Object.keys(contextFilesMap)
.sort((a, b) => a.localeCompare(b))
.map((key: string) => contextFilesMap[key]),
]

return messages
})
}
private readonly humanMessage: InteractionMessage,
private assistantMessage: InteractionMessage,
private fullContext: Promise<ContextMessage[]>,
private usedContextFiles: ContextFile[],
public readonly timestamp: string = new Date().toISOString()
) {}

public getAssistantMessage(): InteractionMessage {
return this.assistantMessage
return { ...this.assistantMessage }
}

public setAssistantMessage(assistantMessage: InteractionMessage): void {
this.assistantMessage = assistantMessage
}

public getHumanMessage(): InteractionMessage {
return { ...this.humanMessage }
}

public async getFullContext(): Promise<ContextMessage[]> {
const msgs = await this.fullContext
return msgs.map(msg => ({ ...msg }))
}

public async hasContext(): Promise<boolean> {
const contextMessages = await this.context
const contextMessages = await this.fullContext
return contextMessages.length > 0
}

public async toPrompt(includeContext: boolean): Promise<Message[]> {
const messages: (ContextMessage | InteractionMessage)[] = [
PromptMixin.mixInto(this.humanMessage),
this.assistantMessage,
]
if (includeContext) {
messages.unshift(...(await this.context))
}

return messages.map(message => ({ speaker: message.speaker, text: message.text }))
public setUsedContext(usedContextFiles: ContextFile[]): void {
this.usedContextFiles = usedContextFiles
}

/**
* Converts the interaction to chat message pair: one message from a human, one from an assistant.
*/
public toChat(): ChatMessage[] {
return [this.humanMessage, { ...this.assistantMessage, contextFiles: this.cachedContextFiles }]
return [this.humanMessage, { ...this.assistantMessage, contextFiles: this.usedContextFiles }]
}

public async toChatPromise(): Promise<ChatMessage[]> {
await this.context
await this.fullContext
return this.toChat()
}

public async toJSON(): Promise<InteractionJSON> {
return {
humanMessage: this.humanMessage,
assistantMessage: this.assistantMessage,
context: await this.context,
fullContext: await this.fullContext,
usedContextFiles: this.usedContextFiles,
timestamp: this.timestamp,
}
}
Expand Down
Loading
Loading