feat: port Claude Code CLI features and add parallel agent/worktree support

- Port session memory compaction, microcompact, hooks system, and
  prompt-too-long retry from the Claude Code CLI source
- Add spawn_parallel_tasks tool with serial queue drain fan-out pattern
- Add worktree isolation: new_task/spawn_parallel_tasks accept a worktree
  param; ClineProvider creates/cleans up git worktrees per child task
- Add completionPayload: attempt_completion result parsed as JSON and
  aggregated across parallel tasks for structured fan-in
- Fix workspacePath priority so explicit override always wins over parent
- Emit TaskSpawned event from delegateParentAndOpenChild
- Scope MCP hub to per-task workspacePath for worktree-aware mcp.json
- Fix microCompact bugs: touched flag, off-by-one threshold, keepRecent:0
- Fix lazy OutputChannel init in hooks executor to avoid test failures
- Fix pre-existing lint warnings across src, packages/cloud, webview-ui

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
davincidreams 2026-04-24 23:47:44 -04:00
parent dae4954696
commit be83bb6698
43 changed files with 4917 additions and 218 deletions

59
.roo-hooks.json.example Normal file
View file

@ -0,0 +1,59 @@
{
"_comment": "Example hooks configuration file for Roo Code. Rename to .roo-hooks.json to use.",
"_comment2": "Hooks are executed before (PreCompact) and after (PostCompact) context condensation.",
"_comment3": "Each hook can be a command (shell command) or http (HTTP POST request).",
"PreCompact": [
{
"matcher": "auto",
"hooks": [
{
"id": "log-pre-compact",
"type": "command",
"description": "Log before automatic condensation",
"command": "echo 'Pre-compact hook triggered at $(date)'",
"shell": "bash",
"timeout": 10
}
]
},
{
"matcher": "manual",
"hooks": [
{
"id": "log-manual-compact",
"type": "command",
"description": "Log before manual condensation",
"command": "echo 'Manual compact triggered'",
"shell": "bash"
}
]
}
],
"PostCompact": [
{
"matcher": "auto",
"hooks": [
{
"id": "log-post-compact",
"type": "command",
"description": "Log after automatic condensation",
"command": "echo 'Post-compact hook: Summary length is ${#HOOK_INPUT}'",
"shell": "bash",
"timeout": 10
},
{
"id": "notify-webhook",
"type": "http",
"description": "Send notification to webhook",
"url": "https://example.com/webhook",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
},
"timeout": 30
}
]
}
]
}

View file

@ -1,10 +1,7 @@
import { z } from "zod"
import { type AuthService, type ShareVisibility, type ShareResponse, shareResponseSchema } from "@roo-code/types"
import { type AuthService, type ShareVisibility, type ShareResponse } from "@roo-code/types"
import { getRooCodeApiUrl } from "./config.js"
import { getUserAgent } from "./utils.js"
import { AuthenticationError, CloudAPIError, NetworkError, TaskNotFoundError } from "./errors.js"
import { CloudAPIError } from "./errors.js"
interface CloudAPIRequestOptions extends Omit<RequestInit, "headers"> {
timeout?: number

View file

@ -165,7 +165,7 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements Di
},
)
this._telemetryClient = new TelemetryClient(this._authService, this._settingsService, this._retryQueue)
this._telemetryClient = new TelemetryClient(this._authService, this._settingsService)
this._shareService = new CloudShareService(this._cloudAPI, this._settingsService, this.log)

View file

@ -9,6 +9,7 @@ import {
} from "@roo-code/types"
abstract class BaseTelemetryClient implements TelemetryClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected providerRef: WeakRef<any> | null = null
protected telemetryEnabled: boolean = false
@ -66,6 +67,7 @@ abstract class BaseTelemetryClient implements TelemetryClient {
public async captureException(_error: Error, _additionalProperties?: Record<string, unknown>): Promise<void> {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public setProvider(provider: any): void {
this.providerRef = new WeakRef(provider)
}

View file

@ -105,7 +105,7 @@ describe("CloudSettingsService", () => {
describe("updateUserSettings", () => {
it("should return false when cloud features are disabled", async () => {
const result = await cloudSettingsService.updateUserSettings({ someSetting: "value" })
const result = await cloudSettingsService.updateUserSettings({})
expect(result).toBe(false)
})
})

View file

@ -90,7 +90,7 @@ describe("WebAuthService", () => {
it("should always end in logged-out state regardless of stored credentials", async () => {
// Even if credentials exist, the no-op service goes to logged-out
;(mockContext as any).secrets.get.mockResolvedValue(
vi.mocked(mockContext.secrets.get).mockResolvedValue(
JSON.stringify({ clientToken: "test-token", sessionId: "test-session" }),
)
@ -226,6 +226,7 @@ describe("WebAuthService", () => {
describe("dispose", () => {
it("should remove all listeners", () => {
const listener = vi.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authService.on("auth-state-changed" as any, listener)
authService.dispose()
// After dispose, emitting should not reach the listener

View file

@ -176,17 +176,6 @@ export class RetryQueue extends EventEmitter<RetryQueueEvents> {
private async retryRequest(request: QueuedRequest): Promise<Response> {
this.log(`[RetryQueue] Retrying request: ${request.url}`)
let headers = { ...request.options.headers }
if (this.authHeaderProvider) {
const freshAuthHeaders = this.authHeaderProvider()
if (freshAuthHeaders) {
headers = {
...headers,
...freshAuthHeaders,
}
}
}
// Cloud features disabled — skip actual HTTP fetch, return fake success
this.log(`[RetryQueue] Cloud features disabled - skipping request to ${request.url}`)

View file

@ -196,7 +196,7 @@ describe("RetryQueue", () => {
it("should clear queue when user changes", async () => {
// Since enqueue is a no-op, we can't add items
// But clear mechanism should still work
let stats = retryQueue.getStats()
const stats = retryQueue.getStats()
expect(stats.totalQueued).toBe(0)
retryQueue.setCurrentUserId("user_123")

View file

@ -26,6 +26,27 @@ export const historyItemSchema = z.object({
awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated)
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
completionResultSummary: z.string().optional(), // Summary from completed child
completionPayload: z.record(z.unknown()).optional(), // Structured JSON result from child
worktreePath: z.string().optional(), // Worktree path if this task ran in an isolated worktree
parallelQueue: z
.array(
z.object({
mode: z.string(),
message: z.string(),
worktree: z.string().optional(),
todos: z.string().optional(),
}),
)
.optional(), // Remaining tasks for spawn_parallel_tasks fan-out
parallelResults: z
.array(
z.object({
taskId: z.string(),
summary: z.string(),
payload: z.record(z.unknown()).optional(),
}),
)
.optional(), // Accumulated results from completed parallel children
})
export type HistoryItem = z.infer<typeof historyItemSchema>

View file

@ -99,6 +99,8 @@ export interface CreateTaskOptions {
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
/** Override the workspace path for this task (e.g., a git worktree). Takes priority over parent task's path. */
workspacePath?: string
}
export enum TaskStatus {

View file

@ -40,6 +40,7 @@ export const toolNames = [
"attempt_completion",
"switch_mode",
"new_task",
"spawn_parallel_tasks",
"codebase_search",
"update_todo_list",
"run_slash_command",

View file

@ -461,6 +461,7 @@ export interface WebviewMessage {
| "ttsEnabled"
| "ttsSpeed"
| "openKeyboardShortcuts"
| "openVSCodeSettings"
| "openMcpSettings"
| "openProjectMcpSettings"
| "restartMcpServer"

View file

@ -123,6 +123,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
return this.currentReasoningDetails.length > 0 ? this.currentReasoningDetails : undefined
}
// eslint-disable-next-line require-yield
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],

View file

@ -30,6 +30,7 @@ import { askFollowupQuestionTool } from "../tools/AskFollowupQuestionTool"
import { switchModeTool } from "../tools/SwitchModeTool"
import { attemptCompletionTool, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool"
import { newTaskTool } from "../tools/NewTaskTool"
import { spawnParallelTasksTool } from "../tools/SpawnParallelTasksTool"
import { updateTodoListTool } from "../tools/UpdateTodoListTool"
import { runSlashCommandTool } from "../tools/RunSlashCommandTool"
import { skillTool } from "../tools/SkillTool"
@ -377,6 +378,11 @@ export async function presentAssistantMessage(cline: Task) {
const modeName = getModeBySlug(mode, customModes)?.name ?? mode
return `[${block.name} in ${modeName} mode: '${message}']`
}
case "spawn_parallel_tasks": {
const tasks = block.params.tasks as Array<{ mode: string; message: string }> | undefined
const count = tasks?.length ?? 0
return `[${block.name}: ${count} task${count !== 1 ? "s" : ""}]`
}
case "run_slash_command":
return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]`
case "skill":
@ -812,6 +818,15 @@ export async function presentAssistantMessage(cline: Task) {
toolCallId: block.id,
})
break
case "spawn_parallel_tasks":
await checkpointSaveAndMark(cline)
await spawnParallelTasksTool.handle(cline, block as ToolUse<"spawn_parallel_tasks">, {
askApproval,
handleError,
pushToolResult,
toolCallId: block.id,
})
break
case "attempt_completion": {
const completionCallbacks: AttemptCompletionCallbacks = {
askApproval,

View file

@ -0,0 +1,609 @@
// npx vitest src/core/condense/__tests__/microCompact.spec.ts
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiMessage } from "../../task-persistence/apiMessages"
import {
microcompactMessages,
estimateMicrocompactSavings,
DEFAULT_MICROCOMPACT_CONFIG,
MICROCOMPACT_CLEARED_MESSAGE,
type MicrocompactConfig,
} from "../microCompact"
describe("Microcompact", () => {
describe("microcompactMessages", () => {
it("should return messages unchanged when disabled", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "This is a long file content that should be cleared",
},
],
},
]
const result = microcompactMessages(messages, { enabled: false })
expect(result.messages).toEqual(messages)
expect(result.tokensSaved).toBe(0)
expect(result.toolsCleared).toBe(0)
expect(result.toolsKept).toBe(0)
})
it("should return messages unchanged when there are not enough tools", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "This is a long file content that should be cleared",
},
],
},
]
const result = microcompactMessages(messages, { threshold: 5, keepRecent: 3 })
expect(result.messages).toEqual(messages)
expect(result.tokensSaved).toBe(0)
expect(result.toolsCleared).toBe(0)
expect(result.toolsKept).toBe(1)
})
it("should clear old tool results when threshold is exceeded", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "file1.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "Content of file 1",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-2",
name: "read_file",
input: { path: "file2.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-2",
content: "Content of file 2",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-3",
name: "read_file",
input: { path: "file3.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-3",
content: "Content of file 3",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-4",
name: "read_file",
input: { path: "file4.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-4",
content: "Content of file 4",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-5",
name: "read_file",
input: { path: "file5.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-5",
content: "Content of file 5",
},
],
},
]
const result = microcompactMessages(messages, { threshold: 3, keepRecent: 2 })
expect(result.toolsCleared).toBe(3)
expect(result.toolsKept).toBe(2)
expect(result.tokensSaved).toBeGreaterThan(0)
// Check that the last 2 tool results are preserved
const lastUserMessage = result.messages[result.messages.length - 1]
if (Array.isArray(lastUserMessage.content)) {
const toolResult = lastUserMessage.content[0] as Anthropic.Messages.ToolResultBlockParam
expect(toolResult.content).toBe("Content of file 5")
}
})
it("should only compact compactable tools", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
{
type: "tool_use",
id: "tool-2",
name: "non_compactable_tool",
input: { data: "test" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "Content of file 1",
},
{
type: "tool_result",
tool_use_id: "tool-2",
content: "Content of non-compactable tool",
},
],
},
]
const result = microcompactMessages(messages, { threshold: 1, keepRecent: 0 })
// Only the compactable tool should be cleared
expect(result.toolsCleared).toBe(1)
expect(result.toolsKept).toBe(0)
// Check that the non-compactable tool is not cleared
const userMessage = result.messages[1]
if (Array.isArray(userMessage.content)) {
const toolResult2 = userMessage.content[1] as Anthropic.Messages.ToolResultBlockParam
expect(toolResult2.content).toBe("Content of non-compactable tool")
}
})
it("should handle array content in tool results", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: [
{ type: "text", text: "Text content" },
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "base64data",
},
},
],
},
],
},
]
const result = microcompactMessages(messages, { threshold: 1, keepRecent: 0 })
expect(result.toolsCleared).toBe(1)
expect(result.tokensSaved).toBeGreaterThan(0)
// Check that the content was cleared
const userMessage = result.messages[1]
if (Array.isArray(userMessage.content)) {
const toolResult = userMessage.content[0] as Anthropic.Messages.ToolResultBlockParam
expect(toolResult.content).toBe(MICROCOMPACT_CLEARED_MESSAGE.replace("{toolName}", "read_file"))
}
})
it("should not double-clear already cleared content", () => {
const clearedMessage = MICROCOMPACT_CLEARED_MESSAGE.replace("{toolName}", "read_file")
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: clearedMessage,
},
],
},
]
const result = microcompactMessages(messages, { threshold: 1, keepRecent: 0 })
// Should not count as clearing since it's already cleared
expect(result.toolsCleared).toBe(0)
expect(result.tokensSaved).toBe(0)
})
it("should use default config when no config is provided", () => {
const messages: ApiMessage[] = []
const result = microcompactMessages(messages)
expect(result.messages).toEqual(messages)
expect(result.tokensSaved).toBe(0)
})
it("should preserve message structure and metadata", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
ts: 1234567890,
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "Content to be cleared",
},
],
ts: 1234567891,
id: "user-msg-1",
},
]
const result = microcompactMessages(messages, { threshold: 1, keepRecent: 0 })
// Check that message structure is preserved
expect(result.messages[0].ts).toBe(1234567890)
expect(result.messages[1].ts).toBe(1234567891)
expect(result.messages[1].id).toBe("user-msg-1")
})
})
describe("estimateMicrocompactSavings", () => {
it("should return 0 when disabled", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "This is a long file content",
},
],
},
]
const savings = estimateMicrocompactSavings(messages, { enabled: false })
expect(savings).toBe(0)
})
it("should return 0 when there are not enough tools", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "This is a long file content",
},
],
},
]
const savings = estimateMicrocompactSavings(messages, { threshold: 5, keepRecent: 3 })
expect(savings).toBe(0)
})
it("should estimate token savings correctly", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "file1.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "Content of file 1",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-2",
name: "read_file",
input: { path: "file2.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-2",
content: "Content of file 2",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-3",
name: "read_file",
input: { path: "file3.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-3",
content: "Content of file 3",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-4",
name: "read_file",
input: { path: "file4.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-4",
content: "Content of file 4",
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-5",
name: "read_file",
input: { path: "file5.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-5",
content: "Content of file 5",
},
],
},
]
const savings = estimateMicrocompactSavings(messages, { threshold: 3, keepRecent: 2 })
// Should estimate savings for the first 3 tools (tools 1-3)
expect(savings).toBeGreaterThan(0)
})
it("should not modify messages when estimating savings", () => {
const messages: ApiMessage[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "Original content",
},
],
},
]
const originalContent = JSON.stringify(messages)
estimateMicrocompactSavings(messages, { threshold: 1, keepRecent: 0 })
const afterEstimateContent = JSON.stringify(messages)
expect(originalContent).toBe(afterEstimateContent)
})
})
describe("DEFAULT_MICROCOMPACT_CONFIG", () => {
it("should have correct default values", () => {
expect(DEFAULT_MICROCOMPACT_CONFIG.enabled).toBe(true)
expect(DEFAULT_MICROCOMPACT_CONFIG.threshold).toBe(5)
expect(DEFAULT_MICROCOMPACT_CONFIG.keepRecent).toBe(3)
})
})
describe("MICROCOMPACT_CLEARED_MESSAGE", () => {
it("should contain placeholder for tool name", () => {
expect(MICROCOMPACT_CLEARED_MESSAGE).toContain("{toolName}")
})
it("should produce correct message when placeholder is replaced", () => {
const message = MICROCOMPACT_CLEARED_MESSAGE.replace("{toolName}", "read_file")
expect(message).toBe("[Content condensed - tool: read_file]")
})
})
})

View file

@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
isPromptTooLongError,
parsePromptTooLongTokenGap,
truncateHeadForPromptTooLongRetry,
groupMessagesByApiRound,
estimateMessageTokens,
} from "../index"
import type { ApiMessage } from "../../task-persistence/apiMessages"
describe("Prompt-Too-Long Retry", () => {
describe("isPromptTooLongError", () => {
it("should detect Anthropic prompt-too-long error", () => {
expect(isPromptTooLongError("prompt is too long")).toBe(true)
expect(isPromptTooLongError("Prompt is too long")).toBe(true)
expect(isPromptTooLongError("PROMPT IS TOO LONG")).toBe(true)
})
it("should detect OpenAI prompt-too-long error", () => {
expect(isPromptTooLongError("maximum context length exceeded")).toBe(true)
expect(isPromptTooLongError("This model's maximum context length is 200000 tokens")).toBe(true)
})
it("should detect generic prompt-too-long errors", () => {
expect(isPromptTooLongError("context length exceeded")).toBe(true)
expect(isPromptTooLongError("too many tokens")).toBe(true)
expect(isPromptTooLongError("tokens exceed limit")).toBe(true)
})
it("should not detect other errors", () => {
expect(isPromptTooLongError("rate limit exceeded")).toBe(false)
expect(isPromptTooLongError("invalid API key")).toBe(false)
expect(isPromptTooLongError("network error")).toBe(false)
expect(isPromptTooLongError("")).toBe(false)
})
})
describe("parsePromptTooLongTokenGap", () => {
it("should parse token gap from Anthropic error format", () => {
expect(parsePromptTooLongTokenGap("prompt is too long: 137500 tokens > 135000 maximum")).toBe(2500)
expect(parsePromptTooLongTokenGap("Prompt is too long: 200000 tokens > 100000 maximum")).toBe(100000)
})
it("should parse token gap from 'exceeded by' format", () => {
expect(parsePromptTooLongTokenGap("exceeded by 5000 tokens")).toBe(5000)
expect(parsePromptTooLongTokenGap("Context exceeded by 1000 tokens")).toBe(1000)
})
it("should return undefined for unparseable messages", () => {
expect(parsePromptTooLongTokenGap("prompt is too long")).toBeUndefined()
expect(parsePromptTooLongTokenGap("rate limit exceeded")).toBeUndefined()
expect(parsePromptTooLongTokenGap("")).toBeUndefined()
})
it("should handle case-insensitive matching", () => {
expect(parsePromptTooLongTokenGap("PROMPT IS TOO LONG: 15000 TOKENS > 10000 MAXIMUM")).toBe(5000)
})
})
describe("estimateMessageTokens", () => {
it("should estimate tokens for string content", () => {
const msg: ApiMessage = {
role: "user",
content: "Hello world!",
ts: Date.now(),
}
expect(estimateMessageTokens(msg)).toBeGreaterThan(0)
})
it("should estimate tokens for text blocks", () => {
const msg: ApiMessage = {
role: "user",
content: [{ type: "text", text: "This is a test message" }],
ts: Date.now(),
}
expect(estimateMessageTokens(msg)).toBeGreaterThan(0)
})
it("should estimate tokens for image blocks", () => {
const msg: ApiMessage = {
role: "user",
content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } }],
ts: Date.now(),
}
expect(estimateMessageTokens(msg)).toBe(1000) // Fixed estimate for images
})
it("should estimate tokens for tool blocks", () => {
const msg: ApiMessage = {
role: "assistant",
content: [{ type: "tool_use", id: "123", name: "test", input: {} }],
ts: Date.now(),
}
expect(estimateMessageTokens(msg)).toBe(100) // Fixed estimate for tool blocks
})
it("should estimate tokens for mixed content", () => {
const msg: ApiMessage = {
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
],
ts: Date.now(),
}
expect(estimateMessageTokens(msg)).toBeGreaterThan(0)
})
})
describe("groupMessagesByApiRound", () => {
it("should group messages by API round", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "User 1", ts: 1 },
{ role: "assistant", content: "Assistant 1", ts: 2 },
{ role: "user", content: "User 2", ts: 3 },
{ role: "assistant", content: "Assistant 2", ts: 4 },
]
const groups = groupMessagesByApiRound(messages)
expect(groups).toHaveLength(2)
expect(groups[0]).toHaveLength(2)
expect(groups[1]).toHaveLength(2)
expect(groups[0][0].role).toBe("user")
expect(groups[0][1].role).toBe("assistant")
})
it("should handle single user message", () => {
const messages: ApiMessage[] = [{ role: "user", content: "User 1", ts: 1 }]
const groups = groupMessagesByApiRound(messages)
expect(groups).toHaveLength(1)
expect(groups[0]).toHaveLength(1)
})
it("should handle assistant-first sequence", () => {
const messages: ApiMessage[] = [
{ role: "assistant", content: "Assistant 1", ts: 1 },
{ role: "user", content: "User 1", ts: 2 },
]
const groups = groupMessagesByApiRound(messages)
expect(groups).toHaveLength(2)
expect(groups[0]).toHaveLength(1)
expect(groups[1]).toHaveLength(1)
})
it("should handle empty array", () => {
const groups = groupMessagesByApiRound([])
expect(groups).toHaveLength(0)
})
})
describe("truncateHeadForPromptTooLongRetry", () => {
const createMessages = (count: number): ApiMessage[] => {
const messages: ApiMessage[] = []
for (let i = 0; i < count; i++) {
messages.push({ role: "user", content: `User ${i}`, ts: i * 2 })
messages.push({ role: "assistant", content: `Assistant ${i}`, ts: i * 2 + 1 })
}
return messages
}
it("should truncate messages based on token gap", () => {
const messages = createMessages(10)
const errorMessage = "prompt is too long: 5000 tokens > 3000 maximum"
const truncated = truncateHeadForPromptTooLongRetry(messages, errorMessage)
expect(truncated).not.toBeNull()
expect(truncated!.length).toBeLessThan(messages.length)
expect(truncated!.length).toBeGreaterThan(0)
})
it("should truncate 20% of groups when token gap is unparseable", () => {
const messages = createMessages(10)
const errorMessage = "prompt is too long"
const truncated = truncateHeadForPromptTooLongRetry(messages, errorMessage)
expect(truncated).not.toBeNull()
expect(truncated!.length).toBeLessThan(messages.length)
expect(truncated!.length).toBeGreaterThan(0)
})
it("should keep at least one group", () => {
const messages = createMessages(2)
const errorMessage = "prompt is too long: 10000 tokens > 1000 maximum"
const truncated = truncateHeadForPromptTooLongRetry(messages, errorMessage)
expect(truncated).not.toBeNull()
expect(truncated!.length).toBeGreaterThan(0)
})
it("should return null when there are not enough groups", () => {
const messages: ApiMessage[] = [{ role: "user", content: "User 1", ts: 1 }]
const errorMessage = "prompt is too long: 10000 tokens > 1000 maximum"
const truncated = truncateHeadForPromptTooLongRetry(messages, errorMessage)
expect(truncated).toBeNull()
})
it("should prepend synthetic user marker when first message is assistant", () => {
const messages: ApiMessage[] = [
{ role: "assistant", content: "Assistant 1", ts: 1 },
{ role: "user", content: "User 1", ts: 2 },
{ role: "assistant", content: "Assistant 2", ts: 3 },
]
const errorMessage = "prompt is too long: 5000 tokens > 3000 maximum"
const truncated = truncateHeadForPromptTooLongRetry(messages, errorMessage)
expect(truncated).not.toBeNull()
expect(truncated![0].role).toBe("user")
expect(truncated![0].isMeta).toBe(true)
})
it("should strip previous retry marker before grouping", () => {
const messages: ApiMessage[] = [
{
role: "user",
content: "[earlier conversation truncated for condensation retry]",
ts: 1,
isMeta: true,
},
{ role: "user", content: "User 1", ts: 2 },
{ role: "assistant", content: "Assistant 1", ts: 3 },
{ role: "user", content: "User 2", ts: 4 },
{ role: "assistant", content: "Assistant 2", ts: 5 },
]
const errorMessage = "prompt is too long: 5000 tokens > 3000 maximum"
const truncated = truncateHeadForPromptTooLongRetry(messages, errorMessage)
expect(truncated).not.toBeNull()
// Should not have duplicate markers
const markers = truncated!.filter(
(m) => m.isMeta && typeof m.content === "string" && m.content.includes("truncated"),
)
expect(markers.length).toBeLessThanOrEqual(1)
})
it("should return null when all messages would be dropped", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "User 1", ts: 1 },
{ role: "assistant", content: "Assistant 1", ts: 2 },
]
const errorMessage = "prompt is too long: 100000 tokens > 1000 maximum"
const truncated = truncateHeadForPromptTooLongRetry(messages, errorMessage)
// Should keep at least one group
expect(truncated).not.toBeNull()
expect(truncated!.length).toBeGreaterThan(0)
})
})
})

View file

@ -0,0 +1,408 @@
/**
* Tests for Session Memory Compaction
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import { ApiMessage } from "../../task-persistence/apiMessages"
import {
adjustIndexToPreserveAPIInvariants,
calculateMessagesToKeepIndex,
trySessionMemoryCompaction,
setSessionMemoryCompactConfig,
resetSessionMemoryCompactConfig,
getSessionMemoryCompactConfig,
DEFAULT_SM_COMPACT_CONFIG,
resetAllSessionMemoryState,
} from "../sessionMemoryCompact"
import {
setSessionMemoryConfig,
resetSessionMemoryState,
estimateMessageTokens,
hasTextBlocks,
setLastSummarizedMessageId,
} from "../sessionMemory"
describe("Session Memory Compact", () => {
beforeEach(() => {
resetAllSessionMemoryState()
})
describe("adjustIndexToPreserveAPIInvariants", () => {
it("should return the same index when at boundaries", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "test1", ts: 1 },
{ role: "assistant", content: [{ type: "text", text: "response1" }], ts: 2 },
{ role: "user", content: "test2", ts: 3 },
]
expect(adjustIndexToPreserveAPIInvariants(messages, 0)).toBe(0)
expect(adjustIndexToPreserveAPIInvariants(messages, 3)).toBe(3)
})
it("should preserve tool_use/tool_result pairs", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "old message", ts: 1 },
{
role: "assistant",
content: [
{ type: "text", text: "I'll read a file" },
{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "test.txt" } },
],
ts: 2,
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file content" }],
ts: 3,
},
{ role: "user", content: "new message", ts: 4 },
]
// Starting at index 2 (the tool_result user message): the tool_result needs its
// tool_use at index 1 to be in the kept range — so we extend back to 1.
const adjusted = adjustIndexToPreserveAPIInvariants(messages, 2)
expect(adjusted).toBe(1)
// Starting at index 3 (plain "new message"): no tool_results in kept range,
// so no adjustment is needed.
const unchanged = adjustIndexToPreserveAPIInvariants(messages, 3)
expect(unchanged).toBe(3)
})
it("should preserve thinking blocks with same message.id", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "old message", ts: 1 },
{
role: "assistant",
id: "msg_1",
content: [{ type: "text", text: "thinking..." }],
ts: 2,
},
{
role: "assistant",
id: "msg_1",
content: [{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "test.txt" } }],
ts: 3,
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file content" }],
ts: 4,
},
{ role: "user", content: "new message", ts: 5 },
]
// Starting at index 3 (the tool_result): extend back to 2 (tool_use) for the
// tool_use/tool_result pair, then back to 1 (thinking block with same msg_1 id).
const adjusted = adjustIndexToPreserveAPIInvariants(messages, 3)
expect(adjusted).toBe(1)
})
})
describe("calculateMessagesToKeepIndex", () => {
it("should return 0 for empty messages", () => {
expect(calculateMessagesToKeepIndex([], 0)).toBe(0)
})
it("should respect minTokens threshold", () => {
const messages: ApiMessage[] = Array.from({ length: 20 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: i % 2 === 0 ? `message ${i}` : [{ type: "text", text: `response ${i}` }],
ts: i,
}))
setSessionMemoryCompactConfig({ minTokens: 100000, minTextBlockMessages: 0 })
// With a very high minTokens, should keep all messages
const result = calculateMessagesToKeepIndex(messages, -1)
expect(result).toBe(0)
})
it("should respect minTextBlockMessages threshold", () => {
const messages: ApiMessage[] = Array.from({ length: 20 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: i % 2 === 0 ? `message ${i}` : [{ type: "text", text: `response ${i}` }],
ts: i,
}))
setSessionMemoryCompactConfig({ minTokens: 0, minTextBlockMessages: 15 })
// With a high minTextBlockMessages, should keep enough messages
const result = calculateMessagesToKeepIndex(messages, -1)
expect(result).toBeLessThan(messages.length)
})
it("should respect maxTokens cap", () => {
const messages: ApiMessage[] = Array.from({ length: 100 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: i % 2 === 0 ? `message ${i}` : [{ type: "text", text: `response ${i}` }],
ts: i,
}))
setSessionMemoryCompactConfig({ maxTokens: 100 })
// With a low maxTokens, should keep fewer messages
const result = calculateMessagesToKeepIndex(messages, -1)
expect(result).toBeGreaterThan(0)
expect(result).toBeLessThan(messages.length)
})
it("should start from lastSummarizedIndex when provided", () => {
const messages: ApiMessage[] = Array.from({ length: 20 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: i % 2 === 0 ? `message ${i}` : [{ type: "text", text: `response ${i}` }],
ts: i,
}))
setSessionMemoryCompactConfig({ minTokens: 0, minTextBlockMessages: 0 })
// Should start after the last summarized index
const result = calculateMessagesToKeepIndex(messages, 10)
expect(result).toBeGreaterThan(10)
})
})
describe("trySessionMemoryCompaction", () => {
it("should return null when session memory is empty", async () => {
const messages: ApiMessage[] = [{ role: "user", content: "test", ts: 1 }]
const apiHandler = {
createMessage: vi.fn(),
} as any
const result = await trySessionMemoryCompaction(
messages,
"", // Empty session memory
apiHandler,
"test-task",
)
expect(result).toBeNull()
})
it("should return null when session memory matches template", async () => {
const messages: ApiMessage[] = [{ role: "user", content: "test", ts: 1 }]
const apiHandler = {
createMessage: vi.fn(),
} as any
// This is the default template
const template = `
# Session Title
_A short and distinctive 5-10 word descriptive title for the session. Super info dense, no filler_
# Current State
_What is actively being worked on right now? Pending tasks not yet completed. Immediate next steps._
# Task specification
_What did the user ask to build? Any design decisions or other explanatory context_
# Files and Functions
_What are the important files? In short, what do they contain and why are they relevant?_
# Workflow
_What bash commands are usually run and in what order? How to interpret their output if not obvious?_
# Errors & Corrections
_Errors encountered and how they were fixed. What did the user correct? What approaches failed and should not be tried again?_
# Codebase and System Documentation
_What are the important system components? How do they work/fit together?_
# Learnings
_What has worked well? What has not? What to avoid? Do not duplicate items from other sections_
# Key results
_If the user asked a specific output such as an answer to a question, a table, or other document, repeat the exact result here_
# Worklog
_Step by step, what was attempted, done? Very terse summary for each step_
`
const result = await trySessionMemoryCompaction(messages, template, apiHandler, "test-task")
expect(result).toBeNull()
})
it("should succeed with valid session memory", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "test", ts: 1, id: "msg_1" },
{
role: "assistant",
content: [{ type: "text", text: "response" }],
ts: 2,
id: "msg_2",
},
]
const apiHandler = {
createMessage: vi.fn(),
} as any
const sessionMemory = `
# Session Title
Test Session
# Current State
Working on implementing a feature
# Task specification
Build a new feature for the application
# Files and Functions
- src/index.ts: Main entry point
- src/utils.ts: Utility functions
# Workflow
1. Read files
2. Make changes
3. Test
# Errors & Corrections
No errors yet
# Codebase and System Documentation
The app uses TypeScript and React
# Learnings
- Use TypeScript for type safety
# Key results
None yet
# Worklog
- Started implementation
`
setLastSummarizedMessageId("msg_1")
const result = await trySessionMemoryCompaction(messages, sessionMemory, apiHandler, "test-task")
expect(result).not.toBeNull()
expect(result?.messages).toBeDefined()
expect(result?.summary).toContain("Session Memory")
expect(result?.cost).toBe(0)
expect(result?.condenseId).toBeDefined()
})
it("should handle resumed sessions without lastSummarizedMessageId", async () => {
const messages: ApiMessage[] = [
{ role: "user", content: "test", ts: 1, id: "msg_1" },
{
role: "assistant",
content: [{ type: "text", text: "response" }],
ts: 2,
id: "msg_2",
},
]
const apiHandler = {
createMessage: vi.fn(),
} as any
const sessionMemory = `
# Session Title
Test Session
# Current State
Working on implementing a feature
`
// Don't set lastSummarizedMessageId - simulating resumed session
const result = await trySessionMemoryCompaction(messages, sessionMemory, apiHandler, "test-task")
expect(result).not.toBeNull()
})
})
describe("Configuration", () => {
it("should use default configuration", () => {
const config = {
...DEFAULT_SM_COMPACT_CONFIG,
}
expect(config.minTokens).toBe(10000)
expect(config.minTextBlockMessages).toBe(5)
expect(config.maxTokens).toBe(50000)
})
it("should allow configuration updates", () => {
setSessionMemoryCompactConfig({
minTokens: 15000,
maxTokens: 60000,
})
const config = getSessionMemoryCompactConfig()
expect(config.minTokens).toBe(15000)
expect(config.maxTokens).toBe(60000)
})
it("should reset configuration", () => {
setSessionMemoryCompactConfig({
minTokens: 15000,
})
resetSessionMemoryCompactConfig()
const config = {
...DEFAULT_SM_COMPACT_CONFIG,
}
expect(config.minTokens).toBe(10000)
})
})
describe("Session Memory Utils", () => {
beforeEach(() => {
resetSessionMemoryState()
})
it("should estimate message tokens correctly", () => {
const messages: ApiMessage[] = [
{ role: "user", content: "This is a test message with some text", ts: 1 },
{
role: "assistant",
content: [
{ type: "text", text: "This is a response" },
{ type: "tool_use", id: "tool_1", name: "read_file", input: { path: "test.txt" } },
],
ts: 2,
},
]
const tokens = estimateMessageTokens(messages)
expect(tokens).toBeGreaterThan(0)
})
it("should detect messages with text blocks", () => {
const messageWithText: ApiMessage = {
role: "assistant",
content: [{ type: "text", text: "response" }],
ts: 1,
}
const messageWithoutText: ApiMessage = {
role: "assistant",
content: [{ type: "tool_use", id: "tool_1", name: "test", input: {} }],
ts: 2,
}
expect(hasTextBlocks(messageWithText)).toBe(true)
expect(hasTextBlocks(messageWithoutText)).toBe(false)
})
it("should handle session memory configuration", () => {
setSessionMemoryConfig({
minimumMessageTokensToInit: 15000,
minimumTokensBetweenUpdate: 6000,
toolCallsBetweenUpdates: 5,
})
const config = {
...DEFAULT_SM_COMPACT_CONFIG,
}
// Session memory config is separate from compact config
// Just verify the function doesn't throw
expect(() => setSessionMemoryConfig({})).not.toThrow()
})
})
})

View file

@ -11,8 +11,15 @@ import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { generateFoldedFileContext } from "./foldedFileContext"
import type { HookSystem } from "../hooks"
export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext"
export * from "./microCompact"
export * from "./sessionMemory"
export * from "./sessionMemoryCompact"
// Export helper functions for testing
export { groupMessagesByApiRound, estimateMessageTokens }
/**
* Converts a tool_use block to a text representation.
@ -111,6 +118,182 @@ export function transformMessagesForCondensing<
export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing
export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing
// Prompt-too-long retry configuration
const MAX_PTL_RETRIES = 2 // Maximum number of retry attempts when condensation hits prompt-too-long error
const PTL_RETRY_MARKER = "[earlier conversation truncated for condensation retry]"
/**
* Checks if an error message indicates a "prompt too long" error from the API.
* This handles multiple providers that may return different error messages.
*
* @param errorMessage - The error message to check
* @returns True if this is a prompt-too-long error, false otherwise
*/
export function isPromptTooLongError(errorMessage: string): boolean {
const lowerMessage = errorMessage.toLowerCase()
// Common patterns across providers:
// - Anthropic: "prompt is too long"
// - OpenAI: "maximum context length exceeded"
// - Generic: "context length exceeded", "too many tokens"
return (
lowerMessage.includes("prompt is too long") ||
lowerMessage.includes("maximum context length exceeded") ||
lowerMessage.includes("context length exceeded") ||
lowerMessage.includes("too many tokens") ||
lowerMessage.includes("tokens exceed")
)
}
/**
* Parses the token gap from a prompt-too-long error message.
* Returns the number of tokens over the limit, or undefined if unparseable.
*
* @param errorMessage - The error message from the API
* @returns The number of tokens over the limit, or undefined
*/
export function parsePromptTooLongTokenGap(errorMessage: string): number | undefined {
// Try to match patterns like "137500 tokens > 135000 maximum" or "exceeded by 2500 tokens"
const match =
errorMessage.match(/(\d+)\s*tokens?\s*>\s*(\d+)/i) || errorMessage.match(/exceeded\s+by\s+(\d+)\s*tokens?/i)
if (match) {
const actual = parseInt(match[1], 10)
const limit = match[2] ? parseInt(match[2], 10) : actual
return Math.max(0, actual - limit)
}
return undefined
}
/**
* Groups messages by API round (user message + assistant response).
* This is used for intelligent truncation that removes complete conversation turns.
*
* @param messages - The messages to group
* @returns Array of message groups, where each group represents one API round
*/
function groupMessagesByApiRound(messages: ApiMessage[]): ApiMessage[][] {
const groups: ApiMessage[][] = []
let currentGroup: ApiMessage[] = []
for (const msg of messages) {
if (msg.role === "user") {
// Start a new group when we see a user message
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
currentGroup = [msg]
} else {
// Add assistant messages to the current group
currentGroup.push(msg)
}
}
// Don't forget the last group
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
return groups
}
/**
* Estimates the token count for a message.
* This is a rough estimation used for determining how many groups to drop.
*
* @param msg - The message to estimate tokens for
* @returns Estimated token count
*/
function estimateMessageTokens(msg: ApiMessage): number {
const content = msg.content
if (typeof content === "string") {
// Rough estimate: ~4 characters per token
return Math.ceil(content.length / 4)
} else if (Array.isArray(content)) {
let total = 0
for (const block of content) {
if (block.type === "text") {
total += Math.ceil(block.text.length / 4)
} else if (block.type === "image") {
// Images are expensive, estimate conservatively
total += 1000
} else {
// Other blocks (tool_use, tool_result, etc.)
total += 100
}
}
return total
}
return 0
}
/**
* Truncates messages from the head (beginning) to reduce token count when
* condensation hits a prompt-too-long error. This is a fallback mechanism
* when the condensed context itself is still too large.
*
* @param messages - The messages to truncate
* @param errorMessage - The error message that triggered the retry
* @returns Truncated messages, or null if nothing can be dropped
*/
export function truncateHeadForPromptTooLongRetry(messages: ApiMessage[], errorMessage: string): ApiMessage[] | null {
// Strip our own synthetic marker from a previous retry before grouping
const input =
messages[0]?.role === "user" &&
messages[0]?.isMeta &&
typeof messages[0].content === "string" &&
messages[0].content === PTL_RETRY_MARKER
? messages.slice(1)
: messages
const groups = groupMessagesByApiRound(input)
if (groups.length < 2) {
return null // Not enough groups to drop
}
const tokenGap = parsePromptTooLongTokenGap(errorMessage)
let dropCount: number
if (tokenGap !== undefined && tokenGap > 0) {
// Calculate how many groups to drop to cover the token gap
let acc = 0
dropCount = 0
for (const group of groups) {
const groupTokens = group.reduce((sum, msg) => sum + estimateMessageTokens(msg), 0)
acc += groupTokens
dropCount++
if (acc >= tokenGap) {
break
}
}
} else {
// Fallback: drop 20% of groups when we can't parse the gap
dropCount = Math.max(1, Math.floor(groups.length * 0.2))
}
// Keep at least one group so there's something to summarize
dropCount = Math.min(dropCount, groups.length - 1)
if (dropCount < 1) {
return null
}
const sliced = groups.slice(dropCount).flat()
// If the first message is an assistant message, prepend a synthetic user marker
// to ensure the conversation starts with a user message (API requirement)
if (sliced[0]?.role === "assistant") {
return [
{
role: "user",
content: PTL_RETRY_MARKER,
ts: Date.now(),
isMeta: true,
},
...sliced,
]
}
return sliced
}
const SUMMARY_PROMPT = `You are a helpful AI assistant tasked with summarizing conversations.
CRITICAL: This is a summarization-only request. DO NOT call any tools or functions.
@ -233,6 +416,12 @@ export type SummarizeConversationOptions = {
filesReadByRoo?: string[]
cwd?: string
rooIgnoreController?: RooIgnoreController
/** Optional hook system for executing pre/post compact hooks */
hookSystem?: HookSystem
/** Whether prompt-too-long retry is enabled */
promptTooLongRetryEnabled?: boolean
/** Maximum number of retry attempts when condensation hits prompt-too-long error */
promptTooLongMaxRetries?: number
}
/**
@ -266,6 +455,9 @@ export async function summarizeConversation(options: SummarizeConversationOption
filesReadByRoo,
cwd,
rooIgnoreController,
hookSystem,
promptTooLongRetryEnabled = true,
promptTooLongMaxRetries = MAX_PTL_RETRIES,
} = options
TelemetryService.instance.captureContextCondensed(
taskId,
@ -276,7 +468,7 @@ export async function summarizeConversation(options: SummarizeConversationOption
const response: SummarizeResponse = { messages, cost: 0, summary: "" }
// Get messages to summarize (all messages since the last summary, if any)
const messagesToSummarize = getMessagesSinceLastSummary(messages)
let messagesToSummarize = getMessagesSinceLastSummary(messages)
if (messagesToSummarize.length <= 1) {
const error =
@ -294,32 +486,35 @@ export async function summarizeConversation(options: SummarizeConversationOption
return { ...response, error }
}
// Execute pre-compact hooks if hook system is provided
let effectiveCustomCondensingPrompt = customCondensingPrompt
if (hookSystem) {
const trigger: "manual" | "auto" = isAutomaticTrigger ? "auto" : "manual"
const preCompactResult = await hookSystem.executePreCompactHooks(trigger, customCondensingPrompt || null, {
cwd,
taskId,
})
// Use custom instructions from hooks if provided
if (preCompactResult.newCustomInstructions) {
effectiveCustomCondensingPrompt = preCompactResult.newCustomInstructions
}
// Log hook results
if (preCompactResult.userMessage) {
console.log(`[PreCompact Hooks] ${preCompactResult.userMessage}`)
}
}
// Use custom prompt if provided and non-empty, otherwise use the default CONDENSE prompt
// This respects user's custom condensing prompt setting
const condenseInstructions = customCondensingPrompt?.trim() || supportPrompt.default.CONDENSE
const condenseInstructions = effectiveCustomCondensingPrompt?.trim() || supportPrompt.default.CONDENSE
const finalRequestMessage: Anthropic.MessageParam = {
role: "user",
content: condenseInstructions,
}
// Inject synthetic tool_results for orphan tool_calls to prevent API rejections
// (e.g., when user triggers condense after receiving attempt_completion but before responding)
const messagesWithToolResults = injectSyntheticToolResults(messagesToSummarize)
// Transform tool_use and tool_result blocks to text representations.
// This is necessary because some providers (like Bedrock via LiteLLM) require the `tools` parameter
// when tool blocks are present. By converting them to text, we can send the conversation for
// summarization without needing to pass the tools parameter.
const messagesWithTextToolBlocks = transformMessagesForCondensing(
maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler),
)
const requestMessages = messagesWithTextToolBlocks.map(({ role, content }) => ({ role, content }))
// Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt
const promptToUse = SUMMARY_PROMPT
// Validate that the API handler supports message creation
if (!apiHandler || typeof apiHandler.createMessage !== "function") {
console.error("API handler is invalid for condensing. Cannot proceed.")
@ -327,61 +522,117 @@ export async function summarizeConversation(options: SummarizeConversationOption
return { ...response, error }
}
// Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt
const promptToUse = SUMMARY_PROMPT
let summary = ""
let cost = 0
let outputTokens = 0
let ptlAttempts = 0 // Prompt-too-long retry attempts
try {
const stream = apiHandler.createMessage(promptToUse, requestMessages, metadata)
// Retry loop for prompt-too-long errors
while (true) {
// Inject synthetic tool_results for orphan tool_calls to prevent API rejections
// (e.g., when user triggers condense after receiving attempt_completion but before responding)
const messagesWithToolResults = injectSyntheticToolResults(messagesToSummarize)
for await (const chunk of stream) {
if (chunk.type === "text") {
summary += chunk.text
} else if (chunk.type === "usage") {
// Record final usage chunk only
cost = chunk.totalCost ?? 0
outputTokens = chunk.outputTokens ?? 0
}
}
} catch (error) {
console.error("Error during condensing API call:", error)
const errorMessage = error instanceof Error ? error.message : String(error)
// Transform tool_use and tool_result blocks to text representations.
// This is necessary because some providers (like Bedrock via LiteLLM) require the `tools` parameter
// when tool blocks are present. By converting them to text, we can send the conversation for
// summarization without needing to pass the tools parameter.
const messagesWithTextToolBlocks = transformMessagesForCondensing(
maybeRemoveImageBlocks([...messagesWithToolResults, finalRequestMessage], apiHandler),
)
// Capture detailed error information for debugging
let errorDetails = ""
if (error instanceof Error) {
errorDetails = `Error: ${error.message}`
// Capture any additional API error properties
const anyError = error as unknown as Record<string, unknown>
if (anyError.status) {
errorDetails += `\n\nHTTP Status: ${anyError.status}`
}
if (anyError.code) {
errorDetails += `\nError Code: ${anyError.code}`
}
if (anyError.response) {
try {
errorDetails += `\n\nAPI Response:\n${JSON.stringify(anyError.response, null, 2)}`
} catch {
errorDetails += `\n\nAPI Response: [Unable to serialize]`
const requestMessages = messagesWithTextToolBlocks.map(({ role, content }) => ({ role, content }))
try {
const stream = apiHandler.createMessage(promptToUse, requestMessages, metadata)
for await (const chunk of stream) {
if (chunk.type === "text") {
summary += chunk.text
} else if (chunk.type === "usage") {
// Record final usage chunk only
cost = chunk.totalCost ?? 0
outputTokens = chunk.outputTokens ?? 0
}
}
if (anyError.body) {
try {
errorDetails += `\n\nResponse Body:\n${JSON.stringify(anyError.body, null, 2)}`
} catch {
errorDetails += `\n\nResponse Body: [Unable to serialize]`
// Success! Break out of the retry loop
break
} catch (error) {
console.error("Error during condensing API call:", error)
const errorMessage = error instanceof Error ? error.message : String(error)
// Check if this is a prompt-too-long error and retry is enabled
if (promptTooLongRetryEnabled && isPromptTooLongError(errorMessage)) {
ptlAttempts++
console.log(
`[Condense] Prompt too long error detected. Retry attempt ${ptlAttempts}/${promptTooLongMaxRetries}`,
)
if (ptlAttempts <= promptTooLongMaxRetries) {
// Try to truncate messages and retry
const truncated = truncateHeadForPromptTooLongRetry(messagesToSummarize, errorMessage)
if (truncated) {
const droppedMessages = messagesToSummarize.length - truncated.length
console.log(
`[Condense] Truncated ${droppedMessages} messages for retry. ${truncated.length} messages remaining.`,
)
messagesToSummarize = truncated
continue // Retry with truncated messages
} else {
console.log(
`[Condense] Cannot truncate further. Not enough messages to drop after ${ptlAttempts} attempts.`,
)
}
} else {
console.log(`[Condense] Max retry attempts (${promptTooLongMaxRetries}) exceeded. Giving up.`)
}
}
} else {
errorDetails = String(error)
}
return {
...response,
cost,
error: t("common:errors.condense_api_failed", { message: errorMessage }),
errorDetails,
// If we get here, either:
// 1. This is not a prompt-too-long error
// 2. Retry is disabled
// 3. We've exhausted all retry attempts
// 4. We cannot truncate further
// Capture detailed error information for debugging
let errorDetails = ""
if (error instanceof Error) {
errorDetails = `Error: ${error.message}`
// Capture any additional API error properties
const anyError = error as unknown as Record<string, unknown>
if (anyError.status) {
errorDetails += `\n\nHTTP Status: ${anyError.status}`
}
if (anyError.code) {
errorDetails += `\nError Code: ${anyError.code}`
}
if (anyError.response) {
try {
errorDetails += `\n\nAPI Response:\n${JSON.stringify(anyError.response, null, 2)}`
} catch {
errorDetails += `\n\nAPI Response: [Unable to serialize]`
}
}
if (anyError.body) {
try {
errorDetails += `\n\nResponse Body:\n${JSON.stringify(anyError.body, null, 2)}`
} catch {
errorDetails += `\n\nResponse Body: [Unable to serialize]`
}
}
} else {
errorDetails = String(error)
}
return {
...response,
cost,
error: t("common:errors.condense_api_failed", { message: errorMessage }),
errorDetails,
}
}
}
@ -506,6 +757,30 @@ ${commandBlocks}
}
const newContextTokens = messageTokens + toolTokens
// Execute post-compact hooks if hook system is provided
if (hookSystem) {
const trigger: "manual" | "auto" = isAutomaticTrigger ? "auto" : "manual"
// Estimate previous token count (before condensation)
const prevContextBlocks = messages.flatMap((message) =>
typeof message.content === "string" ? [{ text: message.content, type: "text" as const }] : message.content,
)
const prevContextTokens = await apiHandler.countTokens(prevContextBlocks)
const postCompactResult = await hookSystem.executePostCompactHooks(
trigger,
summary,
prevContextTokens,
newContextTokens,
{ cwd, taskId },
)
// Log hook results
if (postCompactResult.userMessage) {
console.log(`[PostCompact Hooks] ${postCompactResult.userMessage}`)
}
}
return { messages: newMessages, summary, cost, newContextTokens, condenseId }
}

View file

@ -0,0 +1,287 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiMessage } from "../task-persistence/apiMessages"
/**
* Configuration for microcompact feature.
*/
export type MicrocompactConfig = {
/** Master switch. When false, microcompact is a no-op. */
enabled: boolean
/** Number of messages back to start clearing tool results. */
threshold: number
/** Keep this many most-recent compactable tool results. */
keepRecent: number
}
/**
* Default configuration for microcompact.
*/
export const DEFAULT_MICROCOMPACT_CONFIG: MicrocompactConfig = {
enabled: true,
threshold: 5,
keepRecent: 3,
}
/**
* Message cleared placeholder text.
*/
export const MICROCOMPACT_CLEARED_MESSAGE = "[Content condensed - tool: {toolName}]"
/**
* Tools that are eligible for microcompaction.
* These tools produce large output that can be safely cleared after some time.
*/
const COMPACTABLE_TOOLS = new Set<string>([
"read_file",
"execute_command",
"search_files",
"list_files",
"list_code_definition_names",
"codebase_search",
"browser_action",
])
/**
* Result of a microcompact operation.
*/
export type MicrocompactResult = {
messages: ApiMessage[]
tokensSaved: number
toolsCleared: number
toolsKept: number
}
/**
* Helper to estimate token count for text content.
* This is a rough approximation - actual token counting should use the API handler.
*/
function estimateTextTokens(text: string): number {
// Rough approximation: ~4 characters per token for English text
return Math.ceil(text.length / 4)
}
/**
* Helper to calculate tool result tokens.
*/
function calculateToolResultTokens(block: Anthropic.Messages.ToolResultBlockParam): number {
if (!block.content) {
return 0
}
if (typeof block.content === "string") {
return estimateTextTokens(block.content)
}
// Array of TextBlockParam | ImageBlockParam | DocumentBlockParam
const contentArray = block.content as Array<
Anthropic.Messages.TextBlockParam | Anthropic.Messages.ImageBlockParam | Anthropic.Messages.DocumentBlockParam
>
return contentArray.reduce((sum, item) => {
if (item.type === "text") {
return sum + estimateTextTokens(item.text)
}
// Images/documents are approximately 2000 tokens regardless of format
if (item.type === "image" || item.type === "document") {
return sum + 2000
}
return sum
}, 0)
}
/**
* Walk messages and collect tool_use IDs whose tool name is in COMPACTABLE_TOOLS,
* in encounter order.
*/
function collectCompactableToolIds(messages: ApiMessage[]): Array<{ id: string; toolName: string }> {
const ids: Array<{ id: string; toolName: string }> = []
for (const message of messages) {
if (message.role === "assistant" && Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "tool_use" && COMPACTABLE_TOOLS.has(block.name)) {
ids.push({ id: block.id, toolName: block.name })
}
}
}
}
return ids
}
/**
* Build a map from tool_use_id to tool name for all compactable tools.
*/
function buildToolNameMap(messages: ApiMessage[]): Map<string, string> {
const toolNameMap = new Map<string, string>()
for (const message of messages) {
if (message.role === "assistant" && Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "tool_use" && COMPACTABLE_TOOLS.has(block.name)) {
toolNameMap.set(block.id, block.name)
}
}
}
}
return toolNameMap
}
/**
* Get the cleared message placeholder for a specific tool.
*/
function getClearedMessage(toolName: string): string {
return MICROCOMPACT_CLEARED_MESSAGE.replace("{toolName}", toolName)
}
/**
* Microcompact conversation messages by clearing old tool result content.
*
* This function identifies compactable tool results (file reads, shell output, grep results, etc.)
* and replaces their content with a placeholder message. Only tool results older than the
* configured threshold are cleared, preserving the most recent N results for context.
*
* @param messages - The conversation messages to microcompact
* @param config - The microcompact configuration
* @returns The microcompact result with modified messages and statistics
*/
export function microcompactMessages(
messages: ApiMessage[],
config: Partial<MicrocompactConfig> = {},
): MicrocompactResult {
const finalConfig: MicrocompactConfig = {
...DEFAULT_MICROCOMPACT_CONFIG,
...config,
}
// If disabled, return messages unchanged
if (!finalConfig.enabled) {
return { messages, tokensSaved: 0, toolsCleared: 0, toolsKept: 0 }
}
// Collect all compactable tool IDs in encounter order
const compactableTools = collectCompactableToolIds(messages)
// If there are fewer than threshold + keepRecent tools, no need to clear
if (compactableTools.length < finalConfig.threshold + finalConfig.keepRecent) {
return { messages, tokensSaved: 0, toolsCleared: 0, toolsKept: compactableTools.length }
}
// Determine which tools to clear and which to keep
// Keep the most recent N tools, clear the rest
const keepRecent = Math.max(0, finalConfig.keepRecent)
const toolsToKeep =
keepRecent > 0 ? new Set(compactableTools.slice(-keepRecent).map((t) => t.id)) : new Set<string>()
const toolsToClear = new Set(compactableTools.filter((t) => !toolsToKeep.has(t.id)).map((t) => t.id))
if (toolsToClear.size === 0) {
return { messages, tokensSaved: 0, toolsCleared: 0, toolsKept: compactableTools.length }
}
// Build a map from tool_use_id to tool name for placeholder messages
const toolNameMap = buildToolNameMap(messages)
let tokensSaved = 0
let toolsCleared = 0
// Clear tool result content
const result: ApiMessage[] = messages.map((message) => {
if (message.role !== "user" || !Array.isArray(message.content)) {
return message
}
let touched = false
const newContent = message.content.map((block) => {
if (block.type === "tool_result" && toolsToClear.has(block.tool_use_id)) {
const toolName = toolNameMap.get(block.tool_use_id) || "unknown"
const clearedMessage = getClearedMessage(toolName)
// Check if already cleared to avoid double-counting
if (typeof block.content === "string" && block.content === clearedMessage) {
return block
}
tokensSaved += calculateToolResultTokens(block)
toolsCleared++
touched = true
return { ...block, content: clearedMessage }
}
return block
})
if (!touched) {
return message
}
return {
...message,
content: newContent,
}
})
console.log(
`[Microcompact] Cleared ${toolsCleared} tool results (~${tokensSaved} tokens), kept last ${toolsToKeep.size}`,
)
return {
messages: result,
tokensSaved,
toolsCleared,
toolsKept: toolsToKeep.size,
}
}
/**
* Estimate the number of tokens that could be saved by microcompacting.
* This does not modify the messages, just returns an estimate.
*
* @param messages - The conversation messages to analyze
* @param config - The microcompact configuration
* @returns Estimated number of tokens that would be saved
*/
export function estimateMicrocompactSavings(messages: ApiMessage[], config: Partial<MicrocompactConfig> = {}): number {
const finalConfig: MicrocompactConfig = {
...DEFAULT_MICROCOMPACT_CONFIG,
...config,
}
if (!finalConfig.enabled) {
return 0
}
const compactableTools = collectCompactableToolIds(messages)
if (compactableTools.length < finalConfig.threshold + finalConfig.keepRecent) {
return 0
}
const keepRecent = Math.max(0, finalConfig.keepRecent)
const toolsToKeep =
keepRecent > 0 ? new Set(compactableTools.slice(-keepRecent).map((t) => t.id)) : new Set<string>()
const toolsToClear = new Set(compactableTools.filter((t) => !toolsToKeep.has(t.id)).map((t) => t.id))
if (toolsToClear.size === 0) {
return 0
}
let tokensSaved = 0
const toolNameMap = buildToolNameMap(messages)
for (const message of messages) {
if (message.role !== "user" || !Array.isArray(message.content)) {
continue
}
for (const block of message.content) {
if (block.type === "tool_result" && toolsToClear.has(block.tool_use_id)) {
const toolName = toolNameMap.get(block.tool_use_id) || "unknown"
const clearedMessage = getClearedMessage(toolName)
// Skip if already cleared
if (typeof block.content === "string" && block.content === clearedMessage) {
continue
}
tokensSaved += calculateToolResultTokens(block)
}
}
}
return tokensSaved
}

View file

@ -0,0 +1,606 @@
/**
* Session Memory Extraction for Roo Code
*
* This module extracts key information from conversations and stores it
* as structured session memory that can be used for condensation.
*
* Adapted from Claude Code CLI's session memory system.
*/
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler } from "../../api"
import { ApiMessage } from "../task-persistence/apiMessages"
const MAX_SECTION_LENGTH = 2000
const MAX_TOTAL_SESSION_MEMORY_TOKENS = 12000
/**
* Default session memory template
*/
export const DEFAULT_SESSION_MEMORY_TEMPLATE = `
# Session Title
_A short and distinctive 5-10 word descriptive title for the session. Super info dense, no filler_
# Current State
_What is actively being worked on right now? Pending tasks not yet completed. Immediate next steps._
# Task specification
_What did the user ask to build? Any design decisions or other explanatory context_
# Files and Functions
_What are the important files? In short, what do they contain and why are they relevant?_
# Workflow
_What bash commands are usually run and in what order? How to interpret their output if not obvious?_
# Errors & Corrections
_Errors encountered and how they were fixed. What did the user correct? What approaches failed and should not be tried again?_
# Codebase and System Documentation
_What are the important system components? How do they work/fit together?_
# Learnings
_What has worked well? What has not? What to avoid? Do not duplicate items from other sections_
# Key results
_If the user asked a specific output such as an answer to a question, a table, or other document, repeat the exact result here_
# Worklog
_Step by step, what was attempted, done? Very terse summary for each step_
`
/**
* Default prompt for updating session memory
*/
function getDefaultUpdatePrompt(): string {
return `IMPORTANT: This message and these instructions are NOT part of the actual user conversation. Do NOT include any references to "note-taking", "session notes extraction", or these update instructions in the notes content.
Based on the user conversation above (EXCLUDING this note-taking instruction message), update the session notes file.
The file {{notesPath}} has already been read for you. Here are its current contents:
<current_notes_content>
{{currentNotes}}
</current_notes_content>
Your ONLY task is to update the notes file with the new information from the conversation.
CRITICAL RULES FOR EDITING:
- The file must maintain its exact structure with all sections, headers, and italic descriptions intact
-- NEVER modify, delete, or add section headers (the lines starting with '#' like # Task specification)
-- NEVER modify or delete the italic _section description_ lines (these are the lines in italics immediately following each header - they start and end with underscores)
-- The italic _section descriptions_ are TEMPLATE INSTRUCTIONS that must be preserved exactly as-is - they guide what content belongs in each section
-- ONLY update the actual content that appears BELOW the italic _section descriptions_ within each existing section
-- Do NOT add any new sections, summaries, or information outside the existing structure
- Do NOT reference this note-taking process or instructions anywhere in the notes
- It's OK to skip updating a section if there are no substantial new insights to add. Do not add filler content like "No info yet", just leave sections blank/unedited if appropriate.
- Write DETAILED, INFO-DENSE content for each section - include specifics like file paths, function names, error messages, exact commands, technical details, etc.
- For "Key results", include the complete, exact output the user requested (e.g., full table, full answer, etc.)
- Keep each section under ~${MAX_SECTION_LENGTH} tokens/words - if a section is approaching this limit, condense it by cycling out less important details while preserving the most critical information
- Focus on actionable, specific information that would help someone understand or recreate the work discussed in the conversation
- IMPORTANT: Always update "Current State" to reflect the most recent work - this is critical for continuity after compaction
STRUCTURE PRESERVATION REMINDER:
Each section has TWO parts that must be preserved exactly as they appear in the current file:
1. The section header (line starting with #)
2. The italic description line (the _italicized text_ immediately after the header - this is a template instruction)
You ONLY update the actual content that comes AFTER these two preserved lines. The italic description lines starting and ending with underscores are part of the template structure, NOT content to be edited or removed.
REMEMBER: Update the notes with insights from the actual user conversation only. Do not delete or change section headers or italic _section descriptions_.`
}
/**
* Configuration for session memory extraction
*/
export type SessionMemoryConfig = {
/** Minimum context window tokens before initializing session memory */
minimumMessageTokensToInit: number
/** Minimum context window growth (in tokens) between session memory updates */
minimumTokensBetweenUpdate: number
/** Number of tool calls between session memory updates */
toolCallsBetweenUpdates: number
}
/**
* Default configuration values
*/
export const DEFAULT_SESSION_MEMORY_CONFIG: SessionMemoryConfig = {
minimumMessageTokensToInit: 10000,
minimumTokensBetweenUpdate: 5000,
toolCallsBetweenUpdates: 3,
}
/**
* Current session memory configuration
*/
let sessionMemoryConfig: SessionMemoryConfig = {
...DEFAULT_SESSION_MEMORY_CONFIG,
}
/**
* Track the last summarized message ID
*/
let lastSummarizedMessageId: string | undefined
/**
* Track context size at last memory extraction
*/
let tokensAtLastExtraction = 0
/**
* Track whether session memory has been initialized
*/
let sessionMemoryInitialized = false
/**
* Track extraction state
*/
let extractionStartedAt: number | undefined
/**
* Track tool call count since last update
*/
let toolCallsSinceLastUpdate = 0
/**
* Get the message ID up to which the session memory is current
*/
export function getLastSummarizedMessageId(): string | undefined {
return lastSummarizedMessageId
}
/**
* Set the last summarized message ID
*/
export function setLastSummarizedMessageId(messageId: string | undefined): void {
lastSummarizedMessageId = messageId
}
/**
* Mark extraction as started
*/
export function markExtractionStarted(): void {
extractionStartedAt = Date.now()
}
/**
* Mark extraction as completed
*/
export function markExtractionCompleted(): void {
extractionStartedAt = undefined
}
/**
* Wait for any in-progress session memory extraction to complete (with 15s timeout)
*/
export async function waitForSessionMemoryExtraction(): Promise<void> {
const EXTRACTION_WAIT_TIMEOUT_MS = 15000
const EXTRACTION_STALE_THRESHOLD_MS = 60000 // 1 minute
const startTime = Date.now()
while (extractionStartedAt) {
const extractionAge = Date.now() - extractionStartedAt
if (extractionAge > EXTRACTION_STALE_THRESHOLD_MS) {
// Extraction is stale, don't wait
return
}
if (Date.now() - startTime > EXTRACTION_WAIT_TIMEOUT_MS) {
// Timeout - continue anyway
return
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
/**
* Get the current session memory configuration
*/
export function getSessionMemoryConfig(): SessionMemoryConfig {
return { ...sessionMemoryConfig }
}
/**
* Set the session memory configuration
*/
export function setSessionMemoryConfig(config: Partial<SessionMemoryConfig>): void {
sessionMemoryConfig = {
...sessionMemoryConfig,
...config,
}
}
/**
* Record the context size at the time of extraction
*/
export function recordExtractionTokenCount(currentTokenCount: number): void {
tokensAtLastExtraction = currentTokenCount
}
/**
* Check if session memory has been initialized
*/
export function isSessionMemoryInitialized(): boolean {
return sessionMemoryInitialized
}
/**
* Mark session memory as initialized
*/
export function markSessionMemoryInitialized(): void {
sessionMemoryInitialized = true
}
/**
* Check if we've met the threshold to initialize session memory
*/
export function hasMetInitializationThreshold(currentTokenCount: number): boolean {
return currentTokenCount >= sessionMemoryConfig.minimumMessageTokensToInit
}
/**
* Check if we've met the threshold for the next update
*/
export function hasMetUpdateThreshold(currentTokenCount: number): boolean {
const tokensSinceLastExtraction = currentTokenCount - tokensAtLastExtraction
return tokensSinceLastExtraction >= sessionMemoryConfig.minimumTokensBetweenUpdate
}
/**
* Get the configured number of tool calls between updates
*/
export function getToolCallsBetweenUpdates(): number {
return sessionMemoryConfig.toolCallsBetweenUpdates
}
/**
* Increment tool call count
*/
export function incrementToolCallCount(): void {
toolCallsSinceLastUpdate++
}
/**
* Get current tool call count
*/
export function getToolCallCount(): number {
return toolCallsSinceLastUpdate
}
/**
* Reset tool call count
*/
export function resetToolCallCount(): void {
toolCallsSinceLastUpdate = 0
}
/**
* Reset session memory state (useful for testing)
*/
export function resetSessionMemoryState(): void {
sessionMemoryConfig = { ...DEFAULT_SESSION_MEMORY_CONFIG }
tokensAtLastExtraction = 0
sessionMemoryInitialized = false
lastSummarizedMessageId = undefined
extractionStartedAt = undefined
toolCallsSinceLastUpdate = 0
}
/**
* Estimate token count for text content
*/
function estimateTextTokens(text: string): number {
// Rough approximation: ~4 characters per token for English text
return Math.ceil(text.length / 4)
}
/**
* Parse the session memory file and analyze section sizes
*/
function analyzeSectionSizes(content: string): Record<string, number> {
const sections: Record<string, number> = {}
const lines = content.split("\n")
let currentSection = ""
let currentContent: string[] = []
for (const line of lines) {
if (line.startsWith("# ")) {
if (currentSection && currentContent.length > 0) {
const sectionContent = currentContent.join("\n").trim()
sections[currentSection] = estimateTextTokens(sectionContent)
}
currentSection = line
currentContent = []
} else {
currentContent.push(line)
}
}
if (currentSection && currentContent.length > 0) {
const sectionContent = currentContent.join("\n").trim()
sections[currentSection] = estimateTextTokens(sectionContent)
}
return sections
}
/**
* Generate reminders for sections that are too long
*/
function generateSectionReminders(sectionSizes: Record<string, number>, totalTokens: number): string {
const overBudget = totalTokens > MAX_TOTAL_SESSION_MEMORY_TOKENS
const oversizedSections = Object.entries(sectionSizes)
.filter(([_, tokens]) => tokens > MAX_SECTION_LENGTH)
.sort(([, a], [, b]) => b - a)
.map(([section, tokens]) => `- "${section}" is ~${tokens} tokens (limit: ${MAX_SECTION_LENGTH})`)
if (oversizedSections.length === 0 && !overBudget) {
return ""
}
const parts: string[] = []
if (overBudget) {
parts.push(
`\n\nCRITICAL: The session memory file is currently ~${totalTokens} tokens, which exceeds the maximum of ${MAX_TOTAL_SESSION_MEMORY_TOKENS} tokens. You MUST condense the file to fit within this budget. Aggressively shorten oversized sections by removing less important details, merging related items, and summarizing older entries. Prioritize keeping "Current State" and "Errors & Corrections" accurate and detailed.`,
)
}
if (oversizedSections.length > 0) {
parts.push(
`\n\n${overBudget ? "Oversized sections to condense" : "IMPORTANT: The following sections exceed the per-section limit and MUST be condensed"}:\n${oversizedSections.join("\n")}`,
)
}
return parts.join("")
}
/**
* Substitute variables in the prompt template using {{variable}} syntax
*/
function substituteVariables(template: string, variables: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
Object.prototype.hasOwnProperty.call(variables, key) ? variables[key]! : match,
)
}
/**
* Check if the session memory content is essentially empty (matches the template)
*/
export function isSessionMemoryEmpty(content: string): boolean {
// Compare trimmed content to detect if it's just the template
return content.trim() === DEFAULT_SESSION_MEMORY_TEMPLATE.trim()
}
/**
* Build the session memory update prompt
*/
export function buildSessionMemoryUpdatePrompt(currentNotes: string, notesPath: string): string {
const promptTemplate = getDefaultUpdatePrompt()
// Analyze section sizes and generate reminders if needed
const sectionSizes = analyzeSectionSizes(currentNotes)
const totalTokens = estimateTextTokens(currentNotes)
const sectionReminders = generateSectionReminders(sectionSizes, totalTokens)
// Substitute variables in the prompt
const variables = {
currentNotes,
notesPath,
}
const basePrompt = substituteVariables(promptTemplate, variables)
// Add section size reminders and/or total budget warnings
return basePrompt + sectionReminders
}
/**
* Truncate session memory sections that exceed the per-section token limit
*/
export function truncateSessionMemoryForCompact(content: string): {
truncatedContent: string
wasTruncated: boolean
} {
const lines = content.split("\n")
const maxCharsPerSection = MAX_SECTION_LENGTH * 4 // estimateTextTokens uses length/4
const outputLines: string[] = []
let currentSectionLines: string[] = []
let currentSectionHeader = ""
let wasTruncated = false
for (const line of lines) {
if (line.startsWith("# ")) {
const result = flushSessionSection(currentSectionHeader, currentSectionLines, maxCharsPerSection)
outputLines.push(...result.lines)
wasTruncated = wasTruncated || result.wasTruncated
currentSectionHeader = line
currentSectionLines = []
} else {
currentSectionLines.push(line)
}
}
// Flush the last section
const result = flushSessionSection(currentSectionHeader, currentSectionLines, maxCharsPerSection)
outputLines.push(...result.lines)
wasTruncated = wasTruncated || result.wasTruncated
return {
truncatedContent: outputLines.join("\n"),
wasTruncated,
}
}
function flushSessionSection(
sectionHeader: string,
sectionLines: string[],
maxCharsPerSection: number,
): { lines: string[]; wasTruncated: boolean } {
if (!sectionHeader) {
return { lines: sectionLines, wasTruncated: false }
}
const sectionContent = sectionLines.join("\n")
if (sectionContent.length <= maxCharsPerSection) {
return { lines: [sectionHeader, ...sectionLines], wasTruncated: false }
}
// Truncate at a line boundary near the limit
let charCount = 0
const keptLines: string[] = [sectionHeader]
for (const line of sectionLines) {
if (charCount + line.length + 1 > maxCharsPerSection) {
break
}
keptLines.push(line)
charCount += line.length + 1
}
keptLines.push("\n[... section truncated for length ...]")
return { lines: keptLines, wasTruncated: true }
}
/**
* Extract session memory from conversation messages
*
* This function uses the AI to extract and update session memory based on
* the conversation history.
*
* @param messages - The conversation messages to extract memory from
* @param currentSessionMemory - The current session memory content (if any)
* @param apiHandler - The API handler to use for the extraction
* @returns The updated session memory content
*/
export async function extractSessionMemory(
messages: ApiMessage[],
currentSessionMemory: string | null,
apiHandler: ApiHandler,
): Promise<string> {
// If no current memory, start with the template
const currentNotes = currentSessionMemory || DEFAULT_SESSION_MEMORY_TEMPLATE
// Build the update prompt
const prompt = buildSessionMemoryUpdatePrompt(currentNotes, "session-memory.md")
// Transform messages for the API (convert tool blocks to text)
const messagesForApi = messages.map((msg) => ({
role: msg.role,
content:
typeof msg.content === "string"
? msg.content
: msg.content.map((block) => {
if (block.type === "tool_use") {
return {
type: "text" as const,
text: `[Tool Use: ${(block as Anthropic.Messages.ToolUseBlockParam).name}]\n${JSON.stringify((block as Anthropic.Messages.ToolUseBlockParam).input, null, 2)}`,
}
}
if (block.type === "tool_result") {
const errorSuffix = (block as Anthropic.Messages.ToolResultBlockParam).is_error
? " (Error)"
: ""
if (typeof (block as Anthropic.Messages.ToolResultBlockParam).content === "string") {
return {
type: "text" as const,
text: `[Tool Result${errorSuffix}]\n${(block as Anthropic.Messages.ToolResultBlockParam).content}`,
}
}
return {
type: "text" as const,
text: `[Tool Result${errorSuffix}]\n[Complex content]`,
}
}
return block
}),
}))
// Call the API to update the session memory
let updatedMemory = ""
try {
const stream = apiHandler.createMessage(prompt, messagesForApi, undefined)
for await (const chunk of stream) {
if (chunk.type === "text") {
updatedMemory += chunk.text
}
}
} catch (error) {
console.error("[Session Memory] Error extracting session memory:", error)
// Return current memory if extraction fails
return currentNotes
}
// If the API returned empty content, return current memory
if (!updatedMemory.trim()) {
return currentNotes
}
// Extract the updated session memory from the response
// The response should contain the updated session memory content
// We need to parse it out if it's wrapped in markdown code blocks or similar
const memoryMatch = updatedMemory.match(/```(?:markdown)?\n([\s\S]*?)\n```/) || updatedMemory.match(/^([\s\S]*?)$/)
if (memoryMatch) {
return memoryMatch[1]?.trim() || currentNotes
}
return updatedMemory.trim() || currentNotes
}
/**
* Check if a message contains text blocks
*/
export function hasTextBlocks(message: ApiMessage): boolean {
if (message.role === "assistant") {
const content = message.content
return Array.isArray(content) && content.some((block) => block.type === "text")
}
if (message.role === "user") {
const content = message.content
if (typeof content === "string") {
return content.length > 0
}
if (Array.isArray(content)) {
return content.some((block) => block.type === "text")
}
}
return false
}
/**
* Estimate the number of tokens in a message
*/
export function estimateMessageTokens(messages: ApiMessage[]): number {
let totalTokens = 0
for (const msg of messages) {
if (typeof msg.content === "string") {
totalTokens += estimateTextTokens(msg.content)
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "text") {
totalTokens += estimateTextTokens(block.text)
} else if (block.type === "image") {
totalTokens += 2000 // Approximate token count for images
} else if (block.type === "tool_use") {
totalTokens += estimateTextTokens(
JSON.stringify((block as Anthropic.Messages.ToolUseBlockParam).input),
)
} else if (block.type === "tool_result") {
const content = (block as Anthropic.Messages.ToolResultBlockParam).content
if (typeof content === "string") {
totalTokens += estimateTextTokens(content)
} else if (Array.isArray(content)) {
for (const item of content) {
if (item.type === "text") {
totalTokens += estimateTextTokens(item.text)
} else if (item.type === "image") {
totalTokens += 2000
}
}
}
}
}
}
}
return totalTokens
}

View file

@ -0,0 +1,426 @@
/**
* Session Memory Compaction for Roo Code
*
* This module implements session memory compaction, which uses pre-extracted
* session memory as a condensation summary instead of calling the LLM again.
*
* Adapted from Claude Code CLI's session memory compaction system.
*/
import { Anthropic } from "@anthropic-ai/sdk"
import crypto from "crypto"
import { ApiHandler } from "../../api"
import { ApiMessage } from "../task-persistence/apiMessages"
import {
DEFAULT_SESSION_MEMORY_CONFIG,
estimateMessageTokens,
getLastSummarizedMessageId,
hasTextBlocks,
isSessionMemoryEmpty,
markExtractionCompleted,
markExtractionStarted,
resetSessionMemoryState,
setLastSummarizedMessageId,
setSessionMemoryConfig,
truncateSessionMemoryForCompact,
waitForSessionMemoryExtraction,
type SessionMemoryConfig,
} from "./sessionMemory"
/**
* Configuration for session memory compaction thresholds
*/
export type SessionMemoryCompactConfig = {
/** Minimum tokens to preserve after compaction */
minTokens: number
/** Minimum number of messages with text blocks to keep */
minTextBlockMessages: number
/** Maximum tokens to preserve after compaction (hard cap) */
maxTokens: number
}
/**
* Default configuration values
*/
export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = {
minTokens: 10000,
minTextBlockMessages: 5,
maxTokens: 50000,
}
/**
* Current configuration
*/
let smCompactConfig: SessionMemoryCompactConfig = {
...DEFAULT_SM_COMPACT_CONFIG,
}
/**
* Set the session memory compact configuration
*/
export function setSessionMemoryCompactConfig(config: Partial<SessionMemoryCompactConfig>): void {
smCompactConfig = {
...smCompactConfig,
...config,
}
}
/**
* Get the current session memory compact configuration
*/
export function getSessionMemoryCompactConfig(): SessionMemoryCompactConfig {
return { ...smCompactConfig }
}
/**
* Reset config state (useful for testing)
*/
export function resetSessionMemoryCompactConfig(): void {
smCompactConfig = { ...DEFAULT_SM_COMPACT_CONFIG }
}
/**
* Check if a message contains tool_result blocks and return their tool_use_ids
*/
function getToolResultIds(message: ApiMessage): string[] {
if (message.role !== "user") {
return []
}
const content = message.content
if (!Array.isArray(content)) {
return []
}
const ids: string[] = []
for (const block of content) {
if (block.type === "tool_result") {
ids.push((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id)
}
}
return ids
}
/**
* Check if a message contains tool_use blocks with any of the given ids
*/
function hasToolUseWithIds(message: ApiMessage, toolUseIds: Set<string>): boolean {
if (message.role !== "assistant") {
return false
}
const content = message.content
if (!Array.isArray(content)) {
return false
}
return content.some(
(block) => block.type === "tool_use" && toolUseIds.has((block as Anthropic.Messages.ToolUseBlockParam).id),
)
}
/**
* Adjust the start index to ensure we don't split tool_use/tool_result pairs
* or thinking blocks that share the same message.id with kept assistant messages.
*
* If ANY message we're keeping contains tool_result blocks, we need to
* include the preceding assistant message(s) that contain the matching tool_use blocks.
*/
export function adjustIndexToPreserveAPIInvariants(messages: ApiMessage[], startIndex: number): number {
if (startIndex <= 0 || startIndex >= messages.length) {
return startIndex
}
let adjustedIndex = startIndex
// Step 1: Handle tool_use/tool_result pairs
// Collect tool_result IDs from ALL messages in the kept range
const allToolResultIds: string[] = []
for (let i = startIndex; i < messages.length; i++) {
allToolResultIds.push(...getToolResultIds(messages[i]!))
}
if (allToolResultIds.length > 0) {
// Collect tool_use IDs already in the kept range
const toolUseIdsInKeptRange = new Set<string>()
for (let i = adjustedIndex; i < messages.length; i++) {
const msg = messages[i]!
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use") {
toolUseIdsInKeptRange.add((block as Anthropic.Messages.ToolUseBlockParam).id)
}
}
}
}
// Only look for tool_uses that are NOT already in the kept range
const neededToolUseIds = new Set(allToolResultIds.filter((id) => !toolUseIdsInKeptRange.has(id)))
// Find the assistant message(s) with matching tool_use blocks
for (let i = adjustedIndex - 1; i >= 0 && neededToolUseIds.size > 0; i--) {
const message = messages[i]!
if (hasToolUseWithIds(message, neededToolUseIds)) {
adjustedIndex = i
// Remove found tool_use_ids from the set
if (message.role === "assistant" && Array.isArray(message.content)) {
for (const block of message.content) {
if (
block.type === "tool_use" &&
neededToolUseIds.has((block as Anthropic.Messages.ToolUseBlockParam).id)
) {
neededToolUseIds.delete((block as Anthropic.Messages.ToolUseBlockParam).id)
}
}
}
}
}
}
// Step 2: Handle thinking blocks that share message.id with kept assistant messages
// Collect all message.ids from assistant messages in the kept range
const messageIdsInKeptRange = new Set<string>()
for (let i = adjustedIndex; i < messages.length; i++) {
const msg = messages[i]!
if (msg.role === "assistant" && msg.id) {
messageIdsInKeptRange.add(msg.id)
}
}
// Look backwards for assistant messages with the same message.id that are not in the kept range
for (let i = adjustedIndex - 1; i >= 0; i--) {
const message = messages[i]!
if (message.role === "assistant" && message.id && messageIdsInKeptRange.has(message.id)) {
// This message has the same message.id as one in the kept range
// Include it so thinking blocks can be properly merged
adjustedIndex = i
}
}
return adjustedIndex
}
/**
* Calculate the starting index for messages to keep after compaction.
* Starts from lastSummarizedMessageId, then expands backwards to meet minimums.
*/
export function calculateMessagesToKeepIndex(messages: ApiMessage[], lastSummarizedIndex: number): number {
if (messages.length === 0) {
return 0
}
const config = getSessionMemoryCompactConfig()
// Start from the message after lastSummarizedIndex
let startIndex = lastSummarizedIndex >= 0 ? lastSummarizedIndex + 1 : messages.length
// Calculate current tokens and text-block message count from startIndex to end
let totalTokens = 0
let textBlockMessageCount = 0
for (let i = startIndex; i < messages.length; i++) {
const msg = messages[i]!
totalTokens += estimateMessageTokens([msg])
if (hasTextBlocks(msg)) {
textBlockMessageCount++
}
}
// Check if we already hit the max cap
if (totalTokens >= config.maxTokens) {
return adjustIndexToPreserveAPIInvariants(messages, startIndex)
}
// Check if we already meet both minimums
if (totalTokens >= config.minTokens && textBlockMessageCount >= config.minTextBlockMessages) {
return adjustIndexToPreserveAPIInvariants(messages, startIndex)
}
// Expand backwards until we meet both minimums or hit max cap
for (let i = startIndex - 1; i >= 0; i--) {
const msg = messages[i]!
const msgTokens = estimateMessageTokens([msg])
totalTokens += msgTokens
if (hasTextBlocks(msg)) {
textBlockMessageCount++
}
startIndex = i
// Stop if we hit the max cap
if (totalTokens >= config.maxTokens) {
break
}
// Stop if we meet both minimums
if (totalTokens >= config.minTokens && textBlockMessageCount >= config.minTextBlockMessages) {
break
}
}
// Adjust for tool pairs
return adjustIndexToPreserveAPIInvariants(messages, startIndex)
}
/**
* Try to use session memory for compaction instead of traditional compaction.
* Returns null if session memory compaction cannot be used.
*
* @param messages - The conversation messages
* @param sessionMemory - The session memory content
* @param apiHandler - The API handler
* @param taskId - The task ID for telemetry
* @param autoCompactThreshold - Optional threshold for autocompact
* @returns The compaction result or null if session memory compaction cannot be used
*/
export async function trySessionMemoryCompaction(
messages: ApiMessage[],
sessionMemory: string,
apiHandler: ApiHandler,
taskId: string,
autoCompactThreshold?: number,
lastSummarizedMessageId?: string,
): Promise<SessionMemoryCompactionResult | null> {
// No session memory file exists
if (!sessionMemory) {
console.log("[Session Memory Compaction] No session memory available")
return null
}
// Session memory exists but matches the template (no actual content extracted)
if (isSessionMemoryEmpty(sessionMemory)) {
console.log("[Session Memory Compaction] Session memory is empty (matches template)")
return null
}
try {
let lastSummarizedIndex: number
// Prefer the caller-supplied id; fall back to module-level state for backwards compat
const resolvedLastSummarizedMessageId = lastSummarizedMessageId ?? getLastSummarizedMessageId()
if (resolvedLastSummarizedMessageId) {
// Normal case: we know exactly which messages have been summarized
lastSummarizedIndex = messages.findIndex((msg) => msg.id === resolvedLastSummarizedMessageId)
if (lastSummarizedIndex === -1) {
// The summarized message ID doesn't exist in current messages
console.log("[Session Memory Compaction] Summarized message ID not found")
return null
}
} else {
// Resumed session case: session memory has content but we don't know the boundary
// Set lastSummarizedIndex to last message so startIndex becomes messages.length (no messages kept initially)
lastSummarizedIndex = messages.length - 1
console.log("[Session Memory Compaction] Resumed session detected")
}
// Calculate the starting index for messages to keep
const startIndex = calculateMessagesToKeepIndex(messages, lastSummarizedIndex)
// Filter out old compact boundary messages from messagesToKeep
const messagesToKeep = messages.slice(startIndex).filter((m) => !m.isTruncationMarker)
// Truncate oversized sections to prevent session memory from consuming
// the entire post-compact token budget
const { truncatedContent, wasTruncated } = truncateSessionMemoryForCompact(sessionMemory)
// Build the summary content
let summaryContent = `## Session Memory\n${truncatedContent}`
if (wasTruncated) {
summaryContent += `\n\nSome session memory sections were truncated for length. The full session memory can be viewed in the session memory file.`
}
// Generate a unique condenseId for this summary
const condenseId = crypto.randomUUID()
// Use the last message's timestamp + 1 to ensure unique timestamp for summary
const lastMsgTs = messages[messages.length - 1]?.ts ?? Date.now()
const summaryMessage: ApiMessage = {
role: "user",
content: summaryContent,
ts: lastMsgTs + 1,
isSummary: true,
condenseId,
}
// Tag ALL messages with condenseParent
const newMessages = messages.map((msg) => {
if (!msg.condenseParent) {
return { ...msg, condenseParent: condenseId }
}
return msg
})
// Append the summary message at the end
newMessages.push(summaryMessage)
// Calculate post-compact token count
const postCompactTokenCount = estimateMessageTokens([summaryMessage])
// Only check threshold if one was provided (for autocompact)
if (autoCompactThreshold !== undefined && postCompactTokenCount >= autoCompactThreshold) {
console.log(
`[Session Memory Compaction] Threshold exceeded: ${postCompactTokenCount} >= ${autoCompactThreshold}`,
)
return null
}
console.log("[Session Memory Compaction] Success")
return {
messages: newMessages,
summary: summaryContent,
cost: 0, // No API call made
newContextTokens: postCompactTokenCount,
condenseId,
messagesToKeep,
postCompactTokenCount,
}
} catch (error) {
console.error("[Session Memory Compaction] Error:", error)
return null
}
}
/**
* Result type for session memory compaction
*/
export type SessionMemoryCompactionResult = {
messages: ApiMessage[]
summary: string
cost: number
newContextTokens: number
condenseId: string
messagesToKeep: ApiMessage[]
postCompactTokenCount: number
}
/**
* Check if session memory compaction is enabled
*/
export function isSessionMemoryCompactEnabled(): boolean {
// This will be controlled by the configuration setting
// For now, return true by default
return true
}
/**
* Initialize session memory configuration from settings
*/
export function initializeSessionMemoryConfig(
sessionMemoryConfig?: Partial<SessionMemoryConfig>,
smCompactConfig?: Partial<SessionMemoryCompactConfig>,
): void {
if (sessionMemoryConfig) {
setSessionMemoryConfig(sessionMemoryConfig)
}
if (smCompactConfig) {
setSessionMemoryCompactConfig(smCompactConfig)
}
}
/**
* Reset all session memory state (useful for testing)
*/
export function resetAllSessionMemoryState(): void {
resetSessionMemoryState()
resetSessionMemoryCompactConfig()
}

View file

@ -4,10 +4,21 @@ import crypto from "crypto"
import { TelemetryService } from "@roo-code/telemetry"
import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api"
import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense"
import {
MAX_CONDENSE_THRESHOLD,
MIN_CONDENSE_THRESHOLD,
microcompactMessages,
summarizeConversation,
SummarizeResponse,
type MicrocompactConfig,
trySessionMemoryCompaction,
isSessionMemoryCompactEnabled,
type SessionMemoryCompactionResult,
} from "../condense"
import { ApiMessage } from "../task-persistence/apiMessages"
import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import type { HookSystem } from "../hooks"
/**
* Context Management
@ -147,6 +158,7 @@ export type WillManageContextOptions = {
profileThresholds: Record<string, number>
currentProfileId: string
lastMessageTokens: number
microCompactEnabled?: boolean
}
/**
@ -167,7 +179,16 @@ export function willManageContext({
profileThresholds,
currentProfileId,
lastMessageTokens,
microCompactEnabled,
}: WillManageContextOptions): boolean {
// Microcompact is always a possibility if enabled
if (microCompactEnabled !== false) {
// We can't accurately predict microcompact savings without the actual messages,
// but we can indicate that context management might run
// This is a conservative estimate - actual savings depend on message content
return true
}
if (!autoCondenseContext) {
// When auto-condense is disabled, only truncation can occur
const reservedTokens = maxTokens || ANTHROPIC_DEFAULT_MAX_TOKENS
@ -229,6 +250,20 @@ export type ContextManagementOptions = {
cwd?: string
/** Optional controller for file access validation */
rooIgnoreController?: RooIgnoreController
/** Optional microcompact configuration */
microCompactConfig?: Partial<MicrocompactConfig>
/** Optional session memory content for session memory compaction */
sessionMemory?: string
/** Whether session memory compaction is enabled */
sessionMemoryCompactEnabled?: boolean
/** The message ID up to which session memory has been summarized (avoids global state) */
lastSummarizedMessageId?: string
/** Optional hook system for executing pre/post compact hooks */
hookSystem?: HookSystem
/** Whether prompt-too-long retry is enabled */
promptTooLongRetryEnabled?: boolean
/** Maximum number of retry attempts when condensation hits prompt-too-long error */
promptTooLongMaxRetries?: number
}
export type ContextManagementResult = SummarizeResponse & {
@ -236,6 +271,8 @@ export type ContextManagementResult = SummarizeResponse & {
truncationId?: string
messagesRemoved?: number
newContextTokensAfterTruncation?: number
/** Indicates if session memory compaction was used */
sessionMemoryCompactionUsed?: boolean
}
/**
@ -262,6 +299,13 @@ export async function manageContext({
filesReadByRoo,
cwd,
rooIgnoreController,
microCompactConfig,
sessionMemory,
sessionMemoryCompactEnabled,
lastSummarizedMessageId,
hookSystem,
promptTooLongRetryEnabled,
promptTooLongMaxRetries,
}: ContextManagementOptions): Promise<ContextManagementResult> {
let error: string | undefined
let errorDetails: string | undefined
@ -283,6 +327,39 @@ export async function manageContext({
// Truncate if we're within TOKEN_BUFFER_PERCENTAGE of the context window
const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens
// Step 1: Run microcompact as a pre-step to save tokens before full condensation
let workingMessages = messages
let microcompactTokensSaved = 0
if (microCompactConfig?.enabled !== false) {
const microcompactResult = microcompactMessages(messages, microCompactConfig)
workingMessages = microcompactResult.messages
microcompactTokensSaved = microcompactResult.tokensSaved
if (microcompactTokensSaved > 0) {
console.log(
`[Context Management] Microcompact saved ${microcompactTokensSaved} tokens (${microcompactResult.toolsCleared} tools cleared, ${microcompactResult.toolsKept} kept)`,
)
}
}
// Recalculate tokens after microcompact (only if we actually saved tokens)
let adjustedTotalTokens = totalTokens
if (microcompactTokensSaved > 0) {
// Estimate tokens for all messages except the last one
adjustedTotalTokens = 0
for (const msg of workingMessages.slice(0, -1)) {
const content = msg.content
if (Array.isArray(content)) {
adjustedTotalTokens += await estimateTokenCount(content, apiHandler)
} else if (typeof content === "string") {
adjustedTotalTokens += await estimateTokenCount([{ type: "text", text: content }], apiHandler)
}
}
}
// Calculate new context tokens after microcompact
const adjustedPrevContextTokens = adjustedTotalTokens + lastMessageTokens
// Determine the effective threshold to use
let effectiveThreshold = autoCondenseContextPercent
const profileThreshold = profileThresholds[currentProfileId]
@ -304,11 +381,37 @@ export async function manageContext({
// If no specific threshold is found for the profile, fall back to global setting
if (autoCondenseContext) {
const contextPercent = (100 * prevContextTokens) / contextWindow
if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) {
// Attempt to intelligently condense the context
const contextPercent = (100 * adjustedPrevContextTokens) / contextWindow
if (contextPercent >= effectiveThreshold || adjustedPrevContextTokens > allowedTokens) {
// First, try session memory compaction if enabled and session memory is available
if (sessionMemoryCompactEnabled !== false && sessionMemory && isSessionMemoryCompactEnabled()) {
console.log("[Context Management] Attempting session memory compaction...")
const smResult = await trySessionMemoryCompaction(
workingMessages,
sessionMemory,
apiHandler,
taskId,
undefined, // No autoCompactThreshold for now
lastSummarizedMessageId,
)
if (smResult) {
console.log("[Context Management] Session memory compaction succeeded")
return {
...smResult,
prevContextTokens: adjustedPrevContextTokens,
sessionMemoryCompactionUsed: true,
}
} else {
console.log(
"[Context Management] Session memory compaction failed, falling back to regular condensation",
)
}
}
// Fall back to regular condensation
const result = await summarizeConversation({
messages,
messages: workingMessages,
apiHandler,
systemPrompt,
taskId,
@ -319,20 +422,23 @@ export async function manageContext({
filesReadByRoo,
cwd,
rooIgnoreController,
hookSystem,
promptTooLongRetryEnabled,
promptTooLongMaxRetries,
})
if (result.error) {
error = result.error
errorDetails = result.errorDetails
cost = result.cost
} else {
return { ...result, prevContextTokens }
return { ...result, prevContextTokens: adjustedPrevContextTokens, sessionMemoryCompactionUsed: false }
}
}
}
// Fall back to sliding window truncation if needed
if (prevContextTokens > allowedTokens) {
const truncationResult = truncateConversation(messages, 0.5, taskId)
if (adjustedPrevContextTokens > allowedTokens) {
const truncationResult = truncateConversation(workingMessages, 0.5, taskId)
// Calculate new context tokens after truncation by counting non-truncated messages
// Messages with truncationParent are hidden, so we count only those without it
@ -361,7 +467,7 @@ export async function manageContext({
return {
messages: truncationResult.messages,
prevContextTokens,
prevContextTokens: adjustedPrevContextTokens,
summary: "",
cost,
error,
@ -372,5 +478,12 @@ export async function manageContext({
}
}
// No truncation or condensation needed
return { messages, summary: "", cost, prevContextTokens, error, errorDetails }
return {
messages: workingMessages,
summary: "",
cost,
prevContextTokens: adjustedPrevContextTokens,
error,
errorDetails,
}
}

View file

@ -0,0 +1,176 @@
/**
* Tests for the Hook System
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { HookSystem, createHookSystem, type HookSystemConfig } from "../index"
import * as fs from "fs"
import * as path from "path"
// Mock the fs module
vi.mock("fs", () => ({
existsSync: vi.fn(),
promises: {
readFile: vi.fn(),
},
}))
// Mock the vscode module
vi.mock("vscode", () => ({
window: {
createOutputChannel: vi.fn(() => ({
appendLine: vi.fn(),
dispose: vi.fn(),
})),
showErrorMessage: vi.fn(),
},
}))
describe("HookSystem", () => {
const mockWorkspacePath = "/test/workspace"
const mockConfig: HookSystemConfig = {
enabled: true,
workspacePath: mockWorkspacePath,
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("createHookSystem", () => {
it("should create a HookSystem instance with default enabled state", () => {
const hookSystem = createHookSystem(mockWorkspacePath)
expect(hookSystem).toBeInstanceOf(HookSystem)
expect(hookSystem.isEnabled()).toBe(true)
})
it("should create a HookSystem instance with disabled state", () => {
const hookSystem = createHookSystem(mockWorkspacePath, false)
expect(hookSystem).toBeInstanceOf(HookSystem)
expect(hookSystem.isEnabled()).toBe(false)
})
it("should create a HookSystem instance with custom config path", () => {
const customPath = "/custom/path/hooks.json"
const hookSystem = createHookSystem(mockWorkspacePath, true, customPath)
expect(hookSystem).toBeInstanceOf(HookSystem)
})
})
describe("isEnabled", () => {
it("should return true when hooks are enabled", () => {
const hookSystem = new HookSystem({ ...mockConfig, enabled: true })
expect(hookSystem.isEnabled()).toBe(true)
})
it("should return false when hooks are disabled", () => {
const hookSystem = new HookSystem({ ...mockConfig, enabled: false })
expect(hookSystem.isEnabled()).toBe(false)
})
})
describe("updateConfig", () => {
it("should update the configuration", () => {
const hookSystem = new HookSystem(mockConfig)
expect(hookSystem.isEnabled()).toBe(true)
hookSystem.updateConfig({ enabled: false })
expect(hookSystem.isEnabled()).toBe(false)
})
})
describe("executePreCompactHooks", () => {
it("should return empty result when hooks are disabled", async () => {
const hookSystem = new HookSystem({ ...mockConfig, enabled: false })
const result = await hookSystem.executePreCompactHooks("auto", null)
expect(result).toEqual({ hasFailures: false })
expect(result.newCustomInstructions).toBeUndefined()
expect(result.userMessage).toBeUndefined()
})
it("should return empty result when no hooks are configured", async () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const hookSystem = new HookSystem(mockConfig)
const result = await hookSystem.executePreCompactHooks("auto", null)
expect(result).toEqual({ hasFailures: false })
})
it("should execute pre-compact hooks when configured", async () => {
const mockConfigContent = {
PreCompact: [
{
matcher: "auto",
hooks: [
{
id: "test-hook",
type: "command",
command: "echo 'test'",
},
],
},
],
}
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfigContent))
const hookSystem = new HookSystem(mockConfig)
const result = await hookSystem.executePreCompactHooks("auto", null, { cwd: mockWorkspacePath })
expect(result.hasFailures).toBeDefined()
})
})
describe("executePostCompactHooks", () => {
it("should return empty result when hooks are disabled", async () => {
const hookSystem = new HookSystem({ ...mockConfig, enabled: false })
const result = await hookSystem.executePostCompactHooks("auto", "summary", 1000, 500)
expect(result).toEqual({ hasFailures: false })
expect(result.userMessage).toBeUndefined()
})
it("should return empty result when no hooks are configured", async () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const hookSystem = new HookSystem(mockConfig)
const result = await hookSystem.executePostCompactHooks("auto", "summary", 1000, 500)
expect(result).toEqual({ hasFailures: false })
})
it("should execute post-compact hooks when configured", async () => {
const mockConfigContent = {
PostCompact: [
{
matcher: "auto",
hooks: [
{
id: "test-hook",
type: "command",
command: "echo 'test'",
},
],
},
],
}
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfigContent))
const hookSystem = new HookSystem(mockConfig)
const result = await hookSystem.executePostCompactHooks("auto", "summary", 1000, 500, {
cwd: mockWorkspacePath,
})
expect(result.hasFailures).toBeDefined()
})
})
})

185
src/core/hooks/config.ts Normal file
View file

@ -0,0 +1,185 @@
/**
* Hook Configuration Loader
*
* This module handles loading and validating hook configurations from
* JSON files in the workspace.
*/
import * as fs from "fs"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import type { Hook, HookEvent, HooksConfig } from "./types"
/**
* Schema for validating command hooks
*/
const CommandHookSchema = z.object({
id: z.string().min(1),
type: z.literal("command"),
description: z.string().optional(),
enabled: z.boolean().optional(),
timeout: z.number().positive().optional(),
command: z.string().min(1),
shell: z.enum(["bash", "powershell", "cmd"]).optional(),
cwd: z.string().optional(),
env: z.record(z.string()).optional(),
})
/**
* Schema for validating HTTP hooks
*/
const HttpHookSchema = z.object({
id: z.string().min(1),
type: z.literal("http"),
description: z.string().optional(),
enabled: z.boolean().optional(),
timeout: z.number().positive().optional(),
url: z.string().url(),
method: z.enum(["POST", "PUT", "PATCH"]).optional(),
headers: z.record(z.string()).optional(),
})
/**
* Schema for validating hooks
*/
const HookSchema: z.ZodDiscriminatedUnion<"type", [typeof CommandHookSchema, typeof HttpHookSchema]> =
z.discriminatedUnion("type", [CommandHookSchema, HttpHookSchema])
/**
* Schema for validating hook matchers
*/
const HookMatcherSchema = z.object({
matcher: z.string().optional(),
hooks: z.array(HookSchema),
})
/**
* Schema for validating the entire hooks configuration
*/
const HooksConfigSchema: z.ZodType<HooksConfig> = z.object({
PreCompact: z.array(HookMatcherSchema).optional(),
PostCompact: z.array(HookMatcherSchema).optional(),
})
/**
* Default hooks configuration file name
*/
export const DEFAULT_HOOKS_CONFIG_FILE = ".roo-hooks.json"
/**
* Result of loading hooks configuration
*/
export interface HooksConfigLoadResult {
/** The loaded configuration */
config: HooksConfig
/** Path to the configuration file */
configPath: string
/** Whether the configuration was loaded from a file */
loadedFromFile: boolean
/** Error message if loading failed */
error?: string
}
/**
* Loads hooks configuration from the workspace
*
* @param workspacePath - The workspace root path
* @param configFileName - Optional custom configuration file name
* @returns The loaded hooks configuration
*/
export async function loadHooksConfig(workspacePath: string, configFileName?: string): Promise<HooksConfigLoadResult> {
const configPath = path.join(workspacePath, configFileName || DEFAULT_HOOKS_CONFIG_FILE)
// Check if configuration file exists
if (!fs.existsSync(configPath)) {
return {
config: {},
configPath,
loadedFromFile: false,
}
}
try {
const fileContent = await fs.promises.readFile(configPath, "utf-8")
const rawConfig = JSON.parse(fileContent)
// Validate the configuration
const validatedConfig = HooksConfigSchema.parse(rawConfig)
return {
config: validatedConfig,
configPath,
loadedFromFile: true,
}
} catch (error) {
let errorMessage = "Failed to load hooks configuration"
if (error instanceof z.ZodError) {
errorMessage = `Invalid hooks configuration: ${error.errors.map((e) => e.message).join(", ")}`
} else if (error instanceof Error) {
errorMessage = `Failed to load hooks configuration: ${error.message}`
}
// Show error to user
void vscode.window.showErrorMessage(errorMessage)
return {
config: {},
configPath,
loadedFromFile: false,
error: errorMessage,
}
}
}
/**
* Gets hooks for a specific event from the configuration
*
* @param config - The hooks configuration
* @param event - The hook event to get hooks for
* @param matchQuery - Optional query string to filter hooks
* @returns Array of hooks that match the event and query
*/
export function getHooksForEvent(config: HooksConfig, event: HookEvent, matchQuery?: string): Hook[] {
const matchers = config[event]
if (!matchers || matchers.length === 0) {
return []
}
// Flatten all hooks from all matchers
const allHooks: Hook[] = []
for (const matcher of matchers) {
// If a matcher is specified, check if it matches the query
if (matcher.matcher && matchQuery) {
// Simple string matching - can be enhanced with glob patterns if needed
if (!matchQuery.includes(matcher.matcher)) {
continue
}
}
// Filter enabled hooks
const enabledHooks = matcher.hooks.filter((hook) => hook.enabled !== false)
allHooks.push(...enabledHooks)
}
return allHooks
}
/**
* Validates a hooks configuration object
*
* @param config - The configuration to validate
* @returns Whether the configuration is valid
*/
export function validateHooksConfig(config: unknown): config is HooksConfig {
try {
HooksConfigSchema.parse(config)
return true
} catch {
return false
}
}

291
src/core/hooks/executor.ts Normal file
View file

@ -0,0 +1,291 @@
/**
* Hook Executor
*
* This module handles the execution of hooks, including command hooks
* and HTTP hooks.
*/
import { exec } from "child_process"
import { promisify } from "util"
import * as vscode from "vscode"
import type {
CommandHook,
HttpHook,
Hook,
HookExecutionOptions,
HookExecutionResult,
HookExecutionSummary,
HookEvent,
HookInput,
} from "./types"
const execAsync = promisify(exec)
let _hooksOutputChannel: vscode.OutputChannel | undefined
function getHooksOutputChannel(): vscode.OutputChannel {
if (!_hooksOutputChannel) {
_hooksOutputChannel = vscode.window.createOutputChannel("Roo Code Hooks")
}
return _hooksOutputChannel
}
/**
* Default timeout for hook execution in milliseconds
*/
const DEFAULT_HOOK_TIMEOUT_MS = 60 * 1000 // 60 seconds
/**
* Executes a command hook
*
* @param hook - The command hook to execute
* @param input - The hook input data
* @param options - Execution options
* @returns The execution result
*/
async function executeCommandHook(
hook: CommandHook,
input: HookInput,
options: HookExecutionOptions,
): Promise<HookExecutionResult> {
const startTime = Date.now()
const timeout = (hook.timeout || 60) * 1000
const cwd = hook.cwd || options.cwd || process.cwd()
// Prepare environment variables
const env = {
...process.env,
...hook.env,
// Add hook input as environment variables
HOOK_EVENT: input.hook_event_name,
HOOK_INPUT: JSON.stringify(input),
}
// Determine shell to use
let shell = hook.shell || "bash"
let command = hook.command
// On Windows, default to cmd if no shell specified
if (process.platform === "win32" && !hook.shell) {
shell = "cmd"
}
// Wrap command for different shells
if (shell === "powershell") {
command = `powershell -Command "${command.replace(/"/g, '\\"')}"`
} else if (shell === "cmd") {
command = `cmd /c "${command}"`
}
try {
// Execute the command with timeout
const { stdout, stderr } = await execAsync(command, {
cwd,
env,
timeout,
windowsHide: true,
})
const output = stdout.trim()
const error = stderr.trim()
return {
hook,
succeeded: true,
output: output || "",
error: error || undefined,
duration: Date.now() - startTime,
}
} catch (error) {
let errorMessage = "Unknown error"
if (error instanceof Error) {
errorMessage = error.message
// Check if it was a timeout
if (error.message.includes("timed out")) {
errorMessage = `Command timed out after ${timeout}ms`
}
}
return {
hook,
succeeded: false,
output: "",
error: errorMessage,
duration: Date.now() - startTime,
}
}
}
/**
* Executes an HTTP hook
*
* @param hook - The HTTP hook to execute
* @param input - The hook input data
* @param options - Execution options
* @returns The execution result
*/
async function executeHttpHook(
hook: HttpHook,
input: HookInput,
options: HookExecutionOptions,
): Promise<HookExecutionResult> {
const startTime = Date.now()
const timeout = (hook.timeout || 60) * 1000
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
// Combine options signal with timeout signal
if (options.signal) {
options.signal.addEventListener("abort", () => controller.abort())
}
const response = await fetch(hook.url, {
method: hook.method || "POST",
headers: {
"Content-Type": "application/json",
...hook.headers,
},
body: JSON.stringify(input),
signal: controller.signal,
})
clearTimeout(timeoutId)
const responseText = await response.text()
if (!response.ok) {
return {
hook,
succeeded: false,
output: responseText,
error: `HTTP ${response.status}: ${response.statusText}`,
duration: Date.now() - startTime,
}
}
return {
hook,
succeeded: true,
output: responseText,
duration: Date.now() - startTime,
}
} catch (error) {
let errorMessage = "Unknown error"
if (error instanceof Error) {
errorMessage = error.message
if (error.name === "AbortError") {
errorMessage = `HTTP request timed out after ${timeout}ms`
}
}
return {
hook,
succeeded: false,
output: "",
error: errorMessage,
duration: Date.now() - startTime,
}
}
}
/**
* Executes a single hook
*
* @param hook - The hook to execute
* @param input - The hook input data
* @param options - Execution options
* @returns The execution result
*/
export async function executeHook(
hook: Hook,
input: HookInput,
options: HookExecutionOptions = {},
): Promise<HookExecutionResult> {
// Check for abort signal
if (options.signal?.aborted) {
return {
hook,
succeeded: false,
output: "",
error: "Hook execution aborted",
duration: 0,
}
}
// Execute based on hook type
if (hook.type === "command") {
return executeCommandHook(hook, input, options)
} else if (hook.type === "http") {
return executeHttpHook(hook, input, options)
}
// This should never happen due to type checking
return {
hook,
succeeded: false,
output: "",
error: `Unknown hook type: ${(hook as Hook).type}`,
duration: 0,
}
}
/**
* Executes multiple hooks for an event
*
* @param event - The hook event
* @param hooks - The hooks to execute
* @param input - The hook input data
* @param options - Execution options
* @returns The execution summary
*/
export async function executeHooks(
event: HookEvent,
hooks: Hook[],
input: HookInput,
options: HookExecutionOptions = {},
): Promise<HookExecutionSummary> {
const startTime = Date.now()
// Execute all hooks in parallel
const results = await Promise.all(hooks.map((hook) => executeHook(hook, input, options)))
// Check for failures
const hasFailures = results.some((result) => !result.succeeded)
// Build user message
const messages: string[] = []
for (const result of results) {
const hookType = result.hook.type === "command" ? "command" : "http"
const hookName = result.hook.description || result.hook.id
if (result.succeeded) {
if (result.output) {
messages.push(`${event} [${hookName}] completed successfully: ${result.output}`)
} else {
messages.push(`${event} [${hookName}] completed successfully`)
}
} else {
if (result.error) {
messages.push(`${event} [${hookName}] failed: ${result.error}`)
} else {
messages.push(`${event} [${hookName}] failed`)
}
}
}
if (messages.length > 0) {
const ch = getHooksOutputChannel()
ch.appendLine(`[${new Date().toISOString()}] ${event} hooks executed`)
ch.appendLine(messages.join("\n"))
ch.appendLine("---")
}
return {
event,
results,
hasFailures,
totalDuration: Date.now() - startTime,
userMessage: messages.length > 0 ? messages.join("\n") : undefined,
}
}

183
src/core/hooks/index.ts Normal file
View file

@ -0,0 +1,183 @@
/**
* Hook System for Roo Code Extension
*
* This module provides a hook system that allows users to execute custom
* commands at specific points in the extension's lifecycle, such as before
* and after context condensation.
*
* Features:
* - Command hooks: Execute shell commands
* - HTTP hooks: Send HTTP POST requests
* - Configuration via JSON file in workspace
* - Async execution with timeout support
* - Graceful error handling
*/
import type {
CompactTrigger,
HookExecutionOptions,
HooksConfig,
PreCompactHookInput,
PostCompactHookInput,
} from "./types"
import { loadHooksConfig, getHooksForEvent, DEFAULT_HOOKS_CONFIG_FILE } from "./config"
import { executeHooks } from "./executor"
// Re-export types and utilities
export * from "./types"
export { loadHooksConfig, getHooksForEvent, DEFAULT_HOOKS_CONFIG_FILE } from "./config"
export { executeHooks, executeHook } from "./executor"
/**
* Hook system configuration
*/
export interface HookSystemConfig {
/** Whether hooks are enabled */
enabled: boolean
/** Path to hooks configuration file */
configPath?: string
/** Workspace root path */
workspacePath: string
}
/**
* Result of executing pre-compact hooks
*/
export interface PreCompactHookResult {
/** New custom instructions from hooks */
newCustomInstructions?: string
/** User-facing message */
userMessage?: string
/** Whether any hook failed */
hasFailures: boolean
}
/**
* Result of executing post-compact hooks
*/
export interface PostCompactHookResult {
/** User-facing message */
userMessage?: string
/** Whether any hook failed */
hasFailures: boolean
}
/**
* Hook system class that manages hook execution
*/
export class HookSystem {
private config: HookSystemConfig
constructor(config: HookSystemConfig) {
this.config = config
}
/**
* Updates the hook system configuration
*/
updateConfig(config: Partial<HookSystemConfig>): void {
this.config = { ...this.config, ...config }
}
/**
* Checks if hooks are enabled
*/
isEnabled(): boolean {
return this.config.enabled
}
/**
* Loads hooks configuration fresh from disk on each call so that
* changes to .roo-hooks.json are always picked up without a reload.
*/
private async loadConfig(): Promise<HooksConfig> {
const result = await loadHooksConfig(this.config.workspacePath, this.config.configPath)
return result.config
}
/**
* Executes pre-compact hooks
*/
async executePreCompactHooks(
trigger: CompactTrigger,
customInstructions: string | null,
options: HookExecutionOptions = {},
): Promise<PreCompactHookResult> {
if (!this.isEnabled()) {
return { hasFailures: false }
}
const config = await this.loadConfig()
const hooks = getHooksForEvent(config, "PreCompact", trigger)
if (hooks.length === 0) {
return { hasFailures: false }
}
const input: PreCompactHookInput = {
hook_event_name: "PreCompact",
trigger,
custom_instructions: customInstructions,
cwd: options.cwd || this.config.workspacePath,
taskId: options.taskId || "",
}
const summary = await executeHooks("PreCompact", hooks, input, options)
const successfulOutputs = summary.results
.filter((result) => result.succeeded && result.output.trim().length > 0)
.map((result) => result.output.trim())
return {
newCustomInstructions: successfulOutputs.length > 0 ? successfulOutputs.join("\n\n") : undefined,
userMessage: summary.userMessage,
hasFailures: summary.hasFailures,
}
}
/**
* Executes post-compact hooks
*/
async executePostCompactHooks(
trigger: CompactTrigger,
compactSummary: string,
prevTokens: number,
newTokens: number,
options: HookExecutionOptions = {},
): Promise<PostCompactHookResult> {
if (!this.isEnabled()) {
return { hasFailures: false }
}
const config = await this.loadConfig()
const hooks = getHooksForEvent(config, "PostCompact", trigger)
if (hooks.length === 0) {
return { hasFailures: false }
}
const input: PostCompactHookInput = {
hook_event_name: "PostCompact",
trigger,
compact_summary: compactSummary,
prev_tokens: prevTokens,
new_tokens: newTokens,
cwd: options.cwd || this.config.workspacePath,
taskId: options.taskId || "",
}
const summary = await executeHooks("PostCompact", hooks, input, options)
return {
userMessage: summary.userMessage,
hasFailures: summary.hasFailures,
}
}
}
/**
* Creates a new hook system instance
*/
export function createHookSystem(workspacePath: string, enabled: boolean = true, configPath?: string): HookSystem {
return new HookSystem({ enabled, workspacePath, configPath })
}

166
src/core/hooks/types.ts Normal file
View file

@ -0,0 +1,166 @@
/**
* Hook Types for Roo Code Extension
*
* This module defines the type system for the hook functionality,
* which allows users to execute custom commands at specific points
* in the extension's lifecycle (e.g., before/after context condensation).
*/
/**
* Hook event types that can be triggered in the extension
*/
export type HookEvent = "PreCompact" | "PostCompact"
/**
* Trigger type for compact hooks
*/
export type CompactTrigger = "manual" | "auto"
/**
* Base hook configuration
*/
export interface BaseHook {
/** Unique identifier for this hook */
id: string
/** Type of hook (command, http) */
type: "command" | "http"
/** Optional description of what this hook does */
description?: string
/** Whether this hook is enabled */
enabled?: boolean
/** Optional timeout in seconds (default: 60) */
timeout?: number
}
/**
* Command hook configuration - executes a shell command
*/
export interface CommandHook extends BaseHook {
type: "command"
/** Shell command to execute */
command: string
/** Shell interpreter to use (bash, powershell, cmd) */
shell?: "bash" | "powershell" | "cmd"
/** Working directory for the command (default: project root) */
cwd?: string
/** Environment variables to pass to the command */
env?: Record<string, string>
}
/**
* HTTP hook configuration - sends an HTTP POST request
*/
export interface HttpHook extends BaseHook {
type: "http"
/** URL to POST the hook data to */
url: string
/** HTTP method (default: POST) */
method?: "POST" | "PUT" | "PATCH"
/** Additional headers to include in the request */
headers?: Record<string, string>
}
/**
* Union type for all hook configurations
*/
export type Hook = CommandHook | HttpHook
/**
* Hook matcher configuration - allows filtering when hooks run
*/
export interface HookMatcher {
/** Optional pattern to match (e.g., trigger type) */
matcher?: string
/** List of hooks to execute when the matcher matches */
hooks: Hook[]
}
/**
* Hooks configuration - maps hook events to their matchers
*/
export type HooksConfig = Partial<Record<HookEvent, HookMatcher[]>>
/**
* Input data passed to PreCompact hooks
*/
export interface PreCompactHookInput {
/** Hook event name */
hook_event_name: "PreCompact"
/** Trigger type (manual or auto) */
trigger: CompactTrigger
/** Custom instructions for condensation (if any) */
custom_instructions: string | null
/** Current working directory */
cwd: string
/** Task ID */
taskId: string
}
/**
* Input data passed to PostCompact hooks
*/
export interface PostCompactHookInput {
/** Hook event name */
hook_event_name: "PostCompact"
/** Trigger type (manual or auto) */
trigger: CompactTrigger
/** The generated summary from condensation */
compact_summary: string
/** Number of tokens before condensation */
prev_tokens: number
/** Number of tokens after condensation */
new_tokens: number
/** Current working directory */
cwd: string
/** Task ID */
taskId: string
}
/**
* Union type for all hook inputs
*/
export type HookInput = PreCompactHookInput | PostCompactHookInput
/**
* Result of executing a single hook
*/
export interface HookExecutionResult {
/** The hook that was executed */
hook: Hook
/** Whether the hook executed successfully */
succeeded: boolean
/** Output from the hook (stdout for commands, response body for HTTP) */
output: string
/** Error message if the hook failed */
error?: string
/** Execution time in milliseconds */
duration: number
}
/**
* Result of executing hooks for an event
*/
export interface HookExecutionSummary {
/** Event name */
event: HookEvent
/** All hook execution results */
results: HookExecutionResult[]
/** Whether any hook failed */
hasFailures: boolean
/** Total execution time in milliseconds */
totalDuration: number
/** User-facing message summarizing the results */
userMessage?: string
}
/**
* Options for executing hooks
*/
export interface HookExecutionOptions {
/** Abort signal to cancel hook execution */
signal?: AbortSignal
/** Working directory for command hooks */
cwd?: string
/** Task ID for tracking */
taskId?: string
}

View file

@ -10,6 +10,7 @@ import executeCommand from "./execute_command"
import generateImage from "./generate_image"
import listFiles from "./list_files"
import newTask from "./new_task"
import spawnParallelTasks from "./spawn_parallel_tasks"
import readCommandOutput from "./read_command_output"
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
import runSlashCommand from "./run_slash_command"
@ -57,6 +58,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
generateImage,
listFiles,
newTask,
spawnParallelTasks,
readCommandOutput,
createReadFileTool(readFileOptions),
runSlashCommand,

View file

@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for
const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos`
const WORKTREE_PARAMETER_DESCRIPTION = `Optional git worktree for filesystem isolation. Use "auto" to create a new branch+worktree automatically, or provide a branch name (e.g., "feat/my-branch") to check out that branch in an isolated worktree. Omit when the task doesn't need its own filesystem scope.`
export default {
type: "function",
function: {
@ -31,8 +33,12 @@ export default {
type: ["string", "null"],
description: TODOS_PARAMETER_DESCRIPTION,
},
worktree: {
type: ["string", "null"],
description: WORKTREE_PARAMETER_DESCRIPTION,
},
},
required: ["mode", "message", "todos"],
required: ["mode", "message", "todos", "worktree"],
additionalProperties: false,
},
},

View file

@ -0,0 +1,52 @@
import type OpenAI from "openai"
const SPAWN_PARALLEL_TASKS_DESCRIPTION = `Spawn multiple subtasks that execute sequentially and whose results are aggregated before returning to you.
Use this when you need to split work into independent chunks (e.g., implement N features, analyze N files, run N experiments) and collect all results before proceeding. Each task runs to completion before the next starts. When all tasks finish, you receive their aggregated results as a JSON array.
CRITICAL: This tool MUST be called alone. Do NOT call it alongside other tools in the same turn.`
export default {
type: "function",
function: {
name: "spawn_parallel_tasks",
description: SPAWN_PARALLEL_TASKS_DESCRIPTION,
strict: true,
parameters: {
type: "object",
properties: {
tasks: {
type: "array",
description: "List of tasks to execute. Must contain at least 2 items.",
items: {
type: "object",
properties: {
mode: {
type: "string",
description: "Mode slug for this task (e.g., code, debug, architect)",
},
message: {
type: "string",
description: "Instructions for this task",
},
worktree: {
type: ["string", "null"],
description:
'Optional git worktree isolation. "auto" creates a new branch+worktree, or provide a branch name.',
},
todos: {
type: ["string", "null"],
description: "Optional markdown checklist of initial todos for this task",
},
},
required: ["mode", "message", "worktree", "todos"],
additionalProperties: false,
},
minItems: 2,
},
},
required: ["tasks"],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -35,6 +35,8 @@ export type ApiMessage = Anthropic.MessageParam & {
truncationParent?: string
// Identifies a message as a truncation boundary marker
isTruncationMarker?: boolean
// Identifies a message as synthetic/meta (e.g., retry markers)
isMeta?: boolean
}
export async function readApiMessages({

View file

@ -126,7 +126,14 @@ import {
checkpointDiff,
} from "../checkpoints"
import { processUserContentMentions } from "../mentions/processUserContentMentions"
import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHistory } from "../condense"
import {
getMessagesSinceLastSummary,
summarizeConversation,
getEffectiveApiHistory,
extractSessionMemory,
} from "../condense"
import { estimateMessageTokens } from "../condense/sessionMemory"
import { createHookSystem, type HookSystem } from "../hooks"
import { MessageQueueService } from "../message-queue/MessageQueueService"
import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
import { MessageManager } from "../message-manager"
@ -417,6 +424,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// MessageManager for high-level message operations (lazy initialized)
private _messageManager?: MessageManager
// Session Memory (per-task, avoids module-level global state)
private sessionMemoryContent: string | undefined
private sessionMemoryLastMessageId: string | undefined
private sessionMemoryTokensAtLastExtraction = 0
private sessionMemoryInitialized = false
private sessionMemoryToolCallsSinceUpdate = 0
private sessionMemoryExtracting = false
constructor({
provider,
apiConfiguration,
@ -467,10 +482,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
images: historyItem ? [] : images,
}
// Normal use-case is usually retry similar history task with new workspace.
this.workspacePath = parentTask
? parentTask.workspacePath
: (workspacePath ?? getWorkspacePath(path.join(os.homedir(), "Desktop")))
// Explicit workspacePath always wins (e.g., worktree tasks). Falls back to parent's path, then default.
this.workspacePath =
workspacePath ?? parentTask?.workspacePath ?? getWorkspacePath(path.join(os.homedir(), "Desktop"))
this.instanceId = crypto.randomUUID().slice(0, 8)
this.taskNumber = -1
@ -3614,6 +3628,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.consecutiveNoToolUseCount = 0
}
// Fire-and-forget session memory extraction after each tool turn.
// Runs in background and never blocks the agentic loop.
void this.maybeExtractSessionMemory()
// Push to stack if there's content OR if we're paused waiting for a subtask.
// When paused, we push an empty item so the loop continues to the pause check.
if (this.userMessageContent.length > 0 || this.isPaused) {
@ -3887,6 +3905,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Generate environment details to include in the condensed summary
const environmentDetails = await getEnvironmentDetails(this, true)
const forcedCfg = vscode.workspace.getConfiguration(Package.name)
const forcedHooksEnabled = forcedCfg.get<boolean>("compactHooksEnabled", true)
const forcedHooksPath = forcedCfg.get<string>("compactHooksPath") || undefined
const forcedHookSystem: HookSystem | undefined = forcedHooksEnabled
? createHookSystem(this.cwd, true, forcedHooksPath)
: undefined
// Force aggressive truncation by keeping only 75% of the conversation history
const truncateResult = await manageContext({
messages: this.apiConversationHistory,
@ -3902,6 +3927,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
currentProfileId,
metadata,
environmentDetails,
sessionMemory: this.sessionMemoryContent,
sessionMemoryCompactEnabled: forcedCfg.get<boolean>("sessionMemoryCompactEnabled", true),
lastSummarizedMessageId: this.sessionMemoryLastMessageId,
hookSystem: forcedHookSystem,
promptTooLongRetryEnabled: true,
promptTooLongMaxRetries: 2,
})
if (truncateResult.messages !== this.apiConversationHistory) {
@ -3984,6 +4015,66 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
/**
* Conditionally extracts session memory from the current conversation history.
* Runs asynchronously and fire-and-forget — never blocks the main agentic loop.
* Guards against concurrent extractions and respects the configured thresholds.
*/
private async maybeExtractSessionMemory(): Promise<void> {
if (this.sessionMemoryExtracting) {
return
}
const cfg = vscode.workspace.getConfiguration(Package.name)
if (!cfg.get<boolean>("sessionMemoryCompactEnabled", true)) {
return
}
const minTokensToInit = cfg.get<number>("sessionMemoryMinTokensToInit", 10000)
const minTokensBetweenUpdate = cfg.get<number>("sessionMemoryMinTokensBetweenUpdate", 5000)
const toolCallsBetweenUpdates = cfg.get<number>("sessionMemoryToolCallsBetweenUpdates", 3)
this.sessionMemoryToolCallsSinceUpdate++
if (this.sessionMemoryToolCallsSinceUpdate < toolCallsBetweenUpdates) {
return
}
const currentTokens = estimateMessageTokens(this.apiConversationHistory)
if (!this.sessionMemoryInitialized && currentTokens < minTokensToInit) {
return
}
if (this.sessionMemoryInitialized) {
const tokensSince = currentTokens - this.sessionMemoryTokensAtLastExtraction
if (tokensSince < minTokensBetweenUpdate) {
return
}
}
this.sessionMemoryExtracting = true
this.sessionMemoryToolCallsSinceUpdate = 0
const snapshotLastMessageId = this.apiConversationHistory.at(-1)?.id
try {
const updated = await extractSessionMemory(
this.apiConversationHistory,
this.sessionMemoryContent ?? null,
this.api,
)
this.sessionMemoryContent = updated
this.sessionMemoryInitialized = true
this.sessionMemoryTokensAtLastExtraction = currentTokens
if (snapshotLastMessageId) {
this.sessionMemoryLastMessageId = snapshotLastMessageId
}
} catch (err) {
console.error("[Session Memory] Extraction failed:", err)
} finally {
this.sessionMemoryExtracting = false
}
}
public async *attemptApiRequest(
retryAttempt: number = 0,
options: { skipProviderRateLimit?: boolean } = {},
@ -4111,6 +4202,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
? await this.getFilesReadByRooSafely("attemptApiRequest")
: undefined
const cfg = vscode.workspace.getConfiguration(Package.name)
const promptTooLongRetryEnabled = cfg.get<boolean>("promptTooLongRetryEnabled", true)
const promptTooLongMaxRetries = cfg.get<number>("promptTooLongMaxRetries", 2)
const compactHooksEnabled = cfg.get<boolean>("compactHooksEnabled", true)
const compactHooksPath = cfg.get<string>("compactHooksPath") || undefined
const hookSystem: HookSystem | undefined = compactHooksEnabled
? createHookSystem(this.cwd, true, compactHooksPath)
: undefined
try {
const truncateResult = await manageContext({
messages: this.apiConversationHistory,
@ -4130,6 +4230,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
filesReadByRoo: contextMgmtFilesReadByRoo,
cwd: this.cwd,
rooIgnoreController: this.rooIgnoreController,
sessionMemory: this.sessionMemoryContent,
sessionMemoryCompactEnabled: cfg.get<boolean>("sessionMemoryCompactEnabled", true),
lastSummarizedMessageId: this.sessionMemoryLastMessageId,
hookSystem,
promptTooLongRetryEnabled,
promptTooLongMaxRetries,
})
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)

View file

@ -30,6 +30,7 @@ interface DelegationProvider {
parentTaskId: string
childTaskId: string
completionResultSummary: string
completionPayload?: Record<string, unknown>
}): Promise<void>
}
@ -169,10 +170,22 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
pushToolResult("")
// If the result is valid JSON, pass it as a structured payload alongside the summary.
let completionPayload: Record<string, unknown> | undefined
try {
const parsed = JSON.parse(result)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
completionPayload = parsed as Record<string, unknown>
}
} catch {
// Not JSON — leave completionPayload undefined
}
await provider.reopenParentFromDelegation({
parentTaskId: task.parentTaskId!,
childTaskId: task.taskId,
completionResultSummary: result,
completionPayload,
})
return "delegated"

View file

@ -15,13 +15,14 @@ interface NewTaskParams {
mode: string
message: string
todos?: string
worktree?: string
}
export class NewTaskTool extends BaseTool<"new_task"> {
readonly name = "new_task" as const
async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { mode, message, todos } = params
const { mode, message, todos, worktree } = params
const { askApproval, handleError, pushToolResult } = callbacks
try {
@ -115,6 +116,7 @@ export class NewTaskTool extends BaseTool<"new_task"> {
message: unescapedMessage,
initialTodos: todoItems,
mode,
worktree: worktree || undefined,
})
// Reflect delegation in tool result (no pause/unpause, no wait)

View file

@ -0,0 +1,121 @@
import { TodoItem } from "@roo-code/types"
import { Task } from "../task/Task"
import { getModeBySlug } from "../../shared/modes"
import { formatResponse } from "../prompts/responses"
import { BaseTool, ToolCallbacks } from "./BaseTool"
import type { ToolUse } from "../../shared/tools"
import { parseMarkdownChecklist } from "./UpdateTodoListTool"
interface ParallelTaskSpec {
mode: string
message: string
worktree?: string
todos?: string
}
interface SpawnParallelTasksParams {
tasks: ParallelTaskSpec[]
}
export class SpawnParallelTasksTool extends BaseTool<"spawn_parallel_tasks"> {
readonly name = "spawn_parallel_tasks" as const
async execute(params: SpawnParallelTasksParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { tasks } = params
const { askApproval, handleError, pushToolResult } = callbacks
try {
if (!tasks || tasks.length < 2) {
task.consecutiveMistakeCount++
task.recordToolError("spawn_parallel_tasks")
task.didToolFailInCurrentTurn = true
pushToolResult(formatResponse.toolError("spawn_parallel_tasks requires at least 2 tasks"))
return
}
const provider = task.providerRef.deref()
if (!provider) {
pushToolResult(formatResponse.toolError("Provider reference lost"))
return
}
const state = await provider.getState()
// Validate all modes up front before asking approval
for (const spec of tasks) {
if (!spec.mode) {
pushToolResult(formatResponse.toolError(`Each task must have a mode`))
return
}
if (!spec.message) {
pushToolResult(formatResponse.toolError(`Each task must have a message`))
return
}
const targetMode = getModeBySlug(spec.mode, state?.customModes)
if (!targetMode) {
pushToolResult(formatResponse.toolError(`Invalid mode: ${spec.mode}`))
return
}
}
// Parse todos for all tasks
const parsedTasks: Array<{ spec: ParallelTaskSpec; todoItems: TodoItem[] }> = []
for (const spec of tasks) {
let todoItems: TodoItem[] = []
if (spec.todos) {
try {
todoItems = parseMarkdownChecklist(spec.todos)
} catch {
pushToolResult(
formatResponse.toolError(`Invalid todos format in task "${spec.message.slice(0, 40)}..."`),
)
return
}
}
parsedTasks.push({ spec, todoItems })
}
const toolMessage = JSON.stringify({
tool: "spawnParallelTasks",
taskCount: tasks.length,
tasks: tasks.map((t) => ({ mode: t.mode, message: t.message.slice(0, 100) })),
})
const didApprove = await askApproval("tool", toolMessage)
if (!didApprove) return
task.consecutiveMistakeCount = 0
// The first task starts immediately; the rest are queued in the parent's history.
// reopenParentFromDelegation will drain the queue, starting each child in turn,
// and resume the parent with aggregated results when the queue is empty.
const [first, ...rest] = parsedTasks
await (provider as any).delegateParentAndOpenChild({
parentTaskId: task.taskId,
message: first.spec.message,
initialTodos: first.todoItems,
mode: first.spec.mode,
worktree: first.spec.worktree || undefined,
parallelQueue: rest.map((t) => ({
mode: t.spec.mode,
message: t.spec.message,
worktree: t.spec.worktree || undefined,
todos: t.spec.todos || undefined,
})),
})
pushToolResult(`Spawned ${tasks.length} sequential tasks. Awaiting all results...`)
} catch (error) {
await handleError("spawning parallel tasks", error)
}
}
override async handlePartial(task: Task, block: ToolUse<"spawn_parallel_tasks">): Promise<void> {
const partialMessage = JSON.stringify({ tool: "spawnParallelTasks" })
await task.ask("tool", partialMessage, block.partial).catch(() => {})
}
}
export const spawnParallelTasksTool = new SpawnParallelTasksTool()

View file

@ -70,6 +70,7 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { worktreeService } from "@roo-code/core"
import { MarketplaceManager } from "../../services/marketplace"
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
import { CodeIndexManager } from "../../services/code-index/manager"
@ -3233,8 +3234,10 @@ export class ClineProvider
message: string
initialTodos: TodoItem[]
mode: string
worktree?: string
parallelQueue?: Array<{ mode: string; message: string; worktree?: string; todos?: string }>
}): Promise<Task> {
const { parentTaskId, message, initialTodos, mode } = params
const { parentTaskId, message, initialTodos, mode, worktree, parallelQueue } = params
// Metadata-driven delegation is always enabled
@ -3310,24 +3313,54 @@ export class ClineProvider
)
}
// 4) Create child as sole active (parent reference preserved for lineage)
// 4) Optionally create a git worktree for filesystem isolation.
let childWorktreePath: string | undefined
if (worktree) {
try {
const branchName = worktree === "auto" ? `roo/task-${Date.now().toString(36)}` : worktree
const worktreeBase = path.join(os.homedir(), ".roo", "worktrees")
const projectName = path.basename(parent.workspacePath)
const suffix = Math.random().toString(36).slice(2, 7)
const worktreeDest = path.join(worktreeBase, `${projectName}-${suffix}`)
const result = await worktreeService.createWorktree(parent.workspacePath, {
path: worktreeDest,
branch: branchName,
createNewBranch: true,
})
if (result.success) {
childWorktreePath = worktreeDest
this.log(`[delegateParentAndOpenChild] Created worktree at ${worktreeDest} on branch ${branchName}`)
} else {
this.log(`[delegateParentAndOpenChild] Worktree creation failed (non-fatal): ${result.message}`)
}
} catch (err) {
this.log(
`[delegateParentAndOpenChild] Worktree creation error (non-fatal): ${
(err as Error)?.message ?? String(err)
}`,
)
}
}
// 5) Create child as sole active (parent reference preserved for lineage).
// Pass initialStatus: "active" to ensure the child task's historyItem is created
// with status from the start, avoiding race conditions where the task might
// call attempt_completion before status is persisted separately.
//
// Pass startTask: false to prevent the child from beginning its task loop
// (and writing to globalState via saveClineMessages → updateTaskHistory)
// before we persist the parent's delegation metadata in step 5.
// Without this, the child's fire-and-forget startTask() races with step 5,
// before we persist the parent's delegation metadata in step 6.
// Without this, the child's fire-and-forget startTask() races with step 6,
// and the last writer to globalState overwrites the other's changes—
// causing the parent's delegation fields to be lost.
const child = await this.createTask(message, undefined, parent as any, {
initialTodos,
initialStatus: "active",
startTask: false,
workspacePath: childWorktreePath,
})
// 5) Persist parent delegation metadata BEFORE the child starts writing.
// 6) Persist parent delegation metadata BEFORE the child starts writing.
try {
const { historyItem } = await this.getTaskWithId(parentTaskId)
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId]))
@ -3337,6 +3370,8 @@ export class ClineProvider
delegatedToId: child.taskId,
awaitingChildId: child.taskId,
childIds,
// Store the parallel queue in the parent so reopenParentFromDelegation can drain it
...(parallelQueue && parallelQueue.length > 0 ? { parallelQueue } : {}),
}
await this.updateTaskHistory(updatedHistory)
} catch (err) {
@ -3347,11 +3382,22 @@ export class ClineProvider
)
}
// 6) Start the child task now that parent metadata is safely persisted.
// 7) Persist the child's worktree path so it can be cleaned up on completion.
if (childWorktreePath) {
try {
const { historyItem: childHistory } = await this.getTaskWithId(child.taskId)
await this.updateTaskHistory({ ...childHistory, worktreePath: childWorktreePath })
} catch {
// non-fatal
}
}
// 8) Start the child task now that parent metadata is safely persisted.
child.start()
// 7) Emit TaskDelegated (provider-level)
// 9) Emit TaskSpawned and TaskDelegated (provider-level)
try {
this.emit(RooCodeEventName.TaskSpawned, child.taskId)
this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId)
} catch {
// non-fatal
@ -3367,8 +3413,9 @@ export class ClineProvider
parentTaskId: string
childTaskId: string
completionResultSummary: string
completionPayload?: Record<string, unknown>
}): Promise<void> {
const { parentTaskId, childTaskId, completionResultSummary } = params
const { parentTaskId, childTaskId, completionResultSummary, completionPayload } = params
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
// 1) Load parent from history and current persisted messages
@ -3376,115 +3423,19 @@ export class ClineProvider
let parentClineMessages: ClineMessage[] = []
try {
parentClineMessages = await readTaskMessages({
taskId: parentTaskId,
globalStoragePath,
})
parentClineMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath })
} catch {
parentClineMessages = []
}
let parentApiMessages: any[] = []
try {
parentApiMessages = (await readApiMessages({
taskId: parentTaskId,
globalStoragePath,
})) as any[]
parentApiMessages = (await readApiMessages({ taskId: parentTaskId, globalStoragePath })) as any[]
} catch {
parentApiMessages = []
}
// 2) Inject synthetic records: UI subtask_result and update API tool_result
const ts = Date.now()
// Defensive: ensure arrays
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
const subtaskUiMessage: ClineMessage = {
type: "say",
say: "subtask_result",
text: completionResultSummary,
ts,
}
parentClineMessages.push(subtaskUiMessage)
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
// Find the tool_use_id from the last assistant message's new_task tool_use
let toolUseId: string | undefined
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
const msg = parentApiMessages[i]
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.name === "new_task") {
toolUseId = block.id
break
}
}
if (toolUseId) break
}
}
// Preferred: if the parent history contains the native tool_use for new_task,
// inject a matching tool_result for the Anthropic message contract:
// user → assistant (tool_use) → user (tool_result)
if (toolUseId) {
// Check if the last message is already a user message with a tool_result for this tool_use_id
// (in case this is a retry or the history was already updated)
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
let alreadyHasToolResult = false
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
for (const block of lastMsg.content) {
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
// Update the existing tool_result content
block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
alreadyHasToolResult = true
break
}
}
}
// If no existing tool_result found, create a NEW user message with the tool_result
if (!alreadyHasToolResult) {
parentApiMessages.push({
role: "user",
content: [
{
type: "tool_result" as const,
tool_use_id: toolUseId,
content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
},
],
ts,
})
}
// Validate the newly injected tool_result against the preceding assistant message.
// This ensures the tool_result's tool_use_id matches a tool_use in the immediately
// preceding assistant message (Anthropic API requirement).
const lastMessage = parentApiMessages[parentApiMessages.length - 1]
if (lastMessage?.role === "user") {
const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1))
parentApiMessages[parentApiMessages.length - 1] = validatedMessage
}
} else {
// If there is no corresponding tool_use in the parent API history, we cannot emit a
// tool_result. Fall back to a plain user text note so the parent can still resume.
parentApiMessages.push({
role: "user",
content: [
{
type: "text" as const,
text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
},
],
ts,
})
}
await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath })
// 3) Close child instance if still open (single-open-task invariant).
// 2) Close child instance if still open (single-open-task invariant).
// This MUST happen BEFORE updating the child's status to "completed" because
// removeClineFromStack() → abortTask(true) → saveClineMessages() writes
// the historyItem with initialStatus (typically "active"), which would
@ -3494,14 +3445,15 @@ export class ClineProvider
await this.removeClineFromStack()
}
// 4) Update child metadata to "completed" status.
// This runs after the abort so it overwrites the stale "active" status
// that saveClineMessages() may have written during step 3.
// 3) Update child metadata to "completed" and clean up its worktree (if any).
let childWorktreePath: string | undefined
try {
const { historyItem: childHistory } = await this.getTaskWithId(childTaskId)
childWorktreePath = childHistory.worktreePath
await this.updateTaskHistory({
...childHistory,
status: "completed",
completionPayload: completionPayload,
})
} catch (err) {
this.log(
@ -3511,7 +3463,189 @@ export class ClineProvider
)
}
// 5) Update parent metadata and persist BEFORE emitting completion event
if (childWorktreePath) {
try {
await worktreeService.deleteWorktree(historyItem.workspace ?? this.cwd, childWorktreePath)
this.log(`[reopenParentFromDelegation] Deleted worktree at ${childWorktreePath}`)
} catch (err) {
this.log(
`[reopenParentFromDelegation] Worktree cleanup failed (non-fatal): ${
(err as Error)?.message ?? String(err)
}`,
)
}
}
// 4) Handle parallel queue: if the parent has more tasks queued, start the next one
// instead of resuming the parent. The parent resumes only when the queue is empty.
const currentResult = {
taskId: childTaskId,
summary: completionResultSummary,
payload: completionPayload,
}
const accumulatedResults = [...(historyItem.parallelResults ?? []), currentResult]
const remainingQueue = historyItem.parallelQueue ?? []
if (remainingQueue.length > 0) {
// There are more parallel tasks to run — start the next one.
const [nextTask, ...rest] = remainingQueue
// Persist the result so far and reduce the queue
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId]))
await this.updateTaskHistory({
...historyItem,
status: "delegated",
completedByChildId: childTaskId,
completionResultSummary,
childIds,
parallelQueue: rest,
parallelResults: accumulatedResults,
awaitingChildId: undefined, // will be set by delegateParentAndOpenChild
})
try {
this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary)
} catch {
// non-fatal
}
// Mode-switch to the next task's mode, then start it
try {
await this.handleModeSwitch(nextTask.mode as any)
} catch {
// non-fatal
}
let nextTodoItems: TodoItem[] = []
if (nextTask.todos) {
try {
const { parseMarkdownChecklist } = await import("../tools/UpdateTodoListTool")
nextTodoItems = parseMarkdownChecklist(nextTask.todos)
} catch {
// non-fatal: start with empty todos
}
}
// Re-fetch history so delegateParentAndOpenChild sees the updated queue state
const { historyItem: refreshedParentHistory } = await this.getTaskWithId(parentTaskId)
const nextChild = await this.createTask(nextTask.message, undefined, undefined, {
initialTodos: nextTodoItems,
initialStatus: "active",
startTask: false,
workspacePath: nextTask.worktree
? await this._createWorktreeForTask(refreshedParentHistory.workspace ?? this.cwd, nextTask.worktree)
: undefined,
})
// Record the next child in parent history
const updatedChildIds = Array.from(new Set([...(refreshedParentHistory.childIds ?? []), nextChild.taskId]))
await this.updateTaskHistory({
...refreshedParentHistory,
delegatedToId: nextChild.taskId,
awaitingChildId: nextChild.taskId,
childIds: updatedChildIds,
})
if (nextTask.worktree && nextChild.workspacePath !== refreshedParentHistory.workspace) {
try {
const { historyItem: nextChildHistory } = await this.getTaskWithId(nextChild.taskId)
await this.updateTaskHistory({ ...nextChildHistory, worktreePath: nextChild.workspacePath })
} catch {
// non-fatal
}
}
nextChild.start()
try {
this.emit(RooCodeEventName.TaskSpawned, nextChild.taskId)
this.emit(RooCodeEventName.TaskDelegated, parentTaskId, nextChild.taskId)
} catch {
// non-fatal
}
return
}
// 5) All tasks done (or this was a plain new_task). Build the tool result content.
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
const ts = Date.now()
const isParallelFanIn = accumulatedResults.length > 1
// Determine result text — aggregated JSON for parallel fan-in, plain text otherwise
let toolResultContent: string
if (isParallelFanIn) {
toolResultContent = JSON.stringify(accumulatedResults, null, 2)
} else {
toolResultContent = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
}
const subtaskUiMessage: ClineMessage = {
type: "say",
say: "subtask_result",
text: isParallelFanIn
? `All ${accumulatedResults.length} parallel tasks completed:\n${toolResultContent}`
: completionResultSummary,
ts,
}
parentClineMessages.push(subtaskUiMessage)
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
// Find the matching tool_use_id — supports both new_task and spawn_parallel_tasks
let toolUseId: string | undefined
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
const msg = parentApiMessages[i]
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (
block.type === "tool_use" &&
(block.name === "new_task" || block.name === "spawn_parallel_tasks")
) {
toolUseId = block.id
break
}
}
if (toolUseId) break
}
}
if (toolUseId) {
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
let alreadyHasToolResult = false
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
for (const block of lastMsg.content) {
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
block.content = toolResultContent
alreadyHasToolResult = true
break
}
}
}
if (!alreadyHasToolResult) {
parentApiMessages.push({
role: "user",
content: [{ type: "tool_result" as const, tool_use_id: toolUseId, content: toolResultContent }],
ts,
})
}
const lastMessage = parentApiMessages[parentApiMessages.length - 1]
if (lastMessage?.role === "user") {
const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1))
parentApiMessages[parentApiMessages.length - 1] = validatedMessage
}
} else {
parentApiMessages.push({
role: "user",
content: [{ type: "text" as const, text: toolResultContent }],
ts,
})
}
await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath })
// 6) Update parent metadata and persist BEFORE emitting completion event
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId]))
const updatedHistory: typeof historyItem = {
...historyItem,
@ -3520,21 +3654,23 @@ export class ClineProvider
completionResultSummary,
awaitingChildId: undefined,
childIds,
parallelQueue: undefined,
parallelResults: undefined,
}
await this.updateTaskHistory(updatedHistory)
// 6) Emit TaskDelegationCompleted (provider-level)
// 7) Emit TaskDelegationCompleted (provider-level)
try {
this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary)
} catch {
// non-fatal
}
// 7) Reopen the parent from history as the sole active task (restores saved mode)
// 8) Reopen the parent from history as the sole active task (restores saved mode)
// IMPORTANT: startTask=false to suppress resume-from-history ask scheduling
const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false })
// 8) Inject restored histories into the in-memory instance before resuming
// 9) Inject restored histories into the in-memory instance before resuming
if (parentInstance) {
try {
await parentInstance.overwriteClineMessages(parentClineMessages)
@ -3551,7 +3687,7 @@ export class ClineProvider
await parentInstance.resumeAfterDelegation()
}
// 9) Emit TaskDelegationResumed (provider-level)
// 10) Emit TaskDelegationResumed (provider-level)
try {
this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId)
} catch {
@ -3559,6 +3695,25 @@ export class ClineProvider
}
}
/** Creates a worktree for a child task, returning the path on success or undefined on failure. */
private async _createWorktreeForTask(parentWorkspacePath: string, worktree: string): Promise<string | undefined> {
try {
const branchName = worktree === "auto" ? `roo/task-${Date.now().toString(36)}` : worktree
const worktreeBase = path.join(os.homedir(), ".roo", "worktrees")
const projectName = path.basename(parentWorkspacePath)
const suffix = Math.random().toString(36).slice(2, 7)
const worktreeDest = path.join(worktreeBase, `${projectName}-${suffix}`)
const result = await worktreeService.createWorktree(parentWorkspacePath, {
path: worktreeDest,
branch: branchName,
createNewBranch: true,
})
return result.success ? worktreeDest : undefined
} catch {
return undefined
}
}
/**
* Convert a file path to a webview-accessible URI
* This method safely converts file paths to URIs that can be loaded in the webview

View file

@ -1377,6 +1377,11 @@ export const webviewMessageHandler = async (
}
break
}
case "openVSCodeSettings": {
const query = message.text || ""
await vscode.commands.executeCommand("workbench.action.openSettings", query)
break
}
case "openMcpSettings": {
const mcpSettingsFilePath = await provider.getMcpHub()?.getMcpSettingsFilePath()

View file

@ -429,6 +429,100 @@
"default": false,
"description": "%settings.debugProxy.tlsInsecure.description%",
"markdownDescription": "%settings.debugProxy.tlsInsecure.description%"
},
"moo-code.microCompactEnabled": {
"type": "boolean",
"default": true,
"description": "%settings.microCompactEnabled.description%",
"icon": "settings-gear"
},
"moo-code.microCompactThreshold": {
"type": "number",
"default": 5,
"minimum": 1,
"maximum": 50,
"description": "%settings.microCompactThreshold.description%",
"icon": "settings-gear"
},
"moo-code.microCompactKeepRecent": {
"type": "number",
"default": 3,
"minimum": 1,
"maximum": 20,
"description": "%settings.microCompactKeepRecent.description%",
"icon": "settings-gear"
},
"moo-code.sessionMemoryCompactEnabled": {
"type": "boolean",
"default": true,
"description": "%settings.sessionMemoryCompactEnabled.description%",
"icon": "settings-gear"
},
"moo-code.sessionMemoryCompactMinTokens": {
"type": "number",
"default": 10000,
"minimum": 1000,
"maximum": 100000,
"description": "%settings.sessionMemoryCompactMinTokens.description%",
"icon": "settings-gear"
},
"moo-code.sessionMemoryCompactMaxTokens": {
"type": "number",
"default": 50000,
"minimum": 10000,
"maximum": 200000,
"description": "%settings.sessionMemoryCompactMaxTokens.description%",
"icon": "settings-gear"
},
"moo-code.sessionMemoryMinTokensToInit": {
"type": "number",
"default": 10000,
"minimum": 1000,
"maximum": 100000,
"description": "%settings.sessionMemoryMinTokensToInit.description%",
"icon": "settings-gear"
},
"moo-code.sessionMemoryMinTokensBetweenUpdate": {
"type": "number",
"default": 5000,
"minimum": 1000,
"maximum": 50000,
"description": "%settings.sessionMemoryMinTokensBetweenUpdate.description%",
"icon": "settings-gear"
},
"moo-code.sessionMemoryToolCallsBetweenUpdates": {
"type": "number",
"default": 3,
"minimum": 1,
"maximum": 20,
"description": "%settings.sessionMemoryToolCallsBetweenUpdates.description%",
"icon": "settings-gear"
},
"moo-code.compactHooksEnabled": {
"type": "boolean",
"default": true,
"description": "%settings.compactHooksEnabled.description%",
"icon": "settings-gear"
},
"moo-code.compactHooksPath": {
"type": "string",
"default": "",
"description": "%settings.compactHooksPath.description%",
"icon": "settings-gear"
},
"moo-code.promptTooLongRetryEnabled": {
"type": "boolean",
"default": true,
"description": "%settings.promptTooLongRetryEnabled.description%",
"icon": "settings-gear"
},
"moo-code.promptTooLongMaxRetries": {
"type": "number",
"default": 2,
"minimum": 0,
"maximum": 5,
"description": "%settings.promptTooLongMaxRetries.description%",
"icon": "settings-gear"
}
}
}

View file

@ -45,5 +45,18 @@
"settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files.",
"settings.debugProxy.enabled.description": "**Enable Debug Proxy** — Route all outbound network requests through a proxy for MITM debugging. Only active when running in debug mode (F5).",
"settings.debugProxy.serverUrl.description": "Proxy URL (e.g., `http://127.0.0.1:8888`). Only used when **Debug Proxy** is enabled.",
"settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging."
"settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging.",
"settings.microCompactEnabled.description": "Enable microcompact to automatically clear old tool result content (file reads, shell output, search results, etc.) to save tokens. Runs before full condensation.",
"settings.microCompactThreshold.description": "Number of messages back to start clearing tool results. When there are more than this many compactable tool results, older ones will be cleared. Default: 5.",
"settings.microCompactKeepRecent.description": "Number of most-recent compactable tool results to keep. The most recent N tool results are always preserved. Default: 3.",
"settings.sessionMemoryCompactEnabled.description": "Enable session memory compaction to use pre-extracted session memory as a condensation summary instead of calling the LLM again. This can significantly reduce API costs and improve performance for long conversations.",
"settings.sessionMemoryCompactMinTokens.description": "Minimum tokens to preserve after session memory compaction. The compaction will keep at least this many tokens of recent messages. Default: 10000.",
"settings.sessionMemoryCompactMaxTokens.description": "Maximum tokens to preserve after session memory compaction. This is a hard cap to prevent keeping too many messages. Default: 50000.",
"settings.sessionMemoryMinTokensToInit.description": "Minimum context window tokens before initializing session memory extraction. Session memory will only start being extracted after the conversation reaches this size. Default: 10000.",
"settings.sessionMemoryMinTokensBetweenUpdate.description": "Minimum context window growth (in tokens) between session memory updates. Session memory will only be updated after the conversation grows by this many tokens. Default: 5000.",
"settings.sessionMemoryToolCallsBetweenUpdates.description": "Number of tool calls between session memory updates. Session memory will be updated after this many tool calls occur. Default: 3.",
"settings.compactHooksEnabled.description": "Enable pre/post compact hooks to execute custom commands before and after context condensation. Hooks are defined in a .roo-hooks.json file in the workspace root.",
"settings.compactHooksPath.description": "Custom path to the hooks configuration file. Leave empty to use the default .roo-hooks.json file in the workspace root. Supports relative paths from the workspace root.",
"settings.promptTooLongRetryEnabled.description": "Enable automatic retry with head truncation when condensation hits a prompt-too-long error. This helps recover from situations where the condensed context is still too large for the API.",
"settings.promptTooLongMaxRetries.description": "Maximum number of retry attempts when condensation hits a prompt-too-long error. Each retry truncates more messages from the beginning of the conversation. Range: 0-5, default: 2."
}

View file

@ -592,7 +592,10 @@ export class McpHub {
// Get project-level MCP configuration path
private async getProjectMcpPath(): Promise<string | null> {
const workspacePath = this.providerRef.deref()?.cwd ?? getWorkspacePath()
const provider = this.providerRef.deref()
// For worktree tasks, prefer the current task's workspacePath so each worktree
// can have its own .roo/mcp.json with branch-specific server definitions.
const workspacePath = provider?.getCurrentTask?.()?.workspacePath ?? provider?.cwd ?? getWorkspacePath()
const projectMcpDir = path.join(workspacePath, ".roo")
const projectMcpPath = path.join(projectMcpDir, "mcp.json")

View file

@ -284,6 +284,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
attempt_completion: "complete tasks",
switch_mode: "switch modes",
new_task: "create new task",
spawn_parallel_tasks: "spawn parallel tasks",
codebase_search: "codebase search",
update_todo_list: "update todo list",
run_slash_command: "run slash command",

View file

@ -86,7 +86,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setMode,
alwaysAllowModeSwitch,
customModes,
telemetrySetting,
telemetrySetting: _telemetrySetting,
soundEnabled,
soundVolume,
cloudIsAuthenticated,

View file

@ -472,6 +472,68 @@ export const ContextManagementSettings = ({
/>
</SearchableSetting>
{/* Session Memory */}
<SearchableSetting
settingId="context-session-memory"
section="contextManagement"
label="Session Memory Compaction">
<div className="flex justify-between items-center">
<span className="block font-medium">Session Memory Compaction</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
vscode.postMessage({ type: "openVSCodeSettings", text: "moo-code.sessionMemory" })
}
title="Open in VS Code Settings">
<span className="codicon codicon-settings-gear mr-1" />
Configure
</Button>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
Extracts structured notes from your conversation and uses them when compacting context, avoiding
an extra LLM call. Configure thresholds via{" "}
<button
className="underline cursor-pointer bg-transparent border-0 text-vscode-textLink-foreground p-0"
onClick={() =>
vscode.postMessage({ type: "openVSCodeSettings", text: "moo-code.sessionMemory" })
}>
VS Code Settings
</button>
.
</div>
</SearchableSetting>
{/* Compact Hooks */}
<SearchableSetting settingId="context-compact-hooks" section="contextManagement" label="Compact Hooks">
<div className="flex justify-between items-center">
<span className="block font-medium">Compact Hooks</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
vscode.postMessage({ type: "openVSCodeSettings", text: "moo-code.compactHooks" })
}
title="Open in VS Code Settings">
<span className="codicon codicon-settings-gear mr-1" />
Configure
</Button>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
Run shell commands or HTTP webhooks before and after context condensation. Define hooks in{" "}
<code className="text-xs bg-vscode-textCodeBlock-background px-1 rounded">.roo-hooks.json</code>{" "}
at the workspace root. Configure via{" "}
<button
className="underline cursor-pointer bg-transparent border-0 text-vscode-textLink-foreground p-0"
onClick={() =>
vscode.postMessage({ type: "openVSCodeSettings", text: "moo-code.compactHooks" })
}>
VS Code Settings
</button>
.
</div>
</SearchableSetting>
{/* Auto Condense Context */}
<SearchableSetting
settingId="context-auto-condense"