diff --git a/src/core/bot.ts b/src/core/bot.ts index f0766ef..03c9215 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -2099,18 +2099,37 @@ export class LettaBot implements AgentSession { try { let response = ''; + let lastErrorDetail: { message: string; stopReason: string; apiError?: Record } | undefined; for await (const msg of stream()) { if (msg.type === 'tool_call') { this.syncTodoToolCall(msg); } + if (msg.type === 'error') { + lastErrorDetail = { + message: (msg as any).message || 'unknown', + stopReason: (msg as any).stopReason || 'error', + apiError: (msg as any).apiError, + }; + } if (msg.type === 'assistant') { response += msg.content || ''; } if (msg.type === 'result') { // TODO(letta-code-sdk#31): Remove once SDK handles HITL approvals in bypassPermissions mode. if (msg.success === false || msg.error) { + // Enrich opaque errors from run metadata (mirrors processMessage logic). + const convId = typeof msg.conversationId === 'string' ? msg.conversationId : undefined; + if (this.store.agentId && + (!lastErrorDetail || lastErrorDetail.message === 'Agent stopped: error')) { + const enriched = await getLatestRunError(this.store.agentId, convId); + if (enriched) { + lastErrorDetail = { message: enriched.message, stopReason: enriched.stopReason }; + } + } + const errMsg = lastErrorDetail?.message || msg.error || 'error'; + const errReason = lastErrorDetail?.stopReason || msg.error || 'error'; const detail = typeof msg.result === 'string' ? msg.result.trim() : ''; - throw new Error(detail ? `Agent run failed: ${msg.error || 'error'} (${detail})` : `Agent run failed: ${msg.error || 'error'}`); + throw new Error(detail ? `Agent run failed: ${errReason} (${errMsg})` : `Agent run failed: ${errReason} -- ${errMsg}`); } break; } diff --git a/src/core/sdk-session-contract.test.ts b/src/core/sdk-session-contract.test.ts index 8c4ed6e..49bf5ee 100644 --- a/src/core/sdk-session-contract.test.ts +++ b/src/core/sdk-session-contract.test.ts @@ -11,7 +11,17 @@ vi.mock('@letta-ai/letta-code-sdk', () => ({ imageFromURL: vi.fn(), })); +vi.mock('../tools/letta-api.js', () => ({ + updateAgentName: vi.fn(), + getPendingApprovals: vi.fn(), + rejectApproval: vi.fn(), + cancelRuns: vi.fn(), + recoverOrphanedConversationApproval: vi.fn(), + getLatestRunError: vi.fn().mockResolvedValue(null), +})); + import { createSession, resumeSession } from '@letta-ai/letta-code-sdk'; +import { getLatestRunError } from '../tools/letta-api.js'; import { LettaBot } from './bot.js'; function deferred() { @@ -352,4 +362,90 @@ describe('SDK session contract', () => { expect(botInternal.processingKeys.has('slack')).toBe(false); expect(processSpy).toHaveBeenCalledWith('slack'); }); + + it('enriches opaque error via stream error event in sendToAgent', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'error', message: 'Bad request to Anthropic: context too long', stopReason: 'llm_api_error' }; + yield { type: 'result', success: false, error: 'error' }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conversation-contract-test', + }; + + vi.mocked(createSession).mockReturnValue(mockSession as never); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + }); + + await expect(bot.sendToAgent('trigger error')).rejects.toThrow( + 'Bad request to Anthropic: context too long' + ); + }); + + it('enriches error from run metadata when stream error is opaque', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'result', success: false, error: 'error', conversationId: 'conv-123' }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conv-123', + }; + + vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(getLatestRunError).mockResolvedValueOnce({ + message: 'INTERNAL_SERVER_ERROR: Bad request to Anthropic: Error code: 400', + stopReason: 'llm_api_error', + isApprovalError: false, + }); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + }); + + await expect(bot.sendToAgent('trigger error')).rejects.toThrow( + 'INTERNAL_SERVER_ERROR: Bad request to Anthropic: Error code: 400' + ); + expect(getLatestRunError).toHaveBeenCalledWith('agent-contract-test', 'conv-123'); + }); + + it('falls back to msg.error when no enrichment is available', async () => { + const mockSession = { + initialize: vi.fn(async () => undefined), + send: vi.fn(async (_message: unknown) => undefined), + stream: vi.fn(() => + (async function* () { + yield { type: 'result', success: false, error: 'timeout' }; + })() + ), + close: vi.fn(() => undefined), + agentId: 'agent-contract-test', + conversationId: 'conversation-contract-test', + }; + + vi.mocked(createSession).mockReturnValue(mockSession as never); + vi.mocked(getLatestRunError).mockResolvedValueOnce(null); + + const bot = new LettaBot({ + workingDir: join(dataDir, 'working'), + allowedTools: [], + }); + + await expect(bot.sendToAgent('trigger error')).rejects.toThrow( + 'Agent run failed: timeout' + ); + }); });