healing messages

This commit is contained in:
dal 2025-08-06 17:57:04 -06:00
parent aaae50a32f
commit 1d545a009c
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
8 changed files with 213 additions and 157 deletions

View File

@ -1,4 +1,4 @@
import { type ModelMessage, hasToolCall, stepCountIs, streamText } from 'ai'; import { type ModelMessage, NoSuchToolError, hasToolCall, stepCountIs, streamText } from 'ai';
import { wrapTraced } from 'braintrust'; import { wrapTraced } from 'braintrust';
import z from 'zod'; import z from 'zod';
import { import {
@ -9,7 +9,7 @@ import {
modifyMetrics, modifyMetrics,
} from '../../tools'; } from '../../tools';
import { Sonnet4 } from '../../utils/models/sonnet-4'; import { Sonnet4 } from '../../utils/models/sonnet-4';
import { healToolWithLlm } from '../../utils/tool-call-repair'; import { createNoSuchToolHealingMessage, healToolWithLlm } from '../../utils/tool-call-repair';
import { getAnalystAgentSystemPrompt } from './get-analyst-agent-system-prompt'; import { getAnalystAgentSystemPrompt } from './get-analyst-agent-system-prompt';
const DEFAULT_CACHE_OPTIONS = { const DEFAULT_CACHE_OPTIONS = {
@ -46,6 +46,12 @@ export function createAnalystAgent(analystAgentOptions: AnalystAgentOptions) {
} as ModelMessage; } as ModelMessage;
async function stream({ messages }: AnalystStreamOptions) { async function stream({ messages }: AnalystStreamOptions) {
const maxRetries = 2;
let attempt = 0;
const currentMessages = [...messages];
while (attempt <= maxRetries) {
try {
return wrapTraced( return wrapTraced(
() => () =>
streamText({ streamText({
@ -57,7 +63,7 @@ export function createAnalystAgent(analystAgentOptions: AnalystAgentOptions) {
modifyDashboards, modifyDashboards,
doneTool, doneTool,
}, },
messages: [systemMessage, ...messages], messages: [systemMessage, ...currentMessages],
stopWhen: STOP_CONDITIONS, stopWhen: STOP_CONDITIONS,
toolChoice: 'required', toolChoice: 'required',
maxOutputTokens: 10000, maxOutputTokens: 10000,
@ -69,6 +75,35 @@ export function createAnalystAgent(analystAgentOptions: AnalystAgentOptions) {
name: 'Analyst Agent', name: 'Analyst Agent',
} }
)(); )();
} catch (error) {
attempt++;
// Only retry for NoSuchToolError
if (!NoSuchToolError.isInstance(error) || attempt > maxRetries) {
console.error('Error in analyst agent:', error);
throw error;
}
// Add healing message and retry
const healingMessage = createNoSuchToolHealingMessage(
error,
`createMetrics, modifyMetrics, createDashboards, modifyDashboards, createReports, modifyReports, doneTool are the tools that are available to you at this moment.
The previous phase of the workflow was the think and prep phase that has access to the following tools:
sequentialThinking, executeSql, respondWithoutAssetCreation, submitThoughts, messageUserClarifyingQuestion
However, you don't have access to any of those tools at this moment.
`
);
currentMessages.push(healingMessage);
console.info(
`Retrying analyst agent after NoSuchToolError (attempt ${attempt}/${maxRetries})`
);
}
}
throw new Error('Max retry attempts exceeded');
} }
async function getSteps() { async function getSteps() {

View File

@ -1,4 +1,4 @@
import { type ModelMessage, hasToolCall, stepCountIs, streamText } from 'ai'; import { type ModelMessage, NoSuchToolError, hasToolCall, stepCountIs, streamText } from 'ai';
import { wrapTraced } from 'braintrust'; import { wrapTraced } from 'braintrust';
import z from 'zod'; import z from 'zod';
import { import {
@ -9,6 +9,7 @@ import {
submitThoughts, submitThoughts,
} from '../../tools'; } from '../../tools';
import { Sonnet4 } from '../../utils/models/sonnet-4'; import { Sonnet4 } from '../../utils/models/sonnet-4';
import { createNoSuchToolHealingMessage } from '../../utils/tool-call-repair';
import { getThinkAndPrepAgentSystemPrompt } from './get-think-and-prep-agent-system-prompt'; import { getThinkAndPrepAgentSystemPrompt } from './get-think-and-prep-agent-system-prompt';
const DEFAULT_CACHE_OPTIONS = { const DEFAULT_CACHE_OPTIONS = {
@ -47,7 +48,13 @@ export function createThinkAndPrepAgent(thinkAndPrepAgentSchema: ThinkAndPrepAge
} as ModelMessage; } as ModelMessage;
async function stream({ messages }: ThinkAndPrepStreamOptions) { async function stream({ messages }: ThinkAndPrepStreamOptions) {
return wrapTraced( const maxRetries = 2;
let attempt = 0;
const currentMessages = [...messages];
while (attempt <= maxRetries) {
try {
return await wrapTraced(
() => () =>
streamText({ streamText({
model: Sonnet4, model: Sonnet4,
@ -58,7 +65,7 @@ export function createThinkAndPrepAgent(thinkAndPrepAgentSchema: ThinkAndPrepAge
submitThoughts, submitThoughts,
messageUserClarifyingQuestion, messageUserClarifyingQuestion,
}, },
messages: [systemMessage, ...messages], messages: [systemMessage, ...currentMessages],
stopWhen: STOP_CONDITIONS, stopWhen: STOP_CONDITIONS,
toolChoice: 'required', toolChoice: 'required',
maxOutputTokens: 10000, maxOutputTokens: 10000,
@ -68,6 +75,34 @@ export function createThinkAndPrepAgent(thinkAndPrepAgentSchema: ThinkAndPrepAge
name: 'Think and Prep Agent', name: 'Think and Prep Agent',
} }
)(); )();
} catch (error) {
attempt++;
// Only retry for NoSuchToolError
if (!NoSuchToolError.isInstance(error) || attempt > maxRetries) {
console.error('Error in think and prep agent:', error);
throw error;
}
// Add healing message and retry
const healingMessage = createNoSuchToolHealingMessage(
error,
`sequentialThinking, executeSql, respondWithoutAssetCreation, submitThoughts, messageUserClarifyingQuestion are the tools that are available to you at this moment.
The next phase of the workflow will be the analyst that has access to the following tools:
createMetrics, modifyMetrics, createDashboards, modifyDashboards, doneTool
You'll be able to use those when they are available to you.`
);
currentMessages.push(healingMessage);
console.info(
`Retrying think and prep agent after NoSuchToolError (attempt ${attempt}/${maxRetries})`
);
}
}
throw new Error('Max retry attempts exceeded');
} }
async function getSteps() { async function getSteps() {

View File

@ -1,61 +1,44 @@
import { updateChat, updateMessage } from '@buster/database'; import { updateChat, updateMessage } from '@buster/database';
import { createStep } from '@mastra/core';
import type { RuntimeContext } from '@mastra/core/runtime-context';
import { generateObject } from 'ai'; import { generateObject } from 'ai';
import type { CoreMessage } from 'ai'; import type { ModelMessage } from 'ai';
import { wrapTraced } from 'braintrust'; import { wrapTraced } from 'braintrust';
import { z } from 'zod'; import { z } from 'zod';
import { thinkAndPrepWorkflowInputSchema } from '../schemas/workflow-schemas';
import { Haiku35 } from '../utils/models/haiku-3-5'; import { Haiku35 } from '../utils/models/haiku-3-5';
import { appendToConversation, standardizeMessages } from '../utils/standardizeMessages';
import type { AnalystRuntimeContext } from '../workflows/analyst-workflow';
const inputSchema = thinkAndPrepWorkflowInputSchema;
// Schema for what the LLM returns // Schema for what the LLM returns
const llmOutputSchema = z.object({ const llmOutputSchema = z.object({
title: z.string().describe('The title for the chat.'), title: z.string().describe('The title for the chat.'),
}); });
// Schema for what the step returns (includes pass-through data)
export const generateChatTitleOutputSchema = z.object({
title: z.string().describe('The title for the chat.'),
// Pass through dashboard context
dashboardFiles: z
.array(
z.object({
id: z.string(),
name: z.string(),
versionNumber: z.number(),
metricIds: z.array(z.string()),
})
)
.optional(),
});
const generateChatTitleInstructions = ` const generateChatTitleInstructions = `
I am a chat title generator that is responsible for generating a title for the chat. I am a chat title generator that is responsible for generating a title for the chat.
The title should be 3-8 words, capturing the main topic or intent of the conversation. The title should be 3-8 words, capturing the main topic or intent of the conversation. With an emphasis on the user's question and most recent converstaion topic.
`; `;
const generateChatTitleExecution = async ({ export interface GenerateChatTitleParams {
inputData, prompt: string;
runtimeContext, conversationHistory?: CoreMessage[];
}: { chatId?: string;
inputData: z.infer<typeof inputSchema>; messageId?: string;
runtimeContext: RuntimeContext<AnalystRuntimeContext>; }
}): Promise<z.infer<typeof generateChatTitleOutputSchema>> => {
try {
// Use the input data directly
const prompt = inputData.prompt;
const conversationHistory = inputData.conversationHistory;
// Prepare messages for the agent export interface GenerateChatTitleResult {
title: string;
}
export async function generateChatTitle({
prompt,
conversationHistory,
chatId,
messageId,
}: GenerateChatTitleParams): Promise<GenerateChatTitleResult> {
try {
// Prepare messages for the LLM
let messages: CoreMessage[]; let messages: CoreMessage[];
if (conversationHistory && conversationHistory.length > 0) { if (conversationHistory && conversationHistory.length > 0) {
// Use conversation history as context + append new user message // Use conversation history as context + append new user message
messages = appendToConversation(conversationHistory as CoreMessage[], prompt); messages = appendToConversation(conversationHistory, prompt);
} else { } else {
// Otherwise, use just the prompt // Otherwise, use just the prompt
messages = standardizeMessages(prompt); messages = standardizeMessages(prompt);
@ -97,9 +80,6 @@ const generateChatTitleExecution = async ({
title = { title: 'New Analysis' }; title = { title: 'New Analysis' };
} }
const chatId = runtimeContext.get('chatId');
const messageId = runtimeContext.get('messageId');
// Run database updates concurrently // Run database updates concurrently
const updatePromises: Promise<{ success: boolean }>[] = []; const updatePromises: Promise<{ success: boolean }>[] = [];
@ -121,33 +101,16 @@ const generateChatTitleExecution = async ({
await Promise.all(updatePromises); await Promise.all(updatePromises);
return { return { title: title.title };
...title,
dashboardFiles: inputData.dashboardFiles, // Pass through dashboard context
};
} catch (error) { } catch (error) {
// Handle AbortError gracefully // Handle AbortError gracefully
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
// Return a fallback title when aborted // Return a fallback title when aborted
return { return { title: 'New Analysis' };
title: 'New Analysis',
dashboardFiles: inputData.dashboardFiles, // Pass through dashboard context
};
} }
console.error('[GenerateChatTitle] Failed to generate chat title:', error); console.error('[GenerateChatTitle] Failed to generate chat title:', error);
// Return a fallback title instead of crashing // Return a fallback title instead of crashing
return { return { title: 'New Analysis' };
title: 'New Analysis',
dashboardFiles: inputData.dashboardFiles, // Pass through dashboard context
};
} }
}; }
export const generateChatTitleStep = createStep({
id: 'generate-chat-title',
description: 'This step is a single llm call to quickly generate a title for the chat.',
inputSchema,
outputSchema: generateChatTitleOutputSchema,
execute: generateChatTitleExecution,
});

View File

@ -197,9 +197,8 @@ rows:
runtimeContext: contextWithoutUserId, runtimeContext: contextWithoutUserId,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: contextWithoutUserId as unknown as RuntimeContext,
runtimeContext: contextWithoutUserId as unknown as RuntimeContext,
}); });
expect(result.message).toBe('Unable to verify your identity. Please log in again.'); expect(result.message).toBe('Unable to verify your identity. Please log in again.');
expect(result.files).toHaveLength(0); expect(result.files).toHaveLength(0);
@ -218,9 +217,8 @@ description: Invalid dashboard
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(0); expect(result.files).toHaveLength(0);
@ -247,9 +245,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(0); expect(result.files).toHaveLength(0);
@ -275,9 +272,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(0); expect(result.files).toHaveLength(0);
@ -308,9 +304,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(1); expect(result.files).toHaveLength(1);
@ -369,9 +364,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(1); expect(result.files).toHaveLength(1);
@ -409,9 +403,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.duration).toBeGreaterThan(0); expect(result.duration).toBeGreaterThan(0);
@ -450,9 +443,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(3); expect(result.files).toHaveLength(3);
@ -503,9 +495,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(1); expect(result.files).toHaveLength(1);
@ -553,9 +544,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(1); expect(result.files).toHaveLength(1);
@ -608,10 +598,10 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const successResult = await createDashboards.execute({ const successResult = await createDashboards.execute(
context: successInput, successInput,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext, { experimental_context: mockRuntimeContext as unknown as RuntimeContext }
}); );
expect(successResult.message).toBe('Successfully created 1 dashboard files.'); expect(successResult.message).toBe('Successfully created 1 dashboard files.');
// Track created dashboard for cleanup // Track created dashboard for cleanup
@ -628,9 +618,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const failureResult = await createDashboards.execute({ const failureResult = await createDashboards.execute(failureInput, {
context: failureInput, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(failureResult.message).toContain("Failed to create 'Failure Test'"); expect(failureResult.message).toContain("Failed to create 'Failure Test'");
}); });
@ -662,9 +651,8 @@ rows:
runtimeContext: mockRuntimeContext, runtimeContext: mockRuntimeContext,
}; };
const result = await createDashboards.execute({ const result = await createDashboards.execute(input, {
context: input, experimental_context: mockRuntimeContext as unknown as RuntimeContext,
runtimeContext: mockRuntimeContext as unknown as RuntimeContext,
}); });
expect(result.files).toHaveLength(1); expect(result.files).toHaveLength(1);

View File

@ -0,0 +1,30 @@
import type { ModelMessage, ToolModelMessage, ToolResultPart } from 'ai';
import type { NoSuchToolError } from 'ai';
/**
* Creates a healing message for NoSuchToolError that simulates a tool error result.
* This allows the LLM to understand which tool failed and what tools are available.
*
* @param error - The NoSuchToolError that was caught
* @param availableTools - A comma-separated string of available tool names
* @returns A ModelMessage with a tool error result
*/
export function createNoSuchToolHealingMessage(
error: NoSuchToolError,
healingMessage: string
): ModelMessage {
return {
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: error.toolCallId,
toolName: error.toolName,
output: {
type: 'text',
value: `Tool "${error.toolName}" is not available. ${healingMessage}.`,
},
},
],
};
}

View File

@ -1,6 +1,6 @@
import type { LanguageModelV2ToolCall } from '@ai-sdk/provider'; import type { LanguageModelV2ToolCall } from '@ai-sdk/provider';
import { generateObject } from 'ai'; import { generateObject } from 'ai';
import type { ToolSet } from 'ai'; import { type InvalidToolInputError, NoSuchToolError, type ToolSet } from 'ai';
import { Haiku35 } from '../models/haiku-3-5'; import { Haiku35 } from '../models/haiku-3-5';
interface ToolCallWithArgs extends LanguageModelV2ToolCall { interface ToolCallWithArgs extends LanguageModelV2ToolCall {
@ -10,11 +10,17 @@ interface ToolCallWithArgs extends LanguageModelV2ToolCall {
export async function healToolWithLlm({ export async function healToolWithLlm({
toolCall, toolCall,
tools, tools,
error,
}: { }: {
toolCall: LanguageModelV2ToolCall; toolCall: LanguageModelV2ToolCall;
tools: ToolSet; tools: ToolSet;
error: NoSuchToolError | InvalidToolInputError;
}) { }) {
try { try {
if (error instanceof NoSuchToolError) {
return null;
}
const tool = tools[toolCall.toolName as keyof typeof tools]; const tool = tools[toolCall.toolName as keyof typeof tools];
if (!tool) { if (!tool) {

View File

@ -1 +1,2 @@
export { healToolWithLlm } from './heal-tool-with-llm'; export { healToolWithLlm } from './heal-tool-with-llm';
export { createNoSuchToolHealingMessage } from './heal-no-such-tool';

View File

@ -1,18 +1,16 @@
import { createWorkflow } from '@mastra/core'; import { createWorkflow } from '@mastra/core';
import { z } from 'zod'; import { z } from 'zod';
import { import { type AnalystAgentOptions } from '../agents/analyst-agent/analyst-agent';
type AnalystAgentContext,
AnalystAgentContextSchema,
} from '../agents/analyst-agent/analyst-agent-context';
import { thinkAndPrepWorkflowInputSchema } from '../schemas/workflow-schemas'; import { thinkAndPrepWorkflowInputSchema } from '../schemas/workflow-schemas';
// Type alias for consistency
export type AnalystAgentContext = AnalystAgentOptions;
export type AnalystRuntimeContext = AnalystAgentOptions;
// Re-export for backward compatibility // Re-export for backward compatibility
export { thinkAndPrepWorkflowInputSchema, AnalystAgentContextSchema, type AnalystAgentContext }; export { thinkAndPrepWorkflowInputSchema, type AnalystAgentContext };
// Legacy exports - deprecated, use AnalystAgentContext instead // Legacy exports - deprecated, use AnalystAgentContext instead
export { export { type AnalystAgentContext as AnalystRuntimeContext };
AnalystAgentContextSchema as AnalystRuntimeContextSchema,
type AnalystAgentContext as AnalystRuntimeContext,
};
import { analystStep } from '../steps/analyst-step'; import { analystStep } from '../steps/analyst-step';
import { createTodosStep } from '../steps/create-todos-step'; import { createTodosStep } from '../steps/create-todos-step';
import { extractValuesSearchStep } from '../steps/extract-values-search-step'; import { extractValuesSearchStep } from '../steps/extract-values-search-step';