refactor(ai): Implement unified AI service layer and fix subtask update

- Unified Service: Introduced 'scripts/modules/ai-services-unified.js' to centralize AI interactions using provider modules ('src/ai-providers/') and the Vercel AI SDK.

- Provider Modules: Implemented 'anthropic.js' and 'perplexity.js' wrappers for Vercel SDK.

- 'updateSubtaskById' Fix: Refactored the AI call within 'updateSubtaskById' to use 'generateTextService' from the unified layer, resolving runtime errors related to parameter passing and streaming. This serves as the pattern for refactoring other AI calls in 'scripts/modules/task-manager/'.

- Task Status: Marked Subtask 61.19 as 'done'.

- Rules: Added new 'ai-services.mdc' rule.

This centralizes AI logic, replacing previous direct SDK calls and custom implementations. API keys are resolved via 'resolveEnvVariable' within the service layer. The refactoring of 'updateSubtaskById' establishes the standard approach for migrating other AI-dependent functions in the task manager module to use the unified service.

Relates to Task 61.
This commit is contained in:
Eyal Toledano
2025-04-22 02:42:04 -04:00
parent c90578b6da
commit b3b424be93
11 changed files with 844 additions and 588 deletions

View File

@@ -1,13 +1,48 @@
/**
* ai-services-unified.js
* Centralized AI service layer using ai-client-factory and AI SDK core functions.
* Centralized AI service layer using provider modules and config-manager.
*/
import { generateText } from 'ai';
import { getClient } from './ai-client-factory.js';
import { log } from './utils.js'; // Import log for retry logging
// Import logger from utils later when needed
// import { log } from './utils.js';
// Vercel AI SDK functions are NOT called directly anymore.
// import { generateText, streamText, generateObject } from 'ai';
// --- Core Dependencies ---
import {
// REMOVED: getProviderAndModelForRole, // This was incorrect
getMainProvider, // ADD individual getters
getMainModelId,
getResearchProvider,
getResearchModelId,
getFallbackProvider,
getFallbackModelId,
getParametersForRole
// ConfigurationError // Import if needed for specific handling
} from './config-manager.js'; // Corrected: Removed getProviderAndModelForRole
import { log, resolveEnvVariable } from './utils.js';
// --- Provider Service Imports ---
// Corrected path from scripts/ai-providers/... to ../../src/ai-providers/...
import * as anthropic from '../../src/ai-providers/anthropic.js';
import * as perplexity from '../../src/ai-providers/perplexity.js';
// TODO: Import other provider modules when implemented (openai, ollama, etc.)
// --- Provider Function Map ---
// Maps provider names (lowercase) to their respective service functions
const PROVIDER_FUNCTIONS = {
anthropic: {
generateText: anthropic.generateAnthropicText,
streamText: anthropic.streamAnthropicText,
generateObject: anthropic.generateAnthropicObject
// streamObject: anthropic.streamAnthropicObject, // Add when implemented
},
perplexity: {
generateText: perplexity.generatePerplexityText,
streamText: perplexity.streamPerplexityText,
generateObject: perplexity.generatePerplexityObject
// streamObject: perplexity.streamPerplexityObject, // Add when implemented
}
// TODO: Add entries for openai, ollama, etc. when implemented
};
// --- Configuration for Retries ---
const MAX_RETRIES = 2; // Total attempts = 1 + MAX_RETRIES
@@ -30,39 +65,86 @@ function isRetryableError(error) {
}
/**
* Internal helper to attempt an AI SDK API call with retries.
* Internal helper to resolve the API key for a given provider.
* @param {string} providerName - The name of the provider (lowercase).
* @param {object|null} session - Optional MCP session object.
* @returns {string|null} The API key or null if not found/needed.
* @throws {Error} If a required API key is missing.
*/
function _resolveApiKey(providerName, session) {
const keyMap = {
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
google: 'GOOGLE_API_KEY',
perplexity: 'PERPLEXITY_API_KEY',
grok: 'GROK_API_KEY',
mistral: 'MISTRAL_API_KEY',
azure: 'AZURE_OPENAI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
xai: 'XAI_API_KEY'
// ollama doesn't need an API key mapped here
};
if (providerName === 'ollama') {
return null; // Ollama typically doesn't require an API key for basic setup
}
const envVarName = keyMap[providerName];
if (!envVarName) {
throw new Error(
`Unknown provider '${providerName}' for API key resolution.`
);
}
const apiKey = resolveEnvVariable(envVarName, session);
if (!apiKey) {
throw new Error(
`Required API key ${envVarName} for provider '${providerName}' is not set in environment or session.`
);
}
return apiKey;
}
/**
* Internal helper to attempt a provider-specific AI API call with retries.
*
* @param {object} client - The AI client instance.
* @param {function} apiCallFn - The AI SDK function to call (e.g., generateText).
* @param {object} apiParams - Parameters for the AI SDK function (excluding model).
* @param {function} providerApiFn - The specific provider function to call (e.g., generateAnthropicText).
* @param {object} callParams - Parameters object for the provider function.
* @param {string} providerName - Name of the provider (for logging).
* @param {string} modelId - Specific model ID (for logging).
* @param {string} attemptRole - The role being attempted (for logging).
* @returns {Promise<object>} The result from the successful API call.
* @throws {Error} If the call fails after all retries.
*/
async function _attemptApiCallWithRetries(
client,
apiCallFn,
apiParams,
async function _attemptProviderCallWithRetries(
providerApiFn,
callParams,
providerName,
modelId,
attemptRole
) {
let retries = 0;
const fnName = providerApiFn.name; // Get function name for logging
while (retries <= MAX_RETRIES) {
try {
log(
'info',
`Attempt ${retries + 1}/${MAX_RETRIES + 1} calling ${apiCallFn.name} for role ${attemptRole}`
`Attempt ${retries + 1}/${MAX_RETRIES + 1} calling ${fnName} (Provider: ${providerName}, Model: ${modelId}, Role: ${attemptRole})`
);
// Call the provided AI SDK function (generateText, streamText, etc.)
const result = await apiCallFn({ model: client, ...apiParams });
// Call the specific provider function directly
const result = await providerApiFn(callParams);
log(
'info',
`${apiCallFn.name} succeeded for role ${attemptRole} on attempt ${retries + 1}`
`${fnName} succeeded for role ${attemptRole} (Provider: ${providerName}) on attempt ${retries + 1}`
);
return result; // Success!
} catch (error) {
log(
'warn',
`Attempt ${retries + 1} failed for role ${attemptRole} (${apiCallFn.name}): ${error.message}`
`Attempt ${retries + 1} failed for role ${attemptRole} (${fnName} / ${providerName}): ${error.message}`
);
if (isRetryableError(error) && retries < MAX_RETRIES) {
@@ -76,140 +158,35 @@ async function _attemptApiCallWithRetries(
} else {
log(
'error',
`Non-retryable error or max retries reached for role ${attemptRole} (${apiCallFn.name}).`
`Non-retryable error or max retries reached for role ${attemptRole} (${fnName} / ${providerName}).`
);
throw error; // Final failure for this attempt chain
}
}
}
// Should theoretically not be reached due to throw in the else block, but needed for linting/type safety
// Should not be reached due to throw in the else block
throw new Error(
`Exhausted all retries for role ${attemptRole} (${apiCallFn.name})`
`Exhausted all retries for role ${attemptRole} (${fnName} / ${providerName})`
);
}
/**
* Unified service function for generating text.
* Handles client retrieval, retries, and fallback (main -> fallback -> research).
* TODO: Add detailed logging.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {object} [params.overrideOptions={}] - Optional overrides for ai-client-factory { provider, modelId }.
* @param {string} params.prompt - The prompt for the AI.
* @param {number} [params.maxTokens] - Max tokens for the generation.
* @param {number} [params.temperature] - Temperature setting.
* // ... include other standard generateText options as needed ...
* @returns {Promise<object>} The result from the AI SDK's generateText function.
* Base logic for unified service functions.
* @param {string} serviceType - Type of service ('generateText', 'streamText', 'generateObject').
* @param {object} params - Original parameters passed to the service function.
* @returns {Promise<any>} Result from the underlying provider call.
*/
async function generateTextService(params) {
async function _unifiedServiceRunner(serviceType, params) {
const {
role: initialRole,
session,
overrideOptions,
...generateTextParams
systemPrompt,
prompt,
schema,
objectName,
...restApiParams
} = params;
log('info', 'generateTextService called', { role: initialRole });
// Determine the sequence explicitly based on the initial role
let sequence;
if (initialRole === 'main') {
sequence = ['main', 'fallback', 'research'];
} else if (initialRole === 'fallback') {
sequence = ['fallback', 'research']; // Try fallback, then research
} else if (initialRole === 'research') {
sequence = ['research', 'fallback']; // Try research, then fallback
} else {
// Default sequence if initialRole is unknown or invalid
log(
'warn',
`Unknown initial role: ${initialRole}. Defaulting to main -> fallback -> research sequence.`
);
sequence = ['main', 'fallback', 'research'];
}
let lastError = null;
// Iterate through the determined sequence
for (const currentRole of sequence) {
// Removed the complex conditional check, as the sequence is now pre-determined
log('info', `Attempting service call with role: ${currentRole}`);
let client;
try {
client = await getClient(currentRole, session, overrideOptions);
const clientInfo = {
provider: client?.provider || 'unknown',
model: client?.modelId || client?.model || 'unknown'
};
log('info', 'Retrieved AI client', clientInfo);
// Attempt the API call with retries using the helper
const result = await _attemptApiCallWithRetries(
client,
generateText,
generateTextParams,
currentRole
);
log('info', `generateTextService succeeded using role: ${currentRole}`); // Add success log
return result; // Success!
} catch (error) {
log(
'error', // Log as error since this role attempt failed
`Service call failed for role ${currentRole}: ${error.message}`
);
lastError = error; // Store the error to throw if all roles in sequence fail
// Log the reason for moving to the next role
if (!client) {
log(
'warn',
`Could not get client for role ${currentRole}, trying next role in sequence...`
);
} else {
// Error happened during API call after client was retrieved
log(
'warn',
`Retries exhausted or non-retryable error for role ${currentRole}, trying next role in sequence...`
);
}
// Continue to the next role in the sequence automatically
}
}
// If loop completes, all roles in the sequence failed
log('error', `All roles in the sequence [${sequence.join(', ')}] failed.`);
throw (
lastError ||
new Error(
'AI service call failed for all configured roles in the sequence.'
)
);
}
// TODO: Implement streamTextService, generateObjectService etc.
/**
* Unified service function for streaming text.
* Handles client retrieval, retries, and fallback sequence.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {object} [params.overrideOptions={}] - Optional overrides for ai-client-factory.
* @param {string} params.prompt - The prompt for the AI.
* // ... include other standard streamText options as needed ...
* @returns {Promise<object>} The result from the AI SDK's streamText function (typically a Streamable object).
*/
async function streamTextService(params) {
const {
role: initialRole,
session,
overrideOptions,
...streamTextParams // Collect remaining params for streamText
} = params;
log('info', 'streamTextService called', { role: initialRole });
log('info', `${serviceType}Service called`, { role: initialRole });
let sequence;
if (initialRole === 'main') {
@@ -229,54 +206,190 @@ async function streamTextService(params) {
let lastError = null;
for (const currentRole of sequence) {
log('info', `Attempting service call with role: ${currentRole}`);
let client;
let providerName, modelId, apiKey, roleParams, providerFnSet, providerApiFn;
try {
client = await getClient(currentRole, session, overrideOptions);
const clientInfo = {
provider: client?.provider || 'unknown',
model: client?.modelId || client?.model || 'unknown'
};
log('info', 'Retrieved AI client', clientInfo);
log('info', `Attempting service call with role: ${currentRole}`);
const result = await _attemptApiCallWithRetries(
client,
streamText, // Pass streamText function
streamTextParams,
currentRole
);
log('info', `streamTextService succeeded using role: ${currentRole}`);
return result;
} catch (error) {
log(
'error',
`Service call failed for role ${currentRole}: ${error.message}`
);
lastError = error;
if (!client) {
log(
'warn',
`Could not get client for role ${currentRole}, trying next role in sequence...`
);
// --- Corrected Config Fetching ---
// 1. Get Config: Provider, Model, Parameters for the current role
// Call individual getters based on the current role
if (currentRole === 'main') {
providerName = getMainProvider(); // Use individual getter
modelId = getMainModelId(); // Use individual getter
} else if (currentRole === 'research') {
providerName = getResearchProvider(); // Use individual getter
modelId = getResearchModelId(); // Use individual getter
} else if (currentRole === 'fallback') {
providerName = getFallbackProvider(); // Use individual getter
modelId = getFallbackModelId(); // Use individual getter
} else {
log(
'warn',
`Retries exhausted or non-retryable error for role ${currentRole}, trying next role in sequence...`
'error',
`Unknown role encountered in _unifiedServiceRunner: ${currentRole}`
);
lastError =
lastError || new Error(`Unknown AI role specified: ${currentRole}`);
continue; // Skip to the next role attempt
}
// --- End Corrected Config Fetching ---
if (!providerName || !modelId) {
log(
'warn',
`Skipping role '${currentRole}': Provider or Model ID not configured.`
);
lastError =
lastError ||
new Error(
`Configuration missing for role '${currentRole}'. Provider: ${providerName}, Model: ${modelId}`
);
continue; // Skip to the next role
}
roleParams = getParametersForRole(currentRole); // Get { maxTokens, temperature }
// 2. Get Provider Function Set
providerFnSet = PROVIDER_FUNCTIONS[providerName?.toLowerCase()];
if (!providerFnSet) {
log(
'warn',
`Skipping role '${currentRole}': Provider '${providerName}' not supported or map entry missing.`
);
lastError =
lastError ||
new Error(`Unsupported provider configured: ${providerName}`);
continue;
}
// Use the original service type to get the function
providerApiFn = providerFnSet[serviceType];
if (typeof providerApiFn !== 'function') {
log(
'warn',
`Skipping role '${currentRole}': Service type '${serviceType}' not implemented for provider '${providerName}'.`
);
lastError =
lastError ||
new Error(
`Service '${serviceType}' not implemented for provider ${providerName}`
);
continue;
}
// 3. Resolve API Key (will throw if required and missing)
apiKey = _resolveApiKey(providerName?.toLowerCase(), session); // Throws on failure
// 4. Construct Messages Array
const messages = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
// IN THE FUTURE WHEN DOING CONTEXT IMPROVEMENTS
// {
// type: 'text',
// text: 'Large cached context here like a tasks json',
// providerOptions: {
// anthropic: { cacheControl: { type: 'ephemeral' } }
// }
// }
// Example
// if (params.context) { // context is a json string of a tasks object or some other stu
// messages.push({
// type: 'text',
// text: params.context,
// providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
// });
// }
if (prompt) {
// Ensure prompt exists before adding
messages.push({ role: 'user', content: prompt });
} else {
// Throw an error if the prompt is missing, as it's essential
throw new Error('User prompt content is missing.');
}
// 5. Prepare call parameters (using messages array)
const callParams = {
apiKey,
modelId,
maxTokens: roleParams.maxTokens,
temperature: roleParams.temperature,
messages, // *** Pass the constructed messages array ***
// Add specific params for generateObject if needed
...(serviceType === 'generateObject' && { schema, objectName }),
...restApiParams // Include other params like maxRetries
};
// 6. Attempt the call with retries
const result = await _attemptProviderCallWithRetries(
providerApiFn,
callParams,
providerName,
modelId,
currentRole
);
log('info', `${serviceType}Service succeeded using role: ${currentRole}`);
return result; // Return original result for other cases
} catch (error) {
log(
'error', // Log as error since this role attempt failed
`Service call failed for role ${currentRole} (Provider: ${providerName || 'unknown'}): ${error.message}`
);
lastError = error; // Store the error to throw if all roles fail
// Log reason and continue (handled within the loop now)
}
}
// If loop completes, all roles failed
log('error', `All roles in the sequence [${sequence.join(', ')}] failed.`);
throw (
lastError ||
new Error(
'AI service call (streamText) failed for all configured roles in the sequence.'
`AI service call (${serviceType}) failed for all configured roles in the sequence.`
)
);
}
/**
* Unified service function for generating text.
* Handles client retrieval, retries, and fallback sequence.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {string} params.prompt - The prompt for the AI.
* @param {string} [params.systemPrompt] - Optional system prompt.
* // Other specific generateText params can be included here.
* @returns {Promise<string>} The generated text content.
*/
async function generateTextService(params) {
// Now directly returns the text string or throws error
return _unifiedServiceRunner('generateText', params);
}
/**
* Unified service function for streaming text.
* Handles client retrieval, retries, and fallback sequence.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {string} params.prompt - The prompt for the AI.
* @param {string} [params.systemPrompt] - Optional system prompt.
* // Other specific streamText params can be included here.
* @returns {Promise<ReadableStream<string>>} A readable stream of text deltas.
*/
async function streamTextService(params) {
// Now directly returns the stream object or throws error
return _unifiedServiceRunner('streamText', params);
}
/**
* Unified service function for generating structured objects.
* Handles client retrieval, retries, and fallback sequence.
@@ -284,85 +397,22 @@ async function streamTextService(params) {
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {object} [params.overrideOptions={}] - Optional overrides for ai-client-factory.
* @param {z.Schema} params.schema - The Zod schema for the expected object.
* @param {import('zod').ZodSchema} params.schema - The Zod schema for the expected object.
* @param {string} params.prompt - The prompt for the AI.
* // ... include other standard generateObject options as needed ...
* @returns {Promise<object>} The result from the AI SDK's generateObject function.
* @param {string} [params.systemPrompt] - Optional system prompt.
* @param {string} [params.objectName='generated_object'] - Name for object/tool.
* @param {number} [params.maxRetries=3] - Max retries for object generation.
* // Other specific generateObject params can be included here.
* @returns {Promise<object>} The generated object matching the schema.
*/
async function generateObjectService(params) {
const {
role: initialRole,
session,
overrideOptions,
...generateObjectParams // Collect remaining params for generateObject
} = params;
log('info', 'generateObjectService called', { role: initialRole });
let sequence;
if (initialRole === 'main') {
sequence = ['main', 'fallback', 'research'];
} else if (initialRole === 'fallback') {
sequence = ['fallback', 'research'];
} else if (initialRole === 'research') {
sequence = ['research', 'fallback'];
} else {
log(
'warn',
`Unknown initial role: ${initialRole}. Defaulting to main -> fallback -> research sequence.`
);
sequence = ['main', 'fallback', 'research'];
}
let lastError = null;
for (const currentRole of sequence) {
log('info', `Attempting service call with role: ${currentRole}`);
let client;
try {
client = await getClient(currentRole, session, overrideOptions);
const clientInfo = {
provider: client?.provider || 'unknown',
model: client?.modelId || client?.model || 'unknown'
};
log('info', 'Retrieved AI client', clientInfo);
const result = await _attemptApiCallWithRetries(
client,
generateObject, // Pass generateObject function
generateObjectParams,
currentRole
);
log('info', `generateObjectService succeeded using role: ${currentRole}`);
return result;
} catch (error) {
log(
'error',
`Service call failed for role ${currentRole}: ${error.message}`
);
lastError = error;
if (!client) {
log(
'warn',
`Could not get client for role ${currentRole}, trying next role in sequence...`
);
} else {
log(
'warn',
`Retries exhausted or non-retryable error for role ${currentRole}, trying next role in sequence...`
);
}
}
}
log('error', `All roles in the sequence [${sequence.join(', ')}] failed.`);
throw (
lastError ||
new Error(
'AI service call (generateObject) failed for all configured roles in the sequence.'
)
);
const defaults = {
objectName: 'generated_object',
maxRetries: 3
};
const combinedParams = { ...defaults, ...params };
// Now directly returns the generated object or throws error
return _unifiedServiceRunner('generateObject', combinedParams);
}
export { generateTextService, streamTextService, generateObjectService };

View File

@@ -338,6 +338,20 @@ function getOllamaBaseUrl(explicitRoot = null) {
return getGlobalConfig(explicitRoot).ollamaBaseUrl;
}
/**
* Gets model parameters (maxTokens, temperature) for a specific role.
* @param {string} role - The role ('main', 'research', 'fallback').
* @param {string|null} explicitRoot - Optional explicit path to the project root.
* @returns {{maxTokens: number, temperature: number}}
*/
function getParametersForRole(role, explicitRoot = null) {
const roleConfig = getModelConfigForRole(role, explicitRoot);
return {
maxTokens: roleConfig.maxTokens,
temperature: roleConfig.temperature
};
}
/**
* Checks if the API key for a given provider is set in the environment.
* Checks process.env first, then session.env if session is provided.
@@ -561,6 +575,7 @@ export {
getDefaultPriority,
getProjectName,
getOllamaBaseUrl,
getParametersForRole,
// API Key Checkers (still relevant)
isApiKeySet,

View File

@@ -10,7 +10,7 @@ import {
stopLoadingIndicator
} from '../ui.js';
import { log, readJSON, writeJSON, truncate, isSilentMode } from '../utils.js';
import { getAvailableAIModel } from '../ai-services.js';
import { generateTextService } from '../ai-services-unified.js';
import {
getDebugFlag,
getMainModelId,
@@ -54,6 +54,7 @@ async function updateSubtaskById(
};
let loadingIndicator = null;
try {
report(`Updating subtask ${subtaskId} with prompt: "${prompt}"`, 'info');
@@ -193,236 +194,55 @@ async function updateSubtaskById(
);
}
// Create the system prompt (as before)
const systemPrompt = `You are an AI assistant helping to update software development subtasks with additional information.
let additionalInformation = '';
try {
// Reverted: Keep the original system prompt
const systemPrompt = `You are an AI assistant helping to update software development subtasks with additional information.
Given a subtask, you will provide additional details, implementation notes, or technical insights based on user request.
Focus only on adding content that enhances the subtask - don't repeat existing information.
Be technical, specific, and implementation-focused rather than general.
Provide concrete examples, code snippets, or implementation details when relevant.`;
// Replace the old research/Claude code with the new model selection approach
let additionalInformation = '';
let modelAttempts = 0;
const maxModelAttempts = 2; // Try up to 2 models before giving up
// Reverted: Use the full JSON stringification for the user message
const subtaskData = JSON.stringify(subtask, null, 2);
const userMessageContent = `Here is the subtask to enhance:\n${subtaskData}\n\nPlease provide additional information addressing this request:\n${prompt}\n\nReturn ONLY the new information to add - do not repeat existing content.`;
while (modelAttempts < maxModelAttempts && !additionalInformation) {
modelAttempts++; // Increment attempt counter at the start
const isLastAttempt = modelAttempts >= maxModelAttempts;
let modelType = null; // Declare modelType outside the try block
const serviceRole = useResearch ? 'research' : 'main';
report(`Calling AI stream service with role: ${serviceRole}`, 'info');
try {
// Get the best available model based on our current state
const result = getAvailableAIModel({
claudeOverloaded,
requiresResearch: useResearch
});
modelType = result.type;
const client = result.client;
const streamResult = await generateTextService({
role: serviceRole,
session: session,
systemPrompt: systemPrompt, // Pass the original system prompt
prompt: userMessageContent // Pass the original user message content
});
report(
`Attempt ${modelAttempts}/${maxModelAttempts}: Generating subtask info using ${modelType}`,
'info'
);
// Update loading indicator text - only for text output
if (outputFormat === 'text') {
if (loadingIndicator) {
stopLoadingIndicator(loadingIndicator); // Stop previous indicator
}
loadingIndicator = startLoadingIndicator(
`Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...`
);
}
const subtaskData = JSON.stringify(subtask, null, 2);
const userMessageContent = `Here is the subtask to enhance:\n${subtaskData}\n\nPlease provide additional information addressing this request:\n${prompt}\n\nReturn ONLY the new information to add - do not repeat existing content.`;
if (modelType === 'perplexity') {
// Construct Perplexity payload
const perplexityModel = getResearchModelId(session);
const response = await client.chat.completions.create({
model: perplexityModel,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessageContent }
],
temperature: getResearchTemperature(session),
max_tokens: getResearchMaxTokens(session)
});
additionalInformation = response.choices[0].message.content.trim();
} else {
// Claude
let responseText = '';
let streamingInterval = null;
try {
// Only update streaming indicator for text output
if (outputFormat === 'text') {
let dotCount = 0;
const readline = await import('readline');
streamingInterval = setInterval(() => {
readline.cursorTo(process.stdout, 0);
process.stdout.write(
`Receiving streaming response from Claude${'.'.repeat(dotCount)}`
);
dotCount = (dotCount + 1) % 4;
}, 500);
}
// Construct Claude payload using config getters
const stream = await client.messages.create({
model: getMainModelId(session),
max_tokens: getMainMaxTokens(session),
temperature: getMainTemperature(session),
system: systemPrompt,
messages: [{ role: 'user', content: userMessageContent }],
stream: true
});
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
responseText += chunk.delta.text;
}
if (reportProgress) {
await reportProgress({
progress:
(responseText.length / getMainMaxTokens(session)) * 100
});
}
if (mcpLog) {
mcpLog.info(
`Progress: ${(responseText.length / getMainMaxTokens(session)) * 100}%`
);
}
}
} finally {
if (streamingInterval) clearInterval(streamingInterval);
// Clear the loading dots line - only for text output
if (outputFormat === 'text') {
const readline = await import('readline');
readline.cursorTo(process.stdout, 0);
process.stdout.clearLine(0);
}
}
report(
`Completed streaming response from Claude API! (Attempt ${modelAttempts})`,
'info'
);
additionalInformation = responseText.trim();
}
// Success - break the loop
if (additionalInformation) {
report(
`Successfully generated information using ${modelType} on attempt ${modelAttempts}.`,
'info'
);
break;
} else {
// Handle case where AI gave empty response without erroring
report(
`AI (${modelType}) returned empty response on attempt ${modelAttempts}.`,
'warn'
);
if (isLastAttempt) {
throw new Error(
'AI returned empty response after maximum attempts.'
);
}
// Allow loop to continue to try another model/attempt if possible
}
} catch (modelError) {
const failedModel =
modelType || modelError.modelType || 'unknown model';
report(
`Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`,
'warn'
);
// --- More robust overload check ---
let isOverload = false;
// Check 1: SDK specific property (common pattern)
if (modelError.type === 'overloaded_error') {
isOverload = true;
}
// Check 2: Check nested error property (as originally intended)
else if (modelError.error?.type === 'overloaded_error') {
isOverload = true;
}
// Check 3: Check status code if available (e.g., 429 Too Many Requests or 529 Overloaded)
else if (modelError.status === 429 || modelError.status === 529) {
isOverload = true;
}
// Check 4: Check the message string itself (less reliable)
else if (modelError.message?.toLowerCase().includes('overloaded')) {
isOverload = true;
}
// --- End robust check ---
if (isOverload) {
// Use the result of the check
claudeOverloaded = true; // Mark Claude as overloaded for the *next* potential attempt
if (!isLastAttempt) {
report(
'Claude overloaded. Will attempt fallback model if available.',
'info'
);
// Stop the current indicator before continuing - only for text output
if (outputFormat === 'text' && loadingIndicator) {
stopLoadingIndicator(loadingIndicator);
loadingIndicator = null; // Reset indicator
}
continue; // Go to next iteration of the while loop to try fallback
} else {
// It was the last attempt, and it failed due to overload
report(
`Overload error on final attempt (${modelAttempts}/${maxModelAttempts}). No fallback possible.`,
'error'
);
// Let the error be thrown after the loop finishes, as additionalInformation will be empty.
// We don't throw immediately here, let the loop exit and the check after the loop handle it.
}
} else {
// Error was NOT an overload
// If it's not an overload, throw it immediately to be caught by the outer catch.
report(
`Non-overload error on attempt ${modelAttempts}: ${modelError.message}`,
'error'
);
throw modelError; // Re-throw non-overload errors immediately.
}
} // End inner catch
} // End while loop
// If loop finished without getting information
if (!additionalInformation) {
// Only show debug info for text output (CLI)
if (outputFormat === 'text') {
console.log(
'>>> DEBUG: additionalInformation is falsy! Value:',
additionalInformation
);
if (outputFormat === 'text' && loadingIndicator) {
// Stop indicator immediately since generateText is blocking
stopLoadingIndicator(loadingIndicator);
loadingIndicator = null;
}
throw new Error(
'Failed to generate additional information after all attempts.'
);
}
// Only show debug info for text output (CLI)
if (outputFormat === 'text') {
console.log(
'>>> DEBUG: Got additionalInformation:',
additionalInformation.substring(0, 50) + '...'
);
}
// Assign the result directly (generateTextService returns the text string)
additionalInformation = streamResult ? streamResult.trim() : '';
if (!additionalInformation) {
throw new Error('AI returned empty response.'); // Changed error message slightly
}
report(
// Corrected log message to reflect generateText
`Successfully generated text using AI role: ${serviceRole}.`,
'info'
);
} catch (aiError) {
report(`AI service call failed: ${aiError.message}`, 'error');
throw aiError;
} // Removed the inner finally block as streamingInterval is gone
// Create timestamp
const currentDate = new Date();
const timestamp = currentDate.toISOString();
// Format the additional information with timestamp
const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`;
const formattedInformation = `\n\n<info added on ${currentDate.toISOString()}>\n${additionalInformation}\n</info added on ${currentDate.toISOString()}>`;
// Only show debug info for text output (CLI)
if (outputFormat === 'text') {
@@ -556,9 +376,9 @@ Provide concrete examples, code snippets, or implementation details when relevan
' 1. Run task-master list --with-subtasks to see all available subtask IDs'
);
console.log(
' 2. Use a valid subtask ID with the --id parameter in format \"parentId.subtaskId\"'
' 2. Use a valid subtask ID with the --id parameter in format "parentId.subtaskId"'
);
} else if (error.message?.includes('empty response from AI')) {
} else if (error.message?.includes('empty stream response')) {
console.log(
chalk.yellow(
'\nThe AI model returned an empty response. This might be due to the prompt or API issues. Try rephrasing or trying again later.'
@@ -575,11 +395,6 @@ Provide concrete examples, code snippets, or implementation details when relevan
}
return null;
} finally {
// Final cleanup check for the indicator, although it should be stopped by now
if (outputFormat === 'text' && loadingIndicator) {
stopLoadingIndicator(loadingIndicator);
}
}
}