Merge pull request #1 from leuel-a/feat/deterministic-hook-system

Feat/deterministic hook system
This commit is contained in:
Leuel Asfaw 2026-02-18 21:21:15 +03:00 committed by GitHub
commit ef17942cd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 390 additions and 0 deletions

9
.rgignore Normal file
View file

@ -0,0 +1,9 @@
.vscode/
.roo/
releases/
.git/
.github/
__tests__/

261
ARCHITECTURE_NOTES.md Normal file
View 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

View file

@ -46,6 +46,7 @@ export const toolNames = [
"skill",
"generate_image",
"custom_tool",
"select_active_intent",
] as const
export const toolNamesSchema = z.enum(toolNames)

View file

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

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

View 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

View 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
View 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() {}
}

View file

View file

View file

View file

View file

View file

0
src/hooks/types.ts Normal file
View file

View 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")
}
}

View file

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