revert: remove unreviewed multi-agent routing scaffold (#330)
This commit is contained in:
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Config Normalization
|
||||
*
|
||||
* Converts legacy single-agent configs to multi-agent format
|
||||
* and ensures all required fields are present.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type {
|
||||
AgentBinding,
|
||||
AgentConfig,
|
||||
LettaBotConfig,
|
||||
NormalizedConfig,
|
||||
} from './types.js';
|
||||
|
||||
const DEFAULT_WORKSPACE = join(homedir(), '.lettabot', 'workspace');
|
||||
const DEFAULT_MODEL = 'zai/glm-4.7';
|
||||
|
||||
/**
|
||||
* Normalize config to multi-agent format
|
||||
*
|
||||
* This function:
|
||||
* 1. Converts legacy single-agent `agent` field to `agents.list[]`
|
||||
* 2. Ensures defaults are populated
|
||||
* 3. Creates implicit bindings for single-account channels
|
||||
*/
|
||||
export function normalizeConfig(config: LettaBotConfig): NormalizedConfig {
|
||||
// Check if already using multi-agent format
|
||||
const hasMultiAgent = config.agents?.list && config.agents.list.length > 0;
|
||||
|
||||
let agents: NormalizedConfig['agents'];
|
||||
let bindings: AgentBinding[];
|
||||
|
||||
if (hasMultiAgent) {
|
||||
// Multi-agent mode: use as-is with defaults filled in
|
||||
const defaultModel = config.agents?.defaults?.model || config.agent?.model || DEFAULT_MODEL;
|
||||
|
||||
agents = {
|
||||
defaults: {
|
||||
model: defaultModel,
|
||||
...config.agents?.defaults,
|
||||
},
|
||||
list: config.agents!.list!.map(agent => ({
|
||||
...agent,
|
||||
workspace: resolveWorkspace(agent.workspace, agent.id),
|
||||
model: agent.model || defaultModel,
|
||||
})),
|
||||
};
|
||||
|
||||
// Ensure at least one default agent
|
||||
if (!agents.list.some(a => a.default)) {
|
||||
agents.list[0].default = true;
|
||||
}
|
||||
|
||||
bindings = config.bindings || [];
|
||||
} else {
|
||||
// Legacy single-agent mode: convert to multi-agent
|
||||
const legacyAgent = config.agent;
|
||||
const agentId = legacyAgent?.id || 'main';
|
||||
const agentName = legacyAgent?.name || 'LettaBot';
|
||||
const agentModel = legacyAgent?.model || DEFAULT_MODEL;
|
||||
|
||||
agents = {
|
||||
defaults: {
|
||||
model: agentModel,
|
||||
},
|
||||
list: [{
|
||||
id: agentId,
|
||||
name: agentName,
|
||||
default: true,
|
||||
workspace: DEFAULT_WORKSPACE,
|
||||
model: agentModel,
|
||||
}],
|
||||
};
|
||||
|
||||
// No explicit bindings in legacy mode - default agent handles all
|
||||
bindings = [];
|
||||
}
|
||||
|
||||
// Create implicit bindings for channels without explicit bindings
|
||||
bindings = addImplicitBindings(config, agents.list, bindings);
|
||||
|
||||
// Build normalized config (omit legacy `agent` field)
|
||||
const { agent: _legacyAgent, ...rest } = config;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
agents,
|
||||
bindings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve workspace path, expanding ~ and ensuring absolute path
|
||||
*/
|
||||
function resolveWorkspace(workspace: string, agentId: string): string {
|
||||
if (!workspace) {
|
||||
return join(homedir(), '.lettabot', `workspace-${agentId}`);
|
||||
}
|
||||
|
||||
// Expand ~ to home directory
|
||||
if (workspace.startsWith('~')) {
|
||||
return workspace.replace('~', homedir());
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add implicit bindings for single-account channels
|
||||
*
|
||||
* When a channel has no explicit bindings and uses single-account mode,
|
||||
* we implicitly route it to the default agent.
|
||||
*/
|
||||
function addImplicitBindings(
|
||||
config: LettaBotConfig,
|
||||
agentsList: AgentConfig[],
|
||||
existingBindings: AgentBinding[]
|
||||
): AgentBinding[] {
|
||||
const bindings = [...existingBindings];
|
||||
const defaultAgent = agentsList.find(a => a.default) || agentsList[0];
|
||||
|
||||
// Helper to check if a channel/account combo already has a binding
|
||||
const hasBinding = (channel: string, accountId?: string): boolean => {
|
||||
return bindings.some(b => {
|
||||
if (b.match.channel !== channel) return false;
|
||||
// If checking specific account, must match
|
||||
if (accountId && b.match.accountId && b.match.accountId !== accountId) return false;
|
||||
// If no specific account in binding, it's a catch-all for the channel
|
||||
if (!b.match.accountId && !b.match.peer) return true;
|
||||
return b.match.accountId === accountId;
|
||||
});
|
||||
};
|
||||
|
||||
// Process each channel type
|
||||
const channelTypes = ['telegram', 'slack', 'discord', 'whatsapp', 'signal'] as const;
|
||||
|
||||
for (const channelType of channelTypes) {
|
||||
const channelConfig = config.channels[channelType];
|
||||
if (!channelConfig?.enabled) continue;
|
||||
|
||||
// Check for multi-account mode
|
||||
const accounts = (channelConfig as any).accounts as Record<string, unknown> | undefined;
|
||||
|
||||
if (accounts && Object.keys(accounts).length > 0) {
|
||||
// Multi-account: add binding for each account without explicit binding
|
||||
for (const accountId of Object.keys(accounts)) {
|
||||
if (!hasBinding(channelType, accountId)) {
|
||||
bindings.push({
|
||||
agentId: defaultAgent.id,
|
||||
match: { channel: channelType, accountId },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single account: add binding if no channel-wide binding exists
|
||||
if (!hasBinding(channelType)) {
|
||||
bindings.push({
|
||||
agentId: defaultAgent.id,
|
||||
match: { channel: channelType },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of enabled channel account pairs from config
|
||||
*/
|
||||
export function getEnabledChannelAccounts(config: LettaBotConfig | NormalizedConfig): Array<{
|
||||
channel: string;
|
||||
accountId: string;
|
||||
}> {
|
||||
const result: Array<{ channel: string; accountId: string }> = [];
|
||||
|
||||
const channelTypes = ['telegram', 'slack', 'discord', 'whatsapp', 'signal'] as const;
|
||||
|
||||
for (const channelType of channelTypes) {
|
||||
const channelConfig = config.channels[channelType];
|
||||
if (!channelConfig?.enabled) continue;
|
||||
|
||||
const accounts = (channelConfig as any).accounts as Record<string, unknown> | undefined;
|
||||
|
||||
if (accounts && Object.keys(accounts).length > 0) {
|
||||
for (const accountId of Object.keys(accounts)) {
|
||||
result.push({ channel: channelType, accountId });
|
||||
}
|
||||
} else {
|
||||
// Single account mode uses 'default' as accountId
|
||||
result.push({ channel: channelType, accountId: 'default' });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate config and return errors
|
||||
*/
|
||||
export function validateConfig(config: LettaBotConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for multi-agent mode
|
||||
if (config.agents?.list && config.agents.list.length > 0) {
|
||||
// Validate agent IDs are unique
|
||||
const ids = new Set<string>();
|
||||
for (const agent of config.agents.list) {
|
||||
if (!agent.id) {
|
||||
errors.push('Agent config missing required "id" field');
|
||||
} else if (ids.has(agent.id)) {
|
||||
errors.push(`Duplicate agent ID: ${agent.id}`);
|
||||
} else {
|
||||
ids.add(agent.id);
|
||||
}
|
||||
|
||||
if (!agent.workspace) {
|
||||
errors.push(`Agent "${agent.id}" missing required "workspace" field`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate bindings reference valid agents
|
||||
if (config.bindings) {
|
||||
for (const binding of config.bindings) {
|
||||
if (!ids.has(binding.agentId)) {
|
||||
errors.push(`Binding references unknown agent: ${binding.agentId}`);
|
||||
}
|
||||
if (!binding.match?.channel) {
|
||||
errors.push(`Binding for agent "${binding.agentId}" missing required "match.channel" field`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate at least one channel is enabled (or will be via multi-agent)
|
||||
const hasChannel = Object.values(config.channels || {}).some(
|
||||
ch => ch && typeof ch === 'object' && (ch as any).enabled
|
||||
);
|
||||
if (!hasChannel) {
|
||||
errors.push('No channels enabled. Enable at least one channel (telegram, slack, discord, whatsapp, or signal)');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
/**
|
||||
* Agent Instance
|
||||
*
|
||||
* Handles a single agent in multi-agent mode.
|
||||
* Each instance has its own workspace, store, and Letta agent.
|
||||
*/
|
||||
|
||||
import { createAgent, createSession, resumeSession, type Session } from '@letta-ai/letta-code-sdk';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
import type { InboundMessage, TriggerContext } from './types.js';
|
||||
import { updateAgentName } from '../tools/letta-api.js';
|
||||
import { formatMessageEnvelope } from './formatter.js';
|
||||
import { loadMemoryBlocks } from './memory.js';
|
||||
import { SYSTEM_PROMPT } from './system-prompt.js';
|
||||
|
||||
/**
|
||||
* Agent instance configuration
|
||||
*/
|
||||
export interface AgentInstanceConfig {
|
||||
/** Config ID (e.g., "home", "work") - used for store path */
|
||||
configId: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Working directory for this agent */
|
||||
workspace: string;
|
||||
/** Model to use */
|
||||
model?: string;
|
||||
/** Allowed tools */
|
||||
allowedTools?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent state persisted to disk
|
||||
*/
|
||||
interface AgentState {
|
||||
/** Letta Cloud agent ID */
|
||||
agentId: string | null;
|
||||
/** Current conversation ID */
|
||||
conversationId?: string | null;
|
||||
/** Server URL this agent belongs to */
|
||||
baseUrl?: string;
|
||||
/** When the agent was created */
|
||||
createdAt?: string;
|
||||
/** When the agent was last used */
|
||||
lastUsedAt?: string;
|
||||
/** Last message target for heartbeats */
|
||||
lastMessageTarget?: {
|
||||
channel: string;
|
||||
chatId: string;
|
||||
messageId?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_ALLOWED_TOOLS = [
|
||||
'Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep', 'Task',
|
||||
'web_search', 'conversation_search',
|
||||
];
|
||||
|
||||
/**
|
||||
* Single agent instance
|
||||
*/
|
||||
export class AgentInstance {
|
||||
readonly configId: string;
|
||||
readonly name: string;
|
||||
readonly workspace: string;
|
||||
readonly model: string | undefined;
|
||||
|
||||
private state: AgentState;
|
||||
private statePath: string;
|
||||
private allowedTools: string[];
|
||||
private processing = false;
|
||||
private messageQueue: Array<{
|
||||
msg: InboundMessage;
|
||||
adapter: ChannelAdapter;
|
||||
resolve: (value: void) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
|
||||
constructor(config: AgentInstanceConfig) {
|
||||
this.configId = config.configId;
|
||||
this.name = config.name;
|
||||
this.workspace = this.resolveWorkspace(config.workspace);
|
||||
this.model = config.model;
|
||||
this.allowedTools = config.allowedTools || DEFAULT_ALLOWED_TOOLS;
|
||||
|
||||
// State stored in ~/.lettabot/agents/{configId}/state.json
|
||||
const stateDir = join(homedir(), '.lettabot', 'agents', config.configId);
|
||||
this.statePath = join(stateDir, 'state.json');
|
||||
|
||||
// Ensure directories exist
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
mkdirSync(this.workspace, { recursive: true });
|
||||
|
||||
// Load existing state
|
||||
this.state = this.loadState();
|
||||
|
||||
console.log(`[Agent:${this.configId}] Initialized. Letta ID: ${this.state.agentId || '(new)'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Letta Cloud agent ID
|
||||
*/
|
||||
get agentId(): string | null {
|
||||
return this.state.agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current conversation ID
|
||||
*/
|
||||
get conversationId(): string | null {
|
||||
return this.state.conversationId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last message target for heartbeats
|
||||
*/
|
||||
get lastMessageTarget(): AgentState['lastMessageTarget'] | null {
|
||||
return this.state.lastMessageTarget || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming message
|
||||
*/
|
||||
async processMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.messageQueue.push({ msg, adapter, resolve, reject });
|
||||
if (!this.processing) {
|
||||
this.processQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the agent (for cron, heartbeat, etc.)
|
||||
*/
|
||||
async sendToAgent(text: string, _context?: TriggerContext): Promise<string> {
|
||||
const baseOptions = {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowedTools: this.allowedTools,
|
||||
cwd: this.workspace,
|
||||
};
|
||||
|
||||
let session: Session;
|
||||
let usedDefaultConversation = false;
|
||||
let usedSpecificConversation = false;
|
||||
|
||||
if (this.state.conversationId) {
|
||||
usedSpecificConversation = true;
|
||||
session = resumeSession(this.state.conversationId, baseOptions);
|
||||
} else if (this.state.agentId) {
|
||||
usedDefaultConversation = true;
|
||||
session = resumeSession(this.state.agentId, baseOptions);
|
||||
} else {
|
||||
const newAgentId = await createAgent({
|
||||
...baseOptions,
|
||||
model: this.model,
|
||||
memory: loadMemoryBlocks(this.name),
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
memfs: true,
|
||||
});
|
||||
session = resumeSession(newAgentId, baseOptions);
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
await session.send(text);
|
||||
} catch (error) {
|
||||
if (usedSpecificConversation && this.state.agentId) {
|
||||
console.warn(`[Agent:${this.configId}] Conversation missing, creating new...`);
|
||||
session.close();
|
||||
session = createSession(this.state.agentId, baseOptions);
|
||||
await session.send(text);
|
||||
} else if (usedDefaultConversation && this.state.agentId) {
|
||||
console.warn(`[Agent:${this.configId}] Default conversation missing, creating new...`);
|
||||
session.close();
|
||||
session = createSession(this.state.agentId, baseOptions);
|
||||
await session.send(text);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let response = '';
|
||||
for await (const streamMsg of session.stream()) {
|
||||
if (streamMsg.type === 'assistant') {
|
||||
response += streamMsg.content;
|
||||
}
|
||||
if (streamMsg.type === 'result') {
|
||||
this.handleSessionResult(session);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the agent (clear stored state)
|
||||
*/
|
||||
reset(): void {
|
||||
this.state = { agentId: null };
|
||||
this.saveState();
|
||||
console.log(`[Agent:${this.configId}] Reset`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the agent ID (for container deploys that discover existing agents)
|
||||
*/
|
||||
setAgentId(agentId: string): void {
|
||||
this.state.agentId = agentId;
|
||||
this.saveState();
|
||||
console.log(`[Agent:${this.configId}] Agent ID set to: ${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process message queue sequentially
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.processing || this.messageQueue.length === 0) return;
|
||||
|
||||
this.processing = true;
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const { msg, adapter, resolve, reject } = this.messageQueue.shift()!;
|
||||
try {
|
||||
await this.handleMessage(msg, adapter);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single message
|
||||
*/
|
||||
private async handleMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
|
||||
console.log(`[Agent:${this.configId}] Message from ${msg.userId}: ${msg.text.slice(0, 50)}...`);
|
||||
|
||||
// Track last message target for heartbeats
|
||||
this.state.lastMessageTarget = {
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
messageId: msg.messageId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.saveState();
|
||||
|
||||
// Start typing indicator
|
||||
await adapter.sendTypingIndicator(msg.chatId);
|
||||
|
||||
const baseOptions = {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowedTools: this.allowedTools,
|
||||
cwd: this.workspace,
|
||||
};
|
||||
|
||||
let session: Session;
|
||||
let usedDefaultConversation = false;
|
||||
let usedSpecificConversation = false;
|
||||
|
||||
if (this.state.conversationId) {
|
||||
usedSpecificConversation = true;
|
||||
process.env.LETTA_AGENT_ID = this.state.agentId || undefined;
|
||||
session = resumeSession(this.state.conversationId, baseOptions);
|
||||
} else if (this.state.agentId) {
|
||||
usedDefaultConversation = true;
|
||||
process.env.LETTA_AGENT_ID = this.state.agentId;
|
||||
session = resumeSession(this.state.agentId, baseOptions);
|
||||
} else {
|
||||
const newAgentId = await createAgent({
|
||||
...baseOptions,
|
||||
model: this.model,
|
||||
memory: loadMemoryBlocks(this.name),
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
memfs: true,
|
||||
});
|
||||
session = resumeSession(newAgentId, baseOptions);
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize session with timeout
|
||||
const initTimeoutMs = 30000;
|
||||
let initInfo;
|
||||
|
||||
try {
|
||||
initInfo = await this.withTimeout(session.initialize(), 'Session initialize', initTimeoutMs);
|
||||
} catch (error) {
|
||||
if (usedSpecificConversation && this.state.agentId) {
|
||||
console.warn(`[Agent:${this.configId}] Conversation missing, creating new...`);
|
||||
session.close();
|
||||
session = createSession(this.state.agentId, baseOptions);
|
||||
initInfo = await this.withTimeout(session.initialize(), 'Session initialize', initTimeoutMs);
|
||||
} else if (usedDefaultConversation && this.state.agentId) {
|
||||
console.warn(`[Agent:${this.configId}] Default conversation missing, creating new...`);
|
||||
session.close();
|
||||
session = createSession(this.state.agentId, baseOptions);
|
||||
initInfo = await this.withTimeout(session.initialize(), 'Session initialize', initTimeoutMs);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Send message
|
||||
const formattedMessage = formatMessageEnvelope(msg);
|
||||
await this.withTimeout(session.send(formattedMessage), 'Session send', initTimeoutMs);
|
||||
|
||||
// Stream response
|
||||
let response = '';
|
||||
let messageId: string | null = null;
|
||||
let lastUpdate = Date.now();
|
||||
let sentAnyMessage = false;
|
||||
|
||||
// Keep typing indicator alive
|
||||
const typingInterval = setInterval(() => {
|
||||
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
|
||||
}, 4000);
|
||||
|
||||
try {
|
||||
for await (const streamMsg of session.stream()) {
|
||||
if (streamMsg.type === 'assistant') {
|
||||
response += streamMsg.content;
|
||||
|
||||
// Stream updates for channels that support editing
|
||||
const canEdit = adapter.supportsEditing?.() ?? true;
|
||||
if (canEdit && Date.now() - lastUpdate > 500 && response.length > 0) {
|
||||
try {
|
||||
if (messageId) {
|
||||
await adapter.editMessage(msg.chatId, messageId, response);
|
||||
} else {
|
||||
const result = await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: response,
|
||||
threadId: msg.threadId,
|
||||
});
|
||||
messageId = result.messageId;
|
||||
}
|
||||
sentAnyMessage = true;
|
||||
} catch {
|
||||
// Ignore edit errors
|
||||
}
|
||||
lastUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
if (streamMsg.type === 'result') {
|
||||
this.handleSessionResult(session);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
|
||||
// Send final response
|
||||
if (response.trim()) {
|
||||
try {
|
||||
if (messageId) {
|
||||
await adapter.editMessage(msg.chatId, messageId, response);
|
||||
} else {
|
||||
await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: response,
|
||||
threadId: msg.threadId,
|
||||
});
|
||||
sentAnyMessage = true;
|
||||
}
|
||||
} catch (sendError) {
|
||||
console.error(`[Agent:${this.configId}] Error sending response:`, sendError);
|
||||
if (!messageId) {
|
||||
await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: response,
|
||||
threadId: msg.threadId,
|
||||
});
|
||||
sentAnyMessage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Agent:${this.configId}] Error:`, error);
|
||||
await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
threadId: msg.threadId,
|
||||
});
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session result - save agent/conversation IDs
|
||||
*/
|
||||
private handleSessionResult(session: Session): void {
|
||||
const isNewAgent = !this.state.agentId && session.agentId;
|
||||
|
||||
if (session.agentId && session.agentId !== this.state.agentId) {
|
||||
const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
|
||||
this.state.agentId = session.agentId;
|
||||
this.state.baseUrl = currentBaseUrl;
|
||||
this.state.lastUsedAt = new Date().toISOString();
|
||||
if (!this.state.createdAt) {
|
||||
this.state.createdAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
if (session.conversationId && session.conversationId !== this.state.conversationId) {
|
||||
this.state.conversationId = session.conversationId;
|
||||
}
|
||||
|
||||
this.saveState();
|
||||
|
||||
// Set agent name on new creation
|
||||
if (isNewAgent && session.agentId) {
|
||||
updateAgentName(session.agentId, this.name).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a promise with timeout
|
||||
*/
|
||||
private async withTimeout<T>(promise: Promise<T>, label: string, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve workspace path
|
||||
*/
|
||||
private resolveWorkspace(workspace: string): string {
|
||||
if (workspace.startsWith('~')) {
|
||||
return workspace.replace('~', homedir());
|
||||
}
|
||||
return resolve(workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load state from disk
|
||||
*/
|
||||
private loadState(): AgentState {
|
||||
try {
|
||||
if (existsSync(this.statePath)) {
|
||||
const raw = readFileSync(this.statePath, 'utf-8');
|
||||
return JSON.parse(raw) as AgentState;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Agent:${this.configId}] Failed to load state:`, e);
|
||||
}
|
||||
return { agentId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state to disk
|
||||
*/
|
||||
private saveState(): void {
|
||||
try {
|
||||
writeFileSync(this.statePath, JSON.stringify(this.state, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`[Agent:${this.configId}] Failed to save state:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* Agent Manager
|
||||
*
|
||||
* Manages multiple agent instances in multi-agent mode.
|
||||
* Creates, retrieves, and manages lifecycle of AgentInstance objects.
|
||||
*/
|
||||
|
||||
import type { NormalizedConfig, AgentConfig } from '../config/types.js';
|
||||
import { AgentInstance, type AgentInstanceConfig } from './agent-instance.js';
|
||||
import { agentExists, findAgentByName } from '../tools/letta-api.js';
|
||||
|
||||
/**
|
||||
* Manager for multiple agent instances
|
||||
*/
|
||||
export class AgentManager {
|
||||
private agents: Map<string, AgentInstance> = new Map();
|
||||
private defaultAgentId: string;
|
||||
private config: NormalizedConfig;
|
||||
|
||||
constructor(config: NormalizedConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Find default agent
|
||||
const defaultAgent = config.agents.list.find(a => a.default) || config.agents.list[0];
|
||||
this.defaultAgentId = defaultAgent.id;
|
||||
|
||||
// Create agent instances
|
||||
for (const agentConfig of config.agents.list) {
|
||||
const instance = this.createInstance(agentConfig);
|
||||
this.agents.set(agentConfig.id, instance);
|
||||
}
|
||||
|
||||
console.log(`[AgentManager] Initialized ${this.agents.size} agent(s). Default: ${this.defaultAgentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an agent instance by ID
|
||||
*/
|
||||
getAgent(agentId: string): AgentInstance | undefined {
|
||||
return this.agents.get(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default agent
|
||||
*/
|
||||
getDefaultAgent(): AgentInstance {
|
||||
return this.agents.get(this.defaultAgentId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default agent ID
|
||||
*/
|
||||
getDefaultAgentId(): string {
|
||||
return this.defaultAgentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agent IDs
|
||||
*/
|
||||
listAgentIds(): string[] {
|
||||
return Array.from(this.agents.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agents with their info
|
||||
*/
|
||||
listAgents(): Array<{
|
||||
configId: string;
|
||||
name: string;
|
||||
workspace: string;
|
||||
agentId: string | null;
|
||||
isDefault: boolean;
|
||||
}> {
|
||||
return Array.from(this.agents.values()).map(agent => ({
|
||||
configId: agent.configId,
|
||||
name: agent.name,
|
||||
workspace: agent.workspace,
|
||||
agentId: agent.agentId,
|
||||
isDefault: agent.configId === this.defaultAgentId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify all agents exist on the server
|
||||
* Clears agent IDs that no longer exist
|
||||
*/
|
||||
async verifyAgents(): Promise<void> {
|
||||
for (const [configId, agent] of this.agents) {
|
||||
if (agent.agentId) {
|
||||
const exists = await agentExists(agent.agentId);
|
||||
if (!exists) {
|
||||
console.log(`[AgentManager] Agent ${configId} (${agent.agentId}) not found on server, clearing...`);
|
||||
agent.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to discover existing agents by name on the server
|
||||
* Useful for container deploys where agents already exist
|
||||
*/
|
||||
async discoverAgentsByName(): Promise<void> {
|
||||
for (const [configId, agent] of this.agents) {
|
||||
if (!agent.agentId) {
|
||||
console.log(`[AgentManager] Searching for existing agent named "${agent.name}"...`);
|
||||
const found = await findAgentByName(agent.name);
|
||||
if (found) {
|
||||
console.log(`[AgentManager] Found existing agent: ${found.id}`);
|
||||
agent.setAgentId(found.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status summary
|
||||
*/
|
||||
getStatus(): {
|
||||
totalAgents: number;
|
||||
defaultAgentId: string;
|
||||
agents: Array<{
|
||||
configId: string;
|
||||
name: string;
|
||||
agentId: string | null;
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
} {
|
||||
return {
|
||||
totalAgents: this.agents.size,
|
||||
defaultAgentId: this.defaultAgentId,
|
||||
agents: this.listAgents(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent instance from config
|
||||
*/
|
||||
private createInstance(agentConfig: AgentConfig): AgentInstance {
|
||||
const defaultModel = this.config.agents.defaults?.model;
|
||||
|
||||
const instanceConfig: AgentInstanceConfig = {
|
||||
configId: agentConfig.id,
|
||||
name: agentConfig.name || agentConfig.id,
|
||||
workspace: agentConfig.workspace,
|
||||
model: agentConfig.model || defaultModel,
|
||||
};
|
||||
|
||||
return new AgentInstance(instanceConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent manager from normalized config
|
||||
*/
|
||||
export function createAgentManager(config: NormalizedConfig): AgentManager {
|
||||
return new AgentManager(config);
|
||||
}
|
||||
@@ -3,11 +3,15 @@
|
||||
*
|
||||
* In multi-agent mode, the gateway manages multiple AgentSession instances,
|
||||
* each with their own channels, message queue, and state.
|
||||
*
|
||||
* See: docs/multi-agent-architecture.md
|
||||
*/
|
||||
|
||||
import type { AgentSession } from './interfaces.js';
|
||||
import type { AgentSession, AgentRouter } from './interfaces.js';
|
||||
import type { TriggerContext } from './types.js';
|
||||
import type { StreamMsg } from './bot.js';
|
||||
|
||||
export class LettaGateway {
|
||||
export class LettaGateway implements AgentRouter {
|
||||
private agents: Map<string, AgentSession> = new Map();
|
||||
|
||||
/**
|
||||
@@ -58,7 +62,7 @@ export class LettaGateway {
|
||||
|
||||
/** Stop all agents */
|
||||
async stop(): Promise<void> {
|
||||
console.log('[Gateway] Stopping all agents...');
|
||||
console.log(`[Gateway] Stopping all agents...`);
|
||||
for (const [name, session] of this.agents) {
|
||||
try {
|
||||
await session.stop();
|
||||
@@ -69,6 +73,37 @@ export class LettaGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a named agent and return the response.
|
||||
* If no name is given, routes to the first registered agent.
|
||||
*/
|
||||
async sendToAgent(agentName: string | undefined, text: string, context?: TriggerContext): Promise<string> {
|
||||
const agent = this.resolveAgent(agentName);
|
||||
return agent.sendToAgent(text, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a message to a named agent, yielding chunks as they arrive.
|
||||
*/
|
||||
async *streamToAgent(agentName: string | undefined, text: string, context?: TriggerContext): AsyncGenerator<StreamMsg> {
|
||||
const agent = this.resolveAgent(agentName);
|
||||
yield* agent.streamToAgent(text, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent by name, defaulting to the first registered agent.
|
||||
*/
|
||||
private resolveAgent(name?: string): AgentSession {
|
||||
if (!name) {
|
||||
const first = this.agents.values().next().value;
|
||||
if (!first) throw new Error('No agents configured');
|
||||
return first;
|
||||
}
|
||||
const agent = this.agents.get(name);
|
||||
if (!agent) throw new Error(`Agent not found: ${name}`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a message to a channel.
|
||||
* Finds the agent that owns the channel and delegates.
|
||||
@@ -79,7 +114,7 @@ export class LettaGateway {
|
||||
options: { text?: string; filePath?: string; kind?: 'image' | 'file' }
|
||||
): Promise<string | undefined> {
|
||||
// Try each agent until one owns the channel
|
||||
for (const [, session] of this.agents) {
|
||||
for (const [name, session] of this.agents) {
|
||||
const status = session.getStatus();
|
||||
if (status.channels.includes(channelId)) {
|
||||
return session.deliverToChannel(channelId, chatId, options);
|
||||
@@ -87,28 +122,4 @@ export class LettaGateway {
|
||||
}
|
||||
throw new Error(`No agent owns channel: ${channelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to an agent by name.
|
||||
* If name is undefined, route to first configured agent.
|
||||
*/
|
||||
async sendToAgent(agentName: string | undefined, text: string, context?: Parameters<AgentSession['sendToAgent']>[1]): Promise<string> {
|
||||
const session = agentName ? this.getAgent(agentName) : this.agents.values().next().value as AgentSession | undefined;
|
||||
if (!session) {
|
||||
throw new Error(agentName ? `Agent not found: ${agentName}` : 'No agents configured');
|
||||
}
|
||||
return session.sendToAgent(text, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a message to an agent by name.
|
||||
* If name is undefined, route to first configured agent.
|
||||
*/
|
||||
async *streamToAgent(agentName: string | undefined, text: string, context?: Parameters<AgentSession['streamToAgent']>[1]): AsyncGenerator<import('./bot.js').StreamMsg> {
|
||||
const session = agentName ? this.getAgent(agentName) : this.agents.values().next().value as AgentSession | undefined;
|
||||
if (!session) {
|
||||
throw new Error(agentName ? `Agent not found: ${agentName}` : 'No agents configured');
|
||||
}
|
||||
yield* session.streamToAgent(text, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './router.js';
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Message Router
|
||||
*
|
||||
* Routes incoming messages to the correct agent based on bindings.
|
||||
* Priority: peer match > accountId match > channel match > default agent
|
||||
*/
|
||||
|
||||
import type { AgentBinding } from '../config/types.js';
|
||||
|
||||
/**
|
||||
* Context for routing a message
|
||||
*/
|
||||
export interface RoutingContext {
|
||||
/** Channel type: "telegram", "slack", etc. */
|
||||
channel: string;
|
||||
/** Account ID for multi-account channels */
|
||||
accountId?: string;
|
||||
/** Chat/User ID */
|
||||
peerId?: string;
|
||||
/** Type of peer */
|
||||
peerKind?: 'dm' | 'group';
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of routing
|
||||
*/
|
||||
export interface RoutingResult {
|
||||
/** Target agent ID */
|
||||
agentId: string;
|
||||
/** Which binding matched (null if default) */
|
||||
matchedBinding: AgentBinding | null;
|
||||
/** Match specificity level */
|
||||
matchLevel: 'peer' | 'account' | 'channel' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Message router that matches bindings to find target agent
|
||||
*/
|
||||
export class MessageRouter {
|
||||
private bindings: AgentBinding[];
|
||||
private defaultAgentId: string;
|
||||
|
||||
constructor(bindings: AgentBinding[], defaultAgentId: string) {
|
||||
// Sort bindings by specificity (most specific first)
|
||||
this.bindings = this.sortBySpecificity(bindings);
|
||||
this.defaultAgentId = defaultAgentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message to an agent
|
||||
*/
|
||||
route(ctx: RoutingContext): RoutingResult {
|
||||
for (const binding of this.bindings) {
|
||||
const matchLevel = this.getMatchLevel(binding, ctx);
|
||||
if (matchLevel) {
|
||||
return {
|
||||
agentId: binding.agentId,
|
||||
matchedBinding: binding,
|
||||
matchLevel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentId: this.defaultAgentId,
|
||||
matchedBinding: null,
|
||||
matchLevel: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the match level for a binding against context
|
||||
* Returns null if no match, or the specificity level if matched
|
||||
*/
|
||||
private getMatchLevel(binding: AgentBinding, ctx: RoutingContext): 'peer' | 'account' | 'channel' | null {
|
||||
// Channel must always match
|
||||
if (binding.match.channel !== ctx.channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check peer match (most specific)
|
||||
if (binding.match.peer) {
|
||||
if (!ctx.peerId || !ctx.peerKind) {
|
||||
return null;
|
||||
}
|
||||
if (binding.match.peer.kind !== ctx.peerKind) {
|
||||
return null;
|
||||
}
|
||||
if (binding.match.peer.id !== ctx.peerId) {
|
||||
return null;
|
||||
}
|
||||
// Peer matches - also check accountId if specified
|
||||
if (binding.match.accountId && binding.match.accountId !== ctx.accountId) {
|
||||
return null;
|
||||
}
|
||||
return 'peer';
|
||||
}
|
||||
|
||||
// Check account match
|
||||
if (binding.match.accountId) {
|
||||
if (binding.match.accountId !== ctx.accountId) {
|
||||
return null;
|
||||
}
|
||||
return 'account';
|
||||
}
|
||||
|
||||
// Channel-only match (least specific)
|
||||
return 'channel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort bindings by specificity
|
||||
*
|
||||
* Order: peer+account > peer > account > channel
|
||||
*/
|
||||
private sortBySpecificity(bindings: AgentBinding[]): AgentBinding[] {
|
||||
return [...bindings].sort((a, b) => {
|
||||
const scoreA = this.getSpecificityScore(a);
|
||||
const scoreB = this.getSpecificityScore(b);
|
||||
return scoreB - scoreA; // Higher score = more specific = first
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate specificity score for sorting
|
||||
*/
|
||||
private getSpecificityScore(binding: AgentBinding): number {
|
||||
let score = 0;
|
||||
|
||||
// Peer match is most specific
|
||||
if (binding.match.peer) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
// Account ID adds specificity
|
||||
if (binding.match.accountId) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Channel is baseline (always present)
|
||||
score += 1;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bindings for a specific agent
|
||||
*/
|
||||
getBindingsForAgent(agentId: string): AgentBinding[] {
|
||||
return this.bindings.filter(b => b.agentId === agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique agent IDs from bindings
|
||||
*/
|
||||
getRoutedAgentIds(): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const binding of this.bindings) {
|
||||
ids.add(binding.agentId);
|
||||
}
|
||||
ids.add(this.defaultAgentId);
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug: describe the routing decision for a context
|
||||
*/
|
||||
describeRoute(ctx: RoutingContext): string {
|
||||
const result = this.route(ctx);
|
||||
|
||||
if (result.matchLevel === 'default') {
|
||||
return `→ ${result.agentId} (default agent)`;
|
||||
}
|
||||
|
||||
const binding = result.matchedBinding!;
|
||||
const matchDesc = [];
|
||||
matchDesc.push(`channel=${binding.match.channel}`);
|
||||
if (binding.match.accountId) {
|
||||
matchDesc.push(`account=${binding.match.accountId}`);
|
||||
}
|
||||
if (binding.match.peer) {
|
||||
matchDesc.push(`peer=${binding.match.peer.kind}:${binding.match.peer.id}`);
|
||||
}
|
||||
|
||||
return `→ ${result.agentId} (matched: ${matchDesc.join(', ')})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a router from normalized config
|
||||
*/
|
||||
export function createRouter(bindings: AgentBinding[], defaultAgentId: string): MessageRouter {
|
||||
return new MessageRouter(bindings, defaultAgentId);
|
||||
}
|
||||
3
src/types/telegramify-markdown.d.ts
vendored
3
src/types/telegramify-markdown.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
declare module 'telegramify-markdown' {
|
||||
export default function telegramifyMarkdown(input: string, strategy?: string): string;
|
||||
}
|
||||
@@ -10,14 +10,5 @@
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"vendor",
|
||||
"src/config/normalize.ts",
|
||||
"src/core/agent-instance.ts",
|
||||
"src/core/agent-manager.ts",
|
||||
"src/routing/**/*"
|
||||
]
|
||||
"exclude": ["node_modules", "dist", "vendor"]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user