This commit is contained in:
Andrew Pareles 2025-04-08 02:04:08 -07:00
parent 39bf2283cc
commit 4e197f4659
7 changed files with 113 additions and 64 deletions

View file

@ -586,6 +586,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const runningTerminalIds = this._terminalToolService.listTerminalIds()
const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, runningTerminalIds, chatMode })
// console.log('SYSTEM MESSAGE', systemMessage)
// all messages so far in the chat history (including tools)
const messages: LLMChatMessage[] = [
{ role: 'system', content: systemMessage, },
@ -613,6 +614,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (!opts.preapproved) { // skip this if pre-approved
// 1. validate tool params
try {
console.log('VALIDATING PARAMS!!!', opts.unvalidatedToolParams)
const params = await this._toolsService.validateParams[toolName](opts.unvalidatedToolParams)
toolParams = params
@ -716,12 +718,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
onText: ({ fullText, fullReasoning, toolCall }) => {
this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall }, 'merge')
},
onFinalMessage: async ({ fullText, toolCall, fullReasoning, anthropicReasoning }) => {
onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => {
this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning, anthropicReasoning })
// added to history and no longer streaming this, so clear messages so far and streamingToken (but do not stop isRunning)
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
// resolve with tool calls
resMessageIsDonePromise(toolCall)
resMessageIsDonePromise(toolCall) // resolve with tool calls
},
onError: (error) => {
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''

View file

@ -157,7 +157,6 @@ export class ToolsService implements IToolsService {
this.validateParams = {
read_file: async (params: ParsedToolParamsObj) => {
const { uri: uriStr, startLine: startLineUnknown, endLine: endLineUnknown, pageNumber: pageNumberUnknown } = params
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)

View file

@ -188,9 +188,11 @@ export const availableTools = (chatMode: ChatMode) => {
const availableToolsStr = (tools: InternalToolInfo[]) => {
return `${tools.map((t, i) => {
const params = Object.keys(t.params).map(paramName => `<${paramName}>\n${t.params[paramName].description}\n</${paramName}>`).join('\n')
const params = Object.keys(t.params).map(paramName => ` <${paramName}>\n${t.params[paramName].description}\n </${paramName}>`).join('\n')
return `\
${i}. ${t.name}: ${t.description}
${i}. ${t.name}
Description: ${t.description}
Format:
<${t.name}>${!params ? '' : `\n${params}`}
</${t.name}>`
}).join('\n\n')}`
@ -225,11 +227,16 @@ Tool calling details: ${''/* We expect tools to come at the end - not a hard li
- Tool calling is optional.
- To call a tool, just write its name followed by any parameters in XML format. For example:
<tool_name>
<parameter1>value1</parameter1>
<parameter2>value2</parameter2>
<parameter1>
value1
</parameter1>
<parameter2>
value2
</parameter2>
</tool_name>
- You must write your tool call at the END of your response. The beginning of your response should be your normal response followed by the tool call at the END.
- You must write your tool call at the END of your response. The beginning of your response should be normal text, explanations, etc (if you decide to write anything), followed by the tool call at the END.
- You are only allowed to output one tool call per response.
- You may omit optional parameters.
- The tool call will be executed immediately, and you will have access to the results in your next response.`
}
// - You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them.

View file

@ -41,7 +41,7 @@ export type LLMChatMessage = {
export type ParsedToolParamsObj = {
[paramName: string]: string;
[paramName: string]: string | undefined;
}
export type RawToolCallObj = {
name: ToolName;

View file

@ -1,14 +1,21 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js'
import { availableTools, InternalToolInfo, ToolName } from '../../common/prompt/prompts.js'
import { OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js'
import { OnFinalMessage, OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js'
import { ChatMode } from '../../common/voidSettingsTypes.js'
import sax from 'sax'
import { createSaxParser } from './sax.js'
// =============== reasoning ===============
// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true
export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => {
export const extractReasoningOnTextWrapper = (
onText: OnText, onFinalMessage: OnFinalMessage, thinkTags: [string, string]
): { newOnText: OnText, newOnFinalMessage: OnFinalMessage } => {
let latestAddIdx = 0 // exclusive index in fullText_
let foundTag1 = false
let foundTag2 = false
@ -100,19 +107,26 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string
onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
}
return newOnText
}
const getOnFinalMessageParams = () => {
const fullText_ = fullTextSoFar
const tag1Idx = fullText_.indexOf(thinkTags[0])
const tag2Idx = fullText_.indexOf(thinkTags[1])
if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning
if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning
export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [string, string]): { fullText: string, fullReasoning: string } => {
const tag1Idx = fullText_.indexOf(thinkTags[0])
const tag2Idx = fullText_.indexOf(thinkTags[1])
if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning
if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning
const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx)
const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity)
const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx)
const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity)
return { fullText, fullReasoning }
return { fullText, fullReasoning }
}
const newOnFinalMessage: OnFinalMessage = (params) => {
const { fullText, fullReasoning } = getOnFinalMessageParams()
onFinalMessage({ ...params, fullText, fullReasoning })
}
return { newOnText, newOnFinalMessage }
}
@ -131,9 +145,11 @@ type ToolsState = {
currentToolCall: RawToolCallObj,
}
export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) => {
export const extractToolsOnTextWrapper = (
onText: OnText, onFinalMessage: OnFinalMessage, chatMode: ChatMode
): { newOnText: OnText, newOnFinalMessage: OnFinalMessage } => {
const tools = availableTools(chatMode)
if (!tools) return onText
if (!tools) return { newOnText: onText, newOnFinalMessage: onFinalMessage }
const toolOfToolName: { [toolName: string]: InternalToolInfo | undefined } = {}
for (const t of tools) { toolOfToolName[t.name] = t }
@ -149,17 +165,15 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) =>
const getRawNewText = () => {
return trueFullText.substring(parser.startTagPosition, parser.position + 1)
}
const parser = sax.parser(false, {
lowercase: true,
});
const parser = createSaxParser({ lowercase: true })
// when see open tag <tagName>
parser.onopentag = (node) => {
const rawNewText = getRawNewText()
console.log('raw new text a', rawNewText)
console.log('OPEN!', node.name)
const tagName = node.name;
console.log('OPENING', tagName)
console.log('state0:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName })
if (state.level === 'normal') {
if (tagName in toolOfToolName) { // valid toolName
state = {
@ -170,6 +184,8 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) =>
}
else {
fullText += rawNewText // count as plaintext
console.log('adding raw a', rawNewText)
}
}
else if (state.level === 'tool') {
@ -185,31 +201,25 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) =>
// would normally be rawNewText, but we ignore all text inside tools
}
}
else if (state.level === 'param') {
else if (state.level === 'param') { // cannot double nest
fullText += rawNewText // count as plaintext
}
};
console.log('adding raw b', rawNewText)
parser.ontext = (text) => {
console.log('TEXT!', JSON.stringify(text))
if (state.level === 'normal') {
fullText += text
}
// start param
else if (state.level === 'tool') {
// ignore all text in a tool, all text should go in the param tags inside it
}
else if (state.level === 'param') {
state.currentToolCall.rawParams[state.currentToolCall.name] += text
}
}
console.log('state1:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName })
};
parser.onclosetag = (tagName) => {
const rawNewText = getRawNewText()
console.log('raw new text b', rawNewText)
console.log('CLOSE!', tagName)
console.log('CLOSING', tagName)
console.log('state0:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName })
if (state.level === 'normal') {
fullText += rawNewText
console.log('adding raw A', rawNewText)
}
else if (state.level === 'tool') {
if (tagName === state.toolName) { // closed the tool
@ -221,6 +231,7 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) =>
}
else { // add as text
fullText += rawNewText
console.log('adding raw B', rawNewText)
}
}
else if (state.level === 'param') {
@ -234,21 +245,40 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) =>
}
else {
fullText += rawNewText
console.log('adding raw C', rawNewText)
}
}
console.log('state1:', state.level, { toolName: (state as any).toolName, paramName: (state as any).paramName })
};
parser.ontext = (text) => {
if (state.level === 'normal') {
fullText += text
}
// start param
else if (state.level === 'tool') {
// ignore all text in a tool, all text should go in the param tags inside it
}
else if (state.level === 'param') {
if (!(state.paramName in state.currentToolCall.rawParams)) state.currentToolCall.rawParams[state.paramName] = ''
state.currentToolCall.rawParams[state.paramName] += text
}
}
let prevFullTextLen = 0
const newOnText: OnText = (params) => {
const newText = params.fullText.substring(prevFullTextLen)
prevFullTextLen = params.fullText.length
trueFullText = params.fullText
console.log('newText', newText.length)
parser.write(newText)
console.log('calling ontext...')
onText({
...params,
fullText,
@ -256,7 +286,15 @@ export const extractToolsOnTextWrapper = (onText: OnText, chatMode: ChatMode) =>
});
};
return newOnText;
const newOnFinalMessage: OnFinalMessage = (params) => {
console.log('final message!!!', trueFullText)
console.log('----- returning ----\n', fullText)
console.log('----- tools ----\n', JSON.stringify(currentToolCalls, null, 2))
onFinalMessage({ ...params, fullText, toolCall: currentToolCalls[0] })
}
return { newOnText, newOnFinalMessage };
}

View file

@ -63,7 +63,7 @@ export function createSaxParser(options: SaxParserOptions = {}): SaxParser {
if (cursor < buffer.length && this.ontext) {
this.ontext(buffer.substring(cursor));
}
// Clear the buffer once all content is processed.
// Clear the buffer since all content is processed.
buffer = '';
break;
}
@ -123,7 +123,11 @@ export function createSaxParser(options: SaxParserOptions = {}): SaxParser {
// Move the cursor past the current tag.
cursor = gtIndex + 1;
}
},
// Remove any content already processed from the buffer.
buffer = buffer.slice(cursor);
}
};
return parser;

View file

@ -11,7 +11,7 @@ import { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, On
import { ChatMode, defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js';
import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js';
import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js';
import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js';
import { extractReasoningOnTextWrapper, extractToolsOnTextWrapper } from './extractGrammar.js';
type InternalCommonMessageParams = {
@ -156,12 +156,16 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage
const { needsManualParse: needsManualReasoningParse, nameOfFieldInDelta: nameOfReasoningFieldInDelta } = providerReasoningIOSettings?.output ?? {}
const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags
if (manuallyParseReasoning) {
onText = extractReasoningOnTextWrapper(onText, openSourceThinkTags)
const { newOnText, newOnFinalMessage } = extractReasoningOnTextWrapper(onText, onFinalMessage, openSourceThinkTags)
onText = newOnText
onFinalMessage = newOnFinalMessage
}
// manually parse out tool results
if (chatMode) {
onText = extractToolsOnTextWrapper(onText, chatMode)
const { newOnText, newOnFinalMessage } = extractToolsOnTextWrapper(onText, onFinalMessage, chatMode)
onText = newOnText
onFinalMessage = newOnFinalMessage
}
let fullReasoningSoFar = ''
@ -192,12 +196,7 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage
onError({ message: 'Void: Response from model was empty.', fullError: null })
}
else {
if (manuallyParseReasoning) {
const { fullText, fullReasoning } = extractReasoningOnFinalMessage(fullTextSoFar, openSourceThinkTags)
onFinalMessage({ fullText, fullReasoning, anthropicReasoning: null });
} else {
onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null });
}
onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null });
}
})
// when error/fail - this catches errors of both .create() and .then(for await)
@ -282,7 +281,9 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM
// manually parse out tool results
if (chatMode) {
onText = extractToolsOnTextWrapper(onText, chatMode)
const { newOnText, newOnFinalMessage } = extractToolsOnTextWrapper(onText, onFinalMessage, chatMode)
onText = newOnText
onFinalMessage = newOnFinalMessage
}
// when receive text