feat: gate unified user message tag behind experimental setting

- Add UNIFIED_USER_MESSAGE_TAG experiment (disabled by default)
- Create userContentTags utility for conditional tag formatting
- Update Task.ts to conditionally wrap task/resume content
- Update tool files (AskFollowupQuestion, AttemptCompletion, ExecuteCommand) to conditionally wrap feedback/answers
- Update formatResponse functions to accept useUnifiedTag parameter
- Update presentAssistantMessage.ts to pass experiment flag
- Update ReadFileTool.ts to pass experiment flag
- Update processUserContentMentions.ts to detect tags based on experiment

When experiment is disabled (default):
- Uses legacy tags: <task>, <feedback>, <answer>
- Maintains backward compatibility

When experiment is enabled:
- Uses unified <user_message> tag for all contexts
- Simplifies tag detection and handling

Relates to COM-450
This commit is contained in:
Roo Code 2026-01-14 16:04:59 +00:00
parent 4b17f985b4
commit f8f631dc82
11 changed files with 156 additions and 36 deletions

View file

@ -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<typeof experimentsSchema>

View file

@ -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,
),
)

View file

@ -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<ProcessUserContentMentionsResult> {
// 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
// "<feedback>" (see formatToolDeniedFeedback, attemptCompletion,
// executeCommand, and consecutiveMistakeCount >= 3) or "<answer>"
// (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("<task>") ||
text.includes("<feedback>") ||
text.includes("<answer>") ||
text.includes("<user_message>")
const shouldProcessMentions = (text: string) => hasUserContentTags(text, useUnifiedTag)
if (block.type === "text") {
if (shouldProcessMentions(block.text)) {

View file

@ -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<feedback>\n${feedback}\n</feedback>`
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<feedback>\n${feedback}\n</feedback>`
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<feedback>\n${feedback}\n</feedback>`
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) => {

View file

@ -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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
includeDiagnosticMessages,
maxDiagnosticMessages,
maxReadFileLine,
useUnifiedTag,
})
// Switch mode if specified in a slash command's frontmatter

View file

@ -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(`<answer>\n${text}\n</answer>`, 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)
}

View file

@ -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<feedback>\n${text}\n</feedback>`
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)
}

View file

@ -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:`,
`<feedback>\n${text}\n</feedback>`,
wrappedFeedback,
].join("\n"),
images,
),

View file

@ -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 || []
}
}

View file

@ -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<string, ExperimentId>
type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
@ -26,6 +27,7 @@ export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
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(

View file

@ -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 <user_message> tag.
*
* When disabled (legacy mode), uses context-specific tags:
* - <task> for initial task
* - <feedback> for user feedback
* - <answer> 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 "<user_message>"
}
switch (context) {
case "task":
return "<task>"
case "feedback":
return "<feedback>"
case "answer":
return "<answer>"
case "resume":
return "<user_message>"
default:
return "<user_message>"
}
}
/**
* Get the closing tag for user content based on context and experiment setting
*/
export function getUserContentCloseTag(context: UserContentContext, useUnifiedTag: boolean): string {
if (useUnifiedTag) {
return "</user_message>"
}
switch (context) {
case "task":
return "</task>"
case "feedback":
return "</feedback>"
case "answer":
return "</answer>"
case "resume":
return "</user_message>"
default:
return "</user_message>"
}
}
/**
* 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("<user_message>")
}
return (
text.includes("<task>") ||
text.includes("<feedback>") ||
text.includes("<answer>") ||
text.includes("<user_message>")
)
}