mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-25 05:53:57 +00:00
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:
parent
dae4954696
commit
be83bb6698
43 changed files with 4917 additions and 218 deletions
59
.roo-hooks.json.example
Normal file
59
.roo-hooks.json.example
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const toolNames = [
|
|||
"attempt_completion",
|
||||
"switch_mode",
|
||||
"new_task",
|
||||
"spawn_parallel_tasks",
|
||||
"codebase_search",
|
||||
"update_todo_list",
|
||||
"run_slash_command",
|
||||
|
|
|
|||
|
|
@ -461,6 +461,7 @@ export interface WebviewMessage {
|
|||
| "ttsEnabled"
|
||||
| "ttsSpeed"
|
||||
| "openKeyboardShortcuts"
|
||||
| "openVSCodeSettings"
|
||||
| "openMcpSettings"
|
||||
| "openProjectMcpSettings"
|
||||
| "restartMcpServer"
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
609
src/core/condense/__tests__/microCompact.spec.ts
Normal file
609
src/core/condense/__tests__/microCompact.spec.ts
Normal 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]")
|
||||
})
|
||||
})
|
||||
})
|
||||
248
src/core/condense/__tests__/promptTooLongRetry.spec.ts
Normal file
248
src/core/condense/__tests__/promptTooLongRetry.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
408
src/core/condense/__tests__/sessionMemoryCompact.spec.ts
Normal file
408
src/core/condense/__tests__/sessionMemoryCompact.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
287
src/core/condense/microCompact.ts
Normal file
287
src/core/condense/microCompact.ts
Normal 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
|
||||
}
|
||||
606
src/core/condense/sessionMemory.ts
Normal file
606
src/core/condense/sessionMemory.ts
Normal 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
|
||||
}
|
||||
426
src/core/condense/sessionMemoryCompact.ts
Normal file
426
src/core/condense/sessionMemoryCompact.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
176
src/core/hooks/__tests__/hooks.spec.ts
Normal file
176
src/core/hooks/__tests__/hooks.spec.ts
Normal 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
185
src/core/hooks/config.ts
Normal 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
291
src/core/hooks/executor.ts
Normal 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
183
src/core/hooks/index.ts
Normal 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
166
src/core/hooks/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
52
src/core/prompts/tools/native-tools/spawn_parallel_tasks.ts
Normal file
52
src/core/prompts/tools/native-tools/spawn_parallel_tasks.ts
Normal 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
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
121
src/core/tools/SpawnParallelTasksTool.ts
Normal file
121
src/core/tools/SpawnParallelTasksTool.ts
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setMode,
|
||||
alwaysAllowModeSwitch,
|
||||
customModes,
|
||||
telemetrySetting,
|
||||
telemetrySetting: _telemetrySetting,
|
||||
soundEnabled,
|
||||
soundVolume,
|
||||
cloudIsAuthenticated,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue