diff --git a/.rgignore b/.rgignore new file mode 100644 index 00000000000..b7d564b8f64 --- /dev/null +++ b/.rgignore @@ -0,0 +1,9 @@ +.vscode/ +.roo/ + +releases/ + +.git/ +.github/ + +__tests__/ diff --git a/ARCHITECTURE_NOTES.md b/ARCHITECTURE_NOTES.md new file mode 100644 index 00000000000..54fb8718bb7 --- /dev/null +++ b/ARCHITECTURE_NOTES.md @@ -0,0 +1,261 @@ +# ARCHITECTURE_NOTES + +## 1. High-level overview — How the VS Code extension works + +- Activation + - VS Code activates the extension via `package.json` activation events. + - Extension creates singletons: `ClineProvider`, API adapters, managers (ProviderSettingsManager, CustomModesManager), and registers webview providers / commands. +- UI + - Sidebar/tab webview(s) host the chat UI. + - Webview <-> extension comms use postMessage handlers implemented in `webviewMessageHandler`. + - Settings views must bind inputs to a local `cachedState` (see AGENTS.md) and only persist on explicit Save. +- Task model + - `Task` is the runtime unit for an agent conversation/workflow: orchestrates prompt building, streaming, tool execution, retries, checkpoints, and persistence. + - A `ClineProvider` manages tasks and exposes methods for creating/finding the visible task. +- API & streaming + - Provider-specific API adapters (Anthropic/OpenAI-like) implement `createMessage` returning a stream. + - Task creates an `AbortController` per call, streams the response, parses events (text chunks, tool calls, usage), and renders partial assistant messages to the webview. +- Persistence & state + - `contextProxy` is used for global state persistence (settings, stored messages, conversation history). + - Checkpoints are created by the task on user sends / important state transitions. + +--- + +## 2. Core components and responsibilities + +- ClineProvider + + - Lifetime manager for tasks and UI provider for the webview. + - Exposes contextProxy, settings managers, and task creation. + +- Task + + - submitUserMessage(), handleWebviewAskResponse(), ask(), recursivelyMakeClineRequests(), abortTask(), checkpointSave(), saveClineMessages(). + - Maintains: clineMessages, assistantMessageContent, userMessageContent, currentRequest controller, abort flags, usage counters, autoApproval timers. + +- API Adapter + + - Abstracted provider interface to make streaming requests and parse provider-specific events into a normalized internal event stream. + +- MessageQueueService + + - Serializes transport of messages to UI / persistence to avoid races. + +- Managers + + - ProviderSettingsManager, CustomModesManager — config and mode lifecycle. + +- WebviewMessageHandler + + - Normalizes incoming UI messages and routes to provider or Task methods (e.g., askResponse → Task.submitUserMessage). + +- Tool Executors + - Execute tool calls (file read/write, shell, formatters) as requested by the model; results are injected back into the task loop. + +--- + +## 3. The agent loop: recursivelyMakeClineRequests — conceptual steps + +1. Build or pop a userContent stack item to process. +2. Check abort/paused/reset flags and backoff state. +3. Compose prompt: conversation messages, tool metadata, file details (optional), environment hints, mode-specific system content. +4. Create an `api_req_started` placeholder message in UI and start streaming via provider API with an AbortController. +5. Stream parse: + - On text chunks: append to assistant buffer and present partial assistant message. + - On tool-call events: execute tool immediately or schedule; push tool results to user content buffer. + - On usage/grounding events: aggregate telemetry/usage. +6. When assistant completes: + - Convert assistant output and tool results into user content blocks and push back to the stack. + - If stack not empty → recurse (continue loop). +7. Handle error paths: + - Rate limits → exponential backoff and retry. + - Context window truncate → condense context and retry (MAX_CONTEXT_WINDOW_RETRIES). + - Network/first-chunk failures → retry with exponential backoff. + - Abort → update UI row with cancel reason and possibly call abortTask(). +8. Persist checkpoints and telemetry periodically and on state transitions. + +Return semantics: + +- Returns false on normal termination (stack empty). +- Returns true/throws on unexpected error forcing outer stop. + +--- + +## 4. Message flow (sequence diagram) + +```mermaid +sequenceDiagram + participant User + participant Webview + participant WebviewHandler + participant ClineProvider + participant Task + participant APIAdapter + participant ToolExecutor + + User->>Webview: type + send + Webview->>WebviewHandler: postMessage("askResponse") + WebviewHandler->>ClineProvider: getVisibleInstance() / getTask + WebviewHandler->>Task: submitUserMessage(text, images) + Task->>Task: handleWebviewAskResponse(...) + Note over Task: ask() awaiting predicate resolves + Task->>APIAdapter: createMessage(prompt, controller) + APIAdapter-->>Task: stream chunks (text/tool/usage) + Task->>Webview: presentAssistantMessage(partial) + alt tool call event + Task->>ToolExecutor: execute(toolCall) + ToolExecutor-->>Task: toolResult + Task->>Task: push toolResult into userContent + end + Task->>Task: push userContent to stack -> continue loop +``` + +--- + +## 5. Hook system: purpose and architecture + +Purpose: provide extension points for cross-cutting concerns without leaking internal Task implementation. Hooks enable logging, telemetry, testing, customization (modes/providers), and third-party integrations. + +Design goals: + +- Minimal surface area: well-defined hook types for Task lifecycle and stream events. +- Async-capable: hooks can be async and must not block the critical fast-path; use awaited or fire-and-forget based on hook type. +- Backpressure-safe: streaming hooks receive deltas; heavy processing should be offloaded. +- Idempotent & resilient: hooks must not mutate core state in ways that affect correctness; errors should be captured and logged, not crash the task. +- Observability-first: hooks expose granular events for debugging and telemetry. + +Hook categories: + +- Lifecycle hooks (synchronous optional await): + - onTaskStart(taskMeta) + - onTaskStop(taskMeta, reason) + - onCheckpointSaved(checkpointMeta) +- Ask/Response hooks: + - beforeAsk(promptContext) — may mutate promptContext copy + - afterAsk(responseSummary) +- Streaming hooks (should be non-blocking): + - onStreamChunk(chunk) + - onStreamComplete(assistantMessage) +- Tool hooks: + - onToolCall(toolRequest) + - onToolResult(toolResult) +- Persistence hooks: + - onSaveMessages(messages) +- Admin hooks: + - onAbort(reason) + +Hook registration API (concept): + +- Task.hooks.register(name, fn, { priority = 0, awaitable = false }) +- Task.hooks.unregister(id) +- Invocation: Task.hooks.invoke(name, payload) — wraps calls in try/catch and observes awaitable flag. + +Decision: streaming hooks default to non-awaitable to avoid blocking the parse -> render loop. Lifecycle hooks default to awaitable. + +--- + +## 6. Hook invocation schema (mermaid) + +```mermaid +flowchart TD + A[Task Event Occurs] --> B{Registered Hooks?} + B -- Yes --> C[Sort by priority] + C --> D{awaitable?} + D -- true --> E[await hook(payload)] + D -- false --> F[call hook(payload) in microtask / Promise.resolve()] + E --> G[collect results / errors] + F --> G + G --> H[continue core logic] + B -- No --> H +``` + +--- + +## 7. Component diagram (mermaid) + +```mermaid +classDiagram + class Webview { + +postMessage() + +onMessage() + } + class WebviewHandler { + +handle(message) + } + class ClineProvider { + +createTask() + +getVisibleInstance() + } + class Task { + +submitUserMessage() + +ask() + +recursivelyMakeClineRequests() + +abortTask() + +checkpointSave() + +hooks + } + class APIAdapter { + +createMessage() + } + class ToolExecutor { + +execute() + } + + Webview --> WebviewHandler + WebviewHandler --> ClineProvider + ClineProvider --> Task + Task --> APIAdapter + Task --> ToolExecutor + Task --> Webview +``` + +--- + +## 8. Architectural decisions & rationale + +- Event-driven task loop: a streaming, event-based loop simplifies partial UI updates and tool interleaving; streaming allows progressive display and early tool execution. +- Isolation of UI -> Task pathway: UI writes are normalized through `webviewMessageHandler` → `Task.submitUserMessage()` → `Task.handleWebviewAskResponse()` to avoid races and ensure canonical state changes. +- Per-request AbortController: enables precise cancellation of single API calls; Task-level `abortTask()` sets task abort state and coordinates higher-level shutdown. +- Hooks with priority & await semantics: gives control to extensions/internals for synchronous lifecycle needs while protecting the stream path from blocking. +- Checkpointing on user sends: safety and reproducibility for long-running tasks and file operations. +- Separate managers for settings/modes: keep config, mode logic, and UI concerns decoupled from Task runtime. +- Use cachedState in SettingsView: prevents race conditions between UI edits and ContextProxy live state. + +--- + +## 9. Implementation notes and best practices + +- Always write hooks defensively: catch and log errors. +- Keep streaming hooks lightweight; delegate heavy processing to worker tasks or background jobs. +- Respect task abort and per-request AbortController to avoid leaking tool executions. +- When adding a UI input to SettingsView follow AGENTS.md: bind to `cachedState` and persist to `contextProxy` only on explicit Save. +- When modifying prompt composition, prefer creating a copy of `clineMessages` to avoid concurrent mutation issues. +- Use messageQueueService for UI and persistence writes to serialize state transitions. + +--- + +## 10. Example hook registration (conceptual) + +```ts +// Example (conceptual) — register a non-blocking stream logger +Task.hooks.register( + "onStreamChunk", + (chunk) => { + // lightweight logging + console.debug("stream chunk", chunk.type, chunk.length) + }, + { awaitable: false, priority: 10 }, +) +``` + +--- + +## 11. Appendix — Recap of critical constants & limits + +- MAX_CONTEXT_WINDOW_RETRIES = 3 +- MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 +- FORCED_CONTEXT_REDUCTION_PERCENT = 75 + +--- + +End of ARCHITECTURE_NOTES.md diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9fc..1c81f4c6f5b 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -46,6 +46,8 @@ export const toolNames = [ "skill", "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 7f5862be154..306b820dd1f 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -17,6 +17,8 @@ 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" import { searchReplaceTool } from "../tools/SearchReplaceTool" @@ -40,13 +42,17 @@ 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" +import { AgentTraceMiddleware } from "../middlewares/AgentTraceMiddleware" /** * 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. @@ -335,6 +341,10 @@ export async function presentAssistantMessage(cline: Task) { return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) } return readFileTool.getReadFileToolDescription(block.name, block.params) + 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": @@ -675,20 +685,54 @@ export async function presentAssistantMessage(cline: Task) { } } + const middlewareChain = new MiddlewareChain() + middlewareChain.add(new IntentValidationMiddleware()) + middlewareChain.add(new ScopeEnforcementMiddleware()) + middlewareChain.add(new AgentTraceMiddleware()) + + 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 + } + + let capturedToolResult: any = null + const capturePushToolResult = (content: ToolResponse) => { + capturedToolResult = content + return pushToolResult(content) + } + switch (block.name) { + case "list_active_intents": + await listActiveIntentsTool.handle(cline, block as ToolUse<"list_active_intents">, { + askApproval, + handleError, + pushToolResult: capturedToolResult, + }) + break + case "select_active_intent": + await selectActiveIntentTool.handle(cline, block as ToolUse<"select_active_intent">, { + askApproval, + handleError, + pushToolResult: capturedToolResult, + }) + break case "write_to_file": await checkpointSaveAndMark(cline) await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "update_todo_list": await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "apply_diff": @@ -696,7 +740,7 @@ export async function presentAssistantMessage(cline: Task) { await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "edit": @@ -705,7 +749,7 @@ export async function presentAssistantMessage(cline: Task) { await editTool.handle(cline, block as ToolUse<"edit">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "search_replace": @@ -713,7 +757,7 @@ export async function presentAssistantMessage(cline: Task) { await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "edit_file": @@ -721,7 +765,7 @@ export async function presentAssistantMessage(cline: Task) { await editFileTool.handle(cline, block as ToolUse<"edit_file">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "apply_patch": @@ -729,7 +773,7 @@ export async function presentAssistantMessage(cline: Task) { await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "read_file": @@ -737,70 +781,70 @@ export async function presentAssistantMessage(cline: Task) { await readFileTool.handle(cline, block as ToolUse<"read_file">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "list_files": await listFilesTool.handle(cline, block as ToolUse<"list_files">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "codebase_search": await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "search_files": await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "execute_command": await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "read_command_output": await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "use_mcp_tool": await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "access_mcp_resource": await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "ask_followup_question": await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "switch_mode": await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "new_task": @@ -808,7 +852,7 @@ export async function presentAssistantMessage(cline: Task) { await newTaskTool.handle(cline, block as ToolUse<"new_task">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, toolCallId: block.id, }) break @@ -816,7 +860,7 @@ export async function presentAssistantMessage(cline: Task) { const completionCallbacks: AttemptCompletionCallbacks = { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, askFinishSubTaskApproval, toolDescription, } @@ -831,14 +875,14 @@ export async function presentAssistantMessage(cline: Task) { await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "skill": await skillTool.handle(cline, block as ToolUse<"skill">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break case "generate_image": @@ -846,7 +890,7 @@ export async function presentAssistantMessage(cline: Task) { await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { askApproval, handleError, - pushToolResult, + pushToolResult: capturedToolResult, }) break default: { @@ -874,7 +918,7 @@ export async function presentAssistantMessage(cline: Task) { console.error(message) cline.consecutiveMistakeCount++ await cline.say("error", message) - pushToolResult(formatResponse.toolError(message)) + capturedToolResult(formatResponse.toolError(message)) break } } @@ -888,7 +932,7 @@ export async function presentAssistantMessage(cline: Task) { `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`, ) - pushToolResult(result) + capturedToolResult(result) cline.consecutiveMistakeCount = 0 } catch (executionError: any) { cline.consecutiveMistakeCount++ @@ -917,6 +961,14 @@ export async function presentAssistantMessage(cline: Task) { } } + const postResult = await middlewareChain.executeAfter(capturedToolResult, cline, block.name) + if (postResult.modifiedResult !== undefined) { + const lastResult = cline.userMessageContent[cline.userMessageContent.length - 1] + if (lastResult?.type === "tool_result") { + lastResult.content = postResult.modifiedResult + } + } + break } } diff --git a/src/core/intents/IntentLoader.ts b/src/core/intents/IntentLoader.ts new file mode 100644 index 00000000000..f6d4531c203 --- /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 00000000000..91759167a3a --- /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/middlewares/AgentTraceMiddleware.ts b/src/core/middlewares/AgentTraceMiddleware.ts new file mode 100644 index 00000000000..b19cd31e4e0 --- /dev/null +++ b/src/core/middlewares/AgentTraceMiddleware.ts @@ -0,0 +1,167 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as crypto from "crypto" +import { Task } from "../task/Task" +import { generateContentHash } from "../../utils/hash" +import type { ToolMiddleware, MiddlewareResult } from "./ToolMiddleware" + +interface AgentTraceEntry { + id: string + timestamp: string + vcs: { + revision_id: string + } + files: Array<{ + relative_path: string + conversations: Array<{ + url: string + contributor: { + entity_type: "AI" + model_identifier: string + } + ranges: Array<{ + start_line: number + end_line: number + content_hash: string + }> + related: Array<{ + type: "specification" + value: string + }> + }> + }> +} + +export class AgentTraceMiddleware implements ToolMiddleware { + name = "agentTrace" + + async beforeExecute(_params: any, _task: Task, _toolName: string): Promise { + return { allow: true } + } + + async afterExecute(result: any, task: Task, toolName: string): Promise { + if (toolName !== "write_to_file") { + return { allow: true, modifiedResult: result } + } + + try { + const lastToolUse = this.findLastToolUse(task, "write_to_file") + if (!lastToolUse) { + return { allow: true, modifiedResult: result } + } + + const { intent_id, mutation_class, path: filePath, content } = lastToolUse.params + const contentHash = generateContentHash(content) + + const traceEntry: AgentTraceEntry = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + vcs: { + revision_id: await this.getGitSha(task.cwd), + }, + files: [ + { + relative_path: filePath, + conversations: [ + { + url: task.taskId, // session_log_id + contributor: { + entity_type: "AI", + model_identifier: await this.getModelIdentifier(task), + }, + ranges: [ + { + start_line: 1, + end_line: content.split("\n").length, + content_hash: `sha256:${contentHash}`, + }, + ], + related: [ + { + type: "specification", + value: intent_id, // REQ-ID injection + }, + ], + }, + ], + }, + ], + } + + await this.appendToTraceFile(traceEntry, task.cwd) + return { allow: true, modifiedResult: result } + } catch (error) { + console.error("Agent trace middleware error:", error) + return { allow: true, modifiedResult: result } + } + } + + private async getGitSha(cwd: string): Promise { + try { + const { execSync } = require("child_process") + const gitSha = execSync("git rev-parse HEAD", { + cwd, + encoding: "utf8", + }).trim() + return gitSha || "unknown" + } catch { + return "unknown" + } + } + + private async getModelIdentifier(task: Task): Promise { + try { + const provider = task.providerRef.deref() + if (!provider) return "unknown" + + const state = await provider.getState() + const apiConfiguration = state?.apiConfiguration + + if (apiConfiguration) { + switch (apiConfiguration.apiProvider) { + case "anthropic": + return apiConfiguration.apiModelId || "unknown" + case "openrouter": + return apiConfiguration.openRouterModelId || "unknown" + case "openai": + return apiConfiguration.openAiModelId || "unknown" + case "lmstudio": + return apiConfiguration.lmStudioModelId || "unknown" + default: + return "unknown" + } + } + + return "unknown" + } catch { + return "unknown" + } + } + + private findLastToolUse(task: Task, toolName: string): any { + for (let i = task.apiConversationHistory.length - 1; i >= 0; i--) { + const message = task.apiConversationHistory[i] + if (message.role === "assistant" && Array.isArray(message?.content)) { + const toolUse = message.content?.find( + (block: any) => block.type === "tool_use" && block.name === toolName, + ) + if (toolUse) { + return toolUse + } + } + } + return null + } + + private async appendToTraceFile(entry: AgentTraceEntry, cwd: string): Promise { + const tracePath = path.join(cwd, ".orchestration", "agent_trace.jsonl") + + try { + await fs.mkdir(path.dirname(tracePath), { recursive: true }) + const line = JSON.stringify(entry) + "\n" + await fs.appendFile(tracePath, line, "utf8") + } catch (error) { + console.error("Failed to write to agent trace:", error) + } + } +} diff --git a/src/core/middlewares/IntentValidationMiddleware.ts b/src/core/middlewares/IntentValidationMiddleware.ts new file mode 100644 index 00000000000..0404f174262 --- /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 00000000000..8e38dd1ab8c --- /dev/null +++ b/src/core/middlewares/MiddlewareChain.ts @@ -0,0 +1,38 @@ +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 } + } + + async executeAfter(result: any, task: Task, toolName: ToolName): Promise { + let finalResult = result + + for (const middleware of this.middlewares) { + const postResult = await middleware.afterExecute?.(finalResult, task, toolName) + if (postResult?.modifiedResult !== undefined) { + finalResult = postResult.modifiedResult + } + } + + return { allow: true, modifiedResult: finalResult } + } +} diff --git a/src/core/middlewares/ScopeEnforcementMiddleware.ts b/src/core/middlewares/ScopeEnforcementMiddleware.ts new file mode 100644 index 00000000000..14419952e08 --- /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 00000000000..9a470bddf7b --- /dev/null +++ b/src/core/middlewares/ToolMiddleware.ts @@ -0,0 +1,14 @@ +import { Task } from "../task/Task" + +export interface MiddlewareResult { + allow: boolean + error?: string + modifiedParams?: any + modifiedResult?: 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/responses.ts b/src/core/prompts/responses.ts index 60b5b4123ac..4cfa9acfbfc 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 318cd47bc9d..7d9679ffc19 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 00000000000..796f76d8ab5 --- /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 00000000000..360e1b5dfe4 --- /dev/null +++ b/src/core/prompts/sections/intent-protocol.ts @@ -0,0 +1,19 @@ +export function intentProtocolSection(): string { + return ` +=== + +INTENT-DRIVEN PROTOCOL + +You are an Intent-Driven Architect. You MUST follow this two-step process before making any changes: + +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.) +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/system.ts b/src/core/prompts/system.ts index 0d6071644a9..0e76b0c7914 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/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d65..81e44645baa 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -20,6 +20,8 @@ import searchFiles from "./search_files" 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" @@ -68,6 +70,8 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch switchMode, 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 00000000000..bfbc35bd7b2 --- /dev/null +++ b/src/core/prompts/tools/native-tools/list_active_intents.ts @@ -0,0 +1,33 @@ +import type OpenAI from "openai" + +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_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 new file mode 100644 index 00000000000..62c79e105c8 --- /dev/null +++ b/src/core/prompts/tools/native-tools/select_active_intent.ts @@ -0,0 +1,37 @@ +import type OpenAI from "openai" + +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", + function: { + name: "select_active_intent", + description: SELECT_ACTIVE_INTENT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + intent_id: { + type: "string", + description: INTENT_ID_DESCRIPTION, + }, + }, + required: ["intent_id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/native-tools/write_to_file.ts b/src/core/prompts/tools/native-tools/write_to_file.ts index b9e9b313a22..2b645777683 100644 --- a/src/core/prompts/tools/native-tools/write_to_file.ts +++ b/src/core/prompts/tools/native-tools/write_to_file.ts @@ -32,8 +32,17 @@ export default { type: "string", description: CONTENT_PARAMETER_DESCRIPTION, }, + intent_id: { + type: "string", + description: "The intent ID this write operation belongs to (e.g., REQ-001)", + }, + mutation_class: { + type: "string", + enum: ["AST_REFACTOR", "INTENT_EVOLUTION"], + description: "Classification: AST_REFACTOR for syntax changes, INTENT_EVOLUTION for new features", + }, }, - required: ["path", "content"], + required: ["path", "content", "intent_id", "mutation_class"], additionalProperties: false, }, }, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3feb695e104..61c6760977f 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. @@ -859,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 { @@ -1263,6 +1292,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). @@ -2776,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). @@ -4008,6 +4055,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 new file mode 100644 index 00000000000..93939ef07fc --- /dev/null +++ b/src/core/tools/ListActiveIntents.ts @@ -0,0 +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 + + 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 new file mode 100644 index 00000000000..deb5f570b2b --- /dev/null +++ b/src/core/tools/SelectActiveIntent.ts @@ -0,0 +1,86 @@ +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 +} + +export class SelectActiveIntentTool extends BaseTool<"select_active_intent"> { + readonly name = "select_active_intent" as const + + 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) { + // TODO: Figure out what to do when the provider becomes undefined + 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") + } + + override async handlePartial(_task: Task, _block: ToolUse<"select_active_intent">): Promise {} +} + +export const selectActiveIntentTool = new SelectActiveIntentTool() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index c8455ef3d97..4535df37c03 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -21,6 +21,8 @@ import { BaseTool, ToolCallbacks } from "./BaseTool" interface WriteToFileParams { path: string content: string + intent_id: string + mutation_class: "AST_REFACTOR" | "INTENT_EVOLUTION" } export class WriteToFileTool extends BaseTool<"write_to_file"> { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bb9199a65c2..7aec2a07458 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 new file mode 100644 index 00000000000..3aaf32cc19b --- /dev/null +++ b/src/hooks/HookEngine.ts @@ -0,0 +1 @@ +export class HookEngine {} diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/orchestration/OrchestrationStore.ts b/src/orchestration/OrchestrationStore.ts new file mode 100644 index 00000000000..d655f3e9049 --- /dev/null +++ b/src/orchestration/OrchestrationStore.ts @@ -0,0 +1,48 @@ +import path from "path" +import fs from "fs/promises" +import { fileExistsAtPath, createDirectoriesForFile } from "../utils/fs" + +interface OrchestrationStoreOptions { + workspaceRoot: string +} + +const ORCHESTRATION_FOLDER_PATHNAME = ".orchestration" +const ORCHESTRATION_AGENT_TRACE = "agent_trace.jsonl" +const ORCHESTRATION_ACTIVE_INTENTS = "active_intents.yaml" +const ORCHESTRATION_INTENT_MAP = "intent_map.md" + +const orchestrationFilePaths = { + root: ORCHESTRATION_FOLDER_PATHNAME, + agent_trace: `${ORCHESTRATION_FOLDER_PATHNAME}/${ORCHESTRATION_AGENT_TRACE}`, + active_intents: `${ORCHESTRATION_FOLDER_PATHNAME}/${ORCHESTRATION_ACTIVE_INTENTS}`, + intent_map: `${ORCHESTRATION_FOLDER_PATHNAME}/${ORCHESTRATION_INTENT_MAP}`, +} + +export class OrchestrationStore { + private readonly workspaceRoot: string + + constructor({ workspaceRoot }: OrchestrationStoreOptions) { + this.workspaceRoot = workspaceRoot + } + + private resolve(p: string) { + return path.join(this.workspaceRoot, p) + } + + async ensureInitialized(): Promise { + await this.ensureFile(orchestrationFilePaths.active_intents, `active_intents: []\n`) + await this.ensureFile(orchestrationFilePaths.agent_trace, "") + await this.ensureFile(orchestrationFilePaths.intent_map, `# Intent Map\n\n`) + } + + private async ensureFile(relativePath: string, initialContent: string) { + const fullPath = this.resolve(relativePath) + + if (await fileExistsAtPath(fullPath)) { + return + } + + await createDirectoriesForFile(fullPath) + await fs.writeFile(fullPath, initialContent, "utf-8") + } +} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba693611..c285f71c20e 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -115,6 +115,9 @@ 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 } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + list_active_intents: {} // Add more tools as they are migrated to native protocol } @@ -289,12 +292,21 @@ export const TOOL_DISPLAY_NAMES: Record = { skill: "load skill", 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", + "list_active_intents", + ], }, edit: { tools: ["apply_diff", "write_to_file", "generate_image"], @@ -321,6 +333,8 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "update_todo_list", "run_slash_command", "skill", + "select_active_intent", + "list_active_intents", ] as const /** diff --git a/src/utils/hash.ts b/src/utils/hash.ts new file mode 100644 index 00000000000..b1999e88356 --- /dev/null +++ b/src/utils/hash.ts @@ -0,0 +1,11 @@ +import * as crypto from "crypto" + +export function generateContentHash(content: string): string { + return crypto.createHash("sha256").update(content, "utf8").digest("hex") +} + +export function generateBlockHash(content: string, startLine: number, endLine: number): string { + const lines = content.split("\n") + const blockContent = lines.slice(startLine - 1, endLine).join("\n") + return generateContentHash(blockContent) +}