From e0d00191927de448a027a84f38405223e1ee61bf Mon Sep 17 00:00:00 2001 From: leuel-a Date: Fri, 20 Feb 2026 11:41:07 +0300 Subject: [PATCH 1/4] feat: create new tools list_active_intents and select_active_intent --- packages/types/src/tool.ts | 1 + .../presentAssistantMessage.ts | 17 ++++++- src/core/prompts/tools/native-tools/index.ts | 2 + .../tools/native-tools/list_active_intents.ts | 12 +++++ src/core/tools/ListActiveIntents.ts | 10 ++++ src/hooks/HookEngine.ts | 50 ++++++++++++------- src/hooks/postHooks/intentUpdater.ts | 0 src/hooks/postHooks/lessonRecorder.ts | 0 src/hooks/postHooks/traceWriter.ts | 0 src/hooks/preHooks/authorization.ts | 0 src/hooks/preHooks/intentHandshake.ts | 0 src/hooks/preHooks/scopeGuard.ts | 0 src/shared/tools.ts | 3 +- 13 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 src/core/prompts/tools/native-tools/list_active_intents.ts create mode 100644 src/core/tools/ListActiveIntents.ts delete mode 100644 src/hooks/postHooks/intentUpdater.ts delete mode 100644 src/hooks/postHooks/lessonRecorder.ts delete mode 100644 src/hooks/postHooks/traceWriter.ts delete mode 100644 src/hooks/preHooks/authorization.ts delete mode 100644 src/hooks/preHooks/intentHandshake.ts delete mode 100644 src/hooks/preHooks/scopeGuard.ts diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 3655a5f29e..1c81f4c6f5 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -47,6 +47,7 @@ export const toolNames = [ "generate_image", "custom_tool", "select_active_intent", + "list_active_intents", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 2c75943cd9..dacdd07675 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -17,6 +17,7 @@ import { Task } from "../task/Task" import { listFilesTool } from "../tools/ListFilesTool" import { readFileTool } from "../tools/ReadFileTool" import { readCommandOutputTool } from "../tools/ReadCommandOutputTool" +import { listActiveIntentsTool } from "../tools/ListActiveIntents" import { selectActiveIntentTool } from "../tools/SelectActiveIntent" import { writeToFileTool } from "../tools/WriteToFileTool" import { editTool } from "../tools/EditTool" @@ -336,8 +337,10 @@ export async function presentAssistantMessage(cline: Task) { return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) } return readFileTool.getReadFileToolDescription(block.name, block.params) - case "select_active_intent": + case "list_active_intents": return `[${block.name}]` + case "select_active_intent": + return `[${block.name}] for '${block.params.path}'` case "write_to_file": return `[${block.name} for '${block.params.path}']` case "apply_diff": @@ -679,7 +682,19 @@ export async function presentAssistantMessage(cline: Task) { } switch (block.name) { + case "list_active_intents": + await listActiveIntentsTool.handle(cline, block as ToolUse<"list_active_intents">, { + askApproval, + handleError, + pushToolResult, + }) + break case "select_active_intent": + await selectActiveIntentTool.handle(cline, block as ToolUse<"select_active_intent">, { + askApproval, + handleError, + pushToolResult, + }) break case "write_to_file": await checkpointSaveAndMark(cline) diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index adbb294ecb..81e44645ba 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -21,6 +21,7 @@ import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" import selectActiveIntent from "./select_active_intent" +import listActiveIntents from "./list_active_intents" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" @@ -70,6 +71,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch updateTodoList, writeToFile, selectActiveIntent, + listActiveIntents, ] satisfies OpenAI.Chat.ChatCompletionTool[] } diff --git a/src/core/prompts/tools/native-tools/list_active_intents.ts b/src/core/prompts/tools/native-tools/list_active_intents.ts new file mode 100644 index 0000000000..37e8fd5b4f --- /dev/null +++ b/src/core/prompts/tools/native-tools/list_active_intents.ts @@ -0,0 +1,12 @@ +import type OpenAI from "openai" + +const LIST_ACTIVE_INTENT_DESCRIPTION = `` +// TODO: add params here later for the list active intents + +export default { + type: "function", + function: { + name: "list_active_intents", + description: LIST_ACTIVE_INTENT_DESCRIPTION, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/ListActiveIntents.ts b/src/core/tools/ListActiveIntents.ts new file mode 100644 index 0000000000..83a2a079e0 --- /dev/null +++ b/src/core/tools/ListActiveIntents.ts @@ -0,0 +1,10 @@ +import { Task } from "../task/Task" +import { BaseTool, ToolCallbacks } from "./BaseTool" + +export class ListActiveIntent extends BaseTool<"list_active_intents"> { + readonly name = "list_active_intents" as const + + override execute(_params: any, _task: Task, _callbacks: ToolCallbacks): Promise { + throw new Error("Method not implemented.") + } +} diff --git a/src/hooks/HookEngine.ts b/src/hooks/HookEngine.ts index 3f94f0d407..3da5988ddf 100644 --- a/src/hooks/HookEngine.ts +++ b/src/hooks/HookEngine.ts @@ -1,25 +1,41 @@ -import { Task } from "../core/task/Task" -import { OrchestrationStore } from "../orchestration/OrchestrationStore" - -interface HookEngineOptions { - task: Task -} - export class HookEngine { - private readonly store: OrchestrationStore + private preHooks: PreHook[] = [] + private postHooks: PostHook[] = [] - constructor({ task }: HookEngineOptions) { - this.store = new OrchestrationStore({ workspaceRoot: task.cwd }) + registerPre(hook: PreHook) { + this.preHooks.push(hook) } - /** Called before any tool execution */ - preToolHook() { - this.store.ensureInitialized() + registerPost(hook: PostHook) { + this.postHooks.push(hook) } - /** Called after any tool execution */ - postToolHook() {} + /** + * Wrap a tool execution with pre/post hooks. + * `exec` is your existing tool runner: (toolName, args) => result + */ + async runTool( + call: ToolCall, + exec: (call: ToolCall) => Promise, + ctx: HookContext, + ): Promise { + for (const hook of this.preHooks) { + const decision = await hook(call, ctx) + if (decision.action === "short_circuit") { + // Even short-circuited results go through post hooks (optional, but useful) + for (const post of this.postHooks) { + await post(call, decision.result, ctx) + } + return decision.result + } + } - /** */ - preLLMHook() {} + const result = await exec(call) + + for (const post of this.postHooks) { + await post(call, result, ctx) + } + + return result + } } diff --git a/src/hooks/postHooks/intentUpdater.ts b/src/hooks/postHooks/intentUpdater.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/hooks/postHooks/lessonRecorder.ts b/src/hooks/postHooks/lessonRecorder.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/hooks/postHooks/traceWriter.ts b/src/hooks/postHooks/traceWriter.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/hooks/preHooks/authorization.ts b/src/hooks/preHooks/authorization.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/hooks/preHooks/intentHandshake.ts b/src/hooks/preHooks/intentHandshake.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/hooks/preHooks/scopeGuard.ts b/src/hooks/preHooks/scopeGuard.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/shared/tools.ts b/src/shared/tools.ts index d64aeb8bfe..6b9c676861 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -290,12 +290,13 @@ export const TOOL_DISPLAY_NAMES: Record = { generate_image: "generate images", custom_tool: "use custom tools", select_active_intent: "select the active intent", + list_active_intents: "list active intents", } as const // Define available tool groups. export const TOOL_GROUPS: Record = { read: { - tools: ["read_file", "search_files", "list_files", "codebase_search"], + tools: ["read_file", "search_files", "list_files", "codebase_search", "select_active_intent"], }, edit: { tools: ["apply_diff", "write_to_file", "generate_image"], From 88979330605d6cfb7a09e30390c616ff7f4d06d3 Mon Sep 17 00:00:00 2001 From: leuel-a Date: Fri, 20 Feb 2026 21:24:52 +0300 Subject: [PATCH 2/4] fix: change implementation for the select_active_intent tool --- .../presentAssistantMessage.ts | 17 ++++ src/core/intents/IntentLoader.ts | 85 +++++++++++++++++++ src/core/intents/types.ts | 16 ++++ src/core/prompts/responses.ts | 7 ++ src/core/prompts/sections/index.ts | 2 + src/core/prompts/sections/intent-index.ts | 16 ++++ src/core/prompts/sections/intent-protocol.ts | 14 +++ src/core/prompts/system.ts | 4 + .../native-tools/select_active_intent.ts | 7 +- src/core/task/Task.ts | 18 ++++ src/core/tools/ListActiveIntents.ts | 4 +- src/core/tools/SelectActiveIntent.ts | 74 +++++++++++++++- src/core/webview/ClineProvider.ts | 15 ++++ src/hooks/HookEngine.ts | 42 +-------- 14 files changed, 273 insertions(+), 48 deletions(-) create mode 100644 src/core/intents/IntentLoader.ts create mode 100644 src/core/intents/types.ts create mode 100644 src/core/prompts/sections/intent-index.ts create mode 100644 src/core/prompts/sections/intent-protocol.ts diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index dacdd07675..d14239d97e 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -681,6 +681,23 @@ export async function presentAssistantMessage(cline: Task) { } } + // TODO: requiresIntent check should be based on tool definition, not hardcoded list + const requiresIntent: ToolName[] = [ + "apply_diff", + "write_to_file", + "edit_file", + "apply_patch", + "execute_command", + "search_replace", + "edit", + ] + + if (requiresIntent.includes(block.name as ToolName) && !cline.getHasSelectedIntent()) { + const errorMsg = "You must call select_active_intent before using modification tools." + pushToolResult(formatResponse.toolError(errorMsg)) + break + } + switch (block.name) { case "list_active_intents": await listActiveIntentsTool.handle(cline, block as ToolUse<"list_active_intents">, { diff --git a/src/core/intents/IntentLoader.ts b/src/core/intents/IntentLoader.ts new file mode 100644 index 0000000000..f6d4531c20 --- /dev/null +++ b/src/core/intents/IntentLoader.ts @@ -0,0 +1,85 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { logger } from "../../utils/logging" +import type { Intent, ActiveIntentsFile } from "./types" + +export class IntentLoader { + private intents: Map = new Map() + private cwd: string + private readonly log = logger.child({ component: "IntentLoader" }) + private lastLoadTime = 0 + private readonly CACHE_TTL_MS = 5000 // 5 seconds + + constructor(cwd: string) { + this.cwd = cwd + } + + async ensureLoaded(force = false): Promise { + const now = Date.now() + if (!force && this.intents.size > 0 && now - this.lastLoadTime < this.CACHE_TTL_MS) { + // Cache is still valid + return + } + + await this.loadIntents() + this.lastLoadTime = now + } + + private async loadIntents(): Promise { + const intentsPath = path.join(this.cwd, ".orchestration", "active_intents.json") + + try { + const content = await fs.readFile(intentsPath, "utf-8") + const parsed = this.parseJsonSafely(content, intentsPath) + + if (!parsed?.active_intents || !Array.isArray(parsed.active_intents)) { + this.log.warn(`No active_intents found in ${intentsPath}`) + this.intents.clear() + return + } + + this.intents.clear() + + for (const intent of parsed.active_intents) { + if (intent?.id && typeof intent.id === "string") { + this.intents.set(intent.id, intent) + } + } + + this.log.info(`Loaded ${this.intents.size} intents from ${intentsPath}`) + } catch (error: any) { + if (error?.code !== "ENOENT") { + this.log.error(`Failed to load intents from ${intentsPath}`, error) + } + this.intents.clear() + } + } + + getIntent(id: string): Intent | undefined { + return this.intents.get(id) + } + + getAllIntents(): Intent[] { + return Array.from(this.intents.values()) + } + + hasIntent(id: string): boolean { + return this.intents.has(id) + } + + private parseJsonSafely(content: string, filePath: string): ActiveIntentsFile { + try { + const cleaned = this.stripBom(content) + const parsed = JSON.parse(cleaned) + return (parsed ?? { active_intents: [] }) as ActiveIntentsFile + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + this.log.error(`Failed to parse JSON from ${filePath}: ${msg}`) + return { active_intents: [] } + } + } + + private stripBom(s: string): string { + return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s + } +} diff --git a/src/core/intents/types.ts b/src/core/intents/types.ts new file mode 100644 index 0000000000..91759167a3 --- /dev/null +++ b/src/core/intents/types.ts @@ -0,0 +1,16 @@ +enum INTENT_STATUS { + IN_PROGRESS, +} + +export interface ActiveIntentsFile { + active_intents: Intent[] +} + +export interface Intent { + id: string + name: string + status: INTENT_STATUS + owned_scopes: string[] + constraints: string[] + acceptance_criteria: string[] +} diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 60b5b4123a..4cfa9acfbf 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -199,6 +199,13 @@ Otherwise, if you have not completed the task and do not need additional informa const prettyPatchLines = lines.slice(4) return prettyPatchLines.join("\n") }, + + invalidIntentId: (intentId: string) => + JSON.stringify({ + status: "error", + type: "invalid_intent", + intent_id: intentId, + }), } // to avoid circular dependency diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index 318cd47bc9..7d9679ffc1 100644 --- a/src/core/prompts/sections/index.ts +++ b/src/core/prompts/sections/index.ts @@ -8,3 +8,5 @@ export { getCapabilitiesSection } from "./capabilities" export { getModesSection } from "./modes" export { markdownFormattingSection } from "./markdown-formatting" export { getSkillsSection } from "./skills" +export { intentIndexSection } from "./intent-index" +export { intentProtocolSection } from "./intent-protocol" diff --git a/src/core/prompts/sections/intent-index.ts b/src/core/prompts/sections/intent-index.ts new file mode 100644 index 0000000000..796f76d8ab --- /dev/null +++ b/src/core/prompts/sections/intent-index.ts @@ -0,0 +1,16 @@ +import { Intent } from "../../intents/types" + +export function intentIndexSection(intents: Intent[]) { + const rows = (intents ?? []).slice(0, 30).map((i) => { + const scope = (i.owned_scopes ?? []).slice(0, 4).join(", ") + return `- ${i.id}: ${i.name} [${i.status}] scope: ${scope}${(i.owned_scopes?.length ?? 0) > 4 ? ", ..." : ""}` + }) + + const extra = + (intents?.length ?? 0) > 30 ? `\n(Showing 30 of ${intents.length}. Use list_active_intents for full list.)` : "" + + return ` +[ACTIVE INTENT INDEX] +${rows.length ? rows.join("\n") : "- (none found)"}${extra} +`.trim() +} diff --git a/src/core/prompts/sections/intent-protocol.ts b/src/core/prompts/sections/intent-protocol.ts new file mode 100644 index 0000000000..ba18a9b767 --- /dev/null +++ b/src/core/prompts/sections/intent-protocol.ts @@ -0,0 +1,14 @@ +export function intentProtocolSection(): string { + return ` +=== + +INTENT-DRIVEN PROTOCOL + +1. You MUST call the tool "select_active_intent" before making any code edits or running destructive commands. +2. You may call read-only tools before selecting an intent, but you must select an intent before: + - apply_diff / write_to_file / edit_file / apply_patch + - execute_command that changes the repo (git commit, installs, deletions, etc.) +3. If you are unsure which intent applies, request the list of intents from the user or consult the intent index below. +4. After selecting an intent, you must keep all actions within owned_scope and obey constraints. +`.trim() +} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 0d6071644a..0e76b0c791 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -23,6 +23,8 @@ import { addCustomInstructions, markdownFormattingSection, getSkillsSection, + intentProtocolSection, + intentIndexSection, } from "./sections" // Helper function to get prompt component, filtering out empty objects @@ -92,6 +94,8 @@ ${getSharedToolUseSection()}${toolsCatalog} ${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined)} +${intentProtocolSection()} + ${modesSection} ${skillsSection ? `\n${skillsSection}` : ""} ${getRulesSection(cwd, settings)} diff --git a/src/core/prompts/tools/native-tools/select_active_intent.ts b/src/core/prompts/tools/native-tools/select_active_intent.ts index 7cd6de0b9c..506a36be36 100644 --- a/src/core/prompts/tools/native-tools/select_active_intent.ts +++ b/src/core/prompts/tools/native-tools/select_active_intent.ts @@ -1,7 +1,10 @@ import type OpenAI from "openai" -const SELECT_ACTIVE_INTENT_DESCRIPTION = ` ` -const INTENT_ID_DESCRIPTION = `` +const SELECT_ACTIVE_INTENT_DESCRIPTION = ` +Select and activate an intent from active_intents.json.\n +This must be called before making any changes to understand what changes are intended.\n +` +const INTENT_ID_DESCRIPTION = `The intent_id for the currently selected intent by the LLM and will be activated by LLM` export default { type: "function", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3feb695e10..eb24ec3196 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -350,6 +350,10 @@ export class Task extends EventEmitter implements TaskLike { userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false + // Intent + private selectedIntentId?: string + private hasSelectedIntent = false + /** * Flag indicating whether the assistant message for the current streaming session * has been saved to API conversation history. @@ -1263,6 +1267,19 @@ export class Task extends EventEmitter implements TaskLike { return undefined } + public setSelectedIntent(intentId: string): void { + this.selectedIntentId = intentId + this.hasSelectedIntent = true + } + + public getHasSelectedIntent(): boolean { + return this.hasSelectedIntent + } + + public getSelectedIntentId(): string | undefined { + return this.selectedIntentId + } + // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). @@ -4008,6 +4025,7 @@ export class Task extends EventEmitter implements TaskLike { Task.lastGlobalApiRequestTime = performance.now() const systemPrompt = await this.getSystemPrompt() + const { contextTokens } = this.getTokenUsage() if (contextTokens) { diff --git a/src/core/tools/ListActiveIntents.ts b/src/core/tools/ListActiveIntents.ts index 83a2a079e0..f1ce9aa2fb 100644 --- a/src/core/tools/ListActiveIntents.ts +++ b/src/core/tools/ListActiveIntents.ts @@ -1,10 +1,12 @@ import { Task } from "../task/Task" import { BaseTool, ToolCallbacks } from "./BaseTool" -export class ListActiveIntent extends BaseTool<"list_active_intents"> { +export class ListActiveIntents extends BaseTool<"list_active_intents"> { readonly name = "list_active_intents" as const override execute(_params: any, _task: Task, _callbacks: ToolCallbacks): Promise { throw new Error("Method not implemented.") } } + +export const listActiveIntentsTool = new ListActiveIntents() diff --git a/src/core/tools/SelectActiveIntent.ts b/src/core/tools/SelectActiveIntent.ts index 8477d548b3..35421c953b 100644 --- a/src/core/tools/SelectActiveIntent.ts +++ b/src/core/tools/SelectActiveIntent.ts @@ -1,16 +1,82 @@ import { Task } from "../task/Task" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { Intent } from "../intents/types" +import { formatResponse } from "../prompts/responses" interface SelectActiveIntentParams { intent_id: string } -export class SelectActiveIntent extends BaseTool<"select_active_intent"> { +export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { readonly name = "select_active_intent" as const - override execute(_params: SelectActiveIntentParams, _task: Task, _callbacks: ToolCallbacks): Promise { - throw new Error("Method not implemented.") + async execute(params: SelectActiveIntentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { intent_id } = params + const { handleError, pushToolResult } = callbacks + + try { + if (!intent_id) { + task.consecutiveMistakeCount++ + task.recordToolError("select_active_intent") + task.didToolFailInCurrentTurn = true + pushToolResult(await task.sayAndCreateMissingParamError("select_active_intent", "intent_id")) + return + } + + const provider = task.providerRef.deref() + if (!provider) { + return + } + + const intentLoader = provider.getIntentLoader() + await intentLoader.ensureLoaded() + + const intent = intentLoader.getIntent(intent_id) + if (!intent) { + task.setSelectedIntent(intent_id) + pushToolResult(formatResponse.invalidIntentId(intent_id)) + return + } + + pushToolResult(this.formatIntentContextXml(intent)) + } catch (error) { + handleError("selecting intents", error) + } + } + /** + * Formats the selected intent as an XML block for prompt injection. + * Keep this deterministic and safe (escape XML). + */ + private formatIntentContextXml(intent: Intent): string { + const escapeXml = (text: string): string => + String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + + const ownedScopes = intent.owned_scopes ?? [] + + const renderList = (containerTag: string, itemTag: string, items: string[]): string => { + const inner = (items ?? []).map((x) => ` <${itemTag}>${escapeXml(x)}`).join("\n") + + return items && items.length + ? `<${containerTag}>\n${inner}\n ` + : `<${containerTag}>` + } + + return [ + ``, + ` ${escapeXml(intent.id)}`, + ` ${escapeXml(intent.name)}`, + ` ${escapeXml(intent.status as unknown as string)}`, + ` ${renderList("owned_scope", "path", ownedScopes)}`, + ` ${renderList("constraints", "constraint", intent.constraints ?? [])}`, + ` ${renderList("acceptance_criteria", "criteria", intent.acceptance_criteria ?? [])}`, + ``, + ].join("\n") } } -export const selectActiveIntentTool = new SelectActiveIntent() +export const selectActiveIntentTool = new SelectActiveIntentTool() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bb9199a65c..7aec2a0745 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -103,6 +103,7 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" +import { IntentLoader } from "../intents/IntentLoader" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -170,6 +171,11 @@ export class ClineProvider public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager + /** + * Intent Loader + */ + private intentLoader?: IntentLoader + constructor( readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel, @@ -420,6 +426,14 @@ export class ClineProvider } } + public getIntentLoader(): IntentLoader { + if (!this.intentLoader) { + const cwd = this.currentWorkspacePath || process.cwd() + this.intentLoader = new IntentLoader(cwd) + } + return this.intentLoader + } + // Adds a new Task instance to clineStack, marking the start of a new task. // The instance is pushed to the top of the stack (LIFO order). // When the task is completed, the top instance is removed, reactivating the @@ -659,6 +673,7 @@ export class ClineProvider } } + this.intentLoader = undefined this._workspaceTracker?.dispose() this._workspaceTracker = undefined await this.mcpHub?.unregisterClient() diff --git a/src/hooks/HookEngine.ts b/src/hooks/HookEngine.ts index 3da5988ddf..3aaf32cc19 100644 --- a/src/hooks/HookEngine.ts +++ b/src/hooks/HookEngine.ts @@ -1,41 +1 @@ -export class HookEngine { - private preHooks: PreHook[] = [] - private postHooks: PostHook[] = [] - - registerPre(hook: PreHook) { - this.preHooks.push(hook) - } - - registerPost(hook: PostHook) { - this.postHooks.push(hook) - } - - /** - * Wrap a tool execution with pre/post hooks. - * `exec` is your existing tool runner: (toolName, args) => result - */ - async runTool( - call: ToolCall, - exec: (call: ToolCall) => Promise, - ctx: HookContext, - ): Promise { - for (const hook of this.preHooks) { - const decision = await hook(call, ctx) - if (decision.action === "short_circuit") { - // Even short-circuited results go through post hooks (optional, but useful) - for (const post of this.postHooks) { - await post(call, decision.result, ctx) - } - return decision.result - } - } - - const result = await exec(call) - - for (const post of this.postHooks) { - await post(call, result, ctx) - } - - return result - } -} +export class HookEngine {} From 20660835028ba4eda22a7076951468ca4c0ae47f Mon Sep 17 00:00:00 2001 From: leuel-a Date: Sat, 21 Feb 2026 12:54:40 +0300 Subject: [PATCH 3/4] update implementation for pre-hooks to use interceptor pattern and add scope enforcement --- .../presentAssistantMessage.ts | 27 +++++---- .../middlewares/IntentValidationMiddleware.ts | 23 ++++++++ src/core/middlewares/MiddlewareChain.ts | 25 +++++++++ .../middlewares/ScopeEnforcementMiddleware.ts | 56 +++++++++++++++++++ src/core/middlewares/ToolMiddleware.ts | 13 +++++ src/core/prompts/sections/intent-protocol.ts | 15 +++-- .../tools/native-tools/list_active_intents.ts | 27 ++++++++- .../native-tools/select_active_intent.ts | 24 ++++++-- src/core/task/Task.ts | 30 ++++++++++ src/core/tools/ListActiveIntents.ts | 38 ++++++++++++- src/core/tools/SelectActiveIntent.ts | 4 ++ src/shared/tools.ts | 13 ++++- 12 files changed, 264 insertions(+), 31 deletions(-) create mode 100644 src/core/middlewares/IntentValidationMiddleware.ts create mode 100644 src/core/middlewares/MiddlewareChain.ts create mode 100644 src/core/middlewares/ScopeEnforcementMiddleware.ts create mode 100644 src/core/middlewares/ToolMiddleware.ts diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index d14239d97e..ab2b404536 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -42,13 +42,16 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +import { MiddlewareChain } from "../middlewares/MiddlewareChain" +import { IntentValidationMiddleware } from "../middlewares/IntentValidationMiddleware" +import { ScopeEnforcementMiddleware } from "../middlewares/ScopeEnforcementMiddleware" /** * Processes and presents assistant message content to the user interface. * * This function is the core message handling system that: * - Sequentially processes content blocks from the assistant's response. - * - Displays text content to the user. + * - Displays text content to the user * - Executes tool use requests with appropriate user approval. * - Manages the flow of conversation by determining when to proceed to the next content block. * - Coordinates file system checkpointing for modified files. @@ -681,20 +684,16 @@ export async function presentAssistantMessage(cline: Task) { } } - // TODO: requiresIntent check should be based on tool definition, not hardcoded list - const requiresIntent: ToolName[] = [ - "apply_diff", - "write_to_file", - "edit_file", - "apply_patch", - "execute_command", - "search_replace", - "edit", - ] + const middlewareChain = new MiddlewareChain() + middlewareChain.add(new IntentValidationMiddleware()) + middlewareChain.add(new ScopeEnforcementMiddleware()) - if (requiresIntent.includes(block.name as ToolName) && !cline.getHasSelectedIntent()) { - const errorMsg = "You must call select_active_intent before using modification tools." - pushToolResult(formatResponse.toolError(errorMsg)) + const middlewareResult = await middlewareChain.executeBefore(block.params, cline, block.name) + if (!middlewareResult.allow) { + pushToolResult(middlewareResult.error || "Middleware validation failed") + cline.consecutiveMistakeCount++ + cline.didToolFailInCurrentTurn = true + cline.recordToolError(block.name as ToolName, middlewareResult.error || "Middleware validation failed") break } diff --git a/src/core/middlewares/IntentValidationMiddleware.ts b/src/core/middlewares/IntentValidationMiddleware.ts new file mode 100644 index 0000000000..0404f17426 --- /dev/null +++ b/src/core/middlewares/IntentValidationMiddleware.ts @@ -0,0 +1,23 @@ +import { ToolName } from "@roo-code/types" +import { Task } from "../task/Task" +import { ToolMiddleware, MiddlewareResult } from "./ToolMiddleware" + +export class IntentValidationMiddleware implements ToolMiddleware { + name = "intentValidation" + + async beforeExecute(_params: any, task: Task, toolName: ToolName): Promise { + if (toolName === "select_active_intent" || toolName === "list_active_intents") { + return { allow: true } + } + + const selectedIntentId = task.getSelectedIntentId() + if (!selectedIntentId) { + return { + allow: false, + error: "No active intent selected. Use select_active_intent first.", + } + } + + return { allow: true } + } +} diff --git a/src/core/middlewares/MiddlewareChain.ts b/src/core/middlewares/MiddlewareChain.ts new file mode 100644 index 0000000000..c3c85a88c3 --- /dev/null +++ b/src/core/middlewares/MiddlewareChain.ts @@ -0,0 +1,25 @@ +import { ToolName } from "@roo-code/types" +import { Task } from "../task/Task" +import { ToolMiddleware, MiddlewareResult } from "./ToolMiddleware" + +export class MiddlewareChain { + private middlewares: ToolMiddleware[] = [] + + add(middleware: ToolMiddleware): void { + this.middlewares.push(middleware) + } + + async executeBefore(params: any, task: Task, toolName: ToolName): Promise { + for (const middleware of this.middlewares) { + const result = await middleware.beforeExecute?.(params, task, toolName) + if (result?.allow === false) { + return result + } + + if (result?.modifiedParams) { + params = result.modifiedParams + } + } + return { allow: true } + } +} diff --git a/src/core/middlewares/ScopeEnforcementMiddleware.ts b/src/core/middlewares/ScopeEnforcementMiddleware.ts new file mode 100644 index 0000000000..14419952e0 --- /dev/null +++ b/src/core/middlewares/ScopeEnforcementMiddleware.ts @@ -0,0 +1,56 @@ +import { ToolName } from "@roo-code/types" +import { Task } from "../task/Task" +import { ToolMiddleware, MiddlewareResult } from "./ToolMiddleware" + +export class ScopeEnforcementMiddleware implements ToolMiddleware { + name = "scopeEnforcement" + + async beforeExecute(params: any, task: Task, toolName: ToolName): Promise { + const destructiveTools: Array = ["write_to_file", "edit", "apply_diff", "execute_command"] + if (!destructiveTools.includes(toolName)) { + return { allow: true } + } + + const selectedIntentId = task.getSelectedIntentId() + if (!selectedIntentId) { + return { allow: false, error: "No active intent selected" } + } + + const provider = task.providerRef.deref() + if (!provider) { + return { allow: false, error: "Provider unavailable" } + } + + try { + const intentLoader = provider.getIntentLoader() + await intentLoader.ensureLoaded() + const intent = intentLoader.getIntent(selectedIntentId) + + if (!intent) { + return { allow: false, error: `Intent '${selectedIntentId}' not found` } + } + + if (toolName === "write_to_file" && params.path) { + if (intent.owned_scopes?.length) { + const isAuthorized = intent.owned_scopes.some( + (scope) => params.path.startsWith(scope) || params.path === scope, + ) + + if (!isAuthorized) { + return { + allow: false, + error: `Scope Violation: ${selectedIntentId} not authorized to edit ${params.path}`, + } + } + } + } + + return { allow: true } + } catch (error) { + return { + allow: false, + error: `Error validating scope: ${error instanceof Error ? error.message : String(error)}`, + } + } + } +} diff --git a/src/core/middlewares/ToolMiddleware.ts b/src/core/middlewares/ToolMiddleware.ts new file mode 100644 index 0000000000..02063e1626 --- /dev/null +++ b/src/core/middlewares/ToolMiddleware.ts @@ -0,0 +1,13 @@ +import { Task } from "../task/Task" + +export interface MiddlewareResult { + allow: boolean + error?: string + modifiedParams?: any +} + +export interface ToolMiddleware { + name: string + beforeExecute?(params: any, task: Task, toolName: string): Promise + afterExecute?(result: any, task: Task, toolName: string): Promise +} diff --git a/src/core/prompts/sections/intent-protocol.ts b/src/core/prompts/sections/intent-protocol.ts index ba18a9b767..360e1b5dfe 100644 --- a/src/core/prompts/sections/intent-protocol.ts +++ b/src/core/prompts/sections/intent-protocol.ts @@ -2,13 +2,18 @@ export function intentProtocolSection(): string { return ` === -INTENT-DRIVEN PROTOCOL +INTENT-DRIVEN PROTOCOL + +You are an Intent-Driven Architect. You MUST follow this two-step process before making any changes: -1. You MUST call the tool "select_active_intent" before making any code edits or running destructive commands. -2. You may call read-only tools before selecting an intent, but you must select an intent before: +You CANNOT write code or modify files until you have successfully selected an intent. The intent context will provide you with the scope, constraints, and acceptance criteria for your work.1. You MUST call the tool "select_active_intent" before making any code edits or running destructive commands. + +1. First, call list_active_intents to discover all available intents +2. Then, call select_active_intent(intent_id) with the appropriate intent ID +3. You may call read-only tools before selecting an intent, but you must select an intent before: - apply_diff / write_to_file / edit_file / apply_patch - execute_command that changes the repo (git commit, installs, deletions, etc.) -3. If you are unsure which intent applies, request the list of intents from the user or consult the intent index below. -4. After selecting an intent, you must keep all actions within owned_scope and obey constraints. +4. If you are unsure which intent applies, request the list of intents from the user or consult the intent index below. +5. After selecting an intent, you must keep all actions within owned_scope and obey constraints. `.trim() } diff --git a/src/core/prompts/tools/native-tools/list_active_intents.ts b/src/core/prompts/tools/native-tools/list_active_intents.ts index 37e8fd5b4f..bfbc35bd7b 100644 --- a/src/core/prompts/tools/native-tools/list_active_intents.ts +++ b/src/core/prompts/tools/native-tools/list_active_intents.ts @@ -1,12 +1,33 @@ import type OpenAI from "openai" -const LIST_ACTIVE_INTENT_DESCRIPTION = `` -// TODO: add params here later for the list active intents +const LIST_ACTIVE_INTENTS_DESCRIPTION = `List all currently active intents available for selection. Use this tool when you need to choose which intent to work on before making any code changes. + +This tool reads the workspace intent registry (typically .orchestration/active_intents.json) and returns a compact list of intents with: +- id +- name +- status +- owned_scope (high-level scope paths) +- constraints (short summary) +- acceptance_criteria (short summary) + +When to use: +- At the start of a task, before calling select_active_intent +- When you are unsure which intent ID applies +- When select_active_intent fails due to an invalid ID + +Example: List all available active intents +{}` export default { type: "function", function: { name: "list_active_intents", - description: LIST_ACTIVE_INTENT_DESCRIPTION, + description: LIST_ACTIVE_INTENTS_DESCRIPTION, + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + required: [], + }, }, } satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/select_active_intent.ts b/src/core/prompts/tools/native-tools/select_active_intent.ts index 506a36be36..62c79e105c 100644 --- a/src/core/prompts/tools/native-tools/select_active_intent.ts +++ b/src/core/prompts/tools/native-tools/select_active_intent.ts @@ -1,10 +1,20 @@ import type OpenAI from "openai" -const SELECT_ACTIVE_INTENT_DESCRIPTION = ` -Select and activate an intent from active_intents.json.\n -This must be called before making any changes to understand what changes are intended.\n -` -const INTENT_ID_DESCRIPTION = `The intent_id for the currently selected intent by the LLM and will be activated by LLM` +const SELECT_ACTIVE_INTENT_DESCRIPTION = `Select and activate a specific intent by ID. This MUST be called before making any code changes. + +After selecting an intent, all subsequent work should stay within the intent's owned_scope and obey its constraints. The tool returns an block containing the intent details for prompt injection. + +When to use: +- Immediately after list_active_intents, once you choose the correct ID +- At the start of a new task or when switching to a different intent + +Parameters: +- intent_id: (required) The ID of the intent to activate (e.g., "INT-001") + +Example: Activate intent INT-001 +{ "intent_id": "INT-001" }` + +const INTENT_ID_DESCRIPTION = `Intent ID to activate (e.g., "INT-001"). Choose from list_active_intents results.` export default { type: "function", @@ -15,11 +25,13 @@ export default { parameters: { type: "object", properties: { - path: { + intent_id: { type: "string", description: INTENT_ID_DESCRIPTION, }, }, + required: ["intent_id"], + additionalProperties: false, }, }, } satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index eb24ec3196..61c6760977 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -863,6 +863,31 @@ export class Task extends EventEmitter implements TaskLike { return [instance, promise] } + private async validateIntentGate(): Promise { + const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] + const lastMessageContent = lastMessage?.content + + if (Array.isArray(lastMessageContent)) { + const hasIntentSection = lastMessageContent.some((block) => { + return ( + block.type === "tool_use" && + (block.name === "select_active_intent" || block.name === "list_active_intents") + ) + }) + + if (!hasIntentSection && !this.selectedIntentId) { + await this.say( + "error", + "You must cite a valid Intent ID. Use select_active_intent or list_active_intents first.", + ) + return false + } + return true + } + + return false + } + // API Messages private async getSavedApiConversationHistory(): Promise { @@ -2793,6 +2818,11 @@ export class Task extends EventEmitter implements TaskLike { const streamModelInfo = this.cachedStreamingModel.info const cachedModelId = this.cachedStreamingModel.id + const hasValidIntent = await this.validateIntentGate() + if (!hasValidIntent) { + return true + } + // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). diff --git a/src/core/tools/ListActiveIntents.ts b/src/core/tools/ListActiveIntents.ts index f1ce9aa2fb..93939ef07f 100644 --- a/src/core/tools/ListActiveIntents.ts +++ b/src/core/tools/ListActiveIntents.ts @@ -1,12 +1,46 @@ import { Task } from "../task/Task" +import type { Intent } from "../intents/types" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { ToolUse } from "../../shared/tools" export class ListActiveIntents extends BaseTool<"list_active_intents"> { readonly name = "list_active_intents" as const - override execute(_params: any, _task: Task, _callbacks: ToolCallbacks): Promise { - throw new Error("Method not implemented.") + async execute(_params: any, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult, handleError } = callbacks + + try { + const provider = task.providerRef.deref() + if (!provider) { + // TODO: Figure out what to do when provider becomes undefined + return + } + + const intentLoader = provider.getIntentLoader() + await intentLoader.ensureLoaded() + + const intents = intentLoader.getAllIntents() + if (intents.length === 0) { + pushToolResult("No active intents found in .orchestration/active_intents.json") + return + } + + pushToolResult(`Available intents:\n\n${this.formatIntentList(intents)}`) + } catch (error) { + handleError("list active intents", error) + } } + + private formatIntentList(intents: Array) { + return intents + .map( + (intent) => + `- ${intent.id}: ${intent.name} (${intent.status})\n Scope: ${intent.owned_scopes?.join(", ") || "None"}\n Constraints: ${intent.constraints?.join(", ") || "None"}`, + ) + .join("\n\n") + } + + override async handlePartial(_task: Task, _block: ToolUse<"list_active_intents">): Promise {} } export const listActiveIntentsTool = new ListActiveIntents() diff --git a/src/core/tools/SelectActiveIntent.ts b/src/core/tools/SelectActiveIntent.ts index 35421c953b..deb5f570b2 100644 --- a/src/core/tools/SelectActiveIntent.ts +++ b/src/core/tools/SelectActiveIntent.ts @@ -2,6 +2,7 @@ import { Task } from "../task/Task" import { BaseTool, ToolCallbacks } from "./BaseTool" import { Intent } from "../intents/types" import { formatResponse } from "../prompts/responses" +import { ToolUse } from "../../shared/tools" interface SelectActiveIntentParams { intent_id: string @@ -25,6 +26,7 @@ export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { const provider = task.providerRef.deref() if (!provider) { + // TODO: Figure out what to do when the provider becomes undefined return } @@ -77,6 +79,8 @@ export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { ``, ].join("\n") } + + override async handlePartial(_task: Task, _block: ToolUse<"select_active_intent">): Promise {} } export const selectActiveIntentTool = new SelectActiveIntentTool() diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 6b9c676861..2b1b9e2e3e 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -115,6 +115,8 @@ export type NativeToolArgs = { update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } + select_active_intent: { intent_id: string } + list_active_intents: {} // Add more tools as they are migrated to native protocol } @@ -296,7 +298,14 @@ export const TOOL_DISPLAY_NAMES: Record = { // Define available tool groups. export const TOOL_GROUPS: Record = { read: { - tools: ["read_file", "search_files", "list_files", "codebase_search", "select_active_intent"], + tools: [ + "read_file", + "search_files", + "list_files", + "codebase_search", + "select_active_intent", + "list_active_intents", + ], }, edit: { tools: ["apply_diff", "write_to_file", "generate_image"], @@ -323,6 +332,8 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "update_todo_list", "run_slash_command", "skill", + "select_active_intent", + "list_active_intents", ] as const /** From 9adabbd9cea3dd14b6d3306f5b1a70562368384e Mon Sep 17 00:00:00 2001 From: leuel-a Date: Sat, 21 Feb 2026 12:55:41 +0300 Subject: [PATCH 4/4] allow empty params for list_active_intents params --- src/shared/tools.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 2b1b9e2e3e..c285f71c20 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -116,6 +116,7 @@ export type NativeToolArgs = { use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } write_to_file: { path: string; content: string } select_active_intent: { intent_id: string } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type list_active_intents: {} // Add more tools as they are migrated to native protocol }