- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
273 lines
9.3 KiB
TypeScript
273 lines
9.3 KiB
TypeScript
/**
|
|
* A2A Message Format Converters
|
|
*
|
|
* Bidirectional conversion between A2A protocol message format
|
|
* and Dexto's internal message format.
|
|
*
|
|
* These converters live at the server boundary, translating between
|
|
* wire format (A2A) and internal format (DextoAgent).
|
|
*/
|
|
|
|
import type { InternalMessage } from '@dexto/core';
|
|
import type { Message, Part, MessageRole, ConvertedMessage } from '../types.js';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
/**
|
|
* Convert A2A message to internal format for agent.run().
|
|
*
|
|
* Extracts text, image, and file from A2A parts array.
|
|
* agent.run() expects these as separate parameters.
|
|
*
|
|
* @param a2aMsg A2A protocol message
|
|
* @returns Converted message parts for agent.run()
|
|
*/
|
|
export function a2aToInternalMessage(a2aMsg: Message): ConvertedMessage {
|
|
let text = '';
|
|
let image: ConvertedMessage['image'] | undefined;
|
|
let file: ConvertedMessage['file'] | undefined;
|
|
|
|
for (const part of a2aMsg.parts) {
|
|
switch (part.kind) {
|
|
case 'text':
|
|
text += (text ? ' ' : '') + part.text;
|
|
break;
|
|
|
|
case 'file': {
|
|
// Determine if this is an image or general file
|
|
const fileData = part.file;
|
|
const mimeType = fileData.mimeType || '';
|
|
const isImage = mimeType.startsWith('image/');
|
|
|
|
if (isImage && !image) {
|
|
// Treat as image (agent.run() supports one image)
|
|
const data = 'bytes' in fileData ? fileData.bytes : fileData.uri;
|
|
image = {
|
|
image: data,
|
|
mimeType: mimeType,
|
|
};
|
|
} else if (!file) {
|
|
// Take first file only (agent.run() supports one file)
|
|
const data = 'bytes' in fileData ? fileData.bytes : fileData.uri;
|
|
const fileObj: { data: string; mimeType: string; filename?: string } = {
|
|
data: data,
|
|
mimeType: mimeType,
|
|
};
|
|
if (fileData.name) {
|
|
fileObj.filename = fileData.name;
|
|
}
|
|
file = fileObj;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'data':
|
|
// Convert structured data to JSON text
|
|
text += (text ? '\n' : '') + JSON.stringify(part.data, null, 2);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { text, image, file };
|
|
}
|
|
|
|
/**
|
|
* Convert internal message to A2A format.
|
|
*
|
|
* Maps Dexto's internal message structure to A2A protocol format.
|
|
*
|
|
* Role mapping:
|
|
* - 'user' → 'user'
|
|
* - 'assistant' → 'agent'
|
|
* - 'system' → filtered out (not part of A2A conversation)
|
|
* - 'tool' → 'agent' (tool results presented as agent responses)
|
|
*
|
|
* @param msg Internal message from session history
|
|
* @param taskId Optional task ID to associate message with
|
|
* @param contextId Optional context ID to associate message with
|
|
* @returns A2A protocol message or null if message should be filtered
|
|
*/
|
|
export function internalToA2AMessage(
|
|
msg: InternalMessage,
|
|
taskId?: string,
|
|
contextId?: string
|
|
): Message | null {
|
|
// Filter out system messages (internal context, not part of A2A conversation)
|
|
if (msg.role === 'system') {
|
|
return null;
|
|
}
|
|
|
|
// Map role
|
|
const role: MessageRole = msg.role === 'user' ? 'user' : 'agent';
|
|
|
|
// Convert content to parts
|
|
const parts: Part[] = [];
|
|
|
|
if (typeof msg.content === 'string') {
|
|
// Simple text content
|
|
if (msg.content) {
|
|
parts.push({ kind: 'text', text: msg.content });
|
|
}
|
|
} else if (msg.content === null) {
|
|
// Null content (tool-only messages) - skip for A2A
|
|
// These are internal details, not part of user-facing conversation
|
|
} else if (Array.isArray(msg.content)) {
|
|
// Multi-part content
|
|
for (const part of msg.content) {
|
|
switch (part.type) {
|
|
case 'text':
|
|
parts.push({ kind: 'text', text: part.text });
|
|
break;
|
|
|
|
case 'image': {
|
|
const imageData = part.image;
|
|
const mimeType = part.mimeType || 'image/png';
|
|
|
|
// Convert different input types to base64 or URL
|
|
let fileObj: any;
|
|
if (
|
|
imageData instanceof URL ||
|
|
(typeof imageData === 'string' && imageData.startsWith('http'))
|
|
) {
|
|
// URL reference
|
|
fileObj = {
|
|
uri: imageData.toString(),
|
|
mimeType,
|
|
};
|
|
} else if (Buffer.isBuffer(imageData)) {
|
|
// Buffer -> base64
|
|
fileObj = {
|
|
bytes: imageData.toString('base64'),
|
|
mimeType,
|
|
};
|
|
} else if (imageData instanceof Uint8Array) {
|
|
// Uint8Array -> base64
|
|
fileObj = {
|
|
bytes: Buffer.from(imageData).toString('base64'),
|
|
mimeType,
|
|
};
|
|
} else if (imageData instanceof ArrayBuffer) {
|
|
// ArrayBuffer -> base64
|
|
fileObj = {
|
|
bytes: Buffer.from(imageData).toString('base64'),
|
|
mimeType,
|
|
};
|
|
} else if (typeof imageData === 'string') {
|
|
// Assume already base64 if string but not a URL
|
|
fileObj = {
|
|
bytes: imageData,
|
|
mimeType,
|
|
};
|
|
}
|
|
|
|
if (fileObj) {
|
|
parts.push({
|
|
kind: 'file',
|
|
file: fileObj,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'file': {
|
|
const fileData = part.data;
|
|
const mimeType = part.mimeType;
|
|
|
|
// Convert different input types to base64 or URL
|
|
let fileObj: any;
|
|
if (
|
|
fileData instanceof URL ||
|
|
(typeof fileData === 'string' && fileData.startsWith('http'))
|
|
) {
|
|
// URL reference
|
|
fileObj = {
|
|
uri: fileData.toString(),
|
|
mimeType,
|
|
};
|
|
} else if (Buffer.isBuffer(fileData)) {
|
|
// Buffer -> base64
|
|
fileObj = {
|
|
bytes: fileData.toString('base64'),
|
|
mimeType,
|
|
};
|
|
} else if (fileData instanceof Uint8Array) {
|
|
// Uint8Array -> base64
|
|
fileObj = {
|
|
bytes: Buffer.from(fileData).toString('base64'),
|
|
mimeType,
|
|
};
|
|
} else if (fileData instanceof ArrayBuffer) {
|
|
// ArrayBuffer -> base64
|
|
fileObj = {
|
|
bytes: Buffer.from(fileData).toString('base64'),
|
|
mimeType,
|
|
};
|
|
} else if (typeof fileData === 'string') {
|
|
// Assume already base64 if string but not a URL
|
|
fileObj = {
|
|
bytes: fileData,
|
|
mimeType,
|
|
};
|
|
}
|
|
|
|
if (fileObj) {
|
|
// Add filename if present
|
|
if (part.filename) {
|
|
fileObj.name = part.filename;
|
|
}
|
|
|
|
parts.push({
|
|
kind: 'file',
|
|
file: fileObj,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no parts, return null (don't include empty messages in A2A)
|
|
if (parts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const message: Message = {
|
|
role,
|
|
parts,
|
|
messageId: randomUUID(),
|
|
kind: 'message',
|
|
};
|
|
|
|
if (taskId) message.taskId = taskId;
|
|
if (contextId) message.contextId = contextId;
|
|
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Convert array of internal messages to A2A messages.
|
|
*
|
|
* Filters out system messages and empty messages.
|
|
*
|
|
* @param messages Internal messages from session history
|
|
* @param taskId Optional task ID to associate messages with
|
|
* @param contextId Optional context ID to associate messages with
|
|
* @returns Array of A2A protocol messages
|
|
*/
|
|
export function internalMessagesToA2A(
|
|
messages: InternalMessage[],
|
|
taskId?: string,
|
|
contextId?: string
|
|
): Message[] {
|
|
const a2aMessages: Message[] = [];
|
|
|
|
for (const msg of messages) {
|
|
const a2aMsg = internalToA2AMessage(msg, taskId, contextId);
|
|
if (a2aMsg !== null) {
|
|
a2aMessages.push(a2aMsg);
|
|
}
|
|
}
|
|
|
|
return a2aMessages;
|
|
}
|