From 491380a9172dc29e174c8aee2d35839f681c8997 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Fri, 1 Aug 2025 11:36:03 +0800 Subject: [PATCH 1/7] Update Slack chatbot. Add SlackAPI. Strip reasoning parts when generating new messages --- src/functionRegistry.ts | 5 + src/llm/multi-agent/fastMedium.ts | 4 +- src/llm/services/fireworks.ts | 5 + .../firestore/firestoreAgentStateService.ts | 7 +- src/modules/slack/slackApi.ts | 212 ++++++++++++++++++ src/modules/slack/slackChatBotService.ts | 107 +++++++-- .../discovery/selectFilesAgentWithSearch.ts | 7 +- src/swe/projectDetection.ts | 2 +- variables/local.env.example | 4 +- variables/test.env | 2 + 10 files changed, 325 insertions(+), 30 deletions(-) create mode 100644 src/modules/slack/slackApi.ts diff --git a/src/functionRegistry.ts b/src/functionRegistry.ts index fe2ffb061..729b32a5a 100644 --- a/src/functionRegistry.ts +++ b/src/functionRegistry.ts @@ -6,6 +6,7 @@ import { GoogleCloud } from '#functions/cloud/google/google-cloud'; import { CommandLineInterface } from '#functions/commandLine'; import { CustomFunctions } from '#functions/customFunctions'; import { DeepThink } from '#functions/deepThink'; +import { GoogleCalendar } from '#functions/googleCalendar'; import { ImageGen } from '#functions/image'; import { Jira } from '#functions/jira'; import { LlmTools } from '#functions/llmTools'; @@ -18,6 +19,7 @@ import { FileSystemWrite } from '#functions/storage/fileSystemWrite'; import { LocalFileStore } from '#functions/storage/localFileStore'; import { Perplexity } from '#functions/web/perplexity'; import { PublicWeb } from '#functions/web/web'; +import { SlackAPI } from '#modules/slack/slackApi'; import { type ToolType, hasGetToolType } from '#shared/agent/functions'; import { Slack } from '#slack/slack'; import { CodeEditingAgent } from '#swe/codeEditingAgent'; @@ -56,6 +58,9 @@ const FUNCTIONS = [ TypescriptTools, BigQuery, CustomFunctions, + GoogleCalendar, + SlackAPI, + // Add your own classes below this line ]; diff --git a/src/llm/multi-agent/fastMedium.ts b/src/llm/multi-agent/fastMedium.ts index 18f628633..fb4b44eeb 100644 --- a/src/llm/multi-agent/fastMedium.ts +++ b/src/llm/multi-agent/fastMedium.ts @@ -7,7 +7,7 @@ import { BaseLLM } from '../base-llm'; /** * LLM implementation for medium level LLM using a fast provider if available and applicable, else falling back to the standard medium LLM - * https://artificialanalysis.ai/?models=gemini-2-5-flash%2Cgemini-2-5-flash-reasoning%2Cgroq_qwen3-32b-instruct-reasoning%2Cgroq_qwen3-32b-instruct%2Ccerebras_qwen3-32b-instruct-reasoning&endpoints=groq_qwen3-32b-instruct%2Cgroq_qwen3-32b-instruct-reasoning%2Ccerebras_qwen3-235b-a22b-instruct%2Ccerebras_qwen3-32b-instruct-reasoning%2Ccerebras_qwen3-235b-a22b-instruct-reasoning + * https://artificialanalysis.ai/?models=gemini-2-5-flash%2Cgemini-2-5-flash-reasoning%2Cgroq_qwen3-32b-instruct-reasoning%2Cgroq_qwen3-32b-instruct%2Ccerebras_qwen3-32b-instruct-reasoning&endpoints=groq_qwen3-32b-instruct-reasoning%2Ccerebras_qwen3-235b-a22b-instruct-2507%2Ccerebras_qwen3-235b-a22b-instruct-2507-reasoning%2Ccerebras_qwen3-32b-instruct-reasoning */ export class FastMediumLLM extends BaseLLM { private readonly providers: LLM[]; @@ -62,7 +62,7 @@ export class FastMediumLLM extends BaseLLM { if (tokens && this.cerebras.isConfigured() && tokens < this.cerebras.getMaxInputTokens() * 0.4) return await this.cerebras.generateMessage(messages, opts); } catch (e) { - logger.warn(`Error calling fast medium LLM with ${tokens} tokens: ${e.message}`); + logger.warn(e, `Error calling fast medium LLM with ${tokens} tokens: ${e.message}`); } return await this.gemini.generateMessage(messages, opts); } diff --git a/src/llm/services/fireworks.ts b/src/llm/services/fireworks.ts index fcf6ad1c0..bda3f5fde 100644 --- a/src/llm/services/fireworks.ts +++ b/src/llm/services/fireworks.ts @@ -33,6 +33,7 @@ export function fireworksLLMRegistry(): Record LLM> { [`${FIREWORKS_SERVICE}:accounts/fireworks/models/llama-v3p1-70b-instruct`]: fireworksLlama3_70B, [`${FIREWORKS_SERVICE}:accounts/fireworks/models/deepseek-v3`]: fireworksDeepSeekV3, [`${FIREWORKS_SERVICE}:accounts/fireworks/models/qwen3-235b-a22b`]: fireworksQwen3_235bA22b, + [`${FIREWORKS_SERVICE}:accounts/fireworks/models/qwen3-coder-480b-a35b-instruct`]: fireworksQwen3Coder, }; } @@ -40,6 +41,10 @@ export function fireworksQwen3_235bA22b(): LLM { return new Fireworks('Qwen3 235b-A22b (Fireworks)', 'accounts/fireworks/models/qwen3-235b-a22b', 16_000, fixedCostPerMilTokens(0.22, 0.88)); } +export function fireworksQwen3Coder(): LLM { + return new Fireworks('Qwen3 Coder (Fireworks)', 'accounts/fireworks/models/qwen3-coder-480b-a35b-instruct', 262_144, fixedCostPerMilTokens(0.45, 1.8)); +} + export function fireworksLlama3_70B(): LLM { return new Fireworks('LLama3 70b-i (Fireworks)', 'accounts/fireworks/models/llama-v3p1-70b-instruct', 131_072, fixedCostPerMilTokens(0.9, 0.9)); } diff --git a/src/modules/firestore/firestoreAgentStateService.ts b/src/modules/firestore/firestoreAgentStateService.ts index b9097dbc6..01e203490 100644 --- a/src/modules/firestore/firestoreAgentStateService.ts +++ b/src/modules/firestore/firestoreAgentStateService.ts @@ -127,12 +127,11 @@ export class FirestoreAgentStateService implements AgentContextService { } @span({ agentId: 0 }) - async load(agentId: string): Promise { + async load(agentId: string): Promise { const docRef = this.db.doc(`AgentContext/${agentId}`); const docSnap: DocumentSnapshot = await docRef.get(); - if (!docSnap.exists) { - throw new NotFound(`Agent with ID ${agentId} not found.`); - } + if (!docSnap.exists) return null; + const firestoreData = docSnap.data(); if (!firestoreData) { logger.warn({ agentId }, 'Firestore document exists but data is undefined during agent context load.'); diff --git a/src/modules/slack/slackApi.ts b/src/modules/slack/slackApi.ts new file mode 100644 index 000000000..94f45751e --- /dev/null +++ b/src/modules/slack/slackApi.ts @@ -0,0 +1,212 @@ +import { ConversationsHistoryResponse, ConversationsListResponse, ConversationsRepliesResponse, WebClient } from '@slack/web-api'; +import { MessageElement } from '@slack/web-api/dist/types/response/ConversationsHistoryResponse'; +import { logger } from '#o11y/logger'; +import { envVar } from '#utils/env-var'; + +/** + * A class to interact with the Slack API, specifically for fetching conversations and messages. + */ +export class SlackAPI { + private client: WebClient; + + /** + * Constructs a new SlackAPI instance. + */ + constructor() { + const token = envVar('SLACK_BOT_TOKEN'); + this.client = new WebClient(token); + } + + async getConversationReplies(channelId: string, threadTs: string, limit = 100): Promise { + let cursor: string | undefined; + const allMessages: MessageElement[] = []; + + do { + const response = await this.client.conversations.replies({ + channel: channelId, + ts: threadTs, + cursor: cursor, + limit: 100, + }); + + allMessages.push(...response.messages); + cursor = response.response_metadata?.next_cursor; + } while (cursor); + return allMessages; + } + /** + * Fetch all messages in a user's App (Direct Message) channel. + * @param {string} channelId - The channel ID (like 'DXXX' for a Direct Message). + * @param {number} [limit=100] - Optional number of messages to fetch per page. Max is 1000. + * @returns {Promise} - A Promise resolving to an array of message objects. + */ + async getConversationHistory(channelId: string, limit = 100) { + if (!channelId) throw new Error('Channel ID is required to fetch message history'); + + const history = []; + let cursor: string | null = null; + try { + while (true) { + const response = await this.client.conversations.history({ + channel: channelId, + limit: Math.min(limit, 1000), // Slack API max is 1000 + cursor: cursor, + }); + + history.push(...response.messages); + cursor = response.response_metadata?.next_cursor || null; + if (!cursor || cursor === '') break; + } + } catch (error) { + throw new Error(`Failed to fetch history: ${error.message}`); + } + return history; + } + + /** + * Adds a reaction to a Slack message (e.g., 🤖💥 for "bot broken") + * @param channel Slack channel ID (e.g., "C1234567890") + * @param messageTimestamp Message timestamp (e.g., "1629378123.000200" from event.message.ts) + * @param reaction Emoji combo name (e.g., "robot_face::boom") + */ + async addReaction( + channel: string, + messageTimestamp: string, + reaction = 'robot_face', // Default to 🤖 + ): Promise { + try { + await this.client.reactions.add({ + channel, + timestamp: messageTimestamp, + name: reaction, // Use "::" for combo emojis (NOT spaces) + }); + logger.debug(`Reaction added: ${reaction} to ${channel} @ ${messageTimestamp}`); + } catch (error) { + logger.error(error, `Error adding Slack reaction to ${channel} @ ${messageTimestamp}`); + // Don't throw error, just log it + } + } + + /** + * Fetches all accessible conversations (public channels, private channels, DMs, MPIMs) + * and subsequently retrieves all messages posted within those conversations + * during a specific target day. + * + * @param targetDate The `Date` object representing the UTC day for which to fetch messages. + * Example: `new Date(Date.UTC(2025, 6, 23))` for July 23, 2025 UTC. + * @returns A Promise that resolves to a `Map` where keys are Slack `channelId` strings + * and values are arrays of message objects (`any[]`) found in that channel on the target day. + * Messages for channels with no activity on the target day will not be included in the Map. + * Also logs messages to the console similar to the original snippet. + */ + public async getAllConversationsOnDay(targetDate: Date): Promise> { + // Get start and end Unix timestamps for the target day in UTC + const { start, end } = getDayTimestamps( + targetDate.getUTCFullYear(), + targetDate.getUTCMonth() + 1, // getUTCMonth() is 0-indexed, but _getDayTimestamps expects 1-indexed + targetDate.getUTCDate(), + ); + + const allConversationIds: string[] = []; + let cursor: string | null = null; + + // Phase 1: Get all conversation IDs + console.log('Phase 1: Fetching all accessible conversations (channels, DMs, MPIMs)...'); + do { + try { + const result: ConversationsListResponse = await this.client.conversations.list({ + types: 'public_channel,private_channel,im,mpim', + limit: 100, // Fetch up to 100 conversations per API call (recommended max is 100) + cursor, // For pagination + }); + if (result.channels) { + result.channels.forEach((channel) => { + if (channel?.id) allConversationIds.push(channel.id); + }); + } + cursor = result.response_metadata?.next_cursor; + } catch (error) { + console.error(`Error fetching conversation list: ${error}`); + // Optionally rethrow or handle more gracefully + break; // Exit loop on error + } + } while (cursor); + console.log(`Found ${allConversationIds.length} total conversations.`); + + const conversationsMessagesMap: Map = new Map(); + + // Phase 2: For each conversation, fetch messages for the specific day + console.log('\nPhase 2: Fetching messages for each conversation on the target day...'); + for (const channelId of allConversationIds) { + const channelMessages: any[] = []; + let msgCursor: string | null = null; + do { + try { + const res: ConversationsHistoryResponse = await this.client.conversations.history({ + channel: channelId, + oldest: start.toString(), // Start of the target day + latest: end.toString(), // End of the target day + limit: 500, // Fetch up to 200 messages per API call (recommended max is 1000, 200 is safer) + inclusive: true, // Include messages exactly at `oldest` or `latest` timestamp + cursor: msgCursor, // For pagination + }); + + if (res.messages) { + channelMessages.push(...res.messages); + } + msgCursor = res.response_metadata?.next_cursor || null; + } catch (error: any) { + if (error.data?.error) { + switch (error.data.error) { + case 'channel_not_found': + console.warn(` WARNING: Channel ${channelId} not found or has been archived. Skipping.`); + break; + case 'not_in_channel': + console.warn(` WARNING: Bot is not a member of channel ${channelId}. Cannot fetch messages for it.`); + break; + case 'is_archived': + console.warn(` WARNING: Channel ${channelId} is archived. Skipping.`); + break; + case 'account_inactive': + console.warn(` WARNING: Bot token is from an inactive account. Skipping channel ${channelId}.`); + break; + default: + console.error(` ERROR fetching history for channel ${channelId}: ${error.data.error}`); + } + } else { + console.error(` An unexpected error occurred while fetching history for channel ${channelId}: ${error}`); + } + break; // Stop trying to fetch messages for this channel on error + } + } while (msgCursor); // Continue paginating until no more messages or error + + if (channelMessages.length > 0) { + console.log(` Found ${channelMessages.length} messages in channel ${channelId}.`); + // Log messages to console the same way the original snippet did + console.log(`Messages from ${channelId}:`, channelMessages); + conversationsMessagesMap.set(channelId, channelMessages); + } else { + console.log(` No messages found in channel ${channelId} for the target day.`); + } + } + console.log('\nMessage fetching complete.'); + return conversationsMessagesMap; + } +} + +/** + * Helper: Calculates the start and end Unix timestamps (in seconds) for a given UTC day. + * This is a static private method as it doesn't depend on the instance's state. + * @param year UTC year (e.g., 2025) + * @param month UTC month (1-12, e.g., 7 for July) + * @param day UTC day of month (1-31) + * @returns An object containing 'start' and 'end' Unix timestamps. + */ +function getDayTimestamps(year: number, month: number, day: number): { start: number; end: number } { + // Date.UTC expects month to be 0-indexed (0 for Jan, 11 for Dec), so we adjust month-1. + // getUTCMonth() returns 0-11, so it needs +1 before passing to this function, then -1 here. + const start = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)).getTime() / 1000; + // To include the entire last second of the day, add 999 milliseconds to 23:59:59. + const end = new Date(Date.UTC(year, month - 1, day, 23, 59, 59, 999)).getTime() / 1000; + return { start, end }; +} diff --git a/src/modules/slack/slackChatBotService.ts b/src/modules/slack/slackChatBotService.ts index a5477f864..502f0e76d 100644 --- a/src/modules/slack/slackChatBotService.ts +++ b/src/modules/slack/slackChatBotService.ts @@ -13,17 +13,30 @@ import { logger } from '#o11y/logger'; import { type AgentCompleted, type AgentContext, isExecuting } from '#shared/agent/agent.model'; import { sleep } from '#utils/async-utils'; import type { ChatBotService } from '../../chatBot/chatBotService'; +import { SlackAPI } from './slackApi'; let slackApp: App | undefined; const CHATBOT_FUNCTIONS: Array any> = [GitLab, GoogleCloud, Perplexity, LlmTools, Jira]; +/* +There's a few steps involved with spotting a thread and then understanding the context of a message within it. Let's unspool them: + +1. Detect a threaded message by looking for a thread_ts value in the message object. The existence of such a value indicates that the message is part of a thread. +2. Identify parent messages by comparing the thread_ts and ts values. If they are equal, the message is a parent message. +3. Threaded replies are also identified by comparing the thread_ts and ts values. If they are different, the message is a reply. + +One quirk of threaded messages is that a parent message object will retain a thread_ts value, even if all its replies have been deleted. +*/ + /** * Slack implementation of ChatBotService * Only one Slack workspace can be configured in the application as the Slack App is shared between all instances of this class. */ export class SlackChatBotService implements ChatBotService, AgentCompleted { channels: Set = new Set(); + appChannel = ''; + slackApi: SlackAPI; threadId(agent: AgentContext): string { return agent.agentId.replace('Slack-', ''); @@ -62,19 +75,23 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { async sendMessage(agent: AgentContext, message: string): Promise { if (!slackApp) throw new Error('Slack app is not initialized. Call initSlack() first.'); - logger.info(`Sending slack message: ${message}`); - const threadId = this.threadId(agent); + const params: any = { + channel: agent.metadata.channel, + text: message, + thread_ts: agent.metadata.thread_ts, + }; + + /* Only add thread_ts if we’re in a real thread. + - In a channel: event.thread_ts is set for replies + - In the App DM: event.thread_ts is undefined */ + // if (agent.metadata.thread_ts) { + // params.thread_ts = agent.metadata.thread_ts; + // } try { - const result = await slackApp.client.chat.postMessage({ - text: message, - thread_ts: threadId, - channel: agent.metadata.channel, - }); + const result = await slackApp.client.chat.postMessage(params); - if (!result.ok) { - throw new Error(`Failed to send message to Slack: ${result.error}`); - } + if (!result.ok) throw new Error(`Failed to send message to Slack: ${result.error}`); } catch (error) { logger.error(error, 'Error sending message to Slack'); throw error; @@ -86,6 +103,7 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { const botToken = process.env.SLACK_BOT_TOKEN; const signingSecret = process.env.SLACK_SIGNING_SECRET; + this.appChannel = process.env.SLACK_APP_CHANNEL; const channels = process.env.SLACK_CHANNELS; const appToken = process.env.SLACK_APP_TOKEN; @@ -93,6 +111,8 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { logger.error('Slack chatbot requires environment variables SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, SLACK_APP_TOKEN and SLACK_CHANNELS'); } + this.slackApi = new SlackAPI(); + // Initializes your app with your bot token and signing secret slackApp = new App({ token: botToken, @@ -101,7 +121,7 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { appToken: appToken, }); - this.channels = new Set(channels.split(',').map((s) => s.trim())); + this.channels = new Set([this.appChannel, ...channels.split(',').map((s) => s.trim())]); // Listen for messages in channels slackApp.event('message', async ({ event, say }) => { @@ -125,26 +145,74 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { // biomejs formatter changes event['property'] to event.property which doesn't compile const _event: any = event; console.log('Event received for message'); - logger.info(event); + console.log('== BEGIN EVENT =='); + console.log(JSON.stringify(event)); + console.log('== END EVENT =='); logger.info(`channel_type: ${event.channel_type}`); // logger.info(await (say['message'])) const _say: SayFn = say; // if (event.channel_type === 'im') if (event.subtype === 'message_deleted') return; + if (event.subtype === 'message_changed') return; if (event.subtype === 'channel_join') return; + if (event.subtype) console.log(`Event subtype: ${event.subtype}`); // Check if the message is in the desired channel - if (!this.channels.has(event.channel)) { + if (!this.channels.has(event.channel) && event.channel_type !== 'im') { logger.info(`Channel ${event.channel} not configured`); return; } + console.log(`Message received in channel: ${_event.text}`); const agentService = appContext().agentStateService; - // Messages with the app under the Apps section has different properties than messages from a regular channel - if (event.channel === 'D08HGB1HF61') { + // Messages with the app under the Apps section has different properties than messages from a regular channel? + if (event.channel === this.appChannel) { + const threadTs = (event as any).thread_ts; + const newThread = event.ts === threadTs; + let conversationHistory = ''; + + if (!newThread) { + const threadMessages = await new SlackAPI().getConversationReplies(event.channel, threadTs); + conversationHistory = `You are the bot and will be responding to the user.\n${threadMessages.map((message) => { + const tagName = message.bot_profile ? 'bot' : 'user'; + return `<${tagName}>\n${message.text}\n\n`; + })}\n\n`; + } + + try { + const agentExec = await startAgent({ + type: 'autonomous', + subtype: 'codegen', + resumeAgentId: 'Slack-app', + initialPrompt: conversationHistory + _event.text, + llms: defaultLLMs(), + functions: CHATBOT_FUNCTIONS, + agentName: 'Slack-app', + systemPrompt: + 'You are an AI support agent. You are responding to support requests on the company Slack account. Respond in a helpful, concise manner. If you encounter an error responding to the request do not provide details of the error to the user, only respond with "Sorry, I\'m having difficulties providing a response to your request"', + metadata: { channel: event.channel, thread_ts: event.ts }, + completedHandler: this, + humanInLoop: { + budget: 0.5, + count: 5, + }, + }); + await agentExec.execution; + const agent: AgentContext = await appContext().agentStateService.load(agentExec.agentId); + if (agent.state !== 'completed' && agent.state !== 'hitl_feedback') { + logger.error(`Agent did not complete. State was ${agent.state}`); + + await this.slackApi.addReaction(event.channel, event.ts, 'robot_face::boom'); + + return; + } + } catch (e) { + logger.error(e, 'Error handling new Slack app thread'); + } + return; } // In regular channels if the message is not a reply in a thread, then we will start a new agent to handle the first message in the thread @@ -177,7 +245,7 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { functions: CHATBOT_FUNCTIONS, agentName: `Slack-${threadId}`, systemPrompt: - 'You are an AI support agent called TypedAI. You are responding to support requests on the company Slack account. Respond in a helpful, concise manner. If you encounter an error responding to the request do not provide details of the error to the user, only respond with "Sorry, I\'m having difficulties providing a response to your request"', + 'You are an AI support agent. You are responding to support requests on the company Slack account. Respond in a helpful, concise manner. If you encounter an error responding to the request do not provide details of the error to the user, only respond with "Sorry, I\'m having difficulties providing a response to your request"', metadata: { channel: event.channel }, completedHandler: this, humanInLoop: { @@ -191,6 +259,7 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { logger.error(`Agent did not complete. State was ${agent.state}`); return; } + return; // Agent completionHandler sends the message // const response = agent.functionCallHistory.at(-1).parameters[agent.state === 'completed' ? AGENT_COMPLETED_PARAM_NAME : REQUEST_FEEDBACK_PARAM_NAME]; // const sayResult = await say({ @@ -208,10 +277,10 @@ export class SlackChatBotService implements ChatBotService, AgentCompleted { // Otherwise this is a reply to a thread const agentId = `Slack-${_event.thread_ts}`; const agent: AgentContext | null = await agentService.load(agentId); - // Getting a null agent when a conversation is started in the TG AI channel - if (isExecuting(agent)) { + // Getting a null agent when a conversation is started in the App channel - handle in the app specific code + if (agent && isExecuting(agent)) { // TODO make this transactional, and implement - agent.pendingMessages.push(); + agent.pendingMessages.push(_event.text); await agentService.save(agent); return; } diff --git a/src/swe/discovery/selectFilesAgentWithSearch.ts b/src/swe/discovery/selectFilesAgentWithSearch.ts index 1d9572489..0deadd4ec 100644 --- a/src/swe/discovery/selectFilesAgentWithSearch.ts +++ b/src/swe/discovery/selectFilesAgentWithSearch.ts @@ -402,10 +402,11 @@ Respond with a valid JSON object that follows the required schema.`, } async function initializeFileSelectionAgent(requirements: UserContentExt, opts: QueryOptions): Promise { - let projectInfo = opts.projectInfo; - projectInfo ??= (await getProjectInfos())[0]; + const projectInfo: ProjectInfo | null = opts.projectInfo; + const projectInfos: ProjectInfo[] | null = await getProjectInfos(false); + const generateArg: ProjectInfo[] = projectInfo ? [projectInfo] : projectInfos || []; - const projectMaps: RepositoryMaps = await generateRepositoryMaps([projectInfo]); + const projectMaps: RepositoryMaps = await generateRepositoryMaps(generateArg); const repositoryOverview: string = await getRepositoryOverview(); const fileSystemWithSummaries: string = `\n${projectMaps.fileSystemTreeWithFileSummaries.text}\n\n`; const repoOutlineUserPrompt = `${repositoryOverview}${fileSystemWithSummaries}`; diff --git a/src/swe/projectDetection.ts b/src/swe/projectDetection.ts index e959cbc64..a22443034 100644 --- a/src/swe/projectDetection.ts +++ b/src/swe/projectDetection.ts @@ -194,7 +194,7 @@ async function findUpwards(startDir: string, file: string, fss: IFileSystemServi * If no valid file is found, it runs detection via projectDetectionAgent and saves the result to CWD. * Invalid files are renamed to avoid re-parsing them in a loop. */ -export async function getProjectInfos(autoDetect = true): Promise { +export async function getProjectInfos(autoDetect = true): Promise { logger.debug('Starting project detection process.'); const fss = getFileSystem(); // Always access the file relative to the current working directory diff --git a/variables/local.env.example b/variables/local.env.example index 89270c08d..8fa090343 100644 --- a/variables/local.env.example +++ b/variables/local.env.example @@ -68,6 +68,8 @@ SERP_API_KEY= SLACK_BOT_TOKEN= SLACK_SIGNING_SECRET= -# Ensure that your bot is invited to the channel(s) you want to listen to. This is necessary for the bot to receive events from that channel. +# The channel specific for your Slack App +SLACK_APP_CHANNEL= +# Ensure that your bot is invited to the channel(s) you want to listen to. This is necessary for the bot to receive events from that channel. Comma seperate multiple channel ids SLACK_CHANNELS= SLACK_APP_TOKEN= diff --git a/variables/test.env b/variables/test.env index 7eee5176d..4445afe2d 100644 --- a/variables/test.env +++ b/variables/test.env @@ -74,6 +74,8 @@ SERP_API_KEY= SLACK_BOT_TOKEN= SLACK_SIGNING_SECRET= +# The channel specific for your Slack App +SLACK_APP_CHANNEL= # Ensure that your bot is invited to the channel(s) you want to listen to. This is necessary for the bot to receive events from that channel. SLACK_CHANNELS= SLACK_APP_TOKEN= From 078d4f15cc3954c50bef99efae69af83237473fa Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 3 Aug 2025 13:06:30 +0800 Subject: [PATCH 2/7] Update Cerebras models --- src/cli/cli.ts | 5 +++-- src/llm/multi-agent/blueberry.ts | 4 ++-- src/llm/multi-agent/cepo.ts | 4 ++-- src/llm/multi-agent/fastMedium.ts | 4 ++-- src/llm/services/cerebras.ts | 32 ++++++++++++++++++++++--------- src/llm/services/defaultLlms.ts | 4 ++-- src/llm/services/llm.int.ts | 4 ++-- 7 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 9ec1abb6f..c626bcff6 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -4,7 +4,7 @@ import { systemDir } from '#app/appDirs'; import { FastMediumLLM } from '#llm/multi-agent/fastMedium'; import { MAD_Balanced, MAD_Fast, MAD_SOTA } from '#llm/multi-agent/reasoning-debate'; import { Claude4_Opus_Vertex } from '#llm/services/anthropic-vertex'; -import { cerebrasQwen3_235b } from '#llm/services/cerebras'; +import { cerebrasQwen3_235b_Thinking, cerebrasQwen3_Coder } from '#llm/services/cerebras'; import { defaultLLMs } from '#llm/services/defaultLlms'; import { openAIo3 } from '#llm/services/openai'; import { perplexityDeepResearchLLM, perplexityLLM, perplexityReasoningProLLM } from '#llm/services/perplexity-llm'; @@ -18,7 +18,8 @@ export const LLM_CLI_ALIAS: Record LLM> = { h: () => defaultLLMs().hard, xh: () => defaultLLMs().xhard, fm: () => new FastMediumLLM(), - f: cerebrasQwen3_235b, + f: cerebrasQwen3_235b_Thinking, + cc: cerebrasQwen3_Coder, x: xai_Grok4, o3: openAIo3, madb: MAD_Balanced, diff --git a/src/llm/multi-agent/blueberry.ts b/src/llm/multi-agent/blueberry.ts index 44a721666..4a6b47d54 100644 --- a/src/llm/multi-agent/blueberry.ts +++ b/src/llm/multi-agent/blueberry.ts @@ -1,6 +1,6 @@ import { BaseLLM } from '#llm/base-llm'; import { getLLM } from '#llm/llmFactory'; -import { cerebrasQwen3_235b } from '#llm/services/cerebras'; +import { cerebrasQwen3_235b_Thinking } from '#llm/services/cerebras'; import { vertexGemini_2_5_Flash } from '#llm/services/vertexai'; import { logger } from '#o11y/logger'; import type { GenerateTextOptions, LLM } from '#shared/llm/llm.model'; @@ -101,7 +101,7 @@ export class Blueberry extends BaseLLM { } } // if (!this.llms) this.llms = [Claude3_5_Sonnet_Vertex(), GPT4o(), Gemini_1_5_Pro(), Claude3_5_Sonnet_Vertex(), fireworksLlama3_405B()]; - let llm = cerebrasQwen3_235b(); + let llm = cerebrasQwen3_235b_Thinking(); // llm = groqLlama3_1_70B(); llm = vertexGemini_2_5_Flash(); if (!this.llms) this.llms = [llm, llm, llm, llm, llm]; diff --git a/src/llm/multi-agent/cepo.ts b/src/llm/multi-agent/cepo.ts index 3e1fa80df..047ab1c76 100644 --- a/src/llm/multi-agent/cepo.ts +++ b/src/llm/multi-agent/cepo.ts @@ -1,5 +1,5 @@ import { BaseLLM } from '#llm/base-llm'; -import { cerebrasQwen3_32b, cerebrasQwen3_235b } from '#llm/services/cerebras'; +import { cerebrasQwen3_32b, cerebrasQwen3_235b_Thinking } from '#llm/services/cerebras'; import { logger } from '#o11y/logger'; import { withActiveSpan } from '#o11y/trace'; import { type GenerateTextOptions, type LLM, type LlmMessage, assistant, lastText, user } from '#shared/llm/llm.model'; @@ -73,7 +73,7 @@ const sotaConfig: CePOConfig = { // https://github.com/codelion/optillm/blob/main/optillm/cepo/README.md -export function CePO_FastMedium(llmProvider: () => LLM = () => cerebrasQwen3_235b(), name?: string): LLM { +export function CePO_FastMedium(llmProvider: () => LLM = () => cerebrasQwen3_235b_Thinking(), name?: string): LLM { return new CePO_LLM(() => new FastMediumLLM(), 'CePO (FastMedium)', limitedConfig); } diff --git a/src/llm/multi-agent/fastMedium.ts b/src/llm/multi-agent/fastMedium.ts index fb4b44eeb..20a280986 100644 --- a/src/llm/multi-agent/fastMedium.ts +++ b/src/llm/multi-agent/fastMedium.ts @@ -1,4 +1,4 @@ -import { cerebrasQwen3_235b } from '#llm/services/cerebras'; +import { cerebrasQwen3_235b_Thinking } from '#llm/services/cerebras'; import { vertexGemini_2_5_Flash } from '#llm/services/vertexai'; import { countTokens } from '#llm/tokens'; import { logger } from '#o11y/logger'; @@ -20,7 +20,7 @@ export class FastMediumLLM extends BaseLLM { outputCost: 0, totalCost: 0, })); - this.providers = [cerebrasQwen3_235b(), vertexGemini_2_5_Flash({ thinking: 'high' })]; + this.providers = [cerebrasQwen3_235b_Thinking(), vertexGemini_2_5_Flash({ thinking: 'high' })]; this.cerebras = this.providers[0]; this.gemini = this.providers[1]; diff --git a/src/llm/services/cerebras.ts b/src/llm/services/cerebras.ts index 73e5d64f6..5abe10331 100644 --- a/src/llm/services/cerebras.ts +++ b/src/llm/services/cerebras.ts @@ -11,22 +11,36 @@ export const CEREBRAS_SERVICE = 'cerebras'; export function cerebrasLLMRegistry(): Record LLM> { return { 'cerebras:qwen-3-32b': () => cerebrasQwen3_32b(), - 'cerebras:qwen-3-235b-a22b': () => cerebrasQwen3_235b(), - 'cerebras:llama3.1-8b': () => cerebrasLlama3_8b(), + 'cerebras:qwen-3-235b-instruct-2507': () => cerebrasQwen3_235b_Instruct(), + 'cerebras:qwen-3-235b-thinking-2507': () => cerebrasQwen3_235b_Thinking(), + 'cerebras:qwen-3-coder-480b': () => cerebrasQwen3_Coder(), + 'cerebras:llama-4-maverick-17b-128e-instruct': () => cerebrasLlamaMaverick(), }; } +// https://inference-docs.cerebras.ai/models/qwen-3-32b export function cerebrasQwen3_32b(): LLM { return new CerebrasLLM('Qwen3 32b (Cerebras)', 'qwen-3-32b', 16_382, fixedCostPerMilTokens(0.4, 0.8)); } -// https://inference-docs.cerebras.ai/models/qwen-3-235b -export function cerebrasQwen3_235b(): LLM { - return new CerebrasLLM('Qwen3 235b (Cerebras)', 'qwen-3-235b-a22b', 131_000, fixedCostPerMilTokens(0.6, 1.2)); +// https://inference-docs.cerebras.ai/models/qwen-3-235b-2507 +export function cerebrasQwen3_235b_Instruct(): LLM { + return new CerebrasLLM('Qwen3 235b Instruct (Cerebras)', 'qwen-3-235b-a22b-instruct-2507', 131_000, fixedCostPerMilTokens(0.6, 1.2)); } -export function cerebrasLlama3_8b(): LLM { - return new CerebrasLLM('Llama 3.1 8b (Cerebras)', 'llama3.1-8b', 8_192, fixedCostPerMilTokens(0.1, 0.1)); +// https://inference-docs.cerebras.ai/models/qwen-3-235b-thinking +export function cerebrasQwen3_235b_Thinking(): LLM { + return new CerebrasLLM('Qwen3 235b Thinking (Cerebras)', 'qwen-3-235b-a22b-thinking-2507', 131_000, fixedCostPerMilTokens(0.6, 1.2), ['qwen-3-235b-a22b']); +} + +// https://inference-docs.cerebras.ai/models/qwen-3-480b +export function cerebrasQwen3_Coder(): LLM { + return new CerebrasLLM('Qwen3 Coder (Cerebras)', 'qwen-3-coder-480b', 131_000, fixedCostPerMilTokens(2, 2), ['qwen-3-235b-a22b']); +} + +// https://inference-docs.cerebras.ai/models/llama-4-maverick +export function cerebrasLlamaMaverick(): LLM { + return new CerebrasLLM('Llama Maverick (Cerebras)', 'llama-4-maverick-17b-128e-instruct', 32_000, fixedCostPerMilTokens(0.2, 0.6), ['llama3.1-8b']); } const CEREBRAS_KEYS: string[] = []; @@ -42,8 +56,8 @@ let cerebrasKeyIndex = 0; * https://inference-docs.cerebras.ai/introduction */ export class CerebrasLLM extends AiLLM { - constructor(displayName: string, model: string, maxInputTokens: number, calculateCosts: LlmCostFunction) { - super(displayName, CEREBRAS_SERVICE, model, maxInputTokens, calculateCosts); + constructor(displayName: string, model: string, maxInputTokens: number, calculateCosts: LlmCostFunction, oldModelIds?: string[]) { + super(displayName, CEREBRAS_SERVICE, model, maxInputTokens, calculateCosts, oldModelIds); } aiModel(): LanguageModelV1 { diff --git a/src/llm/services/defaultLlms.ts b/src/llm/services/defaultLlms.ts index 8deb50ad2..a11541dee 100644 --- a/src/llm/services/defaultLlms.ts +++ b/src/llm/services/defaultLlms.ts @@ -7,7 +7,7 @@ import { vertexGemini_2_5_Flash, vertexGemini_2_5_Pro } from '#llm/services/vert import { logger } from '#o11y/logger'; import type { AgentLLMs } from '#shared/agent/agent.model'; import type { LLM } from '#shared/llm/llm.model'; -import { cerebrasQwen3_235b } from './cerebras'; +import { cerebrasQwen3_235b_Thinking } from './cerebras'; import { Gemini_2_5_Flash, Gemini_2_5_Pro } from './gemini'; import { groqLlama4_Scout } from './groq'; import { Ollama_LLMs } from './ollama'; @@ -35,7 +35,7 @@ export function defaultLLMs(): AgentLLMs { const easy: LLM | undefined = easyLLMs.find((llm) => llm.isConfigured()); if (!easy) throw new Error('No default easy LLM configured'); - const mediumLLMs = [new FastMediumLLM(), vertexGemini_2_5_Flash(), Gemini_2_5_Flash(), cerebrasQwen3_235b(), openaiGPT41(), Claude3_5_Haiku()]; + const mediumLLMs = [new FastMediumLLM(), vertexGemini_2_5_Flash(), Gemini_2_5_Flash(), cerebrasQwen3_235b_Thinking(), openaiGPT41(), Claude3_5_Haiku()]; const medium: LLM | undefined = mediumLLMs.find((llm) => llm.isConfigured()); if (!medium) throw new Error('No default medium LLM configured'); diff --git a/src/llm/services/llm.int.ts b/src/llm/services/llm.int.ts index 51b6b0a35..58417b348 100644 --- a/src/llm/services/llm.int.ts +++ b/src/llm/services/llm.int.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import { expect } from 'chai'; import { Claude4_Sonnet_Vertex } from '#llm/services/anthropic-vertex'; -import { cerebrasLlama3_8b } from '#llm/services/cerebras'; import { deepinfraDeepSeekR1, deepinfraQwen3_235B_A22B } from '#llm/services/deepinfra'; import { deepSeekV3 } from '#llm/services/deepseek'; import { fireworksLlama3_70B } from '#llm/services/fireworks'; @@ -15,6 +14,7 @@ import { vertexGemini_2_0_Flash_Lite, vertexGemini_2_5_Flash, vertexGemini_2_5_F import type { LlmMessage } from '#shared/llm/llm.model'; import { setupConditionalLoggerOutput } from '#test/testUtils'; import { anthropicClaude4_Sonnet } from './anthropic'; +import { cerebrasQwen3_235b_Instruct } from './cerebras'; import { groqQwen3_32b } from './groq'; const elephantBase64 = fs.readFileSync('test/llm/purple.jpg', 'base64'); @@ -129,7 +129,7 @@ describe('LLMs', () => { }); describe('Cerebras', () => { - const llm = cerebrasLlama3_8b(); + const llm = cerebrasQwen3_235b_Instruct(); it('should generateText', async () => { const response = await llm.generateText(SKY_PROMPT, { temperature: 0, id: 'test' }); From bf44127b9553037151f76cde657afa216bc34690 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 3 Aug 2025 13:12:34 +0800 Subject: [PATCH 3/7] Include token count in live files. Consolidate memory functions --- shared/files/fileSystemService.ts | 5 +++-- src/agent/agentPromptUtils.ts | 10 +++++----- .../codegen/codeGenAgentRunner.test.ts | 4 ++-- src/functions/scm/git.ts | 4 +++- src/functions/storage/fileSystemService.ts | 16 +++++++++------- src/swe/lang/php/phpTools.ts | 2 +- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/shared/files/fileSystemService.ts b/shared/files/fileSystemService.ts index a3bd8d2d5..2c5c04eb6 100644 --- a/shared/files/fileSystemService.ts +++ b/shared/files/fileSystemService.ts @@ -125,11 +125,12 @@ export interface IFileSystemService { /** * Gets the contents of a list of files, returning a formatted XML string of all file contents * @param {Array} filePaths The files paths to read the contents of + * @param {boolean} includeTokenCount Include the token count as an attribute * @returns {Promise} the contents of the file(s) in format file1 contentsfile2 contents */ - readFilesAsXml(filePaths: string | string[]): Promise; + readFilesAsXml(filePaths: string | string[], includeTokenCount?: boolean): Promise; - formatFileContentsAsXml(fileContents: Map): string; + formatFileContentsAsXml(fileContents: Map, includeTokenCount?: boolean): Promise; /** * Check if a file exists. A filePath starts with / is it relative to FileSystem.basePath, otherwise its relative to FileSystem.workingDirectory diff --git a/src/agent/agentPromptUtils.ts b/src/agent/agentPromptUtils.ts index 9ccdd6644..1bac740e5 100644 --- a/src/agent/agentPromptUtils.ts +++ b/src/agent/agentPromptUtils.ts @@ -53,11 +53,11 @@ export async function buildFileSystemTreePrompt(): Promise { // focusing on folder structure and collapsed state. const treeString = await generateFileSystemTreeWithSummaries(summaries, false, collapsedFolders); - if (!treeString.trim()) { - return '\n\n\n\n'; - } + if (!treeString.trim()) return '\n\n\n\n'; + + const tokens = await countTokens(treeString); - return `\n + return `\n ${treeString} \n\n${collapsedFolders.join('\n')}\n`; } catch (error) { @@ -119,7 +119,7 @@ async function buildLiveFilesPrompt(): Promise { } return `\n${rulesFilesPrompt} -${await getFileSystem().readFilesAsXml(liveFiles)} +${await getFileSystem().readFilesAsXml(liveFiles, true)} `; } diff --git a/src/agent/autonomous/codegen/codeGenAgentRunner.test.ts b/src/agent/autonomous/codegen/codeGenAgentRunner.test.ts index aaf710768..503f359b6 100644 --- a/src/agent/autonomous/codegen/codeGenAgentRunner.test.ts +++ b/src/agent/autonomous/codegen/codeGenAgentRunner.test.ts @@ -11,7 +11,7 @@ import { } from '#agent/autonomous/autonomousAgentRunner'; import { convertTypeScriptToPython } from '#agent/autonomous/codegen/pythonCodeGenUtils'; import { AGENT_REQUEST_FEEDBACK, AgentFeedback } from '#agent/autonomous/functions/agentFeedback'; -import { AGENT_COMPLETED_NAME, AGENT_SAVE_MEMORY } from '#agent/autonomous/functions/agentFunctions'; +import { AGENT_COMPLETED_NAME, AGENT_MEMORY } from '#agent/autonomous/functions/agentFunctions'; import { appContext, initInMemoryApplicationContext } from '#app/applicationContext'; import { TEST_FUNC_NOOP, TEST_FUNC_SKY_COLOUR, TEST_FUNC_SUM, TEST_FUNC_THROW_ERROR, TestFunctions } from '#functions/testFunctions'; import { MockLLM, mockLLM, mockLLMs } from '#llm/services/mock-llm'; @@ -30,7 +30,7 @@ const PY_TEST_FUNC_NOOP = `await ${TEST_FUNC_NOOP}()`; const PY_TEST_FUNC_SKY_COLOUR = `await ${TEST_FUNC_SKY_COLOUR}()`; const PY_TEST_FUNC_SUM = (num1, num2) => `await ${TEST_FUNC_SUM}(${num1}, ${num2})`; const PY_TEST_FUNC_THROW_ERROR = `await ${TEST_FUNC_THROW_ERROR}()`; -const PY_SET_MEMORY = (key, content) => `await ${AGENT_SAVE_MEMORY}("${key}", "${content}")`; +const PY_SET_MEMORY = (key, content) => `await ${AGENT_MEMORY}("SAVE", "${key}", "${content}")`; const PYTHON_CODE_PLAN = (pythonCode: string) => `\nRun some code\n${pythonCode}\n`; const REQUEST_FEEDBACK_FUNCTION_CALL_PLAN = (feedback) => diff --git a/src/functions/scm/git.ts b/src/functions/scm/git.ts index 4e36796ec..9bf1f5883 100644 --- a/src/functions/scm/git.ts +++ b/src/functions/scm/git.ts @@ -2,7 +2,7 @@ import { getFileSystem } from '#agent/agentContextLocalStorage'; import { func, funcClass } from '#functionSchema/functionDecorators'; import { logger } from '#o11y/logger'; import { span } from '#o11y/trace'; -import { arg, execCmd, execCommand, failOnError } from '#utils/exec'; +import { arg, execCmd, execCommand, failOnError, formatAnsiWithMarkdownLinks } from '#utils/exec'; import type { IFileSystemService } from '#shared/files/fileSystemService'; import type { Commit, VersionControlSystem } from '#shared/scm/versionControlSystem'; @@ -74,6 +74,8 @@ export class Git implements VersionControlSystem { // The fix is to execute a specific commit command that targets only the added files. const commitResult = await execCommand(`git commit -m ${arg(commitMessage)} -- ${filesToAdd}`); + // Pre-commit hooks make call lint/commit commands with + commitResult.stdout = formatAnsiWithMarkdownLinks(commitResult.stdout); failOnError(`Failed to commit changes for files: ${files.join(', ')}`, commitResult); } diff --git a/src/functions/storage/fileSystemService.ts b/src/functions/storage/fileSystemService.ts index 6d9a81ef3..076940022 100644 --- a/src/functions/storage/fileSystemService.ts +++ b/src/functions/storage/fileSystemService.ts @@ -9,6 +9,7 @@ import { TYPEDAI_FS } from '#app/appDirs'; import { parseArrayParameterValue } from '#functionSchema/functionUtils'; import { LlmTools } from '#functions/llmTools'; import { Git } from '#functions/scm/git'; +import { countTokens } from '#llm/tokens'; import { logger } from '#o11y/logger'; import { getActiveSpan } from '#o11y/trace'; import { FileNotFound } from '#shared/errors'; @@ -479,23 +480,24 @@ export class FileSystemService implements IFileSystemService { /** * Gets the contents of a list of files, returning a formatted XML string of all file contents - * @param {Array} filePaths The files paths to read the contents of + * @param {Array} filePaths The files paths to read the contents of * @returns {Promise} the contents of the file(s) in format file1 contentsfile2 contents */ - async readFilesAsXml(filePaths: string | string[]): Promise { + async readFilesAsXml(filePaths: string | string[], includeTokenCount = false): Promise { if (!Array.isArray(filePaths)) { filePaths = parseArrayParameterValue(filePaths); } const fileContents: Map = await this.readFiles(filePaths); - return this.formatFileContentsAsXml(fileContents); + return this.formatFileContentsAsXml(fileContents, includeTokenCount); } - formatFileContentsAsXml(fileContents: Map): string { + async formatFileContentsAsXml(fileContents: Map, includeTokenCount = false): Promise { let result = ''; - fileContents.forEach((contents, path) => { - result += `${formatXmlContent(contents)}\n`; - }); + for (const [path, contents] of fileContents) { + const tokens = includeTokenCount ? ` tokens="${await countTokens(contents)}"` : ''; + result += `${formatXmlContent(contents)}\n`; + } return result; } diff --git a/src/swe/lang/php/phpTools.ts b/src/swe/lang/php/phpTools.ts index 8ee4b3d14..84b0bc03e 100644 --- a/src/swe/lang/php/phpTools.ts +++ b/src/swe/lang/php/phpTools.ts @@ -8,7 +8,7 @@ export class PhpTools implements LanguageTools { */ @func() async generateProjectMap(): Promise { - throw new Error('Not implemented'); + return ''; } async installPackage(packageName: string): Promise {} From d4fa250aea0613f0016b8eafd3b4e400427c21c7 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 3 Aug 2025 13:28:40 +0800 Subject: [PATCH 4/7] Update agent service --- .../agentContextService.test.ts | 42 +++++++++---------- .../firestore/firestoreAgentStateService.ts | 12 ++++-- .../postgres/postgresAgentStateService.ts | 26 ++++++------ 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/agent/agentContextService/agentContextService.test.ts b/src/agent/agentContextService/agentContextService.test.ts index c384070a2..8dfe5b633 100644 --- a/src/agent/agentContextService/agentContextService.test.ts +++ b/src/agent/agentContextService/agentContextService.test.ts @@ -346,13 +346,11 @@ export function runAgentStateServiceTests( expect(loadedContext.lastUpdate).to.be.greaterThan(savedTime1); }); - // Modified test to expect NotFound error - it('should throw NotFound when loading a non-existent agent', async () => { + it('should return null when loading a non-existent agent', async () => { const id = agentId(); - await expect(service.load(id)).to.be.rejectedWith(NotFound); + expect(await service.load(id)).to.be.null; }); - // Added test for NotAllowed error it('should throw NotAllowed when trying to load an agent belonging to another user', async () => { const idForOtherUser = agentId(); setCurrentUser(otherUser); @@ -394,8 +392,8 @@ export function runAgentStateServiceTests( // Expect the save operation to be rejected await expect(service.save(childContext)).to.be.rejected; - // Verify the child was not saved due to the rejection (load should throw NotFound) - await expect(service.load(childId)).to.be.rejectedWith(NotFound); + // Verify the child was not saved due to the rejection + expect(await service.load(childId)).to.be.null; }); }); @@ -607,17 +605,17 @@ export function runAgentStateServiceTests( setCurrentUser(testUser); // Ensure correct user context await service.delete([agentIdCompleted, agentIdError]); - // Verify the specified agents are deleted (load should now throw NotFound) - await expect(service.load(agentIdCompleted)).to.be.rejectedWith(NotFound); - await expect(service.load(agentIdError)).to.be.rejectedWith(NotFound); + // Verify the specified agents are deleted + expect(await service.load(agentIdCompleted)).to.be.null; + expect(await service.load(agentIdError)).to.be.null; }); it('should NOT delete agents belonging to other users', async () => { setCurrentUser(testUser); // Ensure correct user context await service.delete([agentIdCompleted, otherUserAgentId]); - // Verify testUser's agent is deleted (load should now throw NotFound) - await expect(service.load(agentIdCompleted)).to.be.rejectedWith(NotFound); + // Verify testUser's agent is deleted + expect(await service.load(agentIdCompleted)).to.be.null; // Verify otherUser's agent is NOT deleted (load should now throw NotAllowed) await expect(service.load(otherUserAgentId)).to.be.rejectedWith(NotAllowed); }); @@ -626,8 +624,8 @@ export function runAgentStateServiceTests( setCurrentUser(testUser); // Ensure correct user context await service.delete([agentIdCompleted, executingAgentId]); - // Verify the non-executing agent is deleted (load should now throw NotFound) - await expect(service.load(agentIdCompleted)).to.be.rejectedWith(NotFound); + // Verify the non-executing agent is deleted + expect(await service.load(agentIdCompleted)).to.be.null; // Verify the executing agent is NOT deleted (load should NOT throw NotFound, but should return the agent) // Note: The delete logic filters out executing agents *before* attempting deletion. // So, loading the executing agent after the delete call should still succeed. @@ -636,15 +634,15 @@ export function runAgentStateServiceTests( expect(executingAgentAfterDelete.agentId).to.equal(executingAgentId); }); - it('should delete a parent agent AND its children when parent ID is provided (if parent is deletable)', async () => { + it('should delete a parent agent and its children when parent ID is provided (if parent is deletable)', async () => { setCurrentUser(testUser); // Ensure correct user context // Delete the parent (which is in 'completed' state) await service.delete([parentIdCompleted]); - // Verify parent and all children are deleted (load should now throw NotFound) - await expect(service.load(parentIdCompleted)).to.be.rejectedWith(NotFound); - await expect(service.load(childId1)).to.be.rejectedWith(NotFound); - await expect(service.load(childId2)).to.be.rejectedWith(NotFound); + // Verify parent and all children are deleted + expect(await service.load(parentIdCompleted)).to.be.null; + expect(await service.load(childId1)).to.be.null; + expect(await service.load(childId2)).to.be.null; }); it('should NOT delete child agents if only child ID is provided (due to implementation filter)', async () => { @@ -670,15 +668,15 @@ export function runAgentStateServiceTests( it('should handle non-existent IDs gracefully without error', async () => { const nonExistentId = agentId(); - setCurrentUser(testUser); // Ensure correct user context + setCurrentUser(testUser); // Attempt to delete an existing deletable agent and a non-existent one await expect(service.delete([agentIdCompleted, nonExistentId])).to.not.be.rejected; - // Verify the existing deletable agent was actually deleted (load should now throw NotFound) - await expect(service.load(agentIdCompleted)).to.be.rejectedWith(NotFound); + // Verify the existing deletable agent was actually deleted + expect(await service.load(agentIdCompleted)).to.be.null; // Verify the non-existent ID still results in NotFound on load - await expect(service.load(nonExistentId)).to.be.rejectedWith(NotFound); + expect(await service.load(nonExistentId)).to.be.null; }); }); diff --git a/src/modules/firestore/firestoreAgentStateService.ts b/src/modules/firestore/firestoreAgentStateService.ts index 01e203490..c7929ab8f 100644 --- a/src/modules/firestore/firestoreAgentStateService.ts +++ b/src/modules/firestore/firestoreAgentStateService.ts @@ -308,7 +308,8 @@ export class FirestoreAgentStateService implements AgentContextService { async updateFunctions(agentId: string, functions: string[]): Promise { // Load the agent first to check existence and ownership - const agent = await this.load(agentId); // This will throw NotFound or NotAllowed if necessary + const agent = await this.load(agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${agentId} not found.`); // Agent is guaranteed to exist and be owned by the current user here @@ -403,7 +404,8 @@ export class FirestoreAgentStateService implements AgentContextService { @span() async loadIterations(agentId: string): Promise { // Load the agent first to check existence and ownership - await this.load(agentId); // This will throw NotFound or NotAllowed if necessary + const agent = await this.load(agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${agentId} not found.`); const iterationsColRef = this.db.collection('AgentContext').doc(agentId).collection('iterations'); // Order by the document ID (which is the iteration number as a string) @@ -454,7 +456,8 @@ export class FirestoreAgentStateService implements AgentContextService { @span() async getAgentIterationSummaries(agentId: string): Promise { // Load the agent first to check existence and ownership - await this.load(agentId); // This will throw NotFound or NotAllowed if necessary + const agent = await this.load(agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${agentId} not found.`); const iterationsColRef = this.db.collection('AgentContext').doc(agentId).collection('iterations'); // Select only the fields needed for the summary. @@ -488,7 +491,8 @@ export class FirestoreAgentStateService implements AgentContextService { @span() async getAgentIterationDetail(agentId: string, iterationNumber: number): Promise { // Load the agent first to check existence and ownership - await this.load(agentId); // This will throw NotFound or NotAllowed if necessary + const agent = await this.load(agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${agentId} not found.`); const iterationDocRef = this.db.collection('AgentContext').doc(agentId).collection('iterations').doc(String(iterationNumber)); const docSnap = await iterationDocRef.get(); diff --git a/src/modules/postgres/postgresAgentStateService.ts b/src/modules/postgres/postgresAgentStateService.ts index a33b3ba2e..db039ac52 100644 --- a/src/modules/postgres/postgresAgentStateService.ts +++ b/src/modules/postgres/postgresAgentStateService.ts @@ -331,11 +331,9 @@ export class PostgresAgentStateService implements AgentContextService { ctx.lastUpdate = now.getTime(); } - async load(agentId: string): Promise { + async load(agentId: string): Promise { const row = await this.db.selectFrom('agent_contexts').selectAll().where('agent_id', '=', agentId).executeTakeFirst(); - if (!row) { - throw new NotFound(`Agent with ID ${agentId} not found.`); - } + if (!row) return null; if (row.user_id !== currentUser().id) { logger.warn({ agentId, currentUserId: currentUser().id, ownerId: row.user_id }, 'Attempt to load agent not owned by current user.'); @@ -360,9 +358,7 @@ export class PostgresAgentStateService implements AgentContextService { .where(sql`metadata_serialized->>${key}`, '=', value) .executeTakeFirst(); - if (!row) { - return null; - } + if (!row) return null; // Ownership is already checked by `where('user_id', '=', currentUserId)` return this._deserializeDbRowToAgentContext(row); @@ -482,8 +478,8 @@ export class PostgresAgentStateService implements AgentContextService { async updateFunctions(agentId: string, functions: string[]): Promise { // Load the agent first to check existence and ownership - const agent = await this.load(agentId); // This will throw NotFound or NotAllowed if necessary - + const agent = await this.load(agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${agentId} not found.`); // Agent is guaranteed to exist and be owned by the current user here const newLlmFunctions = new LlmFunctionsImpl(); @@ -509,9 +505,12 @@ export class PostgresAgentStateService implements AgentContextService { } async saveIteration(iterationData: AutonomousIteration): Promise { - if (!Number.isInteger(iterationData.iteration) || iterationData.iteration <= 0) { - throw new Error('Iteration number must be a positive integer.'); - } + if (!Number.isInteger(iterationData.iteration) || iterationData.iteration <= 0) throw new Error('Iteration number must be a positive integer.'); + if (!iterationData.agentId) throw new Error('Agent ID is required for iteration data.'); + + const agent = await this.load(iterationData.agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${iterationData.agentId} not found.`); + const dbData = this._serializeIterationForDb(iterationData); const valuesToInsert: Insertable = { @@ -548,7 +547,8 @@ export class PostgresAgentStateService implements AgentContextService { async getAgentIterationSummaries(agentId: string): Promise { // Load the agent first to check existence and ownership - await this.load(agentId); // This will throw NotFound or NotAllowed if necessary + const agent = await this.load(agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${agentId} not found.`); const rows = await this.db .selectFrom('agent_iterations') From 08b752c0497ec7b429d8e330a0a3f9fff6628d31 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 3 Aug 2025 13:29:14 +0800 Subject: [PATCH 5/7] Update agent memory function --- .../autonomous/functions/agentFunctions.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/agent/autonomous/functions/agentFunctions.ts b/src/agent/autonomous/functions/agentFunctions.ts index 566a799dd..277ed6b3e 100644 --- a/src/agent/autonomous/functions/agentFunctions.ts +++ b/src/agent/autonomous/functions/agentFunctions.ts @@ -2,7 +2,7 @@ import { agentContext } from '#agent/agentContextLocalStorage'; import { func, funcClass } from '#functionSchema/functionDecorators'; import { logger } from '#o11y/logger'; -export const AGENT_SAVE_MEMORY = 'Agent_saveMemory'; +export const AGENT_MEMORY = 'Agent_memory'; export const AGENT_COMPLETED_NAME = 'Agent_completed'; @@ -30,7 +30,7 @@ export class Agent { * @param {string} key A descriptive identifier (alphanumeric and underscores allowed, under 30 characters) for the new memory contents explaining the source of the content. This must not exist in the current memory. * @param {string} content The plain text contents to store in the working memory */ - @func() + // @func() async saveMemory(key: string, content: string): Promise { if (!key || !key.trim().length) throw new Error('Memory key must be provided'); if (!content || !content.trim().length) throw new Error('Memory content must be provided'); @@ -44,7 +44,7 @@ export class Agent { * Note this will over-write any existing memory content * @param {string} key An existing key in the memory contents to update the contents of. */ - @func() + // @func() async deleteMemory(key: string): Promise { const memory = agentContext().memory; if (!memory[key]) logger.info(`deleteMemory key doesn't exist: ${key}`); @@ -56,11 +56,31 @@ export class Agent { * @param {string} key An existing key in the memory to retrieve. * @return {string} The memory contents */ - @func() + // @func() async getMemory(key: string): Promise { if (!key) throw new Error(`Parameter "key" must be provided. Was ${key}`); const memory = agentContext().memory; if (!memory[key]) throw new Error(`Memory key ${key} does not exist`); return memory[key]; } + + /** + * Interacts with the memory entries + * @param operation 'SAVE', 'DELETE', or 'GET' + * @param key The memory key to save, delete, or get + * @param content The content to save to the memory (when operation is 'SAVE') + * @returns void, or string when operation is 'GET' + */ + @func() + async memory(operation: 'SAVE' | 'DELETE' | 'GET', key: string, content?: string): Promise { + if (operation === 'SAVE') { + return this.saveMemory(key, content) as undefined; + } + if (operation === 'DELETE') { + return this.deleteMemory(key) as undefined; + } + if (operation === 'GET') { + return this.getMemory(key); + } + } } From 6046cb9525ba275808aaabcb209998d28ce23256 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 3 Aug 2025 13:30:26 +0800 Subject: [PATCH 6/7] Remove reasoning parts when generating new messages --- src/llm/services/ai-llm.ts | 75 ++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/src/llm/services/ai-llm.ts b/src/llm/services/ai-llm.ts index 4694f2046..2a839ccb9 100644 --- a/src/llm/services/ai-llm.ts +++ b/src/llm/services/ai-llm.ts @@ -117,45 +117,40 @@ export abstract class AiLLM extends BaseLLM { if (typeof content === 'string') { processedContent = content; } else { - processedContent = content.map((part) => { - // Strip extra properties not present in CoreMessage parts - if (part.type === 'image') { - const extPart = part as ImagePartExt; - return { - type: 'image', - image: extPart.image, // string (URL or base64) is compatible with DataContent - mimeType: extPart.mimeType, - } as AiImagePart; - } - if (part.type === 'file') { - const extPart = part as FilePartExt; - return { - type: 'file', - data: extPart.data, // AiFilePart (from 'ai') expects 'data' - mimeType: extPart.mimeType, - } as AiFilePart; // Use AiFilePart (alias for 'ai'.FilePart) - } - if (part.type === 'text') { - const extPart = part as TextPartExt; - return { - type: 'text', - text: extPart.text, - } as AiTextPart; - } - if (part.type === 'tool-call') { - return part as AiToolCallPart; - } - if (part.type === 'reasoning') { - // Assuming local ReasoningPart is compatible with ai's internal one - return part as ReasoningPart; - } - if (part.type === 'redacted-reasoning') { - // Assuming local RedactedReasoningPart (now with data) is compatible - return part as RedactedReasoningPart; - } - // Fallback for unknown parts, though ideally all are handled - return part as any; - }) as Exclude; + processedContent = content + // Remove reasoning and redacted-reasoning parts + .filter((part) => part.type !== 'reasoning' && part.type !== 'redacted-reasoning') + .map((part) => { + // Strip extra properties not present in CoreMessage parts + if (part.type === 'image') { + const extPart = part as ImagePartExt; + return { + type: 'image', + image: extPart.image, // string (URL or base64) is compatible with DataContent + mimeType: extPart.mimeType, + } as AiImagePart; + } + if (part.type === 'file') { + const extPart = part as FilePartExt; + return { + type: 'file', + data: extPart.data, // AiFilePart (from 'ai') expects 'data' + mimeType: extPart.mimeType, + } as AiFilePart; // Use AiFilePart (alias for 'ai'.FilePart) + } + if (part.type === 'text') { + const extPart = part as TextPartExt; + return { + type: 'text', + text: extPart.text, + } as AiTextPart; + } + if (part.type === 'tool-call') { + return part as AiToolCallPart; + } + // Fallback for unknown parts, though ideally all are handled + return part as any; + }) as Exclude; } return { ...restOfMsg, content: processedContent } as CoreMessage; }); @@ -165,7 +160,7 @@ export abstract class AiLLM extends BaseLLM { const combinedOpts = { ...this.defaultOptions, ...opts }; const description = combinedOpts.id ?? ''; return await withActiveSpan(`generateTextFromMessages ${description}`, async (span) => { - // The processMessages method now correctly returns CoreMessage[] + // The processMessages method now correctly returns CoreMessage[] and strips out reasoning parts const messages: CoreMessage[] = this.processMessages(llmMessages); // Gemini Flash 2.0 thinking max is about 42 From 75b424b9e20dfddb6ebad6c913b5c41bc9b34fe2 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 3 Aug 2025 13:31:41 +0800 Subject: [PATCH 7/7] Update postgresAgentStateService.ts --- src/modules/postgres/postgresAgentStateService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/postgres/postgresAgentStateService.ts b/src/modules/postgres/postgresAgentStateService.ts index db039ac52..e5834472e 100644 --- a/src/modules/postgres/postgresAgentStateService.ts +++ b/src/modules/postgres/postgresAgentStateService.ts @@ -538,7 +538,8 @@ export class PostgresAgentStateService implements AgentContextService { async loadIterations(agentId: string): Promise { // Load the agent first to check existence and ownership - await this.load(agentId); // This will throw NotFound or NotAllowed if necessary + const agent = await this.load(agentId); // This will throw NotAllowed if necessary + if (!agent) throw new NotFound(`Agent with ID ${agentId} not found.`); const rows = await this.db.selectFrom('agent_iterations').selectAll().where('agent_id', '=', agentId).orderBy('iteration_number', 'asc').execute();