Skip to content

Commit

Permalink
Use the connect protocol to generate unary AI completions (#1559)
Browse files Browse the repository at this point in the history
* Update Foyle protos to use the new version which has the completion method.

* Fix code.

* Remove the Foyle Address option.

* Print out more information about the error.

* Format.

* Try adding a log statement to debug the tests.

* Try using a console message because log has problems with the mocks.

* Try setting runme.baseURL

* Add a comment.

* Use 8877 as the default port to avoid conflicts with 8080.
  • Loading branch information
jlewi authored Aug 26, 2024
1 parent 3499372 commit f6a63bf
Show file tree
Hide file tree
Showing 10 changed files with 119 additions and 123 deletions.
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ export NODE_OPTIONS="--experimental-specifier-resolution=node --max-old-space-si
npx runme run test:format test:lint test:unit test:e2e
```

```sh {"id":"01J5VPD3TXY1EAZDCXNHN60S77"}
export NODE_OPTIONS="--experimental-specifier-resolution=node --max-old-space-size=8192"
npx runme run test:format test:lint test:unit
```

When testing in CI environment, run:

```sh {"id":"01HF7VQMH8ESX1EFV4PGJBDGG0","name":"test:ci"}
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

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

11 changes: 3 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -840,14 +840,9 @@
"default": false,
"markdownDescription": "If set to `true` enables Stateful Authentication Provider"
},
"runme.foyleAddress": {
"type": "string",
"default": "localhost:9080",
"description": "The address of the Foyle Agent grpc service. The agent generates completions. This should in the form {host}:{port}."
},
"runme.aiBaseURL": {
"type": "string",
"default": "http://localhost:8080/api",
"default": "http://localhost:8877/api",
"description": "The base URL of the AI service."
}
}
Expand Down Expand Up @@ -1146,8 +1141,8 @@
"@aws-sdk/client-eks": "^3.635.0",
"@aws-sdk/credential-providers": "^3.635.0",
"@buf/grpc_grpc.community_timostamm-protobuf-ts": "^2.9.4-20240709201032-5be6b6661793.4",
"@buf/jlewi_foyle.bufbuild_es": "^1.10.0-20240818004946-86c4c8cbe96c.1",
"@buf/jlewi_foyle.connectrpc_es": "^1.4.0-20240818004946-86c4c8cbe96c.3",
"@buf/jlewi_foyle.bufbuild_es": "^1.10.0-20240819224840-b69cd71876b1.1",
"@buf/jlewi_foyle.connectrpc_es": "^1.4.0-20240819224840-b69cd71876b1.3",
"@buf/stateful_runme.community_timostamm-protobuf-ts": "^2.9.4-20240731204114-2df61af8e022.4",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-node": "^1.1.2",
Expand Down
118 changes: 58 additions & 60 deletions src/extension/ai/generate.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,82 @@
import * as vscode from 'vscode'
import { PromiseClient } from '@connectrpc/connect'
import { AIService } from '@buf/jlewi_foyle.connectrpc_es/foyle/v1alpha1/agent_connect'
import {
GenerateCellsRequest,
GenerateCellsResponse,
} from '@buf/jlewi_foyle.bufbuild_es/foyle/v1alpha1/agent_pb'

import { GenerateCellsRequest, GenerateCellsResponse } from '../grpc/aiTypes'
import * as serializer from '../serializer'
import getLogger from '../logger'
import { initAIServiceClient } from '../grpc/aiClient'
import { AIServiceClient } from '../grpc/aiTypes'

import * as protos from './protos'
import * as converters from './converters'
const log = getLogger('AIGenerate')

const clients = new Map<string, AIServiceClient>()
// CompletionGenerator is a class that generates completions for a notebook.
// It generates a single completion
export class CompletionGenerator {
client: PromiseClient<typeof AIService>

const extName = 'runme'

export async function generateCompletion() {
const editor = vscode.window.activeNotebookEditor

if (!editor) {
return
}

if (editor?.selection.isEmpty) {
return
}

// We subtract 1 because end is non-inclusive
const lastSelectedCell = editor?.selection.end - 1
log.trace(`generateCompletion: lastSelectedCell: ${lastSelectedCell}`)

// Notebook uses the vscode interface types NotebookDocument and NotebookCell. We
// need to convert this to NotebookCellData which is the concrete type used by the serializer.
// This allows us to reuse the existing serializer code.
let cellData = editor?.notebook.getCells().map((cell) => converters.cellToCellData(cell))
let notebookData = new vscode.NotebookData(cellData)

let notebookProto = serializer.GrpcSerializer.marshalNotebook(notebookData)

const req = GenerateCellsRequest.create()
req.notebook = notebookProto

const config = vscode.workspace.getConfiguration(extName)
// Include a default so that address is always well defined
const address = config.get<string>('foyleAddress', 'localhost:9080')

let client = clients.get(address)
if (!client) {
log.info(`Creating new client for address: ${address}`)
client = initAIServiceClient(address)
clients.set(address, client)
constructor(client: PromiseClient<typeof AIService>) {
this.client = client
}

client
.generateCells(req)
.then((finished) => {
let response = finished.response
// TODO(jeremy): We should have the server add the traceId to the response and then we should
// log it here. This is for debugging purposes as it allows to easily link to the logs
log.info('Generate request succeeded traceId')
public generateCompletion = async () => {
const editor = vscode.window.activeNotebookEditor

const insertCells = addAIGeneratedCells(lastSelectedCell + 1, response)
if (!editor) {
return
}

const edit = new vscode.WorkspaceEdit()
const notebookUri = editor?.notebook.uri
edit.set(notebookUri, [insertCells])
vscode.workspace.applyEdit(edit).then((result: boolean) => {
log.trace(`applyedit resolved with ${result}`)
})
})
.catch((error) => {
log.error(`AI Generate request failed ${error}`)
if (editor?.selection.isEmpty) {
return
})
}

// We subtract 1 because end is non-inclusive
const lastSelectedCell = editor?.selection.end - 1
log.trace(`generateCompletion: lastSelectedCell: ${lastSelectedCell}`)

// Notebook uses the vscode interface types NotebookDocument and NotebookCell. We
// need to convert this to NotebookCellData which is the concrete type used by the serializer.
// This allows us to reuse the existing serializer code.
let cellData = editor?.notebook.getCells().map((cell) => converters.cellToCellData(cell))
let notebookData = new vscode.NotebookData(cellData)

let notebookProto = serializer.GrpcSerializer.marshalNotebook(notebookData)

const req = new GenerateCellsRequest()
req.notebook = protos.notebookTSToES(notebookProto)

this.client
.generateCells(req)
.then((response) => {
// TODO(jeremy): We should have the server add the traceId to the response and then we should
// log it here. This is for debugging purposes as it allows to easily link to the logs
log.info('Generate request succeeded traceId')

const insertCells = addAIGeneratedCells(lastSelectedCell + 1, response)

const edit = new vscode.WorkspaceEdit()
const notebookUri = editor?.notebook.uri
edit.set(notebookUri, [insertCells])
vscode.workspace.applyEdit(edit).then((result: boolean) => {
log.trace(`applyedit resolved with ${result}`)
})
})
.catch((error) => {
log.error(`AI Generate request failed ${error}`)
return
})
}
}

// addAIGeneratedCells turns the response from the AI model into a set of cells that can be inserted into the notebook.
// This is done by returning a mutation to add the new cells to the notebook.
// index is the position in the notebook at which the new the new cells should be inserted.
//
function addAIGeneratedCells(index: number, response: GenerateCellsResponse): vscode.NotebookEdit {
let newCellData = converters.cellProtosToCellData(response.cells)
let newCellData = converters.cellProtosToCellData(protos.cellsESToTS(response.cells))
// Now insert the new cells at the end of the notebook
return vscode.NotebookEdit.insertCells(index, newCellData)
}
33 changes: 28 additions & 5 deletions src/extension/ai/manager.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,50 @@
import * as vscode from 'vscode'
import { createPromiseClient, PromiseClient, Transport } from '@connectrpc/connect'
import { createConnectTransport } from '@connectrpc/connect-node'
import { AIService } from '@buf/jlewi_foyle.connectrpc_es/foyle/v1alpha1/agent_connect'

import getLogger from '../logger'

import * as ghost from './ghost'
import * as stream from './stream'
import * as generate from './generate'

// AIManager is a class that manages the AI services.
export class AIManager {
log: ReturnType<typeof getLogger>

subscriptions: vscode.Disposable[] = []

client: PromiseClient<typeof AIService>
completionGenerator: generate.CompletionGenerator
constructor() {
const config = vscode.workspace.getConfiguration('runme.experiments')
const autoComplete = config.get<boolean>('aiAutoCell', false)

this.client = this.createAIClient()
this.log = getLogger('AIManager')

this.completionGenerator = new generate.CompletionGenerator(this.client)
if (autoComplete) {
this.registerGhostCellEvents()
}
}

createAIClient(): PromiseClient<typeof AIService> {
const config = vscode.workspace.getConfiguration('runme')
const baseURL = config.get<string>('runme.aiBaseURL', 'http://localhost:8877/api')
return createPromiseClient(AIService, createDefaultTransport(baseURL))
}

// registerGhostCellEvents should be called when the extension is activated.
// It registers event handlers to listen to when cells are added or removed
// as well as when cells change. This is used to create ghost cells.
registerGhostCellEvents() {
const config = vscode.workspace.getConfiguration('runme')
const baseUrl = config.get<string>('runme.aiBaseURL', 'http://localhost:8080/api')

this.log.info('AI: Enabling AutoCell Generation')
let cellGenerator = new ghost.GhostCellGenerator()

// Create a stream creator. The StreamCreator is a class that effectively windows events
// and turns each window into an AsyncIterable of streaming requests.
let creator = new stream.StreamCreator(cellGenerator, baseUrl)
let creator = new stream.StreamCreator(cellGenerator, this.client)

let eventGenerator = new ghost.CellChangeEventGenerator(creator)
// onDidChangeTextDocument fires when the contents of a cell changes.
Expand Down Expand Up @@ -61,3 +72,15 @@ export class AIManager {
this.subscriptions.forEach((subscription) => subscription.dispose())
}
}

function createDefaultTransport(baseURL: string): Transport {
return createConnectTransport({
// eslint-disable-next-line max-len
// N.B unlike https://github.com/connectrpc/examples-es/blob/656f27bbbfb218f1a6dce2c38d39f790859298f1/vanilla-node/client.ts#L25
// For some reason I didn't seem to have to allow unauthorized connections.
// Do we need to use http2?
httpVersion: '2',
// baseUrl needs to include the path prefix.
baseUrl: baseURL,
})
}
30 changes: 10 additions & 20 deletions src/extension/ai/stream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createPromiseClient, PromiseClient, Transport } from '@connectrpc/connect'
import { createConnectTransport } from '@connectrpc/connect-node'
import { ConnectError, PromiseClient } from '@connectrpc/connect'
import { AIService } from '@buf/jlewi_foyle.connectrpc_es/foyle/v1alpha1/agent_connect'
import {
StreamGenerateRequest,
Expand Down Expand Up @@ -65,14 +64,11 @@ export class StreamCreator {
lastIterator: PromiseIterator<StreamGenerateRequest> | null = null

handlers: CompletionHandlers
baseURL: string
client: PromiseClient<typeof AIService>
constructor(handlers: CompletionHandlers, baseURL: string) {
constructor(handlers: CompletionHandlers, client: PromiseClient<typeof AIService>) {
this.handlers = handlers
this.baseURL = baseURL

// Create a client this is actually a PromiseClient
this.client = createPromiseClient(AIService, createDefaultTransport(baseURL))
this.client = client
}

// handleEvent processes a request
Expand Down Expand Up @@ -133,7 +129,13 @@ export class StreamCreator {
this.handlers.processResponse(response)
}
} catch (error) {
console.log('Error processing responses:', error)
if (error instanceof ConnectError) {
log.error(
`Error processing response: ${error}; details: ${error.details}; rawMessage: ${error.rawMessage}`,
)
} else {
log.error(`Error processing response: ${error}`)
}
// Since an error occurred we want to start a new stream for the next request
if (this.lastIterator !== undefined && this.lastIterator !== null) {
// Do we need to call close here? What if the error indicates the stream already closed?
Expand Down Expand Up @@ -254,15 +256,3 @@ class PromiseIterator<T> {
return Promise.reject(err)
}
}

function createDefaultTransport(baseURL: string): Transport {
return createConnectTransport({
// eslint-disable-next-line max-len
// N.B unlike https://github.com/connectrpc/examples-es/blob/656f27bbbfb218f1a6dce2c38d39f790859298f1/vanilla-node/client.ts#L25
// For some reason I didn't seem to have to allow unauthorized connections.
// Do we need to use http2?
httpVersion: '2',
// baseUrl needs to include the path prefix.
baseUrl: baseURL,
})
}
6 changes: 4 additions & 2 deletions src/extension/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ import { IPanel } from './panels/base'
import { NotebookPanel as EnvStorePanel } from './panels/notebook'
import { NotebookCellStatusBarProvider } from './provider/cellStatusBar/notebook'
import { SessionOutputCellStatusBarProvider } from './provider/cellStatusBar/sessionOutput'
import * as generate from './ai/generate'
import * as manager from './ai/manager'
export class RunmeExtension {
protected serializer?: SerializerBase
Expand Down Expand Up @@ -330,7 +329,10 @@ export class RunmeExtension {
}),

// Register a command to generate completions using foyle
RunmeExtension.registerCommand('runme.aiGenerate', generate.generateCompletion),
RunmeExtension.registerCommand(
'runme.aiGenerate',
aiManager.completionGenerator.generateCompletion,
),
)

if (isPlatformAuthEnabled()) {
Expand Down
17 changes: 0 additions & 17 deletions src/extension/grpc/aiClient.ts

This file was deleted.

2 changes: 0 additions & 2 deletions src/extension/grpc/aiTypes.ts

This file was deleted.

Loading

0 comments on commit f6a63bf

Please sign in to comment.