6bb424cddc
Add 8 new agent-callable tools (sessions.list/history/create/delete,
agents.list, message.send, cron.list/trigger) and sanitize tool names
at the API boundary (dots → underscores) to comply with Anthropic's
`^[a-zA-Z0-9_-]{1,128}` requirement. Reverse-maps sanitized names
back to internal names for hook callbacks and tool execution.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
83 lines
2.5 KiB
TypeScript
83 lines
2.5 KiB
TypeScript
import type { ToolResult } from './types.js';
|
|
import type { ToolRegistry } from './registry.js';
|
|
import type { HookEngine } from '../hooks/engine.js';
|
|
import type { ToolPolicyContext } from './policy.js';
|
|
|
|
export interface ToolExecutorConfig {
|
|
defaultTimeoutMs?: number;
|
|
maxOutputBytes?: number;
|
|
}
|
|
|
|
export class ToolExecutor {
|
|
private registry: ToolRegistry;
|
|
private hooks: HookEngine;
|
|
private defaultTimeoutMs: number;
|
|
private maxOutputBytes: number;
|
|
|
|
constructor(registry: ToolRegistry, hooks: HookEngine, config?: ToolExecutorConfig) {
|
|
this.registry = registry;
|
|
this.hooks = hooks;
|
|
this.defaultTimeoutMs = config?.defaultTimeoutMs ?? 30_000;
|
|
this.maxOutputBytes = config?.maxOutputBytes ?? 51_200;
|
|
}
|
|
|
|
async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise<ToolResult> {
|
|
const tool = this.registry.getByApiName(toolName);
|
|
if (!tool) {
|
|
return { success: false, output: '', error: `Tool '${toolName}' not found` };
|
|
}
|
|
|
|
// Policy check (defense in depth — tools should also be filtered at listing time)
|
|
const policy = this.registry.getPolicy();
|
|
if (policy) {
|
|
const allNames = this.registry.list().map(t => t.name);
|
|
if (!policy.isAllowed(toolName, allNames, context)) {
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: `Tool '${toolName}' is not allowed by tool policy`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check hooks
|
|
const action = this.hooks.getAction(toolName);
|
|
if (action === 'confirm') {
|
|
const hookResult = await this.hooks.requestConfirmation(
|
|
toolName,
|
|
args as Record<string, unknown>,
|
|
);
|
|
if (!hookResult.approved) {
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: `Tool '${toolName}' denied by user: ${hookResult.reason ?? 'no reason'}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Execute with timeout
|
|
try {
|
|
const result = await Promise.race([
|
|
tool.execute(args),
|
|
new Promise<ToolResult>((_, reject) =>
|
|
setTimeout(() => reject(new Error(`Tool '${toolName}' timed out after ${this.defaultTimeoutMs}ms`)), this.defaultTimeoutMs)
|
|
),
|
|
]);
|
|
|
|
// Truncate output if too large
|
|
if (result.output.length > this.maxOutputBytes) {
|
|
result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]';
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
}
|