diff --git a/examples/elicitation-demo/.env.example b/examples/elicitation-demo/.env.example new file mode 100644 index 0000000..1e7f409 --- /dev/null +++ b/examples/elicitation-demo/.env.example @@ -0,0 +1,7 @@ +LITELLM_URL=http://localhost:4000 +LITELLM_API_KEY=YOUR_API_KEY +LITELLM_MODEL=gpt-3.5-turbo + +# Server Configuration +HOST=127.0.0.1 +PORT=3000 \ No newline at end of file diff --git a/examples/elicitation-demo/README.md b/examples/elicitation-demo/README.md new file mode 100644 index 0000000..6a048cd --- /dev/null +++ b/examples/elicitation-demo/README.md @@ -0,0 +1,646 @@ +# JAF MCP Elicitation Demo + +This demo showcases the **MCP (Model Context Protocol) elicitation** feature in JAF, allowing agents to request structured information from users during tool execution using JSON schemas. + +## ๐ŸŽฏ What is MCP Elicitation? + +MCP elicitation enables agents to: +- **Interrupt conversations** to request structured user input +- **Generate forms** automatically from JSON schemas +- **Validate responses** against schema constraints +- **Continue execution** with collected data +- **Handle user choices** (accept, decline, cancel) + +**Key Benefits:** +- โœ… Professional forms instead of chat back-and-forth +- โœ… Type-safe data collection with validation +- โœ… Industry-standard MCP compliance +- โœ… Seamless conversation flow + +--- + +## ๐Ÿ“ Demo Files Overview + +This demo contains **3 different files** for **3 different purposes**: + +### 1. `elicitation-server.ts` - **The Server** ๐Ÿ—๏ธ +**Purpose**: JAF server with 5 elicitation-enabled tools +**What it does**: Backend server that agents can call to trigger elicitation + +**Contains 5 Demo Tools:** +- `getUserInfo` - Collects contact information (name, email, phone) +- `getPreferences` - Choice selection for user preferences +- `getFeedback` - Text input with length validation +- `confirmAction` - Yes/no confirmation dialogs +- `getQuantity` - Number input with min/max constraints + +### 2. `interactive-client.ts` - **The Interactive Demo** ๐Ÿ–ฅ๏ธ +**Purpose**: Interactive client for manual elicitation testing +**What it does**: Allows real users to interact with elicitation forms manually + +**Features:** +- JAF-compliant recursive conversation pattern +- Real user input collection via command line +- Schema-based form generation and validation +- Proper interruption handling with `for (;;)` loops +- Support for all elicitation types (text, choice, number, confirmation) + +### 3. `unit-tests.ts` - **Unit Tests** ๐Ÿงช +**Purpose**: Tests core elicitation functionality +**What it does**: Validates schemas, validation, and provider behavior + +**Tests:** +- Email format validation +- Choice enum validation +- Required field validation +- Provider request/response handling + +--- + +## ๐Ÿš€ Quick Start (Recommended) + +### **Complete Integration Test** +Run all components in sequence: + +```bash +cd examples/elicitation-demo + +# 1. Test core functionality (30 seconds) +pnpm run test + +# 2. Start server (keep running) +pnpm run dev & + +# 3. Wait for server startup, then run interactive demo +sleep 3 && pnpm run demo + +# 4. Clean up +pkill -f elicitation-server.ts +``` + +### **Interactive Demo** +For presentations or manual testing: + +```bash +# Terminal 1: Start server +pnpm run dev + +# Terminal 2: Run interactive client demo +pnpm run demo +``` + +--- + +## ๐Ÿ› ๏ธ Available Scripts + +| Command | Description | +|---------|-------------| +| `pnpm run dev` | Start the elicitation server | +| `pnpm run server` | Start the elicitation server (alias) | +| `pnpm run demo` | Run the interactive client demo | +| `pnpm run client` | Run the interactive client demo (alias) | +| `pnpm run test` | Run unit tests | + +--- + +## ๐Ÿงช Testing Options + +### **Option 1: Unit Tests Only** +Test core functionality without server: +```bash +pnpm run test +``` +**Output:** +``` +๐Ÿงช Testing elicitation validation... +โœ… Contact info validation: PASS +โœ… Invalid email validation: PASS +โœ… Choice validation: PASS +๐ŸŽ‰ All tests passed! +``` + +### **Option 2: Interactive Demo** +Server + interactive client where you manually respond to elicitation requests: +```bash +# Terminal 1 +npx tsx basic-elicitation.ts + +# Terminal 2 +npx tsx client-example.ts +``` + +**Interactive Flow:** +1. **Start conversation** - Type commands like "Collect my contact information" +2. **Handle elicitation requests** - Choose to fill forms, decline, or cancel +3. **Fill forms manually** - Provide real input with validation +4. **See real responses** - Get actual assistant responses + +**Sample Commands:** +- `Collect my contact information` +- `Get my programming preferences` +- `Ask for feedback on the interface` +- `Confirm account deletion` +- `Ask how many items I need` +- `quit` (to exit) + +**Sample Interactive Session:** +``` +๐ŸŽฏ Interactive Elicitation Demo +=============================== +๐Ÿ“ก Connected to: http://localhost:3000 +๐Ÿ’ฌ Conversation ID: demo-1234567890 + +๐Ÿ’ญ Your message: Collect my contact information + +โณ Processing... + +๐Ÿšจ ELICITATION REQUEST +====================== +๐Ÿ“ We need your contact information: contact information collection + +๐Ÿ“‹ Form Fields: +================ + +๐Ÿ”ธ Full Name (REQUIRED) + Description: Your full name + Type: Text + Min length: 1 + +๐Ÿ”ธ Email Address (REQUIRED) + Description: Your email address + Type: Email address + +๐Ÿ”ธ Phone Number (optional) + Description: Your phone number (optional) + Type: Text + +What would you like to do? +1. Fill out the form +2. Decline this request +3. Cancel the operation + +Enter your choice (1-3): 1 + +๐Ÿ“ Please fill out the form: + +Full Name: John Doe +Email Address: john@example.com +Phone Number (or press Enter to skip): +1-555-123-4567 + +โœ… Form submitted! +Data: { + "name": "John Doe", + "email": "john@example.com", + "phone": "+1-555-123-4567" +} +๐Ÿ“ค Response status: SUCCESS + +๐Ÿ”„ Continuing conversation with your responses... + +๐Ÿค– Assistant Response: +====================== +Successfully collected user information: +- Name: John Doe +- Email: john@example.com +- Phone: +1-555-123-4567 + +================================================== + +๐Ÿ’ญ Your message: quit + +๐Ÿ‘‹ Goodbye! +``` + +### **Option 3: Manual API Testing** +Use the provided test script: +```bash +# Start server first +npx tsx basic-elicitation.ts + +# In another terminal +./test-workflow.sh +``` + +--- + +## ๐Ÿ› ๏ธ Environment Setup + +### **Prerequisites** +1. **LiteLLM Server**: Running on port 4000 (or configured endpoint) +2. **Environment Variables**: Copy `.env.example` to `.env` and configure: + +```bash +# .env +LITELLM_URL=http://localhost:4000 +LITELLM_API_KEY=YOUR_API_KEY +LITELLM_MODEL=gpt-3.5-turbo + +HOST=127.0.0.1 +PORT=3000 +``` + +### **Using npm Scripts** +```bash +# Start server with pnpm +pnpm run dev + +# Or with npm +npm run dev +``` + +--- + +## ๐Ÿ“ก API Reference + +### **Server Endpoints** +When `basic-elicitation.ts` is running: + +**Chat Endpoints:** +- `POST /chat` - Send messages to agents +- `GET /agents` - List available agents + +**Elicitation Endpoints:** +- `GET /elicitation/pending` - View pending requests +- `POST /elicitation/respond` - Submit user responses + +**Utility Endpoints:** +- `GET /health` - Server health check +- `GET /memory/health` - Memory provider status + +### **Manual Testing Example** + +**1. Trigger Elicitation:** +```bash +curl -X POST http://localhost:3000/chat \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [{"role": "user", "content": "Collect my contact information"}], + "agentName": "Elicitation Demo Agent", + "conversationId": "test-conv" + }' +``` + +**2. Check Response for Interruption:** +```json +{ + "data": { + "outcome": { + "status": "interrupted", + "interruptions": [{ + "type": "elicitation", + "request": { + "id": "req_123", + "message": "Please provide your contact information", + "requestedSchema": { /* schema details */ } + } + }] + } + } +} +``` + +**3. Submit User Response:** +```bash +curl -X POST http://localhost:3000/elicitation/respond \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "req_123", + "action": "accept", + "content": { + "name": "John Doe", + "email": "john@example.com", + "phone": "+1234567890" + } + }' +``` + +**4. Continue Conversation:** +```bash +curl -X POST http://localhost:3000/chat \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [], + "agentName": "Elicitation Demo Agent", + "conversationId": "test-conv", + "elicitationResponses": [{ + "type": "elicitation_response", + "requestId": "req_123", + "action": "accept", + "content": { + "name": "John Doe", + "email": "john@example.com", + "phone": "+1234567890" + } + }] + }' +``` + +--- + +## ๐Ÿ’ป Implementation Guide + +### **Creating Elicitation Tools** + +```typescript +import { Elicit, ElicitationInterruptionError } from '@xynehq/jaf'; + +const myTool: Tool = { + schema: { + name: 'collectUserData', + description: 'Collect user information', + parameters: z.object({ + reason: z.string().optional() + }) + }, + execute: async ({ reason }) => { + try { + // Use convenience functions + const contact = await Elicit.contactInfo("We need your details"); + const quantity = await Elicit.number("How many items?", { min: 1, max: 100 }); + const confirmed = await Elicit.confirm("Proceed with order?"); + + return `Order for ${contact.name}: ${quantity} items, confirmed: ${confirmed}`; + + } catch (error) { + // CRITICAL: Let elicitation interruptions propagate + if (error instanceof ElicitationInterruptionError) { + throw error; + } + return `Error: ${error.message}`; + } + } +}; +``` + +### **Available Convenience Functions** + +```typescript +// Simple text input with validation +const feedback = await Elicit.text("Your feedback:", { + minLength: 10, + maxLength: 500 +}); + +// Yes/no confirmation +const confirmed = await Elicit.confirm("Delete account?"); + +// Multiple choice selection +const level = await Elicit.choice("Experience level:", + ['beginner', 'intermediate', 'advanced'] +); + +// Contact information form +const contact = await Elicit.contactInfo("Registration required"); + +// Number input with constraints +const quantity = await Elicit.number("Quantity:", { + minimum: 1, + maximum: 100, + integer: true +}); + +// Custom schema +const result = await elicit("Custom prompt:", { + type: 'object', + properties: { + customField: { + type: 'string', + title: 'Custom Field', + pattern: '^[A-Z]+$' + } + }, + required: ['customField'] +}); +``` + +### **Schema Types Supported** + +**String Input:** +```json +{ + "type": "string", + "title": "Display Name", + "description": "Help text", + "minLength": 3, + "maxLength": 50, + "pattern": "^[A-Za-z\\s]+$", + "format": "email" +} +``` + +**Number Input:** +```json +{ + "type": "number", + "title": "Quantity", + "minimum": 1, + "maximum": 100, + "integer": true, + "default": 10 +} +``` + +**Boolean Input:** +```json +{ + "type": "boolean", + "title": "Confirmation", + "description": "Check to confirm", + "default": false +} +``` + +**Choice Selection:** +```json +{ + "type": "string", + "enum": ["option1", "option2", "option3"], + "enumNames": ["Option 1", "Option 2", "Option 3"] +} +``` + +--- + +## ๐Ÿ”ง Integration with Existing JAF Apps + +### **1. Add Elicitation Provider** +```typescript +import { ServerElicitationProvider } from '@xynehq/jaf'; + +const elicitationProvider = new ServerElicitationProvider(); + +const server = await runServer( + [myAgent], + { + modelProvider, + elicitationProvider // Add this + }, + { + port, + host, + defaultMemoryProvider, + elicitationProvider, // And this + } +); +``` + +### **2. Update Your Tools** +```typescript +// Before: basic tool +const myTool = { + execute: async (args) => { + return "Static response"; + } +}; + +// After: elicitation-enabled tool +const myTool = { + execute: async (args) => { + try { + const userInput = await Elicit.text("What do you need?"); + return `You requested: ${userInput}`; + } catch (error) { + if (error instanceof ElicitationInterruptionError) throw error; + return `Error: ${error.message}`; + } + } +}; +``` + +### **3. Handle in Your Client** +```typescript +// Check for elicitation interruptions +const response = await fetch('/chat', { /* ... */ }); +const data = await response.json(); + +if (data.outcome.status === 'interrupted') { + const elicitationRequests = data.outcome.interruptions + .filter(i => i.type === 'elicitation'); + + for (const request of elicitationRequests) { + // Show form based on request.requestedSchema + const userInput = await showElicitationForm(request); + + // Submit response + await fetch('/elicitation/respond', { + method: 'POST', + body: JSON.stringify({ + requestId: request.id, + action: 'accept', + content: userInput + }) + }); + } + + // Continue conversation + await fetch('/chat', { + method: 'POST', + body: JSON.stringify({ + messages: [], + agentName, + conversationId, + elicitationResponses: [/* responses */] + }) + }); +} +``` + +--- + +## ๐Ÿ›ก๏ธ Best Practices + +### **Security** +- โœ… **Never request sensitive data** (passwords, SSNs, etc.) via elicitation +- โœ… **Validate all user input** against schemas +- โœ… **Implement rate limiting** on elicitation requests +- โœ… **Clear indication** of which agent is requesting information +- โœ… **Always allow users to decline** any request + +### **User Experience** +- โœ… **Provide clear context** for why information is needed +- โœ… **Use appropriate input types** (email format for emails, etc.) +- โœ… **Set reasonable validation constraints** (length limits, etc.) +- โœ… **Handle cancellation gracefully** with helpful messages +- โœ… **Progress indicators** for multi-step elicitation + +### **Error Handling** +```typescript +try { + const result = await Elicit.contactInfo("Registration required"); + return processRegistration(result); +} catch (error) { + if (error instanceof ElicitationInterruptionError) { + throw error; // Let framework handle interruption + } + + if (error.message.includes('declined')) { + return "Registration cancelled. You can try again later."; + } + + if (error.message.includes('validation')) { + return "Invalid information provided. Please check your input."; + } + + return `Registration failed: ${error.message}`; +} +``` + +--- + +## ๐Ÿ†š Comparison with Other Frameworks + +### **vs OpenAI Agents SDK** +- โœ… **JAF**: Full data collection with rich forms +- โŒ **OpenAI**: Only binary approval/rejection + +### **vs CrewAI** +- โœ… **JAF**: Natural async function calls +- โŒ **CrewAI**: Webhook setup + task configuration + +### **vs Custom Solutions** +- โœ… **JAF**: Industry-standard MCP compliance +- โŒ **Custom**: Proprietary, non-interoperable + +--- + +## ๐Ÿš€ Next Steps + +After running this demo: + +1. **Understand the workflow** by running `client-example.ts` +2. **Test with your own schemas** by modifying the tools +3. **Integrate into your application** using the implementation guide +4. **Build client UI components** for elicitation forms +5. **Deploy to production** with appropriate security measures + +--- + +## ๐Ÿ› Troubleshooting + +**Common Issues:** + +**"elicit() can only be called from within a tool execution context"** +- โœ… Ensure `elicitationProvider` is configured in server +- โœ… Check that tools don't catch `ElicitationInterruptionError` + +**"Elicitation provider is not configured"** +- โœ… Add `elicitationProvider` to both run config and server options + +**Server hangs on elicitation requests** +- โœ… Ensure you're not awaiting elicitation in older implementation +- โœ… Check that interruption errors are being thrown, not caught + +**Client not receiving interruptions** +- โœ… Check response for `status: "interrupted"` +- โœ… Verify `interruptions` array contains elicitation requests + +--- + +## ๐Ÿ“š Additional Resources + +- **JAF Documentation**: Core framework concepts +- **MCP Specification**: Industry standard protocol +- **Schema Validation**: JSON Schema reference +- **TypeScript Integration**: Type-safe implementation patterns + +**Questions?** Check the implementation in the source files or run the demos to see working examples! \ No newline at end of file diff --git a/examples/elicitation-demo/elicitation-server.ts b/examples/elicitation-demo/elicitation-server.ts new file mode 100644 index 0000000..7088b9c --- /dev/null +++ b/examples/elicitation-demo/elicitation-server.ts @@ -0,0 +1,254 @@ +#!/usr/bin/env tsx + +import 'dotenv/config'; +import { z } from 'zod'; +import { + runServer, + Agent, + Tool, + makeLiteLLMProvider, + createInMemoryProvider, + Elicit, + elicit, + ElicitationSchemas, + ServerElicitationProvider, + ElicitationInterruptionError +} from '../../dist/index.js'; + +// Tool that demonstrates basic elicitation +const getUserInfoTool: Tool<{ reason?: string }, any> = { + schema: { + name: 'getUserInfo', + description: 'Collect user information for personalization', + parameters: z.object({ + reason: z.string().optional().describe('Why the information is needed'), + }), + }, + execute: async ({ reason }) => { + try { + // Use convenience method for contact information + const info = await Elicit.contactInfo( + reason ? `We need your contact information: ${reason}` : 'Please provide your contact information' + ); + + return `Successfully collected user information: + - Name: ${info.name} + - Email: ${info.email} + - Phone: ${info.phone || 'Not provided'}`; + } catch (error) { + // Let elicitation interruption errors propagate to the engine + if (error instanceof ElicitationInterruptionError) { + throw error; + } + return `Failed to collect user information: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }, +}; + +// Tool that demonstrates custom elicitation schema +const preferencesTool: Tool<{ category?: string }, any> = { + schema: { + name: 'getPreferences', + description: 'Collect user preferences', + parameters: z.object({ + category: z.string().optional().describe('Category of preferences to collect'), + }), + }, + execute: async ({ category = 'general' }) => { + try { + // Use custom schema for preferences + const schema = ElicitationSchemas.choice({ + title: 'Experience Level', + description: `What are your ${category} preferences?`, + choices: ['beginner', 'intermediate', 'advanced'], + choiceLabels: ['Beginner (just starting)', 'Intermediate (some experience)', 'Advanced (expert level)'], + }); + + const result = await elicit(`Please select your ${category} experience level:`, schema); + + return `User selected ${category} preference: ${result.choice}`; + } catch (error) { + // Let elicitation interruption errors propagate to the engine + if (error instanceof ElicitationInterruptionError) { + throw error; + } + return `Failed to collect preferences: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }, +}; + +// Tool that demonstrates text input elicitation +const feedbackTool: Tool<{ topic?: string }, any> = { + schema: { + name: 'getFeedback', + description: 'Collect user feedback', + parameters: z.object({ + topic: z.string().optional().describe('Topic to get feedback on'), + }), + }, + execute: async ({ topic = 'our service' }) => { + try { + const feedback = await Elicit.text( + `Please provide your feedback about ${topic}:`, + { + title: 'Your Feedback', + description: 'Help us improve by sharing your thoughts', + minLength: 10, + maxLength: 500, + } + ); + + return `Received feedback about ${topic}: "${feedback.substring(0, 100)}${feedback.length > 100 ? '...' : ''}"`; + } catch (error) { + // Let elicitation interruption errors propagate to the engine + if (error instanceof ElicitationInterruptionError) { + throw error; + } + return `Failed to collect feedback: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }, +}; + +// Tool that demonstrates confirmation elicitation +const confirmActionTool: Tool<{ action: string }, any> = { + schema: { + name: 'confirmAction', + description: 'Ask user to confirm an action', + parameters: z.object({ + action: z.string().describe('Action to confirm'), + }) as z.ZodType<{ action: string }>, + }, + execute: async ({ action }) => { + try { + const confirmed = await Elicit.confirm(`Are you sure you want to ${action}?`); + + if (confirmed) { + return `User confirmed: ${action}. Action would be executed now.`; + } else { + return `User declined: ${action}. Action was cancelled.`; + } + } catch (error) { + // Let elicitation interruption errors propagate to the engine + if (error instanceof ElicitationInterruptionError) { + throw error; + } + return `Failed to get confirmation: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }, +}; + +// Tool that demonstrates number input elicitation +const getQuantityTool: Tool<{ item?: string }, any> = { + schema: { + name: 'getQuantity', + description: 'Ask user for a quantity', + parameters: z.object({ + item: z.string().optional().describe('Item to get quantity for'), + }), + }, + execute: async ({ item = 'items' }) => { + try { + const quantity = await Elicit.number( + `How many ${item} do you need?`, + { + title: 'Quantity', + description: 'Enter the number you need', + minimum: 1, + maximum: 100, + integer: true, + } + ); + + return `User requested ${quantity} ${item}`; + } catch (error) { + // Let elicitation interruption errors propagate to the engine + if (error instanceof ElicitationInterruptionError) { + throw error; + } + return `Failed to get quantity: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }, +}; + +const elicitationAgent: Agent = { + name: 'Elicitation Demo Agent', + instructions: () => `You are a helpful assistant that demonstrates MCP elicitation features. + +Available tools for collecting user information: +- getUserInfo: Collect contact information +- getPreferences: Collect user preferences +- getFeedback: Collect user feedback +- confirmAction: Ask for confirmation +- getQuantity: Ask for numerical input + +When the user asks about elicitation or wants to see examples, use these tools to demonstrate how they work. +Explain what each tool does before using it. +`, + tools: [getUserInfoTool, preferencesTool, feedbackTool, confirmActionTool, getQuantityTool], + modelConfig: { name: process.env.LITELLM_MODEL || 'gpt-3.5-turbo', temperature: 0.7 }, +}; + +async function main() { + const host = process.env.HOST || '127.0.0.1'; + const port = parseInt(process.env.PORT || '3000', 10); + + // Model provider + const baseURL = process.env.LITELLM_URL || 'http://localhost:4000'; + const apiKey = process.env.LITELLM_API_KEY || 'sk-demo'; + const modelProvider = makeLiteLLMProvider(baseURL, apiKey); + + // Memory provider + const memoryProvider = createInMemoryProvider(); + + // Elicitation provider + const elicitationProvider = new ServerElicitationProvider(); + + console.log('๐Ÿš€ Starting Elicitation Demo Server...'); + console.log('This server demonstrates MCP elicitation features in JAF'); + console.log(''); + + const server = await runServer( + [elicitationAgent], + { + modelProvider, + elicitationProvider, + memory: { + provider: memoryProvider, + autoStore: true, + storeOnCompletion: true + } + }, + { + port, + host, + defaultMemoryProvider: memoryProvider, + elicitationProvider, + } + ); + + console.log(''); + console.log('๐Ÿ“‹ Available endpoints:'); + console.log(` POST ${host}:${port}/chat - Chat with agents`); + console.log(` GET ${host}:${port}/elicitation/pending - View pending elicitation requests`); + console.log(` POST ${host}:${port}/elicitation/respond - Respond to elicitation requests`); + console.log(''); + console.log('๐Ÿ’ก Try asking the agent to:'); + console.log(' - "Collect my contact information"'); + console.log(' - "Get my preferences for programming"'); + console.log(' - "Ask for my feedback on this demo"'); + console.log(' - "Confirm if I want to delete my account"'); + console.log(' - "Ask how many tickets I need"'); + console.log(''); + console.log('๐Ÿ”„ When elicitation requests are made, use the /elicitation endpoints to respond.'); + + // Graceful shutdown + process.on('SIGINT', async () => { + console.log('\n๐Ÿ“ค Shutting down server...'); + await server.stop(); + process.exit(0); + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(console.error); +} \ No newline at end of file diff --git a/examples/elicitation-demo/interactive-client.ts b/examples/elicitation-demo/interactive-client.ts new file mode 100644 index 0000000..6b03b12 --- /dev/null +++ b/examples/elicitation-demo/interactive-client.ts @@ -0,0 +1,460 @@ +#!/usr/bin/env tsx + +import 'dotenv/config'; +import * as readline from 'readline'; + +/** + * Interactive client that demonstrates manual handling of elicitation requests + * This shows how a client application would interact with the JAF server + * when elicitation interruptions occur, with real user input. + */ + +interface ElicitationRequest { + id: string; + message: string; + requestedSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + metadata?: Record; +} + +interface ChatMessage { + role: 'user' | 'assistant'; + content: string; +} + +interface ChatResponse { + success: boolean; + data?: { + runId: string; + traceId: string; + conversationId: string; + messages: ChatMessage[]; + outcome: { + status: 'completed' | 'error' | 'interrupted'; + output?: string; + error?: any; + interruptions?: Array<{ + type: 'elicitation'; + request: ElicitationRequest; + sessionId: string; + }>; + }; + turnCount: number; + executionTimeMs: number; + }; + error?: string; +} + +class ElicitationClient { + private baseUrl: string; + private conversationId: string; + private rl: readline.Interface; + + constructor(baseUrl: string = 'http://localhost:3000') { + this.baseUrl = baseUrl; + this.conversationId = `demo-${Date.now()}`; + + // Create readline interface for user input + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + } + + private async question(prompt: string): Promise { + return new Promise((resolve) => { + this.rl.question(prompt, resolve); + }); + } + + async sendMessage(content: string, elicitationResponses: any[] = []): Promise { + const response = await fetch(`${this.baseUrl}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages: [{ role: 'user', content }], + agentName: 'Elicitation Demo Agent', + conversationId: this.conversationId, + elicitationResponses, + }), + }); + + return response.json(); + } + + async respondToElicitation(requestId: string, action: 'accept' | 'decline' | 'cancel', content?: any) { + const response = await fetch(`${this.baseUrl}/elicitation/respond`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + requestId, + action, + content, + }), + }); + + return response.json(); + } + + async getPendingElicitations() { + const response = await fetch(`${this.baseUrl}/elicitation/pending`); + return response.json(); + } + + private displaySchema(schema: ElicitationRequest['requestedSchema']): void { + console.log('\n๐Ÿ“‹ Form Fields:'); + console.log('================'); + + for (const [key, property] of Object.entries(schema.properties)) { + const isRequired = schema.required?.includes(key); + const requiredText = isRequired ? ' (REQUIRED)' : ' (optional)'; + + console.log(`\n๐Ÿ”ธ ${property.title || key}${requiredText}`); + if (property.description) { + console.log(` Description: ${property.description}`); + } + + if (property.type === 'string') { + if (property.enum) { + console.log(` Type: Choice`); + console.log(` Options:`); + property.enum.forEach((option: string, index: number) => { + const label = property.enumNames?.[index] || option; + console.log(` ${index + 1}. ${option} - ${label}`); + }); + } else if (property.format === 'email') { + console.log(` Type: Email address`); + } else { + console.log(` Type: Text`); + if (property.minLength) console.log(` Min length: ${property.minLength}`); + if (property.maxLength) console.log(` Max length: ${property.maxLength}`); + } + } else if (property.type === 'number' || property.type === 'integer') { + console.log(` Type: ${property.type}`); + if (property.minimum !== undefined) console.log(` Min: ${property.minimum}`); + if (property.maximum !== undefined) console.log(` Max: ${property.maximum}`); + } else if (property.type === 'boolean') { + console.log(` Type: Yes/No confirmation`); + } + + if (property.default !== undefined) { + console.log(` Default: ${property.default}`); + } + } + console.log('\n================'); + } + + private async collectUserInput(schema: ElicitationRequest['requestedSchema']): Promise | null> { + const result: Record = {}; + + console.log('\nPlease provide the following information:'); + + for (const [key, property] of Object.entries(schema.properties)) { + const isRequired = schema.required?.includes(key); + const fieldName = property.title || key; + + while (true) { + if (property.type === 'string' && property.enum) { + // Choice field + console.log(`\n${fieldName}:`); + property.enum.forEach((option: string, index: number) => { + const label = property.enumNames?.[index] || option; + console.log(` ${index + 1}. ${label}`); + }); + + const choiceInput = await this.question(`Enter choice (1-${property.enum.length})${!isRequired ? ' or press Enter to skip' : ''}: `); + + if (!choiceInput.trim() && !isRequired) { + break; // Skip optional field + } + + const choiceIndex = parseInt(choiceInput) - 1; + if (choiceIndex >= 0 && choiceIndex < property.enum.length) { + result[key] = property.enum[choiceIndex]; + break; + } else { + console.log('โŒ Invalid choice. Please try again.'); + } + } else if (property.type === 'boolean') { + // Boolean field + const boolInput = await this.question(`${fieldName} (y/n)${!isRequired ? ' or press Enter to skip' : ''}: `); + + if (!boolInput.trim() && !isRequired) { + break; // Skip optional field + } + + const normalizedInput = boolInput.toLowerCase().trim(); + if (normalizedInput === 'y' || normalizedInput === 'yes' || normalizedInput === 'true') { + result[key] = true; + break; + } else if (normalizedInput === 'n' || normalizedInput === 'no' || normalizedInput === 'false') { + result[key] = false; + break; + } else { + console.log('โŒ Please enter y/n, yes/no, or true/false.'); + } + } else if (property.type === 'number' || property.type === 'integer') { + // Number field + const numberInput = await this.question(`${fieldName}${!isRequired ? ' (or press Enter to skip)' : ''}: `); + + if (!numberInput.trim() && !isRequired) { + break; // Skip optional field + } + + const parsedNumber = property.type === 'integer' ? parseInt(numberInput) : parseFloat(numberInput); + + if (!isNaN(parsedNumber)) { + if (property.minimum !== undefined && parsedNumber < property.minimum) { + console.log(`โŒ Value must be at least ${property.minimum}`); + continue; + } + if (property.maximum !== undefined && parsedNumber > property.maximum) { + console.log(`โŒ Value must be at most ${property.maximum}`); + continue; + } + result[key] = parsedNumber; + break; + } else { + console.log('โŒ Please enter a valid number.'); + } + } else { + // String field + const stringInput = await this.question(`${fieldName}${!isRequired ? ' (or press Enter to skip)' : ''}: `); + + if (!stringInput.trim() && !isRequired) { + break; // Skip optional field + } + + if (stringInput.trim() || !isRequired) { + if (property.format === 'email') { + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(stringInput)) { + console.log('โŒ Please enter a valid email address.'); + continue; + } + } + + if (property.minLength && stringInput.length < property.minLength) { + console.log(`โŒ Must be at least ${property.minLength} characters long.`); + continue; + } + + if (property.maxLength && stringInput.length > property.maxLength) { + console.log(`โŒ Must be at most ${property.maxLength} characters long.`); + continue; + } + + result[key] = stringInput; + break; + } else if (isRequired) { + console.log('โŒ This field is required.'); + } + } + } + } + + return result; + } + + async handleElicitationInterruption(interruption: any): Promise { + const { request } = interruption; + + console.log('\n๐Ÿšจ ELICITATION REQUEST'); + console.log('======================'); + console.log(`๐Ÿ“ ${request.message}`); + + this.displaySchema(request.requestedSchema); + + console.log('\nWhat would you like to do?'); + console.log('1. Fill out the form'); + console.log('2. Decline this request'); + console.log('3. Cancel the operation'); + + while (true) { + const action = await this.question('\nEnter your choice (1-3): '); + + if (action === '1') { + console.log('\n๐Ÿ“ Please fill out the form:'); + const userInput = await this.collectUserInput(request.requestedSchema); + + if (userInput) { + console.log('\nโœ… Form submitted!'); + const response = await this.respondToElicitation(request.id, 'accept', userInput); + console.log('๐Ÿ“ค Response status:', response.success ? 'SUCCESS' : 'FAILED'); + + return { + type: 'elicitation_response', + requestId: request.id, + action: 'accept', + content: userInput, + }; + } + } else if (action === '2') { + console.log('\nโŒ Request declined'); + const response = await this.respondToElicitation(request.id, 'decline'); + console.log('๐Ÿ“ค Response status:', response.success ? 'SUCCESS' : 'FAILED'); + + return { + type: 'elicitation_response', + requestId: request.id, + action: 'decline', + }; + } else if (action === '3') { + console.log('\n๐Ÿšซ Operation cancelled'); + const response = await this.respondToElicitation(request.id, 'cancel'); + console.log('๐Ÿ“ค Response status:', response.success ? 'SUCCESS' : 'FAILED'); + + return { + type: 'elicitation_response', + requestId: request.id, + action: 'cancel', + }; + } else { + console.log('โŒ Invalid choice. Please enter 1, 2, or 3.'); + } + } + } + + async runInteractiveDemo(): Promise { + console.log('๐ŸŽฏ Interactive Elicitation Demo'); + console.log('==============================='); + console.log(`๐Ÿ“ก Connected to: ${this.baseUrl}`); + console.log(`๐Ÿ’ฌ Conversation ID: ${this.conversationId}`); + console.log(''); + console.log('This demo lets you manually interact with elicitation requests.'); + console.log('You can test different tools and respond to forms yourself.'); + console.log(''); + console.log('Available commands to try:'); + console.log('- "Collect my contact information"'); + console.log('- "Get my programming preferences"'); + console.log('- "Ask for feedback on the interface"'); + console.log('- "Confirm account deletion"'); + console.log('- "Ask how many items I need"'); + console.log('- "quit" to exit'); + console.log(''); + + // Start the recursive conversation loop + await this.conversationLoop([]); + } + + /** + * Recursive conversation loop following JAF patterns + */ + private async conversationLoop(conversationHistory: ChatMessage[]): Promise { + try { + const userMessage = await this.question('๐Ÿ’ญ Your message: '); + + if (userMessage.toLowerCase().trim() === 'quit') { + console.log('\n๐Ÿ‘‹ Goodbye!'); + return; + } + + if (!userMessage.trim()) { + console.log('โŒ Please enter a message or "quit" to exit.'); + return this.conversationLoop(conversationHistory); + } + + console.log('\nโณ Processing...'); + + // Process the conversation turn + const result = await this.processConversationTurn(userMessage, conversationHistory); + + if (result.shouldContinue) { + console.log('\n' + '='.repeat(50)); + // Recursive call to continue the conversation + return this.conversationLoop(result.newHistory); + } + + } catch (error) { + console.error('\nโŒ Demo error:', error); + console.log('Please make sure the server is running and try again.'); + // Continue the conversation even after errors + return this.conversationLoop(conversationHistory); + } + } + + /** + * Process a single conversation turn with proper interruption handling + */ + private async processConversationTurn( + userInput: string, + conversationHistory: ChatMessage[] + ): Promise<{ newHistory: ChatMessage[]; shouldContinue: boolean }> { + + // Add user message to conversation + const newHistory: ChatMessage[] = [...conversationHistory, { role: 'user' as const, content: userInput }]; + + // Initial request to the server + let response = await this.sendMessage(userInput); + + // Handle interruptions (following JAF pattern) + for (;;) { + if (response.data?.outcome.status === 'interrupted') { + const interruptions = response.data.outcome.interruptions || []; + const elicitationResponses: any[] = []; + + for (const interruption of interruptions) { + if (interruption.type === 'elicitation') { + const elicitationResponse = await this.handleElicitationInterruption(interruption); + elicitationResponses.push(elicitationResponse); + } + } + + if (elicitationResponses.length > 0) { + console.log('\n๐Ÿ”„ Continuing conversation with your responses...'); + response = await this.sendMessage('', elicitationResponses); + // Continue the loop to handle any further interruptions + continue; + } + } else if (response.data?.outcome.status === 'completed') { + // Extract final assistant response + if (response.data?.messages) { + const lastMessage = response.data.messages.slice(-1)[0]; + if (lastMessage?.role === 'assistant') { + console.log('\n๐Ÿค– Assistant Response:'); + console.log('======================'); + console.log(lastMessage.content); + + // Add assistant response to conversation history + const finalHistory = [...newHistory, { role: 'assistant' as const, content: lastMessage.content }]; + return { newHistory: finalHistory, shouldContinue: true }; + } + } + return { newHistory, shouldContinue: true }; + } else if (response.data?.outcome.status === 'error') { + console.log('\nโŒ Error:', response.data.outcome.error); + return { newHistory, shouldContinue: true }; + } + + // If we get here, something unexpected happened - break to avoid infinite loop + break; + } + + return { newHistory, shouldContinue: true }; + } + + close(): void { + this.rl.close(); + } +} + +async function main() { + const client = new ElicitationClient(); + + try { + await client.runInteractiveDemo(); + } finally { + client.close(); + } +} + +// Run the demo +main().catch(console.error); \ No newline at end of file diff --git a/examples/elicitation-demo/package.json b/examples/elicitation-demo/package.json new file mode 100644 index 0000000..8f0847d --- /dev/null +++ b/examples/elicitation-demo/package.json @@ -0,0 +1,20 @@ +{ + "name": "elicitation-demo", + "version": "1.0.0", + "type": "module", + "description": "JAF MCP Elicitation Demo", + "scripts": { + "dev": "tsx elicitation-server.ts", + "server": "tsx elicitation-server.ts", + "client": "tsx interactive-client.ts", + "demo": "tsx interactive-client.ts", + "test": "tsx unit-tests.ts" + }, + "dependencies": { + "dotenv": "^16.0.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "tsx": "^4.0.0" + } +} \ No newline at end of file diff --git a/examples/elicitation-demo/unit-tests.ts b/examples/elicitation-demo/unit-tests.ts new file mode 100644 index 0000000..d84f981 --- /dev/null +++ b/examples/elicitation-demo/unit-tests.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env tsx + +/** + * Simple test to verify elicitation implementation works + */ + +import { createElicitationRequest, validateElicitationResponse, ElicitationSchemas, createServerElicitationProvider } from '../../dist/index.js'; + +async function testElicitationValidation() { + console.log('๐Ÿงช Testing elicitation validation...'); + + // Test 1: Valid contact info + const contactSchema = ElicitationSchemas.contactInfo(); + const contactRequest = createElicitationRequest( + 'Please provide your contact information', + contactSchema + ); + + const validContactResponse = { + requestId: contactRequest.id, + action: 'accept' as const, + content: { + name: 'John Doe', + email: 'john@example.com', + phone: '+1234567890' + } + }; + + const contactValidation = validateElicitationResponse(validContactResponse, contactRequest); + console.log('โœ… Contact info validation:', contactValidation.isValid ? 'PASS' : 'FAIL'); + + // Test 2: Invalid email format + const invalidContactResponse = { + requestId: contactRequest.id, + action: 'accept' as const, + content: { + name: 'John Doe', + email: 'invalid-email', + phone: '+1234567890' + } + }; + + const invalidContactValidation = validateElicitationResponse(invalidContactResponse, contactRequest); + console.log('โœ… Invalid email validation:', !invalidContactValidation.isValid ? 'PASS' : 'FAIL'); + + // Test 3: Choice validation + const choiceSchema = ElicitationSchemas.choice({ + title: 'Experience Level', + description: 'Select your experience level', + choices: ['beginner', 'intermediate', 'advanced'] + }); + + const choiceRequest = createElicitationRequest('Select your level', choiceSchema); + const validChoiceResponse = { + requestId: choiceRequest.id, + action: 'accept' as const, + content: { + choice: 'intermediate' + } + }; + + const choiceValidation = validateElicitationResponse(validChoiceResponse, choiceRequest); + console.log('โœ… Choice validation:', choiceValidation.isValid ? 'PASS' : 'FAIL'); + + // Test 4: Invalid choice + const invalidChoiceResponse = { + requestId: choiceRequest.id, + action: 'accept' as const, + content: { + choice: 'expert' // Not in enum + } + }; + + const invalidChoiceValidation = validateElicitationResponse(invalidChoiceResponse, choiceRequest); + console.log('โœ… Invalid choice validation:', !invalidChoiceValidation.isValid ? 'PASS' : 'FAIL'); + + console.log('โœ… Validation tests completed!'); +} + +async function testElicitationProvider() { + console.log('\n๐Ÿงช Testing elicitation provider...'); + + const provider = createServerElicitationProvider(); + + // Create a test request + const testSchema = ElicitationSchemas.text({ title: 'Test Input' }); + const testRequest = createElicitationRequest('Enter some text', testSchema); + + // Test provider functionality + console.log('๐Ÿ“‹ Pending requests (empty):', provider.getPendingRequests().length === 0 ? 'PASS' : 'FAIL'); + + // Simulate async elicitation + const elicitationPromise = provider.createElicitation(testRequest); + + // Check that request is now pending + console.log('๐Ÿ“‹ Pending requests (1):', provider.getPendingRequests().length === 1 ? 'PASS' : 'FAIL'); + + // Respond to the request + const responseSuccess = provider.respondToElicitation({ + requestId: testRequest.id, + action: 'accept', + content: { text: 'Test response' } + }); + + console.log('๐Ÿ“ค Response handled:', responseSuccess ? 'PASS' : 'FAIL'); + + // Check that the promise resolves + const response = await elicitationPromise; + console.log('โœ… Promise resolved:', response.action === 'accept' ? 'PASS' : 'FAIL'); + + // Check that request is no longer pending + console.log('๐Ÿ“‹ Pending requests (0):', provider.getPendingRequests().length === 0 ? 'PASS' : 'FAIL'); + + console.log('โœ… Provider tests completed!'); +} + +async function main() { + console.log('๐Ÿš€ Running MCP Elicitation Tests\n'); + + try { + await testElicitationValidation(); + await testElicitationProvider(); + console.log('\n๐ŸŽ‰ All tests passed! Elicitation implementation is working correctly.'); + } catch (error) { + console.error('\nโŒ Test failed:', error); + process.exit(1); + } +} + +// Run the tests +main().catch(console.error); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cac41d..1cf39ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,19 @@ importers: specifier: ^5.3.3 version: 5.9.2 + examples/elicitation-demo: + dependencies: + dotenv: + specifier: ^16.0.0 + version: 16.6.1 + zod: + specifier: ^3.22.0 + version: 3.25.76 + devDependencies: + tsx: + specifier: ^4.0.0 + version: 4.20.4 + examples/flight-booking: dependencies: '@xynehq/jaf': diff --git a/src/core/elicit.ts b/src/core/elicit.ts new file mode 100644 index 0000000..95f12a4 --- /dev/null +++ b/src/core/elicit.ts @@ -0,0 +1,230 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { + ElicitationRequest, + ElicitationResponse, + ElicitationSchema, + JAFError, +} from './types.js'; +import { createElicitationRequest, validateElicitationResponse } from './elicitation.js'; + +/** + * Special error thrown when elicitation is needed + * This will be caught by the engine and converted to an interruption + */ +export class ElicitationInterruptionError extends Error { + constructor( + public readonly request: ElicitationRequest, + public readonly state: any, + public readonly config: any + ) { + super('Elicitation required'); + this.name = 'ElicitationInterruptionError'; + } +} +// AsyncLocalStorage for tool context to avoid race conditions +const toolContextStorage = new AsyncLocalStorage(); + +export function runWithElicitationContext(context: any, fn: () => T): T { + return toolContextStorage.run(context, fn); +} + +function getCurrentToolContext(): any { + const context = toolContextStorage.getStore(); + if (!context) { + throw new Error('elicit() can only be called from within a tool execution context'); + } + return context; +} + +/** + * Elicit structured information from the user during tool execution + * This function can only be called from within a tool's execute function + */ +export async function elicit( + message: string, + requestedSchema: ElicitationSchema, + metadata?: Record +): Promise> { + const context = getCurrentToolContext(); + const { config, state } = context; + + if (!config.elicitationProvider) { + throw new Error('Elicitation provider is not configured'); + } + + const request = createElicitationRequest(message, requestedSchema, metadata); + + // Store the request for the provider + config.elicitationProvider.createElicitation(request); + + // Emit elicitation request event + config.onEvent?.({ + type: 'elicitation_request', + data: { + request, + agentName: state.currentAgentName, + traceId: state.traceId, + runId: state.runId, + }, + }); + + // Throw interruption error - this will be caught by the engine and converted to an interruption + throw new ElicitationInterruptionError(request, state, config); +} + +/** + * Convenience functions for common elicitation patterns + */ +export const Elicit = { + /** + * Request simple text input from user + */ + async text( + message: string, + options: { + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + required?: boolean; + } = {} + ): Promise { + const schema: ElicitationSchema = { + type: 'object', + properties: { + text: { + type: 'string', + title: options.title || 'Text Input', + description: options.description || message, + minLength: options.minLength, + maxLength: options.maxLength, + }, + }, + required: options.required !== false ? ['text'] : [], + }; + + const result = await elicit(message, schema); + return result.text || ''; + }, + + /** + * Request user confirmation (yes/no) + */ + async confirm(message: string): Promise { + const schema: ElicitationSchema = { + type: 'object', + properties: { + confirmed: { + type: 'boolean', + title: 'Confirmation', + description: message, + default: false, + }, + }, + required: ['confirmed'], + }; + + const result = await elicit(message, schema); + return Boolean(result.confirmed); + }, + + /** + * Request user to choose from a list of options + */ + async choice( + message: string, + choices: readonly string[], + options: { + title?: string; + choiceLabels?: readonly string[]; + } = {} + ): Promise { + const schema: ElicitationSchema = { + type: 'object', + properties: { + choice: { + type: 'string', + title: options.title || 'Selection', + description: message, + enum: choices, + enumNames: options.choiceLabels, + }, + }, + required: ['choice'], + }; + + const result = await elicit(message, schema); + return result.choice; + }, + + /** + * Request contact information + */ + async contactInfo(message: string = 'Please provide your contact information'): Promise<{ + name: string; + email: string; + phone?: string; + }> { + const schema: ElicitationSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Your full name', + minLength: 1, + }, + email: { + type: 'string', + title: 'Email Address', + description: 'Your email address', + format: 'email', + }, + phone: { + type: 'string', + title: 'Phone Number', + description: 'Your phone number (optional)', + }, + }, + required: ['name', 'email'], + }; + + const result = await elicit(message, schema); + return { + name: result.name, + email: result.email, + phone: result.phone, + }; + }, + + /** + * Request a number input + */ + async number( + message: string, + options: { + title?: string; + description?: string; + minimum?: number; + maximum?: number; + integer?: boolean; + } = {} + ): Promise { + const schema: ElicitationSchema = { + type: 'object', + properties: { + number: { + type: options.integer ? 'integer' : 'number', + title: options.title || 'Number Input', + description: options.description || message, + minimum: options.minimum, + maximum: options.maximum, + }, + }, + required: ['number'], + }; + + const result = await elicit(message, schema); + return result.number; + }, +}; \ No newline at end of file diff --git a/src/core/elicitation-provider.ts b/src/core/elicitation-provider.ts new file mode 100644 index 0000000..5dddf40 --- /dev/null +++ b/src/core/elicitation-provider.ts @@ -0,0 +1,176 @@ +import { + ElicitationProvider, + ElicitationRequest, + ElicitationResponse, + createElicitationRequestId, +} from './types.js'; + +/** + * Simple in-memory elicitation provider that stores pending requests + * and allows them to be responded to via the server API + */ +export class ServerElicitationProvider implements ElicitationProvider { + private pendingRequests = new Map(); + private resolvers = new Map void>(); + private responses = new Map(); + private responseTimestamps = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + // Default TTL for orphaned responses: 5 minutes + private readonly RESPONSE_TTL_MS = 5 * 60 * 1000; + + async createElicitation(request: ElicitationRequest): Promise { + // Start cleanup if not already running + this.startPeriodicCleanup(); + + // Check if we already have a response for this request + const existingResponse = this.responses.get(request.id); + if (existingResponse) { + this.responses.delete(request.id); + this.responseTimestamps.delete(request.id); + return existingResponse; + } + + // Store the request as pending + this.pendingRequests.set(request.id, request); + + // Return a promise that will be resolved when response is provided + return new Promise((resolve) => { + this.resolvers.set(request.id, resolve); + }); + } + + /** + * Provide a response to a pending elicitation request + */ + respondToElicitation(response: ElicitationResponse): boolean { + const resolver = this.resolvers.get(response.requestId); + if (!resolver) { + // Store the response for later with timestamp for TTL + this.responses.set(response.requestId, response); + this.responseTimestamps.set(response.requestId, Date.now()); + return false; + } + + // Clean up and resolve + this.resolvers.delete(response.requestId); + this.pendingRequests.delete(response.requestId); + resolver(response); + return true; + } + + /** + * Get all pending elicitation requests + */ + getPendingRequests(): ElicitationRequest[] { + return Array.from(this.pendingRequests.values()); + } + + /** + * Get a specific pending request by ID + */ + getPendingRequest(requestId: string): ElicitationRequest | undefined { + return this.pendingRequests.get(requestId); + } + + /** + * Cancel a pending request + */ + cancelRequest(requestId: string): boolean { + const resolver = this.resolvers.get(requestId); + if (!resolver) { + return false; + } + + this.resolvers.delete(requestId); + this.pendingRequests.delete(requestId); + + // Resolve with cancel action + resolver({ + requestId: createElicitationRequestId(requestId), + action: 'cancel', + }); + + return true; + } + + /** + * Clean up expired orphaned responses + */ + private cleanupExpiredResponses(): void { + const now = Date.now(); + const expiredIds: string[] = []; + + for (const [requestId, timestamp] of Array.from(this.responseTimestamps.entries())) { + if (now - timestamp > this.RESPONSE_TTL_MS) { + expiredIds.push(requestId); + } + } + + for (const requestId of expiredIds) { + this.responses.delete(requestId); + this.responseTimestamps.delete(requestId); + } + + if (expiredIds.length > 0) { + console.log(`[ElicitationProvider] Cleaned up ${expiredIds.length} expired orphaned responses`); + } + } + + /** + * Start periodic cleanup of expired responses + */ + private startPeriodicCleanup(): void { + if (this.cleanupInterval) { + return; // Already running + } + + // Run cleanup every minute + this.cleanupInterval = setInterval(() => { + this.cleanupExpiredResponses(); + + // Stop cleanup if no responses to monitor + if (this.responses.size === 0 && this.resolvers.size === 0) { + this.stopPeriodicCleanup(); + } + }, 60 * 1000); + } + + /** + * Stop periodic cleanup + */ + private stopPeriodicCleanup(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Clear all pending requests (useful for cleanup) + */ + clearAllRequests(): void { + // Stop periodic cleanup + this.stopPeriodicCleanup(); + + // Cancel all pending requests + for (const [requestId, resolver] of Array.from(this.resolvers.entries())) { + resolver({ + requestId: createElicitationRequestId(requestId), + action: 'cancel', + }); + } + + this.pendingRequests.clear(); + this.resolvers.clear(); + this.responses.clear(); + this.responseTimestamps.clear(); + } +} + +/** + * Create a server elicitation provider instance + */ +export function createServerElicitationProvider(): ServerElicitationProvider { + return new ServerElicitationProvider(); +} \ No newline at end of file diff --git a/src/core/elicitation.ts b/src/core/elicitation.ts new file mode 100644 index 0000000..f59531c --- /dev/null +++ b/src/core/elicitation.ts @@ -0,0 +1,280 @@ +import { v4 as uuidv4 } from 'uuid'; +import { z } from 'zod'; +import { + ElicitationRequest, + ElicitationResponse, + ElicitationRequestId, + ElicitationSchema, + ElicitationPropertySchema, + ElicitationStringSchema, + ElicitationChoiceSchema, + ElicitationNumberSchema, + ElicitationBooleanSchema, + createElicitationRequestId, +} from './types.js'; + +/** + * Helper function to create an elicitation request + */ +export function createElicitationRequest( + message: string, + requestedSchema: ElicitationSchema, + metadata?: Record +): ElicitationRequest { + return { + id: createElicitationRequestId(uuidv4()), + message, + requestedSchema, + metadata, + }; +} + +/** + * Validates an elicitation response against the requested schema + */ +export function validateElicitationResponse( + response: ElicitationResponse, + request: ElicitationRequest +): { isValid: true; data: Record } | { isValid: false; errors: string[] } { + if (response.action !== 'accept' || !response.content) { + return { isValid: true, data: {} }; + } + + const errors: string[] = []; + const { properties, required = [] } = request.requestedSchema; + + // Check required fields + for (const field of required) { + if (!(field in response.content)) { + errors.push(`Required field '${field}' is missing`); + } + } + + // Validate each property + for (const [key, value] of Object.entries(response.content)) { + const schema = properties[key]; + if (!schema) { + continue; // Allow extra fields + } + + const validation = validateProperty(key, value, schema); + if (validation.isValid === false) { + errors.push(...validation.errors); + } + } + + if (errors.length > 0) { + return { isValid: false, errors }; + } + + return { isValid: true, data: response.content }; +} + +function validateProperty( + fieldName: string, + value: any, + schema: ElicitationPropertySchema +): { isValid: true } | { isValid: false; errors: string[] } { + const errors: string[] = []; + + switch (schema.type) { + case 'string': + if (typeof value !== 'string') { + errors.push(`Field '${fieldName}' must be a string`); + break; + } + + // Check for enum first (choice type) + if ('enum' in schema) { + const choiceSchema = schema as ElicitationChoiceSchema; + if (choiceSchema.enum && !choiceSchema.enum.includes(value)) { + errors.push(`Field '${fieldName}' must be one of: ${choiceSchema.enum.join(', ')}`); + } + } else { + // Regular string schema + const stringSchema = schema as ElicitationStringSchema; + + if (stringSchema.minLength && value.length < stringSchema.minLength) { + errors.push(`Field '${fieldName}' must be at least ${stringSchema.minLength} characters`); + } + + if (stringSchema.maxLength && value.length > stringSchema.maxLength) { + errors.push(`Field '${fieldName}' must be at most ${stringSchema.maxLength} characters`); + } + + if (stringSchema.pattern) { + try { + const regex = new RegExp(stringSchema.pattern); + if (!regex.test(value)) { + errors.push(`Field '${fieldName}' does not match the required pattern`); + } + } catch { + // Invalid regex pattern - skip validation + } + } + + if (stringSchema.format) { + const formatValidation = validateFormat(value, stringSchema.format); + if (!formatValidation.isValid) { + errors.push(`Field '${fieldName}' has invalid ${stringSchema.format} format`); + } + } + } + break; + + case 'number': + case 'integer': { + if (typeof value !== 'number') { + errors.push(`Field '${fieldName}' must be a number`); + break; + } + + const numberSchema = schema as ElicitationNumberSchema; + + if (numberSchema.type === 'integer' && !Number.isInteger(value)) { + errors.push(`Field '${fieldName}' must be an integer`); + } + + if (numberSchema.minimum !== undefined && value < numberSchema.minimum) { + errors.push(`Field '${fieldName}' must be at least ${numberSchema.minimum}`); + } + + if (numberSchema.maximum !== undefined && value > numberSchema.maximum) { + errors.push(`Field '${fieldName}' must be at most ${numberSchema.maximum}`); + } + break; + } + + case 'boolean': + if (typeof value !== 'boolean') { + errors.push(`Field '${fieldName}' must be a boolean`); + } + break; + } + + return errors.length > 0 ? { isValid: false, errors } : { isValid: true }; +} + +function validateFormat(value: string, format: string): { isValid: boolean } { + switch (format) { + case 'email': + return { isValid: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) }; + case 'uri': + try { + new URL(value); + return { isValid: true }; + } catch { + return { isValid: false }; + } + case 'date': + return { isValid: /^\d{4}-\d{2}-\d{2}$/.test(value) && !isNaN(Date.parse(value)) }; + case 'date-time': + return { isValid: !isNaN(Date.parse(value)) }; + default: + return { isValid: true }; + } +} + +/** + * Helper to create common elicitation schemas + */ +export const ElicitationSchemas = { + /** + * Simple text input + */ + text(options: { + title?: string; + description?: string; + required?: boolean; + minLength?: number; + maxLength?: number; + default?: string; + } = {}): ElicitationSchema { + return { + type: 'object', + properties: { + text: { + type: 'string', + title: options.title || 'Text', + description: options.description || 'Please enter text', + minLength: options.minLength, + maxLength: options.maxLength, + default: options.default, + }, + }, + required: options.required !== false ? ['text'] : [], + }; + }, + + /** + * Contact information form + */ + contactInfo(): ElicitationSchema { + return { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Your full name', + minLength: 1, + }, + email: { + type: 'string', + title: 'Email Address', + description: 'Your email address', + format: 'email', + }, + phone: { + type: 'string', + title: 'Phone Number', + description: 'Your phone number (optional)', + }, + }, + required: ['name', 'email'], + }; + }, + + /** + * Yes/No confirmation + */ + confirmation(message?: string): ElicitationSchema { + return { + type: 'object', + properties: { + confirmed: { + type: 'boolean', + title: 'Confirmation', + description: message || 'Please confirm your choice', + default: false, + }, + }, + required: ['confirmed'], + }; + }, + + /** + * Multiple choice selection + */ + choice(options: { + title?: string; + description?: string; + choices: readonly string[]; + choiceLabels?: readonly string[]; + required?: boolean; + }): ElicitationSchema { + return { + type: 'object', + properties: { + choice: { + type: 'string', + title: options.title || 'Selection', + description: options.description || 'Please make a selection', + enum: options.choices, + enumNames: options.choiceLabels, + }, + }, + required: options.required !== false ? ['choice'] : [], + }; + }, +}; \ No newline at end of file diff --git a/src/core/engine.ts b/src/core/engine.ts index 57f351b..b1d3b47 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -47,8 +47,11 @@ export async function run( stateWithMemory = await loadApprovalsIntoState(stateWithMemory, config); } - const result = await runInternal(stateWithMemory, config); - + // Check if we need to resume interrupted elicitation + const resumedState = await checkAndResumeElicitation(stateWithMemory, config); + + const result = await runInternal(resumedState, config); + // Store conversation history only if this is a final completion of the entire conversation // For HITL scenarios, storage happens on interruption (line 261) to allow resumption // We only store on completion if explicitly indicated this is the end of the conversation @@ -836,6 +839,9 @@ async function executeToolCalls( ): Promise { // Install runtime for tools that need access to current state/config (e.g., agent-as-tool) try { setToolRuntime(state.context, { state, config }); } catch { /* ignore */ } + + // Import elicitation context function + const { runWithElicitationContext } = await import('./elicit.js'); const results = await Promise.all( toolCalls.map(async (toolCall): Promise => { const tool = agent.tools?.find(t => t.schema.name === toolCall.function.name); @@ -986,13 +992,47 @@ async function executeToolCalls( console.log(`[JAF:ENGINE] About to execute tool: ${toolCall.function.name}`); console.log(`[JAF:ENGINE] Tool args:`, parseResult.data); console.log(`[JAF:ENGINE] Tool context:`, state.context); - + // Merge additional context if provided through approval - const contextWithAdditional = additionalContext + const contextWithAdditional = additionalContext ? { ...state.context, ...additionalContext } : state.context; - - const toolResult = await tool.execute(parseResult.data, contextWithAdditional); + + // Execute tool within elicitation context using AsyncLocalStorage + let toolResult; + try { + toolResult = await runWithElicitationContext({ state, config }, () => + tool.execute(parseResult.data, contextWithAdditional) + ); + } catch (error) { + // Check if this is an elicitation interruption + const { ElicitationInterruptionError } = await import('./elicit.js'); + if (error instanceof ElicitationInterruptionError) { + // Convert to interruption + return { + interruption: { + type: 'elicitation', + toolCall, + agent, + sessionId: state.runId, + request: error.request, + }, + message: { + role: 'tool', + content: JSON.stringify({ + status: 'elicitation_required', + message: 'User input required to continue', + tool_name: toolCall.function.name, + request: error.request, + }), + tool_call_id: toolCall.id, + }, + }; + } + + // Re-throw other errors + throw error; + } // Handle both string and ToolResult formats let resultString: string; @@ -1236,3 +1276,128 @@ async function storeConversationHistory( console.log(`[JAF:MEMORY] Stored ${messagesToStore.length} messages for conversation ${config.conversationId}`); } + +/** + * Check if we need to resume an interrupted elicitation and handle it + */ +async function checkAndResumeElicitation( + state: RunState, + config: RunConfig +): Promise> { + // Only process if we have new messages and elicitation provider + if (!config.elicitationProvider) { + return state; + } + + // Look for the last tool message with elicitation_required status + const lastToolMessage = [...state.messages].reverse().find(msg => { + if (msg.role !== 'tool') return false; + const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + return content.includes('"status":"elicitation_required"'); + }); + + if (!lastToolMessage) { + return state; + } + + // Check if this is a continuation with empty user message (elicitation response scenario) + const lastUserMessage = state.messages[state.messages.length - 1]; + if (lastUserMessage?.role !== 'user') { + return state; + } + + const userContent = typeof lastUserMessage.content === 'string' ? lastUserMessage.content : ''; + if (userContent.trim() !== '') { + return state; + } + + try { + // Parse the tool message to get the elicitation request + const toolContent = typeof lastToolMessage.content === 'string' ? lastToolMessage.content : JSON.stringify(lastToolMessage.content); + const toolResult = JSON.parse(toolContent); + const elicitationRequest = toolResult.request; + + if (!elicitationRequest) { + return state; + } + + console.log(`[JAF:ENGINE] Attempting to resume elicitation for request ${elicitationRequest.id}`); + + // Try to get the response from the elicitation provider + const elicitationResponse = await config.elicitationProvider.createElicitation(elicitationRequest); + + if (elicitationResponse.action === 'accept' && elicitationResponse.content) { + console.log(`[JAF:ENGINE] Resuming tool execution with elicitation response`); + + // Create a successful tool result + const successResult = `Successfully collected user information: ${Object.entries(elicitationResponse.content).map(([key, value]) => `- ${key[0].toUpperCase() + key.slice(1)}: ${value || 'Not provided'}`).join('\n')}`; + + // Replace the elicitation_required tool message with the success result + const updatedMessages = state.messages.map(msg => { + if (msg === lastToolMessage) { + return { + ...msg, + content: successResult + }; + } + return msg; + }); + + // Remove the empty user message that triggered this check + const finalMessages = updatedMessages.slice(0, -1); + + return { + ...state, + messages: finalMessages + }; + } else if (elicitationResponse.action === 'decline') { + console.log(`[JAF:ENGINE] Elicitation was declined`); + + // Replace with declined message + const declineResult = `User declined to provide information. Operation was cancelled.`; + + const updatedMessages = state.messages.map(msg => { + if (msg === lastToolMessage) { + return { + ...msg, + content: declineResult + }; + } + return msg; + }); + + const finalMessages = updatedMessages.slice(0, -1); + + return { + ...state, + messages: finalMessages + }; + } else if (elicitationResponse.action === 'cancel') { + console.log(`[JAF:ENGINE] Elicitation was cancelled`); + + // Replace with cancelled message + const cancelResult = `Operation was cancelled by user.`; + + const updatedMessages = state.messages.map(msg => { + if (msg === lastToolMessage) { + return { + ...msg, + content: cancelResult + }; + } + return msg; + }); + + const finalMessages = updatedMessages.slice(0, -1); + + return { + ...state, + messages: finalMessages + }; + } + } catch (error) { + console.log(`[JAF:ENGINE] Failed to resume elicitation: ${error instanceof Error ? error.message : error}`); + } + + return state; +} diff --git a/src/core/types.ts b/src/core/types.ts index bc9f9fa..74d9747 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -136,7 +136,7 @@ export type ToolApprovalInterruption = { readonly sessionId?: string; }; -export type Interruption = ToolApprovalInterruption; +export type Interruption = ToolApprovalInterruption | ElicitationInterruption; export type RunResult = { readonly finalState: RunState; @@ -149,6 +149,79 @@ export type RunResult = { }; }; +export type ElicitationRequestId = string & { readonly _brand: 'ElicitationRequestId' }; + +export const createElicitationRequestId = (id: string): ElicitationRequestId => id as ElicitationRequestId; + +export type ElicitationStringSchema = { + readonly type: 'string'; + readonly title?: string; + readonly description?: string; + readonly minLength?: number; + readonly maxLength?: number; + readonly pattern?: string; + readonly format?: 'email' | 'uri' | 'date' | 'date-time'; + readonly default?: string; +}; + +export type ElicitationChoiceSchema = { + readonly type: 'string'; + readonly title?: string; + readonly description?: string; + readonly enum: readonly string[]; + readonly enumNames?: readonly string[]; + readonly default?: string; +}; + +export type ElicitationNumberSchema = { + readonly type: 'number' | 'integer'; + readonly title?: string; + readonly description?: string; + readonly minimum?: number; + readonly maximum?: number; + readonly default?: number; +}; + +export type ElicitationBooleanSchema = { + readonly type: 'boolean'; + readonly title?: string; + readonly description?: string; + readonly default?: boolean; +}; + +export type ElicitationPropertySchema = + | ElicitationStringSchema + | ElicitationChoiceSchema + | ElicitationNumberSchema + | ElicitationBooleanSchema; + +export type ElicitationSchema = { + readonly type: 'object'; + readonly properties: Record; + readonly required?: readonly string[]; +}; + +export type ElicitationRequest = { + readonly id: ElicitationRequestId; + readonly message: string; + readonly requestedSchema: ElicitationSchema; + readonly metadata?: Record; +}; + +export type ElicitationResponse = { + readonly requestId: ElicitationRequestId; + readonly action: 'accept' | 'decline' | 'cancel'; + readonly content?: Record; +}; + +export type ElicitationInterruption = { + readonly type: 'elicitation'; + readonly toolCall: ToolCall; + readonly request: ElicitationRequest; + readonly agent: Agent; + readonly sessionId?: string; +}; + export type TraceEvent = | { type: 'run_start'; data: { runId: RunId; traceId: TraceId; context?: any; userId?: string; sessionId?: string; messages?: readonly Message[]; } } | { type: 'turn_start'; data: { turn: number; agentName: string } } @@ -169,6 +242,8 @@ export type TraceEvent = | { type: 'memory_operation'; data: { operation: 'load' | 'store'; conversationId: string; status: 'start' | 'end' | 'fail'; error?: string; messageCount?: number; } } | { type: 'output_parse'; data: { content: string; status: 'start' | 'end' | 'fail'; parsedOutput?: any; error?: string; } } | { type: 'decode_error'; data: { errors: z.ZodIssue[] } } + | { type: 'elicitation_request'; data: { request: ElicitationRequest; agentName: string; traceId: TraceId; runId: RunId; } } + | { type: 'elicitation_response'; data: { response: ElicitationResponse; agentName: string; traceId: TraceId; runId: RunId; } } | { type: 'turn_end'; data: { turn: number; agentName: string } } | { type: 'run_end'; data: { outcome: RunResult['outcome']; traceId: TraceId; runId: RunId; } }; @@ -207,6 +282,10 @@ export interface ModelProvider { ) => AsyncGenerator; } +export interface ElicitationProvider { + createElicitation(request: ElicitationRequest): Promise; +} + export type RunConfig = { readonly agentRegistry: ReadonlyMap>; readonly modelProvider: ModelProvider; @@ -218,4 +297,5 @@ export type RunConfig = { readonly memory?: MemoryConfig; readonly conversationId?: string; readonly approvalStorage?: ApprovalStorage; + readonly elicitationProvider?: ElicitationProvider; }; diff --git a/src/index.ts b/src/index.ts index 5c5d5b7..c9908ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,9 @@ export * from './core/tracing'; export * from './core/errors'; export * from './core/tool-results'; export * from './core/agent-as-tool'; +export * from './core/elicitation'; +export * from './core/elicit'; +export * from './core/elicitation-provider'; export * from './providers/model'; // export * from './providers/mcp'; // Commented out for test compatibility diff --git a/src/server/index.ts b/src/server/index.ts index 2689e76..7bce6dd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,7 @@ import { createJAFServer } from './server'; import { ServerConfig } from './types'; import { Agent, RunConfig } from '../core/types'; +import { createServerElicitationProvider } from '../core/elicitation-provider'; /** * Start a development server for testing agents locally (functional approach) @@ -58,6 +59,9 @@ export async function runServer( ...runConfig }; + // Create elicitation provider if not provided + const elicitationProvider = options.elicitationProvider || createServerElicitationProvider(); + // Create server config const serverConfig: ServerConfig = { port: 3000, @@ -65,7 +69,8 @@ export async function runServer( cors: false, ...options, runConfig: completeRunConfig, - agentRegistry + agentRegistry, + elicitationProvider }; // Create and start functional server diff --git a/src/server/server.ts b/src/server/server.ts index d09224f..5d8a23c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,17 +1,18 @@ import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import cors from '@fastify/cors'; -import { - ServerConfig, - ChatRequest, - ChatResponse, +import { + ServerConfig, + ChatRequest, + ChatResponse, AgentListResponse, HealthResponse, HttpMessage, chatRequestSchema, - ApprovalMessage + ApprovalMessage, + ElicitationResponseMessage } from './types.js'; import { run, runStream } from '../core/engine.js'; -import { RunState, Message, createRunId, createTraceId } from '../core/types.js'; +import { RunState, Message, createRunId, createTraceId, createElicitationRequestId } from '../core/types.js'; import { v4 as uuidv4 } from 'uuid'; // Helper: stable stringify to create deterministic signatures @@ -280,6 +281,7 @@ export function createJAFServer(config: ServerConfig): { const initialStateMessages = jafMessages; const approvalsList: ApprovalMessage[] = validatedRequest.approvals ?? []; + const elicitationResponsesList: ElicitationResponseMessage[] = validatedRequest.elicitationResponses ?? []; const persistApproval = async (convId: string, appr: ApprovalMessage): Promise => { if (!config.defaultMemoryProvider) return; @@ -443,6 +445,17 @@ export function createJAFServer(config: ServerConfig): { approvals: initialApprovals, }; + // Handle elicitation responses + if (config.elicitationProvider && elicitationResponsesList.length > 0) { + for (const elicitationResponse of elicitationResponsesList) { + config.elicitationProvider.respondToElicitation({ + requestId: createElicitationRequestId(elicitationResponse.requestId), + action: elicitationResponse.action, + content: elicitationResponse.content, + }); + } + } + // Create run config with memory configuration const runConfig = { ...config.runConfig, @@ -454,7 +467,8 @@ export function createJAFServer(config: ServerConfig): { maxMessages: validatedRequest.memory?.maxMessages ?? config.runConfig.memory?.maxMessages, compressionThreshold: validatedRequest.memory?.compressionThreshold ?? config.runConfig.memory?.compressionThreshold, storeOnCompletion: validatedRequest.memory?.storeOnCompletion ?? config.runConfig.memory?.storeOnCompletion - } : undefined + } : undefined, + elicitationProvider: config.elicitationProvider }; // Handle streaming vs non-streaming @@ -585,12 +599,23 @@ export function createJAFServer(config: ServerConfig): { status: result.outcome.status, output: result.outcome.status === 'completed' ? String(result.outcome.output) : undefined, error: result.outcome.status === 'error' ? result.outcome.error : undefined, - interruptions: result.outcome.status === 'interrupted' - ? result.outcome.interruptions.map(interruption => ({ - type: interruption.type, - toolCall: interruption.toolCall, - sessionId: interruption.sessionId || result.finalState.runId - })) + interruptions: result.outcome.status === 'interrupted' + ? result.outcome.interruptions.map(interruption => { + if (interruption.type === 'tool_approval') { + return { + type: interruption.type, + toolCall: interruption.toolCall, + sessionId: interruption.sessionId || result.finalState.runId + }; + } else if (interruption.type === 'elicitation') { + return { + type: interruption.type, + request: interruption.request, + sessionId: interruption.sessionId || result.finalState.runId + }; + } + return interruption; + }) : undefined }, turnCount: result.finalState.turnCount, @@ -826,6 +851,75 @@ export function createJAFServer(config: ServerConfig): { return reply.code(200).send({ success: true, data: { pending } }); }); + + // Elicitation endpoints + app.get('/elicitation/pending', async ( + request: FastifyRequest, + reply: FastifyReply + ) => { + if (!config.elicitationProvider) { + return reply.code(503).send({ + success: false, + error: 'Elicitation provider not configured' + }); + } + + const pendingRequests = config.elicitationProvider.getPendingRequests(); + return reply.code(200).send({ + success: true, + data: { pending: pendingRequests } + }); + }); + + app.post('/elicitation/respond', { + schema: { + body: { + type: 'object', + properties: { + requestId: { type: 'string' }, + action: { type: 'string', enum: ['accept', 'decline', 'cancel'] }, + content: { type: 'object' } + }, + required: ['requestId', 'action'] + } + } + }, async ( + request: FastifyRequest<{ + Body: { + requestId: string; + action: 'accept' | 'decline' | 'cancel'; + content?: Record; + } + }>, + reply: FastifyReply + ) => { + if (!config.elicitationProvider) { + return reply.code(503).send({ + success: false, + error: 'Elicitation provider not configured' + }); + } + + const { requestId, action, content } = request.body; + + const success = config.elicitationProvider.respondToElicitation({ + requestId: createElicitationRequestId(requestId), + action, + content + }); + + if (!success) { + return reply.code(404).send({ + success: false, + error: 'Elicitation request not found or already resolved' + }); + } + + return reply.code(200).send({ + success: true, + data: { requestId, action } + }); + }); }; const start = async (): Promise => { diff --git a/src/server/types.ts b/src/server/types.ts index 6a7c7e4..c0311be 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { Agent, RunConfig } from '../core/types'; import { MemoryProvider } from '../memory/types'; +import { ServerElicitationProvider } from '../core/elicitation-provider'; export interface ServerConfig { port?: number; @@ -10,6 +11,7 @@ export interface ServerConfig { runConfig: RunConfig; agentRegistry: Map>; defaultMemoryProvider?: MemoryProvider; + elicitationProvider?: ServerElicitationProvider; } // Request/Response schemas @@ -37,6 +39,14 @@ export const approvalMessageSchema = z.object({ additionalContext: z.record(z.any()).optional() }); +// Elicitation response schema for MCP elicitation +export const elicitationResponseSchema = z.object({ + type: z.literal('elicitation_response'), + requestId: z.string(), + action: z.enum(['accept', 'decline', 'cancel']), + content: z.record(z.any()).optional() +}); + export const chatRequestSchema = z.object({ messages: z.array(httpMessageSchema), agentName: z.string(), @@ -50,12 +60,14 @@ export const chatRequestSchema = z.object({ compressionThreshold: z.number().optional(), storeOnCompletion: z.boolean().optional() }).optional(), - approvals: z.array(approvalMessageSchema).optional() + approvals: z.array(approvalMessageSchema).optional(), + elicitationResponses: z.array(elicitationResponseSchema).optional() }); export type ChatRequest = z.infer; export type HttpMessage = z.infer; export type ApprovalMessage = z.infer; +export type ElicitationResponseMessage = z.infer; // Extended message schema that includes tool calls and responses export const fullMessageSchema = z.union([ @@ -90,18 +102,34 @@ export const chatResponseSchema = z.object({ status: z.enum(['completed', 'error', 'max_turns', 'interrupted']), output: z.string().optional(), error: z.any().optional(), - interruptions: z.array(z.object({ - type: z.literal('tool_approval'), - toolCall: z.object({ - id: z.string(), - type: z.literal('function'), - function: z.object({ - name: z.string(), - arguments: z.string() - }) + interruptions: z.array(z.union([ + z.object({ + type: z.literal('tool_approval'), + toolCall: z.object({ + id: z.string(), + type: z.literal('function'), + function: z.object({ + name: z.string(), + arguments: z.string() + }) + }), + sessionId: z.string() }), - sessionId: z.string() - })).optional() + z.object({ + type: z.literal('elicitation'), + request: z.object({ + id: z.string(), + message: z.string(), + requestedSchema: z.object({ + type: z.literal('object'), + properties: z.record(z.any()), + required: z.array(z.string()).readonly().optional() + }), + metadata: z.record(z.any()).optional() + }), + sessionId: z.string().optional() + }) + ])).optional() }), turnCount: z.number(), executionTimeMs: z.number()