Merge pull request #620 from buster-so/staging

Release
This commit is contained in:
dal 2025-07-23 21:51:05 -06:00 committed by GitHub
commit bddb42c263
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 102 additions and 74 deletions

View File

@ -27,6 +27,7 @@
"@buster/test-utils": "workspace:*", "@buster/test-utils": "workspace:*",
"@buster/typescript-config": "workspace:*", "@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*", "@buster/vitest-config": "workspace:*",
"@buster/web-tools": "workspace:*",
"@mastra/core": "catalog:", "@mastra/core": "catalog:",
"@trigger.dev/sdk": "catalog:", "@trigger.dev/sdk": "catalog:",
"ai": "catalog:", "ai": "catalog:",

View File

@ -45,7 +45,7 @@
"@buster/test-utils": "workspace:*", "@buster/test-utils": "workspace:*",
"@buster/typescript-config": "workspace:*", "@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*", "@buster/vitest-config": "workspace:*",
"@buster-tools/web-tools": "workspace:*", "@buster/web-tools": "workspace:*",
"@mastra/core": "catalog:", "@mastra/core": "catalog:",
"@mastra/loggers": "^0.10.3", "@mastra/loggers": "^0.10.3",
"ai": "catalog:", "ai": "catalog:",

View File

@ -19,17 +19,16 @@ vi.mock('braintrust', () => ({
wrapAISDKModel: vi.fn((model) => model), wrapAISDKModel: vi.fn((model) => model),
})); }));
// Create a ref object to hold the mock generate function // Mock the AI SDK
const mockGenerateRef = { current: vi.fn() }; vi.mock('ai', () => ({
generateObject: vi.fn(),
}));
// Mock the Agent class from Mastra with the generate function // Mock Mastra
vi.mock('@mastra/core', async () => { vi.mock('@mastra/core', async () => {
const actual = await vi.importActual('@mastra/core'); const actual = await vi.importActual('@mastra/core');
return { return {
...actual, ...actual,
Agent: vi.fn().mockImplementation(() => ({
generate: (...args: any[]) => mockGenerateRef.current(...args),
})),
createStep: actual.createStep, createStep: actual.createStep,
}; };
}); });
@ -41,18 +40,17 @@ import { extractValuesSearchStep } from './extract-values-search-step';
// Import the mocked functions // Import the mocked functions
import { generateEmbedding, searchValuesByEmbedding } from '@buster/stored-values/search'; import { generateEmbedding, searchValuesByEmbedding } from '@buster/stored-values/search';
import { generateObject } from 'ai';
const mockGenerateEmbedding = generateEmbedding as ReturnType<typeof vi.fn>; const mockGenerateEmbedding = generateEmbedding as ReturnType<typeof vi.fn>;
const mockSearchValuesByEmbedding = searchValuesByEmbedding as ReturnType<typeof vi.fn>; const mockSearchValuesByEmbedding = searchValuesByEmbedding as ReturnType<typeof vi.fn>;
const mockGenerateObject = generateObject as ReturnType<typeof vi.fn>;
// Access the mock generate function through the ref
const mockGenerate = mockGenerateRef.current;
describe('extractValuesSearchStep', () => { describe('extractValuesSearchStep', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Set default mock behavior // Set default mock behavior
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: [] }, object: { values: [] },
}); });
}); });
@ -72,7 +70,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock the LLM response for keyword extraction // Mock the LLM response for keyword extraction
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: ['Red Bull', 'California'] }, object: { values: ['Red Bull', 'California'] },
}); });
@ -141,7 +139,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock empty keyword extraction // Mock empty keyword extraction
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: [] }, object: { values: [] },
}); });
@ -195,7 +193,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock successful keyword extraction // Mock successful keyword extraction
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: ['Red Bull'] }, object: { values: ['Red Bull'] },
}); });
@ -226,7 +224,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock LLM extraction success but embedding failure // Mock LLM extraction success but embedding failure
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: ['test keyword'] }, object: { values: ['test keyword'] },
}); });
@ -254,7 +252,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock successful keyword extraction // Mock successful keyword extraction
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: ['test keyword'] }, object: { values: ['test keyword'] },
}); });
@ -284,7 +282,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock two keywords: one succeeds, one fails // Mock two keywords: one succeeds, one fails
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: ['keyword1', 'keyword2'] }, object: { values: ['keyword1', 'keyword2'] },
}); });
@ -327,7 +325,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock everything to fail // Mock everything to fail
mockGenerate.mockRejectedValue(new Error('LLM failure')); mockGenerateObject.mockRejectedValue(new Error('LLM failure'));
mockGenerateEmbedding.mockRejectedValue(new Error('Embedding failure')); mockGenerateEmbedding.mockRejectedValue(new Error('Embedding failure'));
mockSearchValuesByEmbedding.mockRejectedValue(new Error('Database failure')); mockSearchValuesByEmbedding.mockRejectedValue(new Error('Database failure'));
@ -378,7 +376,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock successful keyword extraction // Mock successful keyword extraction
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: ['Red Bull'] }, object: { values: ['Red Bull'] },
}); });
@ -437,7 +435,7 @@ describe('extractValuesSearchStep', () => {
runtimeContext.set('dataSourceId', 'test-datasource-id'); runtimeContext.set('dataSourceId', 'test-datasource-id');
// Mock successful keyword extraction // Mock successful keyword extraction
mockGenerate.mockResolvedValue({ mockGenerateObject.mockResolvedValue({
object: { values: ['test'] }, object: { values: ['test'] },
}); });

View File

@ -1,7 +1,8 @@
import type { StoredValueResult } from '@buster/stored-values'; import type { StoredValueResult } from '@buster/stored-values';
import { generateEmbedding, searchValuesByEmbedding } from '@buster/stored-values/search'; import { generateEmbedding, searchValuesByEmbedding } from '@buster/stored-values/search';
import { Agent, createStep } from '@mastra/core'; import { createStep } from '@mastra/core';
import type { RuntimeContext } from '@mastra/core/runtime-context'; import type { RuntimeContext } from '@mastra/core/runtime-context';
import { generateObject } from 'ai';
import type { CoreMessage } from 'ai'; import type { CoreMessage } from 'ai';
import { wrapTraced } from 'braintrust'; import { wrapTraced } from 'braintrust';
import { z } from 'zod'; import { z } from 'zod';
@ -12,6 +13,11 @@ import type { AnalystRuntimeContext } from '../workflows/analyst-workflow';
const inputSchema = thinkAndPrepWorkflowInputSchema; const inputSchema = thinkAndPrepWorkflowInputSchema;
// Schema for what the LLM returns
const llmOutputSchema = z.object({
values: z.array(z.string()).describe('The values that the agent will search for.'),
});
// Step output schema - what the step returns after performing the search // Step output schema - what the step returns after performing the search
export const extractValuesSearchOutputSchema = z.object({ export const extractValuesSearchOutputSchema = z.object({
values: z.array(z.string()).describe('The values that the agent will search for.'), values: z.array(z.string()).describe('The values that the agent will search for.'),
@ -231,12 +237,6 @@ async function searchStoredValues(
} }
} }
const valuesAgent = new Agent({
name: 'Extract Values',
instructions: extractValuesInstructions,
model: Haiku35,
});
const extractValuesSearchStepExecution = async ({ const extractValuesSearchStepExecution = async ({
inputData, inputData,
runtimeContext, runtimeContext,
@ -264,12 +264,19 @@ const extractValuesSearchStepExecution = async ({
try { try {
const tracedValuesExtraction = wrapTraced( const tracedValuesExtraction = wrapTraced(
async () => { async () => {
const response = await valuesAgent.generate(messages, { const { object } = await generateObject({
maxSteps: 0, model: Haiku35,
output: extractValuesSearchOutputSchema, schema: llmOutputSchema,
messages: [
{
role: 'system',
content: extractValuesInstructions,
},
...messages,
],
}); });
return response.object; return object;
}, },
{ {
name: 'Extract Values', name: 'Extract Values',

View File

@ -1,6 +1,7 @@
import { updateChat, updateMessage } from '@buster/database'; import { updateChat, updateMessage } from '@buster/database';
import { Agent, createStep } from '@mastra/core'; import { createStep } from '@mastra/core';
import type { RuntimeContext } from '@mastra/core/runtime-context'; import type { RuntimeContext } from '@mastra/core/runtime-context';
import { generateObject } from 'ai';
import type { CoreMessage } from 'ai'; import type { CoreMessage } from 'ai';
import { wrapTraced } from 'braintrust'; import { wrapTraced } from 'braintrust';
import { z } from 'zod'; import { z } from 'zod';
@ -11,6 +12,12 @@ import type { AnalystRuntimeContext } from '../workflows/analyst-workflow';
const inputSchema = thinkAndPrepWorkflowInputSchema; const inputSchema = thinkAndPrepWorkflowInputSchema;
// Schema for what the LLM returns
const llmOutputSchema = z.object({
title: z.string().describe('The title for the chat.'),
});
// Schema for what the step returns (includes pass-through data)
export const generateChatTitleOutputSchema = z.object({ export const generateChatTitleOutputSchema = z.object({
title: z.string().describe('The title for the chat.'), title: z.string().describe('The title for the chat.'),
// Pass through dashboard context // Pass through dashboard context
@ -28,13 +35,9 @@ export const generateChatTitleOutputSchema = z.object({
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.
`;
const todosAgent = new Agent({ The title should be 3-8 words, capturing the main topic or intent of the conversation.
name: 'Extract Values', `;
instructions: generateChatTitleInstructions,
model: Haiku35,
});
const generateChatTitleExecution = async ({ const generateChatTitleExecution = async ({
inputData, inputData,
@ -63,12 +66,19 @@ const generateChatTitleExecution = async ({
try { try {
const tracedChatTitle = wrapTraced( const tracedChatTitle = wrapTraced(
async () => { async () => {
const response = await todosAgent.generate(messages, { const { object } = await generateObject({
maxSteps: 0, model: Haiku35,
output: generateChatTitleOutputSchema, schema: llmOutputSchema,
messages: [
{
role: 'system',
content: generateChatTitleInstructions,
},
...messages,
],
}); });
return response.object; return object;
}, },
{ {
name: 'Generate Chat Title', name: 'Generate Chat Title',

View File

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { webSearch } from './web-search-tool'; import { webSearch } from './web-search-tool';
vi.mock('@buster-tools/web-tools', () => { vi.mock('@buster/web-tools', () => {
const mockFirecrawlService = { const mockFirecrawlService = {
webSearch: vi.fn(), webSearch: vi.fn(),
}; };
@ -17,9 +17,7 @@ describe('webSearch tool', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
const { mockFirecrawlService: mock } = vi.mocked( const { mockFirecrawlService: mock } = vi.mocked(await import('@buster/web-tools')) as any;
await import('@buster-tools/web-tools')
) as any;
mockFirecrawlService = mock; mockFirecrawlService = mock;
}); });

View File

@ -1,8 +1,4 @@
import { import { FirecrawlService, type WebSearchOptions, type WebSearchResult } from '@buster/web-tools';
FirecrawlService,
type WebSearchOptions,
type WebSearchResult,
} from '@buster-tools/web-tools';
import type { RuntimeContext } from '@mastra/core/runtime-context'; import type { RuntimeContext } from '@mastra/core/runtime-context';
import { createTool } from '@mastra/core/tools'; import { createTool } from '@mastra/core/tools';
import { wrapTraced } from 'braintrust'; import { wrapTraced } from 'braintrust';

View File

@ -14,7 +14,7 @@ import type { RetryableError, WorkflowContext } from './types';
* Creates a workflow-aware healing message for NoSuchToolError * Creates a workflow-aware healing message for NoSuchToolError
*/ */
function createWorkflowAwareHealingMessage(toolName: string, context?: WorkflowContext): string { function createWorkflowAwareHealingMessage(toolName: string, context?: WorkflowContext): string {
const baseMessage = `Tool "${toolName}" is not available in the current mode.`; const baseMessage = `error: Tool "${toolName}" is not available.`;
if (!context) { if (!context) {
return `${baseMessage} Please use one of the available tools instead.`; return `${baseMessage} Please use one of the available tools instead.`;
@ -22,28 +22,44 @@ function createWorkflowAwareHealingMessage(toolName: string, context?: WorkflowC
const { currentStep, availableTools } = context; const { currentStep, availableTools } = context;
const nextMode = currentStep === 'think-and-prep' ? 'analyst' : 'think-and-prep';
const transitionDescription =
currentStep === 'think-and-prep'
? 'after thinking, understanding the data, and submitting your thoughts'
: 'after completing your analysis';
// Use actual available tools if provided // Use actual available tools if provided
if (availableTools && availableTools.size > 0) { if (availableTools && availableTools.size > 0) {
const toolList = Array.from(availableTools).sort().join(', '); const currentToolList = Array.from(availableTools).sort().join(', ');
return `${baseMessage}
You are currently in ${currentStep} mode. The available tools are: ${toolList}. const nextModeTools =
nextMode === 'analyst'
? 'createMetrics, modifyMetrics, createDashboards, modifyDashboards, doneTool'
: 'sequentialThinking, executeSql, respondWithoutAssetCreation, submitThoughts, messageUserClarifyingQuestion';
Please use one of these tools to continue with your task. Make sure to use the exact tool name as listed above.`; return `${baseMessage} For reference you are currently in ${currentStep} mode which has access to the following tools:
${currentToolList}
The next mode that you'll transition to ${transitionDescription} will be the ${nextMode} mode which has access to the following tools:
${nextModeTools}`;
} }
// Fallback to static message if tools not provided // Fallback to static message if tools not provided
const pipelineContext = ` const currentModeTools =
currentStep === 'think-and-prep'
? 'sequentialThinking, executeSql, respondWithoutAssetCreation, submitThoughts, messageUserClarifyingQuestion'
: 'createMetrics, modifyMetrics, createDashboards, modifyDashboards, doneTool';
This workflow has two steps: const nextModeTools =
1. think-and-prep mode: Available tools are sequentialThinking, executeSql, respondWithoutAssetCreation, submitThoughts, messageUserClarifyingQuestion nextMode === 'analyst'
2. analyst mode: Available tools are createMetrics, modifyMetrics, createDashboards, modifyDashboards, doneTool ? 'createMetrics, modifyMetrics, createDashboards, modifyDashboards, doneTool'
: 'sequentialThinking, executeSql, respondWithoutAssetCreation, submitThoughts, messageUserClarifyingQuestion';
You are currently in ${currentStep} mode. Please use one of the tools available in your current mode. return `${baseMessage} For reference you are currently in ${currentStep} mode which has access to the following tools:
${currentModeTools}
You should proceed with the proper tool calls in the context of the current step. There is a chance you might be a little confused about where you are in the workflow. or the tools available to you.`; The next mode that you'll transition to ${transitionDescription} will be the ${nextMode} mode which has access to the following tools:
${nextModeTools}`;
return baseMessage + pipelineContext;
} }
/** /**

View File

@ -426,7 +426,7 @@ models:
const sql = 'SELECT * FROM users'; const sql = 'SELECT * FROM users';
const result = validateWildcardUsage(sql); const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false); expect(result.isValid).toBe(false);
expect(result.error).toContain('Wildcard usage on physical tables is not allowed'); expect(result.error).toContain("You're not allowed to use a wildcard on physical tables");
expect(result.blockedTables).toContain('users'); expect(result.blockedTables).toContain('users');
}); });
@ -434,7 +434,7 @@ models:
const sql = 'SELECT u.* FROM users u'; const sql = 'SELECT u.* FROM users u';
const result = validateWildcardUsage(sql); const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false); expect(result.isValid).toBe(false);
expect(result.error).toContain('Wildcard usage on physical tables is not allowed'); expect(result.error).toContain("You're not allowed to use a wildcard on physical tables");
expect(result.blockedTables).toContain('u'); expect(result.blockedTables).toContain('u');
}); });
@ -470,7 +470,7 @@ models:
`; `;
const result = validateWildcardUsage(sql); const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false); expect(result.isValid).toBe(false);
expect(result.error).toContain('Wildcard usage on physical tables is not allowed'); expect(result.error).toContain("You're not allowed to use a wildcard on physical tables");
expect(result.blockedTables).toContain('users'); expect(result.blockedTables).toContain('users');
}); });
@ -514,7 +514,7 @@ models:
const sql = 'SELECT * FROM public.users'; const sql = 'SELECT * FROM public.users';
const result = validateWildcardUsage(sql); const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false); expect(result.isValid).toBe(false);
expect(result.error).toContain('Wildcard usage on physical tables is not allowed'); expect(result.error).toContain("You're not allowed to use a wildcard on physical tables");
}); });
it('should handle invalid SQL gracefully', () => { it('should handle invalid SQL gracefully', () => {

View File

@ -410,10 +410,9 @@ export function validateWildcardUsage(
} }
if (blockedTables.length > 0) { if (blockedTables.length > 0) {
const tableList = blockedTables.join(', ');
return { return {
isValid: false, isValid: false,
error: `Wildcard usage on physical tables is not allowed: ${tableList}. Please specify explicit column names.`, error: `You're not allowed to use a wildcard on physical tables, please be specific about which columns you'd like to work with`,
blockedTables, blockedTables,
}; };
} }

View File

@ -1,5 +1,5 @@
{ {
"name": "@buster-tools/web-tools", "name": "@buster/web-tools",
"version": "0.1.0", "version": "0.1.0",
"description": "Web scraping and research tools using Firecrawl and other services", "description": "Web scraping and research tools using Firecrawl and other services",
"type": "module", "type": "module",

View File

@ -208,6 +208,9 @@ importers:
'@buster/vitest-config': '@buster/vitest-config':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/vitest-config version: link:../../packages/vitest-config
'@buster/web-tools':
specifier: workspace:*
version: link:../../packages/web-tools
'@mastra/core': '@mastra/core':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.10.8(openapi-types@12.1.3)(react@18.3.1)(zod@3.25.1) version: 0.10.8(openapi-types@12.1.3)(react@18.3.1)(zod@3.25.1)
@ -681,9 +684,6 @@ importers:
'@ai-sdk/provider': '@ai-sdk/provider':
specifier: ^1.1.3 specifier: ^1.1.3
version: 1.1.3 version: 1.1.3
'@buster-tools/web-tools':
specifier: workspace:*
version: link:../web-tools
'@buster/access-controls': '@buster/access-controls':
specifier: workspace:* specifier: workspace:*
version: link:../access-controls version: link:../access-controls
@ -714,6 +714,9 @@ importers:
'@buster/vitest-config': '@buster/vitest-config':
specifier: workspace:* specifier: workspace:*
version: link:../vitest-config version: link:../vitest-config
'@buster/web-tools':
specifier: workspace:*
version: link:../web-tools
'@mastra/core': '@mastra/core':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.10.8(openapi-types@12.1.3)(react@18.3.1)(zod@3.25.1) version: 0.10.8(openapi-types@12.1.3)(react@18.3.1)(zod@3.25.1)