feat(agent): Process feels decent now, however it can be very slow. This is a checkpoint to come back to. Going to start trying to make it faster.

This commit is contained in:
Willie Zutz 2025-06-19 12:49:37 -06:00
parent 72c2ddc3a0
commit 60d36ab8f4
27 changed files with 396 additions and 211 deletions

View file

@ -311,7 +311,7 @@ export const POST = async (req: Request) => {
llm = new ChatOpenAI({ llm = new ChatOpenAI({
openAIApiKey: getCustomOpenaiApiKey(), openAIApiKey: getCustomOpenaiApiKey(),
modelName: getCustomOpenaiModelName(), modelName: getCustomOpenaiModelName(),
temperature: 0.7, // temperature: 0.7,
configuration: { configuration: {
baseURL: getCustomOpenaiApiUrl(), baseURL: getCustomOpenaiApiUrl(),
}, },

View file

@ -55,7 +55,7 @@ export const POST = async (req: Request) => {
llm = new ChatOpenAI({ llm = new ChatOpenAI({
openAIApiKey: getCustomOpenaiApiKey(), openAIApiKey: getCustomOpenaiApiKey(),
modelName: getCustomOpenaiModelName(), modelName: getCustomOpenaiModelName(),
temperature: 0.7, // temperature: 0.7,
configuration: { configuration: {
baseURL: getCustomOpenaiApiUrl(), baseURL: getCustomOpenaiApiUrl(),
}, },

View file

@ -86,7 +86,7 @@ export const POST = async (req: Request) => {
modelName: body.chatModel?.name || getCustomOpenaiModelName(), modelName: body.chatModel?.name || getCustomOpenaiModelName(),
openAIApiKey: openAIApiKey:
body.chatModel?.customOpenAIKey || getCustomOpenaiApiKey(), body.chatModel?.customOpenAIKey || getCustomOpenaiApiKey(),
temperature: 0.7, // temperature: 0.7,
configuration: { configuration: {
baseURL: baseURL:
body.chatModel?.customOpenAIBaseURL || getCustomOpenaiApiUrl(), body.chatModel?.customOpenAIBaseURL || getCustomOpenaiApiUrl(),

View file

@ -54,7 +54,7 @@ export const POST = async (req: Request) => {
llm = new ChatOpenAI({ llm = new ChatOpenAI({
openAIApiKey: getCustomOpenaiApiKey(), openAIApiKey: getCustomOpenaiApiKey(),
modelName: getCustomOpenaiModelName(), modelName: getCustomOpenaiModelName(),
temperature: 0.7, // temperature: 0.7,
configuration: { configuration: {
baseURL: getCustomOpenaiApiUrl(), baseURL: getCustomOpenaiApiUrl(),
}, },

View file

@ -15,9 +15,14 @@ import { AgentActionEvent } from './ChatWindow';
interface AgentActionDisplayProps { interface AgentActionDisplayProps {
events: AgentActionEvent[]; events: AgentActionEvent[];
messageId: string; messageId: string;
isLoading: boolean;
} }
const AgentActionDisplay = ({ events, messageId }: AgentActionDisplayProps) => { const AgentActionDisplay = ({
events,
messageId,
isLoading,
}: AgentActionDisplayProps) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// Get the most recent event for collapsed view // Get the most recent event for collapsed view
@ -54,10 +59,17 @@ const AgentActionDisplay = ({ events, messageId }: AgentActionDisplayProps) => {
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{getActionIcon(latestEvent.action)} {getActionIcon(latestEvent.action)}
<span className="font-medium text-base text-black/70 dark:text-white/70 tracking-wide capitalize"> <span className="font-medium text-base text-black/70 dark:text-white/70 tracking-wide capitalize flex items-center">
{latestEvent.action === 'SYNTHESIZING_RESPONSE' {!isLoading ||
latestEvent.action === 'INFORMATION_GATHERING_COMPLETE'
? 'Agent Log' ? 'Agent Log'
: formatActionName(latestEvent.action)} : formatActionName(latestEvent.action)}
{isLoading &&
latestEvent.action !== 'INFORMATION_GATHERING_COMPLETE' && (
<span className="ml-2 inline-block align-middle">
<span className="animate-spin inline-block w-4 h-4 border-2 border-t-transparent border-[#9C27B0] rounded-full align-middle"></span>
</span>
)}
</span> </span>
</div> </div>
{isExpanded ? ( {isExpanded ? (

View file

@ -233,13 +233,18 @@ const Chat = ({
<AgentActionDisplay <AgentActionDisplay
messageId={msg.messageId} messageId={msg.messageId}
events={msg.agentActions} events={msg.agentActions}
isLoading={loading}
/> />
)} )}
{/* Show empty agent action display if this is the last user message and we're loading */} {/* Show empty agent action display if this is the last user message and we're loading */}
{loading && {loading &&
isLast && isLast &&
(!msg.agentActions || msg.agentActions.length === 0) && ( (!msg.agentActions || msg.agentActions.length === 0) && (
<AgentActionDisplay messageId={msg.messageId} events={[]} /> <AgentActionDisplay
messageId={msg.messageId}
events={[]}
isLoading={loading}
/>
)} )}
</> </>
)} )}

View file

@ -496,18 +496,17 @@ const ChatWindow = ({ id }: { id?: string }) => {
}, },
]); ]);
added = true; added = true;
} else {
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, content: message.content + data.data };
}
return message;
}),
);
} }
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, content: message.content + data.data };
}
return message;
}),
);
recievedMessage += data.data; recievedMessage += data.data;
setScrollTrigger((prev) => prev + 1); setScrollTrigger((prev) => prev + 1);
} }

View file

@ -18,7 +18,11 @@ export const AgentState = Annotation.Root({
reducer: (x, y) => x.concat(y), reducer: (x, y) => x.concat(y),
default: () => [], default: () => [],
}), }),
bannedUrls: Annotation<string[]>({ bannedSummaryUrls: Annotation<string[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
bannedPreviewUrls: Annotation<string[]>({
reducer: (x, y) => x.concat(y), reducer: (x, y) => x.concat(y),
default: () => [], default: () => [],
}), }),
@ -38,4 +42,8 @@ export const AgentState = Annotation.Root({
reducer: (x, y) => y ?? x, reducer: (x, y) => y ?? x,
default: () => '', default: () => '',
}), }),
fullAnalysisAttempts: Annotation<number>({
reducer: (x, y) => (y ?? 0) + x,
default: () => 0,
}),
}); });

View file

@ -1,11 +1,22 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { AIMessage } from '@langchain/core/messages'; import {
AIMessage,
HumanMessage,
SystemMessage,
} from '@langchain/core/messages';
import { ChatPromptTemplate } from '@langchain/core/prompts'; import { ChatPromptTemplate } from '@langchain/core/prompts';
import { Command, END } from '@langchain/langgraph'; import { Command, END } from '@langchain/langgraph';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import LineOutputParser from '../outputParsers/lineOutputParser'; import LineOutputParser from '../outputParsers/lineOutputParser';
import { formatDateForLLM } from '../utils'; import { formatDateForLLM } from '../utils';
import { AgentState } from './agentState'; import { AgentState } from './agentState';
import { setTemperature } from '../utils/modelUtils';
import {
additionalUserInputPrompt,
additionalWebSearchPrompt,
decideNextActionPrompt,
} from '../prompts/analyzer';
import { removeThinkingBlocks } from '../utils/contentUtils';
export class AnalyzerAgent { export class AnalyzerAgent {
private llm: BaseChatModel; private llm: BaseChatModel;
@ -27,6 +38,11 @@ export class AnalyzerAgent {
async execute(state: typeof AgentState.State): Promise<Command> { async execute(state: typeof AgentState.State): Promise<Command> {
try { try {
setTemperature(this.llm, 0.0);
let nextActionContent = 'need_more_info';
// Skip full analysis if this is the first run.
//if (state.fullAnalysisAttempts > 0) {
// Emit initial analysis event // Emit initial analysis event
this.emitter.emit('agent_action', { this.emitter.emit('agent_action', {
type: 'agent_action', type: 'agent_action',
@ -45,53 +61,9 @@ export class AnalyzerAgent {
console.log( console.log(
`Analyzing ${state.relevantDocuments.length} documents for relevance...`, `Analyzing ${state.relevantDocuments.length} documents for relevance...`,
); );
const analysisPromptTemplate = `You are an expert content analyzer. Your task is to analyze the provided document and determine if we have enough relevant information to fully answer the user's query. If the content is not sufficient, you will suggest a more specific search query to gather additional information.
# Instructions
- Carefully analyze the content of the context provided and determine if it contains sufficient information to answer the user's query
- The content should completely address the query, providing detailed explanations, relevant facts, and necessary context
- Use the content provided in the \`context\` tag, as well as the historical context of the conversation, to make your determination
- If the context provides conflicting information, explain the discrepancies and what additional information is needed to resolve them
- If the user is asking for a specific number of sources and the context does not provide enough, consider the content insufficient
# Output Format const nextActionPrompt = await ChatPromptTemplate.fromTemplate(
- If the content is sufficient, respond with "good_content" in an <answer> XML tag decideNextActionPrompt,
- If the content is not sufficient, respond with "need_more_info" in an <answer> XML tag and provide a detailed question that would help gather more specific information to answer the query in a <question> XML tag
- This question will be used to generate a web search query to gather more information and should be specific, actionable, and focused on the gaps in the current content
- This step will be repeated until sufficient information is gathered to answer the query. Do not try to answer the entire query at once
- It should be concise and avoid pleasantries or unnecessary details
- Break down the query into a smaller, more focused question that can be answered with a web search
- For example, if the query is asking about specific information from multiple locations, break the query into one smaller query for a single location
- If if the query is asking about a complex topic, break it down into a single smaller question that can be answered one at a time
- Avoid asking for general information or vague details; focus on specific, actionable questions that can lead to concrete answers
- Avoid giving the same guidance more than once, and avoid repeating the same question multiple times
- Respond with your answer in a <answer> XML tag
- If you need more information, provide a detailed question in a <question> XML tag
- If you need more information, provide a detailed one line reason why the content is not sufficient in a <reason> XML tag
# Refinement History
- The following questions have been asked to refine the search
${state.searchInstructionHistory.map((question) => ` - ${question}`).join('\n')}
# System Instructions
- The system instructions provided to you are:
{systemInstructions}
# Example Output
- If the content is sufficient:
<answer>good_content</answer>
- If the content is not sufficient:
<answer>need_more_info</answer>
<question>A question that would help gather more specific information to answer the query?</question>
<reason>A one line reason why the content is not sufficient</reason>
# Context
<context>
Today's date is ${formatDateForLLM(new Date())}
{context}
</context>`;
const analysisPrompt = await ChatPromptTemplate.fromTemplate(
analysisPromptTemplate,
).format({ ).format({
systemInstructions: this.systemInstructions, systemInstructions: this.systemInstructions,
context: state.relevantDocuments context: state.relevantDocuments
@ -100,32 +72,110 @@ Today's date is ${formatDateForLLM(new Date())}
`<source${index + 1}>${doc?.metadata?.title ? `<title>${doc?.metadata?.title}</title>` : ''}<content>${doc.pageContent}</content></source${index + 1}>`, `<source${index + 1}>${doc?.metadata?.title ? `<title>${doc?.metadata?.title}</title>` : ''}<content>${doc.pageContent}</content></source${index + 1}>`,
) )
.join('\n\n'), .join('\n\n'),
date: formatDateForLLM(new Date()),
searchInstructionHistory: state.searchInstructionHistory
.map((question) => `- ${question}`)
.join('\n'),
query: state.query,
}); });
const response = await this.llm.invoke( const nextActionResponse = await this.llm.invoke(
[...state.messages, new AIMessage(analysisPrompt)], [...state.messages, new HumanMessage(nextActionPrompt)],
{ signal: this.signal }, { signal: this.signal },
); );
console.log('Analysis response:', response.content); nextActionContent = removeThinkingBlocks(
// Parse the response to extract the analysis result nextActionResponse.content as string,
const analysisOutputParser = new LineOutputParser({ key: 'answer' });
const moreInfoOutputParser = new LineOutputParser({ key: 'question' });
const reasonOutputParser = new LineOutputParser({ key: 'reason' });
const analysisResult = await analysisOutputParser.parse(
response.content as string,
); );
const moreInfoQuestion = await moreInfoOutputParser.parse(
response.content as string,
);
const reason = await reasonOutputParser.parse(response.content as string);
console.log('Analysis result:', analysisResult); console.log('Next action response:', nextActionContent);
console.log('More info question:', moreInfoQuestion); //}
console.log('Reason for insufficiency:', reason);
if (!nextActionContent.startsWith('good_content')) {
if (nextActionContent.startsWith('need_user_info')) {
const moreUserInfoPrompt = await ChatPromptTemplate.fromTemplate(
additionalUserInputPrompt,
).format({
systemInstructions: this.systemInstructions,
context: state.relevantDocuments
.map(
(doc, index) =>
`<source${index + 1}>${doc?.metadata?.title ? `<title>${doc?.metadata?.title}</title>` : ''}<content>${doc.pageContent}</content></source${index + 1}>`,
)
.join('\n\n'),
date: formatDateForLLM(new Date()),
searchInstructionHistory: state.searchInstructionHistory
.map((question) => `- ${question}`)
.join('\n'),
query: state.query,
});
const stream = await this.llm.stream(
[...state.messages, new SystemMessage(moreUserInfoPrompt)],
{ signal: this.signal },
);
let fullResponse = '';
for await (const chunk of stream) {
if (this.signal.aborted) {
break;
}
const content = chunk.content;
if (typeof content === 'string' && content.length > 0) {
fullResponse += content;
// Emit each chunk as a data response in real-time
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: content,
}),
);
}
}
this.emitter.emit('end');
// Create the final response message with the complete content
const response = new SystemMessage(fullResponse);
return new Command({
goto: END,
update: {
messages: [response],
},
});
}
// If we need more information from the LLM, generate a more specific search query
const moreInfoPrompt = await ChatPromptTemplate.fromTemplate(
additionalWebSearchPrompt,
).format({
systemInstructions: this.systemInstructions,
context: state.relevantDocuments
.map(
(doc, index) =>
`<source${index + 1}>${doc?.metadata?.title ? `<title>${doc?.metadata?.title}</title>` : ''}<content>${doc.pageContent}</content></source${index + 1}>`,
)
.join('\n\n'),
date: formatDateForLLM(new Date()),
searchInstructionHistory: state.searchInstructionHistory
.map((question) => `- ${question}`)
.join('\n'),
query: state.query,
});
const moreInfoResponse = await this.llm.invoke(
[...state.messages, new HumanMessage(moreInfoPrompt)],
{ signal: this.signal },
);
const moreInfoQuestion = removeThinkingBlocks(
moreInfoResponse.content as string,
);
if (!analysisResult.startsWith('good_content')) {
// Emit reanalyzing event when we need more information // Emit reanalyzing event when we need more information
this.emitter.emit('agent_action', { this.emitter.emit('agent_action', {
type: 'agent_action', type: 'agent_action',
@ -134,7 +184,6 @@ Today's date is ${formatDateForLLM(new Date())}
message: message:
'Current context is insufficient - gathering more information', 'Current context is insufficient - gathering more information',
details: { details: {
reason: reason,
nextSearchQuery: moreInfoQuestion, nextSearchQuery: moreInfoQuestion,
documentCount: state.relevantDocuments.length, documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length, searchIterations: state.searchInstructionHistory.length,
@ -153,6 +202,7 @@ Today's date is ${formatDateForLLM(new Date())}
], ],
searchInstructions: moreInfoQuestion, searchInstructions: moreInfoQuestion,
searchInstructionHistory: [moreInfoQuestion], searchInstructionHistory: [moreInfoQuestion],
fullAnalysisAttempts: 1,
}, },
}); });
} }
@ -162,8 +212,7 @@ Today's date is ${formatDateForLLM(new Date())}
type: 'agent_action', type: 'agent_action',
data: { data: {
action: 'INFORMATION_GATHERING_COMPLETE', action: 'INFORMATION_GATHERING_COMPLETE',
message: message: 'Sufficient information gathered, ready to respond.',
'Sufficient information gathered - ready to synthesize response',
details: { details: {
documentCount: state.relevantDocuments.length, documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length, searchIterations: state.searchInstructionHistory.length,
@ -194,6 +243,8 @@ Today's date is ${formatDateForLLM(new Date())}
messages: [errorMessage], messages: [errorMessage],
}, },
}); });
} finally {
setTemperature(this.llm); // Reset temperature for subsequent actions
} }
} }
} }

View file

@ -28,20 +28,6 @@ export class SynthesizerAgent {
*/ */
async execute(state: typeof AgentState.State): Promise<Command> { async execute(state: typeof AgentState.State): Promise<Command> {
try { try {
// Emit synthesizing response event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'SYNTHESIZING_RESPONSE',
message: 'Synthesizing final answer...',
details: {
query: state.query,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const synthesisPrompt = `You are an expert information synthesizer. Based on the search results and analysis provided, create a comprehensive, well-structured answer to the user's query. const synthesisPrompt = `You are an expert information synthesizer. Based on the search results and analysis provided, create a comprehensive, well-structured answer to the user's query.
## Response Instructions ## Response Instructions

View file

@ -14,6 +14,7 @@ import {
PreviewContent, PreviewContent,
} from '../utils/analyzePreviewContent'; } from '../utils/analyzePreviewContent';
import { AgentState } from './agentState'; import { AgentState } from './agentState';
import { setTemperature } from '../utils/modelUtils';
export class WebSearchAgent { export class WebSearchAgent {
private llm: BaseChatModel; private llm: BaseChatModel;
@ -37,41 +38,45 @@ export class WebSearchAgent {
* Web search agent node * Web search agent node
*/ */
async execute(state: typeof AgentState.State): Promise<Command> { async execute(state: typeof AgentState.State): Promise<Command> {
// Emit preparing web search event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'PREPARING_SEARCH_QUERY',
// message: `Preparing search query`,
details: {
query: state.query,
searchInstructions: state.searchInstructions || state.query,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const template = PromptTemplate.fromTemplate(webSearchRetrieverAgentPrompt);
const prompt = await template.format({
systemInstructions: this.systemInstructions,
query: state.query,
date: formatDateForLLM(new Date()),
supervisor: state.searchInstructions,
});
const searchQueryResult = await this.llm.invoke(
[...state.messages, prompt],
{ signal: this.signal },
);
// Parse the response to extract the search query with the lineoutputparser
const lineOutputParser = new LineOutputParser({ key: 'answer' });
const searchQuery = await lineOutputParser.parse(
searchQueryResult.content as string,
);
try { try {
setTemperature(this.llm, 0); // Set temperature to 0 for deterministic output
// Emit preparing web search event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'PREPARING_SEARCH_QUERY',
// message: `Preparing search query`,
details: {
query: state.query,
searchInstructions: state.searchInstructions || state.query,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const template = PromptTemplate.fromTemplate(
webSearchRetrieverAgentPrompt,
);
const prompt = await template.format({
systemInstructions: this.systemInstructions,
query: state.query,
date: formatDateForLLM(new Date()),
supervisor: state.searchInstructions,
});
const searchQueryResult = await this.llm.invoke(
[...state.messages, prompt],
{ signal: this.signal },
);
// Parse the response to extract the search query with the lineoutputparser
const lineOutputParser = new LineOutputParser({ key: 'answer' });
const searchQuery = await lineOutputParser.parse(
searchQueryResult.content as string,
);
console.log(`Performing web search for query: "${searchQuery}"`); console.log(`Performing web search for query: "${searchQuery}"`);
// Emit executing web search event // Emit executing web search event
@ -110,11 +115,16 @@ export class WebSearchAgent {
}, },
}); });
let bannedUrls = state.bannedUrls || []; let bannedSummaryUrls = state.bannedSummaryUrls || [];
let bannedPreviewUrls = state.bannedPreviewUrls || [];
// Extract preview content from top 8 search results for analysis // Extract preview content from top 8 search results for analysis
const previewContents: PreviewContent[] = searchResults.results const previewContents: PreviewContent[] = searchResults.results
.filter((result) => !bannedUrls.includes(result.url)) // Filter out banned URLs first .filter(
(result) =>
!bannedSummaryUrls.includes(result.url) &&
!bannedPreviewUrls.includes(result.url),
) // Filter out banned URLs first
.slice(0, 8) // Then take top 8 results .slice(0, 8) // Then take top 8 results
.map((result) => ({ .map((result) => ({
title: result.title || 'Untitled', title: result.title || 'Untitled',
@ -203,6 +213,10 @@ export class WebSearchAgent {
}), }),
); );
previewContents.forEach((content) => {
bannedPreviewUrls.push(content.url); // Add to banned preview URLs to avoid duplicates
});
console.log( console.log(
`Created ${documents.length} documents from preview content`, `Created ${documents.length} documents from preview content`,
); );
@ -233,7 +247,12 @@ export class WebSearchAgent {
// Summarize the top 2 search results // Summarize the top 2 search results
for (const result of searchResults.results) { for (const result of searchResults.results) {
if (bannedUrls.includes(result.url)) { if (this.signal.aborted) {
console.warn('Search operation aborted by signal');
break; // Exit if the operation is aborted
}
if (bannedSummaryUrls.includes(result.url)) {
console.log(`Skipping banned URL: ${result.url}`); console.log(`Skipping banned URL: ${result.url}`);
// Note: We don't emit an agent_action event for banned URLs as this is an internal // Note: We don't emit an agent_action event for banned URLs as this is an internal
// optimization that should be transparent to the user // optimization that should be transparent to the user
@ -247,7 +266,7 @@ export class WebSearchAgent {
} }
attemptedUrlCount++; attemptedUrlCount++;
bannedUrls.push(result.url); // Add to banned URLs to avoid duplicates bannedSummaryUrls.push(result.url); // Add to banned URLs to avoid duplicates
if (documents.length >= 1) { if (documents.length >= 1) {
break; // Limit to top 1 document break; // Limit to top 1 document
@ -345,7 +364,8 @@ export class WebSearchAgent {
update: { update: {
messages: [new AIMessage(responseMessage)], messages: [new AIMessage(responseMessage)],
relevantDocuments: documents, relevantDocuments: documents,
bannedUrls: bannedUrls, bannedSummaryUrls: bannedSummaryUrls,
bannedPreviewUrls: bannedPreviewUrls,
}, },
}); });
} catch (error) { } catch (error) {
@ -360,6 +380,8 @@ export class WebSearchAgent {
messages: [errorMessage], messages: [errorMessage],
}, },
}); });
} finally {
setTemperature(this.llm, undefined); // Reset temperature to default
} }
} }
} }

View file

@ -1,4 +1,5 @@
import { BaseOutputParser } from '@langchain/core/output_parsers'; import { BaseOutputParser } from '@langchain/core/output_parsers';
import { removeThinkingBlocks } from '../utils/contentUtils';
interface LineOutputParserArgs { interface LineOutputParserArgs {
key?: string; key?: string;
@ -23,7 +24,7 @@ class LineOutputParser extends BaseOutputParser<string> {
// First, remove all <think>...</think> blocks to avoid parsing tags inside thinking content // First, remove all <think>...</think> blocks to avoid parsing tags inside thinking content
// This might be a little aggressive. Prompt massaging might be all we need, but this is a guarantee and should rarely mess anything up. // This might be a little aggressive. Prompt massaging might be all we need, but this is a guarantee and should rarely mess anything up.
text = this.removeThinkingBlocks(text); text = removeThinkingBlocks(text);
const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/; const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/;
const startKeyIndex = text.indexOf(`<${this.key}>`); const startKeyIndex = text.indexOf(`<${this.key}>`);
@ -44,17 +45,6 @@ class LineOutputParser extends BaseOutputParser<string> {
return line; return line;
} }
/**
* Removes all content within <think>...</think> blocks
* @param text The input text containing thinking blocks
* @returns The text with all thinking blocks removed
*/
private removeThinkingBlocks(text: string): string {
// Use regex to identify and remove all <think>...</think> blocks
// Using the 's' flag to make dot match newlines
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}
getFormatInstructions(): string { getFormatInstructions(): string {
throw new Error('Not implemented.'); throw new Error('Not implemented.');
} }

View file

@ -1,4 +1,5 @@
import { BaseOutputParser } from '@langchain/core/output_parsers'; import { BaseOutputParser } from '@langchain/core/output_parsers';
import { removeThinkingBlocks } from '../utils/contentUtils';
interface LineListOutputParserArgs { interface LineListOutputParserArgs {
key?: string; key?: string;
@ -23,7 +24,7 @@ class LineListOutputParser extends BaseOutputParser<string[]> {
// First, remove all <think>...</think> blocks to avoid parsing tags inside thinking content // First, remove all <think>...</think> blocks to avoid parsing tags inside thinking content
// This might be a little aggressive. Prompt massaging might be all we need, but this is a guarantee and should rarely mess anything up. // This might be a little aggressive. Prompt massaging might be all we need, but this is a guarantee and should rarely mess anything up.
text = this.removeThinkingBlocks(text); text = removeThinkingBlocks(text);
const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/; const regex = /^(\s*(-|\*|\d+\.\s|\d+\)\s|\u2022)\s*)+/;
const startKeyIndex = text.indexOf(`<${this.key}>`); const startKeyIndex = text.indexOf(`<${this.key}>`);
@ -46,17 +47,6 @@ class LineListOutputParser extends BaseOutputParser<string[]> {
return lines; return lines;
} }
/**
* Removes all content within <think>...</think> blocks
* @param text The input text containing thinking blocks
* @returns The text with all thinking blocks removed
*/
private removeThinkingBlocks(text: string): string {
// Use regex to identify and remove all <think>...</think> blocks
// Using [\s\S] pattern to match all characters including newlines
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}
getFormatInstructions(): string { getFormatInstructions(): string {
throw new Error('Not implemented.'); throw new Error('Not implemented.');
} }

View file

@ -0,0 +1,95 @@
export const decideNextActionPrompt = `You are an expert content analyzer.
Your task is to analyze the provided context and determine if we have enough information to fully answer the user's query.
# Instructions
- Carefully analyze the content of the context provided and determine if it contains sufficient information to answer the user's query
- The content should completely address the query, providing detailed explanations, relevant facts, and necessary context
- Use the content provided in the \`context\` tag, as well as the historical context of the conversation, to make your determination
- If the context provides conflicting information, explain the discrepancies and what additional information is needed to resolve them
- If the user is asking for a specific number of sources and the context does not provide enough, consider the content insufficient
# Response Options
- If the content is sufficient, respond with \`good_content\`
- If the content is not sufficient you have two options
- Option 1 - Ask the user for more information (Respond with \`need_user_info\`)
- Use this option when the content is not sufficient due to information that is would not typically be available online, or when the query is too vague or broad
- For example, if the query is asking for personal opinions, preferences, user experiences, settings, objects the user owns, or specific details that are not typically found in online content
- Option 2 - Ask the LLM to generate a more specific search query (Respond with \`need_more_info\`)
- Only use this option when the content is not sufficient due to missing information that could typically be found online and is not related to personal opinions, preferences, user experiences, or specific objects the user owns
- The only output in your response should be one of the following:
- \`good_content\`
- \`need_user_info\`
- \`need_more_info\`
# System Instructions
{systemInstructions}
# Date
Today's date is {date}
# User Query
{query}
# Context
<context>
{context}
</context>
`;
export const additionalUserInputPrompt = `You are an expert content analyzer.
Your task is to analyze the provided context and user query to determine what additional information is needed to fully answer the user's query.
# Refinement History
- The following automated questions have already been asked to refine the search
{searchInstructionHistory}
# System Instructions
{systemInstructions}
# Date
Today's date is {date}
# User Query
{query}
# Context
<context>
{context}
</context>
# Instructions
Respond with a detailed question that will be directed to the user to gather more specific information that can help refine the search.
`;
export const additionalWebSearchPrompt = `
You are an expert content analyzer.
Your task is to analyze the provided context and user query to determine what additional information is needed to fully answer the user's query.
# Instructions
- Respond with a detailed question that will be directed to an LLM to gather more specific information that can help refine the search.
- If if the query is asking about a complex topic, break it down into a single smaller question that can be answered one at a time. This search process can be iterative
- Break down the query into a smaller, more focused question that can be answered with a web search
- For example, if the query is asking about specific information from multiple locations, break the query into one smaller query for a single location
- Avoid giving the same guidance more than once, and avoid repeating the same question multiple times
- Avoid asking for general information or vague details; focus on specific, actionable questions that can lead to concrete answers
# Refinement History
- The following automated questions have already been asked to refine the search
{searchInstructionHistory}
# System Instructions
{systemInstructions}
# Date
Today's date is {date}
# User Query
{query}
# Context
<context>
{context}
</context>
Respond with a detailed question that will be directed to an LLM to gather more specific information that can help refine the search.
`;

View file

@ -57,7 +57,7 @@ export const loadAnthropicChatModels = async () => {
model: new ChatAnthropic({ model: new ChatAnthropic({
apiKey: anthropicApiKey, apiKey: anthropicApiKey,
modelName: model.key, modelName: model.key,
temperature: 0.7, // temperature: 0.7,
}) as unknown as BaseChatModel, }) as unknown as BaseChatModel,
}; };
}); });

View file

@ -33,7 +33,7 @@ export const loadDeepseekChatModels = async () => {
model: new ChatOpenAI({ model: new ChatOpenAI({
openAIApiKey: deepseekApiKey, openAIApiKey: deepseekApiKey,
modelName: model.key, modelName: model.key,
temperature: 0.7, // temperature: 0.7,
configuration: { configuration: {
baseURL: 'https://api.deepseek.com', baseURL: 'https://api.deepseek.com',
}, },

View file

@ -68,7 +68,7 @@ export const loadGeminiChatModels = async () => {
model: new ChatGoogleGenerativeAI({ model: new ChatGoogleGenerativeAI({
apiKey: geminiApiKey, apiKey: geminiApiKey,
model: model.key, model: model.key,
temperature: 0.7, // temperature: 0.7,
}) as unknown as BaseChatModel, }) as unknown as BaseChatModel,
}; };
}); });

View file

@ -101,7 +101,7 @@ export const loadGroqChatModels = async () => {
model: new ChatOpenAI({ model: new ChatOpenAI({
openAIApiKey: groqApiKey, openAIApiKey: groqApiKey,
modelName: model.key, modelName: model.key,
temperature: 0.7, // temperature: 0.7,
configuration: { configuration: {
baseURL: 'https://api.groq.com/openai/v1', baseURL: 'https://api.groq.com/openai/v1',
}, },

View file

@ -119,7 +119,7 @@ export const getAvailableChatModelProviders = async () => {
model: new ChatOpenAI({ model: new ChatOpenAI({
openAIApiKey: customOpenAiApiKey, openAIApiKey: customOpenAiApiKey,
modelName: customOpenAiModelName, modelName: customOpenAiModelName,
temperature: 0.7, // temperature: 0.7,
configuration: { configuration: {
baseURL: customOpenAiApiUrl, baseURL: customOpenAiApiUrl,
}, },

View file

@ -52,7 +52,7 @@ export const loadLMStudioChatModels = async () => {
baseURL: ensureV1Endpoint(endpoint), baseURL: ensureV1Endpoint(endpoint),
}, },
modelName: model.id, modelName: model.id,
temperature: 0.7, // temperature: 0.7,
streaming: true, streaming: true,
maxRetries: 3, maxRetries: 3,
}) as unknown as BaseChatModel, }) as unknown as BaseChatModel,

View file

@ -31,7 +31,7 @@ export const loadOllamaChatModels = async () => {
model: new ChatOllama({ model: new ChatOllama({
baseUrl: ollamaApiEndpoint, baseUrl: ollamaApiEndpoint,
model: model.model, model: model.model,
temperature: 0.7, // temperature: 0.7,
keepAlive: getKeepAlive(), keepAlive: getKeepAlive(),
}), }),
}; };

View file

@ -69,7 +69,7 @@ export const loadOpenAIChatModels = async () => {
model: new ChatOpenAI({ model: new ChatOpenAI({
openAIApiKey: openaiApiKey, openAIApiKey: openaiApiKey,
modelName: model.key, modelName: model.key,
temperature: 0.7, // temperature: 0.7,
}) as unknown as BaseChatModel, }) as unknown as BaseChatModel,
}; };
}); });

View file

@ -5,7 +5,13 @@ import {
HumanMessage, HumanMessage,
SystemMessage, SystemMessage,
} from '@langchain/core/messages'; } from '@langchain/core/messages';
import { END, MemorySaver, START, StateGraph } from '@langchain/langgraph'; import {
BaseLangGraphError,
END,
MemorySaver,
START,
StateGraph,
} from '@langchain/langgraph';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { import {
AgentState, AgentState,
@ -25,6 +31,7 @@ export class AgentSearch {
private webSearchAgent: WebSearchAgent; private webSearchAgent: WebSearchAgent;
private analyzerAgent: AnalyzerAgent; private analyzerAgent: AnalyzerAgent;
private synthesizerAgent: SynthesizerAgent; private synthesizerAgent: SynthesizerAgent;
private emitter: EventEmitter;
constructor( constructor(
llm: BaseChatModel, llm: BaseChatModel,
@ -38,6 +45,7 @@ export class AgentSearch {
this.embeddings = embeddings; this.embeddings = embeddings;
this.checkpointer = new MemorySaver(); this.checkpointer = new MemorySaver();
this.signal = signal; this.signal = signal;
this.emitter = emitter;
// Initialize agents // Initialize agents
this.webSearchAgent = new WebSearchAgent( this.webSearchAgent = new WebSearchAgent(
@ -97,40 +105,35 @@ export class AgentSearch {
async searchAndAnswer(query: string, history: BaseMessage[] = []) { async searchAndAnswer(query: string, history: BaseMessage[] = []) {
const workflow = this.createWorkflow(); const workflow = this.createWorkflow();
try { const initialState = {
const initialState = { messages: [...history, new HumanMessage(query)],
messages: [...history, new HumanMessage(query)], query,
query, };
};
const result = await workflow.invoke(initialState, { try {
await workflow.invoke(initialState, {
configurable: { thread_id: `agent_search_${Date.now()}` }, configurable: { thread_id: `agent_search_${Date.now()}` },
recursionLimit: 20, recursionLimit: 10,
signal: this.signal, signal: this.signal,
}); });
} catch (error: BaseLangGraphError | any) {
return result; if (error instanceof BaseLangGraphError) {
} catch (error) { console.error('LangGraph error occurred:', error.message);
console.error('Agent workflow error:', error); if (error.lc_error_code === 'GRAPH_RECURSION_LIMIT') {
this.emitter.emit(
// Fallback to a simple response 'data',
const fallbackResponse = await this.llm.invoke( JSON.stringify({
[ type: 'response',
new SystemMessage( data: "I've been working on this for a while and can't find a solution. Please try again with a different query.",
"You are a helpful assistant. The advanced agent workflow failed, so please provide a basic response to the user's query based on your knowledge.", }),
), );
new HumanMessage(query), this.emitter.emit('end');
], }
{ signal: this.signal }, } else if (error.name === 'AbortError') {
); console.warn('Agent search was aborted:', error.message);
} else {
return { console.error('Unexpected error during agent search:', error);
messages: [...history, new HumanMessage(query), fallbackResponse], }
query,
searchResults: [],
next: END,
analysis: '',
};
} }
} }
} }

View file

@ -103,6 +103,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
emitter: eventEmitter, emitter: eventEmitter,
signal: AbortSignal, signal: AbortSignal,
) { ) {
// TODO: Don't we want to set this back to default once search is done?
(llm as unknown as ChatOpenAI).temperature = 0; (llm as unknown as ChatOpenAI).temperature = 0;
this.emitProgress(emitter, 10, `Building search query`); this.emitProgress(emitter, 10, `Building search query`);
@ -693,7 +694,7 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + do
); );
// Execute the agent workflow // Execute the agent workflow
const result = await agentSearch.searchAndAnswer(message, history); await agentSearch.searchAndAnswer(message, history);
// No need to emit end signals here since synthesizerAgent // No need to emit end signals here since synthesizerAgent
// is now streaming in real-time and emits them // is now streaming in real-time and emits them

View file

@ -2,6 +2,8 @@ import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { BaseMessage } from '@langchain/core/messages'; import { BaseMessage } from '@langchain/core/messages';
import LineOutputParser from '../outputParsers/lineOutputParser'; import LineOutputParser from '../outputParsers/lineOutputParser';
import { formatDateForLLM } from '../utils'; import { formatDateForLLM } from '../utils';
import { ChatOpenAI, OpenAIClient } from '@langchain/openai';
import { removeThinkingBlocks } from './contentUtils';
export type PreviewAnalysisResult = { export type PreviewAnalysisResult = {
isSufficient: boolean; isSufficient: boolean;
@ -40,7 +42,6 @@ export const analyzePreviewContent = async (
`Source ${index + 1}: `Source ${index + 1}:
Title: ${content.title} Title: ${content.title}
Snippet: ${content.snippet} Snippet: ${content.snippet}
URL: ${content.url}
---`, ---`,
) )
.join('\n\n'); .join('\n\n');
@ -48,7 +49,10 @@ URL: ${content.url}
// Format chat history for context // Format chat history for context
const formattedChatHistory = chatHistory const formattedChatHistory = chatHistory
.slice(-10) // Only include last 10 messages for context .slice(-10) // Only include last 10 messages for context
.map((message, index) => `${message._getType()}: ${message.content}`) .map(
(message) =>
`${message.getType()}: ${removeThinkingBlocks(message.content.toString())}`,
)
.join('\n'); .join('\n');
const systemPrompt = systemInstructions ? `${systemInstructions}\n\n` : ''; const systemPrompt = systemInstructions ? `${systemInstructions}\n\n` : '';
@ -59,16 +63,14 @@ URL: ${content.url}
`${systemPrompt}You are a preview content analyzer, tasked with determining if search result snippets contain sufficient information to answer a user's query. `${systemPrompt}You are a preview content analyzer, tasked with determining if search result snippets contain sufficient information to answer a user's query.
# Instructions # Instructions
- Analyze the provided search result previews (titles + snippets) to determine if they collectively contain enough information to provide a complete and accurate answer to the user's query - Analyze the provided search result previews (titles + snippets), and chat history context to determine if they collectively contain enough information to provide a complete and accurate answer to the user's query
- Consider the chat history context when making your decision
- You must make a binary decision: either the preview content is sufficient OR it is not sufficient - You must make a binary decision: either the preview content is sufficient OR it is not sufficient
- If the preview content can provide a complete answer to the query, respond with "sufficient" - If the preview content can provide a complete answer to the query, respond with "sufficient"
- If the preview content lacks important details, requires deeper analysis, or cannot fully answer the query, respond with "not_needed: [specific reason why full content analysis is required]" - If the preview content lacks important details, requires deeper analysis, or cannot fully answer the query, respond with "not_needed: [specific reason why full content analysis is required]"
- Be specific in your reasoning when the content is not sufficient - Be specific in your reasoning when the content is not sufficient
- Consider query complexity: simple factual questions may be answerable from snippets, while complex research questions typically need full content
- Consider information completeness: if key details are missing from the snippets that would be needed for a complete answer, full analysis is required
- Output your decision inside a \`decision\` XML tag - Output your decision inside a \`decision\` XML tag
# Information Context:
Today's date is ${formatDateForLLM(new Date())} Today's date is ${formatDateForLLM(new Date())}
# Chat History Context: # Chat History Context:
@ -79,7 +81,7 @@ ${query}
# Search Result Previews to Analyze: # Search Result Previews to Analyze:
${formattedPreviewContent} ${formattedPreviewContent}
`, `,
{ signal }, { signal },
); );

View file

@ -0,0 +1,10 @@
/**
* Removes all content within <think>...</think> blocks
* @param text The input text containing thinking blocks
* @returns The text with all thinking blocks removed
*/
export const removeThinkingBlocks = (text: string): string => {
// Use regex to identify and remove all <think>...</think> blocks
// Using the 's' flag to make dot match newlines
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
};

View file

@ -50,3 +50,14 @@ export function getModelName(llm: BaseChatModel): string {
return 'Unknown'; return 'Unknown';
} }
} }
export function setTemperature(llm: BaseChatModel, temperature?: number) {
try {
// @ts-ignore - Different LLM implementations have different properties
if ('temperature' in llm) {
(llm as any).temperature = temperature;
}
} catch (e) {
console.error('Failed to set temperature:', e);
}
}