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:
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user