feat: add outbound attachment support with media.send tool
Introduces OutboundAttachment type on OutboundMessage, an OutboundAttachmentCollector (push/drain pattern), and a media.send tool that queues files for outbound delivery. Each channel adapter (Telegram, Discord, Slack, WhatsApp) sends attachments after the text reply. Includes 15 tests for collector and tool.
This commit is contained in:
@@ -4,6 +4,8 @@ export { fileWriteTool } from './file-write.js';
|
||||
export { fileEditTool } from './file-edit.js';
|
||||
export { fileListTool } from './file-list.js';
|
||||
export { webFetchTool } from './web-fetch.js';
|
||||
export { createMediaSendTool } from './media-send.js';
|
||||
export { createImageAnalyzeTool } from './image-analyze.js';
|
||||
export { createMemoryReadTool } from './memory-read.js';
|
||||
export { createMemoryWriteTool } from './memory-write.js';
|
||||
export { createMemorySearchTool } from './memory-search.js';
|
||||
@@ -23,6 +25,8 @@ import { fileWriteTool } from './file-write.js';
|
||||
import { fileEditTool } from './file-edit.js';
|
||||
import { fileListTool } from './file-list.js';
|
||||
import { webFetchTool } from './web-fetch.js';
|
||||
import { createMediaSendTool } from './media-send.js';
|
||||
import { createImageAnalyzeTool } from './image-analyze.js';
|
||||
import { createMemoryReadTool } from './memory-read.js';
|
||||
import { createMemoryWriteTool } from './memory-write.js';
|
||||
import { createMemorySearchTool } from './memory-search.js';
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OutboundAttachmentCollector } from '../../backends/native/attachments.js';
|
||||
import { createMediaSendTool } from './media-send.js';
|
||||
|
||||
describe('media.send tool', () => {
|
||||
it('has correct metadata', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
expect(tool.name).toBe('media.send');
|
||||
expect(tool.inputSchema.required).toEqual(['mime_type']);
|
||||
});
|
||||
|
||||
it('queues attachment with base64 data', async () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
const result = await tool.execute({
|
||||
data: 'aGVsbG8=',
|
||||
mime_type: 'image/png',
|
||||
filename: 'hello.png',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('image/png');
|
||||
expect(result.output).toContain('hello.png');
|
||||
expect(collector.count).toBe(1);
|
||||
|
||||
const drained = collector.drain();
|
||||
expect(drained[0]).toEqual({
|
||||
mimeType: 'image/png',
|
||||
data: 'aGVsbG8=',
|
||||
url: undefined,
|
||||
filename: 'hello.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('queues attachment with URL', async () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
const result = await tool.execute({
|
||||
url: 'https://example.com/photo.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('image/jpeg');
|
||||
expect(collector.count).toBe(1);
|
||||
|
||||
const drained = collector.drain();
|
||||
expect(drained[0]).toEqual({
|
||||
mimeType: 'image/jpeg',
|
||||
data: undefined,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
filename: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when neither data nor url is provided', async () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
const result = await tool.execute({ mime_type: 'image/png' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Either data or url must be provided');
|
||||
expect(collector.count).toBe(0);
|
||||
});
|
||||
|
||||
it('queues multiple attachments', async () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
await tool.execute({ data: 'img1', mime_type: 'image/png' });
|
||||
await tool.execute({ url: 'https://example.com/doc.pdf', mime_type: 'application/pdf' });
|
||||
await tool.execute({ data: 'img2', mime_type: 'image/jpeg', filename: 'photo.jpg' });
|
||||
|
||||
expect(collector.count).toBe(3);
|
||||
|
||||
const drained = collector.drain();
|
||||
expect(drained).toHaveLength(3);
|
||||
expect(drained[0].mimeType).toBe('image/png');
|
||||
expect(drained[1].mimeType).toBe('application/pdf');
|
||||
expect(drained[2].mimeType).toBe('image/jpeg');
|
||||
expect(drained[2].filename).toBe('photo.jpg');
|
||||
});
|
||||
|
||||
it('collector drains correctly after tool use', async () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
await tool.execute({ data: 'abc', mime_type: 'image/png' });
|
||||
expect(collector.count).toBe(1);
|
||||
|
||||
const first = collector.drain();
|
||||
expect(first).toHaveLength(1);
|
||||
expect(collector.count).toBe(0);
|
||||
|
||||
// Second drain should be empty
|
||||
const second = collector.drain();
|
||||
expect(second).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('output includes filename when provided', async () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
const result = await tool.execute({
|
||||
data: 'abc',
|
||||
mime_type: 'application/pdf',
|
||||
filename: 'report.pdf',
|
||||
});
|
||||
|
||||
expect(result.output).toContain('report.pdf');
|
||||
});
|
||||
|
||||
it('output omits filename when not provided', async () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const tool = createMediaSendTool(collector);
|
||||
|
||||
const result = await tool.execute({
|
||||
data: 'abc',
|
||||
mime_type: 'image/png',
|
||||
});
|
||||
|
||||
expect(result.output).toBe('Attachment queued (image/png)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
import type { OutboundAttachmentCollector } from '../../backends/native/attachments.js';
|
||||
|
||||
interface MediaSendArgs {
|
||||
data?: string;
|
||||
url?: string;
|
||||
mime_type: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the media.send tool bound to an OutboundAttachmentCollector.
|
||||
*
|
||||
* The tool lets the agent queue a file or image to be sent back to the user.
|
||||
* Attachments are collected during the tool loop and included in the outbound
|
||||
* message after the agent finishes processing.
|
||||
*/
|
||||
export function createMediaSendTool(collector: OutboundAttachmentCollector): Tool {
|
||||
return {
|
||||
name: 'media.send',
|
||||
description:
|
||||
'Attach a file or image to send back to the user. The attachment will be included with the next text response.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'string',
|
||||
description: 'Base64-encoded file content',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL to the file (alternative to data)',
|
||||
},
|
||||
mime_type: {
|
||||
type: 'string',
|
||||
description: 'MIME type of the file (e.g. image/png, application/pdf)',
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: 'Suggested filename',
|
||||
},
|
||||
},
|
||||
required: ['mime_type'],
|
||||
},
|
||||
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
const args = rawArgs as MediaSendArgs;
|
||||
|
||||
if (!args.data && !args.url) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Either data or url must be provided',
|
||||
};
|
||||
}
|
||||
|
||||
collector.push({
|
||||
mimeType: args.mime_type,
|
||||
data: args.data,
|
||||
url: args.url,
|
||||
filename: args.filename,
|
||||
});
|
||||
|
||||
const label = args.filename ? `: ${args.filename}` : '';
|
||||
return {
|
||||
success: true,
|
||||
output: `Attachment queued (${args.mime_type}${label})`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+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 } from './builtin/index.js';
|
||||
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool } 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';
|
||||
|
||||
Reference in New Issue
Block a user