diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index f6f701a25d3..9d58bf7388d 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -14,6 +14,7 @@ export const experimentIds = [ "runSlashCommand", "multipleNativeToolCalls", "customTools", + "unifiedUserMessageTag", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -32,6 +33,7 @@ export const experimentsSchema = z.object({ runSlashCommand: z.boolean().optional(), multipleNativeToolCalls: z.boolean().optional(), customTools: z.boolean().optional(), + unifiedUserMessageTag: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 693327a022e..ff0d3f294f3 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -73,6 +73,10 @@ export async function presentAssistantMessage(cline: Task) { cline.presentAssistantMessageLocked = true cline.presentAssistantMessageHasPendingUpdates = false + // Get experiment setting for unified tags once for this presentation + const state = await cline.providerRef.deref()?.getState() + const useUnifiedTag = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.UNIFIED_USER_MESSAGE_TAG) + if (cline.currentStreamingContentIndex >= cline.assistantMessageContent.length) { // This may happen if the last content block was completed before // streaming could finish. If streaming is finished, and we're out of @@ -174,7 +178,7 @@ export async function presentAssistantMessage(cline: Task) { // Merge approval feedback into tool result (GitHub #10465) if (approvalFeedback) { - const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text, toolProtocol) + const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text, toolProtocol, useUnifiedTag) resultContent = `${feedbackText}\n\n${resultContent}` // Add feedback images to the image blocks @@ -221,7 +225,7 @@ export async function presentAssistantMessage(cline: Task) { await cline.say("user_feedback", text, images) pushToolResult( formatResponse.toolResult( - formatResponse.toolDeniedWithFeedback(text, toolProtocol), + formatResponse.toolDeniedWithFeedback(text, toolProtocol, useUnifiedTag), images, ), ) @@ -372,8 +376,7 @@ export async function presentAssistantMessage(cline: Task) { break } case "tool_use": { - // Fetch state early so it's available for toolDescription and validation - const state = await cline.providerRef.deref()?.getState() + // Use already-fetched state for toolDescription and validation const { mode, customModes, experiments: stateExperiments } = state ?? {} const toolDescription = (): string => { @@ -556,6 +559,7 @@ export async function presentAssistantMessage(cline: Task) { const feedbackText = formatResponse.toolApprovedWithFeedback( approvalFeedback.text, toolProtocol, + useUnifiedTag, ) resultContent = `${feedbackText}\n\n${resultContent}` @@ -597,6 +601,7 @@ export async function presentAssistantMessage(cline: Task) { const feedbackText = formatResponse.toolApprovedWithFeedback( approvalFeedback.text, toolProtocol, + useUnifiedTag, ) resultContent = `${feedbackText}\n\n${resultContent}` } @@ -658,7 +663,7 @@ export async function presentAssistantMessage(cline: Task) { await cline.say("user_feedback", text, images) pushToolResult( formatResponse.toolResult( - formatResponse.toolDeniedWithFeedback(text, toolProtocol), + formatResponse.toolDeniedWithFeedback(text, toolProtocol, useUnifiedTag), images, ), ) diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 5ea78f4dc30..1306a74922c 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { parseMentions, ParseMentionsResult } from "./index" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" import { FileContextTracker } from "../context-tracking/FileContextTracker" +import { hasUserContentTags } from "../../utils/userContentTags" export interface ProcessUserContentMentionsResult { content: Anthropic.Messages.ContentBlockParam[] @@ -9,7 +10,7 @@ export interface ProcessUserContentMentionsResult { } /** - * Process mentions in user content, specifically within task and feedback tags + * Process mentions in user content, specifically within user message tags */ export async function processUserContentMentions({ userContent, @@ -21,6 +22,7 @@ export async function processUserContentMentions({ includeDiagnosticMessages = true, maxDiagnosticMessages = 50, maxReadFileLine, + useUnifiedTag = false, }: { userContent: Anthropic.Messages.ContentBlockParam[] cwd: string @@ -31,6 +33,7 @@ export async function processUserContentMentions({ includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number maxReadFileLine?: number + useUnifiedTag?: boolean }): Promise { // Track the first mode found from slash commands let commandMode: string | undefined @@ -38,20 +41,13 @@ export async function processUserContentMentions({ // Process userContent array, which contains various block types: // TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam. // We need to apply parseMentions() to: - // 1. All TextBlockParam's text (first user message with task) - // 2. ToolResultBlockParam's content/context text arrays if it contains - // "" (see formatToolDeniedFeedback, attemptCompletion, - // executeCommand, and consecutiveMistakeCount >= 3) or "" - // (see askFollowupQuestion), we place all user generated content in - // these tags so they can effectively be used as markers for when we - // should parse mentions). + // 1. All TextBlockParam's text (first user message) + // 2. ToolResultBlockParam's content/context text arrays if it contains user content tags + // We place all user generated content in these tags so they can effectively be used as + // markers for when we should parse mentions. const content = await Promise.all( userContent.map(async (block) => { - const shouldProcessMentions = (text: string) => - text.includes("") || - text.includes("") || - text.includes("") || - text.includes("") + const shouldProcessMentions = (text: string) => hasUserContentTags(text, useUnifiedTag) if (block.type === "text") { if (shouldProcessMentions(block.text)) { diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index ccb09e68e19..72bbef990fe 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -4,6 +4,7 @@ import * as diff from "diff" import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { ToolProtocol, isNativeProtocol, TOOL_PROTOCOL } from "@roo-code/types" +import { wrapUserContent } from "../../utils/userContentTags" export const formatResponse = { toolDenied: (protocol?: ToolProtocol) => { @@ -16,26 +17,26 @@ export const formatResponse = { return `The user denied this operation.` }, - toolDeniedWithFeedback: (feedback?: string, protocol?: ToolProtocol) => { + toolDeniedWithFeedback: (feedback?: string, protocol?: ToolProtocol, useUnifiedTag?: boolean) => { if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { return JSON.stringify({ status: "denied", - message: "The user denied this operation and provided the following feedback", feedback: feedback, }) } - return `The user denied this operation and provided the following feedback:\n\n${feedback}\n` + const wrappedFeedback = wrapUserContent(feedback ?? "", "feedback", useUnifiedTag ?? false) + return `The user denied this operation and responded with the message:\n${wrappedFeedback}` }, - toolApprovedWithFeedback: (feedback?: string, protocol?: ToolProtocol) => { + toolApprovedWithFeedback: (feedback?: string, protocol?: ToolProtocol, useUnifiedTag?: boolean) => { if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { return JSON.stringify({ status: "approved", - message: "The user approved this operation and provided the following context", feedback: feedback, }) } - return `The user approved this operation and provided the following context:\n\n${feedback}\n` + const wrappedFeedback = wrapUserContent(feedback ?? "", "feedback", useUnifiedTag ?? false) + return `The user approved this operation and responded with the message:\n${wrappedFeedback}` }, toolError: (error?: string, protocol?: ToolProtocol) => { @@ -77,15 +78,15 @@ Otherwise, if you have not completed the task and do not need additional informa (This is an automated message, so do not respond to it conversationally.)` }, - tooManyMistakes: (feedback?: string, protocol?: ToolProtocol) => { + tooManyMistakes: (feedback?: string, protocol?: ToolProtocol, useUnifiedTag?: boolean) => { if (isNativeProtocol(protocol ?? TOOL_PROTOCOL.XML)) { return JSON.stringify({ status: "guidance", - message: "You seem to be having trouble proceeding", feedback: feedback, }) } - return `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n\n${feedback}\n` + const wrappedFeedback = wrapUserContent(feedback ?? "", "feedback", useUnifiedTag ?? false) + return `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n${wrappedFeedback}` }, missingToolParameterError: (paramName: string, protocol?: ToolProtocol) => { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b39c2f9b368..d2aa6671a06 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -73,6 +73,7 @@ import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes import { DiffStrategy, type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { getModelMaxOutputTokens } from "../../shared/api" +import { wrapUserContent } from "../../utils/userContentTags" // services import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" @@ -2508,9 +2509,13 @@ export class Task extends EventEmitter implements TaskLike { ) if (response === "messageResponse") { + // Get experiment setting for unified tags + const mistakeState = await this.providerRef.deref()?.getState() + const useUnifiedTagForMistakes = experiments.isEnabled(mistakeState?.experiments ?? {}, EXPERIMENT_IDS.UNIFIED_USER_MESSAGE_TAG) + currentUserContent.push( ...[ - { type: "text" as const, text: formatResponse.tooManyMistakes(text) }, + { type: "text" as const, text: formatResponse.tooManyMistakes(text, this._taskToolProtocol, useUnifiedTagForMistakes) }, ...formatResponse.imageBlocks(images), ], ) @@ -2553,8 +2558,11 @@ export class Task extends EventEmitter implements TaskLike { includeDiagnosticMessages = true, maxDiagnosticMessages = 50, maxReadFileLine = -1, + experiments: experimentsConfig, } = (await this.providerRef.deref()?.getState()) ?? {} + const useUnifiedTag = experiments.isEnabled(experimentsConfig ?? {}, EXPERIMENT_IDS.UNIFIED_USER_MESSAGE_TAG) + const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({ userContent: currentUserContent, cwd: this.cwd, @@ -2565,6 +2573,7 @@ export class Task extends EventEmitter implements TaskLike { includeDiagnosticMessages, maxDiagnosticMessages, maxReadFileLine, + useUnifiedTag, }) // Switch mode if specified in a slash command's frontmatter diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index b75ca3b618e..5f79f0db859 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -2,6 +2,8 @@ import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { parseXml } from "../../utils/xml" import type { ToolUse } from "../../shared/tools" +import { wrapUserContent } from "../../utils/userContentTags" +import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -86,7 +88,13 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { task.consecutiveMistakeCount = 0 const { text, images } = await task.ask("followup", JSON.stringify(follow_up_json), false) await task.say("user_feedback", text ?? "", images) - pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) + + // Get experiment setting for unified tags + const state = await task.providerRef.deref()?.getState() + const useUnifiedTag = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.UNIFIED_USER_MESSAGE_TAG) + const wrappedAnswer = wrapUserContent(text ?? "", "answer", useUnifiedTag) + + pushToolResult(formatResponse.toolResult(wrappedAnswer, images)) } catch (error) { await handleError("asking question", error as Error) } diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 039036f829c..67cd81b96cb 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -8,6 +8,8 @@ import { formatResponse } from "../prompts/responses" import { Package } from "../../shared/package" import type { ToolUse } from "../../shared/tools" import { t } from "../../i18n" +import { wrapUserContent } from "../../utils/userContentTags" +import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -150,8 +152,12 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { // User provided feedback - push tool result to continue the conversation await task.say("user_feedback", text ?? "", images) - const feedbackText = `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\n${text}\n` - pushToolResult(formatResponse.toolResult(feedbackText, images)) + // Get experiment setting for unified tags + const state = await task.providerRef.deref()?.getState() + const useUnifiedTag = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.UNIFIED_USER_MESSAGE_TAG) + const wrappedFeedback = wrapUserContent(text ?? "", "feedback", useUnifiedTag) + + pushToolResult(formatResponse.toolResult(wrappedFeedback, images)) } catch (error) { await handleError("inspecting site", error as Error) } diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 7feb71b0b89..1e75782532e 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -18,6 +18,8 @@ import { Terminal } from "../../integrations/terminal/Terminal" import { Package } from "../../shared/package" import { t } from "../../i18n" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { wrapUserContent } from "../../utils/userContentTags" +import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" class ShellIntegrationError extends Error {} @@ -334,14 +336,18 @@ export async function executeCommandInTerminal( const { text, images } = message await task.say("user_feedback", text, images) + // Get experiment setting for unified tags + const state = await task.providerRef.deref()?.getState() + const useUnifiedTag = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.UNIFIED_USER_MESSAGE_TAG) + const wrappedFeedback = wrapUserContent(text ?? "", "feedback", useUnifiedTag) + return [ true, formatResponse.toolResult( [ `Command is still running in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`, result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n", - `The user provided the following feedback:`, - `\n${text}\n`, + wrappedFeedback, ].join("\n"), images, ), diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2bba6bc6cd9..dd25d792752 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -19,6 +19,7 @@ import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { parseXml } from "../../utils/xml" import { resolveToolProtocol } from "../../utils/resolveToolProtocol" import type { ToolUse } from "../../shared/tools" +import { experiments, EXPERIMENT_IDS } from "../../shared/experiments" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, @@ -343,7 +344,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { maxReadFileLine = -1, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + experiments: stateExperiments, } = state ?? {} + const useUnifiedTag = experiments.isEnabled(stateExperiments ?? {}, EXPERIMENT_IDS.UNIFIED_USER_MESSAGE_TAG) for (const fileResult of fileResults) { if (fileResult.status !== "approved") continue @@ -649,17 +652,17 @@ export class ReadFileTool extends BaseTool<"read_file"> { const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText) if (deniedWithFeedback && deniedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) + statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText, toolProtocol, useUnifiedTag) feedbackImages = deniedWithFeedback.feedbackImages || [] } else if (task.didRejectTool) { - statusMessage = formatResponse.toolDenied() + statusMessage = formatResponse.toolDenied(toolProtocol) } else { const approvedWithFeedback = fileResults.find( (result) => result.status === "approved" && result.feedbackText, ) if (approvedWithFeedback && approvedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) + statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText, toolProtocol, useUnifiedTag) feedbackImages = approvedWithFeedback.feedbackImages || [] } } diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index ad3aeca8634..0d3875d16ca 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -8,6 +8,7 @@ export const EXPERIMENT_IDS = { RUN_SLASH_COMMAND: "runSlashCommand", MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls", CUSTOM_TOOLS: "customTools", + UNIFIED_USER_MESSAGE_TAG: "unifiedUserMessageTag", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -26,6 +27,7 @@ export const experimentConfigsMap: Record = { RUN_SLASH_COMMAND: { enabled: false }, MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false }, CUSTOM_TOOLS: { enabled: false }, + UNIFIED_USER_MESSAGE_TAG: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/src/utils/userContentTags.ts b/src/utils/userContentTags.ts new file mode 100644 index 00000000000..72aa70d97ca --- /dev/null +++ b/src/utils/userContentTags.ts @@ -0,0 +1,82 @@ +/** + * Utilities for handling user content wrapper tags. + * + * When the UNIFIED_USER_MESSAGE_TAG experiment is enabled, all user content + * (task start, feedback, answers, resume messages) uses a single tag. + * + * When disabled (legacy mode), uses context-specific tags: + * - for initial task + * - for user feedback + * - for follow-up question responses + */ + +export type UserContentContext = "task" | "feedback" | "answer" | "resume" + +/** + * Get the opening tag for user content based on context and experiment setting + */ +export function getUserContentOpenTag(context: UserContentContext, useUnifiedTag: boolean): string { + if (useUnifiedTag) { + return "" + } + + switch (context) { + case "task": + return "" + case "feedback": + return "" + case "answer": + return "" + case "resume": + return "" + default: + return "" + } +} + +/** + * Get the closing tag for user content based on context and experiment setting + */ +export function getUserContentCloseTag(context: UserContentContext, useUnifiedTag: boolean): string { + if (useUnifiedTag) { + return "" + } + + switch (context) { + case "task": + return "" + case "feedback": + return "" + case "answer": + return "" + case "resume": + return "" + default: + return "" + } +} + +/** + * Wrap user content with appropriate tags + */ +export function wrapUserContent(content: string, context: UserContentContext, useUnifiedTag: boolean): string { + const openTag = getUserContentOpenTag(context, useUnifiedTag) + const closeTag = getUserContentCloseTag(context, useUnifiedTag) + return `${openTag}\n${content}\n${closeTag}` +} + +/** + * Check if text contains any user content tags (for mention processing) + */ +export function hasUserContentTags(text: string, useUnifiedTag: boolean): boolean { + if (useUnifiedTag) { + return text.includes("") + } + + return ( + text.includes("") || + text.includes("") || + text.includes("") || + text.includes("") + ) +}