fix: change implementation for the select_active_intent tool

This commit is contained in:
leuel-a 2026-02-20 21:24:52 +03:00
parent e0d0019192
commit 8897933060
14 changed files with 273 additions and 48 deletions

View file

@ -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">, {

View file

@ -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<string, Intent> = 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<void> {
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<void> {
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
}
}

16
src/core/intents/types.ts Normal file
View file

@ -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[]
}

View file

@ -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

View file

@ -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"

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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)}

View file

@ -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",

View file

@ -350,6 +350,10 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
Task.lastGlobalApiRequestTime = performance.now()
const systemPrompt = await this.getSystemPrompt()
const { contextTokens } = this.getTokenUsage()
if (contextTokens) {

View file

@ -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<void> {
throw new Error("Method not implemented.")
}
}
export const listActiveIntentsTool = new ListActiveIntents()

View file

@ -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<void> {
throw new Error("Method not implemented.")
async execute(params: SelectActiveIntentParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
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 <intent_context> 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
const ownedScopes = intent.owned_scopes ?? []
const renderList = (containerTag: string, itemTag: string, items: string[]): string => {
const inner = (items ?? []).map((x) => ` <${itemTag}>${escapeXml(x)}</${itemTag}>`).join("\n")
return items && items.length
? `<${containerTag}>\n${inner}\n </${containerTag}>`
: `<${containerTag}></${containerTag}>`
}
return [
`<intent_context>`,
` <id>${escapeXml(intent.id)}</id>`,
` <name>${escapeXml(intent.name)}</name>`,
` <status>${escapeXml(intent.status as unknown as string)}</status>`,
` ${renderList("owned_scope", "path", ownedScopes)}`,
` ${renderList("constraints", "constraint", intent.constraints ?? [])}`,
` ${renderList("acceptance_criteria", "criteria", intent.acceptance_criteria ?? [])}`,
`</intent_context>`,
].join("\n")
}
}
export const selectActiveIntentTool = new SelectActiveIntent()
export const selectActiveIntentTool = new SelectActiveIntentTool()

View file

@ -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()

View file

@ -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<ToolResult>,
ctx: HookContext,
): Promise<ToolResult> {
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 {}