feat: add agent tools and sanitize tool names for Anthropic API

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>
This commit is contained in:
William Valentin
2026-02-07 12:23:09 -08:00
parent f0e3987d1c
commit 6bb424cddc
13 changed files with 656 additions and 124 deletions
+4 -3
View File
@@ -161,11 +161,12 @@ export class NativeAgent {
// Execute each tool call and collect results
const toolResultBlocks: unknown[] = [];
for (const tc of response.toolCalls) {
this.onToolUse?.({ type: 'start', tool: tc.name, args: tc.args });
const internalName = this.toolRegistry!.getByApiName(tc.name)?.name ?? tc.name;
this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args });
const result = await this.toolExecutor!.execute(tc.name, tc.args, this._toolPolicyContext);
const result = await this.toolExecutor!.execute(internalName, tc.args, this._toolPolicyContext);
this.onToolUse?.({ type: 'end', tool: tc.name, result });
this.onToolUse?.({ type: 'end', tool: internalName, result });
toolResultBlocks.push({
type: 'tool_result',
+53
View File
@@ -0,0 +1,53 @@
import type { Tool, ToolResult } from '../types.js';
import type { AgentConfigRegistry } from '../../agents/registry.js';
/**
* Creates an agents.list tool bound to the given AgentConfigRegistry.
* Lists all registered agent configurations with their settings.
*/
export function createAgentsListTool(registry: AgentConfigRegistry): Tool {
return {
name: 'agents.list',
description:
'List all registered agent configurations. Shows agent names, model tiers, tool profiles, and sandbox status.',
inputSchema: {
type: 'object',
properties: {},
},
execute: async (_rawArgs: unknown): Promise<ToolResult> => {
try {
const configs = registry.list();
if (configs.length === 0) {
return {
success: true,
output: 'No agent configurations registered.',
};
}
const lines = configs.map((c) => {
const parts = [`- **${c.name}**`];
if (c.modelTier) parts.push(`tier=${c.modelTier}`);
if (c.toolProfile) parts.push(`profile=${c.toolProfile}`);
if (c.sandbox) parts.push('sandboxed');
if (c.systemPrompt) {
const preview = c.systemPrompt.slice(0, 80).replace(/\n/g, ' ');
parts.push(`prompt="${preview}${c.systemPrompt.length > 80 ? '...' : ''}"`);
}
return parts.join(' | ');
});
return {
success: true,
output: `${configs.length} agent(s) registered:\n\n${lines.join('\n')}`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
}
+82
View File
@@ -0,0 +1,82 @@
import type { Tool, ToolResult } from '../types.js';
import type { CronScheduler } from '../../automation/cron.js';
/**
* Creates cron management tools bound to the given CronScheduler.
*/
export function createCronTools(scheduler: CronScheduler): Tool[] {
const cronList: Tool = {
name: 'cron.list',
description:
'List all configured cron jobs with their names and status.',
inputSchema: {
type: 'object',
properties: {},
},
execute: async (_rawArgs: unknown): Promise<ToolResult> => {
try {
const jobNames = scheduler.getJobNames();
if (jobNames.length === 0) {
return { success: true, output: 'No cron jobs configured.' };
}
const lines = jobNames.map((name) => `- ${name}`);
return {
success: true,
output: `${jobNames.length} cron job(s):\n\n${lines.join('\n')}`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const cronTrigger: Tool = {
name: 'cron.trigger',
description:
'Manually trigger a cron job by name, executing it immediately regardless of its schedule.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of the cron job to trigger',
},
},
required: ['name'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { name: string };
try {
const jobNames = scheduler.getJobNames();
if (!jobNames.includes(args.name)) {
return {
success: false,
output: '',
error: `Cron job "${args.name}" not found. Available: ${jobNames.join(', ')}`,
};
}
scheduler.triggerJob(args.name);
return {
success: true,
output: `Cron job "${args.name}" triggered.`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [cronList, cronTrigger];
}
+4
View File
@@ -15,6 +15,10 @@ export { createProcessTools, ProcessManager } from './process/index.js';
export type { ProcessManagerConfig } from './process/index.js';
export { BrowserManager, createBrowserTools } from './browser/index.js';
export type { BrowserManagerConfig } from './browser/index.js';
export { createSessionTools } from './sessions.js';
export { createAgentsListTool } from './agents-list.js';
export { createMessageSendTool } from './message-send.js';
export { createCronTools } from './cron.js';
import type { Tool } from '../types.js';
import type { MemoryStore } from '../../memory/store.js';
+73
View File
@@ -0,0 +1,73 @@
import type { Tool, ToolResult } from '../types.js';
import type { ChannelRegistry } from '../../channels/registry.js';
interface MessageSendArgs {
channel: string;
peerId: string;
text: string;
}
/**
* Creates a message.send tool bound to the given ChannelRegistry.
* Allows the agent to send messages to any registered channel.
*/
export function createMessageSendTool(channelRegistry: ChannelRegistry): Tool {
return {
name: 'message.send',
description:
'Send a message to a specific user on a specific channel. Use this to proactively reach out to users on Telegram, Discord, Slack, WhatsApp, or other connected channels.',
inputSchema: {
type: 'object',
properties: {
channel: {
type: 'string',
description: 'Channel adapter name (e.g. "telegram", "discord", "slack", "whatsapp", "webchat")',
},
peerId: {
type: 'string',
description: 'Target user or chat ID on the channel',
},
text: {
type: 'string',
description: 'Message text to send',
},
},
required: ['channel', 'peerId', 'text'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as MessageSendArgs;
try {
const adapter = channelRegistry.get(args.channel);
if (!adapter) {
const available = channelRegistry.list().map((a) => a.name);
return {
success: false,
output: '',
error: `Channel "${args.channel}" not found. Available channels: ${available.join(', ')}`,
};
}
if (adapter.status !== 'connected') {
return {
success: false,
output: '',
error: `Channel "${args.channel}" is not connected (status: ${adapter.status})`,
};
}
await adapter.send(args.peerId, { text: args.text });
return {
success: true,
output: `Message sent to ${args.peerId} on ${args.channel}.`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
}
+213
View File
@@ -0,0 +1,213 @@
import type { Tool, ToolResult } from '../types.js';
import type { SessionManager } from '../../session/manager.js';
interface SessionsListArgs {
// no args
}
interface SessionsHistoryArgs {
sessionId: string;
limit?: number;
offset?: number;
}
interface SessionsCreateArgs {
frontend: string;
userId: string;
}
interface SessionsDeleteArgs {
frontend: string;
userId: string;
}
/**
* Creates session management tools bound to the given SessionManager.
*/
export function createSessionTools(sessionManager: SessionManager): Tool[] {
const sessionsList: Tool = {
name: 'sessions.list',
description:
'List all active sessions. Returns session IDs and message counts.',
inputSchema: {
type: 'object',
properties: {},
},
execute: async (_rawArgs: unknown): Promise<ToolResult> => {
try {
const sessionIds = sessionManager.listSessions();
if (sessionIds.length === 0) {
return { success: true, output: 'No active sessions.' };
}
const sessions = sessionIds.map((id) => {
const parts = id.split(':');
const frontend = parts[0];
const userId = parts.slice(1).join(':');
const session = sessionManager.getSession(frontend, userId);
return {
id,
frontend,
userId,
messageCount: session.getHistory().length,
};
});
const lines = sessions.map(
(s) => `- **${s.id}** (${s.messageCount} messages)`,
);
return { success: true, output: lines.join('\n') };
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const sessionsHistory: Tool = {
name: 'sessions.history',
description:
'Get the message history for a specific session. Returns the messages with role and content.',
inputSchema: {
type: 'object',
properties: {
sessionId: {
type: 'string',
description: 'Session ID in "frontend:userId" format',
},
limit: {
type: 'number',
description: 'Maximum number of messages to return (default: all)',
},
offset: {
type: 'number',
description: 'Number of messages to skip from the start (default: 0)',
},
},
required: ['sessionId'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as SessionsHistoryArgs;
try {
const parts = args.sessionId.split(':');
const frontend = parts[0];
const userId = parts.slice(1).join(':');
const session = sessionManager.getSession(frontend, userId);
const allMessages = session.getHistory();
const start = args.offset ?? 0;
const end = args.limit ? start + args.limit : allMessages.length;
const messages = allMessages.slice(start, end);
if (messages.length === 0) {
return {
success: true,
output: `Session "${args.sessionId}" has no messages${start > 0 ? ' at this offset' : ''}.`,
};
}
const lines = messages.map((m, i) => {
const content =
typeof m.content === 'string'
? m.content.slice(0, 200)
: '[multipart]';
return `${start + i + 1}. [${m.role}] ${content}`;
});
return {
success: true,
output: `Session "${args.sessionId}" (${allMessages.length} total messages):\n\n${lines.join('\n')}`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const sessionsCreate: Tool = {
name: 'sessions.create',
description:
'Create a new session (or get an existing one). Returns the session ID.',
inputSchema: {
type: 'object',
properties: {
frontend: {
type: 'string',
description: 'Frontend/channel name (e.g. "telegram", "webchat")',
},
userId: {
type: 'string',
description: 'User ID for this session',
},
},
required: ['frontend', 'userId'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as SessionsCreateArgs;
try {
const session = sessionManager.getSession(args.frontend, args.userId);
return {
success: true,
output: `Session "${session.id}" ready (${session.getHistory().length} existing messages).`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const sessionsDelete: Tool = {
name: 'sessions.delete',
description:
'Clear a session history and close it. The session ID is "frontend:userId".',
inputSchema: {
type: 'object',
properties: {
frontend: {
type: 'string',
description: 'Frontend/channel name',
},
userId: {
type: 'string',
description: 'User ID',
},
},
required: ['frontend', 'userId'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as SessionsDeleteArgs;
try {
const session = sessionManager.getSession(args.frontend, args.userId);
const messageCount = session.getHistory().length;
session.clear();
sessionManager.closeSession(args.frontend, args.userId);
return {
success: true,
output: `Session "${args.frontend}:${args.userId}" cleared (${messageCount} messages removed).`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [sessionsList, sessionsHistory, sessionsCreate, sessionsDelete];
}
+1 -1
View File
@@ -22,7 +22,7 @@ export class ToolExecutor {
}
async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise<ToolResult> {
const tool = this.registry.get(toolName);
const tool = this.registry.getByApiName(toolName);
if (!tool) {
return { success: false, output: '', error: `Tool '${toolName}' not found` };
}
+1 -1
View File
@@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js';
export type { ToolExecutorConfig } from './executor.js';
export { ToolPolicy } from './policy.js';
export type { ToolPolicyContext } from './policy.js';
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool } from './builtin/index.js';
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools } from './builtin/index.js';
export type { WebSearchConfig } from './builtin/web-search.js';
export type { ProcessManagerConfig } from './builtin/process/index.js';
export type { BrowserManagerConfig } from './builtin/browser/index.js';
+35 -2
View File
@@ -56,7 +56,7 @@ describe('ToolRegistry', () => {
const anthropicTools = registry.toAnthropicFormat();
expect(anthropicTools).toEqual([{
name: 'test.echo',
name: 'test_echo',
description: 'Echoes input back',
input_schema: echoTool.inputSchema,
}]);
@@ -86,7 +86,7 @@ describe('ToolRegistry', () => {
expect(openaiTools).toEqual([{
type: 'function',
function: {
name: 'test.echo',
name: 'test_echo',
description: 'Echoes input back',
parameters: echoTool.inputSchema,
},
@@ -161,4 +161,37 @@ describe('ToolRegistry', () => {
expect(() => reg.replace(makeTool('nonexistent'))).toThrow('not registered');
});
});
describe('ToolRegistry — API name sanitization', () => {
it('sanitizeToolName converts dots to underscores', () => {
expect(ToolRegistry.sanitizeToolName('shell.exec')).toBe('shell_exec');
expect(ToolRegistry.sanitizeToolName('file.read')).toBe('file_read');
expect(ToolRegistry.sanitizeToolName('no_dots')).toBe('no_dots');
});
it('getByApiName resolves sanitized names back to internal tools', () => {
const registry = new ToolRegistry();
registry.register(echoTool); // name is 'test.echo'
expect(registry.getByApiName('test_echo')).toBe(echoTool);
expect(registry.getByApiName('test.echo')).toBe(echoTool);
expect(registry.getByApiName('nonexistent')).toBeUndefined();
});
it('toAnthropicFormat outputs sanitized names', () => {
const registry = new ToolRegistry();
registry.register(echoTool);
const tools = registry.toAnthropicFormat();
expect(tools[0].name).toBe('test_echo');
});
it('toOpenAIFormat outputs sanitized names', () => {
const registry = new ToolRegistry();
registry.register(echoTool);
const tools = registry.toOpenAIFormat();
expect(tools[0].function.name).toBe('test_echo');
});
});
});
+14 -4
View File
@@ -20,6 +20,11 @@ export class ToolRegistry {
private tools: Map<string, Tool> = new Map();
private _policy?: ToolPolicy;
/** Sanitize a tool name for API compatibility (dots → underscores). */
static sanitizeToolName(name: string): string {
return name.replace(/\./g, '_');
}
register(tool: Tool): void {
if (this.tools.has(tool.name)) {
throw new Error(`Tool '${tool.name}' is already registered`);
@@ -55,6 +60,11 @@ export class ToolRegistry {
return this.tools.get(name);
}
/** Resolve a tool by its API-sanitized name (underscores → dots fallback). */
getByApiName(name: string): Tool | undefined {
return this.tools.get(name) ?? this.tools.get(name.replace(/_/g, '.'));
}
list(): Tool[] {
return Array.from(this.tools.values());
}
@@ -77,7 +87,7 @@ export class ToolRegistry {
toAnthropicFormat(): AnthropicToolDef[] {
return this.list().map(t => ({
name: t.name,
name: ToolRegistry.sanitizeToolName(t.name),
description: t.description,
input_schema: t.inputSchema,
}));
@@ -86,7 +96,7 @@ export class ToolRegistry {
/** Return Anthropic-format tools filtered by policy. */
filteredToAnthropicFormat(context?: ToolPolicyContext): AnthropicToolDef[] {
return this.filteredList(context).map(t => ({
name: t.name,
name: ToolRegistry.sanitizeToolName(t.name),
description: t.description,
input_schema: t.inputSchema,
}));
@@ -96,7 +106,7 @@ export class ToolRegistry {
return this.list().map(t => ({
type: 'function' as const,
function: {
name: t.name,
name: ToolRegistry.sanitizeToolName(t.name),
description: t.description,
parameters: t.inputSchema,
},
@@ -108,7 +118,7 @@ export class ToolRegistry {
return this.filteredList(context).map(t => ({
type: 'function' as const,
function: {
name: t.name,
name: ToolRegistry.sanitizeToolName(t.name),
description: t.description,
parameters: t.inputSchema,
},