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 {}