feat: add set-conversation command (CLI, API, channel, portal) (#512)

This commit is contained in:
Cameron
2026-03-06 11:30:06 -08:00
committed by GitHub
parent 53c24bcdc7
commit 761de6d716
10 changed files with 467 additions and 28 deletions

8
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.11",
"@letta-ai/letta-code-sdk": "^0.1.9",
"@letta-ai/letta-code-sdk": "^0.1.10",
"@types/express": "^5.0.6",
"@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8",
@@ -1290,9 +1290,9 @@
}
},
"node_modules/@letta-ai/letta-code-sdk": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.9.tgz",
"integrity": "sha512-bk/Q9g9ob9RqQDge4aObPbWbmufaz71XhhApgORwkNh+OaMgbhHLJ5mye+ocHEGG4b/a6odRvWqNzIEX94aX+A==",
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.10.tgz",
"integrity": "sha512-idNRvPI6RbBho0jzm46NbMM4xjRPXLTvOniKbvimnlHDRkx6acsZy1exeu56Xmkpx83orvdcjqsuccBqnZFxNA==",
"license": "Apache-2.0",
"dependencies": {
"@letta-ai/letta-code": "0.17.1"

View File

@@ -67,7 +67,7 @@
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.11",
"@letta-ai/letta-code-sdk": "^0.1.9",
"@letta-ai/letta-code-sdk": "^0.1.10",
"@types/express": "^5.0.6",
"@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8",

View File

@@ -11,6 +11,7 @@ import { listPairingRequests, approvePairingCode } from '../pairing/store.js';
import { parseMultipart } from './multipart.js';
import type { AgentRouter } from '../core/interfaces.js';
import type { ChannelId } from '../core/types.js';
import type { Store } from '../core/store.js';
import {
generateCompletionId, extractLastUserMessage, buildCompletion,
buildChunk, buildToolCallChunk, formatSSE, SSE_DONE,
@@ -31,6 +32,9 @@ interface ServerOptions {
apiKey: string;
host?: string; // Bind address (default: 127.0.0.1 for security)
corsOrigin?: string; // CORS origin (default: same-origin only)
stores?: Map<string, Store>; // Agent stores for management endpoints
agentChannels?: Map<string, string[]>; // Channel IDs per agent name
sessionInvalidators?: Map<string, (key?: string) => void>; // Invalidate live sessions after store writes
}
/**
@@ -555,6 +559,150 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
return;
}
// Route: GET /api/v1/status - Agent status (conversation IDs, channels)
if (req.url === '/api/v1/status' && req.method === 'GET') {
try {
if (!validateApiKey(req.headers, options.apiKey)) {
sendError(res, 401, 'Unauthorized');
return;
}
const agents: Record<string, any> = {};
if (options.stores) {
for (const [name, store] of options.stores) {
const info = store.getInfo();
agents[name] = {
agentId: info.agentId,
conversationId: info.conversationId || null,
conversations: info.conversations || {},
channels: options.agentChannels?.get(name) || [],
baseUrl: info.baseUrl,
createdAt: info.createdAt,
lastUsedAt: info.lastUsedAt,
};
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ agents }));
} catch (error: any) {
log.error('Status error:', error);
sendError(res, 500, error.message || 'Internal server error');
}
return;
}
// Route: POST /api/v1/conversation - Set conversation ID
if (req.url === '/api/v1/conversation' && req.method === 'POST') {
try {
if (!validateApiKey(req.headers, options.apiKey)) {
sendError(res, 401, 'Unauthorized');
return;
}
if (!options.stores || options.stores.size === 0) {
sendError(res, 500, 'No stores configured');
return;
}
const body = await readBody(req, MAX_BODY_SIZE);
let request: { conversationId?: string; agent?: string; key?: string };
try {
request = JSON.parse(body);
} catch {
sendError(res, 400, 'Invalid JSON body');
return;
}
if (!request.conversationId || typeof request.conversationId !== 'string') {
sendError(res, 400, 'Missing required field: conversationId');
return;
}
// Resolve agent name (default to first store)
const agentName = request.agent || options.stores.keys().next().value!;
const store = options.stores.get(agentName);
if (!store) {
sendError(res, 404, `Agent not found: ${agentName}`);
return;
}
const key = request.key || 'shared';
if (key === 'shared') {
store.conversationId = request.conversationId;
} else {
store.setConversationId(key, request.conversationId);
}
// Invalidate the live session so the next message uses the new conversation
const invalidate = options.sessionInvalidators?.get(agentName);
if (invalidate) {
invalidate(key === 'shared' ? undefined : key);
}
log.info(`API set conversation: agent=${agentName} key=${key} conv=${request.conversationId}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, agent: agentName, key, conversationId: request.conversationId }));
} catch (error: any) {
log.error('Set conversation error:', error);
sendError(res, 500, error.message || 'Internal server error');
}
return;
}
// Route: GET /api/v1/conversations - List conversations from Letta API
if (req.url?.startsWith('/api/v1/conversations') && req.method === 'GET') {
try {
if (!validateApiKey(req.headers, options.apiKey)) {
sendError(res, 401, 'Unauthorized');
return;
}
if (!options.stores || options.stores.size === 0) {
sendError(res, 500, 'No stores configured');
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
const agentName = url.searchParams.get('agent') || options.stores.keys().next().value!;
const store = options.stores.get(agentName);
if (!store) {
sendError(res, 404, `Agent not found: ${agentName}`);
return;
}
const agentId = store.getInfo().agentId;
if (!agentId) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ conversations: [] }));
return;
}
const { Letta } = await import('@letta-ai/letta-client');
const client = new Letta({
apiKey: process.env.LETTA_API_KEY || '',
baseURL: process.env.LETTA_BASE_URL || 'https://api.letta.com',
});
const convos = await client.conversations.list({
agent_id: agentId,
limit: 50,
order: 'desc',
order_by: 'last_run_completion',
});
const conversations = convos.map(c => ({
id: c.id,
createdAt: c.created_at,
updatedAt: c.updated_at,
summary: c.summary || null,
messageCount: c.in_context_message_ids?.length || 0,
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ conversations }));
} catch (error: any) {
log.error('List conversations error:', error);
sendError(res, 500, error.message || 'Internal server error');
}
return;
}
// Route: GET /portal - Admin portal for pairing approvals
if ((req.url === '/portal' || req.url === '/portal/') && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -704,6 +852,33 @@ const portalHtml = `<!DOCTYPE html>
/* Status bar */
.status { font-size: 12px; color: #444; text-align: center; margin-top: 16px; }
/* Management tab */
.agent-card { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
.agent-card h3 { font-size: 14px; color: #fff; margin-bottom: 10px; }
.agent-field { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #1a1a1a; font-size: 13px; }
.agent-field:last-child { border-bottom: none; }
.agent-field .label { color: #888; }
.agent-field .value { color: #ccc; font-family: monospace; font-size: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; }
.conv-entry { padding: 4px 0 4px 12px; font-size: 12px; color: #888; font-family: monospace; }
.set-conv-form { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 16px; margin-top: 12px; }
.set-conv-form h3 { font-size: 14px; color: #fff; margin-bottom: 12px; }
.form-row { margin-bottom: 10px; }
.form-row label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; }
.form-row input, .form-row select { width: 100%; padding: 8px 10px; background: #0a0a0a; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 13px; font-family: monospace; }
.form-row input:focus, .form-row select:focus { outline: none; border-color: #555; }
.set-conv-btn { padding: 8px 20px; background: #1a7f37; color: #fff; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
.set-conv-btn:hover { background: #238636; }
.set-conv-btn:disabled { background: #333; color: #666; cursor: default; }
.conv-list { margin-top: 12px; }
.conv-list h3 { font-size: 14px; color: #fff; margin-bottom: 8px; }
.conv-row { display: flex; align-items: center; padding: 10px 12px; background: #141414; border: 1px solid #222; border-radius: 6px; margin-bottom: 4px; cursor: pointer; gap: 12px; }
.conv-row:hover { border-color: #444; }
.conv-row.active { border-color: #1a7f37; background: #0d1117; }
.conv-row .conv-id { font-family: monospace; font-size: 12px; color: #ccc; min-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv-row .conv-meta { flex: 1; font-size: 12px; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv-row .conv-msgs { font-size: 11px; color: #555; white-space: nowrap; }
.conv-loading { padding: 16px; text-align: center; color: #555; font-size: 13px; }
.hidden { display: none; }
</style>
</head>
@@ -728,8 +903,10 @@ const portalHtml = `<!DOCTYPE html>
<script>
const CHANNELS = ['telegram', 'discord', 'slack'];
let apiKey = sessionStorage.getItem('lbkey') || '';
let activeChannel = 'telegram';
let activeTab = 'telegram';
let data = {};
let statusData = {};
let convListData = {};
let refreshTimer;
function login() {
@@ -760,33 +937,138 @@ async function refresh() {
data[ch] = json.requests || [];
} catch (e) { if (e.message === 'Unauthorized') return; data[ch] = []; }
}
try {
const res = await apiFetch('/api/v1/status');
const json = await res.json();
statusData = json.agents || {};
} catch (e) { if (e.message === 'Unauthorized') return; }
renderTabs();
renderList();
renderContent();
if (activeTab === '__status__') loadConversations();
document.getElementById('status').textContent = 'Updated ' + new Date().toLocaleTimeString();
}
function renderTabs() {
const el = document.getElementById('tabs');
el.innerHTML = CHANNELS.map(ch => {
let html = CHANNELS.map(ch => {
const n = (data[ch] || []).length;
const cls = ch === activeChannel ? 'tab active' : 'tab';
const cls = ch === activeTab ? 'tab active' : 'tab';
const count = n > 0 ? '<span class="count">' + n + '</span>' : '';
return '<div class="' + cls + '" onclick="switchTab(\\'' + ch + '\\')">' + ch.charAt(0).toUpperCase() + ch.slice(1) + count + '</div>';
}).join('');
const statusCls = activeTab === '__status__' ? 'tab active' : 'tab';
html += '<div class="' + statusCls + '" onclick="switchTab(\\'__status__\\')">Status</div>';
el.innerHTML = html;
}
function renderContent() {
if (activeTab === '__status__') { renderStatus(); return; }
renderList();
}
function renderList() {
const el = document.getElementById('list');
const items = data[activeChannel] || [];
const items = data[activeTab] || [];
if (items.length === 0) { el.innerHTML = '<div class="empty">No pending pairing requests</div>'; return; }
el.innerHTML = items.map(r => {
const name = r.meta?.username ? '@' + r.meta.username : r.meta?.firstName || 'User ' + r.id;
const ago = timeAgo(r.createdAt);
return '<div class="request"><div class="code">' + esc(r.code) + '</div><div class="meta"><div class="name">' + esc(name) + '</div><div class="time">' + ago + '</div></div><button class="approve-btn" onclick="approve(\\'' + activeChannel + '\\',\\'' + r.code + '\\', this)">Approve</button></div>';
return '<div class="request"><div class="code">' + esc(r.code) + '</div><div class="meta"><div class="name">' + esc(name) + '</div><div class="time">' + ago + '</div></div><button class="approve-btn" onclick="approve(\\'' + activeTab + '\\',\\'' + r.code + '\\', this)">Approve</button></div>';
}).join('');
}
function switchTab(ch) { activeChannel = ch; renderTabs(); renderList(); }
function renderStatus() {
const el = document.getElementById('list');
const agents = Object.entries(statusData);
if (agents.length === 0) { el.innerHTML = '<div class="empty">No agents configured</div>'; return; }
let html = '';
const agentNames = agents.map(a => a[0]);
for (const [name, info] of agents) {
html += '<div class="agent-card"><h3>' + esc(name) + '</h3>';
html += '<div class="agent-field"><span class="label">Agent ID</span><span class="value">' + esc(info.agentId || '(none)') + '</span></div>';
html += '<div class="agent-field"><span class="label">Conversation</span><span class="value">' + esc(info.conversationId || '(none)') + '</span></div>';
const convs = Object.entries(info.conversations || {});
if (convs.length > 0) {
html += '<div class="agent-field"><span class="label">Per-key conversations</span><span class="value">' + convs.length + '</span></div>';
for (const [k, v] of convs) { html += '<div class="conv-entry">' + esc(k) + ' = ' + esc(v) + '</div>'; }
}
if (info.baseUrl) html += '<div class="agent-field"><span class="label">Server</span><span class="value">' + esc(info.baseUrl) + '</span></div>';
if (info.lastUsedAt) html += '<div class="agent-field"><span class="label">Last used</span><span class="value">' + esc(info.lastUsedAt) + '</span></div>';
html += '</div>';
// Conversation list from Letta API
const conversations = convListData[name] || [];
const activeConvId = info.conversationId;
html += '<div class="conv-list"><h3>Conversations</h3>';
if (conversations === 'loading') {
html += '<div class="conv-loading">Loading...</div>';
} else if (conversations.length === 0) {
html += '<div class="conv-loading">No conversations found</div>';
} else {
for (const c of conversations) {
const isActive = c.id === activeConvId ? ' active' : '';
const summary = c.summary ? esc(c.summary) : '';
const msgs = c.messageCount ? c.messageCount + ' msgs' : '';
const ago = c.updatedAt ? timeAgo(c.updatedAt) : '';
html += '<div class="conv-row' + isActive + '" onclick="pickConversation(\\'' + esc(c.id) + '\\',\\'' + esc(name) + '\\')">';
html += '<div class="conv-id">' + esc(c.id) + '</div>';
html += '<div class="conv-meta">' + (summary || ago) + '</div>';
html += '<div class="conv-msgs">' + msgs + '</div>';
html += '</div>';
}
}
html += '</div>';
}
html += '<div class="set-conv-form"><h3>Set Conversation</h3>';
html += '<div class="form-row"><label>Agent</label><select id="sc-agent">';
for (const n of agentNames) { html += '<option value="' + esc(n) + '">' + esc(n) + '</option>'; }
html += '</select></div>';
html += '<div class="form-row"><label>Key (leave empty for shared)</label><input id="sc-key" placeholder="e.g. telegram:12345"></div>';
html += '<div class="form-row"><label>Conversation ID</label><input id="sc-id" placeholder="conversation-xxx"></div>';
html += '<button class="set-conv-btn" onclick="setConversation(this)">Set Conversation</button>';
html += '</div>';
el.innerHTML = html;
}
async function loadConversations() {
for (const name of Object.keys(statusData)) {
convListData[name] = 'loading';
try {
const res = await apiFetch('/api/v1/conversations?agent=' + encodeURIComponent(name));
const json = await res.json();
convListData[name] = json.conversations || [];
} catch (e) { convListData[name] = []; }
}
if (activeTab === '__status__') renderStatus();
}
async function pickConversation(id, agent) {
try {
const res = await apiFetch('/api/v1/conversation', { method: 'POST', body: JSON.stringify({ conversationId: id, agent }) });
const json = await res.json();
if (json.success) { toast('Switched to ' + id.slice(0, 20) + '...'); await refresh(); }
else { toast(json.error || 'Failed', true); }
} catch (e) { toast('Error: ' + e.message, true); }
}
function switchTab(t) { activeTab = t; renderTabs(); renderContent(); }
async function setConversation(btn) {
const agent = document.getElementById('sc-agent').value;
const key = document.getElementById('sc-key').value.trim() || undefined;
const conversationId = document.getElementById('sc-id').value.trim();
if (!conversationId) { toast('Conversation ID is required', true); return; }
btn.disabled = true; btn.textContent = '...';
try {
const body = { conversationId, agent };
if (key) body.key = key;
const res = await apiFetch('/api/v1/conversation', { method: 'POST', body: JSON.stringify(body) });
const json = await res.json();
if (json.success) { toast('Conversation set'); await refresh(); }
else { toast(json.error || 'Failed', true); }
} catch (e) { toast('Error: ' + e.message, true); }
btn.disabled = false; btn.textContent = 'Set Conversation';
}
async function approve(channel, code, btn) {
btn.disabled = true; btn.textContent = '...';

View File

@@ -236,7 +236,7 @@ Ask the bot owner to approve with:
return;
}
if (this.onCommand) {
if (command === 'status' || command === 'reset' || command === 'heartbeat' || command === 'cancel' || command === 'model') {
if (command === 'status' || command === 'reset' || command === 'heartbeat' || command === 'cancel' || command === 'model' || command === 'setconv') {
const result = await this.onCommand(command, message.channel.id, cmdArgs);
if (result) {
await message.channel.send(result);

View File

@@ -268,6 +268,15 @@ export class TelegramAdapter implements ChannelAdapter {
}
});
// Handle /setconv <id>
this.bot.command('setconv', async (ctx) => {
if (this.onCommand) {
const args = ctx.match?.trim() || undefined;
const result = await this.onCommand('setconv', String(ctx.chat.id), args);
await ctx.reply(result || 'Failed to set conversation');
}
});
// Handle text messages
this.bot.on('message:text', async (ctx) => {
const userId = ctx.from?.id;

View File

@@ -247,6 +247,7 @@ Commands:
todo complete <id> Mark a todo complete
todo remove <id> Remove a todo
todo snooze <id> Snooze a todo until a date
set-conversation <id> Set a specific conversation ID
reset-conversation Clear conversation ID (fixes corrupted conversations)
destroy Delete all local data and start fresh
pairing list <ch> List pending pairing requests
@@ -507,6 +508,54 @@ async function main() {
break;
}
case 'set-conversation': {
const p = await import('@clack/prompts');
const config = getConfig();
const newConvId = subCommand;
if (!newConvId) {
console.error('Usage: lettabot set-conversation <conversation-id>');
process.exit(1);
}
p.intro('Set Conversation');
const configuredName =
(config.agent?.name?.trim())
|| (config.agents?.length && config.agents[0].name?.trim())
|| 'LettaBot';
const configuredAgents = (config.agents?.length ? config.agents : [{ name: configuredName }])
.map(agent => agent.name?.trim())
.filter((name): name is string => !!name);
const uniqueAgents = Array.from(new Set(configuredAgents));
let targetAgent = uniqueAgents[0];
if (uniqueAgents.length > 1) {
const choice = await p.select({
message: 'Which agent?',
options: uniqueAgents.map(name => ({ value: name, label: name })),
});
if (p.isCancel(choice)) {
p.cancel('Cancelled');
break;
}
targetAgent = choice as string;
}
const store = new Store('lettabot-agent.json', targetAgent);
const oldConvId = store.conversationId;
store.conversationId = newConvId;
if (oldConvId) {
p.log.info(`Previous conversation: ${oldConvId}`);
}
p.log.success(`Conversation set to: ${newConvId} (agent: ${targetAgent})`);
p.outro('Restart the server for the change to take effect.');
break;
}
case 'reset-conversation': {
const p = await import('@clack/prompts');
const config = getConfig();
@@ -631,7 +680,7 @@ async function main() {
case undefined:
console.log('Usage: lettabot <command>\n');
console.log('Commands: onboard, server, configure, connect, model, channels, skills, reset-conversation, destroy, help\n');
console.log('Commands: onboard, server, configure, connect, model, channels, skills, set-conversation, reset-conversation, destroy, help\n');
console.log('Run "lettabot help" for more information.');
break;

View File

@@ -187,7 +187,7 @@ export function resolveHeartbeatConversationKey(
}
export class LettaBot implements AgentSession {
private store: Store;
readonly store: Store;
private config: BotConfig;
private channels: Map<string, ChannelAdapter> = new Map();
private messageQueue: Array<{ msg: InboundMessage; adapter: ChannelAdapter }> = [];
@@ -243,20 +243,32 @@ export class LettaBot implements AgentSession {
return `${this.config.displayName}: ${text}`;
}
private normalizeResultRunIds(msg: StreamMsg): string[] {
// Forward-looking compatibility:
// - Current SDK releases often emit result.run_ids as null/undefined.
// - When runIds are absent, caller gets [] and falls back to streamed vs
// result text comparison (which works with today's wire payloads).
private normalizeStreamRunIds(msg: StreamMsg): string[] {
const ids: string[] = [];
const rawRunId = (msg as StreamMsg & { runId?: unknown; run_id?: unknown }).runId
?? (msg as StreamMsg & { run_id?: unknown }).run_id;
if (typeof rawRunId === 'string' && rawRunId.trim().length > 0) {
ids.push(rawRunId.trim());
}
const rawRunIds = (msg as StreamMsg & { runIds?: unknown; run_ids?: unknown }).runIds
?? (msg as StreamMsg & { run_ids?: unknown }).run_ids;
if (!Array.isArray(rawRunIds)) return [];
if (Array.isArray(rawRunIds)) {
for (const id of rawRunIds) {
if (typeof id === 'string' && id.trim().length > 0) {
ids.push(id.trim());
}
}
}
const runIds = rawRunIds.filter((id): id is string =>
typeof id === 'string' && id.trim().length > 0
);
if (ids.length === 0) return [];
return [...new Set(ids)];
}
private normalizeResultRunIds(msg: StreamMsg): string[] {
const runIds = this.normalizeStreamRunIds(msg);
if (runIds.length === 0) return [];
return [...new Set(runIds)].sort();
}
@@ -472,6 +484,14 @@ export class LettaBot implements AgentSession {
return this.sessionManager.warmSession();
}
/**
* Invalidate the live session for a conversation key.
* The next message will create a fresh session using the current store state.
*/
invalidateSession(key?: string): void {
this.sessionManager.invalidateSession(key);
}
// =========================================================================
// Channel management
// =========================================================================
@@ -655,6 +675,26 @@ export class LettaBot implements AgentSession {
lines.push('', 'Use `/model <handle>` to switch.');
return lines.join('\n');
}
case 'setconv': {
if (!args?.trim()) {
return 'Usage: /setconv <conversation-id>';
}
const newConvId = args.trim();
const convKey = channelId ? this.resolveConversationKey(channelId, chatId) : 'shared';
if (convKey === 'default') {
return 'Conversations are disabled -- cannot set conversation ID.';
}
if (convKey === 'shared') {
this.store.conversationId = newConvId;
} else {
this.store.setConversationId(convKey, newConvId);
}
this.sessionManager.invalidateSession(convKey);
log.info(`/setconv - conversation set to ${newConvId} for key="${convKey}"`);
return `Conversation set to: ${newConvId}`;
}
default:
return null;
}
@@ -980,6 +1020,10 @@ export class LettaBot implements AgentSession {
let lastErrorDetail: { message: string; stopReason: string; apiError?: Record<string, unknown>; isApprovalError?: boolean } | null = null;
let retryInfo: { attempt: number; maxAttempts: number; reason: string } | null = null;
let reasoningBuffer = '';
let expectedForegroundRunId: string | null = null;
let expectedForegroundRunSource: 'assistant' | 'result' | null = null;
let foregroundRunSwitchCount = 0;
let filteredRunEventCount = 0;
const msgTypeCounts: Record<string, number> = {};
const parseAndHandleDirectives = async () => {
@@ -1056,6 +1100,46 @@ export class LettaBot implements AgentSession {
break;
}
if (!firstChunkLogged) { lap('first stream chunk'); firstChunkLogged = true; }
const eventRunIds = this.normalizeStreamRunIds(streamMsg);
if (expectedForegroundRunId === null && eventRunIds.length > 0) {
if (streamMsg.type === 'assistant' || streamMsg.type === 'result') {
expectedForegroundRunId = eventRunIds[0];
expectedForegroundRunSource = streamMsg.type === 'assistant' ? 'assistant' : 'result';
log.info(`Selected foreground run for stream delivery (seq=${seq}, key=${convKey}, runId=${expectedForegroundRunId}, source=${streamMsg.type})`);
} else {
// Do not lock to a run based on pre-assistant non-terminal events;
// these can belong to a concurrent background run.
filteredRunEventCount++;
log.info(`Deferring run-scoped pre-foreground event (seq=${seq}, key=${convKey}, type=${streamMsg.type}, runIds=${eventRunIds.join(',')})`);
continue;
}
} else if (expectedForegroundRunId && eventRunIds.length > 0 && !eventRunIds.includes(expectedForegroundRunId)) {
const canSafelySwitchForeground = !sentAnyMessage || messageId !== null;
if (streamMsg.type === 'result'
&& foregroundRunSwitchCount === 0
&& canSafelySwitchForeground) {
const previousRunId = expectedForegroundRunId;
const previousRunSource = expectedForegroundRunSource;
expectedForegroundRunId = eventRunIds[0];
expectedForegroundRunSource = 'result';
foregroundRunSwitchCount += 1;
// Drop any state collected from the previous run so it cannot
// flush to user-facing delivery after the switch.
response = '';
reasoningBuffer = '';
streamedAssistantText = '';
lastMsgType = null;
lastAssistantUuid = null;
sawNonAssistantSinceLastUuid = false;
log.warn(`Switching foreground run at result boundary (seq=${seq}, key=${convKey}, from=${previousRunId}, to=${expectedForegroundRunId}, prevSource=${previousRunSource || 'unknown'})`);
} else {
filteredRunEventCount++;
log.info(`Skipping non-foreground stream event (seq=${seq}, key=${convKey}, type=${streamMsg.type}, runIds=${eventRunIds.join(',')}, expected=${expectedForegroundRunId})`);
continue;
}
}
receivedAnyData = true;
msgTypeCounts[streamMsg.type] = (msgTypeCounts[streamMsg.type] || 0) + 1;
@@ -1272,6 +1356,9 @@ export class LettaBot implements AgentSession {
log.debug(`Stream result preview: seq=${seq} responsePreview=${response.trim().slice(0, 60)}`);
}
log.info(`Stream message counts:`, msgTypeCounts);
if (filteredRunEventCount > 0) {
log.info(`Filtered ${filteredRunEventCount} non-foreground event(s) from stream (seq=${seq}, key=${convKey}, expectedRunId=${expectedForegroundRunId || 'unknown'})`);
}
if (streamMsg.error) {
const detail = resultText.trim();
const parts = [`error=${streamMsg.error}`];

View File

@@ -94,10 +94,11 @@ describe('COMMANDS', () => {
expect(COMMANDS).toContain('help');
expect(COMMANDS).toContain('start');
expect(COMMANDS).toContain('model');
expect(COMMANDS).toContain('setconv');
});
it('has exactly 6 commands', () => {
expect(COMMANDS).toHaveLength(7);
it('has exactly 8 commands', () => {
expect(COMMANDS).toHaveLength(8);
});
});

View File

@@ -4,7 +4,7 @@
* Shared command parsing and help text for all channels.
*/
export const COMMANDS = ['status', 'heartbeat', 'reset', 'cancel', 'help', 'start', 'model'] as const;
export const COMMANDS = ['status', 'heartbeat', 'reset', 'cancel', 'help', 'start', 'model', 'setconv'] as const;
export type Command = typeof COMMANDS[number];
export interface ParsedCommand {
@@ -21,6 +21,7 @@ Commands:
/cancel - Abort the current agent run
/model - Show current model and list available models
/model <handle> - Switch to a different model
/setconv <id> - Set conversation ID for this chat
/help - Show this message
Just send a message to get started!`;

View File

@@ -167,6 +167,7 @@ await refreshTokensIfNeeded();
import { normalizeAgents } from './config/types.js';
import { LettaGateway } from './core/gateway.js';
import { LettaBot } from './core/bot.js';
import type { Store } from './core/store.js';
import { TelegramAdapter } from './channels/telegram.js';
import { TelegramMTProtoAdapter } from './channels/telegram-mtproto.js';
import { SlackAdapter } from './channels/slack.js';
@@ -569,6 +570,9 @@ async function main() {
}
const gateway = new LettaGateway();
const agentStores = new Map<string, Store>();
const sessionInvalidators = new Map<string, (key?: string) => void>();
const agentChannelMap = new Map<string, string[]>();
const voiceMemoEnabled = isVoiceMemoConfigured();
const services: {
cronServices: CronService[],
@@ -775,6 +779,9 @@ async function main() {
}
gateway.addAgent(agentConfig.name, bot);
agentStores.set(agentConfig.name, bot.store);
sessionInvalidators.set(agentConfig.name, (key) => bot.invalidateSession(key));
agentChannelMap.set(agentConfig.name, adapters.map(a => a.id));
}
// Start all agents
@@ -793,6 +800,9 @@ async function main() {
apiKey: apiKey,
host: apiHost,
corsOrigin: apiCorsOrigin,
stores: agentStores,
agentChannels: agentChannelMap,
sessionInvalidators,
});
// Startup banner