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
+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];
}