service.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { type BaseMessage, AIMessage, HumanMessage, type SystemMessage, ToolMessage } from '@langchain/core/messages';
  2. import { MessageHistory, MessageMetadata, ManagedMessage } from '@src/background/agent/messages/views';
  3. import { createLogger } from '@src/background/log';
  4. const logger = createLogger('MessageManager');
  5. export class MessageManagerSettings {
  6. maxInputTokens = 128000;
  7. estimatedCharactersPerToken = 3;
  8. imageTokens = 800;
  9. includeAttributes: string[] = [];
  10. messageContext?: string;
  11. sensitiveData?: Record<string, string>;
  12. availableFilePaths?: string[];
  13. constructor(
  14. options: {
  15. maxInputTokens?: number;
  16. estimatedCharactersPerToken?: number;
  17. imageTokens?: number;
  18. includeAttributes?: string[];
  19. messageContext?: string;
  20. sensitiveData?: Record<string, string>;
  21. availableFilePaths?: string[];
  22. } = {},
  23. ) {
  24. if (options.maxInputTokens !== undefined) this.maxInputTokens = options.maxInputTokens;
  25. if (options.estimatedCharactersPerToken !== undefined)
  26. this.estimatedCharactersPerToken = options.estimatedCharactersPerToken;
  27. if (options.imageTokens !== undefined) this.imageTokens = options.imageTokens;
  28. if (options.includeAttributes !== undefined) this.includeAttributes = options.includeAttributes;
  29. if (options.messageContext !== undefined) this.messageContext = options.messageContext;
  30. if (options.sensitiveData !== undefined) this.sensitiveData = options.sensitiveData;
  31. if (options.availableFilePaths !== undefined) this.availableFilePaths = options.availableFilePaths;
  32. }
  33. }
  34. export default class MessageManager {
  35. private history: MessageHistory;
  36. private toolId: number;
  37. private settings: MessageManagerSettings;
  38. constructor(settings: MessageManagerSettings = new MessageManagerSettings()) {
  39. this.settings = settings;
  40. this.history = new MessageHistory();
  41. this.toolId = 1;
  42. }
  43. public initTaskMessages(systemMessage: SystemMessage, task: string, messageContext?: string): void {
  44. // Add system message
  45. this.addMessageWithTokens(systemMessage, 'init');
  46. // Add context message if provided
  47. if (messageContext && messageContext.length > 0) {
  48. const contextMessage = new HumanMessage({
  49. content: `Context for the task: ${messageContext}`,
  50. });
  51. this.addMessageWithTokens(contextMessage, 'init');
  52. }
  53. // Add task instructions
  54. const taskMessage = MessageManager.taskInstructions(task);
  55. this.addMessageWithTokens(taskMessage, 'init');
  56. // Add sensitive data info if sensitive data is provided
  57. if (this.settings.sensitiveData) {
  58. const info = `Here are placeholders for sensitive data: ${Object.keys(this.settings.sensitiveData)}`;
  59. const infoMessage = new HumanMessage({
  60. content: `${info}\nTo use them, write <secret>the placeholder name</secret>`,
  61. });
  62. this.addMessageWithTokens(infoMessage, 'init');
  63. }
  64. // Add example output
  65. const placeholderMessage = new HumanMessage({
  66. content: 'Example output:',
  67. });
  68. this.addMessageWithTokens(placeholderMessage, 'init');
  69. const toolCallId = this.nextToolId();
  70. const toolCalls = [
  71. {
  72. name: 'AgentOutput',
  73. args: {
  74. current_state: {
  75. evaluation_previous_goal:
  76. `Success - I successfully clicked on the 'Apple' link from the Google Search results page,
  77. which directed me to the 'Apple' company homepage. This is a good start toward finding
  78. the best place to buy a new iPhone as the Apple website often list iPhones for sale.`.trim(),
  79. memory: `I searched for 'iPhone retailers' on Google. From the Google Search results page,
  80. I used the 'click_element' tool to click on a element labelled 'Best Buy' but calling
  81. the tool did not direct me to a new page. I then used the 'click_element' tool to click
  82. on a element labelled 'Apple' which redirected me to the 'Apple' company homepage.
  83. Currently at step 3/15.`.trim(),
  84. next_goal: `Looking at reported structure of the current page, I can see the item '[127]<h3 iPhone/>'
  85. in the content. I think this button will lead to more information and potentially prices
  86. for iPhones. I'll click on the link to 'iPhone' at index [127] using the 'click_element'
  87. tool and hope to see prices on the next page.`.trim(),
  88. },
  89. action: [{ click_element: { index: 127 } }],
  90. },
  91. id: String(toolCallId),
  92. type: 'tool_call' as const,
  93. },
  94. ];
  95. const exampleToolCall = new AIMessage({
  96. content: '',
  97. tool_calls: toolCalls,
  98. });
  99. this.addMessageWithTokens(exampleToolCall, 'init');
  100. this.addToolMessage('Browser started', toolCallId, 'init');
  101. // Add history start marker
  102. const historyStartMessage = new HumanMessage({
  103. content: '[Your task history memory starts here]',
  104. });
  105. this.addMessageWithTokens(historyStartMessage);
  106. // Add available file paths if provided
  107. if (this.settings.availableFilePaths && this.settings.availableFilePaths.length > 0) {
  108. const filepathsMsg = new HumanMessage({
  109. content: `Here are file paths you can use: ${this.settings.availableFilePaths}`,
  110. });
  111. this.addMessageWithTokens(filepathsMsg, 'init');
  112. }
  113. }
  114. public nextToolId(): number {
  115. const id = this.toolId;
  116. this.toolId += 1;
  117. return id;
  118. }
  119. /**
  120. * Createthe task instructions
  121. * @param task - The raw description of the task
  122. * @returns A HumanMessage object containing the task instructions
  123. */
  124. private static taskInstructions(task: string): HumanMessage {
  125. 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.你的所有回答都要用中文回答.`;
  126. return new HumanMessage({ content });
  127. }
  128. /**
  129. * Returns the number of messages in the history
  130. * @returns The number of messages in the history
  131. */
  132. public length(): number {
  133. return this.history.messages.length;
  134. }
  135. /**
  136. * Adds a new task to execute, it will be executed based on the history
  137. * @param newTask - The raw description of the new task
  138. */
  139. public addNewTask(newTask: string): void {
  140. const content = `Your new ultimate task is: """${newTask}""". Take the previous context into account and finish your new ultimate task. `;
  141. const msg = new HumanMessage({ content });
  142. this.addMessageWithTokens(msg);
  143. }
  144. /**
  145. * Adds a plan message to the history
  146. * @param plan - The raw description of the plan
  147. * @param position - The position to add the plan
  148. */
  149. public addPlan(plan?: string, position?: number): void {
  150. if (plan) {
  151. const msg = new AIMessage({ content: `<plan>${plan}</plan>` });
  152. this.addMessageWithTokens(msg, null, position);
  153. }
  154. }
  155. /**
  156. * Adds a state message to the history
  157. * @param stateMessage - The HumanMessage object containing the state
  158. */
  159. public addStateMessage(stateMessage: HumanMessage): void {
  160. this.addMessageWithTokens(stateMessage);
  161. }
  162. /**
  163. * Adds a model output message to the history
  164. * @param modelOutput - The model output
  165. */
  166. public addModelOutput(modelOutput: Record<string, any>): void {
  167. const toolCallId = this.nextToolId();
  168. const toolCalls = [
  169. {
  170. name: 'AgentOutput',
  171. args: modelOutput,
  172. id: String(toolCallId),
  173. type: 'tool_call' as const,
  174. },
  175. ];
  176. const msg = new AIMessage({
  177. content: 'tool call',
  178. tool_calls: toolCalls,
  179. });
  180. this.addMessageWithTokens(msg);
  181. // Need a placeholder for the tool response here to avoid errors sometimes
  182. // NOTE: in browser-use, it uses an empty string
  183. this.addToolMessage('tool call response placeholder', toolCallId);
  184. }
  185. /**
  186. * Removes the last state message from the history
  187. */
  188. public removeLastStateMessage(): void {
  189. this.history.removeLastStateMessage();
  190. }
  191. public getMessages(): BaseMessage[] {
  192. const messages = this.history.messages.map(m => m.message);
  193. let totalInputTokens = 0;
  194. logger.debug(`Messages in history: ${this.history.messages.length}:`);
  195. for (const m of this.history.messages) {
  196. totalInputTokens += m.metadata.tokens;
  197. logger.debug(`${m.message.constructor.name} - Token count: ${m.metadata.tokens}`);
  198. }
  199. logger.debug(`Total input tokens: ${totalInputTokens}`);
  200. return messages;
  201. }
  202. /**
  203. * Adds a message to the history with the token count metadata
  204. * @param message - The BaseMessage object to add
  205. * @param messageType - The type of the message (optional)
  206. * @param position - The optional position to add the message, if not provided, the message will be added to the end of the history
  207. */
  208. public addMessageWithTokens(message: BaseMessage, messageType?: string | null, position?: number): void {
  209. let filteredMessage = message;
  210. // filter out sensitive data if provided
  211. if (this.settings.sensitiveData) {
  212. filteredMessage = this._filterSensitiveData(message);
  213. }
  214. const tokenCount = this._countTokens(filteredMessage);
  215. const metadata: MessageMetadata = new MessageMetadata(tokenCount, messageType);
  216. this.history.addMessage(filteredMessage, metadata, position);
  217. }
  218. /**
  219. * Filters out sensitive data from the message
  220. * @param message - The BaseMessage object to filter
  221. * @returns The filtered BaseMessage object
  222. */
  223. private _filterSensitiveData(message: BaseMessage): BaseMessage {
  224. const replaceSensitive = (value: string): string => {
  225. let filteredValue = value;
  226. if (!this.settings.sensitiveData) return filteredValue;
  227. for (const [key, val] of Object.entries(this.settings.sensitiveData)) {
  228. // Skip empty values to match Python behavior
  229. if (!val) continue;
  230. filteredValue = filteredValue.replace(val, `<secret>${key}</secret>`);
  231. }
  232. return filteredValue;
  233. };
  234. if (typeof message.content === 'string') {
  235. message.content = replaceSensitive(message.content);
  236. } else if (Array.isArray(message.content)) {
  237. message.content = message.content.map(item => {
  238. // Add null check to match Python's isinstance() behavior
  239. if (typeof item === 'object' && item !== null && 'text' in item) {
  240. return { ...item, text: replaceSensitive(item.text) };
  241. }
  242. return item;
  243. });
  244. }
  245. return message;
  246. }
  247. /**
  248. * Counts the tokens in the message
  249. * @param message - The BaseMessage object to count the tokens
  250. * @returns The number of tokens in the message
  251. */
  252. private _countTokens(message: BaseMessage): number {
  253. let tokens = 0;
  254. if (Array.isArray(message.content)) {
  255. for (const item of message.content) {
  256. if ('image_url' in item) {
  257. tokens += this.settings.imageTokens;
  258. } else if (typeof item === 'object' && 'text' in item) {
  259. tokens += this._countTextTokens(item.text);
  260. }
  261. }
  262. } else {
  263. let msg = message.content;
  264. // Check if it's an AIMessage with tool_calls
  265. if ('tool_calls' in message) {
  266. msg += JSON.stringify(message.tool_calls);
  267. }
  268. tokens += this._countTextTokens(msg);
  269. }
  270. return tokens;
  271. }
  272. /**
  273. * Counts the tokens in the text
  274. * Rough estimate, no tokenizer provided for now
  275. * @param text - The text to count the tokens
  276. * @returns The number of tokens in the text
  277. */
  278. private _countTextTokens(text: string): number {
  279. return Math.floor(text.length / this.settings.estimatedCharactersPerToken);
  280. }
  281. /**
  282. * Cuts the last message if the total tokens exceed the max input tokens
  283. *
  284. * Get current message list, potentially trimmed to max tokens
  285. */
  286. public cutMessages(): void {
  287. let diff = this.history.totalTokens - this.settings.maxInputTokens;
  288. if (diff <= 0) return;
  289. const lastMsg = this.history.messages[this.history.messages.length - 1];
  290. // if list with image remove image
  291. if (Array.isArray(lastMsg.message.content)) {
  292. let text = '';
  293. lastMsg.message.content = lastMsg.message.content.filter(item => {
  294. if ('image_url' in item) {
  295. diff -= this.settings.imageTokens;
  296. lastMsg.metadata.tokens -= this.settings.imageTokens;
  297. this.history.totalTokens -= this.settings.imageTokens;
  298. logger.debug(
  299. `Removed image with ${this.settings.imageTokens} tokens - total tokens now: ${this.history.totalTokens}/${this.settings.maxInputTokens}`,
  300. );
  301. return false;
  302. }
  303. if ('text' in item) {
  304. text += item.text;
  305. }
  306. return true;
  307. });
  308. lastMsg.message.content = text;
  309. this.history.messages[this.history.messages.length - 1] = lastMsg;
  310. }
  311. if (diff <= 0) return;
  312. // if still over, remove text from state message proportionally to the number of tokens needed with buffer
  313. // Calculate the proportion of content to remove
  314. const proportionToRemove = diff / lastMsg.metadata.tokens;
  315. if (proportionToRemove > 0.99) {
  316. throw new Error(
  317. `Max token limit reached - history is too long - reduce the system prompt or task. proportion_to_remove: ${proportionToRemove}`,
  318. );
  319. }
  320. logger.debug(
  321. `Removing ${(proportionToRemove * 100).toFixed(2)}% of the last message (${(proportionToRemove * lastMsg.metadata.tokens).toFixed(2)} / ${lastMsg.metadata.tokens.toFixed(2)} tokens)`,
  322. );
  323. const content = lastMsg.message.content as string;
  324. const charactersToRemove = Math.floor(content.length * proportionToRemove);
  325. const newContent = content.slice(0, -charactersToRemove);
  326. // remove tokens and old long message
  327. this.history.removeLastStateMessage();
  328. // new message with updated content
  329. const msg = new HumanMessage({ content: newContent });
  330. this.addMessageWithTokens(msg);
  331. const finalMsg = this.history.messages[this.history.messages.length - 1];
  332. logger.debug(
  333. `Added message with ${finalMsg.metadata.tokens} tokens - total tokens now: ${this.history.totalTokens}/${this.settings.maxInputTokens} - total messages: ${this.history.messages.length}`,
  334. );
  335. }
  336. /**
  337. * Adds a tool message to the history
  338. * @param content - The content of the tool message
  339. * @param toolCallId - The tool call id of the tool message, if not provided, a new tool call id will be generated
  340. * @param messageType - The type of the tool message
  341. */
  342. public addToolMessage(content: string, toolCallId?: number, messageType?: string | null): void {
  343. const id = toolCallId ?? this.nextToolId();
  344. const msg = new ToolMessage({ content, tool_call_id: String(id) });
  345. this.addMessageWithTokens(msg, messageType);
  346. }
  347. }