feat: add e2e tests with Letta Cloud (#149)
E2E testing infrastructure that tests the full message flow against a real Letta Cloud agent. Changes: - Add MockChannelAdapter for simulating inbound/outbound messages - Add e2e/bot.e2e.test.ts with 4 e2e tests: - Simple message/response - /status command - /help command - Conversation context retention - Add 'mock' to ChannelId type - Update CI workflow with separate e2e job (uses secrets) - Add npm run test:e2e script E2E tests require: - LETTA_API_KEY (already in repo secrets) - LETTA_E2E_AGENT_ID (needs to be added) E2E tests are skipped locally without these env vars. Written by Cameron ◯ Letta Code "Trust, but verify." - Ronald Reagan (on e2e testing)
This commit is contained in:
30
.github/workflows/test.yml
vendored
30
.github/workflows/test.yml
vendored
@@ -7,7 +7,8 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -23,5 +24,30 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
- name: Run unit tests
|
||||
run: npm run test:run
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
# Only run e2e on main branch (has secrets)
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run e2e tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
|
||||
LETTA_E2E_AGENT_ID: ${{ secrets.LETTA_E2E_AGENT_ID }}
|
||||
|
||||
@@ -108,7 +108,7 @@ LettaBot can transcribe voice messages using OpenAI Whisper. Voice messages are
|
||||
|
||||
### Configuration
|
||||
|
||||
Add your OpenAI API key to `lettabot.config.yaml`:
|
||||
Add your OpenAI API key to `lettabot.yaml`:
|
||||
|
||||
```yaml
|
||||
transcription:
|
||||
|
||||
4
SKILL.md
4
SKILL.md
@@ -178,7 +178,7 @@ Each channel supports three DM policies:
|
||||
|
||||
## Configuration File
|
||||
|
||||
After onboarding, config is saved to `~/.config/lettabot/config.yaml`:
|
||||
After onboarding, config is saved to `~/.lettabot/config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -296,7 +296,7 @@ If an AI agent is helping with setup and WhatsApp is configured:
|
||||
|
||||
The agent can verify success by checking:
|
||||
- `lettabot server` output shows "Connected to Telegram" (or other channel)
|
||||
- Config file exists at `~/.config/lettabot/config.yaml`
|
||||
- Config file exists at `~/.lettabot/config.yaml`
|
||||
- User can message bot on configured channel(s)
|
||||
|
||||
## Self-Hosted Letta
|
||||
|
||||
92
e2e/bot.e2e.test.ts
Normal file
92
e2e/bot.e2e.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* E2E Tests for LettaBot
|
||||
*
|
||||
* These tests use a real Letta Cloud agent to verify the full message flow.
|
||||
* Requires LETTA_API_KEY and LETTA_E2E_AGENT_ID environment variables.
|
||||
*
|
||||
* Run with: npm run test:e2e
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { LettaBot } from '../src/core/bot.js';
|
||||
import { MockChannelAdapter } from '../src/test/mock-channel.js';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Skip if no API key (local dev without secrets)
|
||||
const SKIP_E2E = !process.env.LETTA_API_KEY || !process.env.LETTA_E2E_AGENT_ID;
|
||||
|
||||
describe.skipIf(SKIP_E2E)('e2e: LettaBot with Letta Cloud', () => {
|
||||
let bot: LettaBot;
|
||||
let mockAdapter: MockChannelAdapter;
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create temp directory for test data
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lettabot-e2e-'));
|
||||
|
||||
// Set agent ID from secrets
|
||||
process.env.LETTA_AGENT_ID = process.env.LETTA_E2E_AGENT_ID;
|
||||
|
||||
// Initialize bot with test config
|
||||
bot = new LettaBot({
|
||||
model: 'claude-sonnet-4-20250514', // Good balance of speed/quality
|
||||
workingDir: tempDir,
|
||||
agentName: 'e2e-test',
|
||||
});
|
||||
|
||||
// Register mock channel
|
||||
mockAdapter = new MockChannelAdapter();
|
||||
bot.registerChannel(mockAdapter);
|
||||
|
||||
console.log('[E2E] Bot initialized with agent:', process.env.LETTA_E2E_AGENT_ID);
|
||||
}, 30000); // 30s timeout for setup
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('responds to a simple message', async () => {
|
||||
const response = await mockAdapter.simulateMessage('Say "E2E TEST OK" and nothing else.');
|
||||
|
||||
expect(response).toBeTruthy();
|
||||
expect(response.length).toBeGreaterThan(0);
|
||||
// The agent should respond with something containing our test phrase
|
||||
expect(response.toUpperCase()).toContain('E2E TEST OK');
|
||||
}, 60000); // 60s timeout
|
||||
|
||||
it('handles /status command', async () => {
|
||||
const response = await mockAdapter.simulateMessage('/status');
|
||||
|
||||
expect(response).toBeTruthy();
|
||||
// Status should contain agent info
|
||||
expect(response).toMatch(/agent|status/i);
|
||||
}, 30000);
|
||||
|
||||
it('handles /help command', async () => {
|
||||
const response = await mockAdapter.simulateMessage('/help');
|
||||
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toContain('LettaBot');
|
||||
expect(response).toContain('/status');
|
||||
}, 10000);
|
||||
|
||||
it('maintains conversation context', async () => {
|
||||
// First message - set context
|
||||
await mockAdapter.simulateMessage('Remember this number: 42424242');
|
||||
|
||||
// Clear messages but keep session
|
||||
mockAdapter.clearMessages();
|
||||
|
||||
// Second message - recall context
|
||||
const response = await mockAdapter.simulateMessage('What number did I just tell you to remember?');
|
||||
|
||||
expect(response).toContain('42424242');
|
||||
}, 120000); // 2 min timeout for multi-turn
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:e2e": "vitest run e2e/",
|
||||
"skills": "tsx src/cli.ts skills",
|
||||
"skills:list": "tsx src/cli.ts skills list",
|
||||
"skills:status": "tsx src/cli.ts skills status",
|
||||
|
||||
10
src/cli.ts
10
src/cli.ts
@@ -103,7 +103,15 @@ async function server() {
|
||||
|
||||
// Check if configured
|
||||
if (!existsSync(configPath)) {
|
||||
console.log(`No config found at ${configPath}. Run "lettabot onboard" first.\n`);
|
||||
console.log(`
|
||||
No config file found. Searched locations:
|
||||
1. ./lettabot.yaml (project-local - recommended)
|
||||
2. ./lettabot.yml
|
||||
3. ~/.lettabot/config.yaml (user global)
|
||||
4. ~/.lettabot/config.yml
|
||||
|
||||
Run "lettabot onboard" to create a config file.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mkdirSync } from 'node:fs';
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
import type { BotConfig, InboundMessage, TriggerContext } from './types.js';
|
||||
import { Store } from './store.js';
|
||||
import { updateAgentName } from '../tools/letta-api.js';
|
||||
import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, disableAllToolApprovals } from '../tools/letta-api.js';
|
||||
import { installSkillsToAgent } from '../skills/loader.js';
|
||||
import { formatMessageEnvelope } from './formatter.js';
|
||||
import { loadMemoryBlocks } from './memory.js';
|
||||
@@ -114,6 +114,72 @@ export class LettaBot {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover from stuck approval state.
|
||||
* Returns true if recovery was attempted, false if no recovery needed.
|
||||
* @param maxAttempts Maximum recovery attempts before giving up (default: 2)
|
||||
*/
|
||||
private async attemptRecovery(maxAttempts = 2): Promise<{ recovered: boolean; shouldReset: boolean }> {
|
||||
if (!this.store.agentId) {
|
||||
return { recovered: false, shouldReset: false };
|
||||
}
|
||||
|
||||
const attempts = this.store.recoveryAttempts;
|
||||
if (attempts >= maxAttempts) {
|
||||
console.error(`[Bot] Recovery failed after ${attempts} attempts.`);
|
||||
console.error('[Bot] Try running: lettabot reset-conversation');
|
||||
return { recovered: false, shouldReset: true };
|
||||
}
|
||||
|
||||
console.log('[Bot] Checking for pending approvals...');
|
||||
|
||||
try {
|
||||
// Check for pending approvals
|
||||
const pendingApprovals = await getPendingApprovals(
|
||||
this.store.agentId,
|
||||
this.store.conversationId || undefined
|
||||
);
|
||||
|
||||
if (pendingApprovals.length === 0) {
|
||||
// No pending approvals, reset counter and continue
|
||||
this.store.resetRecoveryAttempts();
|
||||
return { recovered: false, shouldReset: false };
|
||||
}
|
||||
|
||||
console.log(`[Bot] Found ${pendingApprovals.length} pending approval(s), attempting recovery...`);
|
||||
this.store.incrementRecoveryAttempts();
|
||||
|
||||
// Reject all pending approvals
|
||||
for (const approval of pendingApprovals) {
|
||||
console.log(`[Bot] Rejecting approval for ${approval.toolName} (${approval.toolCallId})`);
|
||||
await rejectApproval(
|
||||
this.store.agentId,
|
||||
{ toolCallId: approval.toolCallId, reason: 'Session was interrupted - retrying request' },
|
||||
this.store.conversationId || undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel any active runs
|
||||
const runIds = [...new Set(pendingApprovals.map(a => a.runId))];
|
||||
if (runIds.length > 0) {
|
||||
console.log(`[Bot] Cancelling ${runIds.length} active run(s)...`);
|
||||
await cancelRuns(this.store.agentId, runIds);
|
||||
}
|
||||
|
||||
// Disable tool approvals for the future (proactive fix)
|
||||
console.log('[Bot] Disabling tool approval requirements...');
|
||||
await disableAllToolApprovals(this.store.agentId);
|
||||
|
||||
console.log('[Bot] Recovery completed');
|
||||
return { recovered: true, shouldReset: false };
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Bot] Recovery failed:', error);
|
||||
this.store.incrementRecoveryAttempts();
|
||||
return { recovered: false, shouldReset: this.store.recoveryAttempts >= maxAttempts };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue incoming message for processing (prevents concurrent SDK sessions)
|
||||
*/
|
||||
@@ -181,6 +247,20 @@ export class LettaBot {
|
||||
await adapter.sendTypingIndicator(msg.chatId);
|
||||
console.log('[Bot] Typing indicator sent');
|
||||
|
||||
// Attempt recovery from stuck approval state before starting session
|
||||
const recovery = await this.attemptRecovery();
|
||||
if (recovery.shouldReset) {
|
||||
await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: '(Session recovery failed after multiple attempts. Try: lettabot reset-conversation)',
|
||||
threadId: msg.threadId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (recovery.recovered) {
|
||||
console.log('[Bot] Recovered from stuck approval, continuing with message processing');
|
||||
}
|
||||
|
||||
// Create or resume session
|
||||
let session: Session;
|
||||
let usedDefaultConversation = false;
|
||||
@@ -403,11 +483,11 @@ export class LettaBot {
|
||||
console.error(`[Bot] Result error: ${resultMsg.error}`);
|
||||
}
|
||||
|
||||
// Check for corrupted conversation (empty result usually means error)
|
||||
// Check for potential stuck state (empty result usually means pending approval or error)
|
||||
if (resultMsg.success && resultMsg.result === '' && !response.trim()) {
|
||||
console.error('[Bot] Warning: Agent returned empty result with no response.');
|
||||
console.error('[Bot] This often indicates a corrupted conversation.');
|
||||
console.error('[Bot] Try running: lettabot reset-conversation');
|
||||
console.error('[Bot] This may indicate a pending approval or interrupted session.');
|
||||
console.error('[Bot] Recovery will be attempted on the next message.');
|
||||
}
|
||||
|
||||
// Save agent ID and conversation ID
|
||||
@@ -467,12 +547,10 @@ export class LettaBot {
|
||||
console.error('[Bot] Stream received NO DATA - possible stuck tool approval');
|
||||
console.error('[Bot] Conversation:', this.store.conversationId);
|
||||
console.error('[Bot] This can happen when a previous session disconnected mid-tool-approval');
|
||||
console.error('[Bot] The CLI should auto-recover, but if this persists:');
|
||||
console.error('[Bot] 1. Run: lettabot reset-conversation');
|
||||
console.error('[Bot] 2. Or try your message again (CLI may auto-recover on retry)');
|
||||
console.error('[Bot] Recovery will be attempted automatically on the next message.');
|
||||
await adapter.sendMessage({
|
||||
chatId: msg.chatId,
|
||||
text: '(No response - connection issue. Please try sending your message again.)',
|
||||
text: '(Session interrupted. Please try your message again - recovery in progress.)',
|
||||
threadId: msg.threadId
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -118,4 +118,22 @@ export class Store {
|
||||
this.data.lastMessageTarget = target || undefined;
|
||||
this.save();
|
||||
}
|
||||
|
||||
// Recovery tracking
|
||||
|
||||
get recoveryAttempts(): number {
|
||||
return this.data.recoveryAttempts || 0;
|
||||
}
|
||||
|
||||
incrementRecoveryAttempts(): number {
|
||||
this.data.recoveryAttempts = (this.data.recoveryAttempts || 0) + 1;
|
||||
this.data.lastRecoveryAt = new Date().toISOString();
|
||||
this.save();
|
||||
return this.data.recoveryAttempts;
|
||||
}
|
||||
|
||||
resetRecoveryAttempts(): void {
|
||||
this.data.recoveryAttempts = 0;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface TriggerContext {
|
||||
// Original Types
|
||||
// =============================================================================
|
||||
|
||||
export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal' | 'discord';
|
||||
export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal' | 'discord' | 'mock';
|
||||
|
||||
export interface InboundAttachment {
|
||||
id?: string;
|
||||
@@ -149,4 +149,8 @@ export interface AgentStore {
|
||||
createdAt?: string;
|
||||
lastUsedAt?: string;
|
||||
lastMessageTarget?: LastMessageTarget;
|
||||
|
||||
// Recovery tracking
|
||||
recoveryAttempts?: number; // Count of consecutive recovery attempts
|
||||
lastRecoveryAt?: string; // When last recovery was attempted
|
||||
}
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@@ -129,7 +129,15 @@ import { agentExists, findAgentByName } from './tools/letta-api.js';
|
||||
const configPath = resolveConfigPath();
|
||||
const isContainerDeploy = !!(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.DOCKER_DEPLOY);
|
||||
if (!existsSync(configPath) && !isContainerDeploy) {
|
||||
console.log(`\n No config found at ${configPath}. Run "lettabot onboard" first.\n`);
|
||||
console.log(`
|
||||
No config file found. Searched locations:
|
||||
1. ./lettabot.yaml (project-local - recommended)
|
||||
2. ./lettabot.yml
|
||||
3. ~/.lettabot/config.yaml (user global)
|
||||
4. ~/.lettabot/config.yml
|
||||
|
||||
Run "lettabot onboard" to create a config file.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
117
src/test/mock-channel.ts
Normal file
117
src/test/mock-channel.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Mock Channel Adapter for E2E Testing
|
||||
*
|
||||
* Captures messages sent by the bot and allows simulating inbound messages.
|
||||
*/
|
||||
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../core/types.js';
|
||||
|
||||
export class MockChannelAdapter implements ChannelAdapter {
|
||||
readonly id = 'mock' as const;
|
||||
readonly name = 'Mock (Testing)';
|
||||
|
||||
private running = false;
|
||||
private sentMessages: OutboundMessage[] = [];
|
||||
private responseResolvers: Array<(msg: OutboundMessage) => void> = [];
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
onCommand?: (command: string) => Promise<string | null>;
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
|
||||
const messageId = `mock-${Date.now()}`;
|
||||
this.sentMessages.push(msg);
|
||||
|
||||
// Resolve any waiting promises
|
||||
const resolver = this.responseResolvers.shift();
|
||||
if (resolver) {
|
||||
resolver(msg);
|
||||
}
|
||||
|
||||
return { messageId };
|
||||
}
|
||||
|
||||
async editMessage(_chatId: string, _messageId: string, _text: string): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async sendTypingIndicator(_chatId: string): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
supportsEditing(): boolean {
|
||||
return false; // Disable streaming edits for simpler testing
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate an inbound message and wait for response
|
||||
*/
|
||||
async simulateMessage(
|
||||
text: string,
|
||||
options: {
|
||||
userId?: string;
|
||||
chatId?: string;
|
||||
userName?: string;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
if (!this.onMessage) {
|
||||
throw new Error('No message handler registered');
|
||||
}
|
||||
|
||||
const chatId = options.chatId || 'test-chat-123';
|
||||
|
||||
// Create promise that resolves when bot sends response
|
||||
const responsePromise = new Promise<OutboundMessage>((resolve) => {
|
||||
this.responseResolvers.push(resolve);
|
||||
});
|
||||
|
||||
// Send the inbound message
|
||||
const inbound: InboundMessage = {
|
||||
channel: 'mock',
|
||||
chatId,
|
||||
userId: options.userId || 'test-user-456',
|
||||
userName: options.userName || 'Test User',
|
||||
text,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Don't await - let it process async
|
||||
this.onMessage(inbound).catch(err => {
|
||||
console.error('[MockChannel] Error processing message:', err);
|
||||
});
|
||||
|
||||
// Wait for response with timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Response timeout (60s)')), 60000);
|
||||
});
|
||||
|
||||
const response = await Promise.race([responsePromise, timeoutPromise]);
|
||||
return response.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sent messages (for assertions)
|
||||
*/
|
||||
getSentMessages(): OutboundMessage[] {
|
||||
return [...this.sentMessages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear sent messages
|
||||
*/
|
||||
clearMessages(): void {
|
||||
this.sentMessages = [];
|
||||
}
|
||||
}
|
||||
@@ -211,3 +211,207 @@ export async function findAgentByName(name: string): Promise<{ id: string; name:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Approval Management
|
||||
// ============================================================================
|
||||
|
||||
export interface PendingApproval {
|
||||
runId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for pending approval requests on an agent's conversation.
|
||||
* Returns details of any tool calls waiting for approval.
|
||||
*/
|
||||
export async function getPendingApprovals(
|
||||
agentId: string,
|
||||
conversationId?: string
|
||||
): Promise<PendingApproval[]> {
|
||||
try {
|
||||
const client = getClient();
|
||||
|
||||
// First, check for runs with 'requires_approval' stop reason
|
||||
const runsPage = await client.runs.list({
|
||||
agent_id: agentId,
|
||||
conversation_id: conversationId,
|
||||
stop_reason: 'requires_approval',
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const pendingApprovals: PendingApproval[] = [];
|
||||
|
||||
for await (const run of runsPage) {
|
||||
if (run.status === 'running' || run.stop_reason === 'requires_approval') {
|
||||
// Get recent messages to find approval_request_message
|
||||
const messagesPage = await client.agents.messages.list(agentId, {
|
||||
conversation_id: conversationId,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
for await (const msg of messagesPage) {
|
||||
// Check for approval_request_message type
|
||||
if ('message_type' in msg && msg.message_type === 'approval_request_message') {
|
||||
const approvalMsg = msg as {
|
||||
id: string;
|
||||
tool_calls?: Array<{ tool_call_id: string; name: string }>;
|
||||
tool_call?: { tool_call_id: string; name: string };
|
||||
run_id?: string;
|
||||
};
|
||||
|
||||
// Extract tool call info
|
||||
const toolCalls = approvalMsg.tool_calls || (approvalMsg.tool_call ? [approvalMsg.tool_call] : []);
|
||||
for (const tc of toolCalls) {
|
||||
pendingApprovals.push({
|
||||
runId: approvalMsg.run_id || run.id,
|
||||
toolCallId: tc.tool_call_id,
|
||||
toolName: tc.name,
|
||||
messageId: approvalMsg.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pendingApprovals;
|
||||
} catch (e) {
|
||||
console.error('[Letta API] Failed to get pending approvals:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a pending tool approval request.
|
||||
* Sends an approval response with approve: false.
|
||||
*/
|
||||
export async function rejectApproval(
|
||||
agentId: string,
|
||||
approval: {
|
||||
toolCallId: string;
|
||||
reason?: string;
|
||||
},
|
||||
conversationId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const client = getClient();
|
||||
|
||||
// Send approval response via messages.create
|
||||
await client.agents.messages.create(agentId, {
|
||||
messages: [{
|
||||
type: 'approval',
|
||||
approvals: [{
|
||||
approve: false,
|
||||
tool_call_id: approval.toolCallId,
|
||||
reason: approval.reason || 'Session was interrupted - please retry your request',
|
||||
}],
|
||||
}],
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
console.log(`[Letta API] Rejected approval for tool call ${approval.toolCallId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[Letta API] Failed to reject approval:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel active runs for an agent.
|
||||
* Optionally specify specific run IDs to cancel.
|
||||
* Note: Requires Redis on the server for canceling active runs.
|
||||
*/
|
||||
export async function cancelRuns(
|
||||
agentId: string,
|
||||
runIds?: string[]
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const client = getClient();
|
||||
await client.agents.messages.cancel(agentId, {
|
||||
run_ids: runIds,
|
||||
});
|
||||
console.log(`[Letta API] Cancelled runs for agent ${agentId}${runIds ? ` (${runIds.join(', ')})` : ''}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[Letta API] Failed to cancel runs:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable tool approval requirement for a specific tool on an agent.
|
||||
* This sets requires_approval: false at the server level.
|
||||
*/
|
||||
export async function disableToolApproval(
|
||||
agentId: string,
|
||||
toolName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const client = getClient();
|
||||
await client.agents.tools.updateApproval(toolName, {
|
||||
agent_id: agentId,
|
||||
body_requires_approval: false,
|
||||
});
|
||||
console.log(`[Letta API] Disabled approval requirement for tool ${toolName} on agent ${agentId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[Letta API] Failed to disable tool approval for ${toolName}:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools attached to an agent with their approval settings.
|
||||
*/
|
||||
export async function getAgentTools(agentId: string): Promise<Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
requiresApproval?: boolean;
|
||||
}>> {
|
||||
try {
|
||||
const client = getClient();
|
||||
const toolsPage = await client.agents.tools.list(agentId);
|
||||
const tools: Array<{ name: string; id: string; requiresApproval?: boolean }> = [];
|
||||
|
||||
for await (const tool of toolsPage) {
|
||||
tools.push({
|
||||
name: tool.name ?? 'unknown',
|
||||
id: tool.id,
|
||||
// Note: The API might not return this field directly on list
|
||||
// We may need to check each tool individually
|
||||
requiresApproval: (tool as { requires_approval?: boolean }).requires_approval,
|
||||
});
|
||||
}
|
||||
|
||||
return tools;
|
||||
} catch (e) {
|
||||
console.error('[Letta API] Failed to get agent tools:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable approval requirement for ALL tools on an agent.
|
||||
* Useful for ensuring a headless deployment doesn't get stuck.
|
||||
*/
|
||||
export async function disableAllToolApprovals(agentId: string): Promise<number> {
|
||||
try {
|
||||
const tools = await getAgentTools(agentId);
|
||||
let disabled = 0;
|
||||
|
||||
for (const tool of tools) {
|
||||
const success = await disableToolApproval(agentId, tool.name);
|
||||
if (success) disabled++;
|
||||
}
|
||||
|
||||
console.log(`[Letta API] Disabled approval for ${disabled}/${tools.length} tools on agent ${agentId}`);
|
||||
return disabled;
|
||||
} catch (e) {
|
||||
console.error('[Letta API] Failed to disable all tool approvals:', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user