mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-12 23:01:21 +00:00
Merge pull request #1 from leuel-a/feat/deterministic-hook-system
Feat/deterministic hook system
This commit is contained in:
commit
ef17942cd4
17 changed files with 390 additions and 0 deletions
9
.rgignore
Normal file
9
.rgignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.vscode/
|
||||
.roo/
|
||||
|
||||
releases/
|
||||
|
||||
.git/
|
||||
.github/
|
||||
|
||||
__tests__/
|
||||
261
ARCHITECTURE_NOTES.md
Normal file
261
ARCHITECTURE_NOTES.md
Normal file
|
|
@ -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
|
||||
|
|
@ -46,6 +46,7 @@ export const toolNames = [
|
|||
"skill",
|
||||
"generate_image",
|
||||
"custom_tool",
|
||||
"select_active_intent",
|
||||
] as const
|
||||
|
||||
export const toolNamesSchema = z.enum(toolNames)
|
||||
|
|
|
|||
|
|
@ -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 { selectActiveIntentTool } from "../tools/SelectActiveIntent"
|
||||
import { writeToFileTool } from "../tools/WriteToFileTool"
|
||||
import { editTool } from "../tools/EditTool"
|
||||
import { searchReplaceTool } from "../tools/SearchReplaceTool"
|
||||
|
|
@ -335,6 +336,8 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs)
|
||||
}
|
||||
return readFileTool.getReadFileToolDescription(block.name, block.params)
|
||||
case "select_active_intent":
|
||||
return `[${block.name}]`
|
||||
case "write_to_file":
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "apply_diff":
|
||||
|
|
@ -676,6 +679,8 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
|
||||
switch (block.name) {
|
||||
case "select_active_intent":
|
||||
break
|
||||
case "write_to_file":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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"
|
||||
|
||||
export { getMcpServerTools } from "./mcp_server"
|
||||
export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters"
|
||||
|
|
@ -68,6 +69,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
|
|||
switchMode,
|
||||
updateTodoList,
|
||||
writeToFile,
|
||||
selectActiveIntent,
|
||||
] satisfies OpenAI.Chat.ChatCompletionTool[]
|
||||
}
|
||||
|
||||
|
|
|
|||
22
src/core/prompts/tools/native-tools/select_active_intent.ts
Normal file
22
src/core/prompts/tools/native-tools/select_active_intent.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type OpenAI from "openai"
|
||||
|
||||
const SELECT_ACTIVE_INTENT_DESCRIPTION = ` `
|
||||
const INTENT_ID_DESCRIPTION = ``
|
||||
|
||||
export default {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "select_active_intent",
|
||||
description: SELECT_ACTIVE_INTENT_DESCRIPTION,
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: INTENT_ID_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenAI.Chat.ChatCompletionTool
|
||||
16
src/core/tools/SelectActiveIntent.ts
Normal file
16
src/core/tools/SelectActiveIntent.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Task } from "../task/Task"
|
||||
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
||||
|
||||
interface SelectActiveIntentParams {
|
||||
intent_id: string
|
||||
}
|
||||
|
||||
export class SelectActiveIntent 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.")
|
||||
}
|
||||
}
|
||||
|
||||
export const selectActiveIntentTool = new SelectActiveIntent()
|
||||
25
src/hooks/HookEngine.ts
Normal file
25
src/hooks/HookEngine.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Task } from "../core/task/Task"
|
||||
import { OrchestrationStore } from "../orchestration/OrchestrationStore"
|
||||
|
||||
interface HookEngineOptions {
|
||||
task: Task
|
||||
}
|
||||
|
||||
export class HookEngine {
|
||||
private readonly store: OrchestrationStore
|
||||
|
||||
constructor({ task }: HookEngineOptions) {
|
||||
this.store = new OrchestrationStore({ workspaceRoot: task.cwd })
|
||||
}
|
||||
|
||||
/** Called before any tool execution */
|
||||
preToolHook() {
|
||||
this.store.ensureInitialized()
|
||||
}
|
||||
|
||||
/** Called after any tool execution */
|
||||
postToolHook() {}
|
||||
|
||||
/** */
|
||||
preLLMHook() {}
|
||||
}
|
||||
0
src/hooks/postHooks/intentUpdater.ts
Normal file
0
src/hooks/postHooks/intentUpdater.ts
Normal file
0
src/hooks/postHooks/lessonRecorder.ts
Normal file
0
src/hooks/postHooks/lessonRecorder.ts
Normal file
0
src/hooks/postHooks/traceWriter.ts
Normal file
0
src/hooks/postHooks/traceWriter.ts
Normal file
0
src/hooks/preHooks/authorization.ts
Normal file
0
src/hooks/preHooks/authorization.ts
Normal file
0
src/hooks/preHooks/intentHandshake.ts
Normal file
0
src/hooks/preHooks/intentHandshake.ts
Normal file
0
src/hooks/preHooks/scopeGuard.ts
Normal file
0
src/hooks/preHooks/scopeGuard.ts
Normal file
0
src/hooks/types.ts
Normal file
0
src/hooks/types.ts
Normal file
48
src/orchestration/OrchestrationStore.ts
Normal file
48
src/orchestration/OrchestrationStore.ts
Normal file
|
|
@ -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<void> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -289,6 +289,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
|||
skill: "load skill",
|
||||
generate_image: "generate images",
|
||||
custom_tool: "use custom tools",
|
||||
select_active_intent: "select the active intent",
|
||||
} as const
|
||||
|
||||
// Define available tool groups.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue