Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions packages/toolpack-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down Expand Up @@ -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:
Expand Down
123 changes: 119 additions & 4 deletions packages/toolpack-sdk/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,8 +26,8 @@
if (!shouldLog('debug')) return;
logDebug(`[AIClient][${requestId}] Messages (${messages.length}):`);
messages.forEach((m, i) => {
const preview = safePreview((m as any).content, 300);

Check warning on line 29 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 20.x)

Unexpected any. Specify a different type

Check warning on line 29 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 22.x)

Unexpected any. Specify a different type

Check warning on line 29 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 22.x)

Unexpected any. Specify a different type

Check warning on line 29 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 20.x)

Unexpected any. Specify a different type

Check warning on line 29 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 22.x)

Unexpected any. Specify a different type

Check warning on line 29 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 20.x)

Unexpected any. Specify a different type
logDebug(`[AIClient][${requestId}] #${i} role=${(m as any).role} content=${preview}`);

Check warning on line 30 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 20.x)

Unexpected any. Specify a different type

Check warning on line 30 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest, 22.x)

Unexpected any. Specify a different type

Check warning on line 30 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 22.x)

Unexpected any. Specify a different type

Check warning on line 30 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest, 20.x)

Unexpected any. Specify a different type

Check warning on line 30 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 22.x)

Unexpected any. Specify a different type

Check warning on line 30 in packages/toolpack-sdk/src/client/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest, 20.x)

Unexpected any. Specify a different type
});
}

Expand Down Expand Up @@ -189,6 +190,12 @@
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 {
Expand All @@ -204,6 +211,10 @@
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();
Expand All @@ -219,13 +230,43 @@
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) {
this.bm25Engine.index(this.toolRegistry.getAll());
}
}

/**
* 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.
*/
Expand All @@ -248,6 +289,21 @@
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.
*/
Expand Down Expand Up @@ -420,6 +476,7 @@

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
Expand Down Expand Up @@ -668,7 +725,9 @@
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 };
Expand Down Expand Up @@ -1281,12 +1340,68 @@
}

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
Expand Down
Loading
Loading