refactor: extract errors.ts and display.ts from bot.ts (#435)
This commit is contained in:
@@ -8,36 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. formatQuestionsForChannel (extracted for testability)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mirror the private method's logic so we can test it directly.
|
||||
// If the shape drifts, the type-check on bot.ts will catch it.
|
||||
function formatQuestionsForChannel(questions: Array<{
|
||||
question: string;
|
||||
header: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
multiSelect: boolean;
|
||||
}>): string {
|
||||
const parts: string[] = [];
|
||||
for (const q of questions) {
|
||||
parts.push(`**${q.question}**`);
|
||||
parts.push('');
|
||||
for (let i = 0; i < q.options.length; i++) {
|
||||
parts.push(`${i + 1}. **${q.options[i].label}**`);
|
||||
parts.push(` ${q.options[i].description}`);
|
||||
}
|
||||
if (q.multiSelect) {
|
||||
parts.push('');
|
||||
parts.push('_(You can select multiple options)_');
|
||||
}
|
||||
}
|
||||
parts.push('');
|
||||
parts.push('_Reply with your choice (number, name, or your own answer)._');
|
||||
return parts.join('\n');
|
||||
}
|
||||
import { formatQuestionsForChannel } from './display.js';
|
||||
|
||||
describe('formatQuestionsForChannel', () => {
|
||||
test('single question with 2 options', () => {
|
||||
|
||||
383
src/core/bot.ts
383
src/core/bot.ts
@@ -10,7 +10,9 @@ import { access, unlink, realpath, stat, constants } from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { extname, resolve, join } from 'node:path';
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
import type { BotConfig, InboundMessage, TriggerContext } from './types.js';
|
||||
import type { BotConfig, InboundMessage, TriggerContext, StreamMsg } from './types.js';
|
||||
import { isApprovalConflictError, isConversationMissingError, isAgentMissingFromInitError, formatApiErrorForUser } from './errors.js';
|
||||
import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js';
|
||||
import type { AgentSession } from './interfaces.js';
|
||||
import { Store } from './store.js';
|
||||
import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel } from '../tools/letta-api.js';
|
||||
@@ -29,112 +31,6 @@ import { syncTodosFromTool } from '../todo/store.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
const log = createLogger('Bot');
|
||||
/**
|
||||
* Detect if an error is a 409 CONFLICT from an orphaned approval.
|
||||
*/
|
||||
function isApprovalConflictError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
if (msg.includes('waiting for approval')) return true;
|
||||
if (msg.includes('conflict') && msg.includes('approval')) return true;
|
||||
}
|
||||
const statusError = error as { status?: number };
|
||||
if (statusError?.status === 409) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if an error indicates a missing conversation or agent.
|
||||
* Only these errors should trigger the "create new conversation" fallback.
|
||||
* Auth, network, and protocol errors should NOT be retried.
|
||||
*/
|
||||
function isConversationMissingError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
if (msg.includes('not found')) return true;
|
||||
if (msg.includes('conversation') && (msg.includes('missing') || msg.includes('does not exist'))) return true;
|
||||
if (msg.includes('agent') && msg.includes('not found')) return true;
|
||||
}
|
||||
const statusError = error as { status?: number };
|
||||
if (statusError?.status === 404) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a session initialization error indicates the agent doesn't exist.
|
||||
* The SDK includes CLI stderr in the error message when the subprocess exits
|
||||
* before sending an init message. We check for agent-not-found indicators in
|
||||
* both the SDK-level message and the CLI stderr output it includes.
|
||||
*
|
||||
* This intentionally does NOT treat generic init failures (like "no init
|
||||
* message received") as recoverable. Those can be transient SDK/process
|
||||
* issues, and clearing persisted agent state in those cases can destroy
|
||||
* valid mappings.
|
||||
*/
|
||||
function isAgentMissingFromInitError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const msg = error.message.toLowerCase();
|
||||
const agentMissingPatterns = [
|
||||
/\bagent\b[^.\n]{0,80}\bnot found\b/,
|
||||
/\bnot found\b[^.\n]{0,80}\bagent\b/,
|
||||
/\bagent\b[^.\n]{0,80}\bdoes not exist\b/,
|
||||
/\bunknown agent\b/,
|
||||
/\bagent_not_found\b/,
|
||||
];
|
||||
return agentMissingPatterns.some((pattern) => pattern.test(msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a structured API error into a clear, user-facing message.
|
||||
* The `error` object comes from the SDK's new SDKErrorMessage type.
|
||||
*/
|
||||
function formatApiErrorForUser(error: { message: string; stopReason: string; apiError?: Record<string, unknown> }): string {
|
||||
const msg = error.message.toLowerCase();
|
||||
const apiError = error.apiError || {};
|
||||
const apiMsg = (typeof apiError.message === 'string' ? apiError.message : '').toLowerCase();
|
||||
const reasons: string[] = Array.isArray(apiError.reasons) ? apiError.reasons : [];
|
||||
|
||||
// Billing / credits exhausted
|
||||
if (msg.includes('out of credits') || apiMsg.includes('out of credits')) {
|
||||
return '(Out of credits for hosted inference. Add credits or enable auto-recharge at app.letta.com/settings/organization/usage.)';
|
||||
}
|
||||
|
||||
// Rate limiting / usage exceeded (429)
|
||||
if (msg.includes('rate limit') || msg.includes('429') || msg.includes('usage limit')
|
||||
|| apiMsg.includes('rate limit') || apiMsg.includes('usage limit')) {
|
||||
if (reasons.includes('premium-usage-exceeded') || msg.includes('hosted model usage limit')) {
|
||||
return '(Rate limited -- your Letta Cloud usage limit has been exceeded. Check your plan at app.letta.com.)';
|
||||
}
|
||||
const reasonStr = reasons.length > 0 ? `: ${reasons.join(', ')}` : '';
|
||||
return `(Rate limited${reasonStr}. Try again in a moment.)`;
|
||||
}
|
||||
|
||||
// 409 CONFLICT (concurrent request on same conversation)
|
||||
if (msg.includes('conflict') || msg.includes('409') || msg.includes('another request is currently being processed')) {
|
||||
return '(Another request is still processing on this conversation. Wait a moment and try again.)';
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('forbidden')) {
|
||||
return '(Authentication failed -- check your API key in lettabot.yaml.)';
|
||||
}
|
||||
|
||||
// Agent/conversation not found
|
||||
if (msg.includes('not found') || msg.includes('404')) {
|
||||
return '(Agent or conversation not found -- the configured agent may have been deleted. Try re-onboarding.)';
|
||||
}
|
||||
|
||||
// Server errors
|
||||
if (msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('internal server error')) {
|
||||
return '(Letta API server error -- try again in a moment.)';
|
||||
}
|
||||
|
||||
// Fallback: use the actual error message (truncated for safety)
|
||||
const detail = error.message.length > 200 ? error.message.slice(0, 200) + '...' : error.message;
|
||||
const trimmed = detail.replace(/[.\s]+$/, '');
|
||||
return `(Agent error: ${trimmed}. Try sending your message again.)`;
|
||||
}
|
||||
|
||||
const SUPPORTED_IMAGE_MIMES = new Set([
|
||||
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
|
||||
]);
|
||||
@@ -224,21 +120,7 @@ async function buildMultimodalMessage(
|
||||
return content.length > 1 ? content : formattedText;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream message type with toolCallId/uuid for dedup
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface StreamMsg {
|
||||
type: string;
|
||||
content?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
uuid?: string;
|
||||
isError?: boolean;
|
||||
result?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export { type StreamMsg } from './types.js';
|
||||
|
||||
export function isResponseDeliverySuppressed(msg: Pick<InboundMessage, 'isListeningMode'>): boolean {
|
||||
return msg.isListeningMode === true;
|
||||
@@ -368,233 +250,6 @@ export class LettaBot implements AgentSession {
|
||||
return `${this.config.displayName}: ${text}`;
|
||||
}
|
||||
|
||||
// ---- Tool call display ----
|
||||
|
||||
/**
|
||||
* Pretty display config for known tools.
|
||||
* `header`: bold verb shown to the user (e.g., "Searching")
|
||||
* `argKeys`: ordered preference list of fields to extract from toolInput
|
||||
* or tool_result JSON as the detail line
|
||||
* `format`: optional -- 'code' wraps the detail in backticks
|
||||
*/
|
||||
private static readonly TOOL_DISPLAY_MAP: Record<string, {
|
||||
header: string;
|
||||
argKeys: string[];
|
||||
format?: 'code';
|
||||
/** For 'code' format: if the first argKey value exceeds this length,
|
||||
* fall back to the next argKey shown as plain text instead. */
|
||||
adaptiveCodeThreshold?: number;
|
||||
/** Dynamic header based on tool input. When provided, the return value
|
||||
* replaces `header` entirely and no argKey detail is appended. */
|
||||
headerFn?: (input: Record<string, unknown>) => string;
|
||||
}> = {
|
||||
web_search: { header: 'Searching', argKeys: ['query'] },
|
||||
fetch_webpage: { header: 'Reading', argKeys: ['url'] },
|
||||
Bash: { header: 'Running', argKeys: ['command', 'description'], format: 'code', adaptiveCodeThreshold: 80 },
|
||||
Read: { header: 'Reading', argKeys: ['file_path'] },
|
||||
Edit: { header: 'Editing', argKeys: ['file_path'] },
|
||||
Write: { header: 'Writing', argKeys: ['file_path'] },
|
||||
Glob: { header: 'Finding files', argKeys: ['pattern'] },
|
||||
Grep: { header: 'Searching code', argKeys: ['pattern'] },
|
||||
Task: { header: 'Delegating', argKeys: ['description'] },
|
||||
conversation_search: { header: 'Searching conversation history', argKeys: ['query'] },
|
||||
archival_memory_search: { header: 'Searching archival memory', argKeys: ['query'] },
|
||||
run_code: { header: 'Running code', argKeys: ['code'], format: 'code' },
|
||||
note: { header: 'Taking note', argKeys: ['title', 'content'] },
|
||||
manage_todo: { header: 'Updating todos', argKeys: [] },
|
||||
TodoWrite: { header: 'Updating todos', argKeys: [] },
|
||||
Skill: {
|
||||
header: 'Loading skill',
|
||||
argKeys: ['skill'],
|
||||
headerFn: (input) => {
|
||||
const skill = input.skill as string | undefined;
|
||||
const command = (input.command as string | undefined) || (input.args as string | undefined);
|
||||
if (command === 'unload') return skill ? `Unloading ${skill}` : 'Unloading skill';
|
||||
if (command === 'refresh') return 'Refreshing skills';
|
||||
return skill ? `Loading ${skill}` : 'Loading skill';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a tool call for channel display.
|
||||
*
|
||||
* Known tools get a pretty verb-based header (e.g., **Searching**).
|
||||
* Unknown tools fall back to **Tool**\n<name> (<args>).
|
||||
*
|
||||
* When toolInput is empty (SDK streaming limitation -- the CLI only
|
||||
* forwards the first chunk before args are accumulated), we fall back
|
||||
* to extracting the detail from the tool_result content.
|
||||
*/
|
||||
private formatToolCallDisplay(streamMsg: StreamMsg, toolResult?: StreamMsg): string {
|
||||
const name = streamMsg.toolName || 'unknown';
|
||||
const display = LettaBot.TOOL_DISPLAY_MAP[name];
|
||||
|
||||
if (display) {
|
||||
// --- Dynamic header path (e.g., Skill tool with load/unload/refresh modes) ---
|
||||
if (display.headerFn) {
|
||||
const input = (streamMsg.toolInput as Record<string, unknown> | undefined) ?? {};
|
||||
return `**${display.headerFn(input)}**`;
|
||||
}
|
||||
|
||||
// --- Custom display path ---
|
||||
const detail = this.extractToolDetail(display.argKeys, streamMsg, toolResult);
|
||||
if (detail) {
|
||||
let formatted: string;
|
||||
if (display.format === 'code' && display.adaptiveCodeThreshold) {
|
||||
// Adaptive: short values get code format, long values fall back to
|
||||
// the next argKey as plain text (e.g., Bash shows `command` for short
|
||||
// commands, but the human-readable `description` for long ones).
|
||||
if (detail.length <= display.adaptiveCodeThreshold) {
|
||||
formatted = `\`${detail}\``;
|
||||
} else {
|
||||
const fallback = this.extractToolDetail(display.argKeys.slice(1), streamMsg, toolResult);
|
||||
formatted = fallback || detail.slice(0, display.adaptiveCodeThreshold) + '...';
|
||||
}
|
||||
} else {
|
||||
formatted = display.format === 'code' ? `\`${detail}\`` : detail;
|
||||
}
|
||||
return `**${display.header}**\n${formatted}`;
|
||||
}
|
||||
return `**${display.header}**`;
|
||||
}
|
||||
|
||||
// --- Generic fallback for unknown tools ---
|
||||
let params = this.abbreviateToolInput(streamMsg);
|
||||
if (!params && toolResult?.content) {
|
||||
params = this.extractInputFromToolResult(toolResult.content);
|
||||
}
|
||||
return params ? `**Tool**\n${name} (${params})` : `**Tool**\n${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first matching detail string from a tool call's input or
|
||||
* the subsequent tool_result content (fallback for empty toolInput).
|
||||
*/
|
||||
private extractToolDetail(
|
||||
argKeys: string[],
|
||||
streamMsg: StreamMsg,
|
||||
toolResult?: StreamMsg,
|
||||
): string {
|
||||
if (argKeys.length === 0) return '';
|
||||
|
||||
// 1. Try toolInput (primary -- when SDK provides args)
|
||||
const input = streamMsg.toolInput as Record<string, unknown> | undefined;
|
||||
if (input && typeof input === 'object') {
|
||||
for (const key of argKeys) {
|
||||
const val = input[key];
|
||||
if (typeof val === 'string' && val.length > 0) {
|
||||
return val.length > 120 ? val.slice(0, 117) + '...' : val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try tool_result content (fallback for empty toolInput)
|
||||
if (toolResult?.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolResult.content);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
for (const key of argKeys) {
|
||||
const val = (parsed as Record<string, unknown>)[key];
|
||||
if (typeof val === 'string' && val.length > 0) {
|
||||
return val.length > 120 ? val.slice(0, 117) + '...' : val;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* non-JSON result -- skip */ }
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a brief parameter summary from a tool call's input.
|
||||
* Used only by the generic fallback display path.
|
||||
*/
|
||||
private abbreviateToolInput(streamMsg: StreamMsg): string {
|
||||
const input = streamMsg.toolInput as Record<string, unknown> | undefined;
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
// Filter out undefined/null values (SDK yields {raw: undefined} for partial chunks)
|
||||
const entries = Object.entries(input).filter(([, v]) => v != null).slice(0, 2);
|
||||
return entries
|
||||
.map(([k, v]) => {
|
||||
let str: string;
|
||||
try {
|
||||
str = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v));
|
||||
} catch {
|
||||
str = String(v);
|
||||
}
|
||||
const truncated = str.length > 80 ? str.slice(0, 77) + '...' : str;
|
||||
return `${k}: ${truncated}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: extract input parameters from a tool_result's content.
|
||||
* Some tools echo their input in the result (e.g., web_search includes
|
||||
* `query`). Used only by the generic fallback display path.
|
||||
*/
|
||||
private extractInputFromToolResult(content: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return '';
|
||||
|
||||
const inputKeys = ['query', 'input', 'prompt', 'url', 'search_query', 'text'];
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const key of inputKeys) {
|
||||
const val = (parsed as Record<string, unknown>)[key];
|
||||
if (typeof val === 'string' && val.length > 0) {
|
||||
const truncated = val.length > 80 ? val.slice(0, 77) + '...' : val;
|
||||
parts.push(`${key}: ${truncated}`);
|
||||
if (parts.length >= 2) break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reasoning text for channel display, respecting truncation config.
|
||||
* Returns { text, parseMode? } -- Telegram gets HTML with <blockquote> to
|
||||
* bypass telegramify-markdown (which adds unwanted spaces to blockquotes).
|
||||
* Signal falls back to italic (no blockquote support).
|
||||
* Discord/Slack use markdown blockquotes.
|
||||
*/
|
||||
private formatReasoningDisplay(text: string, channelId?: string): { text: string; parseMode?: string } {
|
||||
const maxChars = this.config.display?.reasoningMaxChars ?? 0;
|
||||
// Trim leading whitespace from each line -- the API often includes leading
|
||||
// spaces in reasoning chunks that look wrong in channel output.
|
||||
const cleaned = text.split('\n').map(line => line.trimStart()).join('\n').trim();
|
||||
const truncated = maxChars > 0 && cleaned.length > maxChars
|
||||
? cleaned.slice(0, maxChars) + '...'
|
||||
: cleaned;
|
||||
|
||||
if (channelId === 'signal') {
|
||||
// Signal: no blockquote support, use italic
|
||||
return { text: `**Thinking**\n_${truncated}_` };
|
||||
}
|
||||
if (channelId === 'telegram' || channelId === 'telegram-mtproto') {
|
||||
// Telegram: use HTML blockquote to bypass telegramify-markdown spacing
|
||||
const escaped = truncated
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return {
|
||||
text: `<blockquote expandable><b>Thinking</b>\n${escaped}</blockquote>`,
|
||||
parseMode: 'HTML',
|
||||
};
|
||||
}
|
||||
// Discord, Slack, etc: markdown blockquote
|
||||
const lines = truncated.split('\n');
|
||||
const quoted = lines.map(line => `> ${line}`).join('\n');
|
||||
return { text: `> **Thinking**\n${quoted}` };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Session options (shared by processMessage and sendToAgent)
|
||||
// =========================================================================
|
||||
@@ -703,30 +358,6 @@ export class LettaBot implements AgentSession {
|
||||
* Format AskUserQuestion questions as a single channel message.
|
||||
* Displays each question with numbered options for the user to choose from.
|
||||
*/
|
||||
private formatQuestionsForChannel(questions: Array<{
|
||||
question: string;
|
||||
header: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
multiSelect: boolean;
|
||||
}>): string {
|
||||
const parts: string[] = [];
|
||||
for (const q of questions) {
|
||||
parts.push(`**${q.question}**`);
|
||||
parts.push('');
|
||||
for (let i = 0; i < q.options.length; i++) {
|
||||
parts.push(`${i + 1}. **${q.options[i].label}**`);
|
||||
parts.push(` ${q.options[i].description}`);
|
||||
}
|
||||
if (q.multiSelect) {
|
||||
parts.push('');
|
||||
parts.push('_(You can select multiple options)_');
|
||||
}
|
||||
}
|
||||
parts.push('');
|
||||
parts.push('_Reply with your choice (number, name, or your own answer)._');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Session lifecycle helpers
|
||||
// =========================================================================
|
||||
@@ -1844,7 +1475,7 @@ export class LettaBot implements AgentSession {
|
||||
options: Array<{ label: string; description: string }>;
|
||||
multiSelect: boolean;
|
||||
}>;
|
||||
const questionText = this.formatQuestionsForChannel(questions);
|
||||
const questionText = formatQuestionsForChannel(questions);
|
||||
log.info(`AskUserQuestion: sending ${questions.length} question(s) to ${msg.channel}:${msg.chatId}`);
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: questionText, threadId: msg.threadId });
|
||||
|
||||
@@ -1986,7 +1617,7 @@ export class LettaBot implements AgentSession {
|
||||
log.info(`Reasoning: ${reasoningBuffer.trim()}`);
|
||||
if (this.config.display?.showReasoning && !suppressDelivery) {
|
||||
try {
|
||||
const reasoning = this.formatReasoningDisplay(reasoningBuffer, adapter.id);
|
||||
const reasoning = formatReasoningDisplay(reasoningBuffer, adapter.id, this.config.display?.reasoningMaxChars);
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text: reasoning.text, threadId: msg.threadId, parseMode: reasoning.parseMode });
|
||||
// Note: display messages don't set sentAnyMessage -- they're informational,
|
||||
// not a substitute for an assistant response. Error handling and retry must
|
||||
@@ -2019,7 +1650,7 @@ export class LettaBot implements AgentSession {
|
||||
// Display tool call (args are fully accumulated by dedupedStream buffer-and-flush)
|
||||
if (this.config.display?.showToolCalls && !suppressDelivery) {
|
||||
try {
|
||||
const text = this.formatToolCallDisplay(streamMsg);
|
||||
const text = formatToolCallDisplay(streamMsg);
|
||||
await adapter.sendMessage({ chatId: msg.chatId, text, threadId: msg.threadId });
|
||||
} catch (err) {
|
||||
log.warn('Failed to send tool call display:', err instanceof Error ? err.message : err);
|
||||
|
||||
119
src/core/display.test.ts
Normal file
119
src/core/display.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatQuestionsForChannel, formatReasoningDisplay, formatToolCallDisplay } from './display.js';
|
||||
import type { StreamMsg } from './types.js';
|
||||
|
||||
describe('formatToolCallDisplay', () => {
|
||||
it('formats known tools using configured header and detail from toolInput', () => {
|
||||
const streamMsg: StreamMsg = {
|
||||
type: 'tool_call',
|
||||
toolName: 'web_search',
|
||||
toolInput: { query: 'weather in SF' },
|
||||
};
|
||||
|
||||
expect(formatToolCallDisplay(streamMsg)).toBe('**Searching**\nweather in SF');
|
||||
});
|
||||
|
||||
it('formats Bash tool with inline code for short commands', () => {
|
||||
const streamMsg: StreamMsg = {
|
||||
type: 'tool_call',
|
||||
toolName: 'Bash',
|
||||
toolInput: { command: 'ls -la' },
|
||||
};
|
||||
|
||||
expect(formatToolCallDisplay(streamMsg)).toBe('**Running**\n`ls -la`');
|
||||
});
|
||||
|
||||
it('falls back to description for long Bash commands', () => {
|
||||
const streamMsg: StreamMsg = {
|
||||
type: 'tool_call',
|
||||
toolName: 'Bash',
|
||||
toolInput: {
|
||||
command: 'x'.repeat(121),
|
||||
description: 'Install project dependencies',
|
||||
},
|
||||
};
|
||||
|
||||
expect(formatToolCallDisplay(streamMsg)).toBe('**Running**\nInstall project dependencies');
|
||||
});
|
||||
|
||||
it('uses dynamic headers for Skill tool actions', () => {
|
||||
const unloadMsg: StreamMsg = {
|
||||
type: 'tool_call',
|
||||
toolName: 'Skill',
|
||||
toolInput: { command: 'unload', skill: 'web-design-guidelines' },
|
||||
};
|
||||
const refreshMsg: StreamMsg = {
|
||||
type: 'tool_call',
|
||||
toolName: 'Skill',
|
||||
toolInput: { command: 'refresh' },
|
||||
};
|
||||
|
||||
expect(formatToolCallDisplay(unloadMsg)).toBe('**Unloading web-design-guidelines**');
|
||||
expect(formatToolCallDisplay(refreshMsg)).toBe('**Refreshing skills**');
|
||||
});
|
||||
|
||||
it('uses tool_result content when toolInput is unavailable', () => {
|
||||
const streamMsg: StreamMsg = {
|
||||
type: 'tool_call',
|
||||
toolName: 'web_search',
|
||||
};
|
||||
const toolResult: StreamMsg = {
|
||||
type: 'tool_result',
|
||||
content: JSON.stringify({ query: 'latest ts features' }),
|
||||
};
|
||||
|
||||
expect(formatToolCallDisplay(streamMsg, toolResult)).toBe('**Searching**\nlatest ts features');
|
||||
});
|
||||
|
||||
it('uses generic fallback formatting for unknown tools', () => {
|
||||
const streamMsg: StreamMsg = {
|
||||
type: 'tool_call',
|
||||
toolName: 'my_custom_tool',
|
||||
toolInput: { foo: 'bar' },
|
||||
};
|
||||
|
||||
expect(formatToolCallDisplay(streamMsg)).toBe('**Tool**\nmy_custom_tool (foo: bar)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReasoningDisplay', () => {
|
||||
it('formats signal output as italic text', () => {
|
||||
const result = formatReasoningDisplay(' think\n deeply', 'signal');
|
||||
expect(result).toEqual({ text: '**Thinking**\n_think\ndeeply_' });
|
||||
});
|
||||
|
||||
it('formats telegram output as escaped HTML blockquote', () => {
|
||||
const result = formatReasoningDisplay('a < b & c > d', 'telegram');
|
||||
expect(result.parseMode).toBe('HTML');
|
||||
expect(result.text).toBe('<blockquote expandable><b>Thinking</b>\na < b & c > d</blockquote>');
|
||||
});
|
||||
|
||||
it('formats non-signal/telegram channels as markdown blockquote', () => {
|
||||
const result = formatReasoningDisplay('line 1\n line 2', 'discord');
|
||||
expect(result).toEqual({ text: '> **Thinking**\n> line 1\n> line 2' });
|
||||
});
|
||||
|
||||
it('truncates when reasoningMaxChars is set', () => {
|
||||
const result = formatReasoningDisplay(' 1234567890', undefined, 5);
|
||||
expect(result.text).toBe('> **Thinking**\n> 12345...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatQuestionsForChannel', () => {
|
||||
it('formats a single question with numbered options and reply hint', () => {
|
||||
const output = formatQuestionsForChannel([{
|
||||
question: 'Choose a stack?',
|
||||
header: 'Stack',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Preferred default' },
|
||||
{ label: 'Python', description: 'Fast prototyping' },
|
||||
],
|
||||
multiSelect: false,
|
||||
}]);
|
||||
|
||||
expect(output).toContain('**Choose a stack?**');
|
||||
expect(output).toContain('1. **TypeScript**');
|
||||
expect(output).toContain('2. **Python**');
|
||||
expect(output).toContain('_Reply with your choice');
|
||||
});
|
||||
});
|
||||
275
src/core/display.ts
Normal file
275
src/core/display.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Display formatting for tool calls, reasoning, and interactive questions.
|
||||
*
|
||||
* Pure functions extracted from LettaBot -- no class state needed.
|
||||
*/
|
||||
|
||||
import type { StreamMsg } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool call display config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pretty display config for known tools.
|
||||
* `header`: bold verb shown to the user (e.g., "Searching")
|
||||
* `argKeys`: ordered preference list of fields to extract from toolInput
|
||||
* or tool_result JSON as the detail line
|
||||
* `format`: optional -- 'code' wraps the detail in backticks
|
||||
*/
|
||||
const TOOL_DISPLAY_MAP: Record<string, {
|
||||
header: string;
|
||||
argKeys: string[];
|
||||
format?: 'code';
|
||||
/** For 'code' format: if the first argKey value exceeds this length,
|
||||
* fall back to the next argKey shown as plain text instead. */
|
||||
adaptiveCodeThreshold?: number;
|
||||
/** Dynamic header based on tool input. When provided, the return value
|
||||
* replaces `header` entirely and no argKey detail is appended. */
|
||||
headerFn?: (input: Record<string, unknown>) => string;
|
||||
}> = {
|
||||
web_search: { header: 'Searching', argKeys: ['query'] },
|
||||
fetch_webpage: { header: 'Reading', argKeys: ['url'] },
|
||||
Bash: { header: 'Running', argKeys: ['command', 'description'], format: 'code', adaptiveCodeThreshold: 80 },
|
||||
Read: { header: 'Reading', argKeys: ['file_path'] },
|
||||
Edit: { header: 'Editing', argKeys: ['file_path'] },
|
||||
Write: { header: 'Writing', argKeys: ['file_path'] },
|
||||
Glob: { header: 'Finding files', argKeys: ['pattern'] },
|
||||
Grep: { header: 'Searching code', argKeys: ['pattern'] },
|
||||
Task: { header: 'Delegating', argKeys: ['description'] },
|
||||
conversation_search: { header: 'Searching conversation history', argKeys: ['query'] },
|
||||
archival_memory_search: { header: 'Searching archival memory', argKeys: ['query'] },
|
||||
run_code: { header: 'Running code', argKeys: ['code'], format: 'code' },
|
||||
note: { header: 'Taking note', argKeys: ['title', 'content'] },
|
||||
manage_todo: { header: 'Updating todos', argKeys: [] },
|
||||
TodoWrite: { header: 'Updating todos', argKeys: [] },
|
||||
Skill: {
|
||||
header: 'Loading skill',
|
||||
argKeys: ['skill'],
|
||||
headerFn: (input) => {
|
||||
const skill = input.skill as string | undefined;
|
||||
const command = (input.command as string | undefined) || (input.args as string | undefined);
|
||||
if (command === 'unload') return skill ? `Unloading ${skill}` : 'Unloading skill';
|
||||
if (command === 'refresh') return 'Refreshing skills';
|
||||
return skill ? `Loading ${skill}` : 'Loading skill';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool detail extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract the first matching detail string from a tool call's input or
|
||||
* the subsequent tool_result content (fallback for empty toolInput).
|
||||
*/
|
||||
function extractToolDetail(
|
||||
argKeys: string[],
|
||||
streamMsg: StreamMsg,
|
||||
toolResult?: StreamMsg,
|
||||
): string {
|
||||
if (argKeys.length === 0) return '';
|
||||
|
||||
// 1. Try toolInput (primary -- when SDK provides args)
|
||||
const input = streamMsg.toolInput as Record<string, unknown> | undefined;
|
||||
if (input && typeof input === 'object') {
|
||||
for (const key of argKeys) {
|
||||
const val = input[key];
|
||||
if (typeof val === 'string' && val.length > 0) {
|
||||
return val.length > 120 ? val.slice(0, 117) + '...' : val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try tool_result content (fallback for empty toolInput)
|
||||
if (toolResult?.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolResult.content);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
for (const key of argKeys) {
|
||||
const val = (parsed as Record<string, unknown>)[key];
|
||||
if (typeof val === 'string' && val.length > 0) {
|
||||
return val.length > 120 ? val.slice(0, 117) + '...' : val;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* non-JSON result -- skip */ }
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a brief parameter summary from a tool call's input.
|
||||
* Used only by the generic fallback display path.
|
||||
*/
|
||||
function abbreviateToolInput(streamMsg: StreamMsg): string {
|
||||
const input = streamMsg.toolInput as Record<string, unknown> | undefined;
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
// Filter out undefined/null values (SDK yields {raw: undefined} for partial chunks)
|
||||
const entries = Object.entries(input).filter(([, v]) => v != null).slice(0, 2);
|
||||
return entries
|
||||
.map(([k, v]) => {
|
||||
let str: string;
|
||||
try {
|
||||
str = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v));
|
||||
} catch {
|
||||
str = String(v);
|
||||
}
|
||||
const truncated = str.length > 80 ? str.slice(0, 77) + '...' : str;
|
||||
return `${k}: ${truncated}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: extract input parameters from a tool_result's content.
|
||||
* Some tools echo their input in the result (e.g., web_search includes
|
||||
* `query`). Used only by the generic fallback display path.
|
||||
*/
|
||||
function extractInputFromToolResult(content: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return '';
|
||||
|
||||
const inputKeys = ['query', 'input', 'prompt', 'url', 'search_query', 'text'];
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const key of inputKeys) {
|
||||
const val = (parsed as Record<string, unknown>)[key];
|
||||
if (typeof val === 'string' && val.length > 0) {
|
||||
const truncated = val.length > 80 ? val.slice(0, 77) + '...' : val;
|
||||
parts.push(`${key}: ${truncated}`);
|
||||
if (parts.length >= 2) break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public display functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a tool call for channel display.
|
||||
*
|
||||
* Known tools get a pretty verb-based header (e.g., **Searching**).
|
||||
* Unknown tools fall back to **Tool**\n<name> (<args>).
|
||||
*
|
||||
* When toolInput is empty (SDK streaming limitation -- the CLI only
|
||||
* forwards the first chunk before args are accumulated), we fall back
|
||||
* to extracting the detail from the tool_result content.
|
||||
*/
|
||||
export function formatToolCallDisplay(streamMsg: StreamMsg, toolResult?: StreamMsg): string {
|
||||
const name = streamMsg.toolName || 'unknown';
|
||||
const display = TOOL_DISPLAY_MAP[name];
|
||||
|
||||
if (display) {
|
||||
// --- Dynamic header path (e.g., Skill tool with load/unload/refresh modes) ---
|
||||
if (display.headerFn) {
|
||||
const input = (streamMsg.toolInput as Record<string, unknown> | undefined) ?? {};
|
||||
return `**${display.headerFn(input)}**`;
|
||||
}
|
||||
|
||||
// --- Custom display path ---
|
||||
const detail = extractToolDetail(display.argKeys, streamMsg, toolResult);
|
||||
if (detail) {
|
||||
let formatted: string;
|
||||
if (display.format === 'code' && display.adaptiveCodeThreshold) {
|
||||
// Adaptive: short values get code format, long values fall back to
|
||||
// the next argKey as plain text (e.g., Bash shows `command` for short
|
||||
// commands, but the human-readable `description` for long ones).
|
||||
if (detail.length <= display.adaptiveCodeThreshold) {
|
||||
formatted = `\`${detail}\``;
|
||||
} else {
|
||||
const fallback = extractToolDetail(display.argKeys.slice(1), streamMsg, toolResult);
|
||||
formatted = fallback || detail.slice(0, display.adaptiveCodeThreshold) + '...';
|
||||
}
|
||||
} else {
|
||||
formatted = display.format === 'code' ? `\`${detail}\`` : detail;
|
||||
}
|
||||
return `**${display.header}**\n${formatted}`;
|
||||
}
|
||||
return `**${display.header}**`;
|
||||
}
|
||||
|
||||
// --- Generic fallback for unknown tools ---
|
||||
let params = abbreviateToolInput(streamMsg);
|
||||
if (!params && toolResult?.content) {
|
||||
params = extractInputFromToolResult(toolResult.content);
|
||||
}
|
||||
return params ? `**Tool**\n${name} (${params})` : `**Tool**\n${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reasoning text for channel display, respecting truncation config.
|
||||
* Returns { text, parseMode? } -- Telegram gets HTML with <blockquote> to
|
||||
* bypass telegramify-markdown (which adds unwanted spaces to blockquotes).
|
||||
* Signal falls back to italic (no blockquote support).
|
||||
* Discord/Slack use markdown blockquotes.
|
||||
*/
|
||||
export function formatReasoningDisplay(
|
||||
text: string,
|
||||
channelId?: string,
|
||||
reasoningMaxChars?: number,
|
||||
): { text: string; parseMode?: string } {
|
||||
const maxChars = reasoningMaxChars ?? 0;
|
||||
// Trim leading whitespace from each line -- the API often includes leading
|
||||
// spaces in reasoning chunks that look wrong in channel output.
|
||||
const cleaned = text.split('\n').map(line => line.trimStart()).join('\n').trim();
|
||||
const truncated = maxChars > 0 && cleaned.length > maxChars
|
||||
? cleaned.slice(0, maxChars) + '...'
|
||||
: cleaned;
|
||||
|
||||
if (channelId === 'signal') {
|
||||
// Signal: no blockquote support, use italic
|
||||
return { text: `**Thinking**\n_${truncated}_` };
|
||||
}
|
||||
if (channelId === 'telegram' || channelId === 'telegram-mtproto') {
|
||||
// Telegram: use HTML blockquote to bypass telegramify-markdown spacing
|
||||
const escaped = truncated
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return {
|
||||
text: `<blockquote expandable><b>Thinking</b>\n${escaped}</blockquote>`,
|
||||
parseMode: 'HTML',
|
||||
};
|
||||
}
|
||||
// Discord, Slack, etc: markdown blockquote
|
||||
const lines = truncated.split('\n');
|
||||
const quoted = lines.map(line => `> ${line}`).join('\n');
|
||||
return { text: `> **Thinking**\n${quoted}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format AskUserQuestion options for channel display.
|
||||
*/
|
||||
export function formatQuestionsForChannel(questions: Array<{
|
||||
question: string;
|
||||
header: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
multiSelect: boolean;
|
||||
}>): string {
|
||||
const parts: string[] = [];
|
||||
for (const q of questions) {
|
||||
parts.push(`**${q.question}**`);
|
||||
parts.push('');
|
||||
for (let i = 0; i < q.options.length; i++) {
|
||||
parts.push(`${i + 1}. **${q.options[i].label}**`);
|
||||
parts.push(` ${q.options[i].description}`);
|
||||
}
|
||||
if (q.multiSelect) {
|
||||
parts.push('');
|
||||
parts.push('_(You can select multiple options)_');
|
||||
}
|
||||
}
|
||||
parts.push('');
|
||||
parts.push('_Reply with your choice (number, name, or your own answer)._');
|
||||
return parts.join('\n');
|
||||
}
|
||||
92
src/core/errors.test.ts
Normal file
92
src/core/errors.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatApiErrorForUser,
|
||||
isAgentMissingFromInitError,
|
||||
isApprovalConflictError,
|
||||
isConversationMissingError,
|
||||
} from './errors.js';
|
||||
|
||||
describe('isApprovalConflictError', () => {
|
||||
it('returns true for approval conflict message and 409 status', () => {
|
||||
expect(isApprovalConflictError(new Error('Run is waiting for approval'))).toBe(true);
|
||||
expect(isApprovalConflictError({ status: 409 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-conflict errors', () => {
|
||||
expect(isApprovalConflictError(new Error('network timeout'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConversationMissingError', () => {
|
||||
it('returns true for missing conversation/agent message and 404 status', () => {
|
||||
expect(isConversationMissingError(new Error('conversation does not exist'))).toBe(true);
|
||||
expect(isConversationMissingError(new Error('agent not found'))).toBe(true);
|
||||
expect(isConversationMissingError({ status: 404 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unrelated errors', () => {
|
||||
expect(isConversationMissingError(new Error('unauthorized'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAgentMissingFromInitError', () => {
|
||||
it('matches known agent-missing patterns', () => {
|
||||
expect(isAgentMissingFromInitError(new Error('failed: unknown agent in config'))).toBe(true);
|
||||
expect(isAgentMissingFromInitError(new Error('stderr: agent_not_found'))).toBe(true);
|
||||
expect(isAgentMissingFromInitError(new Error('Agent abc was not found by server'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match generic init failures', () => {
|
||||
expect(isAgentMissingFromInitError(new Error('no init message received from subprocess'))).toBe(false);
|
||||
expect(isAgentMissingFromInitError({ status: 404 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatApiErrorForUser', () => {
|
||||
it('maps out-of-credits messages', () => {
|
||||
const msg = formatApiErrorForUser({
|
||||
message: 'Request failed: out of credits',
|
||||
stopReason: 'error',
|
||||
});
|
||||
expect(msg).toContain('Out of credits');
|
||||
});
|
||||
|
||||
it('maps premium usage exceeded rate limits', () => {
|
||||
const msg = formatApiErrorForUser({
|
||||
message: '429 rate limit',
|
||||
stopReason: 'error',
|
||||
apiError: { reasons: ['premium-usage-exceeded'] },
|
||||
});
|
||||
expect(msg).toContain('usage limit has been exceeded');
|
||||
});
|
||||
|
||||
it('maps generic rate limits with reason details', () => {
|
||||
const msg = formatApiErrorForUser({
|
||||
message: '429 rate limit',
|
||||
stopReason: 'error',
|
||||
apiError: { reasons: ['burst', 'per-minute'] },
|
||||
});
|
||||
expect(msg).toBe('(Rate limited: burst, per-minute. Try again in a moment.)');
|
||||
});
|
||||
|
||||
it('maps auth, not found, conflict, and server errors', () => {
|
||||
expect(formatApiErrorForUser({ message: '401 unauthorized', stopReason: 'error' }))
|
||||
.toContain('Authentication failed');
|
||||
expect(formatApiErrorForUser({ message: '404 not found', stopReason: 'error' }))
|
||||
.toContain('not found');
|
||||
expect(formatApiErrorForUser({ message: '409 conflict', stopReason: 'error' }))
|
||||
.toContain('Another request is still processing');
|
||||
expect(formatApiErrorForUser({ message: '503 internal server error', stopReason: 'error' }))
|
||||
.toContain('server error');
|
||||
});
|
||||
|
||||
it('falls back to sanitized original message when no mapping matches', () => {
|
||||
const msg = formatApiErrorForUser({
|
||||
message: `${'x'.repeat(205)}. `,
|
||||
stopReason: 'error',
|
||||
});
|
||||
const match = msg.match(/^\(Agent error: (.+)\. Try sending your message again\.\)$/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1]).toBe('x'.repeat(200));
|
||||
});
|
||||
});
|
||||
111
src/core/errors.ts
Normal file
111
src/core/errors.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Error classification and user-facing error formatting.
|
||||
*
|
||||
* Extracted from bot.ts to keep error logic isolated and testable.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detect if an error is a 409 CONFLICT from an orphaned approval.
|
||||
*/
|
||||
export function isApprovalConflictError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
if (msg.includes('waiting for approval')) return true;
|
||||
if (msg.includes('conflict') && msg.includes('approval')) return true;
|
||||
}
|
||||
const statusError = error as { status?: number };
|
||||
if (statusError?.status === 409) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if an error indicates a missing conversation or agent.
|
||||
* Only these errors should trigger the "create new conversation" fallback.
|
||||
* Auth, network, and protocol errors should NOT be retried.
|
||||
*/
|
||||
export function isConversationMissingError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
if (msg.includes('not found')) return true;
|
||||
if (msg.includes('conversation') && (msg.includes('missing') || msg.includes('does not exist'))) return true;
|
||||
if (msg.includes('agent') && msg.includes('not found')) return true;
|
||||
}
|
||||
const statusError = error as { status?: number };
|
||||
if (statusError?.status === 404) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a session initialization error indicates the agent doesn't exist.
|
||||
* The SDK includes CLI stderr in the error message when the subprocess exits
|
||||
* before sending an init message. We check for agent-not-found indicators in
|
||||
* both the SDK-level message and the CLI stderr output it includes.
|
||||
*
|
||||
* This intentionally does NOT treat generic init failures (like "no init
|
||||
* message received") as recoverable. Those can be transient SDK/process
|
||||
* issues, and clearing persisted agent state in those cases can destroy
|
||||
* valid mappings.
|
||||
*/
|
||||
export function isAgentMissingFromInitError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const msg = error.message.toLowerCase();
|
||||
const agentMissingPatterns = [
|
||||
/\bagent\b[^.\n]{0,80}\bnot found\b/,
|
||||
/\bnot found\b[^.\n]{0,80}\bagent\b/,
|
||||
/\bagent\b[^.\n]{0,80}\bdoes not exist\b/,
|
||||
/\bunknown agent\b/,
|
||||
/\bagent_not_found\b/,
|
||||
];
|
||||
return agentMissingPatterns.some((pattern) => pattern.test(msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a structured API error into a clear, user-facing message.
|
||||
* The `error` object comes from the SDK's new SDKErrorMessage type.
|
||||
*/
|
||||
export function formatApiErrorForUser(error: { message: string; stopReason: string; apiError?: Record<string, unknown> }): string {
|
||||
const msg = error.message.toLowerCase();
|
||||
const apiError = error.apiError || {};
|
||||
const apiMsg = (typeof apiError.message === 'string' ? apiError.message : '').toLowerCase();
|
||||
const reasons: string[] = Array.isArray(apiError.reasons) ? apiError.reasons : [];
|
||||
|
||||
// Billing / credits exhausted
|
||||
if (msg.includes('out of credits') || apiMsg.includes('out of credits')) {
|
||||
return '(Out of credits for hosted inference. Add credits or enable auto-recharge at app.letta.com/settings/organization/usage.)';
|
||||
}
|
||||
|
||||
// Rate limiting / usage exceeded (429)
|
||||
if (msg.includes('rate limit') || msg.includes('429') || msg.includes('usage limit')
|
||||
|| apiMsg.includes('rate limit') || apiMsg.includes('usage limit')) {
|
||||
if (reasons.includes('premium-usage-exceeded') || msg.includes('hosted model usage limit')) {
|
||||
return '(Rate limited -- your Letta Cloud usage limit has been exceeded. Check your plan at app.letta.com.)';
|
||||
}
|
||||
const reasonStr = reasons.length > 0 ? `: ${reasons.join(', ')}` : '';
|
||||
return `(Rate limited${reasonStr}. Try again in a moment.)`;
|
||||
}
|
||||
|
||||
// 409 CONFLICT (concurrent request on same conversation)
|
||||
if (msg.includes('conflict') || msg.includes('409') || msg.includes('another request is currently being processed')) {
|
||||
return '(Another request is still processing on this conversation. Wait a moment and try again.)';
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('forbidden')) {
|
||||
return '(Authentication failed -- check your API key in lettabot.yaml.)';
|
||||
}
|
||||
|
||||
// Agent/conversation not found
|
||||
if (msg.includes('not found') || msg.includes('404')) {
|
||||
return '(Agent or conversation not found -- the configured agent may have been deleted. Try re-onboarding.)';
|
||||
}
|
||||
|
||||
// Server errors
|
||||
if (msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('internal server error')) {
|
||||
return '(Letta API server error -- try again in a moment.)';
|
||||
}
|
||||
|
||||
// Fallback: use the actual error message (truncated for safety)
|
||||
const detail = error.message.length > 200 ? error.message.slice(0, 200) + '...' : error.message;
|
||||
const trimmed = detail.replace(/[.\s]+$/, '');
|
||||
return `(Agent error: ${trimmed}. Try sending your message again.)`;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type { AgentSession, AgentRouter } from './interfaces.js';
|
||||
import type { TriggerContext } from './types.js';
|
||||
import type { StreamMsg } from './bot.js';
|
||||
import type { StreamMsg } from './types.js';
|
||||
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
export * from './types.js';
|
||||
export * from './store.js';
|
||||
export * from './bot.js';
|
||||
export * from './errors.js';
|
||||
export * from './display.js';
|
||||
export * from './interfaces.js';
|
||||
export * from './gateway.js';
|
||||
export * from './formatter.js';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
import type { InboundMessage, TriggerContext } from './types.js';
|
||||
import type { GroupBatcher } from './group-batcher.js';
|
||||
import type { StreamMsg } from './bot.js';
|
||||
import type { StreamMsg } from './types.js';
|
||||
|
||||
export interface AgentSession {
|
||||
/** Register a channel adapter */
|
||||
|
||||
@@ -175,6 +175,23 @@ export interface LastMessageTarget {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream message type (used by processMessage, sendToAgent, gateway)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StreamMsg {
|
||||
type: string;
|
||||
content?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
uuid?: string;
|
||||
isError?: boolean;
|
||||
result?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent store - persists the single agent ID
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user