Files
BrowserOS/reference-code/old-lib/runtime/MessageManager.ts
Felarof 8245dfe0ff Rewrite Agent Loop (#7)
* clean-up bunch of files for re-write

* more clean-up and adding basic agent

* Minor fix moved types into respective files.

* Deleted bunch of old files

backup

Update gitignore

Deleted a bunch of files

Remove message manager

Deleted old docs

Update rules

rename Profiler to profiler

* Temporarily adding old code

* Adding two small things back

* backup

* Implemented LangChainProvider and updated cursor rules

backup

LangChainProvider

curosr rules

* Implement tests for LangChainProvider -- unit test and integration test

integration test passes

integration test backup

* Tool Design

Tools Desing

tools design

* NavigationTool ready

NavigationTool ready

NavigationTool ready

NaivgationTool ready

backup

* MessageManager

MessageManager

backup

* Fixed integration test

* Agent design new

Updated agent design and added bunch of /NTN commands

agent new design

* Delete old agent design

* MessageManagerReadOnly class

* PlannerTool ready

PlannerTool almost ready

* ToolManager and DoneTool

* Integration of BrowserAgent

* BrowserAgent implementation v0.1

* BrowserAgent small fix v0.2

* Tool calling design

too call design

tool design claude

* Update agent tool design with // NTN

* add zod-to-json npm install

* BrowserAGent v0.3

* BrowserAgent v0.4

* BrowserAgent v0.5

* fixes

* Build error fixes in my NEWLY added code

build errors fix

* Build error fixes in old code (integration work)

backup

* Comment StreamEventProcessor for now, it is not used

* Small build error fix

* Small rename

* Added integration test to check structuredLLM and changed to 4o-mini

change default to nxtscape

integration test

* Small docstring

* Simplified BrowserAgent code and added integration test

Simplified BrowserAgent code

BrowserAGent integrationt est

* Update CLAUDE.md with project memory and instructions on how to write code

Update CLAUDE.md with project memory and instructions on how to write code

Project Memory

* Just a mova.. Moved ToolManager outside. Build works.

* TabOperations tool

TabOperations Tool and fixing some test

tab operations

* Update CLAUDE.md

* Added ClassificationTool

classifiction tool

classification prommpt

* Refactored and simplified PlannerTool unit test and integration test

* Updated Plnnaer tool

* Update CLAUDE.md

* BrowserAgent modified to do classification

BrowserAgent with classification

* minor fix to ToolManager

* Instead of ToolCall and ToolResult -- just updating message manager once

* minor fix to BrowserAgent integration test

* Changed done to "done_tool"

* Updated CLAUDE.md to reflect understanding of claude

* Uncommented stream event processor

* Renamed EventBus to StreamEventBus

* Commented StreamEventProcessor

* Event Processor

* Integrated EventProcessor with BrowserAgent

Added EventProcessor to BrowserAgetn

* Renamed StreamEventBus to EventBus

* Made EventBus required parameter in ExecutionContext

* PlanGenerator rewrite

PlanGenerator rewrite

backup

* For simple task, explicitly tell it to call done tool

* Max attempts for simple task

* backup

* Revert "backup"

This reverts commit 7d79a3d4d5774bfef79ec9827878b74edad3593f.

* Consolidating where EventBus and EventProcessor are created and initialized

backup

* Update CLAUDE.md

Update CLAUDE.md

* Improving agent loop code

Cleaned up processTooCall

classification task

* Create test-writer subAgent

test-agent-prompt

test agent prompt

test-agent-prompt

Update test-writer.md

* BrowserAgent test

Browseragent test

BrowserAgent test

* BrowserAgent refactor

backup

backup

* Minor fixes

* Minor fix

* minor change -- NEW AGENT LOOP IS WORKING WELL

* Update cursor rules

* Small change

* Improved BrowserAgent integration test

Improved BrowserAgent integration test

* Small change

* Update CLAUDE.md

* Different tools

* FindElementTool is ready

Find element update

backup

find element backup

* Updated to test strings to say "tests..."

* ScrollTool is ready

* RefreshStateTool is updated as well

* MessageManager updated

* SearchTool is ready

backup

* Interaction Element is also ready

* Add debugMessage emitter

* ValidatorTool ready and tests are passing

Validation Tool

validator tool

backup

backup

* GroupTabs tool ready

* Registered all the tools

* Planning changed to 5 steps

* BrowserAgent integration test fix

* Minor string changes

* backup

* Removed too many confusing events in EventProcessor -- there is only event.info right now

* Abort control implemented

backup

Abort

* Formatter for toolResult

Formatter for toolResult

backup

* Always render using Markdown

* Minor fix

---------

Co-authored-by: Nikhil Sonti <nikhilsv92@gmail.com>
2025-07-29 08:14:45 -07:00

637 lines
21 KiB
TypeScript

import { type BaseMessage, AIMessage, HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import { z } from 'zod';
import { Logging } from '@/lib/utils/Logging';
import { wrapUserRequest } from '../utils/MessageUtils';
export const MessageTypeEnum = z.enum(["human", "ai", "system", "tool", "plan", "generic", "task", "browser_state", "validation_feedback", "productivity_task", "productivity_human", "productivity_ai"]);
export type MessageType = z.infer<typeof MessageTypeEnum>;
// Define schemas using Zod
export const MessageMetadataSchema = z.object({
tokens: z.number(), // Token count for this message
messageType: MessageTypeEnum.optional(),
timestamp: z.date().optional(), // When message was added
});
export const MessageManagerSettingsSchema = z.object({
maxInputTokens: z.number().default(128000), // Maximum input tokens allowed
estimatedCharactersPerToken: z.number().default(3), // Characters per token estimate
imageTokens: z.number().default(800), // Token cost per image
includeAttributes: z.array(z.string()).default([]), // Attributes to include
messageContext: z.string().optional(), // Context for messages
sensitiveData: z.record(z.string(), z.string()).optional(), // Sensitive data placeholders
availableFilePaths: z.array(z.string()).optional(), // Available file paths
});
// Infer types from schemas
export type MessageMetadata = z.infer<typeof MessageMetadataSchema>;
export type MessageManagerSettings = z.infer<typeof MessageManagerSettingsSchema>;
// Internal message wrapper
interface TrackedMessage {
// BaseMessage.getType() = "human" | "ai" | "generic" | "developer" | "system" | "function" | "tool" | "remove";
message: BaseMessage; // The actual LangChain message
metadata: MessageMetadata; // Associated metadata
}
export default class MessageManager {
private messages: TrackedMessage[] = [];
private totalTokens = 0;
private toolId = 1;
private settings: MessageManagerSettings;
private previousTaskType: 'productivity' | 'browse' | 'answer' | null = null; // Track previous task type
constructor(settings: Partial<MessageManagerSettings> = {}) {
this.settings = MessageManagerSettingsSchema.parse(settings);
}
public add(message: BaseMessage, messageType?: MessageType, position?: number): void {
const tokens = this.countTokens(message);
if (messageType === undefined) {
messageType = this.getMessageTypeFromBase(message);
}
const metadata: MessageMetadata = {
tokens,
messageType: messageType,
timestamp: new Date(),
};
const trackedMessage: TrackedMessage = { message, metadata };
if (position === undefined) {
this.messages.push(trackedMessage);
} else {
this.messages.splice(position, 0, trackedMessage);
}
// take care of trimming to fit
if (this.isOverBudget()) {
Logging.log('MessageManager', `Over budget - trimming to fit - total tokens: ${this.totalTokens}/${this.settings.maxInputTokens}`, 'warning');
this.trimToFit();
}
this.totalTokens += tokens;
}
public addTaskMessage(task: string): void {
const content = `Your ultimate task is: """${task}""". If you achieved your ultimate task, stop everything and use the done action in the next step to complete the task. If not, continue as usual.`;
const wrappedContent = wrapUserRequest(content);
const msg = new HumanMessage({ content: wrappedContent });
this.add(msg, 'task');
}
public addFollowUpTaskMessage(task: string): void {
const content = `Your NEW ultimate task is: """${task}""". This is a FOLLOW UP of the previous tasks. Make sure to take all of the previous context into account and finish your new ultimate task.`;
const wrappedContent = wrapUserRequest(content);
const msg = new HumanMessage({ content: wrappedContent });
this.add(msg, 'task');
}
public addHumanMessage(content: string, position?: number): void {
const msg = new HumanMessage({ content });
this.add(msg, 'human', position);
}
public addSystemMessage(content: string, position?: number): void {
// Remove any existing system messages first to ensure only one system prompt is active
this.removeSystemMessage();
// Now add the new system message
const msg = new SystemMessage({ content });
this.add(msg, 'system', position);
}
public removeSystemMessage(): void {
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].metadata.messageType === 'system') {
this.remove(i);
}
}
}
public addAIMessage(content: string, position?: number): void {
const msg = new AIMessage({ content });
this.add(msg, 'ai', position);
}
public removeAIMessage(): void {
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].metadata.messageType === 'ai') {
this.remove(i);
}
}
}
public addToolMessage(result: string, toolName: string): void {
// We won't use ToolMessage type because it requires specific format.
// all we need is the this for our tracking ONLY. So we'll use AIMessage instead.
// const toolMessage = new ToolMessage({
// content: result,
// name: toolName,
// tool_call_id: toolName,
// });
const message = {
tool_name: toolName,
result: result,
}
const msg = new AIMessage(JSON.stringify(message));
this.add(msg, 'tool');
}
public addPlanMessage(plan: string | string[], position?: number): void {
let content: string;
if (Array.isArray(plan)) {
// Format array of steps as JSON for easy parsing
content = `<plan>${JSON.stringify(plan)}</plan>`;
} else {
// Keep string format for backward compatibility
content = `<plan>${plan}</plan>`;
}
const msg = new AIMessage({ content });
this.add(msg, 'plan', position);
Logging.log('MessageManager', `Added plan with ${Array.isArray(plan) ? plan.length + ' steps' : 'custom format'}`, 'info');
}
/**
* Get the previous plan message content
* @returns Array of plan steps or undefined
*/
public getPreviousPlan(): string[] | undefined {
// Find the last plan message
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].metadata.messageType === 'plan') {
const content = this.messages[i].message.content as string;
// Extract plan content from <plan> tags
const match = content.match(/<plan>([\s\S]*?)<\/plan>/);
if (match && match[1]) {
try {
// Try to parse as JSON array first
return JSON.parse(match[1]);
} catch {
// If not JSON, split by newlines
return match[1].split('\n').filter(step => step.trim());
}
}
}
}
return undefined;
}
/**
* Update the existing plan message with a new version
* @param updatedPlan - The updated plan with checkbox markers
*/
public updatePlanMessage(updatedPlan: string[]): void {
// Find and remove the last plan message
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].metadata.messageType === 'plan') {
this.remove(i);
break;
}
}
// Add the updated plan
this.addPlanMessage(updatedPlan);
Logging.log('MessageManager', `Updated plan with ${updatedPlan.length} steps`, 'info');
}
/**
* Add model output to history - tracks agent's reasoning and planned actions
* This is crucial for preserving the agent's decision-making process
* @param output - The model output containing reasoning and actions
*/
public addModelOutput(output: Record<string, any>): void {
const toolCallId = this.nextToolId();
const msg = new AIMessage({
content: 'tool call',
tool_calls: [{
name: 'AgentOutput',
args: output,
id: String(toolCallId),
type: 'tool_call' as const
}]
});
this.add(msg, 'ai');
// Add placeholder tool response to maintain conversation flow
this.addToolMessage('tool call response', 'AgentOutput');
Logging.log('MessageManager', 'Added model output to history', 'info');
}
public addBrowserStateMessage(state: string, position?: number): void {
const msg = new SystemMessage({ content: state });
this.add(msg, 'browser_state', position);
}
/**
* Remove the last browser state message
* @returns True if a browser state was removed
*/
public removeBrowserStateMessages(): boolean {
// Find the last browser state message
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].metadata.messageType === 'browser_state') {
const removed = this.remove(i);
if (removed) {
Logging.log('MessageManager', 'Removed browser state message', 'info');
}
return removed;
}
}
return false;
}
public clear(): void {
this.messages = [];
this.totalTokens = 0;
this.toolId = 1;
this.previousTaskType = null; // Reset previous agent type
Logging.log('MessageManager', 'Conversation history cleared');
}
/**
* Get messages excluding browser state messages
* @returns Array of messages without browser states
*/
public getMessagesWithoutBrowserState(): BaseMessage[] {
const messages = this.messages
.filter(m => m.metadata.messageType !== 'browser_state')
.map(m => m.message);
Logging.log('MessageManager', `Returning ${messages.length} messages (excluding browser states)`, 'info');
return messages;
}
/**
* Add validation feedback to message history
* @param suggestions - Array of suggestions from validator
*/
public addValidationFeedback(suggestions: string[]): void {
const content = `Validation failed. Suggestions for next steps:\n${suggestions.map((s, i) => `${i + 1}. ${s}`).join('\n')}`;
const msg = new AIMessage({ content });
this.add(msg, 'validation_feedback');
Logging.log('MessageManager', `Added validation feedback with ${suggestions.length} suggestions`, 'info');
}
/**
* Check if there's a browser state message in the history
* @returns True if browser state exists
*/
public hasBrowserState(): boolean {
return this.messages.some(m => m.metadata.messageType === 'browser_state');
}
/**
* Remove all browser state messages
* @returns Number of browser states removed
*/
public removeAllBrowserStates(): number {
let removed = 0;
// Iterate backwards to avoid index issues
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].metadata.messageType === 'browser_state') {
if (this.remove(i)) {
removed++;
}
}
}
if (removed > 0) {
Logging.log('MessageManager', `Removed ${removed} browser state messages`, 'info');
}
return removed;
}
/**
* Add initial productivity task message
* @param task - The productivity task description
*/
public addProductivityTaskMessage(task: string): void {
const content = `Productivity task: "${task}"`;
const msg = new HumanMessage({ content });
this.add(msg, 'productivity_task');
Logging.log('MessageManager', 'Added productivity task message', 'info');
}
/**
* Add agent's proposal message with structured data
* @param proposal - The structured proposal object
* @param formatted - The formatted string representation for display
*/
public addProductivityAIMessage(proposal: any, formatted: string): void {
// Store both structured data and formatted version
const content = {
proposal: proposal,
formatted: formatted
};
const msg = new AIMessage({ content: JSON.stringify(content) });
this.add(msg, 'productivity_ai');
Logging.log('MessageManager', 'Added productivity AI message', 'info');
}
/**
* Add user feedback on proposal
* @param feedback - User's feedback message
*/
public addProductivityHumanMessage(feedback: string): void {
const msg = new HumanMessage({ content: feedback });
this.add(msg, 'productivity_human');
Logging.log('MessageManager', 'Added productivity human message', 'info');
}
/**
* Get the last productivity AI message
* @returns The last proposal object or undefined
*/
public getLastProductivityAIMessage(): any | undefined {
// Search backwards for the most recent productivity_ai message
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].metadata.messageType === 'productivity_ai') {
const content = this.messages[i].message.content as string;
try {
const parsed = JSON.parse(content);
return parsed.proposal;
} catch {
Logging.log('MessageManager', 'Failed to parse productivity AI message', 'warning');
return undefined;
}
}
}
return undefined;
}
/**
* Check if we're in a productivity conversation (has AI message)
* @returns True if there's an active productivity AI message
*/
public hasProductivityAIMessage(): boolean {
return this.messages.some(m => m.metadata.messageType === 'productivity_ai');
}
/**
* Clear productivity conversation state
* Removes all productivity-related messages
*/
public clearProductivityConversation(): void {
let removed = 0;
// Remove all productivity-related messages
for (let i = this.messages.length - 1; i >= 0; i--) {
const type = this.messages[i].metadata.messageType;
if (type === 'productivity_task' ||
type === 'productivity_ai' ||
type === 'productivity_human') {
if (this.remove(i)) {
removed++;
}
}
}
if (removed > 0) {
Logging.log('MessageManager', `Cleared productivity conversation (${removed} messages removed)`, 'info');
}
}
/**
* Get all productivity messages in order
* @returns Array of productivity messages with their types
*/
public getProductivityConversation(): Array<{ type: MessageType; content: any }> {
const productivityMessages = this.messages
.filter(m =>
m.metadata.messageType === 'productivity_task' ||
m.metadata.messageType === 'productivity_ai' ||
m.metadata.messageType === 'productivity_human'
)
.map(m => {
let content = m.message.content;
// Parse AI messages to extract the proposal
if (m.metadata.messageType === 'productivity_ai' && typeof content === 'string') {
try {
const parsed = JSON.parse(content);
content = parsed;
} catch {
// Keep as string if parsing fails
}
}
return {
type: m.metadata.messageType!,
content: content
};
});
return productivityMessages;
}
public remove(index = -1): boolean {
if (this.messages.length === 0) return false;
const actualIndex = index < 0 ? this.messages.length + index : index;
if (actualIndex < 0 || actualIndex >= this.messages.length) return false;
const removed = this.messages.splice(actualIndex, 1)[0];
this.totalTokens -= removed.metadata.tokens;
return true;
}
public removeLastUserMessage(): boolean {
if (this.messages.length > 2) {
const lastMessage = this.messages[this.messages.length - 1];
if (lastMessage.message instanceof HumanMessage) {
return this.remove(-1);
}
}
return false;
}
public removeOldestNonSystemMessage(): boolean {
for (let i = 0; i < this.messages.length; i++) {
if (!(this.messages[i].message instanceof SystemMessage)) {
return this.remove(i);
}
}
return false;
}
public getMessages(isGemini: boolean = false): BaseMessage[] {
const messages = this.messages.map(m => m.message);
if (isGemini) {
// Gemini expects SystemMessage to be the first message
// let's just convert all system messages to ai messages
for (let i = 0; i < messages.length; i++) {
if (messages[i].getType() === 'system') {
messages[i] = new HumanMessage({ content: messages[i].content });
}
}
}
// Log token usage for debugging
Logging.log('MessageManager', `Messages in history: ${this.messages.length} - Total input tokens: ${this.totalTokens}`);
return messages;
}
public getMessagesWithMetadata(): TrackedMessage[] {
return [...this.messages];
}
public getTotalTokens(): number {
return this.totalTokens;
}
public length(): number {
return this.messages.length;
}
public isOverBudget(): boolean {
return this.totalTokens > this.settings.maxInputTokens;
}
public getRemainingTokens(): number {
return Math.max(0, this.settings.maxInputTokens - this.totalTokens);
}
public canFit(message: BaseMessage): boolean {
const tokens = this.countTokens(message);
return tokens <= this.getRemainingTokens();
}
public nextToolId(): number {
const id = this.toolId;
this.toolId += 1;
return id;
}
private getMessageTypeFromBase(message: BaseMessage): MessageType {
if (message instanceof HumanMessage) return 'human';
if (message instanceof AIMessage) return 'ai';
if (message instanceof SystemMessage) return 'system';
if (message instanceof ToolMessage) return 'tool';
return 'generic';
}
/**
* Trim messages to fit within token budget
* Removes oldest non-system messages or truncates the last message
*/
private trimToFit(): void {
let overBudget = this.totalTokens - this.settings.maxInputTokens;
if (overBudget <= 0) return;
const lastMsg = this.messages[this.messages.length - 1];
// If last message has images, remove them first
if (Array.isArray(lastMsg.message.content)) {
let text = '';
lastMsg.message.content = lastMsg.message.content.filter(item => {
if ('image_url' in item) {
overBudget -= this.settings.imageTokens;
lastMsg.metadata.tokens -= this.settings.imageTokens;
this.totalTokens -= this.settings.imageTokens;
Logging.log(
'MessageManager',
`Removed image with ${this.settings.imageTokens} tokens - total tokens now: ${this.totalTokens}/${this.settings.maxInputTokens}`,
);
return false;
}
if ('text' in item) {
text += item.text;
}
return true;
});
lastMsg.message.content = text;
}
if (overBudget <= 0) return;
// let's old tool & AI messages from oldest to newest
for (let i = 0; i < this.messages.length; i++) {
if (this.messages[i].metadata.messageType === 'tool' || this.messages[i].metadata.messageType === 'ai') {
const removedMsg = this.messages[i];
this.remove(i);
overBudget -= removedMsg.metadata.tokens;
if (overBudget <= 0) return;
}
}
// if still over budget, throw an error.
// the user content is too large.
overBudget = this.totalTokens - this.settings.maxInputTokens;
if (overBudget > 0) {
throw new Error(
`Max token limit reached - Content is too large.`,
);
}
}
/**
* Count tokens in a message
* @param message - Message to count tokens for
* @returns Token count
*/
private countTokens(message: BaseMessage): number {
let tokens = 0;
if (Array.isArray(message.content)) {
for (const item of message.content) {
if ('image_url' in item) {
tokens += this.settings.imageTokens;
} else if (typeof item === 'object' && 'text' in item) {
tokens += this.countTextTokens(item.text);
}
}
} else {
let msg = message.content;
// Check if it's an AIMessage with tool_calls
if ('tool_calls' in message) {
msg += JSON.stringify(message.tool_calls);
}
tokens += this.countTextTokens(msg);
}
return tokens;
}
/**
* Count tokens in text (rough estimate)
* @param text - Text to count
* @returns Estimated token count
*/
private countTextTokens(text: string): number {
return Math.floor(text.length / this.settings.estimatedCharactersPerToken);
}
/**
* Set the last task type that was classified
* @param taskType - The type of task that was classified (productivity or browse)
*/
public setPreviousTaskType(taskType: 'productivity' | 'browse' | 'answer' | null): void {
this.previousTaskType = taskType;
Logging.log('MessageManager', `Set previous task type to: ${taskType}`, 'info');
}
/**
* Get the previous task type that was classified
* @returns The previous task type or null if none
*/
public getPreviousTaskType(): 'productivity' | 'browse' | 'answer' | null {
return this.previousTaskType;
}
}