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:
@@ -6,6 +6,7 @@ import type { ToolExecutor } from '../../tools/executor.js';
|
||||
import type { ToolResult } from '../../tools/types.js';
|
||||
import type { ToolPolicyContext } from '../../tools/policy.js';
|
||||
import type { Attachment } from '../../channels/types.js';
|
||||
import type { OutboundAttachmentCollector } from './attachments.js';
|
||||
import { buildUserMessage, getMessageText } from '../../models/media.js';
|
||||
|
||||
export interface ToolUseEvent {
|
||||
@@ -25,6 +26,8 @@ export interface NativeAgentConfig {
|
||||
onToolUse?: (event: ToolUseEvent) => void;
|
||||
/** Policy context for tool filtering (agent tier, provider). */
|
||||
toolPolicyContext?: ToolPolicyContext;
|
||||
/** Collector for outbound attachments queued by tools (e.g. media.send). */
|
||||
attachmentCollector?: OutboundAttachmentCollector;
|
||||
}
|
||||
|
||||
// Internal message type for the tool loop — supports both text and structured content blocks.
|
||||
@@ -47,6 +50,7 @@ export class NativeAgent {
|
||||
private _totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
||||
private _callCount: number = 0;
|
||||
private _toolPolicyContext?: ToolPolicyContext;
|
||||
private _attachmentCollector?: OutboundAttachmentCollector;
|
||||
|
||||
constructor(config: NativeAgentConfig) {
|
||||
this.modelClient = config.modelClient;
|
||||
@@ -57,6 +61,7 @@ export class NativeAgent {
|
||||
this.maxIterations = config.maxIterations ?? 10;
|
||||
this.onToolUse = config.onToolUse;
|
||||
this._toolPolicyContext = config.toolPolicyContext;
|
||||
this._attachmentCollector = config.attachmentCollector;
|
||||
}
|
||||
|
||||
private get history(): Message[] {
|
||||
@@ -241,4 +246,12 @@ export class NativeAgent {
|
||||
getToolPolicyContext(): ToolPolicyContext | undefined {
|
||||
return this._toolPolicyContext;
|
||||
}
|
||||
|
||||
setAttachmentCollector(collector: OutboundAttachmentCollector | undefined): void {
|
||||
this._attachmentCollector = collector;
|
||||
}
|
||||
|
||||
getAttachmentCollector(): OutboundAttachmentCollector | undefined {
|
||||
return this._attachmentCollector;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OutboundAttachmentCollector } from './attachments.js';
|
||||
|
||||
describe('OutboundAttachmentCollector', () => {
|
||||
it('starts with zero count', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
expect(collector.count).toBe(0);
|
||||
});
|
||||
|
||||
it('push increments count', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
collector.push({ mimeType: 'image/png', data: 'abc123' });
|
||||
expect(collector.count).toBe(1);
|
||||
|
||||
collector.push({ mimeType: 'application/pdf', url: 'https://example.com/doc.pdf' });
|
||||
expect(collector.count).toBe(2);
|
||||
});
|
||||
|
||||
it('drain returns all queued attachments', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
collector.push({ mimeType: 'image/png', data: 'abc123', filename: 'photo.png' });
|
||||
collector.push({ mimeType: 'audio/ogg', url: 'https://example.com/audio.ogg' });
|
||||
|
||||
const drained = collector.drain();
|
||||
expect(drained).toHaveLength(2);
|
||||
expect(drained[0]).toEqual({
|
||||
mimeType: 'image/png',
|
||||
data: 'abc123',
|
||||
filename: 'photo.png',
|
||||
});
|
||||
expect(drained[1]).toEqual({
|
||||
mimeType: 'audio/ogg',
|
||||
url: 'https://example.com/audio.ogg',
|
||||
});
|
||||
});
|
||||
|
||||
it('drain clears the queue', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
collector.push({ mimeType: 'image/png', data: 'abc123' });
|
||||
expect(collector.count).toBe(1);
|
||||
|
||||
collector.drain();
|
||||
expect(collector.count).toBe(0);
|
||||
});
|
||||
|
||||
it('drain returns empty array when nothing queued', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
const drained = collector.drain();
|
||||
expect(drained).toEqual([]);
|
||||
});
|
||||
|
||||
it('drain returns a copy, not the internal array', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
collector.push({ mimeType: 'image/png', data: 'abc123' });
|
||||
const first = collector.drain();
|
||||
const second = collector.drain();
|
||||
|
||||
// First drain got the item, second drain is empty
|
||||
expect(first).toHaveLength(1);
|
||||
expect(second).toHaveLength(0);
|
||||
|
||||
// Mutating the returned array doesn't affect the collector
|
||||
first.push({ mimeType: 'image/gif', data: 'xyz' });
|
||||
expect(collector.count).toBe(0);
|
||||
});
|
||||
|
||||
it('can push and drain multiple cycles', () => {
|
||||
const collector = new OutboundAttachmentCollector();
|
||||
|
||||
// First cycle
|
||||
collector.push({ mimeType: 'image/png', data: 'cycle1' });
|
||||
expect(collector.drain()).toHaveLength(1);
|
||||
expect(collector.count).toBe(0);
|
||||
|
||||
// Second cycle
|
||||
collector.push({ mimeType: 'image/jpeg', data: 'cycle2a' });
|
||||
collector.push({ mimeType: 'image/gif', data: 'cycle2b' });
|
||||
const second = collector.drain();
|
||||
expect(second).toHaveLength(2);
|
||||
expect(second[0].data).toBe('cycle2a');
|
||||
expect(second[1].data).toBe('cycle2b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { OutboundAttachment } from '../../channels/types.js';
|
||||
|
||||
/**
|
||||
* Collects outbound attachments during a tool execution cycle.
|
||||
* Tools can push attachments here, and they'll be included in the reply.
|
||||
*/
|
||||
export class OutboundAttachmentCollector {
|
||||
private _attachments: OutboundAttachment[] = [];
|
||||
|
||||
/** Queue an attachment for inclusion in the next outbound message. */
|
||||
push(attachment: OutboundAttachment): void {
|
||||
this._attachments.push(attachment);
|
||||
}
|
||||
|
||||
/** Remove and return all queued attachments. */
|
||||
drain(): OutboundAttachment[] {
|
||||
const result = [...this._attachments];
|
||||
this._attachments = [];
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Number of queued attachments. */
|
||||
get count(): number {
|
||||
return this._attachments.length;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { NativeAgent, type NativeAgentConfig, type ToolUseEvent } from './agent.js';
|
||||
export { OutboundAttachmentCollector } from './attachments.js';
|
||||
export {
|
||||
AgentOrchestrator,
|
||||
type OrchestratorConfig,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ToolPolicyContext } from '../../tools/policy.js';
|
||||
import type { Attachment } from '../../channels/types.js';
|
||||
import { NativeAgent } from './agent.js';
|
||||
import type { ToolUseEvent } from './agent.js';
|
||||
import type { OutboundAttachmentCollector } from './attachments.js';
|
||||
import { shouldCompact } from '../../context/tokens.js';
|
||||
import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_COMPACTION_CONFIG } from '../../context/compaction.js';
|
||||
import { estimateCost } from '../../models/costs.js';
|
||||
@@ -91,6 +92,8 @@ export interface OrchestratorConfig {
|
||||
memoryStore?: MemoryStore;
|
||||
/** Policy context for tool filtering (agent tier, provider). */
|
||||
toolPolicyContext?: ToolPolicyContext;
|
||||
/** Collector for outbound attachments queued by tools (e.g. media.send). */
|
||||
attachmentCollector?: OutboundAttachmentCollector;
|
||||
}
|
||||
|
||||
// ── AgentOrchestrator ─────────────────────────────────────────────────
|
||||
@@ -139,6 +142,7 @@ export class AgentOrchestrator {
|
||||
maxIterations: config.maxIterations,
|
||||
onToolUse: config.onToolUse,
|
||||
toolPolicyContext: config.toolPolicyContext,
|
||||
attachmentCollector: config.attachmentCollector,
|
||||
});
|
||||
|
||||
// Set the primary tier on the agent
|
||||
|
||||
Reference in New Issue
Block a user