Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,21 @@ export const contextCondenseSchema = z.object({

export type ContextCondense = z.infer<typeof contextCondenseSchema>

/**
* RateLimitRetryMetadata
*/
export const rateLimitRetrySchema = z.object({
type: z.literal("rate_limit_retry"),
status: z.enum(["waiting", "retrying", "cancelled"]),
remainingSeconds: z.number().optional(),
attempt: z.number().optional(),
maxAttempts: z.number().optional(),
origin: z.enum(["pre_request", "retry_attempt"]).optional(),
detail: z.string().optional(),
})

export type RateLimitRetryMetadata = z.infer<typeof rateLimitRetrySchema>

/**
* ClineMessage
*/
Expand Down Expand Up @@ -225,6 +240,7 @@ export const clineMessageSchema = z.object({
reasoning_summary: z.string().optional(),
})
.optional(),
rateLimitRetry: rateLimitRetrySchema.optional(),
})
.optional(),
})
Expand Down
198 changes: 176 additions & 22 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors

interface RateLimitRetryPayload {
type: "rate_limit_retry"
status: "waiting" | "retrying" | "cancelled"
remainingSeconds?: number
attempt?: number
maxAttempts?: number
origin: "pre_request" | "retry_attempt"
detail?: string
}

export interface TaskOptions extends CreateTaskOptions {
provider: ClineProvider
apiConfiguration: ProviderSettings
Expand Down Expand Up @@ -1073,8 +1083,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (partial !== undefined) {
const lastMessage = this.clineMessages.at(-1)

const isRateLimitUpdate = type === "api_req_retry_delayed" && options.metadata?.rateLimitRetry !== undefined
const isUpdatingPreviousPartial =
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
lastMessage &&
lastMessage.type === "say" &&
lastMessage.say === type &&
(lastMessage.partial || isRateLimitUpdate)

if (partial) {
if (isUpdatingPreviousPartial) {
Expand All @@ -1083,6 +1097,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
lastMessage.images = images
lastMessage.partial = partial
lastMessage.progressStatus = progressStatus
if (options.metadata) {
const messageWithMetadata = lastMessage as ClineMessage & ClineMessageWithMetadata
if (!messageWithMetadata.metadata) {
messageWithMetadata.metadata = {}
}
Object.assign(messageWithMetadata.metadata, options.metadata)
}
this.updateClineMessage(lastMessage)
} else {
// This is a new partial message, so add it with partial state.
Expand Down Expand Up @@ -1170,6 +1191,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
images,
checkpoint,
contextCondense,
metadata: options.metadata,
})
}
}
Expand Down Expand Up @@ -2556,6 +2578,124 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

let rateLimitDelay = 0

const sendRateLimitUpdate = async (payload: RateLimitRetryPayload, isPartial: boolean): Promise<void> => {
await this.say("api_req_retry_delayed", undefined, undefined, isPartial, undefined, undefined, {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Mark rate-limit status updates as non-interactive

api_req_retry_delayed is a UI-only status row and shouldn't mutate lastMessageTs. Currently sendRateLimitUpdate calls say() without isNonInteractive, which updates lastMessageTs for new messages in say(). This can interfere with Task.ask's blocking/waiting logic if a status update races with an ask.

Setting isNonInteractive true keeps the message ordering/UI unchanged while avoiding unintended ask lifecycle interactions.

Suggested change
await this.say("api_req_retry_delayed", undefined, undefined, isPartial, undefined, undefined, {
await this.say("api_req_retry_delayed", undefined, undefined, isPartial, undefined, undefined, {
isNonInteractive: true,
metadata: { rateLimitRetry: payload },
})

metadata: { rateLimitRetry: payload },
})
}

const runRateLimitCountdown = async ({
seconds,
origin,
attempt,
maxAttempts,
detail,
}: {
seconds: number
origin: RateLimitRetryPayload["origin"]
attempt?: number
maxAttempts?: number
detail?: string
}): Promise<boolean> => {
const normalizedSeconds = Math.max(0, Math.ceil(seconds))

if (normalizedSeconds <= 0) {
if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return false
}

await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "retrying",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return true
}

for (let i = normalizedSeconds; i > 0; i--) {
if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: i,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return false
}

await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "waiting",
remainingSeconds: i,
attempt,
maxAttempts,
origin,
detail,
},
true,
)

await delay(1000)
}

if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return false
}

await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "retrying",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)

return true
}

// Use the shared timestamp so that subtasks respect the same rate-limit
// window as their parent tasks.
if (Task.lastGlobalApiRequestTime) {
Expand All @@ -2567,11 +2707,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

// Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there.
if (rateLimitDelay > 0 && retryAttempt === 0) {
// Show countdown timer
for (let i = rateLimitDelay; i > 0; i--) {
const delayMessage = `Rate limiting for ${i} seconds...`
await this.say("api_req_retry_delayed", delayMessage, undefined, true)
await delay(1000)
const countdownCompleted = await runRateLimitCountdown({
seconds: rateLimitDelay,
origin: "pre_request",
attempt: 1,
})

if (!countdownCompleted) {
throw new Error(
`[RooCode#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during pre-request rate limit wait`,
)
}
}

Expand Down Expand Up @@ -2723,7 +2868,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (autoApprovalEnabled && alwaysApproveResubmit) {
let errorMsg
let errorMsg: string

if (error.error?.metadata?.raw) {
errorMsg = JSON.stringify(error.error.metadata.raw, null, 2)
Expand Down Expand Up @@ -2755,24 +2900,33 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Wait for the greater of the exponential delay or the rate limit delay
const finalDelay = Math.max(exponentialDelay, rateLimitDelay)

// Show countdown timer with exponential backoff
for (let i = finalDelay; i > 0; i--) {
await this.say(
"api_req_retry_delayed",
`${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying in ${i} seconds...`,
undefined,
true,
const sanitizedDetail = (() => {
if (!errorMsg) {
return undefined
}
const firstLine = errorMsg
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!firstLine) {
return undefined
}
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine
})()

const countdownCompleted = await runRateLimitCountdown({
seconds: finalDelay,
origin: "retry_attempt",
attempt: retryAttempt + 2,
detail: sanitizedDetail,
})

if (!countdownCompleted) {
throw new Error(
`[RooCode#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during rate limit retry wait`,
)
await delay(1000)
}

await this.say(
"api_req_retry_delayed",
`${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying now...`,
undefined,
false,
)

// Delegate generator output from the recursive call with
// incremented retry count.
yield* this.attemptApiRequest(retryAttempt + 1)
Expand Down
9 changes: 8 additions & 1 deletion webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay"
import { appendImages } from "@src/utils/imageUtils"
import { McpExecution } from "./McpExecution"
import { ChatTextArea } from "./ChatTextArea"
import { RateLimitRetryRow } from "./RateLimitRetryRow"
export { RateLimitRetryRow } from "./RateLimitRetryRow"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import { useSelectedModel } from "../ui/hooks/useSelectedModel"
import {
Expand Down Expand Up @@ -264,7 +266,7 @@ export const ChatRowContent = ({
<span style={{ color: successColor, fontWeight: "bold" }}>{t("chat:taskCompleted")}</span>,
]
case "api_req_retry_delayed":
return []
return [null, null]
case "api_req_started":
const getIconSpan = (iconName: string, color: string) => (
<div
Expand Down Expand Up @@ -1237,6 +1239,11 @@ export const ChatRowContent = ({
</div>
</>
)
case "api_req_retry_delayed":
// Prevent multiple blocks returning, we only need a single block
// that's constantly updated
if (!isLast) return null
return <RateLimitRetryRow metadata={message.metadata?.rateLimitRetry} />
case "shell_integration_warning":
return <CommandExecutionError />
case "checkpoint_saved":
Expand Down
9 changes: 7 additions & 2 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,9 +425,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Don't want to reset since there could be a "say" after
// an "ask" while ask is waiting for response.
switch (lastMessage.say) {
case "api_req_retry_delayed":
setSendingDisabled(true)
case "api_req_retry_delayed": {
if (lastMessage.metadata?.rateLimitRetry?.status === "cancelled") {
setSendingDisabled(false)
} else {
setSendingDisabled(true)
}
break
}
case "api_req_started":
if (secondLastMessage?.ask === "command_output") {
setSendingDisabled(true)
Expand Down
Loading