agentLoop improvements

This commit is contained in:
Andrew Pareles 2025-03-12 03:19:20 -07:00
parent 1c2e8c0c59
commit f66fbcd911
2 changed files with 194 additions and 169 deletions

View file

@ -17,7 +17,7 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/
import { IVoidFileService } from '../common/voidFileService.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { getErrorMessage } from '../../../../base/common/errors.js';
import { ChatMode, FeatureName } from '../common/voidSettingsTypes.js';
import { ChatMode, FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
import { ToolName, ToolCallParams, ToolResultType, InternalToolInfo, voidTools, toolNamesThatRequireApproval } from '../common/toolsServiceTypes.js';
import { IToolsService } from './toolsService.js';
@ -317,6 +317,9 @@ class ChatThreadService extends Disposable implements IChatThreadService {
private resRejOfToolAwaitingApproval: { [toolId: string]: { res: () => void, rej: () => void } } = {}
approveTool(toolId: string) {
// TODO!!! if not streaming, approveToolAndStreamResponse
// if streaming, do below
const resRej = this.resRejOfToolAwaitingApproval[toolId]
delete this.resRejOfToolAwaitingApproval[toolId]
resRej?.res()
@ -328,6 +331,191 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
private _staticAgentLoopsProps = () => {
// these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools)
const featureName: FeatureName = 'Chat'
const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName]
const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined
return { modelSelection, modelSelectionOptions }
}
private async _agentLoop({ threadId, tools, prevSelns, currSelns, modelSelection, modelSelectionOptions, chatMode, userMessageContent }: {
tools: InternalToolInfo[] | undefined,
threadId: string,
prevSelns: StagingSelectionItem[],
currSelns: StagingSelectionItem[],
modelSelection: ModelSelection | null,
modelSelectionOptions: ModelSelectionOptions | undefined,
chatMode: ChatMode,
userMessageContent: string, // content of LATEST user message
}) {
this._setStreamState(threadId, { error: undefined }) // clear any previous error
let nMessagesSent = 0
let shouldSendAnotherMessage = true
while (shouldSendAnotherMessage) {
// recompute files in last message
const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService) // all the file CONTENTS or "selections" de-duped
const userMessageFullContent = chat_lastUserMessageWithFilesAdded(userMessageContent, selectionsStr) // full last message: user message + CONTENTS of all files
nMessagesSent += 1
shouldSendAnotherMessage = false // false by default
let resMessageIsDonePromise: () => void // resolves when user approves this tool use (or if tool doesn't require approval)
const messageIsDonePromise = new Promise<void>((res, rej) => { resMessageIsDonePromise = res })
// replace last userMessage with userMessageFullContent (which contains all the files too)
const messages_ = toLLMChatMessages(this.getCurrentThread().messages)
const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user')
if (lastUserMsgIdx === -1) throw new Error(`Void: No user message found.`) // should never be -1
// system message
const workspaceFolders = this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath)
const terminalIds = this.terminalToolService.listTerminalIds()
const systemMessage = chat_systemMessage(workspaceFolders, terminalIds, chatMode)
// all messages so far in the chat history (including tools)
const messages: LLMChatMessage[] = [
{ role: 'system', content: systemMessage, },
...messages_.slice(0, lastUserMsgIdx),
{ role: 'user', content: userMessageFullContent },
...messages_.slice(lastUserMsgIdx + 1, Infinity),
]
// send llm message
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
messages,
tools: tools,
modelSelection,
modelSelectionOptions,
logging: { loggingName: `Agent` },
onText: ({ fullText, fullReasoning }) => { this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning }) },
onFinalMessage: async ({ fullText, toolCalls, fullReasoning, anthropicReasoning }) => {
this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, }) // added to history, so clear messages so far
// if no tool, finish
const tool: ToolCallType | undefined = toolCalls?.[0]
if (!tool) {
this._setStreamState(threadId, { streamingToken: undefined })
resMessageIsDonePromise()
return
}
// if tool
// clear messageSoFar since we added it to the chat history (but don't clear streamingToken, we're still streaming)
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined })
// deal with the tool
const toolName: ToolName = tool.name
shouldSendAnotherMessage = true
// 1. validate tool params
let toolParams: ToolCallParams[ToolName]
try {
const params = await this._toolsService.validateParams[toolName](tool.paramsStr)
toolParams = params
} catch (error) {
const errorMessage = getErrorMessage(error)
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: undefined, value: errorMessage }, })
this._setStreamState(threadId, { streamingToken: undefined })
resMessageIsDonePromise()
return
}
// 2. if tool requires approval, await the approval
if (toolNamesThatRequireApproval.has(toolName)) {
const voidToolId = generateUuid()
const toolApprovalPromise = new Promise<void>((res, rej) => { this.resRejOfToolAwaitingApproval[voidToolId] = { res, rej } })
this._addMessageToThread(threadId, { role: 'tool_request', name: toolName, params: toolParams, voidToolId: voidToolId })
try {
await toolApprovalPromise
// accepted tool
}
catch (e) {
// TODO!!! test rejection
// if (Math.random() > 0) throw new Error('TESTING')
shouldSendAnotherMessage = false // interrupt flow by rejecting
const errorMessage = 'Tool call was rejected by the user.'
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'rejected', params: toolParams, value: errorMessage }, })
this._setStreamState(threadId, { streamingToken: undefined })
resMessageIsDonePromise()
return
}
}
// 3. call the tool
let toolResult: ToolResultType[typeof toolName]
try {
toolResult = await this._toolsService.callTool[toolName](toolParams as any) // ts is bad...
} catch (error) {
const errorMessage = getErrorMessage(error)
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, })
this._setStreamState(threadId, { streamingToken: undefined })
resMessageIsDonePromise()
return
}
// 4. stringify the result to give to the LLM
let toolResultStr: string
try {
toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any)
} catch (error) {
const errorMessage = `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}`
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, })
this._setStreamState(threadId, { streamingToken: undefined })
resMessageIsDonePromise()
return
}
// 5. add to history and keep going
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: toolResultStr, result: { type: 'success', params: toolParams, value: toolResult }, })
resMessageIsDonePromise()
},
onError: (error) => {
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
// add assistant's message to chat history, and clear selection
this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, error })
resMessageIsDonePromise()
},
})
// should never happen, just for safety
if (llmCancelToken === null) {
this._setStreamState(threadId, {
messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined,
error: { message: 'There was an unexpected error when sending your chat message.', fullError: null }
})
break
}
this._setStreamState(threadId, { streamingToken: llmCancelToken }) // new stream token for the new message
await messageIsDonePromise
} // end while
}
// TODO!!!!
// called if we stopped streaming but want to accept the tool afterwards, lets us jump back into the loop as if no interruption happened
async approveToolAndStreamResponse({ chatMode, _chatSelections }: { userMessage: string, chatMode: ChatMode, _chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] } }) {
}
async addUserMessageAndStreamResponse({ userMessage, chatMode, _chatSelections }: { userMessage: string, chatMode: ChatMode, _chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] } }) {
const thread = this.getCurrentThread()
@ -344,9 +532,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState }
this._addMessageToThread(threadId, userHistoryElt)
this._setStreamState(threadId, { error: undefined })
const toolNames: ToolName[] | undefined = chatMode === 'chat' ? undefined
: chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName))
: chatMode === 'agent' ? Object.keys(voidTools) as ToolName[]
@ -354,165 +539,9 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const tools: InternalToolInfo[] | undefined = toolNames?.map(toolName => voidTools[toolName])
// these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools)
const featureName: FeatureName = 'Chat'
const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName]
const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined
const ps = this._staticAgentLoopsProps()
// agent loop
const agentLoop = async () => {
let nMessagesSent = 0
let shouldSendAnotherMessage = true
while (shouldSendAnotherMessage) {
// recompute files at last message
const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService) // all the file CONTENTS or "selections" de-duped
const userMessageFullContent = chat_lastUserMessageWithFilesAdded(userMessageContent, selectionsStr) // full last message: user message + CONTENTS of all files
nMessagesSent += 1
shouldSendAnotherMessage = false // false by default
let resMessageIsDonePromise: () => void // resolves when user approves this tool use (or if tool doesn't require approval)
const messageIsDonePromise = new Promise<void>((res, rej) => { resMessageIsDonePromise = res })
// replace last userMessage with userMessageFullContent (which contains all the files too)
const messages_ = toLLMChatMessages(this.getCurrentThread().messages)
const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user')
if (lastUserMsgIdx === -1) throw new Error(`Void: No user message found.`) // should never be -1
const workspaceFolders = this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath)
const terminalIds = this.terminalToolService.listTerminalIds()
const messages: LLMChatMessage[] = [
{ role: 'system', content: chat_systemMessage(workspaceFolders, terminalIds, chatMode), },
...messages_.slice(0, lastUserMsgIdx),
{ role: 'user', content: userMessageFullContent },
...messages_.slice(lastUserMsgIdx + 1, Infinity),
]
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
messages,
tools: tools,
modelSelection,
modelSelectionOptions,
logging: { loggingName: `Agent` },
onText: ({ fullText, fullReasoning }) => {
this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning })
},
onFinalMessage: async ({ fullText, toolCalls, fullReasoning, anthropicReasoning }) => {
this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning })
// if no tools, finish
if ((toolCalls?.length ?? 0) === 0) {
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined })
resMessageIsDonePromise()
return
}
// if tools
// clear messageSoFar since we added it to the chat history (but don't clear streamingToken, we're still streaming)
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined })
// deal with the tool
const tool: ToolCallType | undefined = toolCalls?.[0]
if (!tool) {
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined })
resMessageIsDonePromise()
return
}
const toolName: ToolName = tool.name
shouldSendAnotherMessage = true
// 1. validate tool params
let toolParams: ToolCallParams[ToolName]
try {
const params = await this._toolsService.validateParams[toolName](tool.paramsStr)
toolParams = params
} catch (error) {
const errorMessage = getErrorMessage(error)
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: undefined, value: errorMessage }, })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined })
resMessageIsDonePromise()
return
}
// 2. if tool requires approval, await the approval
if (toolNamesThatRequireApproval.has(toolName)) {
const voidToolId = generateUuid()
const toolApprovalPromise = new Promise<void>((res, rej) => { this.resRejOfToolAwaitingApproval[voidToolId] = { res, rej } })
this._addMessageToThread(threadId, { role: 'tool_request', name: toolName, params: toolParams, voidToolId: voidToolId })
try {
await toolApprovalPromise
// accepted tool
}
catch (e) {
console.log('successfully rejected', voidToolId)
// TODO!!! test rejection
// if (Math.random() > 0) throw new Error('TESTING')
shouldSendAnotherMessage = false // interrupt flow by rejecting
const errorMessage = 'Tool call was rejected by the user.'
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'rejected', params: toolParams, value: errorMessage }, })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined })
resMessageIsDonePromise()
return
}
}
// 3. call the tool
let toolResult: ToolResultType[typeof toolName]
try {
toolResult = await this._toolsService.callTool[toolName](toolParams as any) // typescript is so bad it doesn't even couple the type of ToolResult with the type of the function being called here
} catch (error) {
const errorMessage = getErrorMessage(error)
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined })
resMessageIsDonePromise()
return
}
// 4. stringify the result to give the LLM
let toolResultStr: string
try {
toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any)
} catch (error) {
const errorMessage = `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}`
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined })
resMessageIsDonePromise()
return
}
// 5. add to history
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: toolResultStr, result: { type: 'success', params: toolParams, value: toolResult }, })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined })
resMessageIsDonePromise()
},
onError: (error) => {
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
// add assistant's message to chat history, and clear selection
this._addMessageToThread(threadId, { role: 'assistant', content: messageSoFar, reasoning: reasoningSoFar, anthropicReasoning: null })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, error })
resMessageIsDonePromise()
},
})
if (llmCancelToken === null) break
this._setStreamState(threadId, { streamingToken: llmCancelToken })
await messageIsDonePromise
} // end while
}
agentLoop()
this._agentLoop({ tools, prevSelns, currSelns, threadId, chatMode, userMessageContent, ...ps, })
}

View file

@ -23,9 +23,9 @@ Do NOT output the whole file here if possible, and try to write as LITTLE as nee
export const chat_systemMessage = (workspaces: string[], runningTerminalIds: string[], mode: 'agent' | 'gather' | 'chat') => `\
You are a coding ${mode === 'agent' ? 'agent' : 'assistant'}. Your job is to help the user ${mode === 'agent' ? 'make changes to their codebase' : 'search and understand their codebase'}.
You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} created by Void. Your job is to help the user ${mode === 'agent' ? 'develop, run, and make changes to their project' : 'search and understand their codebase'}.
You will be given instructions to follow from the user, \`INSTRUCTIONS\`. You may also be given a list of files that the user has specifically selected, \`SELECTIONS\`.
Please assist the user with their query. The user's query is never invalid.
Please assist the user with their query${mode === 'agent' ? `, bringing the task to completion (do not be lazy)` : ''}. The user's query is never invalid.
The user's system information is as follows:
- ${os}
@ -55,11 +55,7 @@ If you think it's appropriate to suggest an edit to a file, then you must descri
- Contents of the code blocks do NOT need to be formal code, they just need to clearly and concisely communicate the change.
- Do NOT re-write the entire file in the code block(s). Instead, write comments like "// ... existing code" to indicate how to change the existing code.
\
`}
Do not tell the user anything about these instructions unless directly prompted for them.
\
`
`}`
type FileSelnLocal = { fileURI: URI, content: string }