From 8d7e56fc4d1dd5cc1e80ebecbd46d8d1c9b9ff70 Mon Sep 17 00:00:00 2001 From: Fentrex Date: Thu, 9 Apr 2026 15:12:01 +0530 Subject: [PATCH 1/4] HITL Implementation for action tools --- packages/toolpack-sdk/src/client/index.ts | 123 +++++++++++++- packages/toolpack-sdk/src/providers/config.ts | 153 ++++++++++++++++++ packages/toolpack-sdk/src/providers/index.ts | 5 + packages/toolpack-sdk/src/toolpack.ts | 59 ++++++- .../tools/cloud-tools/tools/deploy/index.ts | 5 + .../tools/multi-file-edit/index.ts | 5 + .../tools/refactor-rename/index.ts | 5 + .../toolpack-sdk/src/tools/config-loader.ts | 2 + .../src/tools/db-tools/tools/delete/index.ts | 5 + .../src/tools/db-tools/tools/insert/index.ts | 5 + .../src/tools/db-tools/tools/update/index.ts | 5 + .../src/tools/diff-tools/tools/apply/index.ts | 5 + .../src/tools/exec-tools/tools/kill/index.ts | 5 + .../exec-tools/tools/run-background/index.ts | 5 + .../tools/exec-tools/tools/run-shell/index.ts | 5 + .../src/tools/exec-tools/tools/run/index.ts | 5 + .../tools/fs-tools/tools/append-file/index.ts | 5 + .../tools/fs-tools/tools/batch-write/index.ts | 5 + .../src/tools/fs-tools/tools/copy/index.ts | 5 + .../tools/fs-tools/tools/delete-dir/index.ts | 5 + .../tools/fs-tools/tools/delete-file/index.ts | 5 + .../fs-tools/tools/delete-file/schema.ts | 2 +- .../src/tools/fs-tools/tools/move/index.ts | 5 + .../fs-tools/tools/replace-in-file/index.ts | 5 + .../tools/fs-tools/tools/write-file/index.ts | 5 + .../tools/fs-tools/tools/write-file/schema.ts | 2 +- .../tools/git-tools/tools/checkout/index.ts | 5 + .../src/tools/git-tools/tools/commit/index.ts | 5 + .../tools/http-tools/tools/delete/index.ts | 5 + .../src/tools/http-tools/tools/post/index.ts | 5 + .../src/tools/http-tools/tools/put/index.ts | 5 + .../tools/system-tools/tools/set-env/index.ts | 5 + packages/toolpack-sdk/src/tools/types.ts | 16 ++ packages/toolpack-sdk/src/types/index.ts | 30 ++++ 34 files changed, 509 insertions(+), 8 deletions(-) diff --git a/packages/toolpack-sdk/src/client/index.ts b/packages/toolpack-sdk/src/client/index.ts index f63b3aa..73afc0a 100644 --- a/packages/toolpack-sdk/src/client/index.ts +++ b/packages/toolpack-sdk/src/client/index.ts @@ -1,11 +1,12 @@ import { EventEmitter } from 'events'; import { ProviderAdapter } from "../providers/base/index.js"; -import { CompletionRequest, CompletionResponse, CompletionChunk, ToolCallRequest, ToolCallResult, EmbeddingRequest, EmbeddingResponse, ToolProgressEvent, ToolLogEvent } from "../types/index.js"; +import { CompletionRequest, CompletionResponse, CompletionChunk, ToolCallRequest, ToolCallResult, EmbeddingRequest, EmbeddingResponse, ToolProgressEvent, ToolLogEvent, OnToolConfirmCallback, ToolConfirmationRequestedEvent, ToolConfirmationResolvedEvent } from "../types/index.js"; import { SDKError, ProviderError } from "../errors/index.js"; import { ToolRegistry } from '../tools/registry.js'; import { ToolRouter } from '../tools/router.js'; -import type { ToolsConfig, ToolSchema, ToolContext } from "../tools/types.js"; +import type { ToolsConfig, ToolSchema, ToolContext, ToolDefinition } from "../tools/types.js"; import { DEFAULT_TOOLS_CONFIG } from "../tools/types.js"; +import type { HitlConfig } from '../providers/config.js'; import { ModeConfig } from '../modes/mode-types.js'; import { BM25SearchEngine, isToolSearchTool, generateToolCategoriesPrompt } from '../tools/search/index.js'; import { generateBaseAgentContext } from './base-agent-context.js'; @@ -189,6 +190,12 @@ export interface AIClientConfig { toolsConfig?: ToolsConfig; systemPrompt?: string; disableBaseContext?: boolean; + /** Human-in-the-loop configuration for tool confirmation */ + hitlConfig?: HitlConfig; + /** Callback for handling tool confirmation requests */ + onToolConfirm?: OnToolConfirmCallback; + /** Optional conversation ID for tracking context */ + conversationId?: string; } export class AIClient extends EventEmitter { @@ -204,6 +211,10 @@ export class AIClient extends EventEmitter { private overrideSystemPrompt?: string; private disableBaseContext: boolean; private toolResultMaxChars: number; + private hitlConfig?: HitlConfig; + private onToolConfirm?: OnToolConfirmCallback; + private currentRound: number = 0; + private conversationId?: string; constructor(config: AIClientConfig) { super(); @@ -219,6 +230,9 @@ export class AIClient extends EventEmitter { this.disableBaseContext = config.disableBaseContext || false; const configuredMax = this.toolsConfig.resultMaxChars ?? DEFAULT_TOOLS_CONFIG.resultMaxChars ?? 20_000; this.toolResultMaxChars = Number.isFinite(configuredMax) && configuredMax > 0 ? configuredMax : 20_000; + this.hitlConfig = config.hitlConfig; + this.onToolConfirm = config.onToolConfirm; + this.conversationId = config.conversationId; // Index tools for BM25 search if registry is provided if (this.toolRegistry) { @@ -226,6 +240,33 @@ export class AIClient extends EventEmitter { } } + /** + * Check if a tool should bypass confirmation based on HITL config. + * Returns true if the tool should execute without confirmation. + */ + private isBypassed(tool: ToolDefinition): boolean { + const hitl = this.hitlConfig; + + // If HITL config doesn't exist, bypass everything + if (!hitl) return true; + + // If HITL is explicitly disabled, bypass everything + if (hitl.enabled === false) return true; + + // Check confirmation mode + const mode = hitl.confirmationMode ?? 'all'; + if (mode === 'off') return true; + if (mode === 'high-only' && tool.confirmation?.level === 'medium') return true; + + // Check bypass rules + const bypass = hitl.bypass ?? {}; + if (bypass.tools?.includes(tool.name)) return true; + if (bypass.categories?.includes(tool.category)) return true; + if (tool.confirmation && bypass.levels?.includes(tool.confirmation.level)) return true; + + return false; + } + /** * Register a new provider instance. */ @@ -248,6 +289,21 @@ export class AIClient extends EventEmitter { return provider; } + /** + * Update the HITL configuration dynamically. + * This allows modifying bypass rules without restarting the client. + */ + updateHitlConfig(config: HitlConfig): void { + this.hitlConfig = config; + } + + /** + * Get the current HITL configuration. + */ + getHitlConfig(): HitlConfig | undefined { + return this.hitlConfig; + } + /** * Set the default provider for this client. */ @@ -420,6 +476,7 @@ export class AIClient extends EventEmitter { while (response.tool_calls && response.tool_calls.length > 0 && rounds < maxRounds) { rounds++; + this.currentRound = rounds; logInfo(`[AIClient][${requestId}] generate() tool round ${rounds}/${maxRounds} tool_calls=${response.tool_calls.length}`); // Add assistant message with tool calls to conversation @@ -668,7 +725,9 @@ export class AIClient extends EventEmitter { let accumulatedContent = ''; const pendingToolCalls: ToolCallResult[] = []; - logInfo(`[AIClient][${requestId}] stream() round_start ${rounds + 1}/${maxRounds}`); + rounds++; + this.currentRound = rounds; + logInfo(`[AIClient][${requestId}] stream() round_start ${rounds}/${maxRounds}`); let lastFinishReason: string | null = null; const rawRoundReq: any = { ...baseReq, messages }; @@ -1281,12 +1340,68 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools } try { + let args = toolCall.arguments; + + // Human-in-the-loop confirmation check + if (tool.confirmation && this.onToolConfirm && !this.isBypassed(tool)) { + // Emit confirmation requested event + this.emit('tool:confirmation_requested', { + tool, + args, + level: tool.confirmation.level, + reason: tool.confirmation.reason, + } as ToolConfirmationRequestedEvent); + + // Wait for user decision + const decision = await this.onToolConfirm(tool, args, { + roundNumber: this.currentRound, + conversationId: this.conversationId, + }); + + // Emit confirmation resolved event + this.emit('tool:confirmation_resolved', { + tool, + args, + level: tool.confirmation.level, + reason: tool.confirmation.reason, + decision, + } as ToolConfirmationResolvedEvent); + + // Handle decision + if (decision.action === 'deny') { + const denyMsg = `[Execution denied by user${decision.reason ? ': ' + decision.reason : ''}]`; + const duration = Date.now() - startTime; + this.emit('tool:completed', { + toolName: toolCall.name, + toolCallId: toolCall.id, + status: 'completed', + result: denyMsg, + duration, + } as ToolProgressEvent); + this.emit('tool:log', { + id: toolCall.id, + name: toolCall.name, + arguments: args, + result: denyMsg, + duration, + status: 'success', + timestamp: Date.now(), + } as ToolLogEvent); + return denyMsg; + } + + if (decision.action === 'modify') { + args = decision.args; + } + // 'allow' falls through to execution + } + const ctx: ToolContext = { workspaceRoot: process.cwd(), config: this.toolsConfig?.additionalConfigurations ?? {}, log: (msg) => logInfo(`[Tool] ${msg}`), }; - const result = await tool.execute(toolCall.arguments, ctx); + const result = await tool.execute(args, ctx); const duration = Date.now() - startTime; // Emit completed event diff --git a/packages/toolpack-sdk/src/providers/config.ts b/packages/toolpack-sdk/src/providers/config.ts index 56d4893..25e1603 100644 --- a/packages/toolpack-sdk/src/providers/config.ts +++ b/packages/toolpack-sdk/src/providers/config.ts @@ -8,6 +8,8 @@ const CONFIG_FILENAME = 'toolpack.config.json'; // Types // ============================================================================ +export type ConfirmationLevel = 'high' | 'medium'; + export interface OllamaModelConfig { /** Model name as used by Ollama, e.g. 'llama3', 'phi3:mini' */ model: string; @@ -15,6 +17,22 @@ export interface OllamaModelConfig { label?: string; } +export interface HitlConfig { + /** Master switch. Default: true */ + enabled?: boolean; + /** Confirmation mode. Default: 'all' */ + confirmationMode?: 'off' | 'high-only' | 'all'; + /** Bypass rules for specific tools, categories, or risk levels */ + bypass?: { + /** Tool keys to bypass (e.g. ["exec.run", "fs.delete_file"]) */ + tools?: string[]; + /** Categories to bypass (e.g. ["exec-tools"]) */ + categories?: string[]; + /** Risk levels to bypass (e.g. ["medium"]) */ + levels?: ConfirmationLevel[]; + }; +} + export interface ToolpackConfig { /** Optional override system prompt for the AIClient */ systemPrompt?: string; @@ -40,6 +58,9 @@ export interface ToolpackConfig { /** Log file path. Default: 'toolpack-sdk.log' in CWD */ filePath?: string; }; + + /** Human-in-the-loop configuration for tool confirmation */ + hitl?: HitlConfig; } // ============================================================================ @@ -125,3 +146,135 @@ export function getOllamaBaseUrl(configPath?: string): string { const config = getToolpackConfig(configPath); return config.ollama?.baseUrl || 'http://localhost:11434'; } + +// ============================================================================ +// HITL Bypass Helpers +// ============================================================================ + +export type BypassRuleType = 'tool' | 'category' | 'level'; + +export interface AddBypassRuleOptions { + /** Type of bypass rule */ + type: BypassRuleType; + /** Value to bypass (tool name, category, or level) */ + value: string; + /** Optional config path. If not provided, uses local config or creates one */ + configPath?: string; +} + +/** + * Add a bypass rule to the HITL config and persist it to the config file. + * This is useful for implementing "Allow Always" functionality. + * + * @example + * // Bypass a specific tool + * await addBypassRule({ type: 'tool', value: 'fs.write_file' }); + * + * // Bypass all medium-risk tools + * await addBypassRule({ type: 'level', value: 'medium' }); + * + * // Bypass an entire category + * await addBypassRule({ type: 'category', value: 'exec-tools' }); + */ +export async function addBypassRule(options: AddBypassRuleOptions): Promise { + const { type, value, configPath: explicitPath } = options; + + // Determine config file path + let configPath = explicitPath || discoverConfigPath(); + + // If no config exists, create one in CWD + if (!configPath) { + configPath = path.join(process.cwd(), CONFIG_FILENAME); + } + + // Load existing config or create empty one + let config: ToolpackConfig = loadConfig(configPath) || {}; + + // Ensure hitl config exists + if (!config.hitl) { + config.hitl = {}; + } + + // Ensure bypass section exists + if (!config.hitl.bypass) { + config.hitl.bypass = {}; + } + + // Add the bypass rule based on type + switch (type) { + case 'tool': + if (!config.hitl.bypass.tools) { + config.hitl.bypass.tools = []; + } + if (!config.hitl.bypass.tools.includes(value)) { + config.hitl.bypass.tools.push(value); + } + break; + case 'category': + if (!config.hitl.bypass.categories) { + config.hitl.bypass.categories = []; + } + if (!config.hitl.bypass.categories.includes(value)) { + config.hitl.bypass.categories.push(value); + } + break; + case 'level': + if (!config.hitl.bypass.levels) { + config.hitl.bypass.levels = []; + } + const level = value as ConfirmationLevel; + if (!config.hitl.bypass.levels.includes(level)) { + config.hitl.bypass.levels.push(level); + } + break; + } + + // Write config back to file + fs.writeFileSync(configPath, JSON.stringify(config, null, 4), 'utf-8'); + + // Clear cache so next read gets updated config + reloadToolpackConfig(); +} + +/** + * Remove a bypass rule from the HITL config. + * + * @example + * await removeBypassRule({ type: 'tool', value: 'fs.write_file' }); + */ +export async function removeBypassRule(options: AddBypassRuleOptions): Promise { + const { type, value, configPath: explicitPath } = options; + + // Determine config file path + const configPath = explicitPath || discoverConfigPath(); + if (!configPath) return; // No config to modify + + // Load existing config + const config = loadConfig(configPath); + if (!config?.hitl?.bypass) return; // No bypass rules to remove + + // Remove the bypass rule based on type + switch (type) { + case 'tool': + if (config.hitl.bypass.tools) { + config.hitl.bypass.tools = config.hitl.bypass.tools.filter(t => t !== value); + } + break; + case 'category': + if (config.hitl.bypass.categories) { + config.hitl.bypass.categories = config.hitl.bypass.categories.filter(c => c !== value); + } + break; + case 'level': + if (config.hitl.bypass.levels) { + config.hitl.bypass.levels = config.hitl.bypass.levels.filter(l => l !== value); + } + break; + } + + // Write config back to file + fs.writeFileSync(configPath, JSON.stringify(config, null, 4), 'utf-8'); + + // Clear cache + reloadToolpackConfig(); +} diff --git a/packages/toolpack-sdk/src/providers/index.ts b/packages/toolpack-sdk/src/providers/index.ts index 947c7cb..a84a8ca 100644 --- a/packages/toolpack-sdk/src/providers/index.ts +++ b/packages/toolpack-sdk/src/providers/index.ts @@ -9,10 +9,15 @@ export { reloadToolpackConfig, getOllamaProviderEntries, getOllamaBaseUrl, + addBypassRule, + removeBypassRule, } from './config.js'; export type { ToolpackConfig, + HitlConfig, OllamaProviderEntry, + BypassRuleType, + AddBypassRuleOptions, } from "./config.js"; // Ollama (local LLM) export * from './ollama/index.js'; diff --git a/packages/toolpack-sdk/src/toolpack.ts b/packages/toolpack-sdk/src/toolpack.ts index c48d803..6162460 100644 --- a/packages/toolpack-sdk/src/toolpack.ts +++ b/packages/toolpack-sdk/src/toolpack.ts @@ -100,10 +100,30 @@ export interface ToolpackInitConfig { * Optional Knowledge instance for RAG (Retrieval-Augmented Generation). * When provided, the knowledge base will be registered as a tool that the AI can use to search documentation. * Can be null if initialization fails - will be gracefully skipped. - * + * * Accepts any object with a `toTool()` method (e.g. `Knowledge` from `@toolpack-sdk/knowledge`). */ knowledge?: KnowledgeInstance | null; + + /** + * Human-in-the-loop configuration for tool confirmation. + * Default: 'all' when onToolConfirm is provided, 'off' otherwise. + */ + confirmationMode?: 'off' | 'high-only' | 'all'; + + /** + * Callback for handling tool confirmation requests. + * Called before executing tools that have confirmation metadata set. + * If not provided, HITL is disabled regardless of confirmationMode. + */ + onToolConfirm?: ( + tool: import('./tools/types.js').ToolDefinition, + args: Record, + context: { roundNumber: number; conversationId?: string } + ) => Promise; + + /** Optional conversation ID for tracking context across confirmations */ + conversationId?: string; } /** @@ -334,7 +354,22 @@ export class Toolpack extends EventEmitter { } } - // 4. Initialize Client + // 4. Prepare HITL config (merge file-based with programmatic overrides) + const hitlConfig = fullConfig.hitl || {}; + // Programmatic confirmationMode takes precedence over file config + if (config.confirmationMode !== undefined) { + hitlConfig.confirmationMode = config.confirmationMode; + } + // Enable HITL by default if onToolConfirm is provided and no explicit enabled setting + if (hitlConfig.enabled === undefined && config.onToolConfirm) { + hitlConfig.enabled = true; + } + // Default confirmationMode to 'all' when onToolConfirm is provided + if (hitlConfig.confirmationMode === undefined && config.onToolConfirm) { + hitlConfig.confirmationMode = 'all'; + } + + // 5. Initialize Client const client = new AIClient({ providers, defaultProvider: defaultProviderName, @@ -342,6 +377,9 @@ export class Toolpack extends EventEmitter { toolsConfig: registry.getConfig(), systemPrompt: systemPrompt, disableBaseContext: disableBaseContext, + hitlConfig: Object.keys(hitlConfig).length > 0 ? hitlConfig : undefined, + onToolConfirm: config.onToolConfirm, + conversationId: config.conversationId, }); const instance = new Toolpack(client, defaultProviderName, modeRegistry); @@ -529,6 +567,23 @@ export class Toolpack extends EventEmitter { return this.client; } + /** + * Reload configuration from the config file. + * This updates the HITL config in the running instance. + * Call this after modifying config (e.g., bypass rules) to apply changes immediately. + */ + reloadConfig(configPath?: string): void { + const { loadConfig, discoverConfigPath } = require('./providers/config.js'); + const path = configPath || discoverConfigPath(); + if (path) { + const config = loadConfig(path); + if (config?.hitl) { + this.client.updateHitlConfig(config.hitl); + } + // Future: Add other config reloading here as needed + } + } + /** * Get the WorkflowExecutor instance. * Useful for workflow events and approval flows. diff --git a/packages/toolpack-sdk/src/tools/cloud-tools/tools/deploy/index.ts b/packages/toolpack-sdk/src/tools/cloud-tools/tools/deploy/index.ts index a989b6d..6f7568f 100644 --- a/packages/toolpack-sdk/src/tools/cloud-tools/tools/deploy/index.ts +++ b/packages/toolpack-sdk/src/tools/cloud-tools/tools/deploy/index.ts @@ -37,4 +37,9 @@ export const cloudDeployTool: ToolDefinition = { return `Cloud deployment error: ${error instanceof Error ? error.message : String(error)}`; } }, + confirmation: { + level: 'high', + reason: 'This will deploy to production (live site).', + showArgs: ['siteId', 'dir'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/coding-tools/tools/multi-file-edit/index.ts b/packages/toolpack-sdk/src/tools/coding-tools/tools/multi-file-edit/index.ts index 56f958d..5be0930 100644 --- a/packages/toolpack-sdk/src/tools/coding-tools/tools/multi-file-edit/index.ts +++ b/packages/toolpack-sdk/src/tools/coding-tools/tools/multi-file-edit/index.ts @@ -114,4 +114,9 @@ export const codingMultiFileEditTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will modify multiple source code files atomically.', + showArgs: ['edits'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/coding-tools/tools/refactor-rename/index.ts b/packages/toolpack-sdk/src/tools/coding-tools/tools/refactor-rename/index.ts index e92ea31..c659f06 100644 --- a/packages/toolpack-sdk/src/tools/coding-tools/tools/refactor-rename/index.ts +++ b/packages/toolpack-sdk/src/tools/coding-tools/tools/refactor-rename/index.ts @@ -173,4 +173,9 @@ export const codingRefactorRenameTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will rename a symbol across multiple files, rewriting source code without backup.', + showArgs: ['symbol', 'newName'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/config-loader.ts b/packages/toolpack-sdk/src/tools/config-loader.ts index a139452..b17a895 100644 --- a/packages/toolpack-sdk/src/tools/config-loader.ts +++ b/packages/toolpack-sdk/src/tools/config-loader.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { ToolsConfig, DEFAULT_TOOLS_CONFIG } from "./types.js"; import { logDebug } from '../providers/provider-logger.js'; import { McpToolsConfig } from './mcp-tools/index.js'; +import type { HitlConfig } from '../providers/config.js'; const CONFIG_FILENAME = 'toolpack.config.json'; @@ -14,6 +15,7 @@ export interface FullConfig { baseContext?: boolean; modeOverrides?: Record; mcp?: McpToolsConfig; + hitl?: HitlConfig; } /** diff --git a/packages/toolpack-sdk/src/tools/db-tools/tools/delete/index.ts b/packages/toolpack-sdk/src/tools/db-tools/tools/delete/index.ts index 96818d4..e142671 100644 --- a/packages/toolpack-sdk/src/tools/db-tools/tools/delete/index.ts +++ b/packages/toolpack-sdk/src/tools/db-tools/tools/delete/index.ts @@ -23,4 +23,9 @@ export const dbDeleteTool: ToolDefinition = { return `Database delete error: ${error instanceof Error ? error.message : String(error)}`; } }, + confirmation: { + level: 'high', + reason: 'This will permanently delete rows from the database.', + showArgs: ['table', 'where'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/db-tools/tools/insert/index.ts b/packages/toolpack-sdk/src/tools/db-tools/tools/insert/index.ts index e3be87d..90722dc 100644 --- a/packages/toolpack-sdk/src/tools/db-tools/tools/insert/index.ts +++ b/packages/toolpack-sdk/src/tools/db-tools/tools/insert/index.ts @@ -31,4 +31,9 @@ export const dbInsertTool: ToolDefinition = { return `Database insert error: ${error instanceof Error ? error.message : String(error)}`; } }, + confirmation: { + level: 'medium', + reason: 'This will insert rows into the database, creating permanent records.', + showArgs: ['table', 'data'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/db-tools/tools/update/index.ts b/packages/toolpack-sdk/src/tools/db-tools/tools/update/index.ts index fb29f90..ffb0d77 100644 --- a/packages/toolpack-sdk/src/tools/db-tools/tools/update/index.ts +++ b/packages/toolpack-sdk/src/tools/db-tools/tools/update/index.ts @@ -31,4 +31,9 @@ export const dbUpdateTool: ToolDefinition = { return `Database update error: ${error instanceof Error ? error.message : String(error)}`; } }, + confirmation: { + level: 'high', + reason: 'This will update database rows, potentially affecting multiple records.', + showArgs: ['table', 'data', 'where'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/diff-tools/tools/apply/index.ts b/packages/toolpack-sdk/src/tools/diff-tools/tools/apply/index.ts index 66325cd..f304a4a 100644 --- a/packages/toolpack-sdk/src/tools/diff-tools/tools/apply/index.ts +++ b/packages/toolpack-sdk/src/tools/diff-tools/tools/apply/index.ts @@ -27,4 +27,9 @@ export const diffApplyTool: ToolDefinition = { return `Error applying patch: ${error instanceof Error ? error.message : String(error)}`; } }, + confirmation: { + level: 'high', + reason: 'This will apply a patch to files, which may corrupt them if the patch doesn\'t match.', + showArgs: ['path'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/exec-tools/tools/kill/index.ts b/packages/toolpack-sdk/src/tools/exec-tools/tools/kill/index.ts index c85fbc1..c9208a5 100644 --- a/packages/toolpack-sdk/src/tools/exec-tools/tools/kill/index.ts +++ b/packages/toolpack-sdk/src/tools/exec-tools/tools/kill/index.ts @@ -29,4 +29,9 @@ export const execKillTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'medium', + reason: 'This will terminate a running process.', + showArgs: ['process_id'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/exec-tools/tools/run-background/index.ts b/packages/toolpack-sdk/src/tools/exec-tools/tools/run-background/index.ts index 8ea43fc..c69cd8a 100644 --- a/packages/toolpack-sdk/src/tools/exec-tools/tools/run-background/index.ts +++ b/packages/toolpack-sdk/src/tools/exec-tools/tools/run-background/index.ts @@ -41,4 +41,9 @@ export const execRunBackgroundTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will spawn a background process that runs unsupervised.', + showArgs: ['command'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/exec-tools/tools/run-shell/index.ts b/packages/toolpack-sdk/src/tools/exec-tools/tools/run-shell/index.ts index 6588306..73f5fb0 100644 --- a/packages/toolpack-sdk/src/tools/exec-tools/tools/run-shell/index.ts +++ b/packages/toolpack-sdk/src/tools/exec-tools/tools/run-shell/index.ts @@ -47,4 +47,9 @@ export const execRunShellTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will execute a shell command with explicit shell context.', + showArgs: ['command'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/exec-tools/tools/run/index.ts b/packages/toolpack-sdk/src/tools/exec-tools/tools/run/index.ts index 1a5629c..9122c7e 100644 --- a/packages/toolpack-sdk/src/tools/exec-tools/tools/run/index.ts +++ b/packages/toolpack-sdk/src/tools/exec-tools/tools/run/index.ts @@ -37,4 +37,9 @@ export const execRunTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will execute a shell command on the host system.', + showArgs: ['command'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/append-file/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/append-file/index.ts index 2f50dea..8c973db 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/append-file/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/append-file/index.ts @@ -32,4 +32,9 @@ export const fsAppendFileTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'medium', + reason: 'This will modify the file by appending content.', + showArgs: ['path'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/batch-write/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/batch-write/index.ts index b821f5d..222de10 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/batch-write/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/batch-write/index.ts @@ -88,4 +88,9 @@ export const fsBatchWriteTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will overwrite multiple files at once.', + showArgs: ['files'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/copy/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/copy/index.ts index 8f77df2..26be506 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/copy/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/copy/index.ts @@ -54,4 +54,9 @@ export const fsCopyTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'medium', + reason: 'This will copy files or directories, potentially overwriting the destination.', + showArgs: ['path', 'new_path'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-dir/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-dir/index.ts index 58325e2..22dd938 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-dir/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-dir/index.ts @@ -35,4 +35,9 @@ export const fsDeleteDirTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will recursively delete the directory and all its contents. This action cannot be undone.', + showArgs: ['path'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/index.ts index cf0dff5..e8db629 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/index.ts @@ -31,4 +31,9 @@ export const fsDeleteFileTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will permanently delete the file. This action cannot be undone.', + showArgs: ['path'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/schema.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/schema.ts index 9ee82ed..8cdd568 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/schema.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/delete-file/schema.ts @@ -2,7 +2,7 @@ import { ToolParameters } from '../../../types.js'; export const name = 'fs.delete_file'; export const displayName = 'Delete File'; -export const description = 'Delete a file at the given path. Does not delete directories.'; +export const description = 'Remove/delete a file from the filesystem. Does not delete directories.'; export const category = 'filesystem'; export const parameters: ToolParameters = { diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/move/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/move/index.ts index 8f023b2..574b28a 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/move/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/move/index.ts @@ -35,4 +35,9 @@ export const fsMoveTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will move/rename the file or directory, potentially overwriting the destination.', + showArgs: ['path', 'new_path'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/replace-in-file/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/replace-in-file/index.ts index ee75e29..18f3d8d 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/replace-in-file/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/replace-in-file/index.ts @@ -53,4 +53,9 @@ export const fsReplaceInFileTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will perform a global find-and-replace operation that may corrupt the file if the pattern is incorrect.', + showArgs: ['path', 'search', 'replace'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/index.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/index.ts index ddbe70c..1b4f6c6 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/index.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/index.ts @@ -34,4 +34,9 @@ export const fsWriteFileTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will overwrite the entire file contents.', + showArgs: ['path'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/schema.ts b/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/schema.ts index 77a7ae6..4972ea2 100644 --- a/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/schema.ts +++ b/packages/toolpack-sdk/src/tools/fs-tools/tools/write-file/schema.ts @@ -2,7 +2,7 @@ import { ToolParameters } from '../../../types.js'; export const name = 'fs.write_file'; export const displayName = 'Write File'; -export const description = 'Write content to a file. Creates parent directories if they do not exist. Overwrites existing files.'; +export const description = 'Write content to a file. Creates parent directories if they do not exist. Overwrites existing files. IMPORTANT: Do NOT use this to delete/remove files - use fs.delete_file for that.'; export const category = 'filesystem'; export const parameters: ToolParameters = { diff --git a/packages/toolpack-sdk/src/tools/git-tools/tools/checkout/index.ts b/packages/toolpack-sdk/src/tools/git-tools/tools/checkout/index.ts index 7c50352..0106529 100644 --- a/packages/toolpack-sdk/src/tools/git-tools/tools/checkout/index.ts +++ b/packages/toolpack-sdk/src/tools/git-tools/tools/checkout/index.ts @@ -19,4 +19,9 @@ export const gitCheckoutTool: ToolDefinition = { return `Error checking out branch: ${error instanceof Error ? error.message : String(error)}`; } }, + confirmation: { + level: 'medium', + reason: 'This will switch branches, potentially losing uncommitted changes.', + showArgs: ['branch'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/git-tools/tools/commit/index.ts b/packages/toolpack-sdk/src/tools/git-tools/tools/commit/index.ts index 9af13c6..437b5cb 100644 --- a/packages/toolpack-sdk/src/tools/git-tools/tools/commit/index.ts +++ b/packages/toolpack-sdk/src/tools/git-tools/tools/commit/index.ts @@ -24,4 +24,9 @@ export const gitCommitTool: ToolDefinition = { return `Error committing changes: ${error instanceof Error ? error.message : String(error)}`; } }, + confirmation: { + level: 'medium', + reason: 'This will create a permanent commit in the repository history.', + showArgs: ['message'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/http-tools/tools/delete/index.ts b/packages/toolpack-sdk/src/tools/http-tools/tools/delete/index.ts index b2147c5..015a8fc 100644 --- a/packages/toolpack-sdk/src/tools/http-tools/tools/delete/index.ts +++ b/packages/toolpack-sdk/src/tools/http-tools/tools/delete/index.ts @@ -38,4 +38,9 @@ export const httpDeleteTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will send an HTTP DELETE request to destroy remote resources.', + showArgs: ['url'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/http-tools/tools/post/index.ts b/packages/toolpack-sdk/src/tools/http-tools/tools/post/index.ts index dbf98b3..c26ed1a 100644 --- a/packages/toolpack-sdk/src/tools/http-tools/tools/post/index.ts +++ b/packages/toolpack-sdk/src/tools/http-tools/tools/post/index.ts @@ -50,4 +50,9 @@ export const httpPostTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will send an HTTP POST request with arbitrary payload.', + showArgs: ['url', 'body'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/http-tools/tools/put/index.ts b/packages/toolpack-sdk/src/tools/http-tools/tools/put/index.ts index d284730..e5c0ce5 100644 --- a/packages/toolpack-sdk/src/tools/http-tools/tools/put/index.ts +++ b/packages/toolpack-sdk/src/tools/http-tools/tools/put/index.ts @@ -49,4 +49,9 @@ export const httpPutTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'high', + reason: 'This will send an HTTP PUT request to overwrite remote resources.', + showArgs: ['url', 'body'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/system-tools/tools/set-env/index.ts b/packages/toolpack-sdk/src/tools/system-tools/tools/set-env/index.ts index 8161104..549049f 100644 --- a/packages/toolpack-sdk/src/tools/system-tools/tools/set-env/index.ts +++ b/packages/toolpack-sdk/src/tools/system-tools/tools/set-env/index.ts @@ -28,4 +28,9 @@ export const systemSetEnvTool: ToolDefinition = { parameters, category, execute, + confirmation: { + level: 'medium', + reason: 'This will modify the process environment, affecting all subsequent operations.', + showArgs: ['key', 'value'], + }, }; diff --git a/packages/toolpack-sdk/src/tools/types.ts b/packages/toolpack-sdk/src/tools/types.ts index 7e78670..0936a5a 100644 --- a/packages/toolpack-sdk/src/tools/types.ts +++ b/packages/toolpack-sdk/src/tools/types.ts @@ -29,6 +29,16 @@ export interface ToolContext { log: (message: string) => void; } +// ── Tool Confirmation (HITL) ───────────────────────────────── + +export type ConfirmationLevel = 'high' | 'medium'; + +export interface ToolConfirmation { + level: ConfirmationLevel; + reason: string; // Shown to user: "This will permanently delete the file." + showArgs?: string[]; // Which args to surface in the prompt (e.g. ['path', 'table']) +} + export interface ToolDefinition { name: string; displayName: string; @@ -42,6 +52,12 @@ export interface ToolDefinition { * Default: true */ cacheable?: boolean; + /** + * Human-in-the-loop confirmation configuration. + * If set, the tool will require user confirmation before execution. + * Note: Only effective when onToolConfirm callback is provided to AIClient. + */ + confirmation?: ToolConfirmation; } /** diff --git a/packages/toolpack-sdk/src/types/index.ts b/packages/toolpack-sdk/src/types/index.ts index 3588441..6cefc44 100644 --- a/packages/toolpack-sdk/src/types/index.ts +++ b/packages/toolpack-sdk/src/types/index.ts @@ -188,6 +188,36 @@ export interface ToolLogEvent { timestamp: number; } +// ── Human-in-the-Loop (HITL) Types ──────────────────────────── + +import type { ConfirmationLevel, ToolDefinition } from '../tools/types.js'; + +export type ConfirmationDecision = + | { action: 'allow' } + | { action: 'deny'; reason?: string } + | { action: 'modify'; args: Record }; + +export interface ToolConfirmationRequestedEvent { + tool: ToolDefinition; + args: Record; + level: ConfirmationLevel; + reason: string; +} + +export interface ToolConfirmationResolvedEvent extends ToolConfirmationRequestedEvent { + decision: ConfirmationDecision; +} + +/** + * Callback type for handling tool confirmation requests. + * Called before executing tools that have confirmation metadata set. + */ +export type OnToolConfirmCallback = ( + tool: ToolDefinition, + args: Record, + context: { roundNumber: number; conversationId?: string } +) => Promise; + /** * Information about a single model available from a provider. */ From 5fe1892451f9be3a53a1e3fddad76f2ccd4d8424 Mon Sep 17 00:00:00 2001 From: Fentrex Date: Thu, 9 Apr 2026 16:47:40 +0530 Subject: [PATCH 2/4] Review items cleared --- packages/toolpack-sdk/src/providers/config.ts | 195 +++++++----- packages/toolpack-sdk/src/toolpack.ts | 15 +- packages/toolpack-sdk/tests/unit/hitl.test.ts | 299 ++++++++++++++++++ 3 files changed, 430 insertions(+), 79 deletions(-) create mode 100644 packages/toolpack-sdk/tests/unit/hitl.test.ts diff --git a/packages/toolpack-sdk/src/providers/config.ts b/packages/toolpack-sdk/src/providers/config.ts index 25e1603..138a25a 100644 --- a/packages/toolpack-sdk/src/providers/config.ts +++ b/packages/toolpack-sdk/src/providers/config.ts @@ -1,9 +1,28 @@ import * as fs from 'fs'; import * as path from 'path'; import { ModeConfig } from '../modes/mode-types.js'; +import { SDKError } from '../errors/index.js'; const CONFIG_FILENAME = 'toolpack.config.json'; +// Simple file lock for config writes to prevent race conditions +const configLocks = new Map>(); + +async function acquireConfigLock(configPath: string): Promise<() => void> { + while (configLocks.has(configPath)) { + await configLocks.get(configPath); + } + let release: () => void; + const lockPromise = new Promise((resolve) => { + release = resolve; + }); + configLocks.set(configPath, lockPromise); + return () => { + configLocks.delete(configPath); + release!(); + }; +} + // ============================================================================ // Types // ============================================================================ @@ -181,59 +200,74 @@ export async function addBypassRule(options: AddBypassRuleOptions): Promise t !== value); - } - break; - case 'category': - if (config.hitl.bypass.categories) { - config.hitl.bypass.categories = config.hitl.bypass.categories.filter(c => c !== value); - } - break; - case 'level': - if (config.hitl.bypass.levels) { - config.hitl.bypass.levels = config.hitl.bypass.levels.filter(l => l !== value); - } - break; - } + // Acquire lock to prevent concurrent writes + const release = await acquireConfigLock(configPath); - // Write config back to file - fs.writeFileSync(configPath, JSON.stringify(config, null, 4), 'utf-8'); - - // Clear cache - reloadToolpackConfig(); + try { + // Load existing config + const config = loadConfig(configPath); + if (!config?.hitl?.bypass) return; // No bypass rules to remove + + // Remove the bypass rule based on type + switch (type) { + case 'tool': + if (config.hitl.bypass.tools) { + config.hitl.bypass.tools = config.hitl.bypass.tools.filter(t => t !== value); + } + break; + case 'category': + if (config.hitl.bypass.categories) { + config.hitl.bypass.categories = config.hitl.bypass.categories.filter(c => c !== value); + } + break; + case 'level': + if (config.hitl.bypass.levels) { + config.hitl.bypass.levels = config.hitl.bypass.levels.filter(l => l !== value); + } + break; + } + + // Write config back to file + try { + fs.writeFileSync(configPath, JSON.stringify(config, null, 4), 'utf-8'); + } catch (error) { + throw new SDKError( + `Failed to remove bypass rule from config file: ${error instanceof Error ? error.message : String(error)}`, + 'CONFIG_WRITE_ERROR' + ); + } + + // Clear cache + reloadToolpackConfig(); + } finally { + // Always release the lock + release(); + } } diff --git a/packages/toolpack-sdk/src/toolpack.ts b/packages/toolpack-sdk/src/toolpack.ts index 6162460..5ba7b06 100644 --- a/packages/toolpack-sdk/src/toolpack.ts +++ b/packages/toolpack-sdk/src/toolpack.ts @@ -13,7 +13,7 @@ import { OpenAIAdapter } from './providers/openai/index.js'; import { AnthropicAdapter } from './providers/anthropic/index.js'; import { GeminiAdapter } from './providers/gemini/index.js'; import { OllamaAdapter, OllamaProvider } from './providers/ollama/index.js'; -import { getOllamaBaseUrl } from './providers/config.js'; +import { getOllamaBaseUrl, loadConfig, discoverConfigPath } from './providers/config.js'; import { initLogger, logWarn,logError,logInfo } from './providers/provider-logger.js'; import { ToolRegistry } from './tools/registry.js'; import { loadToolsConfig, loadFullConfig, ToolProject } from './tools/index.js'; @@ -573,14 +573,17 @@ export class Toolpack extends EventEmitter { * Call this after modifying config (e.g., bypass rules) to apply changes immediately. */ reloadConfig(configPath?: string): void { - const { loadConfig, discoverConfigPath } = require('./providers/config.js'); const path = configPath || discoverConfigPath(); if (path) { - const config = loadConfig(path); - if (config?.hitl) { - this.client.updateHitlConfig(config.hitl); + try { + const config = loadConfig(path); + if (config?.hitl) { + this.client.updateHitlConfig(config.hitl); + } + // Future: Add other config reloading here as needed + } catch (error) { + logWarn(`[Toolpack] Failed to reload config from ${path}: ${error instanceof Error ? error.message : String(error)}`); } - // Future: Add other config reloading here as needed } } diff --git a/packages/toolpack-sdk/tests/unit/hitl.test.ts b/packages/toolpack-sdk/tests/unit/hitl.test.ts new file mode 100644 index 0000000..eca4469 --- /dev/null +++ b/packages/toolpack-sdk/tests/unit/hitl.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AIClient } from '../../src/client'; +import { ProviderAdapter, CompletionRequest, CompletionResponse, CompletionChunk, EmbeddingRequest, EmbeddingResponse } from '../../src/providers/base'; +import { ToolDefinition } from '../../src/tools/types'; +import { HitlConfig } from '../../src/providers/config'; + +// Mock provider for testing +class MockProvider implements ProviderAdapter { + async generate(request: CompletionRequest): Promise { + return { content: 'mock response' }; + } + async *stream(request: CompletionRequest): AsyncGenerator { + yield { delta: 'mock' }; + yield { finish_reason: 'stop' }; + } + async embed(request: EmbeddingRequest): Promise { + return { embeddings: [] }; + } +} + +// Helper to create a mock tool with confirmation +function createMockTool(name: string, level: 'high' | 'medium' | undefined, category: string = 'filesystem'): ToolDefinition { + return { + name, + displayName: name, + description: `Test ${name}`, + parameters: { type: 'object', properties: {}, required: [] }, + category, + execute: async () => 'executed', + ...(level && { + confirmation: { + level, + reason: `This is a ${level} risk operation`, + showArgs: ['path'], + }, + }), + }; +} + +describe('HITL - isBypassed Logic', () => { + it('should bypass when HITL config is undefined', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + }); + + const tool = createMockTool('fs.delete_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(true); + }); + + it('should bypass when HITL is explicitly disabled', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { enabled: false }, + }); + + const tool = createMockTool('fs.delete_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(true); + }); + + it('should NOT bypass when HITL enabled is undefined (default to enabled)', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { bypass: {} }, + }); + + const tool = createMockTool('fs.delete_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(false); + }); + + it('should NOT bypass when HITL is explicitly enabled', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { enabled: true }, + }); + + const tool = createMockTool('fs.delete_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(false); + }); +}); + +describe('HITL - Bypass Rule Matching', () => { + it('should bypass when tool is in bypass.tools list', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + bypass: { + tools: ['fs.write_file'], + }, + }, + }); + + const tool = createMockTool('fs.write_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(true); + }); + + it('should NOT bypass when tool is not in bypass.tools list', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + bypass: { + tools: ['fs.write_file'], + }, + }, + }); + + const tool = createMockTool('fs.delete_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(false); + }); + + it('should bypass when tool category is in bypass.categories list', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + bypass: { + categories: ['filesystem'], + }, + }, + }); + + const tool = createMockTool('fs.write_file', 'high', 'filesystem'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(true); + }); + + it('should bypass when tool level is in bypass.levels list', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + bypass: { + levels: ['high'], + }, + }, + }); + + const tool = createMockTool('fs.delete_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(true); + }); + + it('should NOT bypass when tool level does not match bypass.levels', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + bypass: { + levels: ['medium'], + }, + }, + }); + + const tool = createMockTool('fs.delete_file', 'high'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(false); + }); + + it('should handle tool without confirmation metadata', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + bypass: { + levels: ['high'], + }, + }, + }); + + const tool = createMockTool('fs.read_file', undefined, 'filesystem'); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(false); // No confirmation metadata = not bypassed, executes normally + }); +}); + +describe('HITL - Confirmation Mode Filtering', () => { + it('should bypass all tools when mode is "off"', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + confirmationMode: 'off', + }, + }); + + const highRiskTool = createMockTool('fs.delete_file', 'high'); + const mediumRiskTool = createMockTool('db.insert', 'medium'); + + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(highRiskTool)).toBe(true); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(mediumRiskTool)).toBe(true); + }); + + it('should bypass medium-risk tools when mode is "high-only"', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + confirmationMode: 'high-only', + }, + }); + + const highRiskTool = createMockTool('fs.delete_file', 'high'); + const mediumRiskTool = createMockTool('db.insert', 'medium'); + + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(highRiskTool)).toBe(false); // High risk = confirm + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(mediumRiskTool)).toBe(true); // Medium risk = bypass + }); + + it('should confirm all risk levels when mode is "all" (default)', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + confirmationMode: 'all', + }, + }); + + const highRiskTool = createMockTool('fs.delete_file', 'high'); + const mediumRiskTool = createMockTool('db.insert', 'medium'); + + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(highRiskTool)).toBe(false); + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(mediumRiskTool)).toBe(false); + }); +}); + +describe('HITL - updateHitlConfig', () => { + it('should update HITL config dynamically', () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig: { + enabled: true, + bypass: {}, + }, + }); + + const tool = createMockTool('fs.write_file', 'high'); + + // Initially not bypassed + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(false); + + // Update config to add bypass rule + client.updateHitlConfig({ + enabled: true, + bypass: { + tools: ['fs.write_file'], + }, + }); + + // Now should be bypassed + // @ts-ignore - accessing private method for testing + expect(client.isBypassed(tool)).toBe(true); + }); + + it('should get current HITL config', () => { + const hitlConfig: HitlConfig = { + enabled: true, + confirmationMode: 'high-only', + bypass: { + tools: ['fs.write_file'], + }, + }; + + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + hitlConfig, + }); + + expect(client.getHitlConfig()).toEqual(hitlConfig); + }); +}); From 550b2ecc4731e93ebc8b56ba553e7189ab364799 Mon Sep 17 00:00:00 2001 From: Fentrex Date: Thu, 9 Apr 2026 22:09:22 +0530 Subject: [PATCH 3/4] README.md files updated --- README.md | 44 +++++++++++++++++++++++++++++++++ packages/toolpack-sdk/README.md | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/README.md b/README.md index befe16e..bcf3e2b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A unified TypeScript/Node.js SDK for building AI-powered applications with multi - **Embeddings** — Vector generation for RAG applications (OpenAI, Gemini, Ollama) - **Workflow Engine** — AI-driven planning and step-by-step task execution with progress events - **Mode System** — Built-in Agent and Chat modes, plus `createMode()` for custom modes with tool filtering +- **HITL Confirmation** — Human-in-the-loop approval for high-risk operations with configurable bypass rules - **Custom Providers** — Bring your own provider by implementing the `ProviderAdapter` interface - **79 Built-in Tools** across 10 categories: - **MCP Tool Server Integration** — dynamically bridge external Model Context Protocol servers into Toolpack as first-class tools via `createMcpToolProject()` and `disconnectMcpToolProject()`. @@ -766,6 +767,49 @@ Create a `toolpack.config.json` in your project root: | `enabledTools` | string[] | `[]` | Whitelist specific tools (empty = all) | | `enabledToolCategories` | string[] | `[]` | Whitelist categories (empty = all) | +### HITL (Human-in-the-Loop) Configuration + +Configure user confirmation for high-risk tool operations: + +```json +{ + "hitl": { + "enabled": true, + "confirmationMode": "all", + "bypass": { + "tools": ["fs.write_file"], + "categories": ["filesystem"], + "levels": ["medium"] + } + } +} +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | boolean | `true` | Master switch for HITL confirmation | +| `confirmationMode` | string | `"all"` | `"off"`, `"high-only"`, or `"all"` | +| `bypass.tools` | string[] | `[]` | Tool names to bypass (e.g., `["fs.write_file"]`) | +| `bypass.categories` | string[] | `[]` | Categories to bypass (e.g., `["filesystem"]`) | +| `bypass.levels` | string[] | `[]` | Risk levels to bypass (`["high"]` or `["medium"]`) | + +**Programmatic API:** + +```typescript +import { addBypassRule, removeBypassRule } from 'toolpack-sdk'; + +// Add bypass rule +await addBypassRule({ type: 'tool', value: 'fs.delete_file' }); + +// Remove bypass rule +await removeBypassRule({ type: 'tool', value: 'fs.delete_file' }); + +// Reload config to apply changes +toolpack.reloadConfig(); +``` + +See the [HITL documentation](https://toolpacksdk.com/guides/hitl-confirmation) for detailed configuration options and best practices. + #### Web Search Providers The `web.search` tool supports multiple search backends with automatic fallback: diff --git a/packages/toolpack-sdk/README.md b/packages/toolpack-sdk/README.md index 2984619..8e1aadd 100644 --- a/packages/toolpack-sdk/README.md +++ b/packages/toolpack-sdk/README.md @@ -16,6 +16,7 @@ A unified TypeScript/Node.js SDK for building AI-powered applications with multi - **Embeddings** — Vector generation for RAG applications (OpenAI, Gemini, Ollama) - **Workflow Engine** — AI-driven planning and step-by-step task execution with progress events - **Mode System** — Built-in Agent and Chat modes, plus `createMode()` for custom modes with tool filtering +- **HITL Confirmation** — Human-in-the-loop approval for high-risk operations with configurable bypass rules - **Custom Providers** — Bring your own provider by implementing the `ProviderAdapter` interface - **79 Built-in Tools** across 10 categories: - **MCP Tool Server Integration** — dynamically bridge external Model Context Protocol servers into Toolpack as first-class tools via `createMcpToolProject()` and `disconnectMcpToolProject()`. @@ -766,6 +767,49 @@ Create a `toolpack.config.json` in your project root: | `enabledTools` | string[] | `[]` | Whitelist specific tools (empty = all) | | `enabledToolCategories` | string[] | `[]` | Whitelist categories (empty = all) | +### HITL (Human-in-the-Loop) Configuration + +Configure user confirmation for high-risk tool operations: + +```json +{ + "hitl": { + "enabled": true, + "confirmationMode": "all", + "bypass": { + "tools": ["fs.write_file"], + "categories": ["filesystem"], + "levels": ["medium"] + } + } +} +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | boolean | `true` | Master switch for HITL confirmation | +| `confirmationMode` | string | `"all"` | `"off"`, `"high-only"`, or `"all"` | +| `bypass.tools` | string[] | `[]` | Tool names to bypass (e.g., `["fs.write_file"]`) | +| `bypass.categories` | string[] | `[]` | Categories to bypass (e.g., `["filesystem"]`) | +| `bypass.levels` | string[] | `[]` | Risk levels to bypass (`["high"]` or `["medium"]`) | + +**Programmatic API:** + +```typescript +import { addBypassRule, removeBypassRule } from 'toolpack-sdk'; + +// Add bypass rule +await addBypassRule({ type: 'tool', value: 'fs.delete_file' }); + +// Remove bypass rule +await removeBypassRule({ type: 'tool', value: 'fs.delete_file' }); + +// Reload config to apply changes +toolpack.reloadConfig(); +``` + +See the [HITL documentation](https://toolpacksdk.com/guides/hitl-confirmation) for detailed configuration options and best practices. + #### Web Search Providers The `web.search` tool supports multiple search backends with automatic fallback: From 00973fb4adea6b7723bdb50fde6112475ade33d8 Mon Sep 17 00:00:00 2001 From: Fentrex Date: Thu, 9 Apr 2026 22:20:02 +0530 Subject: [PATCH 4/4] Lint errors fixed --- packages/toolpack-sdk/src/providers/config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/toolpack-sdk/src/providers/config.ts b/packages/toolpack-sdk/src/providers/config.ts index 138a25a..306e038 100644 --- a/packages/toolpack-sdk/src/providers/config.ts +++ b/packages/toolpack-sdk/src/providers/config.ts @@ -211,7 +211,7 @@ export async function addBypassRule(options: AddBypassRuleOptions): Promise