From c3e0f3c9661008b6ea6f098b1a6107bb0c8b52e2 Mon Sep 17 00:00:00 2001 From: MikePfunk28 Date: Sat, 14 Feb 2026 19:48:51 -0500 Subject: [PATCH 1/4] Refactor deployment tiers and rate limiting logic - Updated deployment tier terminology from "Tier 1", "Tier 2", and "Tier 3" to "Freemium", "Personal", and "Enterprise" for clarity. - Implemented rate limiting checks in multiple actions to prevent burst abuse per user. - Adjusted comments and documentation to reflect new tier names and functionalities. - Enhanced integration tests to align with the new tier structure and ensure proper functionality. - Modified architecture preview components to display updated tier information. --- convex/agentBuilderWorkflow.ts | 31 +++++++ convex/automatedAgentBuilder.ts | 11 +++ convex/awsDeployment.ts | 24 +++--- convex/awsDiagramGenerator.ts | 20 ++--- convex/crons.ts | 18 ++-- convex/deploymentRouter.ts | 47 ++++------- convex/guardrails.ts | 6 +- convex/http.ts | 2 +- convex/integration.test.ts | 91 +++++++++++++++------ convex/interleavedReasoning.ts | 11 +++ convex/mcpClient.ts | 29 ++++++- convex/promptChainExecutor.ts | 29 +++++++ convex/strandsAgentExecution.ts | 9 +- convex/stripeMutations.ts | 42 ++++++++++ convex/unifiedAgentExecution.ts | 14 ++++ convex/workflowExecutor.ts | 9 ++ src/components/ArchitecturePreview.test.tsx | 78 +++++++++--------- src/components/ArchitecturePreview.tsx | 56 ++++++------- 18 files changed, 368 insertions(+), 159 deletions(-) diff --git a/convex/agentBuilderWorkflow.ts b/convex/agentBuilderWorkflow.ts index cc2a93d..3ebe8eb 100644 --- a/convex/agentBuilderWorkflow.ts +++ b/convex/agentBuilderWorkflow.ts @@ -278,6 +278,15 @@ export const executeWorkflowStage = action( { throw new Error( gateResult.reason ); } + // Rate limit: prevent burst abuse per user + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + throw new Error( rlResult.reason ?? "Rate limit exceeded. Please try again later." ); + } + const stage = WORKFLOW_STAGES[args.stage as keyof typeof WORKFLOW_STAGES]; if ( !stage ) { throw new Error( `Invalid workflow stage: ${args.stage}` ); @@ -464,6 +473,17 @@ export const executeCompleteWorkflow = action( { throw new Error( gateResult.reason ); } + // Rate limit: prevent burst abuse per user + { + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + throw new Error( rlResult.reason ?? "Rate limit exceeded. Please try again later." ); + } + } + const workflowResults: Array<{ stage: string; output: string; @@ -539,6 +559,17 @@ export const streamWorkflowExecution = action( { throw new Error( gateResult.reason ); } + // Rate limit: prevent burst abuse per user + { + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + throw new Error( rlResult.reason ?? "Rate limit exceeded. Please try again later." ); + } + } + const { resolveBedrockModelId } = await import( "./modelRegistry.js" ); const resolvedModelId = resolveBedrockModelId( effectiveModelId ); diff --git a/convex/automatedAgentBuilder.ts b/convex/automatedAgentBuilder.ts index edb6b11..3fc9bd6 100644 --- a/convex/automatedAgentBuilder.ts +++ b/convex/automatedAgentBuilder.ts @@ -150,6 +150,17 @@ export const processResponse = action( { throw new Error( gateResult.reason ); } + // Rate limit: prevent burst abuse per user + { + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + throw new Error( rlResult.reason ?? "Rate limit exceeded. Please try again later." ); + } + } + // Use Claude Haiku 4.5 with interleaved thinking to analyze and ask next question const systemPrompt = buildSystemPrompt( session.agentRequirements ); const response = await analyzeAndAskNext( systemPrompt, updatedHistory ); diff --git a/convex/awsDeployment.ts b/convex/awsDeployment.ts index 2ff971a..9c960a1 100644 --- a/convex/awsDeployment.ts +++ b/convex/awsDeployment.ts @@ -84,12 +84,12 @@ export const deployToAWS = action( { // Check if user has AWS credentials configured (saved) const hasAWSCreds = userId ? await ctx.runQuery( api.awsAuth.hasValidAWSCredentials ) : false; - // If user has saved AWS credentials, deploy to THEIR account (Tier 2) + // If user has saved AWS credentials, deploy to THEIR account (personal tier) if ( hasAWSCreds && userId ) { - return await deployTier2( ctx, args, userId ); + return await deployPersonal( ctx, args, userId ); } - // Otherwise, use platform deployment (Tier 1) + // Otherwise, use platform deployment (freemium tier) if ( tier === "freemium" ) { // Anonymous users must provide AWS credentials if ( !userId ) { @@ -105,17 +105,17 @@ export const deployToAWS = action( { } // Deploy to platform Fargate - return await deployTier1( ctx, args, userId ); + return await deployFreemium( ctx, args, userId ); } else if ( tier === "enterprise" ) { - // Tier 3: Enterprise SSO (not implemented yet) + // Enterprise: SSO deployment (not implemented yet) throw new Error( "Enterprise tier not yet implemented" ); } - // Fallback to Tier 1 - requires authentication + // Fallback to freemium - requires authentication if ( !userId ) { throw new Error( "Authentication required for deployment." ); } - return await deployTier1( ctx, args, userId ); + return await deployFreemium( ctx, args, userId ); }, } ); @@ -948,9 +948,9 @@ export const executeDeploymentInternal = internalAction( { // ============================================================================ /** - * Tier 1: Deploy to YOUR Fargate (Freemium) + * Freemium: Deploy to platform Fargate */ -async function deployTier1( ctx: any, args: any, userId: Id<"users"> ): Promise { +async function deployFreemium( ctx: any, args: any, userId: Id<"users"> ): Promise { // Create deployment record const deploymentId: any = await ctx.runMutation( internal.awsDeployment.createDeploymentInternal, { agentId: args.agentId, @@ -963,7 +963,7 @@ async function deployTier1( ctx: any, args: any, userId: Id<"users"> ): Promise< try { await ctx.runMutation( internalStripeMutations.incrementUsageAndReportOverage, { userId } ); } catch ( billingErr ) { - console.error( "awsDeployment.deployTier1: billing failed (non-fatal)", { + console.error( "awsDeployment.deployFreemium: billing failed (non-fatal)", { userId, error: billingErr instanceof Error ? billingErr.message : billingErr, } ); @@ -985,9 +985,9 @@ async function deployTier1( ctx: any, args: any, userId: Id<"users"> ): Promise< } /** - * Tier 2: Deploy to USER's Fargate (Personal AWS Account) using Web Identity Federation + * Personal: Deploy to USER's Fargate (Personal AWS Account) using Web Identity Federation */ -async function deployTier2( ctx: any, args: any, userId: string ): Promise { +async function deployPersonal( ctx: any, args: any, userId: string ): Promise { // Get user's stored Role ARN const user = await ctx.runQuery( internal.awsDeployment.getUserTierInternal, { userId } ); diff --git a/convex/awsDiagramGenerator.ts b/convex/awsDiagramGenerator.ts index 8e4509a..77cf8ce 100644 --- a/convex/awsDiagramGenerator.ts +++ b/convex/awsDiagramGenerator.ts @@ -138,8 +138,8 @@ function buildResourceList(deployment: any): AWSResource[] { // Add region as context const region = deployment.region || "us-east-1"; - if (deployment.tier === "freemium" || deployment.tier === "tier1") { - // Tier 1: AgentCore (Bedrock) + if (deployment.tier === "freemium") { + // Freemium: AgentCore (Bedrock) resources.push({ type: "bedrock-agentcore", name: deployment.agentName || "Agent", @@ -161,8 +161,8 @@ function buildResourceList(deployment: any): AWSResource[] { }, }); } - } else if (deployment.tier === "personal" || deployment.tier === "tier2") { - // Tier 2: Personal AWS (Fargate) + } else if (deployment.tier === "personal") { + // Personal: AWS (Fargate) // VPC resources.push({ @@ -282,12 +282,12 @@ function buildResourceList(deployment: any): AWSResource[] { }, }); } - } else if (deployment.tier === "enterprise" || deployment.tier === "tier3") { - // Tier 3: Enterprise (includes everything from Tier 2 plus SSO) - - // Include all Tier 2 resources - const tier2Deployment = { ...deployment, tier: "tier2" }; - resources.push(...buildResourceList(tier2Deployment)); + } else if (deployment.tier === "enterprise") { + // Enterprise: includes everything from Personal tier plus SSO + + // Include all Personal tier resources + const personalDeployment = { ...deployment, tier: "personal" }; + resources.push(...buildResourceList(personalDeployment)); // Add SSO/Identity Center resources.push({ diff --git a/convex/crons.ts b/convex/crons.ts index 04e11d0..09f7c4b 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -1,11 +1,10 @@ /** * Cron Jobs Configuration * - * Scheduled tasks for queue processing and maintenance - * - * NOTE: To save costs, the queue processor is now triggered on-demand - * when tests are submitted, rather than polling every few seconds. - * Only the cleanup job runs on a schedule. + * Scheduled tasks for billing resets and maintenance. + * + * NOTE: Queue processing is triggered on-demand when tests are submitted, + * NOT via polling. Only billing resets and cleanup run on a schedule. */ import { cronJobs } from "convex/server"; @@ -13,7 +12,12 @@ import { internal } from "./_generated/api"; const crons = cronJobs(); -// ALL CRON JOBS DISABLED -// Cleanup should be triggered manually or on-demand, not on a schedule +// Reset freemium-tier users' monthly usage on the 1st of each month at 00:00 UTC. +// Paid users are reset via Stripe invoice.paid webhook instead. +crons.monthly( + "reset freemium monthly usage", + { day: 1, hourUTC: 0, minuteUTC: 0 }, + internal.stripeMutations.resetFreemiumMonthlyUsage, +); export default crons; diff --git a/convex/deploymentRouter.ts b/convex/deploymentRouter.ts index 9f52f57..0c472ab 100644 --- a/convex/deploymentRouter.ts +++ b/convex/deploymentRouter.ts @@ -1,7 +1,7 @@ // Deployment Router - Routes deployments to correct tier -// Tier 1: Freemium (YOUR AWS) -// Tier 2: Personal (USER's AWS) -// Tier 3: Enterprise (ENTERPRISE AWS via SSO) +// Freemium: AgentCore sandbox (OUR AWS) +// Personal: Fargate (USER's AWS) +// Enterprise: SSO deployment (ENTERPRISE AWS via SSO) import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; @@ -34,13 +34,13 @@ export const deployAgent = action({ // Route based on tier switch (user.tier) { case "freemium": - return await deployTier1(ctx, args, userId); + return await deployFreemium(ctx, args, userId); case "personal": - return await deployTier2(ctx, args, userId); + return await deployPersonal(ctx, args, userId); case "enterprise": - return await deployTier3(ctx, args, userId); + return await deployEnterprise(ctx, args, userId); default: throw new Error(`Unknown tier: ${user.tier}`); @@ -62,8 +62,8 @@ export const getUserTier = query({ }, }); -// Tier 1: Deploy to AgentCore (Freemium) -async function deployTier1(ctx: any, args: any, userId: Id<"users">): Promise { +// Freemium: Deploy to AgentCore sandbox +async function deployFreemium(ctx: any, args: any, userId: Id<"users">): Promise { // Check usage limits const user = await ctx.runQuery(api.deploymentRouter.getUserTier); @@ -135,7 +135,7 @@ async function deployTier1(ctx: any, args: any, userId: Id<"users">): Promise): Promise): Promise { +async function deployPersonal(ctx: any, args: any, _userId: Id<"users">): Promise { try { const result: any = await ctx.runAction( api.awsCrossAccount.deployToUserAccount, @@ -188,8 +188,8 @@ async function deployTier2(ctx: any, args: any, _userId: Id<"users">): Promise { +// Enterprise: Deploy to ENTERPRISE AWS (via SSO) +async function deployEnterprise(_ctx: any, _args: any, _userId: string): Promise { try { // TODO: Implement enterprise SSO deployment // This would use AWS SSO credentials instead of AssumeRole @@ -228,24 +228,9 @@ export const incrementUsage = mutation({ }, }); -// Reset monthly usage (call this from a cron job) -export const resetMonthlyUsage = mutation({ - args: {}, - handler: async (ctx) => { - const users = await ctx.db - .query("users") - .withIndex("by_tier", (q) => q.eq("tier", "freemium")) - .collect(); - - for (const user of users) { - await ctx.db.patch(user._id, { - executionsThisMonth: 0, - }); - } - - return { reset: users.length }; - }, -}); +// Monthly usage reset for freemium users is handled by: +// - Cron: crons.ts → internal.stripeMutations.resetFreemiumMonthlyUsage (1st of month) +// - Paid users: Stripe invoice.paid webhook → internal.stripeMutations.resetMonthlyUsage // Get deployment history export const getDeploymentHistory = query({ diff --git a/convex/guardrails.ts b/convex/guardrails.ts index 02b1fa2..cd571c6 100644 --- a/convex/guardrails.ts +++ b/convex/guardrails.ts @@ -132,7 +132,11 @@ export function validateMessage( } /** - * Check rate limits for user + * Check rate limits for user (messages-per-hour, content guardrail). + * + * NOTE: For per-minute burst protection on Bedrock actions, use + * rateLimiter.ts:checkRateLimit() instead. This function handles + * the content-safety messages-per-hour limit within evaluateGuardrails(). */ export function checkRateLimits( userId: string, diff --git a/convex/http.ts b/convex/http.ts index 5133034..e2fdbc8 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -54,7 +54,7 @@ http.route({ // Return in MCP protocol format return new Response( JSON.stringify({ - tools: agents.map((agent: any) => ({ + tools: agents.map((agent: { name: string; description?: string; inputSchema?: unknown }) => ({ name: agent.name, description: agent.description, inputSchema: agent.inputSchema diff --git a/convex/integration.test.ts b/convex/integration.test.ts index 6593c60..59fd66d 100644 --- a/convex/integration.test.ts +++ b/convex/integration.test.ts @@ -1356,8 +1356,8 @@ describe("Deployment Integration Tests", () => { const testUserId = await t.run(async (ctx) => { return await ctx.db.insert("users", { - email: "tier1-all@example.com", - name: "Tier 1 All Tests User", + email: "freemium-all@example.com", + name: "Freemium All Tests User", tier: "freemium", executionsThisMonth: 0, createdAt: Date.now(), @@ -1368,8 +1368,8 @@ describe("Deployment Integration Tests", () => { const testAgentId = await t.run(async (ctx) => { return await ctx.db.insert("agents", { createdBy: testUserId, - name: "Tier 1 Test Agent", - description: "Test agent for Tier 1 deployment", + name: "Freemium Test Agent", + description: "Test agent for freemium deployment", systemPrompt: "You are a test agent", model: "anthropic.claude-3-sonnet-20240229-v1:0", tools: [], @@ -1378,7 +1378,7 @@ describe("Deployment Integration Tests", () => { }); }); - t.withIdentity({ subject: "test-user-tier1-all" }); + t.withIdentity({ subject: "test-user-freemium-all" }); // Test 1: Deploy agent const result = await t.action(api.deploymentRouter.deployAgent, { @@ -1404,8 +1404,8 @@ describe("Deployment Integration Tests", () => { const testUserId = await t.run(async (ctx) => { return await ctx.db.insert("users", { - email: "tier1-usage-limits@example.com", - name: "Tier 1 Usage Limits User", + email: "freemium-usage-limits@example.com", + name: "Freemium Usage Limits User", tier: "freemium", executionsThisMonth: 0, createdAt: Date.now(), @@ -1425,7 +1425,7 @@ describe("Deployment Integration Tests", () => { }); }); - t.withIdentity({ subject: "test-user-tier1-usage-limits" }); + t.withIdentity({ subject: "test-user-freemium-usage-limits" }); // Test usage increment const userBefore = await t.query(api.deploymentRouter.getUserTier); @@ -1454,16 +1454,16 @@ describe("Deployment Integration Tests", () => { }); }); - describe("Tier 2 (Personal AWS) Deployment Workflow", () => { - test("should handle Tier 2 deployment workflow", async () => { + describe("Personal (AWS Fargate) Deployment Workflow", () => { + test("should handle personal tier deployment workflow", async () => { const t = convexTest(schema, modules); // Create personal tier user const testUserId = await t.run(async (ctx) => { return await ctx.db.insert("users", { - email: "tier2-all@example.com", - name: "Tier 2 All Tests User", + email: "personal-all@example.com", + name: "Personal All Tests User", tier: "personal", createdAt: Date.now(), }); @@ -1473,12 +1473,12 @@ describe("Deployment Integration Tests", () => { const testAgentId = await t.run(async (ctx) => { return await ctx.db.insert("agents", { createdBy: testUserId, - name: "Tier 2 Test Agent", - description: "Test agent for Tier 2 deployment", + name: "Personal Test Agent", + description: "Test agent for personal tier deployment", systemPrompt: "You are a test agent", model: "gpt-4", tools: [], - generatedCode: "# Tier 2 test agent code", + generatedCode: "# Personal tier test agent code", deploymentType: "aws", }); }); @@ -1497,7 +1497,7 @@ describe("Deployment Integration Tests", () => { }); }); - t.withIdentity({ subject: "test-user-tier2-all" }); + t.withIdentity({ subject: "test-user-personal-all" }); // Test deployment const result = await t.action(api.deploymentRouter.deployAgent, { @@ -1972,17 +1972,41 @@ describe("Deployment Integration Tests", () => { }); }); - // Reset monthly usage - const result = await t.mutation(api.deploymentRouter.resetMonthlyUsage); + // Reset monthly usage via direct DB (mirrors cron's resetFreemiumMonthlyUsage logic) + const resetCount = await t.run(async (ctx) => { + const freemiumUsers = await ctx.db + .query("users") + .filter((q) => + q.or( + q.eq(q.field("tier"), "freemium"), + q.eq(q.field("tier"), undefined) + ) + ) + .collect(); - expect(result).toBeDefined(); - expect(result.reset).toBeGreaterThanOrEqual(2); + let count = 0; + for (const user of freemiumUsers) { + if ((user.executionsThisMonth ?? 0) > 0) { + await ctx.db.patch(user._id, { + executionsThisMonth: 0, + rawCallsThisMonth: 0, + tokensInputThisMonth: 0, + tokensOutputThisMonth: 0, + billingPeriodStart: Date.now(), + }); + count++; + } + } + return count; + }); + + expect(resetCount).toBeGreaterThanOrEqual(2); // Verify usage was reset const users = await t.run(async (ctx) => { return await ctx.db .query("users") - .withIndex("by_tier", (q) => q.eq("tier", "freemium")) + .filter((q) => q.eq(q.field("tier"), "freemium")) .collect(); }); @@ -2006,8 +2030,29 @@ describe("Deployment Integration Tests", () => { }); }); - // Reset monthly usage (only affects freemium) - await t.mutation(api.deploymentRouter.resetMonthlyUsage); + // Reset monthly usage (only affects freemium — mirrors cron logic) + await t.run(async (ctx) => { + const freemiumUsers = await ctx.db + .query("users") + .filter((q) => + q.or( + q.eq(q.field("tier"), "freemium"), + q.eq(q.field("tier"), undefined) + ) + ) + .collect(); + for (const user of freemiumUsers) { + if ((user.executionsThisMonth ?? 0) > 0) { + await ctx.db.patch(user._id, { + executionsThisMonth: 0, + rawCallsThisMonth: 0, + tokensInputThisMonth: 0, + tokensOutputThisMonth: 0, + billingPeriodStart: Date.now(), + }); + } + } + }); // Verify personal user usage was not reset const user = await t.run(async (ctx) => { diff --git a/convex/interleavedReasoning.ts b/convex/interleavedReasoning.ts index c7fb785..e57bdfa 100644 --- a/convex/interleavedReasoning.ts +++ b/convex/interleavedReasoning.ts @@ -138,6 +138,17 @@ export const sendMessage: any = action( { throw new Error( gateResult.reason ); } + // Rate limit: prevent burst abuse per user + { + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + throw new Error( rlResult.reason ?? "Rate limit exceeded. Please try again later." ); + } + } + // Fall back to direct Claude Haiku 4.5 with interleaved thinking response = await invokeClaudeWithInterleavedThinking( conversation.systemPrompt, diff --git a/convex/mcpClient.ts b/convex/mcpClient.ts index c40b9b9..3281de4 100644 --- a/convex/mcpClient.ts +++ b/convex/mcpClient.ts @@ -255,6 +255,26 @@ export const invokeMCPTool = action( { // Use server-specific timeout or provided timeout const timeout = args.timeout || server.timeout || 30000; + // Gate: If this server routes to Bedrock (AgentCore), enforce tier access + const isBedrockServer = server.name === "bedrock-agentcore-mcp-server" || args.toolName === "execute_agent"; + if ( isBedrockServer && billingUserId ) { + const { requireBedrockAccessForUser } = await import( "./lib/bedrockGate" ); + const userDoc = await ctx.runQuery( internal.users.getInternal, { id: billingUserId } ); + const gateResult = await requireBedrockAccessForUser( userDoc, undefined ); + if ( !gateResult.allowed ) { + return { + success: false, + error: gateResult.reason, + }; + } + } else if ( isBedrockServer && !billingUserId ) { + // Unauthenticated caller trying to invoke Bedrock — block + return { + success: false, + error: "Authentication required to use cloud AI models. Please sign in.", + }; + } + // Invoke tool with retry logic const result = await invokeMCPToolWithRetry( server, @@ -643,14 +663,17 @@ async function invokeBedrockDirect( parameters: any, timeout: number ): Promise< content: [{ text: input }], } ); - // Prepare request payload - const payload = { - anthropic_version: "bedrock-2023-05-31", + // Prepare request payload — anthropic_version header is only valid for Claude models + const isClaudeModel = modelId.toLowerCase().startsWith( "anthropic." ); + const payload: Record = { max_tokens: 4096, system: systemPrompt, messages: messages, temperature: 0.7, }; + if ( isClaudeModel ) { + payload.anthropic_version = "bedrock-2023-05-31"; + } const command = new InvokeModelCommand( { modelId: modelId, diff --git a/convex/promptChainExecutor.ts b/convex/promptChainExecutor.ts index ffcea20..1ad6648 100644 --- a/convex/promptChainExecutor.ts +++ b/convex/promptChainExecutor.ts @@ -63,6 +63,21 @@ export const executePromptChain = action({ }; } gateUserId = gateResult.userId; + + // Rate limit: prevent burst abuse per user + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + return { + success: false, + finalOutput: null, + intermediateResults: [], + totalLatency: 0, + error: rlResult.reason ?? "Rate limit exceeded. Please try again later.", + }; + } } const startTime = Date.now(); @@ -204,6 +219,20 @@ export const executeParallelPrompts = action({ }; } gateUserId = gateResult.userId; + + // Rate limit: prevent burst abuse per user + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + return { + success: false, + results: [], + totalLatency: 0, + error: rlResult.reason ?? "Rate limit exceeded. Please try again later.", + }; + } } const startTime = Date.now(); diff --git a/convex/strandsAgentExecution.ts b/convex/strandsAgentExecution.ts index 57c7fd2..a095741 100644 --- a/convex/strandsAgentExecution.ts +++ b/convex/strandsAgentExecution.ts @@ -179,12 +179,13 @@ async function executeViaAgentCore( ): Promise { // Load agent's skills from dynamicTools table (if configured) const agentSkills: import( "./lib/toolDispatch" ).SkillDefinition[] = []; - const agentDoc = agent as any; // Access optional fields added to schema - if ( agentDoc.skills && Array.isArray( agentDoc.skills ) ) { + // Type-safe access to optional skills array on agent document + const skills = ( agent as { skills?: Array<{ skillId?: string; enabled?: boolean }> } ).skills; + if ( skills && Array.isArray( skills ) ) { // Load full skill definitions for enabled skills - const enabledSkills = agentDoc.skills.filter( - ( s: any ) => s.enabled !== false, + const enabledSkills = skills.filter( + ( s ) => s.enabled !== false, ); for ( const skillRef of enabledSkills ) { if ( skillRef.skillId ) { diff --git a/convex/stripeMutations.ts b/convex/stripeMutations.ts index 28cd672..e844566 100644 --- a/convex/stripeMutations.ts +++ b/convex/stripeMutations.ts @@ -242,6 +242,48 @@ export const resetMonthlyUsage = internalMutation( { }, } ); +/** + * Reset monthly usage for all freemium-tier users. + * Called by cron on the 1st of each month. + * Paid users are reset via invoice.paid webhook instead. + */ +export const resetFreemiumMonthlyUsage = internalMutation( { + args: {}, + handler: async ( ctx ) => { + const freemiumUsers = await ctx.db + .query( "users" ) + .filter( ( q ) => + q.or( + q.eq( q.field( "tier" ), "freemium" ), + q.eq( q.field( "tier" ), undefined ) + ) + ) + .collect(); + + let resetCount = 0; + for ( const user of freemiumUsers ) { + // Only reset if they have usage + if ( + ( user.executionsThisMonth ?? 0 ) > 0 || + ( user.rawCallsThisMonth ?? 0 ) > 0 || + ( user.tokensInputThisMonth ?? 0 ) > 0 || + ( user.tokensOutputThisMonth ?? 0 ) > 0 + ) { + await ctx.db.patch( user._id, { + executionsThisMonth: 0, + rawCallsThisMonth: 0, + tokensInputThisMonth: 0, + tokensOutputThisMonth: 0, + billingPeriodStart: Date.now(), + } ); + resetCount++; + } + } + + console.log( `resetFreemiumMonthlyUsage: reset ${resetCount} freemium users` ); + }, +} ); + /** * Mark subscription as past_due when payment fails. */ diff --git a/convex/unifiedAgentExecution.ts b/convex/unifiedAgentExecution.ts index 897a605..0c8a0b2 100644 --- a/convex/unifiedAgentExecution.ts +++ b/convex/unifiedAgentExecution.ts @@ -115,6 +115,20 @@ export const executeUnifiedAgent = action({ error: gateResult.reason, }; } + + // Rate limit: prevent burst abuse per user + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gateResult.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gateResult.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + return { + success: false, + modality: "text", + content: "", + error: rlResult.reason ?? "Rate limit exceeded. Please try again later.", + }; + } } // Make modality and model switching decision diff --git a/convex/workflowExecutor.ts b/convex/workflowExecutor.ts index f544655..2ba9bc1 100644 --- a/convex/workflowExecutor.ts +++ b/convex/workflowExecutor.ts @@ -175,6 +175,15 @@ async function executePromptModelWorkflow( throw new Error( gate.reason ); } gateResult = gate; + + // Rate limit: prevent burst abuse per user + const { checkRateLimit, buildTierRateLimitConfig } = await import( "./rateLimiter" ); + const { getTierConfig } = await import( "./lib/tierConfig" ); + const rlCfg = buildTierRateLimitConfig( getTierConfig( gate.tier ).maxConcurrentTests, "agentExecution" ); + const rlResult = await checkRateLimit( ctx, String( gate.userId ), "agentExecution", rlCfg ); + if ( !rlResult.allowed ) { + throw new Error( rlResult.reason ?? "Rate limit exceeded. Please try again later." ); + } } // Execute composed messages with actual API calls diff --git a/src/components/ArchitecturePreview.test.tsx b/src/components/ArchitecturePreview.test.tsx index c58eda1..a88764e 100644 --- a/src/components/ArchitecturePreview.test.tsx +++ b/src/components/ArchitecturePreview.test.tsx @@ -9,13 +9,13 @@ import { describe, it, expect } from "vitest"; // Extract the tier determination logic for testing function determineDeploymentTier(deploymentType: string, model: string): string { - // Tier 2: Personal AWS (Fargate) - Docker, Ollama, or custom models + // Personal: AWS (Fargate) - Docker, Ollama, or custom models // Check these first as they take priority if (deploymentType === "docker" || deploymentType === "ollama") { - return "tier2"; + return "personal"; } - // Tier 1: Freemium (AgentCore) - Bedrock models only + // Freemium: AgentCore - Bedrock models only // Bedrock models can be identified by: // 1. Containing "bedrock" in the name // 2. Using AWS Bedrock provider prefixes (anthropic., amazon., meta., cohere., ai21., mistral.) @@ -25,170 +25,170 @@ function determineDeploymentTier(deploymentType: string, model: string): string bedrockProviders.some(provider => model.startsWith(provider)); if (isBedrockModel) { - return "tier1"; + return "freemium"; } - // Non-Bedrock AWS deployments use Fargate (tier2) - return "tier2"; + // Non-Bedrock AWS deployments use Fargate (personal tier) + return "personal"; } - // Default to tier1 for local development - return "tier1"; + // Default to freemium for local development + return "freemium"; } describe("ArchitecturePreview - Tier Detection", () => { - describe("Tier 1 (Freemium/AgentCore)", () => { + describe("Freemium (AgentCore)", () => { it("should return tier1 for AWS deployment with Bedrock model", () => { const tier = determineDeploymentTier("aws", "bedrock-claude-v3"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should return tier1 for AWS deployment with anthropic.claude-3-5-sonnet model", () => { const tier = determineDeploymentTier("aws", "anthropic.claude-3-5-sonnet-20240620-v1:0"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should return tier1 for AWS deployment with any bedrock model variant", () => { const tier = determineDeploymentTier("aws", "amazon.titan-text-express-v1:bedrock"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should return tier1 for local deployment type (default)", () => { const tier = determineDeploymentTier("local", "gpt-4"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should return tier1 for unknown deployment type (default)", () => { const tier = determineDeploymentTier("unknown", "some-model"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); }); - describe("Tier 2 (Personal AWS/Fargate)", () => { + describe("Personal (AWS Fargate)", () => { it("should return tier2 for docker deployment type", () => { const tier = determineDeploymentTier("docker", "llama-3.1-70b"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should return tier2 for ollama deployment type", () => { const tier = determineDeploymentTier("ollama", "llama3.2"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should return tier2 for AWS deployment with non-Bedrock model", () => { const tier = determineDeploymentTier("aws", "gpt-4"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should return tier2 for AWS deployment with OpenAI model", () => { const tier = determineDeploymentTier("aws", "openai/gpt-4-turbo"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should return tier2 for docker deployment with any model", () => { const tier = determineDeploymentTier("docker", "custom-model-v1"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should return tier2 for ollama deployment with Bedrock-named model (deployment type takes precedence)", () => { const tier = determineDeploymentTier("ollama", "bedrock-clone"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); }); describe("Edge Cases", () => { it("should handle empty deployment type", () => { const tier = determineDeploymentTier("", "bedrock-model"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should handle empty model name", () => { const tier = determineDeploymentTier("aws", ""); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should handle case-sensitive bedrock check", () => { const tier = determineDeploymentTier("aws", "Bedrock-Model"); - expect(tier).toBe("tier2"); // Should not match due to case sensitivity + expect(tier).toBe("personal"); // Should not match due to case sensitivity }); it("should handle bedrock substring in model name", () => { const tier = determineDeploymentTier("aws", "my-bedrock-custom-model"); - expect(tier).toBe("tier1"); // Should match because it contains "bedrock" + expect(tier).toBe("freemium"); // Should match because it contains "bedrock" }); it("should handle AWS deployment type with different casing", () => { const tier = determineDeploymentTier("AWS", "bedrock-model"); - expect(tier).toBe("tier1"); // Falls through to default (tier1) since "AWS" !== "aws" + expect(tier).toBe("freemium"); // Falls through to default (freemium) since "AWS" !== "aws" }); }); describe("Real-world Model Examples", () => { it("should correctly classify Claude 3.5 Sonnet on Bedrock", () => { const tier = determineDeploymentTier("aws", "anthropic.claude-3-5-sonnet-20240620-v1:0"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should correctly classify Claude 3 Haiku on Bedrock", () => { const tier = determineDeploymentTier("aws", "anthropic.claude-3-haiku-20240307-v1:0"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should correctly classify Llama 3.1 on Ollama", () => { const tier = determineDeploymentTier("ollama", "llama3.1:70b"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should correctly classify custom Docker model", () => { const tier = determineDeploymentTier("docker", "my-custom-model:latest"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should correctly classify Amazon Titan on Bedrock", () => { const tier = determineDeploymentTier("aws", "amazon.titan-text-express-v1"); - expect(tier).toBe("tier1"); // Starts with "amazon." provider prefix + expect(tier).toBe("freemium"); // Starts with "amazon." provider prefix }); it("should correctly classify Bedrock model with prefix", () => { const tier = determineDeploymentTier("aws", "bedrock:anthropic.claude-v3"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should correctly classify Meta Llama on Bedrock", () => { const tier = determineDeploymentTier("aws", "meta.llama3-70b-instruct-v1:0"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should correctly classify Cohere Command on Bedrock", () => { const tier = determineDeploymentTier("aws", "cohere.command-text-v14"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should correctly classify AI21 Jurassic on Bedrock", () => { const tier = determineDeploymentTier("aws", "ai21.j2-ultra-v1"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); it("should correctly classify Mistral on Bedrock", () => { const tier = determineDeploymentTier("aws", "mistral.mistral-7b-instruct-v0:2"); - expect(tier).toBe("tier1"); + expect(tier).toBe("freemium"); }); }); describe("Deployment Type Priority", () => { it("should prioritize docker deployment type over model name", () => { const tier = determineDeploymentTier("docker", "bedrock-model"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should prioritize ollama deployment type over model name", () => { const tier = determineDeploymentTier("ollama", "bedrock-model"); - expect(tier).toBe("tier2"); + expect(tier).toBe("personal"); }); it("should check model name only for aws deployment type", () => { const tier = determineDeploymentTier("aws", "openai-gpt4"); - expect(tier).toBe("tier2"); // Non-Bedrock AWS models use Fargate + expect(tier).toBe("personal"); // Non-Bedrock AWS models use Fargate (personal tier) }); }); }); diff --git a/src/components/ArchitecturePreview.tsx b/src/components/ArchitecturePreview.tsx index 640e09d..1be37e6 100644 --- a/src/components/ArchitecturePreview.tsx +++ b/src/components/ArchitecturePreview.tsx @@ -68,9 +68,9 @@ export function ArchitecturePreview({
- {tier === "tier1" && "Tier 1: Freemium (AgentCore)"} - {tier === "tier2" && "Tier 2: Personal AWS (Fargate)"} - {tier === "tier3" && "Tier 3: Enterprise"} + {tier === "freemium" && "Freemium (AgentCore)"} + {tier === "personal" && "Personal AWS (Fargate)"} + {tier === "enterprise" && "Enterprise"}
@@ -163,11 +163,11 @@ export function ArchitecturePreview({ {estimatedCost}

- {tier === "tier1" && + {tier === "freemium" && "Freemium tier includes limited free usage. Additional usage charged per request."} - {tier === "tier2" && + {tier === "personal" && "Costs include Fargate compute, storage, and data transfer. You only pay when your agent is active."} - {tier === "tier3" && + {tier === "enterprise" && "Enterprise pricing includes dedicated resources, SSO, and priority support."}

@@ -197,13 +197,13 @@ export function ArchitecturePreview({
CloudWatch logging and monitoring - {tier === "tier2" && ( + {tier === "personal" && (
  • VPC isolation with private subnets
  • )} - {tier === "tier3" && ( + {tier === "enterprise" && ( <>
  • @@ -234,13 +234,13 @@ export function ArchitecturePreview({ } function determineDeploymentTier(deploymentType: string, model: string): string { - // Tier 2: Personal AWS (Fargate) - Docker, Ollama, or custom models + // Personal: AWS (Fargate) - Docker, Ollama, or custom models // Check these first as they take priority if (deploymentType === "docker" || deploymentType === "ollama") { - return "tier2"; + return "personal"; } - // Tier 1: Freemium (AgentCore) - Bedrock models only + // Freemium: AgentCore - Bedrock models only // Bedrock models can be identified by: // 1. Containing "bedrock" in the name // 2. Using AWS Bedrock provider prefixes (anthropic., amazon., meta., cohere., ai21., mistral.) @@ -250,15 +250,15 @@ function determineDeploymentTier(deploymentType: string, model: string): string bedrockProviders.some(provider => model.startsWith(provider)); if (isBedrockModel) { - return "tier1"; + return "freemium"; } - // Non-Bedrock AWS deployments use Fargate (tier2) - return "tier2"; + // Non-Bedrock AWS deployments use Fargate (personal tier) + return "personal"; } - // Default to tier1 for local development - return "tier1"; + // Default to freemium for local development + return "freemium"; } function buildResourceEstimates( @@ -267,8 +267,8 @@ function buildResourceEstimates( ): ResourceEstimate[] { const resources: ResourceEstimate[] = []; - if (tier === "tier1") { - // Tier 1: AgentCore (Bedrock) + if (tier === "freemium") { + // Freemium: AgentCore (Bedrock) resources.push({ name: "AWS Bedrock AgentCore", type: "Managed Runtime", @@ -292,8 +292,8 @@ function buildResourceEstimates( description: "Log aggregation and monitoring", cost: "$0.50/GB", }); - } else if (tier === "tier2") { - // Tier 2: Personal AWS (Fargate) + } else if (tier === "personal") { + // Personal: AWS (Fargate) resources.push({ name: "VPC", type: "Network", @@ -333,8 +333,8 @@ function buildResourceEstimates( description: "Traffic distribution (optional)", cost: "$0.0225/hour", }); - } else if (tier === "tier3") { - // Tier 3: Enterprise (includes all Tier 2 + SSO) + } else if (tier === "enterprise") { + // Enterprise: includes all Personal tier resources + SSO resources.push({ name: "VPC", type: "Network", @@ -388,19 +388,19 @@ function buildResourceEstimates( } function calculateEstimatedCost(tier: string): string { - if (tier === "tier1") { - // Tier 1: AgentCore - pay per request + if (tier === "freemium") { + // Freemium: AgentCore - pay per request return "$0.001 - $0.01/request"; - } else if (tier === "tier2") { - // Tier 2: Fargate - pay per hour when active + } else if (tier === "personal") { + // Personal: Fargate - pay per hour when active const baseCost = 0.04; // Fargate (includes storage and logs in estimate) const estimatedHourly = baseCost; const estimatedMonthly = estimatedHourly * 24 * 30; // Assuming 24/7 operation return `~$${estimatedHourly.toFixed(2)}/hour (~$${estimatedMonthly.toFixed(2)}/month)`; - } else if (tier === "tier3") { - // Tier 3: Enterprise - contact for pricing + } else if (tier === "enterprise") { + // Enterprise: contact for pricing return "Contact for enterprise pricing"; } From cdc59483a172b638a16aa2b3cb6261fa6d5fb4ec Mon Sep 17 00:00:00 2001 From: MikePfunk28 Date: Sat, 14 Feb 2026 23:02:40 -0500 Subject: [PATCH 2/4] Refactor MCP server configuration and transport handling - Updated BuiltInMcpServer and DbMcpServer interfaces to make command, args, and env optional. - Added url and transportType fields to support different transport methods. - Modified addMCPServer and updateMCPServer mutations to validate transport-specific fields. - Adjusted built-in MCP server configurations to reflect new transport types and disable unnecessary servers. - Updated platformValue calculation to reflect new infrastructure items. - Refined realAgentTesting comments to align with AgentCore implementation. - Adjusted schema definitions for mcpServers to accommodate new optional fields. - Updated strandsAgentExecution to ensure correct type handling for skill references. - Removed unused AWS SDK dependencies related to ECS and ECR. - Revised AWSAuthModal and AWSRoleSetup components to reflect new permissions for Bedrock. - Updated ArchitecturePreview component to reflect changes in deployment tiers and resource estimates. - Adjusted CodePreview component to clarify deployment instructions for Bedrock AgentCore. --- cloudformation/user-onboarding-template.yaml | 213 +++----------- convex/agentExecution.test.ts | 31 +- convex/agents.ts | 12 +- convex/awsCrossAccount.ts | 106 ++----- convex/awsDeployment.ts | 262 ++--------------- convex/awsDeploymentFlow.ts | 107 +------ convex/awsDiagramGenerator.ts | 65 +---- convex/cdkGenerator.ts | 187 +++--------- convex/cloudFormationGenerator.ts | 283 ++----------------- convex/codeGenerator.ts | 8 +- convex/constants.ts | 9 +- convex/deploymentPackageGenerator.ts | 54 +--- convex/deploymentRouter.ts | 43 ++- convex/diagramGenerator.ts | 35 +-- convex/http.ts | 54 +--- convex/integration.test.ts | 2 +- convex/lib/aws/cloudwatchClient.ts | 4 +- convex/lib/aws/s3Client.ts | 2 +- convex/lib/cloudFormationGenerator.ts | 162 ++++------- convex/lib/fileGenerators.ts | 23 +- convex/lib/toolDispatch.ts | 4 +- convex/mcpClient.ts | 237 ++++++++++++---- convex/mcpConfig.ts | 73 +++-- convex/platformValue.ts | 2 +- convex/realAgentTesting.ts | 15 +- convex/schema.ts | 12 +- convex/strandsAgentExecution.ts | 4 +- package.json | 3 - src/components/AWSAuthModal.tsx | 3 +- src/components/AWSRoleSetup.tsx | 20 +- src/components/AgentBuilder.tsx | 38 ++- src/components/ArchitecturePreview.test.tsx | 153 ++++------ src/components/ArchitecturePreview.tsx | 98 +++---- src/components/CodePreview.tsx | 2 +- 34 files changed, 684 insertions(+), 1642 deletions(-) diff --git a/cloudformation/user-onboarding-template.yaml b/cloudformation/user-onboarding-template.yaml index 2bec7b3..59ca7a7 100644 --- a/cloudformation/user-onboarding-template.yaml +++ b/cloudformation/user-onboarding-template.yaml @@ -7,12 +7,12 @@ Parameters: Description: The AWS Account ID of the Agent Builder Platform AllowedPattern: '[0-9]{12}' ConstraintDescription: Must be a valid 12-digit AWS Account ID - + UserIdentifier: Type: String Description: Your email or unique identifier (used as External ID for security) Default: '' - + ProjectName: Type: String Description: Project name for resource tagging @@ -36,121 +36,35 @@ Metadata: default: 'Project Name' Resources: - # VPC for agent deployment - AgentVPC: - Type: AWS::EC2::VPC + # S3 Bucket for agent artifacts + AgentArtifactsBucket: + Type: AWS::S3::Bucket Properties: - CidrBlock: 10.0.0.0/16 - EnableDnsHostnames: true - EnableDnsSupport: true + BucketName: !Sub '${ProjectName}-${AWS::AccountId}-artifacts' + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + VersioningConfiguration: + Status: Enabled Tags: - Key: Name - Value: !Sub '${ProjectName}-vpc' + Value: !Sub '${ProjectName}-artifacts' - Key: ManagedBy Value: 'CloudFormation' - # Internet Gateway - InternetGateway: - Type: AWS::EC2::InternetGateway - Properties: - Tags: - - Key: Name - Value: !Sub '${ProjectName}-igw' - - AttachGateway: - Type: AWS::EC2::VPCGatewayAttachment - Properties: - VpcId: !Ref AgentVPC - InternetGatewayId: !Ref InternetGateway - - # Public Subnet - PublicSubnet: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref AgentVPC - CidrBlock: 10.0.1.0/24 - MapPublicIpOnLaunch: true - AvailabilityZone: !Select [0, !GetAZs ''] - Tags: - - Key: Name - Value: !Sub '${ProjectName}-public-subnet' - - # Route Table - PublicRouteTable: - Type: AWS::EC2::RouteTable - Properties: - VpcId: !Ref AgentVPC - Tags: - - Key: Name - Value: !Sub '${ProjectName}-public-rt' - - PublicRoute: - Type: AWS::EC2::Route - DependsOn: AttachGateway - Properties: - RouteTableId: !Ref PublicRouteTable - DestinationCidrBlock: 0.0.0.0/0 - GatewayId: !Ref InternetGateway - - SubnetRouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - SubnetId: !Ref PublicSubnet - RouteTableId: !Ref PublicRouteTable - - # Security Group - AgentSecurityGroup: - Type: AWS::EC2::SecurityGroup - Properties: - GroupName: !Sub '${ProjectName}-agent-sg' - GroupDescription: Security group for Agent Builder agents - VpcId: !Ref AgentVPC - SecurityGroupEgress: - - IpProtocol: -1 - CidrIp: 0.0.0.0/0 - Tags: - - Key: Name - Value: !Sub '${ProjectName}-agent-sg' - - # ECS Cluster - ECSCluster: - Type: AWS::ECS::Cluster - Properties: - ClusterName: !Sub '${ProjectName}-cluster' - CapacityProviders: - - FARGATE - DefaultCapacityProviderStrategy: - - CapacityProvider: FARGATE - Weight: 1 - Tags: - - Key: Name - Value: !Sub '${ProjectName}-cluster' - # CloudWatch Log Group LogGroup: Type: AWS::Logs::LogGroup Properties: - LogGroupName: !Sub '/aws/${ProjectName}/agents' + LogGroupName: !Sub '/aws/agentcore/${ProjectName}/agents' RetentionInDays: 7 - # IAM Role for Fargate Task Execution - FargateExecutionRole: - Type: AWS::IAM::Role - Properties: - RoleName: !Sub '${ProjectName}-fargate-execution-role' - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: ecs-tasks.amazonaws.com - Action: sts:AssumeRole - ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy - Tags: - - Key: Name - Value: !Sub '${ProjectName}-fargate-execution-role' - # Cross-Account IAM Role CrossAccountRole: Type: AWS::IAM::Role @@ -179,32 +93,31 @@ Resources: PolicyDocument: Version: '2012-10-17' Statement: - - Sid: ECSPermissions + - Sid: BedrockPermissions Effect: Allow Action: - - ecs:CreateCluster - - ecs:RegisterTaskDefinition - - ecs:RunTask - - ecs:StopTask - - ecs:DescribeTasks - - ecs:DescribeTaskDefinition - - ecs:ListTasks + - bedrock:CreateAgent + - bedrock:CreateAgentActionGroup + - bedrock:InvokeAgent + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + - bedrock:GetAgent + - bedrock:ListAgents + - bedrock:UpdateAgent + - bedrock:DeleteAgent Resource: '*' - - - Sid: ECRPermissions + + - Sid: S3Permissions Effect: Allow Action: - - ecr:CreateRepository - - ecr:GetAuthorizationToken - - ecr:BatchCheckLayerAvailability - - ecr:GetDownloadUrlForLayer - - ecr:BatchGetImage - - ecr:PutImage - - ecr:InitiateLayerUpload - - ecr:UploadLayerPart - - ecr:CompleteLayerUpload - Resource: '*' - + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:ListBucket + Resource: + - !GetAtt AgentArtifactsBucket.Arn + - !Sub '${AgentArtifactsBucket.Arn}/*' + - Sid: LogsPermissions Effect: Allow Action: @@ -212,8 +125,8 @@ Resources: - logs:CreateLogStream - logs:PutLogEvents - logs:DescribeLogStreams - Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/${ProjectName}/*' - + Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/agentcore/${ProjectName}/*' + - Sid: IAMPermissions Effect: Allow Action: @@ -222,24 +135,6 @@ Resources: - iam:PassRole - iam:GetRole Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ProjectName}-*' - - - Sid: VPCPermissions - Effect: Allow - Action: - - ec2:DescribeVpcs - - ec2:DescribeSubnets - - ec2:DescribeSecurityGroups - - ec2:CreateSecurityGroup - - ec2:AuthorizeSecurityGroupIngress - - ec2:AuthorizeSecurityGroupEgress - Resource: '*' - - - Sid: BedrockPermissions - Effect: Allow - Action: - - bedrock:InvokeModel - - bedrock:InvokeModelWithResponseStream - Resource: '*' Roles: - !Ref CrossAccountRole @@ -262,29 +157,11 @@ Outputs: Export: Name: !Sub '${AWS::StackName}-Region' - ECSClusterName: - Description: ECS Cluster name for agent deployment - Value: !Ref ECSCluster - Export: - Name: !Sub '${AWS::StackName}-ECSCluster' - - VPCId: - Description: VPC ID - Value: !Ref AgentVPC - Export: - Name: !Sub '${AWS::StackName}-VPCId' - - SubnetId: - Description: Public Subnet ID - Value: !Ref PublicSubnet - Export: - Name: !Sub '${AWS::StackName}-SubnetId' - - SecurityGroupId: - Description: Security Group ID - Value: !Ref AgentSecurityGroup + ArtifactsBucketName: + Description: S3 bucket for agent artifacts + Value: !Ref AgentArtifactsBucket Export: - Name: !Sub '${AWS::StackName}-SecurityGroupId' + Name: !Sub '${AWS::StackName}-ArtifactsBucket' SetupInstructions: Description: Next steps diff --git a/convex/agentExecution.test.ts b/convex/agentExecution.test.ts index 188b814..1493ba4 100644 --- a/convex/agentExecution.test.ts +++ b/convex/agentExecution.test.ts @@ -2,8 +2,8 @@ * Agent Execution Integration Tests * * Tests agent execution in multiple environments: - * 1. Docker/ECS Fargate (for Ollama models) - * 2. AWS Bedrock AgentCore (for Bedrock models) + * 1. Local Docker/Ollama (for local models) + * 2. AWS Bedrock AgentCore (for cloud models) * * Requirements: 3.1-3.7, 6.1-6.7, 7.1-7.7, 14.1-14.2 */ @@ -40,7 +40,7 @@ describe("Agent Execution Infrastructure", () => { // Set authenticated user for all subsequent operations t = t.withIdentity({ subject: testUserId }); - // Create Ollama agent (for Docker/Fargate testing) + // Create Ollama agent (for local Docker testing) ollamaAgentId = await t.run(async (ctx: any) => { return await ctx.db.insert("agents", { name: "Ollama Test Agent", @@ -690,19 +690,11 @@ class BedrockTestAgent(Agent): testQuery: "test", }); - // Simulate ECS task assignment - await t.run(async (ctx: any) => { - await ctx.db.patch(result.testId, { - ecsTaskArn: "arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123", - ecsTaskId: "abc123", - }); - }); - const test = await t.query(api.testExecution.getTestById, { testId: result.testId, }); - // Test routed through AgentCore — ECS fields are deprecated + // Test routed through AgentCore // Verify the test was created and queued successfully }); @@ -811,7 +803,6 @@ class BedrockTestAgent(Agent): expect(test.cpuUsed).toBe(0.5); // These metrics can be used to calculate: - // - Fargate cost: (executionTime / 3600000) * (memory/1024) * $0.04048 // - AgentCore cost: per-invocation pricing }); @@ -835,7 +826,7 @@ class BedrockTestAgent(Agent): }); describe("Complete Test Execution Flow", () => { - test("should execute complete test flow for Ollama agent (Docker/Fargate)", async () => { + test("should execute complete test flow for Ollama agent (local Docker)", async () => { // Requirement 3.1-3.7: Complete execution flow // 1. Submit test @@ -884,17 +875,7 @@ class BedrockTestAgent(Agent): expect(test.phase).toBe("building"); expect(test.startedAt).toBeDefined(); - // 5. Simulate container start - add ECS task info - await t.run(async (ctx: any) => { - await ctx.db.patch(result.testId, { - ecsTaskArn: "arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123", - ecsTaskId: "abc123", - cloudwatchLogGroup: "/ecs/agent-tests", - cloudwatchLogStream: "test-abc123", - }); - }); - - // 6. Update to RUNNING status + // 5. Update to RUNNING status await t.run(async (ctx: any) => { await ctx.runMutation(internal.testExecution.updateStatus, { testId: result.testId, diff --git a/convex/agents.ts b/convex/agents.ts index e91b8af..4ea250e 100644 --- a/convex/agents.ts +++ b/convex/agents.ts @@ -81,10 +81,12 @@ export const create = mutation({ mcpInputSchema: v.optional(v.string()), // JSON-stringified JSON Schema for MCP tool input mcpServers: v.optional(v.array(v.object({ name: v.string(), - command: v.string(), - args: v.array(v.string()), + command: v.optional(v.string()), + args: v.optional(v.array(v.string())), env: v.optional(v.record(v.string(), v.string())), // MCP server environment variables disabled: v.optional(v.boolean()), + url: v.optional(v.string()), + transportType: v.optional(v.string()), // "stdio" | "sse" | "http" | "direct" }))), sourceWorkflowId: v.optional(v.id("workflows")), }, @@ -127,10 +129,12 @@ export const update = mutation({ mcpInputSchema: v.optional(v.string()), // JSON-stringified JSON Schema for MCP tool input mcpServers: v.optional(v.array(v.object({ name: v.string(), - command: v.string(), - args: v.array(v.string()), + command: v.optional(v.string()), + args: v.optional(v.array(v.string())), env: v.optional(v.record(v.string(), v.string())), // MCP server environment variables disabled: v.optional(v.boolean()), + url: v.optional(v.string()), + transportType: v.optional(v.string()), // "stdio" | "sse" | "http" | "direct" }))), modelProvider: v.optional(v.string()), sourceWorkflowId: v.optional(v.id("workflows")), diff --git a/convex/awsCrossAccount.ts b/convex/awsCrossAccount.ts index c910f7c..5045313 100644 --- a/convex/awsCrossAccount.ts +++ b/convex/awsCrossAccount.ts @@ -85,29 +85,45 @@ export const deployToUserAccount = action({ const agent = await ctx.runQuery(api.agents.get, { id: args.agentId }); if (!agent) throw new Error("Agent not found"); - // Deploy to user's Fargate using their credentials - const deployment = await deployToFargate({ - credentials, - region: awsAccount.region!, - agent, - accountType: "user", + // Deploy to user's AgentCore using their credentials + // Extract dependencies from agent tools + const dependencies: string[] = []; + for (const tool of agent.tools || []) { + if (tool.requiresPip && tool.pipPackages) { + dependencies.push(...tool.pipPackages); + } + } + + const environmentVariables: Record = { + AGENT_NAME: agent.name, + AGENT_MODEL: agent.model, + }; + + const deployment: any = await ctx.runAction(api.agentcoreDeployment.deployToAgentCore, { + agentId: args.agentId, + code: agent.generatedCode, + dependencies, + environmentVariables, }); + if (!deployment.success) { + throw new Error(deployment.error || "AgentCore deployment failed"); + } + // Log deployment await ctx.runMutation(api.deployments.create, { agentId: args.agentId, tier: "personal", awsAccountId: awsAccount.awsAccountId, region: awsAccount.region!, - taskArn: deployment.taskArn, + taskArn: deployment.runtimeId || "agentcore", status: "running", }); return { success: true, - taskArn: deployment.taskArn, - logStreamUrl: deployment.logStreamUrl, - message: "Agent deployed to your AWS account!", + runtimeId: deployment.runtimeId, + message: "Agent deployed to your AWS account via Bedrock AgentCore!", }; }, }); @@ -143,71 +159,5 @@ export const validateRole = action({ }, }); -// Helper: Deploy to Fargate (works for both YOUR account and USER account) -async function deployToFargate(params: { - credentials: { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - }; - region: string; - agent: any; - accountType: "platform" | "user"; -}) { - const { credentials, region, agent, accountType } = params; - - // Call AWS HTTP action to run ECS task - const response = await fetch( - `${process.env.CONVEX_SITE_URL}/aws/runTask`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.AWS_API_SECRET}`, - }, - body: JSON.stringify({ - credentials, - region, - cluster: - accountType === "platform" - ? process.env.ECS_CLUSTER_NAME - : "agent-builder-cluster", - taskDefinition: - accountType === "platform" - ? process.env.ECS_TASK_FAMILY - : "agent-builder-agent-tester", - subnets: [ - accountType === "platform" - ? process.env.ECS_SUBNET_ID - : "subnet-user", - ], - securityGroups: [ - accountType === "platform" - ? process.env.ECS_SECURITY_GROUP_ID - : "sg-user", - ], - containerOverrides: { - name: "agent-tester", - environment: [ - { name: "AGENT_ID", value: agent._id }, - { name: "AGENT_NAME", value: agent.name }, - { name: "AGENT_CODE", value: agent.code }, - ], - }, - }), - } - ); - - if (!response.ok) { - throw new Error(`Failed to run task: ${response.statusText}`); - } - - const result = await response.json(); - - return { - taskArn: result.taskArn, - logStreamUrl: `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logsV2:log-groups/log-group/${encodeURIComponent( - "/ecs/agent-builder-agent-tester" - )}`, - }; -} +// Fargate helper removed — all cross-account deployments now go through Bedrock AgentCore. +// See agentcoreDeployment.ts for the AgentCore deployment path. diff --git a/convex/awsDeployment.ts b/convex/awsDeployment.ts index 9c960a1..cfe4868 100644 --- a/convex/awsDeployment.ts +++ b/convex/awsDeployment.ts @@ -17,7 +17,6 @@ import { incrementUsageAndReportOverageImpl } from "./stripeMutations"; import { getAuthUserId } from "@convex-dev/auth/server"; import { assembleDeploymentPackageFiles } from "./deploymentPackageGenerator"; import { sanitizeAgentName } from "./constants"; -import { isOllamaModelId } from "./modelRegistry"; /** * Deploy agent - Routes to correct tier (Tier 1/2/3) @@ -104,7 +103,7 @@ export const deployToAWS = action( { throw new Error( `Free tier limit reached (${freeLimits.monthlyExecutions} executions/month). Configure AWS credentials to deploy to your own account!` ); } - // Deploy to platform Fargate + // Deploy to platform AgentCore return await deployFreemium( ctx, args, userId ); } else if ( tier === "enterprise" ) { // Enterprise: SSO deployment (not implemented yet) @@ -168,13 +167,13 @@ export const executeDeployment = internalAction( { progress: { stage: "deploying", percentage: 60, - message: "Deploying to AWS AgentCore...", - currentStep: "Deploying to AWS", + message: "Deploying to Bedrock AgentCore...", + currentStep: "Deploying to AgentCore", totalSteps: 5, }, } ); - // Deploy to AWS using AgentCore CLI + // Deploy to AgentCore const deploymentResult = await deployToAgentCore( artifacts, args.config ); // Update status to completed with final progress @@ -619,29 +618,7 @@ function generateAgentCoreRequirements( tools: any[] ): string { return Array.from( packages ).join( String.raw`\n` ); } -function generateAgentCoreDockerfile( agent: any ): string { - const isOllamaModel = isOllamaModelId( agent.model, agent.deploymentType ); - - if ( isOllamaModel ) { - // Validate model name to prevent shell injection in entrypoint.sh - const safeModelPattern = /^[A-Za-z0-9._:/-]+$/; - const modelName = safeModelPattern.test( agent.model ) ? agent.model : "llama3:latest"; - - return `FROM ollama/ollama:latest - -RUN apt-get update && apt-get install -y python3.11 python3-pip curl && rm -rf /var/lib/apt/lists/* - -WORKDIR /app -COPY requirements.txt agent.py ./ -RUN pip3 install --no-cache-dir -r requirements.txt - -RUN echo '#!/bin/bash\nollama serve &\nsleep 5\nollama pull ${modelName}\npython3 agent.py' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh - -EXPOSE 8080 11434 -ENTRYPOINT ["/app/entrypoint.sh"] -`; - } - +function generateAgentCoreDockerfile( _agent: any ): string { return `FROM python:3.11-slim RUN apt-get update && apt-get install -y gcc g++ curl && rm -rf /var/lib/apt/lists/* @@ -884,8 +861,8 @@ export const executeDeploymentInternal = internalAction( { progress: { stage: "building", percentage: 10, - message: "Building Docker image...", - currentStep: "docker-build", + message: "Building agent package...", + currentStep: "agent-build", totalSteps: 5, }, } ); @@ -904,7 +881,6 @@ export const executeDeploymentInternal = internalAction( { currentStep: "agentcore-deploy", totalSteps: 5, }, - ecrRepositoryUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/agent-repo", cloudFormationStackId: "arn:aws:cloudformation:us-east-1:123456789012:stack/agent-stack/12345", } ); @@ -948,7 +924,7 @@ export const executeDeploymentInternal = internalAction( { // ============================================================================ /** - * Freemium: Deploy to platform Fargate + * Freemium: Deploy to platform AgentCore (our AWS account) */ async function deployFreemium( ctx: any, args: any, userId: Id<"users"> ): Promise { // Create deployment record @@ -985,7 +961,7 @@ async function deployFreemium( ctx: any, args: any, userId: Id<"users"> ): Promi } /** - * Personal: Deploy to USER's Fargate (Personal AWS Account) using Web Identity Federation + * Personal: Deploy to USER's AgentCore (Personal AWS Account) using Web Identity Federation */ async function deployPersonal( ctx: any, args: any, userId: string ): Promise { // Get user's stored Role ARN @@ -1084,15 +1060,15 @@ export const executeCrossAccountDeploymentInternal = internalAction( { // Assume role in user's account await assumeUserRole( args.roleArn, args.externalId ); - // Deploy to their Fargate + // Deploy to their AgentCore await ctx.runMutation( internal.awsDeployment.updateDeploymentStatusInternal, { deploymentId: args.deploymentId, status: "DEPLOYING", progress: { stage: "deploying", percentage: 50, - message: "Deploying to your AWS Fargate...", - currentStep: "deploy-fargate", + message: "Deploying to your AWS Bedrock AgentCore...", + currentStep: "deploy-agentcore", totalSteps: 5, }, } ); @@ -1276,36 +1252,16 @@ export const executeWebIdentityDeploymentInternal = internalAction( { awsCallerArn: callerArn, } ); - const { ECRClient, DescribeRepositoriesCommand, CreateRepositoryCommand } = await import( "@aws-sdk/client-ecr" ); - const ecrClient = new ECRClient( { region, credentials: awsCredentials } ); - const repositoryName = `agent-builder/${sanitizedName}`; - let repositoryUri: string | undefined; - - try { - const describe = await ecrClient.send( new DescribeRepositoriesCommand( { repositoryNames: [repositoryName] } ) ); - repositoryUri = describe.repositories?.[0]?.repositoryUri; - } catch ( repoError: any ) { - if ( repoError.name === "RepositoryNotFoundException" ) { - const created = await ecrClient.send( new CreateRepositoryCommand( { repositoryName } ) ); - repositoryUri = created.repository?.repositoryUri; - } else { - throw repoError; - } - } - await ctx.runMutation( internal.awsDeployment.updateDeploymentStatusInternal, { deploymentId: args.deploymentId, status: "DEPLOYING", progress: { - stage: "registry-ready", + stage: "artifacts-ready", percentage: 80, - message: repositoryUri - ? `ECR repository ready at ${repositoryUri}` - : "ECR repository ready", - currentStep: "prepare-registry", + message: `Agent package staged at s3://${bucketName}/${packageKey}`, + currentStep: "prepare-deployment", totalSteps: 5, }, - ecrRepositoryUri: repositoryUri, } ); let downloadUrl: string | null = null; @@ -1322,10 +1278,7 @@ export const executeWebIdentityDeploymentInternal = internalAction( { const instructionsLines = [ `Artifacts uploaded to s3://${bucketName}/${packageKey}`, `1. Download package: aws s3 cp s3://${bucketName}/${packageKey} ./agent_package.zip --region ${region}`, - repositoryUri - ? `2. Build and push the container image:\n aws ecr get-login-password --region ${region} | docker login --username AWS --password-stdin ${awsAccountId}.dkr.ecr.${region}.amazonaws.com\n docker build -t ${repositoryUri}:latest .\n docker push ${repositoryUri}:latest` - : "2. Build and push your agent image to the provisioned ECR repository.", - "3. Deploy the AgentCore stack using the CloudFormation template inside agent_package.zip or run deploy_agentcore.sh.", + "2. Deploy the AgentCore stack using the CloudFormation template inside agent_package.zip or run deploy_agentcore.sh.", ]; if ( downloadUrl ) { @@ -1340,11 +1293,10 @@ export const executeWebIdentityDeploymentInternal = internalAction( { progress: { stage: "staged", percentage: 100, - message: "Artifacts staged in your AWS account. Push the container image and launch AgentCore to finish deployment.", + message: "Artifacts staged in your AWS account. Deploy the AgentCore stack to finish deployment.", currentStep: "staged", totalSteps: 5, }, - ecrRepositoryUri: repositoryUri, s3BucketName: bucketName, deploymentPackageKey: packageKey, awsAccountId, @@ -1403,181 +1355,5 @@ async function assumeUserRole( roleArn: string, externalId: string ) { return await response.json(); } -/** - * - Deploy agent to user's AWS account using temporary credentials - */ -async function deployToUserAWS( - agent: any, - region: string, - accessKeyId: string, - secretAccessKey: string, - sessionToken: string -) { - const { - ECRClient, - CreateRepositoryCommand, - GetAuthorizationTokenCommand - } = await import( "@aws-sdk/client-ecr" ); - - const { - ECSClient, - CreateClusterCommand, - RegisterTaskDefinitionCommand, - CreateServiceCommand - } = await import( "@aws-sdk/client-ecs" ); - - const { - S3Client, - CreateBucketCommand, - PutObjectCommand - } = await import( "@aws-sdk/client-s3" ); - - const { - EC2Client, - DescribeVpcsCommand, - DescribeSubnetsCommand, - CreateSecurityGroupCommand, - AuthorizeSecurityGroupIngressCommand, - DescribeSecurityGroupsCommand - } = await import( "@aws-sdk/client-ec2" ); - - // Configure AWS clients with temporary credentials - const credentials = { - accessKeyId, - secretAccessKey, - sessionToken - }; - - const ecrClient = new ECRClient( { region, credentials } ); - const ecsClient = new ECSClient( { region, credentials } ); - const s3Client = new S3Client( { region, credentials } ); - const ec2Client = new EC2Client( { region, credentials } ); - - // 1. Create ECR repository for agent image - const repoName = `agent-${agent._id.toLowerCase()}`; - try { - await ecrClient.send( new CreateRepositoryCommand( { - repositoryName: repoName, - imageScanningConfiguration: { - scanOnPush: true - } - } ) ); - } catch ( error: any ) { - if ( error.name !== "RepositoryAlreadyExistsException" ) { - throw error; - } - } - - // 2. Get ECR auth token for Docker push - const authResponse = await ecrClient.send( new GetAuthorizationTokenCommand( {} ) ); - const authToken = authResponse.authorizationData?.[0]; - - if ( !authToken ) { - throw new Error( "Failed to get ECR authorization token" ); - } - - // 3. Create S3 bucket for agent artifacts - const bucketName = `agent-artifacts-${Date.now()}`; - try { - await s3Client.send( new CreateBucketCommand( { - Bucket: bucketName, - CreateBucketConfiguration: { - LocationConstraint: ( region !== "us-east-1" ? region : undefined ) as any - } - } ) ); - } catch ( error: any ) { - if ( error.name !== "BucketAlreadyOwnedByYou" ) { - throw error; - } - } - - // 4. Upload agent code to S3 - const agentCode = generateAgentCoreCode( agent ); - await s3Client.send( new PutObjectCommand( { - Bucket: bucketName, - Key: "agent.py", - Body: agentCode, - ContentType: "text/x-python" - } ) ); - - // 5. Create ECS cluster - const clusterName = `agent-cluster-${agent._id}`; - try { - await ecsClient.send( new CreateClusterCommand( { - clusterName, - capacityProviders: ["FARGATE"], - defaultCapacityProviderStrategy: [{ - capacityProvider: "FARGATE", - weight: 1 - }] - } ) ); - } catch ( error: any ) { - if ( error.name !== "ClusterAlreadyExistsException" ) { - throw error; - } - } - - // 6. Register task definition - const taskFamily = `agent-task-${agent._id}`; - const taskDefResponse = await ecsClient.send( new RegisterTaskDefinitionCommand( { - family: taskFamily, - networkMode: "awsvpc", - requiresCompatibilities: ["FARGATE"], - cpu: "256", - memory: "512", - executionRoleArn: `arn:aws:iam::${authToken.proxyEndpoint?.split( '.' )[0].split( '//' )[1]}:role/ecsTaskExecutionRole`, - containerDefinitions: [{ - name: "agent-container", - image: `${authToken.proxyEndpoint}/${repoName}:latest`, - essential: true, - portMappings: [{ - containerPort: 8080, - protocol: "tcp" - }], - environment: [ - { name: "AGENT_NAME", value: agent.name }, - { name: "MODEL_ID", value: agent.model } - ], - logConfiguration: { - logDriver: "awslogs", - options: { - "awslogs-group": `/ecs/${taskFamily}`, - "awslogs-region": region, - "awslogs-stream-prefix": "agent" - } - } - }] - } ) ); - - // 7. Create ECS service - const serviceName = `agent-service-${agent._id}`; - try { - await ecsClient.send( new CreateServiceCommand( { - cluster: clusterName, - serviceName, - taskDefinition: taskDefResponse.taskDefinition?.taskDefinitionArn, - desiredCount: 1, - launchType: "FARGATE", - networkConfiguration: { - awsvpcConfiguration: { - assignPublicIp: "ENABLED", - subnets: [], // TODO: Get default VPC subnets - securityGroups: [] // TODO: Create security group - } - } - } ) ); - } catch ( error: any ) { - if ( error.name !== "ServiceAlreadyExistsException" ) { - throw error; - } - } - - return { - ecrRepository: `${authToken.proxyEndpoint}/${repoName}`, - ecsCluster: clusterName, - ecsService: serviceName, - s3Bucket: bucketName, - taskDefinition: taskDefResponse.taskDefinition?.taskDefinitionArn - }; -} +// ECS/Fargate deployment removed — all deployments now go through Bedrock AgentCore. +// See agentcoreDeployment.ts for the AgentCore deployment path. diff --git a/convex/awsDeploymentFlow.ts b/convex/awsDeploymentFlow.ts index c7b021a..5ed8104 100644 --- a/convex/awsDeploymentFlow.ts +++ b/convex/awsDeploymentFlow.ts @@ -1,15 +1,13 @@ /** * AWS Deployment Flow - Vercel-style Experience - * + * * Flow: * 1. User clicks "Deploy to AWS" * 2. Check if they have AWS configured * 3. If not, offer two options: * a) Quick Setup - We create role via CloudFormation (1-click) * b) Manual Setup - Step-by-step wizard - * 4. Once configured, deploy based on model type: - * - Bedrock models → AgentCore (Lambda-based, no ECR) - * - Ollama models → ECS Fargate (ECR + Docker) + * 4. Once configured, deploy to Bedrock AgentCore */ import { action, mutation, query } from "./_generated/server"; @@ -64,16 +62,11 @@ export const checkDeploymentReadiness = query({ }; } - // Determine deployment type based on model - const isBedrock = agent.model.includes("anthropic") || - agent.model.includes("amazon") || - agent.model.includes("bedrock"); - return { ready: true, - deploymentType: isBedrock ? "bedrock_agentcore" : "fargate_ollama", - requiresECR: !isBedrock, // Only Ollama needs ECR - estimatedCost: isBedrock ? "$0.10/hour" : "$0.05/hour", + deploymentType: "bedrock_agentcore", + requiresECR: false, + estimatedCost: "Pay-per-use (Bedrock pricing)", roleArn: (user as any).awsRoleArn }; }, @@ -148,8 +141,7 @@ function generateQuickSetupTemplate(identityProvider: string): string { }] }, ManagedPolicyArns: [ - "arn:aws:iam::aws:policy/AmazonECS_FullAccess", - "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess", + "arn:aws:iam::aws:policy/AmazonBedrockFullAccess", "arn:aws:iam::aws:policy/AmazonS3FullAccess", "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess" ], @@ -272,12 +264,8 @@ export const deployAgent: any = action({ throw new Error("Agent not found"); } - // Route to correct deployment method - if (readiness.deploymentType === "bedrock_agentcore") { - return await deployToBedrockAgentCore(ctx, agent, args.region, userId); - } else { - return await deployToFargateOllama(ctx, agent, args.region, userId); - } + // All deployments go through AgentCore + return await deployToBedrockAgentCore(ctx, agent, args.region, userId); }, }); @@ -373,52 +361,7 @@ async function deployToBedrockAgentCore( }; } -/** - * Deploy Ollama model to Fargate (ECR + Docker) - */ -async function deployToFargateOllama( - ctx: any, - agent: any, - region: string, - userId: string -) { - // This uses the existing deployToUserAWS function - // which handles ECR, Docker, and Fargate - - const user = await ctx.runQuery(internal.awsDeployment.getUserTierInternal, { - userId - }); - - if (!(user as any)?.awsRoleArn) { - throw new Error("AWS role not configured"); - } - - const assumeResult = await ctx.runAction(api.awsAuth.assumeRoleWithWebIdentity, { - roleArn: (user as any).awsRoleArn - }); - - if (!assumeResult.success) { - throw new Error(assumeResult.error || "Failed to assume role"); - } - - const { credentials } = assumeResult; - - // Call the existing deployment function - // (This is defined in awsDeployment.ts) - const result = await deployToUserAWSWithCredentials( - agent, - region, - credentials.accessKeyId, - credentials.secretAccessKey, - credentials.sessionToken - ); - - return { - deploymentType: "fargate_ollama", - ...result, - message: "Deployed to ECS Fargate with Ollama" - }; -} +// Fargate/ECS deployment removed — all deployments now go through Bedrock AgentCore. /** * Generate Bedrock agent code (Lambda handler) @@ -465,34 +408,4 @@ def handler(event, context): `; } -/** - * Package code for Lambda deployment - */ -async function packageForLambda(code: string): Promise { - // In production, this would: - // 1. Create temp directory - // 2. Write agent.py - // 3. Install dependencies - // 4. Zip everything - // 5. Return zip bytes - - // For now, return mock zip - return new Uint8Array([80, 75, 3, 4]); // ZIP header -} - -// Re-export for use in other files -async function deployToUserAWSWithCredentials( - agent: any, - region: string, - accessKeyId: string, - secretAccessKey: string, - sessionToken: string -) { - // This would call the existing deployToUserAWS function - // from awsDeployment.ts - return { - ecrRepository: "placeholder", - ecsCluster: "placeholder", - ecsService: "placeholder" - }; -} +// ECS/Fargate stubs removed — all deployments now use Bedrock AgentCore. diff --git a/convex/awsDiagramGenerator.ts b/convex/awsDiagramGenerator.ts index 77cf8ce..f52765b 100644 --- a/convex/awsDiagramGenerator.ts +++ b/convex/awsDiagramGenerator.ts @@ -162,76 +162,31 @@ function buildResourceList(deployment: any): AWSResource[] { }); } } else if (deployment.tier === "personal") { - // Personal: AWS (Fargate) - - // VPC - resources.push({ - type: "vpc", - name: `${deployment.agentName}-vpc`, - properties: { - cidr: "10.0.0.0/16", - region, - }, - }); + // Personal: AWS (Bedrock AgentCore in user's account) - // Subnets + // AgentCore Runtime resources.push({ - type: "subnet", - name: `${deployment.agentName}-subnet-1`, - properties: { - cidr: "10.0.1.0/24", - availabilityZone: `${region}a`, - }, - }); - - resources.push({ - type: "subnet", - name: `${deployment.agentName}-subnet-2`, + type: "bedrock-agentcore", + name: `${deployment.agentName}-runtime`, properties: { - cidr: "10.0.2.0/24", - availabilityZone: `${region}b`, + region, }, }); - // ECR Repository - if (deployment.ecrRepositoryUri) { - resources.push({ - type: "ecr", - name: deployment.ecrRepositoryUri.split("/").pop() || "agent-repository", - id: deployment.ecrRepositoryUri, - properties: { - region, - }, - }); - } - - // ECS Cluster + // Lambda (AgentCore entry point) resources.push({ - type: "ecs-cluster", - name: `${deployment.agentName}-cluster`, + type: "lambda", + name: `${deployment.agentName}-invoker`, properties: { + runtime: "python3.11", region, }, }); - // ECS Fargate Service - if (deployment.taskArn) { - resources.push({ - type: "ecs-fargate", - name: `${deployment.agentName}-service`, - id: deployment.taskArn, - properties: { - cpu: "256", - memory: "512", - region, - }, - }); - } - // CloudWatch Logs resources.push({ type: "cloudwatch-logs", - name: `/ecs/${deployment.agentName}`, + name: `/agentcore/${deployment.agentName}`, properties: { retentionDays: deployment.logRetentionDays || 7, region, diff --git a/convex/cdkGenerator.ts b/convex/cdkGenerator.ts index 0844c7d..26dc11d 100644 --- a/convex/cdkGenerator.ts +++ b/convex/cdkGenerator.ts @@ -114,8 +114,8 @@ function generatePackageJson(stackName: string): string { "cdk", "agentcore", "bedrock", - "ecs", - "fargate" + "agentcore", + "bedrock" ], "author": "AgentCore CDK Generator", "license": "MIT" @@ -167,16 +167,13 @@ function generateCdkJson(): string { "@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/core:checkSecretUsage": true, "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], - "@aws-cdk-containers/ecs-service-extensions:enableLogging": true, "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, - "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, "@aws-cdk/aws-iam:minimizePolicies": true, "@aws-cdk/core:validateSnapshotRemovalPolicy": true, "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, "@aws-cdk/core:enablePartitionLiterals": true, "@aws-cdk/aws-iam:standardizedServicePrincipals": true, - "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, @@ -190,15 +187,10 @@ function generateCdkJson(): string { const { agentName, environment = "prod" } = args; return `import * as cdk from 'aws-cdk-lib'; -import * as ec2 from 'aws-cdk-lib/aws-ec2'; -import * as ecs from 'aws-cdk-lib/aws-ecs'; -import * as ecr from 'aws-cdk-lib/aws-ecr'; -import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; -import * as applicationautoscaling from 'aws-cdk-lib/aws-applicationautoscaling'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import { Construct } from 'constructs'; @@ -207,29 +199,15 @@ export interface AgentCoreStackProps extends cdk.StackProps { environment: string; modelId: string; tools: any[]; - vpcConfig: { - createVpc: boolean; - vpcCidr?: string; - availabilityZones?: string[]; - }; monitoring: { enableXRay: boolean; enableCloudWatch: boolean; logRetentionDays?: number; }; - scaling: { - minCapacity: number; - maxCapacity: number; - targetCpuUtilization: number; - }; } export class AgentCoreStack extends cdk.Stack { - public readonly vpc: ec2.IVpc; - public readonly cluster: ecs.Cluster; - public readonly service: ecs.FargateService; - public readonly loadBalancer: elbv2.ApplicationLoadBalancer; - public readonly ecrRepository: ecr.Repository; + public readonly s3Bucket: s3.Bucket; constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); @@ -239,39 +217,8 @@ export class AgentCoreStack extends cdk.Stack { cdk.Tags.of(this).add('Environment', props.environment); cdk.Tags.of(this).add('ManagedBy', 'CDK'); - // VPC - if (props.vpcConfig.createVpc) { - this.vpc = new ec2.Vpc(this, 'VPC', { - vpcName: \`\${props.agentName}-\${props.environment}-vpc\`, - ipAddresses: ec2.IpAddresses.cidr(props.vpcConfig.vpcCidr || '10.0.0.0/16'), - maxAzs: 3, - natGateways: 2, - subnetConfiguration: [ - { - cidrMask: 24, - name: 'Public', - subnetType: ec2.SubnetType.PUBLIC, - }, - { - cidrMask: 24, - name: 'Private', - subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - ], - }); - } else { - this.vpc = ec2.Vpc.fromLookup(this, 'DefaultVPC', { isDefault: true }); - } - - // ECR Repository - this.ecrRepository = new ecr.Repository(this, 'ECRRepository', { - repositoryName: \`\${props.agentName}-\${props.environment}\`, - imageScanOnPush: true, - lifecycleRules: [{ maxImageCount: 10 }], - }); - - // S3 Bucket - const s3Bucket = new s3.Bucket(this, 'S3Bucket', { + // S3 Bucket for agent artifacts + this.s3Bucket = new s3.Bucket(this, 'S3Bucket', { bucketName: \`\${props.agentName}-\${props.environment}-\${cdk.Aws.ACCOUNT_ID}-storage\`, encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, @@ -293,94 +240,45 @@ export class AgentCoreStack extends cdk.Stack { }, }); - // Log Group + // Log Group for AgentCore const logGroup = new logs.LogGroup(this, 'LogGroup', { - logGroupName: \`/aws/ecs/\${props.agentName}-\${props.environment}\`, + logGroupName: \`/aws/agentcore/\${props.agentName}-\${props.environment}\`, retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY, }); - // ECS Cluster - this.cluster = new ecs.Cluster(this, 'Cluster', { - clusterName: \`\${props.agentName}-\${props.environment}-cluster\`, - vpc: this.vpc, - containerInsights: props.monitoring.enableCloudWatch, + // IAM Role for Bedrock AgentCore + const agentCoreRole = new iam.Role(this, 'AgentCoreRole', { + roleName: \`\${props.agentName}-\${props.environment}-agentcore-role\`, + assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonBedrockFullAccess'), + ], }); - // Task Definition - const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition', { - family: \`\${props.agentName}-\${props.environment}\`, - cpu: 1024, - memoryLimitMiB: 2048, - }); + // Grant S3 access to AgentCore role + this.s3Bucket.grantReadWrite(agentCoreRole); - // Container - const container = taskDefinition.addContainer('agentcore', { - image: ecs.ContainerImage.fromEcrRepository(this.ecrRepository, 'latest'), - environment: { - AWS_REGION: cdk.Aws.REGION, - ENVIRONMENT: props.environment, - AGENT_NAME: props.agentName, - }, - secrets: { - MODEL_ID: ecs.Secret.fromSecretsManager(secrets, 'MODEL_ID'), - }, - logging: ecs.LogDrivers.awsLogs({ - logGroup: logGroup, - streamPrefix: 'ecs', - }), - }); + // Grant secrets access + secrets.grantRead(agentCoreRole); - container.addPortMappings({ containerPort: 8080 }); - - // Load Balancer - this.loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', { - vpc: this.vpc, - internetFacing: true, - }); - - const targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', { - port: 8080, - protocol: elbv2.ApplicationProtocol.HTTP, - vpc: this.vpc, - targetType: elbv2.TargetType.IP, - healthCheck: { path: '/ping' }, - }); - - this.loadBalancer.addListener('Listener', { - port: 80, - defaultTargetGroups: [targetGroup], - }); - - // ECS Service - this.service = new ecs.FargateService(this, 'Service', { - serviceName: \`\${props.agentName}-\${props.environment}-service\`, - cluster: this.cluster, - taskDefinition, - desiredCount: props.scaling.minCapacity, - }); - - this.service.attachToApplicationTargetGroup(targetGroup); - - // Auto Scaling - const scalableTarget = this.service.autoScaleTaskCount({ - minCapacity: props.scaling.minCapacity, - maxCapacity: props.scaling.maxCapacity, - }); - - scalableTarget.scaleOnCpuUtilization('CpuScaling', { - targetUtilizationPercent: props.scaling.targetCpuUtilization, - }); + // Grant CloudWatch access + logGroup.grantWrite(agentCoreRole); // Outputs new cdk.CfnOutput(this, 'AgentCoreEndpoint', { - value: \`http://\${this.loadBalancer.loadBalancerDnsName}\`, + value: \`https://bedrock-agentcore.\${cdk.Aws.REGION}.amazonaws.com/agents/\${props.agentName}-\${props.environment}/invoke\`, exportName: \`\${props.agentName}-\${props.environment}-endpoint\`, }); - new cdk.CfnOutput(this, 'ECRRepositoryURI', { - value: this.ecrRepository.repositoryUri, - exportName: \`\${props.agentName}-\${props.environment}-ecr-uri\`, + new cdk.CfnOutput(this, 'S3BucketName', { + value: this.s3Bucket.bucketName, + exportName: \`\${props.agentName}-\${props.environment}-s3-bucket\`, + }); + + new cdk.CfnOutput(this, 'AgentCoreRoleArn', { + value: agentCoreRole.roleArn, + exportName: \`\${props.agentName}-\${props.environment}-role-arn\`, }); } }`; @@ -442,21 +340,18 @@ Production-ready AgentCore infrastructure deployment using AWS CDK. ## Architecture -- **ECS Fargate**: Serverless container hosting -- **Application Load Balancer**: High availability -- **Auto Scaling**: CPU-based scaling -- **VPC**: Isolated network -- **ECR**: Container registry -- **S3**: Agent storage -- **CloudWatch**: Monitoring +- **Bedrock AgentCore**: Managed agent runtime +- **IAM Roles**: Least-privilege access control +- **S3**: Agent artifact storage +- **Secrets Manager**: Configuration & secrets +- **CloudWatch**: Monitoring & logging ## Quick Start 1. Install dependencies: \`npm install\` 2. Bootstrap CDK: \`npm run bootstrap\` 3. Deploy: \`npm run deploy\` -4. Build and push container to ECR -5. Update ECS service +4. Configure AgentCore endpoint ## Commands @@ -471,10 +366,10 @@ Access CloudWatch dashboard for metrics and logs. ## Security -- VPC with private subnets -- Security groups with least privilege -- IAM roles with minimal permissions -- Encrypted storage +- IAM roles with least-privilege policies +- Secrets Manager for sensitive configuration +- Encrypted S3 storage with versioning +- CloudWatch logging with configurable retention ## Cleanup @@ -530,14 +425,12 @@ function generateDeploymentInstructions(stackName: string, region: string): stri ## Prerequisites - AWS CLI configured - Node.js 18+ -- Docker ## Steps 1. \`npm install\` 2. \`cdk bootstrap\` 3. \`cdk deploy\` -4. Build and push container to ECR -5. Update ECS service +4. Configure AgentCore endpoint ## Verification - Check CloudWatch logs diff --git a/convex/cloudFormationGenerator.ts b/convex/cloudFormationGenerator.ts index 6c522de..63a4502 100644 --- a/convex/cloudFormationGenerator.ts +++ b/convex/cloudFormationGenerator.ts @@ -164,7 +164,7 @@ Parameters: Type: String Default: t3.medium AllowedValues: [t3.small, t3.medium, t3.large, t3.xlarge, m5.large, m5.xlarge, m5.2xlarge] - Description: EC2 instance type for ECS tasks + Description: EC2 instance type (reserved for future use) MinCapacity: Type: Number @@ -469,7 +469,7 @@ Resources: Statement: - Effect: Allow Principal: - Service: ecs-tasks.amazonaws.com + Service: bedrock.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/AmazonBedrockFullAccess @@ -485,7 +485,7 @@ Resources: - logs:CreateLogStream - logs:PutLogEvents - logs:DescribeLogStreams - Resource: !Sub 'arn:aws:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/ecs/\${AgentName}-\${Environment}*' + Resource: !Sub 'arn:aws:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/agentcore/\${AgentName}-\${Environment}*' - Effect: Allow Action: - s3:GetObject @@ -509,26 +509,24 @@ Resources: Statement: - Effect: Allow Principal: - Service: ecs-tasks.amazonaws.com + Service: bedrock.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy + - arn:aws:iam::aws:policy/AmazonBedrockFullAccess Policies: - PolicyName: AgentCoreExecutionPolicy PolicyDocument: Version: '2012-10-17' Statement: - - Effect: Allow - Action: - - ecr:GetAuthorizationToken - - ecr:BatchCheckLayerAvailability - - ecr:GetDownloadUrlForLayer - - ecr:BatchGetImage - Resource: '*' - Effect: Allow Action: - secretsmanager:GetSecretValue Resource: !Ref AgentCoreSecrets + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + Resource: !Sub '\${AgentCoreS3Bucket}/*' Tags: - Key: Environment Value: !Ref Environment @@ -609,274 +607,42 @@ Resources: Value: !Ref Environment # ============================================================================ - # ECS Cluster and Service + # Bedrock AgentCore Runtime # ============================================================================ - AgentCoreCluster: - Type: AWS::ECS::Cluster - Properties: - ClusterName: !Sub '\${AgentName}-\${Environment}-cluster' - CapacityProviders: - - FARGATE - - FARGATE_SPOT - DefaultCapacityProviderStrategy: - - CapacityProvider: FARGATE - Weight: 1 - - CapacityProvider: FARGATE_SPOT - Weight: !If [IsProduction, 0, 1] - ClusterSettings: - - Name: containerInsights - Value: !If [EnableDetailedCloudWatch, enabled, disabled] - Tags: - - Key: Environment - Value: !Ref Environment - AgentCoreLogGroup: Type: AWS::Logs::LogGroup Properties: - LogGroupName: !Sub '/aws/ecs/\${AgentName}-\${Environment}' + LogGroupName: !Sub '/aws/agentcore/\${AgentName}-\${Environment}' RetentionInDays: !Ref LogRetentionDays Tags: - Key: Environment Value: !Ref Environment - AgentCoreTaskDefinition: - Type: AWS::ECS::TaskDefinition - Properties: - Family: !Sub '\${AgentName}-\${Environment}' - Cpu: 1024 - Memory: 2048 - NetworkMode: awsvpc - RequiresCompatibilities: - - FARGATE - ExecutionRoleArn: !GetAtt AgentCoreExecutionRole.Arn - TaskRoleArn: !GetAtt AgentCoreTaskRole.Arn - ContainerDefinitions: - - Name: agentcore - Image: !Sub '\${AWS::AccountId}.dkr.ecr.\${AWS::Region}.amazonaws.com/\${AgentName}-\${Environment}:latest' - PortMappings: - - ContainerPort: 8080 - Protocol: tcp - Environment: - - Name: AWS_REGION - Value: !Ref 'AWS::Region' - - Name: ENVIRONMENT - Value: !Ref Environment - - Name: AGENT_NAME - Value: !Ref AgentName - Secrets: - - Name: MODEL_ID - ValueFrom: !Sub '\${AgentCoreSecrets}:MODEL_ID::' - LogConfiguration: - LogDriver: awslogs - Options: - awslogs-group: !Ref AgentCoreLogGroup - awslogs-region: !Ref 'AWS::Region' - awslogs-stream-prefix: ecs - HealthCheck: - Command: - - CMD-SHELL - - curl -f http://localhost:8080/ping || exit 1 - Interval: 30 - Timeout: 5 - Retries: 3 - StartPeriod: 60 - Tags: - - Key: Environment - Value: !Ref Environment - - AgentCoreService: - Type: AWS::ECS::Service - DependsOn: LoadBalancerListener - Properties: - ServiceName: !Sub '\${AgentName}-\${Environment}-service' - Cluster: !Ref AgentCoreCluster - TaskDefinition: !Ref AgentCoreTaskDefinition - DesiredCount: !Ref MinCapacity - LaunchType: FARGATE - NetworkConfiguration: - AwsvpcConfiguration: - SecurityGroups: - - !Ref AgentCoreSecurityGroup - Subnets: - - !If [CreateVpc, !Ref PrivateSubnet1, !Ref 'AWS::NoValue'] - - !If [CreateVpc, !Ref PrivateSubnet2, !Ref 'AWS::NoValue'] - AssignPublicIp: DISABLED - LoadBalancers: - - ContainerName: agentcore - ContainerPort: 8080 - TargetGroupArn: !Ref AgentCoreTargetGroup - DeploymentConfiguration: - MaximumPercent: 200 - MinimumHealthyPercent: 50 - DeploymentCircuitBreaker: - Enable: true - Rollback: true - EnableExecuteCommand: !If [IsProduction, false, true] - Tags: - - Key: Environment - Value: !Ref Environment - - # ============================================================================ - # Load Balancer - # ============================================================================ - AgentCoreLoadBalancer: - Type: AWS::ElasticLoadBalancingV2::LoadBalancer - Properties: - Name: !Sub '\${AgentName}-\${Environment}-alb' - Scheme: internet-facing - Type: application - SecurityGroups: - - !Ref LoadBalancerSecurityGroup - Subnets: - - !If [CreateVpc, !Ref PublicSubnet1, !Ref 'AWS::NoValue'] - - !If [CreateVpc, !Ref PublicSubnet2, !Ref 'AWS::NoValue'] - Tags: - - Key: Environment - Value: !Ref Environment - - AgentCoreTargetGroup: - Type: AWS::ElasticLoadBalancingV2::TargetGroup - Properties: - Name: !Sub '\${AgentName}-\${Environment}-tg' - Port: 8080 - Protocol: HTTP - VpcId: !If [CreateVpc, !Ref VPC, !Ref 'AWS::NoValue'] - TargetType: ip - HealthCheckPath: /ping - HealthCheckProtocol: HTTP - HealthCheckIntervalSeconds: 30 - HealthCheckTimeoutSeconds: 5 - HealthyThresholdCount: 2 - UnhealthyThresholdCount: 3 - Matcher: - HttpCode: 200 - Tags: - - Key: Environment - Value: !Ref Environment - - LoadBalancerListener: - Type: AWS::ElasticLoadBalancingV2::Listener - Properties: - DefaultActions: - - Type: forward - TargetGroupArn: !Ref AgentCoreTargetGroup - LoadBalancerArn: !Ref AgentCoreLoadBalancer - Port: 80 - Protocol: HTTP - - # ============================================================================ - # Auto Scaling - # ============================================================================ - AgentCoreAutoScalingTarget: - Type: AWS::ApplicationAutoScaling::ScalableTarget - Properties: - MaxCapacity: !Ref MaxCapacity - MinCapacity: !Ref MinCapacity - ResourceId: !Sub 'service/\${AgentCoreCluster}/\${AgentCoreService.Name}' - RoleARN: !Sub 'arn:aws:iam::\${AWS::AccountId}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService' - ScalableDimension: ecs:service:DesiredCount - ServiceNamespace: ecs - - AgentCoreAutoScalingPolicy: - Type: AWS::ApplicationAutoScaling::ScalingPolicy - Properties: - PolicyName: !Sub '\${AgentName}-\${Environment}-scaling-policy' - PolicyType: TargetTrackingScaling - ScalingTargetId: !Ref AgentCoreAutoScalingTarget - TargetTrackingScalingPolicyConfiguration: - PredefinedMetricSpecification: - PredefinedMetricType: ECSServiceAverageCPUUtilization - TargetValue: !Ref TargetCpuUtilization - ScaleOutCooldown: 300 - ScaleInCooldown: 300 - # ============================================================================ # CloudWatch Alarms # ============================================================================ - HighCPUAlarm: + AgentCoreErrorAlarm: Type: AWS::CloudWatch::Alarm Condition: EnableDetailedCloudWatch Properties: - AlarmName: !Sub '\${AgentName}-\${Environment}-high-cpu' - AlarmDescription: High CPU utilization - MetricName: CPUUtilization - Namespace: AWS/ECS - Statistic: Average + AlarmName: !Sub '\${AgentName}-\${Environment}-errors' + AlarmDescription: Agent invocation errors + MetricName: Errors + Namespace: AWS/Bedrock + Statistic: Sum Period: 300 EvaluationPeriods: 2 - Threshold: 80 + Threshold: 5 ComparisonOperator: GreaterThanThreshold - Dimensions: - - Name: ServiceName - Value: !Sub '\${AgentName}-\${Environment}-service' - - Name: ClusterName - Value: !Ref AgentCoreCluster - TreatMissingData: notBreaching - - HighMemoryAlarm: - Type: AWS::CloudWatch::Alarm - Condition: EnableDetailedCloudWatch - Properties: - AlarmName: !Sub '\${AgentName}-\${Environment}-high-memory' - AlarmDescription: High memory utilization - MetricName: MemoryUtilization - Namespace: AWS/ECS - Statistic: Average - Period: 300 - EvaluationPeriods: 2 - Threshold: 80 - ComparisonOperator: GreaterThanThreshold - Dimensions: - - Name: ServiceName - Value: !Sub '\${AgentName}-\${Environment}-service' - - Name: ClusterName - Value: !Ref AgentCoreCluster - TreatMissingData: notBreaching - - ServiceUnhealthyAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: !Sub '\${AgentName}-\${Environment}-service-unhealthy' - AlarmDescription: Service has unhealthy targets - MetricName: UnHealthyHostCount - Namespace: AWS/ApplicationELB - Statistic: Average - Period: 60 - EvaluationPeriods: 2 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - Dimensions: - - Name: TargetGroup - Value: !GetAtt AgentCoreTargetGroup.TargetGroupFullName - - Name: LoadBalancer - Value: !GetAtt AgentCoreLoadBalancer.LoadBalancerFullName TreatMissingData: notBreaching Outputs: AgentCoreEndpoint: - Description: AgentCore endpoint URL - Value: !Sub 'http://\${AgentCoreLoadBalancer.DNSName}' + Description: Bedrock AgentCore endpoint URL + Value: !Sub 'https://bedrock-agentcore.\${AWS::Region}.amazonaws.com/agents/\${AgentName}-\${Environment}/invoke' Export: Name: !Sub '\${AWS::StackName}-endpoint' - AgentCoreClusterName: - Description: ECS cluster name - Value: !Ref AgentCoreCluster - Export: - Name: !Sub '\${AWS::StackName}-cluster' - - AgentCoreServiceName: - Description: ECS service name - Value: !Sub '\${AgentName}-\${Environment}-service' - Export: - Name: !Sub '\${AWS::StackName}-service' - - ECRRepositoryURI: - Description: ECR repository URI - Value: !Sub '\${AWS::AccountId}.dkr.ecr.\${AWS::Region}.amazonaws.com/\${AgentName}-\${Environment}' - Export: - Name: !Sub '\${AWS::StackName}-ecr-uri' - S3BucketName: Description: S3 bucket for agent storage Value: !Ref AgentCoreS3Bucket @@ -890,13 +656,6 @@ Outputs: Export: Name: !Sub '\${AWS::StackName}-vpc-id' - PrivateSubnets: - Condition: CreateVpc - Description: Private subnet IDs - Value: !Sub '\${PrivateSubnet1},\${PrivateSubnet2}' - Export: - Name: !Sub '\${AWS::StackName}-private-subnets' - SecurityGroupId: Description: AgentCore security group ID Value: !Ref AgentCoreSecurityGroup diff --git a/convex/codeGenerator.ts b/convex/codeGenerator.ts index bc671e0..7eb387a 100644 --- a/convex/codeGenerator.ts +++ b/convex/codeGenerator.ts @@ -51,10 +51,12 @@ export const generateAgent = action({ deploymentType: v.string(), mcpServers: v.optional(v.array(v.object({ name: v.string(), - command: v.string(), - args: v.array(v.string()), + command: v.optional(v.string()), + args: v.optional(v.array(v.string())), env: v.optional(v.record(v.string(), v.string())), // MCP server environment variables disabled: v.optional(v.boolean()), + url: v.optional(v.string()), + transportType: v.optional(v.string()), // "stdio" | "sse" | "http" | "direct" }))), dynamicTools: v.optional(v.array(v.object({ name: v.string(), @@ -1184,7 +1186,7 @@ volumes: } /** - * Generate AWS SAM template for Lambda/ECS deployment + * Generate AWS SAM template for Lambda deployment */ function generateSAMTemplate(agentName: string, model: string): string { const functionName = agentName.replace(/[^a-zA-Z0-9]/g, ''); diff --git a/convex/constants.ts b/convex/constants.ts index a241115..f19a4ad 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -43,10 +43,7 @@ export type ExtrasPip = typeof VALID_EXTRAS_PIP[number]; * Default resource configurations */ export const DEFAULT_RESOURCES = { - ECS_CPU: "256", - ECS_MEMORY: "512", LOG_RETENTION_DAYS: 7, - ECR_IMAGE_RETENTION: 10, CONTAINER_PORT: 8000, } as const; @@ -110,10 +107,10 @@ export function isAWSDeployment(deploymentType: string): boolean { } /** - * Check if deployment type is container-based + * Check if deployment type is local-only (no cloud deployment) */ -export function isContainerDeployment(deploymentType: string): boolean { - return deploymentType === DEPLOYMENT_TYPES.DOCKER || +export function isLocalDeployment(deploymentType: string): boolean { + return deploymentType === DEPLOYMENT_TYPES.DOCKER || deploymentType === DEPLOYMENT_TYPES.OLLAMA; } diff --git a/convex/deploymentPackageGenerator.ts b/convex/deploymentPackageGenerator.ts index 09a0b24..7b7ebb2 100644 --- a/convex/deploymentPackageGenerator.ts +++ b/convex/deploymentPackageGenerator.ts @@ -7,7 +7,7 @@ import { action } from "./_generated/server"; import { v } from "convex/values"; import { api } from "./_generated/api"; import { getAuthUserId } from "@convex-dev/auth/server"; -import { isAWSDeployment, isContainerDeployment, sanitizePythonModuleName, escapePythonString, escapePythonTripleQuote } from "./constants"; +import { isAWSDeployment, isLocalDeployment, sanitizePythonModuleName, escapePythonString, escapePythonTripleQuote } from "./constants"; import { generateRequirementsTxt, generateDockerfile, @@ -67,54 +67,10 @@ function buildResourceListFromAgent(agent: any): AWSResource[] { }, }); } else { - // Docker/Container deployment (Fargate) - resources.push({ - type: "vpc", - name: `${agent.name}-vpc`, - properties: { - cidr: "10.0.0.0/16", - region, - }, - }); - - resources.push({ - type: "subnet", - name: `${agent.name}-subnet-1`, - properties: { - cidr: "10.0.1.0/24", - availabilityZone: `${region}a`, - }, - }); - - resources.push({ - type: "ecr", - name: `${agent.name}-repository`, - properties: { - region, - }, - }); - - resources.push({ - type: "ecs-cluster", - name: `${agent.name}-cluster`, - properties: { - region, - }, - }); - - resources.push({ - type: "ecs-fargate", - name: `${agent.name}-service`, - properties: { - cpu: "256", - memory: "512", - region, - }, - }); - + // Docker/Container deployment (local only — cloud deploys go through AgentCore) resources.push({ type: "cloudwatch-logs", - name: `/ecs/${agent.name}`, + name: `/agentcore/${agent.name}`, properties: { retentionDays: 7, region, @@ -163,7 +119,7 @@ export function assembleDeploymentPackageFiles(agent: any, options: DeploymentPa files["cloudformation.yaml"] = generateCloudFormationTemplate(agent); } - if (isContainerDeployment(agent.deploymentType) || options.includeCLIScript) { + if (isLocalDeployment(agent.deploymentType) || options.includeCLIScript) { files["deploy.sh"] = generateDeployScript(agent); } @@ -251,7 +207,7 @@ export const generateDeploymentPackage = action({ includeCLIScript: v.optional(v.boolean()), includeLambdaConfig: v.optional(v.boolean()), usePyprojectToml: v.optional(v.boolean()), - deploymentTarget: v.optional(v.string()), // "fargate" | "lambda" | "agentcore" + deploymentTarget: v.optional(v.string()), // "lambda" | "agentcore" })), }, handler: async (ctx, args) => { diff --git a/convex/deploymentRouter.ts b/convex/deploymentRouter.ts index 0c472ab..646e2c2 100644 --- a/convex/deploymentRouter.ts +++ b/convex/deploymentRouter.ts @@ -1,6 +1,6 @@ // Deployment Router - Routes deployments to correct tier // Freemium: AgentCore sandbox (OUR AWS) -// Personal: Fargate (USER's AWS) +// Personal: AgentCore (USER's AWS via assume-role) // Enterprise: SSO deployment (ENTERPRISE AWS via SSO) import { v } from "convex/values"; @@ -158,23 +158,48 @@ async function deployFreemium(ctx: any, args: any, userId: Id<"users">): Promise } } -// Personal: Deploy to USER's Fargate (Personal AWS Account) +// Personal: Deploy to USER's Bedrock AgentCore (Personal AWS Account via assume-role) // No per-deployment billing — user pays AWS directly for their own runtime. // We only charge the $5/month subscription for platform access. -async function deployPersonal(ctx: any, args: any, _userId: Id<"users">): Promise { +async function deployPersonal(ctx: any, args: any, userId: Id<"users">): Promise { try { - const result: any = await ctx.runAction( - api.awsCrossAccount.deployToUserAccount, - { - agentId: args.agentId, + // Get agent details + const agent = await ctx.runQuery(api.agents.get, { id: args.agentId }); + if (!agent) { + throw new Error("Agent not found"); + } + + // Extract dependencies from agent tools + const dependencies: string[] = []; + for (const tool of agent.tools || []) { + if (tool.requiresPip && tool.pipPackages) { + dependencies.push(...tool.pipPackages); } - ); + } + + // Build environment variables + const environmentVariables: Record = { + AGENT_NAME: agent.name, + AGENT_MODEL: agent.model, + }; + + // Deploy to AgentCore in user's AWS account + const result: any = await ctx.runAction(api.agentcoreDeployment.deployToAgentCore, { + agentId: args.agentId, + code: agent.generatedCode, + dependencies, + environmentVariables, + }); + + if (!result.success) { + throw new Error(result.error || "AgentCore deployment failed"); + } return { success: true, tier: "personal", result, - message: "Agent deployed to your AWS account", + message: "Agent deployed to your AWS account via Bedrock AgentCore", }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/convex/diagramGenerator.ts b/convex/diagramGenerator.ts index 0972f78..17109d1 100644 --- a/convex/diagramGenerator.ts +++ b/convex/diagramGenerator.ts @@ -129,42 +129,31 @@ with Diagram("${agentName} - Bedrock AgentCore", filename="${sanitizedName}_arch } /** - * Generate Docker/Ollama architecture diagram + * Generate Docker/Ollama architecture diagram (local deployment) */ function generateDockerDiagram(agentName: string, model: string, tools: any[]): string { const sanitizedName = agentName.replace(/[^a-zA-Z0-9]/g, '_'); - + return `# Architecture diagram for ${agentName} -with Diagram("${agentName} - Docker Deployment", filename="${sanitizedName}_architecture", show=False, direction="LR"): +with Diagram("${agentName} - Local Docker Deployment", filename="${sanitizedName}_architecture", show=False, direction="LR"): # User/Client user = Custom("User", "./assets/user.png") - - # Load Balancer - lb = ELB("Load Balancer") - - # ECS Fargate - with Cluster("ECS Fargate"): + + # Local Docker Container + with Cluster("Local Docker"): with Cluster("Agent Container"): - agent = ECS("Agent Runtime") + agent = Custom("Agent Runtime", "./assets/docker.png") ollama = Custom("Ollama\\n${model}", "./assets/ollama.png") - + agent >> ollama - + # Tools with Cluster("Agent Tools"): - ${tools.map((tool, idx) => `tool${idx} = Lambda("${tool.name}")`).join('\n ')} - - # CloudWatch - logs = CloudwatchLogs("CloudWatch Logs") - - # ECR for images - ecr = ECR("Container Registry") - + ${tools.map((tool, idx) => `tool${idx} = Custom("${tool.name}", "./assets/tool.png")`).join('\n ')} + # Connections - user >> lb >> agent + user >> agent ${tools.map((_, idx) => `agent >> tool${idx}`).join('\n ')} - agent >> logs - ecr >> agent `; } diff --git a/convex/http.ts b/convex/http.ts index e2fdbc8..cfce512 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -330,58 +330,8 @@ http.route({ }), }); -// AWS ECS RunTask -http.route({ - path: "/aws/runTask", - method: "POST", - handler: httpAction(async (ctx, request) => { - const authHeader = request.headers.get("Authorization"); - if (authHeader !== `Bearer ${process.env.AWS_API_SECRET}`) { - return new Response("Unauthorized", { status: 401 }); - } - - const body = await request.json(); - const { credentials, region, cluster, taskDefinition, subnets, securityGroups, containerOverrides } = body; - - try { - const AWS = await import("@aws-sdk/client-ecs"); - const ecs = new AWS.ECSClient({ - region, - credentials: { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }, - }); - - const command = new AWS.RunTaskCommand({ - cluster, - taskDefinition, - launchType: "FARGATE", - networkConfiguration: { - awsvpcConfiguration: { subnets, securityGroups, assignPublicIp: "ENABLED" }, - }, - overrides: { containerOverrides: [containerOverrides] }, - }); - - const response = await ecs.send(command); - if (!response.tasks || response.tasks.length === 0) { - throw new Error("No tasks were created"); - } - - const task = response.tasks[0]; - return new Response( - JSON.stringify({ taskArn: task.taskArn, taskId: task.taskArn?.split("/").pop(), status: task.lastStatus }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - } catch (error: any) { - return new Response( - JSON.stringify({ error: error.message, code: error.Code }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); - } - }), -}); +// ECS RunTask endpoint removed — all deployments now use Bedrock AgentCore. +// See agentcoreDeployment.ts for the AgentCore deployment path. // Validate Role http.route({ diff --git a/convex/integration.test.ts b/convex/integration.test.ts index 59fd66d..4befedc 100644 --- a/convex/integration.test.ts +++ b/convex/integration.test.ts @@ -1454,7 +1454,7 @@ describe("Deployment Integration Tests", () => { }); }); - describe("Personal (AWS Fargate) Deployment Workflow", () => { + describe("Personal (AgentCore) Deployment Workflow", () => { test("should handle personal tier deployment workflow", async () => { const t = convexTest(schema, modules); diff --git a/convex/lib/aws/cloudwatchClient.ts b/convex/lib/aws/cloudwatchClient.ts index 3504aa4..e522763 100644 --- a/convex/lib/aws/cloudwatchClient.ts +++ b/convex/lib/aws/cloudwatchClient.ts @@ -21,7 +21,7 @@ export const cloudwatchClient = new CloudWatchLogsClient({ : undefined, }); -const LOG_GROUP = process.env.CLOUDWATCH_LOG_GROUP || "/ecs/agent-tests"; +const LOG_GROUP = process.env.CLOUDWATCH_LOG_GROUP || "/agentcore/agent-tests"; /** * Fetch new log events from CloudWatch Logs @@ -92,6 +92,6 @@ export async function pollNewLogs(params: { * Generate log stream name from test ID */ export function getLogStreamName(taskId: string): string { - // ECS log stream format: {prefix}/{container-name}/{task-id} + // Log stream format: {prefix}/{container-name}/{task-id} return `agent-test/agent-test-container/${taskId}`; } diff --git a/convex/lib/aws/s3Client.ts b/convex/lib/aws/s3Client.ts index 811f9d6..b91af35 100644 --- a/convex/lib/aws/s3Client.ts +++ b/convex/lib/aws/s3Client.ts @@ -128,7 +128,7 @@ export async function uploadDeploymentPackage(params: { } /** - * Upload build context for ECS task + * Upload build context for agent deployment */ export async function uploadBuildContext(params: { testId: string; diff --git a/convex/lib/cloudFormationGenerator.ts b/convex/lib/cloudFormationGenerator.ts index f5abe94..f3ce277 100644 --- a/convex/lib/cloudFormationGenerator.ts +++ b/convex/lib/cloudFormationGenerator.ts @@ -1,6 +1,6 @@ /** * CloudFormation Template Generator - * Generates AWS infrastructure templates for agent deployment + * Generates AWS infrastructure templates for Bedrock AgentCore deployment */ import { DEFAULT_RESOURCES, sanitizeAgentName } from "../constants"; @@ -49,85 +49,25 @@ class CloudFormationBuilder { return this; } - addECRRepository(): this { + addS3Bucket(): this { this.sections.push( `Resources:`, - ` # ECR Repository for agent container`, - ` AgentRepository:`, - ` Type: AWS::ECR::Repository`, + ` # S3 Bucket for agent artifacts`, + ` AgentArtifactsBucket:`, + ` Type: AWS::S3::Bucket`, ` Properties:`, - ` RepositoryName: !Sub '\${AgentName}-\${Environment}'`, - ` ImageScanningConfiguration:`, - ` ScanOnPush: true`, - ` LifecyclePolicy:`, - ` LifecyclePolicyText: |`, - ` {`, - ` "rules": [{`, - ` "rulePriority": 1,`, - ` "description": "Keep last ${DEFAULT_RESOURCES.ECR_IMAGE_RETENTION} images",`, - ` "selection": {`, - ` "tagStatus": "any",`, - ` "countType": "imageCountMoreThan",`, - ` "countNumber": ${DEFAULT_RESOURCES.ECR_IMAGE_RETENTION}`, - ` },`, - ` "action": { "type": "expire" }`, - ` }]`, - ` }`, - `` - ); - return this; - } - - addECSCluster(): this { - this.sections.push( - ` # ECS Cluster`, - ` AgentCluster:`, - ` Type: AWS::ECS::Cluster`, - ` Properties:`, - ` ClusterName: !Sub '\${AgentName}-cluster-\${Environment}'`, - ` CapacityProviders:`, - ` - FARGATE`, - ` - FARGATE_SPOT`, - ` DefaultCapacityProviderStrategy:`, - ` - CapacityProvider: FARGATE`, - ` Weight: 1`, - `` - ); - return this; - } - - addTaskDefinition(): this { - this.sections.push( - ` # Task Definition`, - ` AgentTaskDefinition:`, - ` Type: AWS::ECS::TaskDefinition`, - ` Properties:`, - ` Family: !Sub '\${AgentName}-task'`, - ` NetworkMode: awsvpc`, - ` RequiresCompatibilities:`, - ` - FARGATE`, - ` Cpu: '${DEFAULT_RESOURCES.ECS_CPU}'`, - ` Memory: '${DEFAULT_RESOURCES.ECS_MEMORY}'`, - ` ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn`, - ` TaskRoleArn: !GetAtt TaskRole.Arn`, - ` ContainerDefinitions:`, - ` - Name: agent`, - ` Image: !Sub '\${AWS::AccountId}.dkr.ecr.\${AWS::Region}.amazonaws.com/\${AgentRepository}:latest'`, - ` Essential: true`, - ` PortMappings:`, - ` - ContainerPort: ${DEFAULT_RESOURCES.CONTAINER_PORT}`, - ` Protocol: tcp`, - ` LogConfiguration:`, - ` LogDriver: awslogs`, - ` Options:`, - ` awslogs-group: !Ref AgentLogGroup`, - ` awslogs-region: !Ref AWS::Region`, - ` awslogs-stream-prefix: agent`, - ` Environment:`, - ` - Name: AGENT_NAME`, - ` Value: !Ref AgentName`, - ` - Name: ENVIRONMENT`, - ` Value: !Ref Environment`, + ` BucketName: !Sub '\${AgentName}-\${Environment}-\${AWS::AccountId}-artifacts'`, + ` BucketEncryption:`, + ` ServerSideEncryptionConfiguration:`, + ` - ServerSideEncryptionByDefault:`, + ` SSEAlgorithm: AES256`, + ` PublicAccessBlockConfiguration:`, + ` BlockPublicAcls: true`, + ` BlockPublicPolicy: true`, + ` IgnorePublicAcls: true`, + ` RestrictPublicBuckets: true`, + ` VersioningConfiguration:`, + ` Status: Enabled`, `` ); return this; @@ -139,7 +79,7 @@ class CloudFormationBuilder { ` AgentLogGroup:`, ` Type: AWS::Logs::LogGroup`, ` Properties:`, - ` LogGroupName: !Sub '/ecs/\${AgentName}'`, + ` LogGroupName: !Sub '/aws/agentcore/\${AgentName}'`, ` RetentionInDays: ${DEFAULT_RESOURCES.LOG_RETENTION_DAYS}`, `` ); @@ -149,61 +89,70 @@ class CloudFormationBuilder { addIAMRoles(): this { this.sections.push( ` # IAM Roles`, - ` TaskExecutionRole:`, + ` AgentCoreExecutionRole:`, ` Type: AWS::IAM::Role`, ` Properties:`, + ` RoleName: !Sub '\${AgentName}-\${Environment}-agentcore-role'`, ` AssumeRolePolicyDocument:`, ` Statement:`, ` - Effect: Allow`, ` Principal:`, - ` Service: ecs-tasks.amazonaws.com`, + ` Service: bedrock.amazonaws.com`, ` Action: sts:AssumeRole`, ` ManagedPolicyArns:`, - ` - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy`, - ``, - ` TaskRole:`, - ` Type: AWS::IAM::Role`, - ` Properties:`, - ` AssumeRolePolicyDocument:`, - ` Statement:`, - ` - Effect: Allow`, - ` Principal:`, - ` Service: ecs-tasks.amazonaws.com`, - ` Action: sts:AssumeRole`, + ` - arn:aws:iam::aws:policy/AmazonBedrockFullAccess`, ` Policies:`, - ` - PolicyName: BedrockAccess`, + ` - PolicyName: AgentCoreAccess`, ` PolicyDocument:`, ` Statement:`, ` - Effect: Allow`, ` Action:`, ` - bedrock:InvokeModel`, ` - bedrock:InvokeModelWithResponseStream`, + ` - s3:GetObject`, + ` - s3:PutObject`, + ` - logs:CreateLogGroup`, + ` - logs:CreateLogStream`, + ` - logs:PutLogEvents`, ` Resource: '*'`, `` ); return this; } + addSecretsManager(): this { + this.sections.push( + ` # Secrets Manager for agent configuration`, + ` AgentSecrets:`, + ` Type: AWS::SecretsManager::Secret`, + ` Properties:`, + ` Name: !Sub '\${AgentName}-\${Environment}-secrets'`, + ` Description: !Sub 'Secrets for \${AgentName} agent'`, + `` + ); + return this; + } + addOutputs(): this { this.sections.push( `Outputs:`, - ` RepositoryUri:`, - ` Description: ECR Repository URI`, - ` Value: !GetAtt AgentRepository.RepositoryUri`, + ` AgentCoreRoleArn:`, + ` Description: AgentCore Execution Role ARN`, + ` Value: !GetAtt AgentCoreExecutionRole.Arn`, ` Export:`, - ` Name: !Sub '\${AWS::StackName}-RepositoryUri'`, + ` Name: !Sub '\${AWS::StackName}-RoleArn'`, ``, - ` ClusterName:`, - ` Description: ECS Cluster Name`, - ` Value: !Ref AgentCluster`, + ` ArtifactsBucketName:`, + ` Description: S3 Bucket for agent artifacts`, + ` Value: !Ref AgentArtifactsBucket`, ` Export:`, - ` Name: !Sub '\${AWS::StackName}-ClusterName'`, + ` Name: !Sub '\${AWS::StackName}-ArtifactsBucket'`, ``, - ` TaskDefinitionArn:`, - ` Description: Task Definition ARN`, - ` Value: !Ref AgentTaskDefinition`, + ` LogGroupName:`, + ` Description: CloudWatch Log Group`, + ` Value: !Ref AgentLogGroup`, ` Export:`, - ` Name: !Sub '\${AWS::StackName}-TaskDefinitionArn'` + ` Name: !Sub '\${AWS::StackName}-LogGroup'` ); return this; } @@ -220,11 +169,10 @@ export function generateCloudFormationTemplate(agent: Agent): string { return new CloudFormationBuilder() .addHeader(agent) .addParameters(agent) - .addECRRepository() - .addECSCluster() - .addTaskDefinition() + .addS3Bucket() .addCloudWatchLogs() .addIAMRoles() + .addSecretsManager() .addOutputs() .build(); } diff --git a/convex/lib/fileGenerators.ts b/convex/lib/fileGenerators.ts index 93d6464..8b79dbd 100644 --- a/convex/lib/fileGenerators.ts +++ b/convex/lib/fileGenerators.ts @@ -465,25 +465,7 @@ chmod +x lambda_deploy.sh - Lambda execution role with Bedrock permissions - Python 3.11+ -### Option 2: ECS Fargate (Containers) - -Best for: Always-on agents, high traffic - -\`\`\`bash -# Deploy using CloudFormation -aws cloudformation create-stack --stack-name ${sanitizedName} --template-body file://cloudformation.yaml --capabilities CAPABILITY_IAM - -# Or use CLI script -chmod +x deploy.sh -./deploy.sh -\`\`\` - -**Requirements:** -- Docker installed -- AWS CLI configured -- ECR repository access - -### Option 3: Bedrock AgentCore (Managed) +### Option 2: Bedrock AgentCore (Managed) Best for: Production agents, enterprise use @@ -502,7 +484,7 @@ chmod +x deploy_agentcore.sh - S3 bucket for agent packages - AgentCore execution role -### Option 4: Local Docker +### Option 3: Local Docker Best for: Development and testing @@ -535,7 +517,6 @@ To change the model, update the \`MODEL_ID\` environment variable or modify \`${ All deployments log to CloudWatch: - Lambda: \`/aws/lambda/${sanitizedName}-lambda\` -- Fargate: \`/ecs/${sanitizedName}\` - AgentCore: \`/aws/bedrock/agentcore/${sanitizedName}\` `; diff --git a/convex/lib/toolDispatch.ts b/convex/lib/toolDispatch.ts index 2b58ade..f234a86 100644 --- a/convex/lib/toolDispatch.ts +++ b/convex/lib/toolDispatch.ts @@ -242,7 +242,7 @@ export async function dispatchToolCall( case "code": { // Code skills provide their implementation as tool output. // The code is stored in skillConfig and returned for the agent to use/incorporate. - // Full ECS execution requires a testExecution record (use the Agent Tester for that). + // Full execution requires a testExecution record (use the Agent Tester for that). const codeConfig = skill.skillConfig as { code?: string; language?: string }; const code = codeConfig?.code || ""; if ( !code ) { @@ -300,7 +300,7 @@ export async function dispatchToolCall( } case "sandbox": { - // Sandbox execution: Docker via ECS, E2B is a future placeholder + // Sandbox execution: Docker container or E2B (future placeholder) const sandboxConfig = skill.skillConfig as { runtime?: "docker" | "e2b"; command?: string; diff --git a/convex/mcpClient.ts b/convex/mcpClient.ts index 3281de4..cdc3bb3 100644 --- a/convex/mcpClient.ts +++ b/convex/mcpClient.ts @@ -470,8 +470,10 @@ async function invokeMCPToolWithRetry( /** * Direct MCP tool invocation * - * For Bedrock AgentCore: Uses Bedrock Runtime API directly - * For other MCP servers: Uses MCP SDK with stdio transport + * Transport routing: + * - "direct" or Bedrock AgentCore → Direct API calls (no MCP protocol overhead) + * - "sse" or "http" → MCP SDK with SSE/HTTP transport (cloud-compatible) + * - "stdio" or undefined → MCP SDK with stdio transport (local dev only) */ async function invokeMCPToolDirect( server: any, @@ -479,19 +481,160 @@ async function invokeMCPToolDirect( parameters: any, timeout: number ): Promise { - // Special handling for Bedrock AgentCore + // Special handling for Bedrock AgentCore (always direct) if ( server.name === "bedrock-agentcore-mcp-server" || toolName === "execute_agent" ) { return await invokeBedrockAgentCore( parameters, timeout ); } - // For other MCP servers, use MCP SDK + const transportType: string = server.transportType || "stdio"; + + // Direct API calls — bypass MCP protocol entirely for built-in servers + if ( transportType === "direct" ) { + return await invokeDirectAPI( server, toolName, parameters, timeout ); + } + + // SSE/HTTP transport — cloud-compatible, no subprocess spawning + if ( transportType === "sse" || transportType === "http" ) { + return await invokeMCPViaHTTP( server, toolName, parameters, timeout ); + } + + // Stdio transport — spawns subprocess, local dev only + return await invokeMCPViaStdio( server, toolName, parameters, timeout ); +} + +/** + * Invoke a built-in MCP server via direct HTTP/API calls (no MCP protocol) + */ +async function invokeDirectAPI( + server: any, + toolName: string, + parameters: any, + timeout: number +): Promise { + const controller = new AbortController(); + const timer = setTimeout( () => controller.abort(), timeout ); + + try { + // Ollama: Direct REST API + if ( server.name === "ollama-mcp-server" ) { + const ollamaHost = server.env?.OLLAMA_HOST || "http://127.0.0.1:11434"; + if ( toolName === "chat_completion" ) { + const resp = await fetch( `${ollamaHost}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( { model: parameters.model, messages: parameters.messages, stream: false } ), + signal: controller.signal, + } ); + if ( !resp.ok ) throw new Error( `Ollama API error: ${resp.status} ${resp.statusText}` ); + return await resp.json(); + } + if ( toolName === "list" ) { + const resp = await fetch( `${ollamaHost}/api/tags`, { signal: controller.signal } ); + if ( !resp.ok ) throw new Error( `Ollama API error: ${resp.status} ${resp.statusText}` ); + return await resp.json(); + } + if ( toolName === "show" ) { + const resp = await fetch( `${ollamaHost}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( { name: parameters.name || parameters.model } ), + signal: controller.signal, + } ); + if ( !resp.ok ) throw new Error( `Ollama API error: ${resp.status} ${resp.statusText}` ); + return await resp.json(); + } + throw new Error( `Unsupported Ollama tool: ${toolName}` ); + } + + // Document Fetcher: Direct fetch + HTML cleanup + if ( server.name === "document-fetcher-mcp-server" ) { + if ( toolName === "fetch_url" ) { + const resp = await fetch( parameters.url, { signal: controller.signal } ); + if ( !resp.ok ) throw new Error( `Fetch error: ${resp.status} ${resp.statusText}` ); + const html = await resp.text(); + // Basic HTML → text cleanup (strip tags) + const text = html.replace( /]*>[\s\S]*?<\/script>/gi, "" ) + .replace( /]*>[\s\S]*?<\/style>/gi, "" ) + .replace( /<[^>]+>/g, " " ) + .replace( /\s+/g, " " ) + .trim(); + return { content: text.slice( 0, 50000 ) }; // Cap at 50KB + } + throw new Error( `Unsupported document-fetcher tool: ${toolName}` ); + } + + throw new Error( `No direct API handler for server "${server.name}" tool "${toolName}"` ); + } finally { + clearTimeout( timer ); + } +} + +/** + * Invoke MCP server via SSE/HTTP transport (cloud-compatible, no subprocess) + */ +async function invokeMCPViaHTTP( + server: any, + toolName: string, + parameters: any, + timeout: number +): Promise { + if ( !server.url ) { + throw new Error( `MCP server "${server.name}" has transport "${server.transportType}" but no url configured` ); + } + + const { Client } = await import( "@modelcontextprotocol/sdk/client/index.js" ); + const { SSEClientTransport } = await import( "@modelcontextprotocol/sdk/client/sse.js" ); + + let client: any = null; + + try { + const transport = new SSEClientTransport( new URL( server.url ) ); + + client = new Client( + { name: "agent-builder-convex", version: "1.0.0" }, + { capabilities: {} }, + ); + + await Promise.race( [ + client.connect( transport ), + new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( "MCP SSE connection timeout" ) ), timeout ) + ), + ] ); + + const result = await Promise.race( [ + client.callTool( { name: toolName, arguments: parameters } ), + new Promise( ( _, reject ) => + setTimeout( () => reject( new Error( "MCP tool invocation timeout" ) ), timeout ) + ), + ] ); + + return result.content?.[0]?.text || result; + } catch ( error: any ) { + console.error( `MCP SSE invocation error (${server.name}/${toolName}):`, error ); + throw error; + } finally { + if ( client ) { + try { await client.close(); } catch { /* ignore close errors */ } + } + } +} + +/** + * Invoke MCP server via stdio transport (local dev only — spawns subprocess) + */ +async function invokeMCPViaStdio( + server: any, + toolName: string, + parameters: any, + timeout: number +): Promise { const { Client } = await import( "@modelcontextprotocol/sdk/client/index.js" ); const { StdioClientTransport } = await import( "@modelcontextprotocol/sdk/client/stdio.js" ); let client: any = null; try { - // Create stdio transport for the MCP server const transport = new StdioClientTransport( { command: server.command, args: server.args || [], @@ -501,18 +644,11 @@ async function invokeMCPToolDirect( ), } ); - // Create MCP client client = new Client( - { - name: "agent-builder-convex", - version: "1.0.0", - }, - { - capabilities: {}, - } + { name: "agent-builder-convex", version: "1.0.0" }, + { capabilities: {} }, ); - // Connect to the server with timeout await Promise.race( [ client.connect( transport ), new Promise( ( _, reject ) => @@ -520,10 +656,7 @@ async function invokeMCPToolDirect( ), ] ); - // List available tools const toolsList = await client.listTools(); - - // Find the requested tool const tool = toolsList.tools.find( ( t: any ) => t.name === toolName ); if ( !tool ) { throw new Error( @@ -531,30 +664,20 @@ async function invokeMCPToolDirect( ); } - // Call the tool with timeout const result = await Promise.race( [ - client.callTool( { - name: toolName, - arguments: parameters, - } ), + client.callTool( { name: toolName, arguments: parameters } ), new Promise( ( _, reject ) => setTimeout( () => reject( new Error( "MCP tool invocation timeout" ) ), timeout ) ), ] ); - // Return the result content return result.content?.[0]?.text || result; } catch ( error: any ) { - console.error( `MCP invocation error (${server.name}/${toolName}):`, error ); + console.error( `MCP stdio invocation error (${server.name}/${toolName}):`, error ); throw error; } finally { - // Clean up: close the client connection if ( client ) { - try { - await client.close(); - } catch ( closeError ) { - console.error( "Error closing MCP client:", closeError ); - } + try { await client.close(); } catch { /* ignore close errors */ } } } } @@ -819,29 +942,42 @@ export const testMCPServerConnection = action( { } // For other MCP servers, connect and list tools - const { Client } = await import( "@modelcontextprotocol/sdk/client/index.js" ); - const { StdioClientTransport } = await import( "@modelcontextprotocol/sdk/client/stdio.js" ); + const transportType: string = server.transportType || "stdio"; + + // Direct API servers — return their hardcoded tool list + if ( transportType === "direct" ) { + return { + success: true, + status: "connected", + tools: server.availableTools || [], + }; + } + const { Client } = await import( "@modelcontextprotocol/sdk/client/index.js" ); let client: any = null; + let transport: any = null; try { - const transport = new StdioClientTransport( { - command: server.command, - args: server.args || [], - env: Object.fromEntries( - Object.entries( { ...process.env, ...( server.env || {} ) } ) - .filter( ( entry ): entry is [string, string] => entry[1] !== undefined ), - ), - } ); + if ( transportType === "sse" || transportType === "http" ) { + if ( !server.url ) throw new Error( "SSE/HTTP server missing url" ); + const { SSEClientTransport } = await import( "@modelcontextprotocol/sdk/client/sse.js" ); + transport = new SSEClientTransport( new URL( server.url ) ); + } else { + // stdio transport + const { StdioClientTransport } = await import( "@modelcontextprotocol/sdk/client/stdio.js" ); + transport = new StdioClientTransport( { + command: server.command, + args: server.args || [], + env: Object.fromEntries( + Object.entries( { ...process.env, ...( server.env || {} ) } ) + .filter( ( entry ): entry is [string, string] => entry[1] !== undefined ), + ), + } ); + } client = new Client( - { - name: "agent-builder-convex", - version: "1.0.0", - }, - { - capabilities: {}, - } + { name: "agent-builder-convex", version: "1.0.0" }, + { capabilities: {} }, ); // Connect with 10 second timeout @@ -852,7 +988,6 @@ export const testMCPServerConnection = action( { ), ] ); - // List available tools const toolsList = await client.listTools(); return { @@ -866,11 +1001,7 @@ export const testMCPServerConnection = action( { }; } finally { if ( client ) { - try { - await client.close(); - } catch ( closeError ) { - console.error( "Error closing test client:", closeError ); - } + try { await client.close(); } catch { /* ignore close errors */ } } } } catch ( error: any ) { diff --git a/convex/mcpConfig.ts b/convex/mcpConfig.ts index 26f7fc9..32c2cb5 100644 --- a/convex/mcpConfig.ts +++ b/convex/mcpConfig.ts @@ -26,12 +26,14 @@ interface BuiltInMcpServer { _creationTime: number; name: string; userId: string; // "system" — not a real user ID - command: string; - args: string[]; - env: Record; + command?: string; // Required for stdio transport; omit for sse/http/direct + args?: string[]; // Required for stdio transport; omit for sse/http/direct + env?: Record; disabled: boolean; timeout: number; status: string; + url?: string; // Required for sse/http transport + transportType: "stdio" | "sse" | "http" | "direct"; // How to connect to this server availableTools: Array<{ name: string; description: string }>; createdAt: number; updatedAt: number; @@ -43,13 +45,15 @@ interface DbMcpServer { _creationTime: number; name: string; userId: Id<"users">; - command: string; - args: string[]; - env: Record; + command?: string; // Required for stdio transport; omit for sse/http + args?: string[]; // Required for stdio transport; omit for sse/http + env?: Record; disabled: boolean; - timeout: number; + timeout?: number; status: string; - availableTools: Array<{ name: string; description: string }>; + url?: string; // Required for sse/http transport + transportType?: string; // "stdio" | "sse" | "http" — defaults to "stdio" + availableTools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; createdAt: number; updatedAt: number; } @@ -63,12 +67,10 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ _creationTime: Date.now(), name: "bedrock-agentcore-mcp-server", userId: "system", - command: "bedrock-agentcore", - args: [], - env: {}, disabled: false, timeout: 60000, status: "connected", + transportType: "direct", // Calls Bedrock API directly — no MCP protocol availableTools: [ { name: "execute_agent", description: "Execute a strands-agents agent" }, ], @@ -82,12 +84,10 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ _creationTime: Date.now(), name: "document-fetcher-mcp-server", userId: "system", - command: "uvx", - args: ["mcp-document-fetcher"], - env: {}, disabled: false, timeout: 30000, status: "connected", + transportType: "direct", // Direct fetch() calls — no MCP subprocess availableTools: [ { name: "fetch_url", description: "Fetch and clean a web page" }, { name: "parse_llms_txt", description: "Parse an llms.txt file and extract links" }, @@ -102,12 +102,10 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ _creationTime: Date.now(), name: "aws-diagram-mcp-server", userId: "system", - command: "uvx", - args: ["awslabs.aws-diagram-mcp-server@latest"], - env: {}, - disabled: false, + disabled: true, // Disabled: requires subprocess (uvx) — needs SSE server or reimplementation timeout: 30000, - status: "connected", + status: "disconnected", + transportType: "direct", availableTools: [ { name: "create_diagram", description: "Create AWS architecture diagram" }, { name: "get_resources", description: "Get AWS resources from a region" }, @@ -121,20 +119,17 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ _creationTime: Date.now(), name: "ollama-mcp-server", userId: "system", - command: "node", - args: [process.env.OLLAMA_MCP_PATH || ""], env: { - OLLAMA_HOST: "http://127.0.0.1:11434" + OLLAMA_HOST: "http://127.0.0.1:11434", }, disabled: false, timeout: 60000, status: "connected", + transportType: "direct", // Direct Ollama REST API — no MCP subprocess availableTools: [ { name: "chat_completion", description: "Chat with Ollama models" }, { name: "list", description: "List available Ollama models" }, - { name: "pull", description: "Pull an Ollama model" }, { name: "show", description: "Show model information" }, - { name: "serve", description: "Serve Ollama model" }, ], createdAt: Date.now(), updatedAt: Date.now(), @@ -146,12 +141,10 @@ const BUILT_IN_MCP_SERVERS: BuiltInMcpServer[] = [ _creationTime: Date.now(), name: "task-master-ai", userId: "system", - command: "npx", - args: ["task-master-ai", "mcp"], - env: {}, - disabled: false, + disabled: true, // Disabled: requires subprocess (npx) — needs SSE server or reimplementation timeout: 30000, - status: "connected", + status: "disconnected", + transportType: "direct", availableTools: [ { name: "parse_prd", description: "Parse a PRD document into structured tasks" }, { name: "next_task", description: "Get the next task to work on based on priority and dependencies" }, @@ -286,11 +279,13 @@ export const getMCPServerByNameInternal = internalQuery( { export const addMCPServer = mutation( { args: { name: v.string(), - command: v.string(), - args: v.array( v.string() ), + command: v.optional( v.string() ), // Required for stdio transport + args: v.optional( v.array( v.string() ) ), // Required for stdio transport env: v.optional( v.object( {} ) ), disabled: v.optional( v.boolean() ), timeout: v.optional( v.number() ), + url: v.optional( v.string() ), // Required for sse/http transport + transportType: v.optional( v.string() ), // "stdio" | "sse" | "http" — defaults to "stdio" }, handler: async ( ctx, args ) => { // Get Convex user document ID @@ -319,6 +314,18 @@ export const addMCPServer = mutation( { ); } + // Validate transport-specific required fields + const transport = args.transportType || "stdio"; + if ( transport === "sse" || transport === "http" ) { + if ( !args.url ) { + throw new Error( `Transport "${transport}" requires a url field` ); + } + } else if ( transport === "stdio" ) { + if ( !args.command ) { + throw new Error( "Transport \"stdio\" requires a command field" ); + } + } + // Create new MCP server const serverId = await ctx.db.insert( "mcpServers", { name: args.name, @@ -328,6 +335,8 @@ export const addMCPServer = mutation( { env: args.env, disabled: args.disabled ?? false, timeout: args.timeout, + url: args.url, + transportType: args.transportType, status: "unknown", createdAt: Date.now(), updatedAt: Date.now(), @@ -350,6 +359,8 @@ export const updateMCPServer = mutation( { env: v.optional( v.object( {} ) ), disabled: v.optional( v.boolean() ), timeout: v.optional( v.number() ), + url: v.optional( v.string() ), + transportType: v.optional( v.string() ), } ), }, handler: async ( ctx, args ) => { diff --git a/convex/platformValue.ts b/convex/platformValue.ts index cb74cb7..2aa7b70 100644 --- a/convex/platformValue.ts +++ b/convex/platformValue.ts @@ -27,7 +27,7 @@ export const calculatePlatformValue = query({ included: { infrastructure: { value: 2000, - items: ["VPC setup", "ECS Fargate config", "ALB", "Security groups", "IAM roles"] + items: ["Bedrock AgentCore", "S3 storage", "CloudWatch logs", "IAM roles", "Secrets Manager"] }, memory: { value: 1500, diff --git a/convex/realAgentTesting.ts b/convex/realAgentTesting.ts index 0b8cfc7..754bc00 100644 --- a/convex/realAgentTesting.ts +++ b/convex/realAgentTesting.ts @@ -45,7 +45,7 @@ export const createTestContainer = action({ const containerId = `agent-test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // In real implementation, this would: - // 1. Create ECS Fargate task with the container + // 1. Create AgentCore runtime with the agent package // 2. Set up chat endpoint with strandsagents conversation manager // 3. Install all dependencies (agentcore, strandsagents, tools) // 4. Start the agent with @agent decorator @@ -502,8 +502,8 @@ if __name__ == "__main__": async function startTestContainer(containerId: string, config: any): Promise { // In real implementation, this would: - // 1. Create ECS Fargate task with the container configuration - // 2. Wait for container to be ready + // 1. Create AgentCore runtime with the agent configuration + // 2. Wait for runtime to be ready // 3. Return the chat interface URL // For now, return a mock URL @@ -511,7 +511,7 @@ async function startTestContainer(containerId: string, config: any): Promise { - // Mock implementation - in reality would check ECS task status + // Mock implementation - in reality would check AgentCore runtime status return { status: "running", chatUrl: `http://localhost:8000/chat/${containerId}`, @@ -536,8 +536,8 @@ function generateDeploymentPackage(args: any) { async function deployToBedrock(deploymentPackage: any, awsCredentials: any): Promise { // In real implementation, this would: - // 1. Build container image with agent - // 2. Push to ECR + // 1. Package agent code and dependencies + // 2. Upload to S3 // 3. Create Bedrock AgentCore deployment // 4. Return deployment details @@ -548,6 +548,5 @@ async function deployToBedrock(deploymentPackage: any, awsCredentials: any): Pro } async function cleanupContainer(containerId: string): Promise { - // In real implementation, this would stop and remove the ECS task - // Container cleanup — in real implementation would stop/remove ECS task + // In real implementation, this would stop and cleanup the AgentCore runtime } \ No newline at end of file diff --git a/convex/schema.ts b/convex/schema.ts index 1ccfd45..efb37e9 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -207,10 +207,12 @@ const applicationTables = { // MCP Configuration mcpServers: v.optional( v.array( v.object( { name: v.string(), - command: v.string(), - args: v.array( v.string() ), + command: v.optional( v.string() ), // Required for stdio transport; omit for sse/http + args: v.optional( v.array( v.string() ) ), // Required for stdio transport; omit for sse/http env: v.optional( v.record( v.string(), v.string() ) ), // MCP server environment variables (e.g. { "PATH": "/usr/bin" }) disabled: v.optional( v.boolean() ), + url: v.optional( v.string() ), // Required for sse/http transport (e.g. "https://mcp.example.com/sse") + transportType: v.optional( v.string() ), // "stdio" | "sse" | "http" | "direct" — defaults to "stdio" } ) ) ), // Dynamic Tools (Meta-tooling) @@ -461,11 +463,13 @@ const applicationTables = { userId: v.id( "users" ), // Server Configuration - command: v.string(), - args: v.array( v.string() ), + command: v.optional( v.string() ), // Required for stdio transport; omit for sse/http + args: v.optional( v.array( v.string() ) ), // Required for stdio transport; omit for sse/http env: v.optional( v.record( v.string(), v.string() ) ), // MCP server environment variables (e.g. { "PATH": "/usr/bin" }) disabled: v.boolean(), timeout: v.optional( v.number() ), // Timeout in milliseconds + url: v.optional( v.string() ), // Required for sse/http transport (e.g. "https://mcp.example.com/sse") + transportType: v.optional( v.string() ), // "stdio" | "sse" | "http" | "direct" — defaults to "stdio" // Connection Status status: v.string(), // "connected" | "disconnected" | "error" | "unknown" diff --git a/convex/strandsAgentExecution.ts b/convex/strandsAgentExecution.ts index a095741..30f13c9 100644 --- a/convex/strandsAgentExecution.ts +++ b/convex/strandsAgentExecution.ts @@ -191,7 +191,7 @@ async function executeViaAgentCore( if ( skillRef.skillId ) { // Load from dynamicTools table const skillDoc = await ctx.runQuery( internal.metaTooling.getSkillById, { - skillId: skillRef.skillId, + skillId: skillRef.skillId as import( "./_generated/dataModel" ).Id<"dynamicTools">, } ); if ( skillDoc?.skillType && skillDoc?.toolDefinition ) { agentSkills.push( { @@ -206,7 +206,7 @@ async function executeViaAgentCore( } } - const thinkingLevel = agentDoc.thinkingLevel as "low" | "medium" | "high" | undefined; + const thinkingLevel = (agent as unknown as { thinkingLevel?: string }).thinkingLevel as "low" | "medium" | "high" | undefined; return await executeDirectBedrock( ctx, agent, message, history, agentSkills, thinkingLevel ); } diff --git a/package.json b/package.json index 3cfc10e..31c7302 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,6 @@ "@aws-sdk/client-cloudformation": "^3.913.0", "@aws-sdk/client-cloudwatch-logs": "^3.701.0", "@aws-sdk/client-cognito-identity-provider": "^3.914.0", - "@aws-sdk/client-ec2": "^3.913.0", - "@aws-sdk/client-ecr": "^3.913.0", - "@aws-sdk/client-ecs": "^3.701.0", "@aws-sdk/client-lambda": "^3.913.0", "@aws-sdk/client-polly": "^3.917.0", "@aws-sdk/client-s3": "^3.917.0", diff --git a/src/components/AWSAuthModal.tsx b/src/components/AWSAuthModal.tsx index 4f32a56..5b6c945 100644 --- a/src/components/AWSAuthModal.tsx +++ b/src/components/AWSAuthModal.tsx @@ -122,8 +122,7 @@ export function AWSAuthModal({ isOpen, onClose, onSuccess }: AWSAuthModalProps)
  • Attach permissions policy: PowerUserAccess or create custom policy with:
      -
    • ECS full access
    • -
    • ECR full access
    • +
    • Bedrock full access
    • CloudFormation full access
    • IAM limited (for service roles)
    diff --git a/src/components/AWSRoleSetup.tsx b/src/components/AWSRoleSetup.tsx index 45fe0cc..c482606 100644 --- a/src/components/AWSRoleSetup.tsx +++ b/src/components/AWSRoleSetup.tsx @@ -84,19 +84,13 @@ export function AWSRoleSetup() { Sid: "AgentCoreDeployment", Effect: "Allow", Action: [ - "ecr:CreateRepository", - "ecr:PutImage", - "ecr:InitiateLayerUpload", - "ecr:UploadLayerPart", - "ecr:CompleteLayerUpload", - "ecr:BatchCheckLayerAvailability", - "ecr:GetAuthorizationToken", - "ecs:CreateCluster", - "ecs:CreateService", - "ecs:RegisterTaskDefinition", - "ecs:UpdateService", - "ecs:DescribeServices", - "ecs:DescribeTasks", + "bedrock:CreateAgent", + "bedrock:CreateAgentActionGroup", + "bedrock:InvokeAgent", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock:GetAgent", + "bedrock:ListAgents", "iam:PassRole", "logs:CreateLogGroup", "logs:CreateLogStream", diff --git a/src/components/AgentBuilder.tsx b/src/components/AgentBuilder.tsx index 3633d9c..4b7f4aa 100644 --- a/src/components/AgentBuilder.tsx +++ b/src/components/AgentBuilder.tsx @@ -249,7 +249,7 @@ export function AgentBuilder() { return () => { cancelled = true; }; - }, [automationData, executeWorkflow, lastAutomationStamp, setWorkflowResult] ); + }, [automationData, executeWorkflow, lastAutomationStamp, setWorkflowResult, workflowResult] ); useEffect( () => { if ( workflowResult ) { @@ -260,7 +260,7 @@ export function AgentBuilder() { } }, [workflowResult, automationStatus] ); - const handleAutoGenerateFromPlan = useCallback( async () => { + const autoGenerateFromPlan = useCallback( async () => { if ( !automationSummary ) { toast.error( "No automation plan available yet" ); return; @@ -280,7 +280,7 @@ export function AgentBuilder() { const finalOutput = automationSummary.finalOutput || ""; - const nameMatch = requirementsOutput.match( /Agent Name[:\-]\s*(.+)/i ); + const nameMatch = requirementsOutput.match( /Agent Name[:-]\s*(.+)/i ); let derivedName = nameMatch ? nameMatch[1].trim() : ""; if ( !derivedName ) { derivedName = `Automated Agent ${new Date().toLocaleTimeString()}`; @@ -325,7 +325,7 @@ export function AgentBuilder() { setRequirementsTxt( response.requirementsTxt || "" ); toast.success( "Agent generated from automation plan" ); setCurrentStep( 4 ); - } catch ( error ) { + } catch ( error: unknown ) { console.error( error ); toast.error( "Automatic generation failed", { description: error instanceof Error ? error.message : undefined, @@ -334,7 +334,11 @@ export function AgentBuilder() { setIsGenerating( false ); setIsAutoGenerating( false ); } - }, [automationSummary, automationData, config.tools, generateAgent] ); + }, [automationSummary, automationData, config.model, config.tools, generateAgent] ); + + const handleAutoGenerateFromPlan = useCallback( () => { + void autoGenerateFromPlan(); + }, [autoGenerateFromPlan] ); const handleExportConfig = useCallback( () => { const payload = { @@ -360,10 +364,7 @@ export function AgentBuilder() { fileInputRef.current?.click(); }, [] ); - const handleImportFileSelected = useCallback( async ( event: ChangeEvent ) => { - const file = event.target.files?.[0]; - if ( !file ) return; - + const importFile = useCallback( async ( file: File ) => { try { const text = await file.text(); const data = JSON.parse( text ); @@ -390,15 +391,24 @@ export function AgentBuilder() { setSavedAgentId( null ); toast.success( "Builder state imported" ); - } catch ( error: any ) { + } catch ( error: unknown ) { + const message = error instanceof Error ? error.message : String( error ); toast.error( "Failed to import configuration", { - description: error?.message, + description: message, } ); - } finally { - event.target.value = ""; } }, [setConfig, setWorkflowResult] ); + const handleImportFileSelected = useCallback( + ( event: ChangeEvent ) => { + const file = event.target.files?.[0]; + if ( !file ) return; + event.target.value = ""; + void importFile( file ); + }, + [importFile] + ); + const handleDownload = async () => { // Allow download even without saving - generate package on the fly const agentToDownload = savedAgentId || { @@ -490,7 +500,7 @@ export function AgentBuilder() { type="file" accept="application/json" className="hidden" - onChange={handleImportFileSelected} + onChange={(e) => { handleImportFileSelected(e); }} />
    diff --git a/src/components/ArchitecturePreview.test.tsx b/src/components/ArchitecturePreview.test.tsx index a88764e..9c55508 100644 --- a/src/components/ArchitecturePreview.test.tsx +++ b/src/components/ArchitecturePreview.test.tsx @@ -2,33 +2,25 @@ import { describe, it, expect } from "vitest"; /** * Test suite for ArchitecturePreview tier detection logic - * + * * This tests the determineDeploymentTier function which is the core * logic for determining which AWS tier to use based on deployment type and model. + * + * Tier architecture (post Fargate/ECS removal): + * - Freemium: Local (Docker/Ollama) + limited AgentCore via our account + * - Personal: Full AgentCore in user's own AWS account (via assume-role) + * - Enterprise: Same as personal + SSO (future) */ // Extract the tier determination logic for testing -function determineDeploymentTier(deploymentType: string, model: string): string { - // Personal: AWS (Fargate) - Docker, Ollama, or custom models - // Check these first as they take priority - if (deploymentType === "docker" || deploymentType === "ollama") { - return "personal"; +function determineDeploymentTier(deploymentType: string, _model: string): string { + // Freemium: Local (Docker/Ollama) or limited AgentCore via our account + if (deploymentType === "docker" || deploymentType === "ollama" || deploymentType === "local") { + return "freemium"; } - // Freemium: AgentCore - Bedrock models only - // Bedrock models can be identified by: - // 1. Containing "bedrock" in the name - // 2. Using AWS Bedrock provider prefixes (anthropic., amazon., meta., cohere., ai21., mistral.) - if (deploymentType === "aws") { - const bedrockProviders = ["anthropic.", "amazon.", "meta.", "cohere.", "ai21.", "mistral."]; - const isBedrockModel = model.includes("bedrock") || - bedrockProviders.some(provider => model.startsWith(provider)); - - if (isBedrockModel) { - return "freemium"; - } - - // Non-Bedrock AWS deployments use Fargate (personal tier) + // Personal: AgentCore in user's own AWS account (via assume-role) + if (deploymentType === "aws" || deploymentType === "agentcore") { return "personal"; } @@ -37,61 +29,56 @@ function determineDeploymentTier(deploymentType: string, model: string): string } describe("ArchitecturePreview - Tier Detection", () => { - describe("Freemium (AgentCore)", () => { - it("should return tier1 for AWS deployment with Bedrock model", () => { - const tier = determineDeploymentTier("aws", "bedrock-claude-v3"); - expect(tier).toBe("freemium"); - }); - - it("should return tier1 for AWS deployment with anthropic.claude-3-5-sonnet model", () => { - const tier = determineDeploymentTier("aws", "anthropic.claude-3-5-sonnet-20240620-v1:0"); + describe("Freemium (Local + AgentCore)", () => { + it("should return freemium for docker deployment type", () => { + const tier = determineDeploymentTier("docker", "llama-3.1-70b"); expect(tier).toBe("freemium"); }); - it("should return tier1 for AWS deployment with any bedrock model variant", () => { - const tier = determineDeploymentTier("aws", "amazon.titan-text-express-v1:bedrock"); + it("should return freemium for ollama deployment type", () => { + const tier = determineDeploymentTier("ollama", "llama3.2"); expect(tier).toBe("freemium"); }); - it("should return tier1 for local deployment type (default)", () => { + it("should return freemium for local deployment type", () => { const tier = determineDeploymentTier("local", "gpt-4"); expect(tier).toBe("freemium"); }); - it("should return tier1 for unknown deployment type (default)", () => { + it("should return freemium for unknown deployment type (default)", () => { const tier = determineDeploymentTier("unknown", "some-model"); expect(tier).toBe("freemium"); }); - }); - describe("Personal (AWS Fargate)", () => { - it("should return tier2 for docker deployment type", () => { - const tier = determineDeploymentTier("docker", "llama-3.1-70b"); - expect(tier).toBe("personal"); + it("should return freemium for docker deployment regardless of model", () => { + const tier = determineDeploymentTier("docker", "anthropic.claude-3-5-sonnet-20240620-v1:0"); + expect(tier).toBe("freemium"); }); - it("should return tier2 for ollama deployment type", () => { - const tier = determineDeploymentTier("ollama", "llama3.2"); - expect(tier).toBe("personal"); + it("should return freemium for ollama deployment with Bedrock-named model", () => { + const tier = determineDeploymentTier("ollama", "bedrock-clone"); + expect(tier).toBe("freemium"); }); + }); - it("should return tier2 for AWS deployment with non-Bedrock model", () => { - const tier = determineDeploymentTier("aws", "gpt-4"); + describe("Personal (AgentCore in user's AWS)", () => { + it("should return personal for aws deployment type", () => { + const tier = determineDeploymentTier("aws", "anthropic.claude-3-5-sonnet-20240620-v1:0"); expect(tier).toBe("personal"); }); - it("should return tier2 for AWS deployment with OpenAI model", () => { - const tier = determineDeploymentTier("aws", "openai/gpt-4-turbo"); + it("should return personal for agentcore deployment type", () => { + const tier = determineDeploymentTier("agentcore", "anthropic.claude-3-5-sonnet-20240620-v1:0"); expect(tier).toBe("personal"); }); - it("should return tier2 for docker deployment with any model", () => { - const tier = determineDeploymentTier("docker", "custom-model-v1"); + it("should return personal for AWS deployment with any model", () => { + const tier = determineDeploymentTier("aws", "gpt-4"); expect(tier).toBe("personal"); }); - it("should return tier2 for ollama deployment with Bedrock-named model (deployment type takes precedence)", () => { - const tier = determineDeploymentTier("ollama", "bedrock-clone"); + it("should return personal for agentcore deployment with any model", () => { + const tier = determineDeploymentTier("agentcore", "custom-model-v1"); expect(tier).toBe("personal"); }); }); @@ -107,88 +94,68 @@ describe("ArchitecturePreview - Tier Detection", () => { expect(tier).toBe("personal"); }); - it("should handle case-sensitive bedrock check", () => { - const tier = determineDeploymentTier("aws", "Bedrock-Model"); - expect(tier).toBe("personal"); // Should not match due to case sensitivity - }); - - it("should handle bedrock substring in model name", () => { - const tier = determineDeploymentTier("aws", "my-bedrock-custom-model"); - expect(tier).toBe("freemium"); // Should match because it contains "bedrock" - }); - it("should handle AWS deployment type with different casing", () => { const tier = determineDeploymentTier("AWS", "bedrock-model"); - expect(tier).toBe("freemium"); // Falls through to default (freemium) since "AWS" !== "aws" + expect(tier).toBe("freemium"); // Falls through to default since "AWS" !== "aws" }); }); describe("Real-world Model Examples", () => { - it("should correctly classify Claude 3.5 Sonnet on Bedrock", () => { + it("should correctly classify Claude on Bedrock (aws deployment)", () => { const tier = determineDeploymentTier("aws", "anthropic.claude-3-5-sonnet-20240620-v1:0"); - expect(tier).toBe("freemium"); + expect(tier).toBe("personal"); }); - it("should correctly classify Claude 3 Haiku on Bedrock", () => { - const tier = determineDeploymentTier("aws", "anthropic.claude-3-haiku-20240307-v1:0"); - expect(tier).toBe("freemium"); + it("should correctly classify Amazon Titan on Bedrock (aws deployment)", () => { + const tier = determineDeploymentTier("aws", "amazon.titan-text-express-v1"); + expect(tier).toBe("personal"); }); it("should correctly classify Llama 3.1 on Ollama", () => { const tier = determineDeploymentTier("ollama", "llama3.1:70b"); - expect(tier).toBe("personal"); + expect(tier).toBe("freemium"); }); it("should correctly classify custom Docker model", () => { const tier = determineDeploymentTier("docker", "my-custom-model:latest"); - expect(tier).toBe("personal"); - }); - - it("should correctly classify Amazon Titan on Bedrock", () => { - const tier = determineDeploymentTier("aws", "amazon.titan-text-express-v1"); - expect(tier).toBe("freemium"); // Starts with "amazon." provider prefix - }); - - it("should correctly classify Bedrock model with prefix", () => { - const tier = determineDeploymentTier("aws", "bedrock:anthropic.claude-v3"); expect(tier).toBe("freemium"); }); - it("should correctly classify Meta Llama on Bedrock", () => { + it("should correctly classify Meta Llama on Bedrock (aws deployment)", () => { const tier = determineDeploymentTier("aws", "meta.llama3-70b-instruct-v1:0"); - expect(tier).toBe("freemium"); + expect(tier).toBe("personal"); }); - it("should correctly classify Cohere Command on Bedrock", () => { + it("should correctly classify Cohere on Bedrock (aws deployment)", () => { const tier = determineDeploymentTier("aws", "cohere.command-text-v14"); - expect(tier).toBe("freemium"); - }); - - it("should correctly classify AI21 Jurassic on Bedrock", () => { - const tier = determineDeploymentTier("aws", "ai21.j2-ultra-v1"); - expect(tier).toBe("freemium"); + expect(tier).toBe("personal"); }); - it("should correctly classify Mistral on Bedrock", () => { - const tier = determineDeploymentTier("aws", "mistral.mistral-7b-instruct-v0:2"); - expect(tier).toBe("freemium"); + it("should correctly classify DeepSeek on Bedrock (agentcore deployment)", () => { + const tier = determineDeploymentTier("agentcore", "deepseek.v3-v1:0"); + expect(tier).toBe("personal"); }); }); describe("Deployment Type Priority", () => { - it("should prioritize docker deployment type over model name", () => { + it("should always classify docker as freemium regardless of model", () => { const tier = determineDeploymentTier("docker", "bedrock-model"); - expect(tier).toBe("personal"); + expect(tier).toBe("freemium"); }); - it("should prioritize ollama deployment type over model name", () => { + it("should always classify ollama as freemium regardless of model", () => { const tier = determineDeploymentTier("ollama", "bedrock-model"); - expect(tier).toBe("personal"); + expect(tier).toBe("freemium"); }); - it("should check model name only for aws deployment type", () => { + it("should always classify aws as personal regardless of model", () => { const tier = determineDeploymentTier("aws", "openai-gpt4"); - expect(tier).toBe("personal"); // Non-Bedrock AWS models use Fargate (personal tier) + expect(tier).toBe("personal"); + }); + + it("should always classify agentcore as personal regardless of model", () => { + const tier = determineDeploymentTier("agentcore", "openai-gpt4"); + expect(tier).toBe("personal"); }); }); }); diff --git a/src/components/ArchitecturePreview.tsx b/src/components/ArchitecturePreview.tsx index 1be37e6..85700c7 100644 --- a/src/components/ArchitecturePreview.tsx +++ b/src/components/ArchitecturePreview.tsx @@ -68,9 +68,9 @@ export function ArchitecturePreview({
    - {tier === "freemium" && "Freemium (AgentCore)"} - {tier === "personal" && "Personal AWS (Fargate)"} - {tier === "enterprise" && "Enterprise"} + {tier === "freemium" && "Freemium (Local + AgentCore)"} + {tier === "personal" && "Personal (AgentCore)"} + {tier === "enterprise" && "Enterprise (AgentCore + SSO)"}
    @@ -166,7 +166,7 @@ export function ArchitecturePreview({ {tier === "freemium" && "Freemium tier includes limited free usage. Additional usage charged per request."} {tier === "personal" && - "Costs include Fargate compute, storage, and data transfer. You only pay when your agent is active."} + "Costs include Bedrock model inference and AgentCore runtime. You only pay when your agent is active."} {tier === "enterprise" && "Enterprise pricing includes dedicated resources, SSO, and priority support."}

    @@ -200,7 +200,7 @@ export function ArchitecturePreview({ {tier === "personal" && (
  • - VPC isolation with private subnets + Cross-account assume-role isolation
  • )} {tier === "enterprise" && ( @@ -233,27 +233,14 @@ export function ArchitecturePreview({ ); } -function determineDeploymentTier(deploymentType: string, model: string): string { - // Personal: AWS (Fargate) - Docker, Ollama, or custom models - // Check these first as they take priority - if (deploymentType === "docker" || deploymentType === "ollama") { - return "personal"; +function determineDeploymentTier(deploymentType: string, _model: string): string { + // Freemium: Local (Docker/Ollama) or limited AgentCore via our account + if (deploymentType === "docker" || deploymentType === "ollama" || deploymentType === "local") { + return "freemium"; } - // Freemium: AgentCore - Bedrock models only - // Bedrock models can be identified by: - // 1. Containing "bedrock" in the name - // 2. Using AWS Bedrock provider prefixes (anthropic., amazon., meta., cohere., ai21., mistral.) - if (deploymentType === "aws") { - const bedrockProviders = ["anthropic.", "amazon.", "meta.", "cohere.", "ai21.", "mistral."]; - const isBedrockModel = model.includes("bedrock") || - bedrockProviders.some(provider => model.startsWith(provider)); - - if (isBedrockModel) { - return "freemium"; - } - - // Non-Bedrock AWS deployments use Fargate (personal tier) + // Personal: AgentCore in user's own AWS account (via assume-role) + if (deploymentType === "aws" || deploymentType === "agentcore") { return "personal"; } @@ -293,29 +280,29 @@ function buildResourceEstimates( cost: "$0.50/GB", }); } else if (tier === "personal") { - // Personal: AWS (Fargate) + // Personal: AgentCore in user's AWS account resources.push({ - name: "VPC", - type: "Network", - icon: , - description: "Isolated virtual network", - cost: "Free", + name: "AWS Bedrock AgentCore", + type: "Managed Runtime", + icon: , + description: "Agent execution in your AWS account", + cost: "Pay per request", }); resources.push({ - name: "ECS Fargate", + name: "Lambda Function", type: "Compute", icon: , - description: "Containerized agent execution", - cost: "$0.04/hour", + description: "Agent invocation handler", + cost: "Included", }); resources.push({ - name: "ECR Repository", + name: "S3 Bucket", type: "Storage", icon: , - description: "Docker image storage", - cost: "$0.10/GB/month", + description: "Agent artifacts and data", + cost: "$0.023/GB/month", }); resources.push({ @@ -325,38 +312,30 @@ function buildResourceEstimates( description: "Log aggregation and monitoring", cost: "$0.50/GB", }); - - resources.push({ - name: "Application Load Balancer", - type: "Network", - icon: , - description: "Traffic distribution (optional)", - cost: "$0.0225/hour", - }); } else if (tier === "enterprise") { // Enterprise: includes all Personal tier resources + SSO resources.push({ - name: "VPC", - type: "Network", - icon: , - description: "Isolated virtual network", - cost: "Free", + name: "AWS Bedrock AgentCore", + type: "Managed Runtime", + icon: , + description: "Agent execution in your AWS account", + cost: "Pay per request", }); resources.push({ - name: "ECS Fargate", + name: "Lambda Function", type: "Compute", icon: , - description: "Containerized agent execution", - cost: "$0.04/hour", + description: "Agent invocation handler", + cost: "Included", }); resources.push({ - name: "ECR Repository", + name: "S3 Bucket", type: "Storage", icon: , - description: "Docker image storage", - cost: "$0.10/GB/month", + description: "Agent artifacts and data", + cost: "$0.023/GB/month", }); resources.push({ @@ -392,13 +371,8 @@ function calculateEstimatedCost(tier: string): string { // Freemium: AgentCore - pay per request return "$0.001 - $0.01/request"; } else if (tier === "personal") { - // Personal: Fargate - pay per hour when active - const baseCost = 0.04; // Fargate (includes storage and logs in estimate) - - const estimatedHourly = baseCost; - const estimatedMonthly = estimatedHourly * 24 * 30; // Assuming 24/7 operation - - return `~$${estimatedHourly.toFixed(2)}/hour (~$${estimatedMonthly.toFixed(2)}/month)`; + // Personal: AgentCore - pay per request in your account + return "$0.001 - $0.05/request (in your AWS account)"; } else if (tier === "enterprise") { // Enterprise: contact for pricing return "Contact for enterprise pricing"; diff --git a/src/components/CodePreview.tsx b/src/components/CodePreview.tsx index 5e67a79..4348e44 100644 --- a/src/components/CodePreview.tsx +++ b/src/components/CodePreview.tsx @@ -155,7 +155,7 @@ export function CodePreview({ code, dockerConfig, requirementsTxt, deploymentTyp <>

    1. Configure AWS credentials and Bedrock access

    2. Install dependencies: pip install -r requirements.txt

    -

    3. Deploy to Lambda or ECS using the generated configuration

    +

    3. Deploy to Bedrock AgentCore using the generated configuration

    )} {deploymentType === "ollama" && ( From 0faf6e41a6a62bd83257264a1ad77627f643a0d6 Mon Sep 17 00:00:00 2001 From: MikePfunk28 Date: Mon, 16 Feb 2026 13:42:24 -0500 Subject: [PATCH 3/4] fix: Specify owner when adding issues and PRs to project in GitHub Actions workflow --- .github/workflows/setup-github-projects_Version3.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/setup-github-projects_Version3.yml b/.github/workflows/setup-github-projects_Version3.yml index 375182e..21d826c 100644 --- a/.github/workflows/setup-github-projects_Version3.yml +++ b/.github/workflows/setup-github-projects_Version3.yml @@ -38,7 +38,7 @@ jobs: while read -r issue_num; do [ -z "$issue_num" ] && continue ISSUE_URL="https://github.com/$REPO/issues/$issue_num" - err=$(gh project item-add "$PROJECT_ID" --url "$ISSUE_URL" 2>&1) || { + err=$(gh project item-add "$PROJECT_ID" --owner MikePfunk28 --url "$ISSUE_URL" 2>&1) || { echo "Error adding issue $issue_num to project $PROJECT_ID: $err" >&2 failures=$((failures+1)) } @@ -58,10 +58,10 @@ jobs: if [ "${{ github.event_name }}" = "issues" ]; then ISSUE_URL="https://github.com/${REPO}/issues/${{ github.event.issue.number }}" - out=$(gh project item-add "$PROJECT_ID" --url "$ISSUE_URL" 2>&1) || { echo "Error adding issue $ISSUE_URL to project: $out" >&2; exit 1; } + out=$(gh project item-add "$PROJECT_ID" --owner MikePfunk28 --url "$ISSUE_URL" 2>&1) || { echo "Error adding issue $ISSUE_URL to project: $out" >&2; exit 1; } elif [ "${{ github.event_name }}" = "pull_request" ]; then URL="https://github.com/${REPO}/pull/${{ github.event.pull_request.number }}" - out=$(gh project item-add "$PROJECT_ID" --url "$URL" 2>&1) || { echo "Error adding PR $URL to project: $out" >&2; exit 1; } + out=$(gh project item-add "$PROJECT_ID" --owner MikePfunk28 --url "$URL" 2>&1) || { echo "Error adding PR $URL to project: $out" >&2; exit 1; } else echo "Unsupported event: ${{ github.event_name }}" >&2 exit 1 From bf06249b72a73c350040d8cae08939af62baefc3 Mon Sep 17 00:00:00 2001 From: MikePfunk28 Date: Mon, 16 Feb 2026 13:49:12 -0500 Subject: [PATCH 4/4] feat: Add packaging function for Python agent code and update handler return type in MCP tool invocation --- convex/awsDeploymentFlow.ts | 13 ++++++++++++- convex/mcpClient.ts | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/convex/awsDeploymentFlow.ts b/convex/awsDeploymentFlow.ts index 5ed8104..4e3dd49 100644 --- a/convex/awsDeploymentFlow.ts +++ b/convex/awsDeploymentFlow.ts @@ -320,7 +320,7 @@ async function deployToBedrockAgentCore( const agentCode = generateBedrockAgentCode(agent); // Package as Lambda deployment package - const deploymentPackage = await packageForLambda(agentCode); + const deploymentPackage = await packageForLambda( agentCode ); try { // Try to create new function @@ -408,4 +408,15 @@ def handler(event, context): `; } +/** + * Package Python agent code as a zip buffer suitable for Lambda Code.ZipFile. + * Uses jszip (already a project dependency via awsDeployment.ts). + */ +async function packageForLambda( code: string ): Promise { + const JSZipModule = await import( "jszip" ); + const zip = new JSZipModule.default(); + zip.file( "agent.py", code ); + return zip.generateAsync( { type: "uint8array" } ); +} + // ECS/Fargate stubs removed — all deployments now use Bedrock AgentCore. diff --git a/convex/mcpClient.ts b/convex/mcpClient.ts index cdc3bb3..d36da02 100644 --- a/convex/mcpClient.ts +++ b/convex/mcpClient.ts @@ -886,7 +886,7 @@ export const testMCPServerConnection = action( { args: { serverName: v.string(), }, - handler: async ( ctx, args ) => { + handler: async ( ctx, args ): Promise<{ success: boolean; status: string; error?: string; tools?: Array<{ name: string; description?: string; inputSchema?: unknown }> }> => { try { // Get server configuration const server = await ctx.runQuery( api.mcpConfig.getMCPServerByName, { @@ -964,6 +964,7 @@ export const testMCPServerConnection = action( { transport = new SSEClientTransport( new URL( server.url ) ); } else { // stdio transport + if ( !server.command ) throw new Error( "stdio server missing command" ); const { StdioClientTransport } = await import( "@modelcontextprotocol/sdk/client/stdio.js" ); transport = new StdioClientTransport( { command: server.command,