diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index e5f4aa9e2..dbae357b0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,6 +5,9 @@ on: aws_region: description: 'AWS region for deployment' default: 'us-east-1' + cdk_branch: + description: 'CDK repo branch to build from (default: main)' + default: 'main' pull_request_target: branches: [main] @@ -78,9 +81,11 @@ jobs: # Build @aws/agentcore-cdk from source for cross-package testing. # Requires secrets: CDK_REPO_NAME (org/repo), CDK_REPO_TOKEN (fine-grained PAT) - - name: Build CDK package from main + - name: Build CDK package run: | - git clone --depth 1 "https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" /tmp/cdk-repo + CDK_BRANCH="${{ inputs.cdk_branch || 'main' }}" + echo "Building CDK from branch: $CDK_BRANCH" + git clone --depth 1 --branch "$CDK_BRANCH" "https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" /tmp/cdk-repo cd /tmp/cdk-repo npm ci npm run build diff --git a/.prettierignore b/.prettierignore index 8eda17e39..d7d9cd713 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,6 @@ CHANGELOG.md src/assets/**/*.md .github/scripts/prompts/ +src/assets/**/*.ts +src/assets/**/*.json +src/assets/**/*.template diff --git a/AGENTS.md b/AGENTS.md index c5db221ba..0243a082a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,8 @@ Each primitive extends `BasePrimitive` and implements: `add()`, `remove()`, `pre Current primitives: -- `AgentPrimitive` — agent creation (template + BYO), removal, credential resolution +- `AgentPrimitive` — agent creation (template + BYO), removal, credential resolution. Template agents: Strands, + LangChain_LangGraph, GoogleADK, OpenAIAgents, VercelAI - `MemoryPrimitive` — memory creation with strategies, removal - `CredentialPrimitive` — credential creation, .env management, removal - `EvaluatorPrimitive` — custom evaluator creation/removal with cross-reference validation diff --git a/README.md b/README.md index 4abc7a033..42aeda5ec 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,12 @@ agentcore invoke ## Supported Frameworks -| Framework | Notes | -| ------------------- | ----------------------------- | -| Strands Agents | AWS-native, streaming support | -| LangChain/LangGraph | Graph-based workflows | -| Google ADK | Gemini models only | -| OpenAI Agents | OpenAI models only | +| Framework | Notes | +| ------------------- | --------------------------------------------------- | +| Strands Agents | AWS-native, streaming support (Python + TypeScript) | +| LangChain/LangGraph | Graph-based workflows | +| Google ADK | Gemini models only | +| OpenAI Agents | OpenAI models only | ## Supported Model Providers diff --git a/docs/commands.md b/docs/commands.md index f6f15b9ae..b5a304a70 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -51,6 +51,13 @@ agentcore create \ # Skip agent creation agentcore create --name MyProject --no-agent +# TypeScript (Strands or Vercel AI) +agentcore create \ + --name MyTsProject \ + --language TypeScript \ + --framework Strands \ + --model-provider Bedrock + # Preview without creating agentcore create --name MyProject --defaults --dry-run @@ -71,8 +78,8 @@ agentcore create \ | `--defaults` | Use defaults (Python, Strands, Bedrock, no memory) | | `--no-agent` | Skip agent creation | | `--type ` | `create` (default) or `import` | -| `--language ` | `Python` (default) | -| `--framework ` | `Strands`, `LangChain_LangGraph`, `GoogleADK`, `OpenAIAgents` | +| `--language ` | `Python` (default) or `TypeScript` (Strands-only; see [Frameworks](frameworks.md#supported-languages)) | +| `--framework ` | `Strands`, `LangChain_LangGraph`, `GoogleADK`, `OpenAIAgents`, `VercelAI` | | `--model-provider

` | `Bedrock`, `Anthropic`, `OpenAI`, `Gemini` | | `--build ` | `CodeZip` (default) or `Container` (see [Container Builds](container-builds.md)) | | `--api-key ` | API key for non-Bedrock providers | @@ -202,7 +209,7 @@ agentcore add agent \ | `--type ` | `create` (default), `byo`, or `import` | | `--build ` | `CodeZip` (default) or `Container` (see [Container Builds](container-builds.md)) | | `--language ` | `Python` (create); `Python`, `TypeScript`, `Other` (BYO) | -| `--framework ` | `Strands`, `LangChain_LangGraph`, `GoogleADK`, `OpenAIAgents` | +| `--framework ` | `Strands`, `LangChain_LangGraph`, `GoogleADK`, `OpenAIAgents`, `VercelAI` | | `--model-provider

` | `Bedrock`, `Anthropic`, `OpenAI`, `Gemini` | | `--api-key ` | API key for non-Bedrock providers | | `--memory ` | `none`, `shortTerm`, `longAndShortTerm` (create and import; see [Memory Shorthand Mapping](memory.md#--memory-shorthand-mapping)) | diff --git a/docs/container-builds.md b/docs/container-builds.md index 61d65bcde..4abdde81c 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -48,6 +48,28 @@ The template uses `ghcr.io/astral-sh/uv:python3.12-bookworm-slim` as the base im You can customize the Dockerfile freely — add system packages, change the base image, or use multi-stage builds. +### TypeScript Dockerfile + +For TypeScript agents, the generated `Dockerfile` uses `public.ecr.aws/docker/library/node:22-slim`: + +- **Layer caching**: `package.json` (+ `package-lock.json` if present) is copied first, then `npm ci --omit=dev` runs + (falls back to `npm install` when no lockfile is present) +- **Non-root**: Runs as `bedrock_agentcore` (UID 1000), matching the Python image +- **Entrypoint**: `npx tsx main.ts` — no compile step, so dev and container runtime share the same entry shape +- **Ports**: Exposes 8080 / 8000 / 9000 to match the HTTP / MCP / A2A contract + +Example `agentcore.json` for a TypeScript container agent: + +```json +{ + "name": "MyTsAgent", + "build": "Container", + "entrypoint": "main.ts", + "codeLocation": "app/MyTsAgent/", + "runtimeVersion": "NODE_22" +} +``` + ## Configuration In `agentcore.json`, set `"build": "Container"`: diff --git a/docs/frameworks.md b/docs/frameworks.md index 7d2b8658a..67ce4f32c 100644 --- a/docs/frameworks.md +++ b/docs/frameworks.md @@ -3,6 +3,17 @@ AgentCore CLI supports multiple agent frameworks for template-based agent creation, plus a BYO (Bring Your Own) option for existing code. +## Supported Languages + +| Language | Supported Frameworks | Runtime | Notes | +| ---------- | -------------------- | ------------ | ---------------------------------------------------------------------------------- | +| Python | All frameworks | Python 3.12+ | Default language. Uses `uv` for dependency management. | +| TypeScript | Strands, Vercel AI | Node 22 | Uses `npm` + `tsx` for the dev loop. Other frameworks are not yet available in TS. | + +Pass `--language TypeScript` to `agentcore create` or `agentcore add agent` to scaffold a TypeScript project. The +framework is restricted to `Strands` or `VercelAI`; other values are rejected. See +[Local Development](local-development.md#typescript-agents) for the TS dev loop. + ## Available Frameworks | Framework | Supported Model Providers | @@ -11,6 +22,7 @@ for existing code. | **LangChain_LangGraph** | Bedrock, Anthropic, OpenAI, Gemini | | **GoogleADK** | Gemini only | | **OpenAIAgents** | OpenAI only | +| **VercelAI** | Bedrock, Anthropic, OpenAI, Gemini | ## Framework Selection Guide @@ -26,8 +38,13 @@ AWS's native agent framework designed for Amazon Bedrock. **Model providers:** Bedrock, Anthropic, OpenAI, Gemini +**Languages:** Python, TypeScript + ```bash agentcore create --framework Strands --model-provider Bedrock + +# TypeScript variant +agentcore create --framework Strands --model-provider Bedrock --language TypeScript ``` ### LangChain / LangGraph @@ -76,6 +93,27 @@ OpenAI's native agent framework. agentcore create --framework OpenAIAgents --model-provider OpenAI --api-key sk-... ``` +### Vercel AI SDK + +Vercel's AI SDK for building AI-powered applications. + +**Best for:** + +- Full-stack AI applications with streaming support +- Projects using Vercel's ecosystem +- TypeScript-first agent development + +**Model providers:** Bedrock, Anthropic, OpenAI, Gemini + +**Languages:** Python, TypeScript + +```bash +agentcore create --framework VercelAI --model-provider Bedrock + +# TypeScript variant +agentcore create --framework VercelAI --model-provider Bedrock --language TypeScript +``` + ## Import from Bedrock Agents If you have an existing Bedrock Agent, you can import its configuration and translate it into runnable Strands or @@ -151,19 +189,19 @@ agentcore add agent \ ## Framework Comparison -| Feature | Strands | LangChain | GoogleADK | OpenAIAgents | -| ---------------------- | ------- | --------- | --------- | ------------ | -| Multi-provider support | Yes | Yes | No | No | -| AWS Bedrock native | Yes | No | No | No | -| Tool ecosystem | Growing | Extensive | Moderate | Moderate | -| Memory integration | Native | Via libs | Via libs | Via libs | +| Feature | Strands | LangChain | GoogleADK | OpenAIAgents | VercelAI | +| ---------------------- | ------- | --------- | --------- | ------------ | -------- | +| Multi-provider support | Yes | Yes | No | No | Yes | +| AWS Bedrock native | Yes | No | No | No | No | +| Tool ecosystem | Growing | Extensive | Moderate | Moderate | Moderate | +| Memory integration | Native | Via libs | Via libs | Via libs | Via libs | ## Protocol Compatibility Not all frameworks support all protocol modes. MCP protocol is a standalone tool server with no framework. -| Protocol | Supported Frameworks | -| -------- | ----------------------------------------------------- | -| **HTTP** | Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents | -| **MCP** | None (standalone tool server) | -| **A2A** | Strands, GoogleADK, LangChain_LangGraph | +| Protocol | Supported Frameworks | +| -------- | --------------------------------------------------------------- | +| **HTTP** | Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents, VercelAI | +| **MCP** | None (standalone tool server) | +| **A2A** | Strands, GoogleADK, LangChain_LangGraph | diff --git a/docs/local-development.md b/docs/local-development.md index d13033768..178e50bea 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -42,6 +42,16 @@ The dev server automatically: 2. Runs `uv sync` to install dependencies from `pyproject.toml` 3. Starts uvicorn with your agent +### TypeScript Agents + +TypeScript agents (Strands-only) use Node 22 and `tsx` for the dev loop: + +1. Runs `npm install` on first scaffold to populate `node_modules/` from `package.json` +2. Starts the agent with `npx tsx watch main.ts` — file changes reload automatically +3. No compile step is required; `tsx` executes `.ts` sources directly + +Set `AGENTCORE_SKIP_INSTALL=1` to skip `npm install` if you want to manage dependencies yourself. + ### API Keys For non-Bedrock providers, add keys to `agentcore/.env.local`: diff --git a/e2e-tests/e2e-helper.ts b/e2e-tests/e2e-helper.ts index e0f6a51cc..47fb10844 100644 --- a/e2e-tests/e2e-helper.ts +++ b/e2e-tests/e2e-helper.ts @@ -28,6 +28,10 @@ interface E2EConfig { requiredEnvVar?: string; build?: string; memory?: string; + /** Language for the agent project. Defaults to 'Python'. */ + language?: 'Python' | 'TypeScript'; + /** Skip logs and traces tests. */ + skipObservability?: boolean; /** Lifecycle configuration to pass via --idle-timeout / --max-lifetime flags. */ lifecycleConfig?: { idleTimeout?: number; @@ -37,7 +41,8 @@ interface E2EConfig { export function createE2ESuite(cfg: E2EConfig) { const hasApiKey = !cfg.requiredEnvVar || !!process.env[cfg.requiredEnvVar]; - const canRun = baseCanRun && hasApiKey; + const needsUv = cfg.language !== 'TypeScript'; + const canRun = prereqs.npm && prereqs.git && hasAws && hasApiKey && (!needsUv || prereqs.uv); describe.sequential(`e2e: ${cfg.framework}/${cfg.modelProvider} — create → deploy → invoke`, () => { let testDir: string; @@ -58,7 +63,7 @@ export function createE2ESuite(cfg: E2EConfig) { '--name', agentName, '--language', - 'Python', + cfg.language ?? 'Python', '--framework', cfg.framework, '--model-provider', @@ -221,7 +226,7 @@ export function createE2ESuite(cfg: E2EConfig) { 120000 ); - it.skipIf(!canRun)( + it.skipIf(!canRun || !!cfg.skipObservability)( 'logs returns entries from the invocation', async () => { await retry( @@ -250,7 +255,7 @@ export function createE2ESuite(cfg: E2EConfig) { 120000 ); - it.skipIf(!canRun)( + it.skipIf(!canRun || !!cfg.skipObservability)( 'logs supports level filtering', async () => { // --level error should succeed even if no error-level logs exist @@ -261,7 +266,7 @@ export function createE2ESuite(cfg: E2EConfig) { 120000 ); - it.skipIf(!canRun)( + it.skipIf(!canRun || !!cfg.skipObservability)( 'traces list succeeds after invocation', async () => { // traces list has no --json flag — verify exit code and non-empty output diff --git a/e2e-tests/ts-strands-bedrock.test.ts b/e2e-tests/ts-strands-bedrock.test.ts new file mode 100644 index 000000000..0e8c50dad --- /dev/null +++ b/e2e-tests/ts-strands-bedrock.test.ts @@ -0,0 +1,8 @@ +import { createE2ESuite } from './e2e-helper.js'; + +createE2ESuite({ + framework: 'Strands', + modelProvider: 'Bedrock', + language: 'TypeScript', + skipObservability: true, +}); diff --git a/e2e-tests/ts-vercelai-bedrock.test.ts b/e2e-tests/ts-vercelai-bedrock.test.ts new file mode 100644 index 000000000..047f55d31 --- /dev/null +++ b/e2e-tests/ts-vercelai-bedrock.test.ts @@ -0,0 +1,8 @@ +import { createE2ESuite } from './e2e-helper.js'; + +createE2ESuite({ + framework: 'VercelAI', + modelProvider: 'Bedrock', + language: 'TypeScript', + skipObservability: true, +}); diff --git a/integ-tests/create-with-agent.test.ts b/integ-tests/create-with-agent.test.ts index 69f0594b8..aa7871462 100644 --- a/integ-tests/create-with-agent.test.ts +++ b/integ-tests/create-with-agent.test.ts @@ -71,3 +71,51 @@ describe('integration: create with Python agent', () => { expect(await exists(join(agentDir, '.venv')), '.venv/ should exist in agent directory').toBeTruthy(); }); }); + +describe('integration: create with TypeScript agent', () => { + let testDir: string; + + beforeAll(async () => { + testDir = join(tmpdir(), `agentcore-integ-ts-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it.skipIf(!hasNpm || !hasGit)('scaffolds a TypeScript Strands agent with main.ts entrypoint', async () => { + const name = `TsAgent${Date.now().toString().slice(-6)}`; + // Skip the real npm install to keep the test fast and offline-safe. + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'TypeScript', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir, + { skipInstall: true } + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const agentDir = join(json.projectPath, 'app', json.agentName || name); + expect(await exists(join(agentDir, 'main.ts')), 'main.ts should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'package.json')), 'package.json should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'tsconfig.json')), 'tsconfig.json should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'model', 'load.ts')), 'model/load.ts should exist').toBeTruthy(); + expect(await exists(join(agentDir, 'mcp_client', 'client.ts')), 'mcp_client/client.ts should exist').toBeTruthy(); + }); +}); diff --git a/integ-tests/tui/create-typescript-strands.test.ts b/integ-tests/tui/create-typescript-strands.test.ts new file mode 100644 index 000000000..66e30c860 --- /dev/null +++ b/integ-tests/tui/create-typescript-strands.test.ts @@ -0,0 +1,119 @@ +/** + * TUI Integration Test: Create flow with TypeScript + Strands + * + * Drives the TUI `create` wizard through the basic path with + * `--language TypeScript --framework Strands`, confirms the scaffold + * completes, and verifies agentcore.json ends up with + * runtimeVersion === "NODE_22" and entrypoint === "main.ts". + */ +import { TuiSession, WaitForTimeoutError } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const CLI_DIST = join(__dirname, '..', '..', 'dist', 'cli', 'index.mjs'); +const SCREENSHOTS_DIR = '/tmp/tui-test-create-typescript/screenshots'; + +function saveTextScreenshot(session: TuiSession, name: string): string { + const screen = session.readScreen({ numbered: true }); + const nonEmpty = screen.lines.filter((l: string) => l.trim() !== ''); + const { cols, rows } = screen.dimensions; + const header = `Screenshot: ${name} (${cols}x${rows})`; + const border = '='.repeat(Math.max(header.length, 60)); + const text = `${border}\n${header}\n${border}\n${nonEmpty.join('\n')}\n${border}\n`; + const path = join(SCREENSHOTS_DIR, `${name}.txt`); + writeFileSync(path, text, 'utf-8'); + return path; +} + +async function safeWaitFor(session: TuiSession, pattern: string | RegExp, timeoutMs = 10_000): Promise { + try { + await session.waitFor(pattern, timeoutMs); + return true; + } catch (err) { + if (err instanceof WaitForTimeoutError) return false; + throw err; + } +} + +function readAgentcoreJson(projectDir: string): Record { + return JSON.parse(readFileSync(join(projectDir, 'agentcore', 'agentcore.json'), 'utf-8')); +} + +describe('Create Flow: TypeScript + Strands via TUI', () => { + let session: TuiSession; + + beforeAll(() => { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (session?.alive) await session.close(); + }); + + it('scaffolds a TypeScript Strands agent with runtimeVersion NODE_22 and entrypoint main.ts', async () => { + const { dir: parentDir, cleanup } = await createMinimalProjectDir({ projectName: 'ts-create-test' }); + + try { + session = await TuiSession.launch({ + command: process.execPath, + args: [ + CLI_DIST, + 'create', + '--name', + 'TsTuiCreate', + '--language', + 'TypeScript', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + ], + cwd: parentDir, + cols: 120, + rows: 35, + env: { AGENTCORE_SKIP_INSTALL: '1' }, + }); + + const atAdvanced = await safeWaitFor(session, 'Advanced', 15_000); + if (!atAdvanced) saveTextScreenshot(session, 'ts-01-advanced-fail'); + expect(atAdvanced, 'Should reach Advanced config step').toBe(true); + saveTextScreenshot(session, 'ts-01-advanced'); + + await session.sendSpecialKey('down'); + await session.sendSpecialKey('enter'); + + const atConfirm = await safeWaitFor(session, /confirm|review/i, 10_000); + if (!atConfirm) saveTextScreenshot(session, 'ts-02-confirm-fail'); + expect(atConfirm, 'Should reach confirm step').toBe(true); + saveTextScreenshot(session, 'ts-02-confirm'); + + await session.sendKeys('y'); + + const created = await safeWaitFor(session, /created|success|Commands/i, 30_000); + saveTextScreenshot(session, 'ts-03-result'); + expect(created, 'Scaffold should complete').toBe(true); + + const entries = readdirSync(parentDir); + const projectDirName = entries.find(e => e.startsWith('TsTuiCreate') || e === 'TsTuiCreate'); + expect(projectDirName, 'Project directory should exist').toBeDefined(); + + const projectPath = join(parentDir, projectDirName!); + const config = readAgentcoreJson(projectPath); + const agents = config.runtimes as Record[]; + expect(agents.length).toBeGreaterThan(0); + + const agent = agents[0]!; + expect(agent.runtimeVersion).toBe('NODE_22'); + expect(agent.entrypoint).toBe('main.ts'); + } finally { + await cleanup(); + } + }, 60_000); +}); diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index a97bbeb1d..360b768e2 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -382,7 +382,6 @@ test('AgentCoreStack synthesizes with empty spec', () => { credentials: [], evaluators: [], onlineEvalConfigs: [], - configBundles: [], policyEngines: [], agentCoreGateways: [], mcpRuntimeTools: [], @@ -447,6 +446,8 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "cdk/tsconfig.json", "container/python/Dockerfile", "container/python/dockerignore.template", + "container/typescript/Dockerfile", + "container/typescript/dockerignore.template", "evaluators/python-lambda/execution-role-policy.json", "evaluators/python-lambda/lambda_function.py", "evaluators/python-lambda/pyproject.toml", @@ -543,6 +544,19 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/mcp/standalone/base/main.py", "python/mcp/standalone/base/pyproject.toml", "typescript/.gitkeep", + "typescript/http/strands/base/README.md", + "typescript/http/strands/base/gitignore.template", + "typescript/http/strands/base/main.ts", + "typescript/http/strands/base/mcp_client/client.ts", + "typescript/http/strands/base/model/load.ts", + "typescript/http/strands/base/package.json", + "typescript/http/strands/base/tsconfig.json", + "typescript/http/vercelai/base/README.md", + "typescript/http/vercelai/base/gitignore.template", + "typescript/http/vercelai/base/main.ts", + "typescript/http/vercelai/base/model/load.ts", + "typescript/http/vercelai/base/package.json", + "typescript/http/vercelai/base/tsconfig.json", ] `; @@ -5609,3 +5623,611 @@ When modifying JSON config files: `; exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/.gitkeep should match snapshot 1`] = `""`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/README.md should match snapshot 1`] = ` +"This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a \`.gitignore\` file, an +\`agentcore/\` folder which represents the configurations and state associated with this project. Other \`agentcore\` +commands like \`deploy\`, \`dev\`, and \`invoke\` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in \`main.ts\`. Using the AgentCore SDK \`BedrockAgentCoreApp\`, this file +defines an HTTP server that streams tokens from your chosen Agent framework SDK. + +\`model/load.ts\` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| \`{{identityProviders.[0].envVarName}}\` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| \`LOCAL_DEV\` | No | Set to \`1\` to use \`.env.local\` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, \`node_modules/\` is already populated with dependencies. + +\`agentcore dev\` will start a local server using \`npx tsx watch main.ts\` for hot reload. The port is logged to the terminal (default \`8080\`). + +In a new terminal, you can invoke that server with: + +\`agentcore invoke --dev "What can you do"\` + +# Deployment + +After providing credentials, \`agentcore deploy\` will deploy your project into Amazon Bedrock AgentCore. + +Use \`agentcore invoke\` to invoke your deployed agent. +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/gitignore.template should match snapshot 1`] = ` +"# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/main.ts should match snapshot 1`] = ` +"import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { Agent, tool } from '@strands-agents/sdk'; +import { loadModel } from './model/load.js'; +import { getStreamableHttpMcpClient } from './mcp_client/client.js'; + +// Define a collection of MCP clients +const mcpClients = [getStreamableHttpMcpClient()].filter(Boolean); + +// Define a collection of tools used by the model +const tools: unknown[] = []; + +// Define a simple function tool +const addNumbers = tool({ + name: 'add_numbers', + description: 'Return the sum of two numbers', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + }, + callback: async ({ a, b }: { a: number; b: number }) => a + b, +}); +tools.push(addNumbers); + +// Add MCP clients to tools if available +for (const mcpClient of mcpClients) { + if (mcpClient) { + tools.push(mcpClient); + } +} + +const SYSTEM_PROMPT = \` +You are a helpful assistant. Use tools when appropriate. +\`; + +let cachedAgent: Agent | null = null; + +async function getOrCreateAgent(): Promise { + if (!cachedAgent) { + const model = await loadModel(); + cachedAgent = new Agent({ + model, + systemPrompt: SYSTEM_PROMPT, + tools, + }); + } + return cachedAgent; +} + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: any, context: any) { + const agent = await getOrCreateAgent(); + + for await (const event of agent.stream(payload.prompt ?? '')) { + if ( + event.type === 'modelStreamUpdateEvent' && + event.event?.type === 'modelContentBlockDeltaEvent' && + event.event.delta?.type === 'textDelta' + ) { + yield { data: event.event.delta.text }; + } + } + }, + }, +}); + +app.run({ port: parseInt(process.env.PORT ?? '8080') }); +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/mcp_client/client.ts should match snapshot 1`] = ` +"import { McpClient } from '@strands-agents/sdk'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication +const EXAMPLE_MCP_ENDPOINT = 'https://mcp.exa.ai/mcp'; + +export function getStreamableHttpMcpClient(): McpClient { + // to use an MCP server that supports bearer authentication, add a headers() callback to requestInit + const transport = new StreamableHTTPClientTransport(new URL(EXAMPLE_MCP_ENDPOINT)); + return new McpClient({ transport }); +} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/model/load.ts should match snapshot 1`] = ` +"{{#if (eq modelProvider "Bedrock")}} +import { BedrockModel } from '@strands-agents/sdk/models/bedrock'; + +export function loadModel(): BedrockModel { + return new BedrockModel({ modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0' }); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { AnthropicModel } from '@strands-agents/sdk/models/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} or ANTHROPIC_API_KEY not found. Add your key to agentcore/.env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: AnthropicModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new AnthropicModel({ + apiKey, + modelId: 'claude-sonnet-4-5-20250929', + maxTokens: 5000, + }); + } + return _model; +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { OpenAIModel } from '@strands-agents/sdk/models/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} or OPENAI_API_KEY not found. Add your key to agentcore/.env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: OpenAIModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new OpenAIModel({ + api: 'chat', + apiKey, + modelId: 'gpt-4.1', + }); + } + return _model; +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { GoogleModel } from '@strands-agents/sdk/models/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} or GEMINI_API_KEY not found. Add your key to agentcore/.env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: GoogleModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new GoogleModel({ + apiKey, + modelId: 'gemini-2.5-flash', + }); + } + return _model; +} +{{/if}} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/package.json should match snapshot 1`] = ` +"{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Strands TypeScript SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + {{#if (eq modelProvider "Anthropic")}} + "@anthropic-ai/sdk": "^0.71.2", + {{/if}} + {{#if (eq modelProvider "OpenAI")}} + "openai": "^6.7.0", + {{/if}} + {{#if (eq modelProvider "Gemini")}} + "@google/genai": "^1.40.0", + {{/if}} + "@modelcontextprotocol/sdk": "^1.25.2", + "@strands-agents/sdk": "1.0.0-rc.4", + "bedrock-agentcore": "^0.2.4", + "tsx": "^4.19.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "overrides": { + "bedrock-agentcore": { + "@strands-agents/sdk": "$@strands-agents/sdk" + } + } +} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/strands/base/tsconfig.json should match snapshot 1`] = ` +"{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/README.md should match snapshot 1`] = ` +"This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a \`.gitignore\` file, an +\`agentcore/\` folder which represents the configurations and state associated with this project. Other \`agentcore\` +commands like \`deploy\`, \`dev\`, and \`invoke\` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in \`main.ts\`. Using the AgentCore SDK \`BedrockAgentCoreApp\`, this file +defines an HTTP app that streams tokens using the Vercel AI SDK's \`streamText\` API. + +\`model/load.ts\` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| \`{{identityProviders.[0].envVarName}}\` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| \`LOCAL_DEV\` | No | Set to \`1\` to use \`.env.local\` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, \`node_modules/\` is already populated with dependencies. + +\`agentcore dev\` will start a local server using \`npx tsx watch main.ts\` for hot reload. The port is logged to the terminal (default \`8080\`). + +In a new terminal, you can invoke that server with: + +\`agentcore invoke --dev "What can you do"\` + +# Deployment + +After providing credentials, \`agentcore deploy\` will deploy your project into Amazon Bedrock AgentCore. + +Use \`agentcore invoke\` to invoke your deployed agent. +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/gitignore.template should match snapshot 1`] = ` +"# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/main.ts should match snapshot 1`] = ` +"import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { streamText } from 'ai'; +import { loadModel } from './model/load.js'; + +const SYSTEM_PROMPT = \`You are a helpful assistant.\`; + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: any, context: any) { + const model = await loadModel(); + const result = streamText({ + model, + system: SYSTEM_PROMPT, + prompt: payload.prompt ?? '', + }); + + for await (const chunk of result.textStream) { + yield { data: chunk }; + } + }, + }, +}); + +app.run({ port: parseInt(process.env.PORT ?? '8080') }); +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/model/load.ts should match snapshot 1`] = ` +"{{#if (eq modelProvider "Bedrock")}} +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; + +const provider = fromNodeProviderChain(); + +const bedrock = createAmazonBedrock({ + region: process.env.AWS_REGION ?? 'us-east-1', + credentialProvider: async () => { + const creds = await provider(); + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }; + }, +}); + +export function loadModel() { + return bedrock('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { createAnthropic } from '@ai-sdk/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} or ANTHROPIC_API_KEY not found. Add your key to agentcore/.env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _anthropic: ReturnType | undefined; + +async function getProvider() { + if (!_anthropic) { + const apiKey = await getApiKey(); + _anthropic = createAnthropic({ apiKey }); + } + return _anthropic; +} + +export async function loadModel() { + const anthropic = await getProvider(); + return anthropic('claude-sonnet-4-5-20250929'); +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { createOpenAI } from '@ai-sdk/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} or OPENAI_API_KEY not found. Add your key to agentcore/.env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _openai: ReturnType | undefined; + +async function getProvider() { + if (!_openai) { + const apiKey = await getApiKey(); + _openai = createOpenAI({ apiKey }); + } + return _openai; +} + +export async function loadModel() { + const openai = await getProvider(); + return openai('gpt-4.1'); +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error(\`\${IDENTITY_ENV_VAR} or GEMINI_API_KEY not found. Add your key to agentcore/.env.local\`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _google: ReturnType | undefined; + +async function getProvider() { + if (!_google) { + const apiKey = await getApiKey(); + _google = createGoogleGenerativeAI({ apiKey }); + } + return _google; +} + +export async function loadModel() { + const google = await getProvider(); + return google('gemini-2.5-flash'); +} +{{/if}} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/package.json should match snapshot 1`] = ` +"{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Vercel AI SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + "ai": "^6.0.0", + {{#if (eq modelProvider "Bedrock")}} + "@ai-sdk/amazon-bedrock": "^4.0.0", + "@aws-sdk/credential-providers": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Anthropic")}} + "@ai-sdk/anthropic": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "OpenAI")}} + "@ai-sdk/openai": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Gemini")}} + "@ai-sdk/google": "^3.0.0", + {{/if}} + "bedrock-agentcore": "^0.2.4", + "tsx": "^4.19.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} +" +`; + +exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/tsconfig.json should match snapshot 1`] = ` +"{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} +" +`; diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 79282f729..df5c767f9 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -14,7 +14,6 @@ test('AgentCoreStack synthesizes with empty spec', () => { credentials: [], evaluators: [], onlineEvalConfigs: [], - configBundles: [], policyEngines: [], agentCoreGateways: [], mcpRuntimeTools: [], diff --git a/src/assets/container/typescript/Dockerfile b/src/assets/container/typescript/Dockerfile new file mode 100644 index 000000000..df9c6bac1 --- /dev/null +++ b/src/assets/container/typescript/Dockerfile @@ -0,0 +1,25 @@ +FROM public.ecr.aws/docker/library/node:22-slim + +WORKDIR /app + +ENV NODE_ENV=production \ + DOCKER_CONTAINER=1 + +RUN userdel -r node 2>/dev/null || true +RUN useradd -m -u 1000 bedrock_agentcore + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev || npm install --omit=dev + +COPY --chown=bedrock_agentcore:bedrock_agentcore . . + +USER bedrock_agentcore + +# AgentCore Runtime service contract ports +# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-service-contract.html +# 8080: HTTP Mode +# 8000: MCP Mode +# 9000: A2A Mode +EXPOSE 8080 8000 9000 + +CMD ["npx", "tsx", "main.ts"] diff --git a/src/assets/container/typescript/dockerignore.template b/src/assets/container/typescript/dockerignore.template new file mode 100644 index 000000000..4fe494a08 --- /dev/null +++ b/src/assets/container/typescript/dockerignore.template @@ -0,0 +1,24 @@ +# Node +node_modules/ +dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ + +# Testing +coverage/ + +# Secrets and environment files +.env +.env.* + +# Version control +.git/ + +# AgentCore build artifacts +.agentcore/artifacts/ +*.zip diff --git a/src/assets/typescript/http/strands/base/README.md b/src/assets/typescript/http/strands/base/README.md new file mode 100644 index 000000000..69903b4ac --- /dev/null +++ b/src/assets/typescript/http/strands/base/README.md @@ -0,0 +1,37 @@ +This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a `.gitignore` file, an +`agentcore/` folder which represents the configurations and state associated with this project. Other `agentcore` +commands like `deploy`, `dev`, and `invoke` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in `main.ts`. Using the AgentCore SDK `BedrockAgentCoreApp`, this file +defines an HTTP server that streams tokens from your chosen Agent framework SDK. + +`model/load.ts` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| `{{identityProviders.[0].envVarName}}` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| `LOCAL_DEV` | No | Set to `1` to use `.env.local` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, `node_modules/` is already populated with dependencies. + +`agentcore dev` will start a local server using `npx tsx watch main.ts` for hot reload. The port is logged to the terminal (default `8080`). + +In a new terminal, you can invoke that server with: + +`agentcore invoke --dev "What can you do"` + +# Deployment + +After providing credentials, `agentcore deploy` will deploy your project into Amazon Bedrock AgentCore. + +Use `agentcore invoke` to invoke your deployed agent. diff --git a/src/assets/typescript/http/strands/base/gitignore.template b/src/assets/typescript/http/strands/base/gitignore.template new file mode 100644 index 000000000..feb4f544d --- /dev/null +++ b/src/assets/typescript/http/strands/base/gitignore.template @@ -0,0 +1,22 @@ +# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/typescript/http/strands/base/main.ts b/src/assets/typescript/http/strands/base/main.ts new file mode 100644 index 000000000..698b9afc4 --- /dev/null +++ b/src/assets/typescript/http/strands/base/main.ts @@ -0,0 +1,71 @@ +import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { Agent, tool } from '@strands-agents/sdk'; +import { loadModel } from './model/load.js'; +import { getStreamableHttpMcpClient } from './mcp_client/client.js'; + +// Define a collection of MCP clients +const mcpClients = [getStreamableHttpMcpClient()].filter(Boolean); + +// Define a collection of tools used by the model +const tools: unknown[] = []; + +// Define a simple function tool +const addNumbers = tool({ + name: 'add_numbers', + description: 'Return the sum of two numbers', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + }, + callback: async ({ a, b }: { a: number; b: number }) => a + b, +}); +tools.push(addNumbers); + +// Add MCP clients to tools if available +for (const mcpClient of mcpClients) { + if (mcpClient) { + tools.push(mcpClient); + } +} + +const SYSTEM_PROMPT = ` +You are a helpful assistant. Use tools when appropriate. +`; + +let cachedAgent: Agent | null = null; + +async function getOrCreateAgent(): Promise { + if (!cachedAgent) { + const model = await loadModel(); + cachedAgent = new Agent({ + model, + systemPrompt: SYSTEM_PROMPT, + tools, + }); + } + return cachedAgent; +} + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: any, context: any) { + const agent = await getOrCreateAgent(); + + for await (const event of agent.stream(payload.prompt ?? '')) { + if ( + event.type === 'modelStreamUpdateEvent' && + event.event?.type === 'modelContentBlockDeltaEvent' && + event.event.delta?.type === 'textDelta' + ) { + yield { data: event.event.delta.text }; + } + } + }, + }, +}); + +app.run({ port: parseInt(process.env.PORT ?? '8080') }); diff --git a/src/assets/typescript/http/strands/base/mcp_client/client.ts b/src/assets/typescript/http/strands/base/mcp_client/client.ts new file mode 100644 index 000000000..d6e8528f8 --- /dev/null +++ b/src/assets/typescript/http/strands/base/mcp_client/client.ts @@ -0,0 +1,11 @@ +import { McpClient } from '@strands-agents/sdk'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication +const EXAMPLE_MCP_ENDPOINT = 'https://mcp.exa.ai/mcp'; + +export function getStreamableHttpMcpClient(): McpClient { + // to use an MCP server that supports bearer authentication, add a headers() callback to requestInit + const transport = new StreamableHTTPClientTransport(new URL(EXAMPLE_MCP_ENDPOINT)); + return new McpClient({ transport }); +} diff --git a/src/assets/typescript/http/strands/base/model/load.ts b/src/assets/typescript/http/strands/base/model/load.ts new file mode 100644 index 000000000..00e22cd9b --- /dev/null +++ b/src/assets/typescript/http/strands/base/model/load.ts @@ -0,0 +1,102 @@ +{{#if (eq modelProvider "Bedrock")}} +import { BedrockModel } from '@strands-agents/sdk/models/bedrock'; + +export function loadModel(): BedrockModel { + return new BedrockModel({ modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0' }); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { AnthropicModel } from '@strands-agents/sdk/models/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or ANTHROPIC_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: AnthropicModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new AnthropicModel({ + apiKey, + modelId: 'claude-sonnet-4-5-20250929', + maxTokens: 5000, + }); + } + return _model; +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { OpenAIModel } from '@strands-agents/sdk/models/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or OPENAI_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: OpenAIModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new OpenAIModel({ + api: 'chat', + apiKey, + modelId: 'gpt-4.1', + }); + } + return _model; +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { GoogleModel } from '@strands-agents/sdk/models/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or GEMINI_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: GoogleModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new GoogleModel({ + apiKey, + modelId: 'gemini-2.5-flash', + }); + } + return _model; +} +{{/if}} diff --git a/src/assets/typescript/http/strands/base/package.json b/src/assets/typescript/http/strands/base/package.json new file mode 100644 index 000000000..0edb269db --- /dev/null +++ b/src/assets/typescript/http/strands/base/package.json @@ -0,0 +1,36 @@ +{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Strands TypeScript SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + {{#if (eq modelProvider "Anthropic")}} + "@anthropic-ai/sdk": "^0.71.2", + {{/if}} + {{#if (eq modelProvider "OpenAI")}} + "openai": "^6.7.0", + {{/if}} + {{#if (eq modelProvider "Gemini")}} + "@google/genai": "^1.40.0", + {{/if}} + "@modelcontextprotocol/sdk": "^1.25.2", + "@strands-agents/sdk": "1.0.0-rc.4", + "bedrock-agentcore": "^0.2.4", + "tsx": "^4.19.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "overrides": { + "bedrock-agentcore": { + "@strands-agents/sdk": "$@strands-agents/sdk" + } + } +} diff --git a/src/assets/typescript/http/strands/base/tsconfig.json b/src/assets/typescript/http/strands/base/tsconfig.json new file mode 100644 index 000000000..c199ae076 --- /dev/null +++ b/src/assets/typescript/http/strands/base/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/assets/typescript/http/vercelai/base/README.md b/src/assets/typescript/http/vercelai/base/README.md new file mode 100644 index 000000000..7b1d8e0e3 --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/README.md @@ -0,0 +1,37 @@ +This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a `.gitignore` file, an +`agentcore/` folder which represents the configurations and state associated with this project. Other `agentcore` +commands like `deploy`, `dev`, and `invoke` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in `main.ts`. Using the AgentCore SDK `BedrockAgentCoreApp`, this file +defines an HTTP app that streams tokens using the Vercel AI SDK's `streamText` API. + +`model/load.ts` instantiates your chosen model provider. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| `{{identityProviders.[0].envVarName}}` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| `LOCAL_DEV` | No | Set to `1` to use `.env.local` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, `node_modules/` is already populated with dependencies. + +`agentcore dev` will start a local server using `npx tsx watch main.ts` for hot reload. The port is logged to the terminal (default `8080`). + +In a new terminal, you can invoke that server with: + +`agentcore invoke --dev "What can you do"` + +# Deployment + +After providing credentials, `agentcore deploy` will deploy your project into Amazon Bedrock AgentCore. + +Use `agentcore invoke` to invoke your deployed agent. diff --git a/src/assets/typescript/http/vercelai/base/gitignore.template b/src/assets/typescript/http/vercelai/base/gitignore.template new file mode 100644 index 000000000..feb4f544d --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/gitignore.template @@ -0,0 +1,22 @@ +# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/typescript/http/vercelai/base/main.ts b/src/assets/typescript/http/vercelai/base/main.ts new file mode 100644 index 000000000..09fdb933f --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/main.ts @@ -0,0 +1,24 @@ +import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { streamText } from 'ai'; +import { loadModel } from './model/load.js'; + +const SYSTEM_PROMPT = `You are a helpful assistant.`; + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + async *process(payload: any, context: any) { + const model = await loadModel(); + const result = streamText({ + model, + system: SYSTEM_PROMPT, + prompt: payload.prompt ?? '', + }); + + for await (const chunk of result.textStream) { + yield { data: chunk }; + } + }, + }, +}); + +app.run({ port: parseInt(process.env.PORT ?? '8080') }); diff --git a/src/assets/typescript/http/vercelai/base/model/load.ts b/src/assets/typescript/http/vercelai/base/model/load.ts new file mode 100644 index 000000000..a6e61a840 --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/model/load.ts @@ -0,0 +1,121 @@ +{{#if (eq modelProvider "Bedrock")}} +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; + +const provider = fromNodeProviderChain(); + +const bedrock = createAmazonBedrock({ + region: process.env.AWS_REGION ?? 'us-east-1', + credentialProvider: async () => { + const creds = await provider(); + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }; + }, +}); + +export function loadModel() { + return bedrock('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { createAnthropic } from '@ai-sdk/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or ANTHROPIC_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _anthropic: ReturnType | undefined; + +async function getProvider() { + if (!_anthropic) { + const apiKey = await getApiKey(); + _anthropic = createAnthropic({ apiKey }); + } + return _anthropic; +} + +export async function loadModel() { + const anthropic = await getProvider(); + return anthropic('claude-sonnet-4-5-20250929'); +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { createOpenAI } from '@ai-sdk/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or OPENAI_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _openai: ReturnType | undefined; + +async function getProvider() { + if (!_openai) { + const apiKey = await getApiKey(); + _openai = createOpenAI({ apiKey }); + } + return _openai; +} + +export async function loadModel() { + const openai = await getProvider(); + return openai('gpt-4.1'); +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or GEMINI_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _google: ReturnType | undefined; + +async function getProvider() { + if (!_google) { + const apiKey = await getApiKey(); + _google = createGoogleGenerativeAI({ apiKey }); + } + return _google; +} + +export async function loadModel() { + const google = await getProvider(); + return google('gemini-2.5-flash'); +} +{{/if}} diff --git a/src/assets/typescript/http/vercelai/base/package.json b/src/assets/typescript/http/vercelai/base/package.json new file mode 100644 index 000000000..f963b0c88 --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/package.json @@ -0,0 +1,35 @@ +{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Vercel AI SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + "ai": "^6.0.0", + {{#if (eq modelProvider "Bedrock")}} + "@ai-sdk/amazon-bedrock": "^4.0.0", + "@aws-sdk/credential-providers": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Anthropic")}} + "@ai-sdk/anthropic": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "OpenAI")}} + "@ai-sdk/openai": "^3.0.0", + {{/if}} + {{#if (eq modelProvider "Gemini")}} + "@ai-sdk/google": "^3.0.0", + {{/if}} + "bedrock-agentcore": "^0.2.4", + "tsx": "^4.19.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/src/assets/typescript/http/vercelai/base/tsconfig.json b/src/assets/typescript/http/vercelai/base/tsconfig.json new file mode 100644 index 000000000..c199ae076 --- /dev/null +++ b/src/assets/typescript/http/vercelai/base/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 25c5f4c9a..b99ea2f4e 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -318,7 +318,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt agentRuntimeArn: options.runtimeArn, payload: new TextEncoder().encode(JSON.stringify({ prompt: options.payload })), contentType: 'application/json', - accept: 'application/json', + accept: 'application/json, text/event-stream', runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, ...(options.baggage && { baggage: options.baggage }), @@ -414,7 +414,7 @@ export async function invokeAgentRuntime(options: InvokeAgentRuntimeOptions): Pr agentRuntimeArn: options.runtimeArn, payload: new TextEncoder().encode(JSON.stringify({ prompt: options.payload })), contentType: 'application/json', - accept: 'application/json', + accept: 'application/json, text/event-stream', runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, ...(options.baggage && { baggage: options.baggage }), diff --git a/src/cli/commands/add/__tests__/add-agent.test.ts b/src/cli/commands/add/__tests__/add-agent.test.ts index 4d4353c36..cee0dfbeb 100644 --- a/src/cli/commands/add/__tests__/add-agent.test.ts +++ b/src/cli/commands/add/__tests__/add-agent.test.ts @@ -98,7 +98,7 @@ describe('add agent command', () => { expect(json.error.includes('Invalid framework'), `Error: ${json.error}`).toBeTruthy(); }); - it('rejects TypeScript for create path', async () => { + it('rejects TypeScript with a non-Strands framework', async () => { const result = await runCLI( [ 'add', @@ -108,7 +108,7 @@ describe('add agent command', () => { '--language', 'TypeScript', '--framework', - 'Strands', + 'LangChain_LangGraph', '--model-provider', 'Bedrock', '--memory', @@ -121,7 +121,7 @@ describe('add agent command', () => { expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); - expect(json.error.includes('Python'), `Error should mention Python: ${json.error}`).toBeTruthy(); + expect(json.error.includes('Strands'), `Error should mention Strands: ${json.error}`).toBeTruthy(); }); it('validates framework/model compatibility', async () => { diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index b304afa5c..2be895494 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -173,12 +173,25 @@ describe('validate', () => { }); // AC5: Create path language restrictions - it('returns error for create path with TypeScript or Other', () => { - let result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'TypeScript' }); + it('accepts TypeScript with Strands and rejects TypeScript with other frameworks', () => { + let result = validateAddAgentOptions({ + ...validAgentOptionsCreate, + language: 'TypeScript', + framework: 'Strands', + }); + expect(result.valid).toBe(true); + + result = validateAddAgentOptions({ + ...validAgentOptionsCreate, + language: 'TypeScript', + framework: 'LangChain_LangGraph', + }); expect(result.valid).toBe(false); - expect(result.error?.includes('Python')).toBeTruthy(); + expect(result.error?.includes('Strands')).toBeTruthy(); + }); - result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'Other' }); + it('returns error for create path with Other language', () => { + const result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'Other' }); expect(result.valid).toBe(false); expect(result.error?.includes('Python')).toBeTruthy(); }); diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 15ec081ab..4a6275010 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -116,6 +116,14 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes } options.protocol = protocolResult.data; + // TypeScript only supports HTTP today; MCP and A2A templates have not been authored yet + if (protocolResult.data !== 'HTTP' && options.language === 'TypeScript') { + return { + valid: false, + error: `${protocolResult.data} protocol is not yet supported for TypeScript. Use --protocol HTTP or --language Python.`, + }; + } + const isByoPath = options.type === 'byo'; const isImportPath = options.type === 'import'; @@ -236,11 +244,19 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes return { valid: false, error: '--code-location is required for BYO path' }; } } else { - if (options.language === 'TypeScript') { - return { valid: false, error: 'Create path only supports Python (TypeScript templates not yet available)' }; - } if (options.language === 'Other') { - return { valid: false, error: 'Create path only supports Python' }; + return { valid: false, error: 'Create path only supports Python or TypeScript' }; + } + if ( + options.language === 'TypeScript' && + options.framework && + options.framework !== 'Strands' && + options.framework !== 'VercelAI' + ) { + return { + valid: false, + error: `Framework ${options.framework} is not yet available for TypeScript. Only Strands and Vercel AI SDK are supported.`, + }; } if (!options.memory) { diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index 8c118ebf5..ad1c999f7 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -125,13 +125,27 @@ describe('validateCreateOptions', () => { expect(result.error).toContain('Invalid language'); }); - it('returns invalid for TypeScript language', () => { + it('accepts TypeScript with Strands framework', () => { const result = validateCreateOptions( { name: 'TestProj4', language: 'TypeScript', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, testDir ); + expect(result.valid).toBe(true); + }); + + it('rejects TypeScript with a non-Strands framework', () => { + const result = validateCreateOptions( + { + name: 'TestProj4b', + language: 'TypeScript', + framework: 'LangChain_LangGraph', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); expect(result.valid).toBe(false); - expect(result.error).toContain('TypeScript is not yet supported'); + expect(result.error).toContain('is not yet available for TypeScript'); }); it('returns invalid for invalid framework', () => { diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index 0f97de294..626bcc8eb 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -18,7 +18,7 @@ import type { TargetLanguage, } from '../../../schema'; import { checkCreateDependencies } from '../../external-requirements'; -import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../operations'; +import { initGitRepo, setupNodeProject, setupPythonProject, writeEnvFile, writeGitignore } from '../../operations'; import { createConfigBundleForAgent } from '../../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, @@ -327,6 +327,24 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P onProgress?.('Set up Python environment', 'done'); } + // Set up Node environment if needed (unless skipped) + if (language === 'TypeScript' && !skipInstall) { + onProgress?.('Set up Node environment', 'start'); + const agentDir = join(projectRoot, APP_DIR, name); + const nodeResult = await setupNodeProject({ projectDir: agentDir }); + if (nodeResult.status === 'success') { + onProgress?.('Set up Node environment', 'done'); + } else { + const firstLine = (nodeResult.error ?? '').split('\n').find(l => l.trim().length > 0) ?? ''; + const warn = + nodeResult.status === 'npm_not_found' + ? 'npm not found on PATH. Install Node.js 20+ and run `npm install` in the agent directory.' + : `npm install failed${firstLine ? `: ${firstLine.replace(/^npm (error|warn) /i, '').slice(0, 160)}` : ''}. Run \`npm install\` in ${agentDir} to retry and see the full error.`; + depWarnings.push(warn); + onProgress?.('Set up Node environment', 'done'); + } + } + return { success: true, projectPath: projectRoot, @@ -361,6 +379,13 @@ export function getDryRunInfo(options: { wouldCreate.push(`${projectRoot}/app/${name}/`); wouldCreate.push(`${projectRoot}/app/${name}/main.py`); wouldCreate.push(`${projectRoot}/app/${name}/pyproject.toml`); + } else if (language === 'TypeScript') { + wouldCreate.push(`${projectRoot}/app/${name}/`); + wouldCreate.push(`${projectRoot}/app/${name}/main.ts`); + wouldCreate.push(`${projectRoot}/app/${name}/package.json`); + wouldCreate.push(`${projectRoot}/app/${name}/tsconfig.json`); + wouldCreate.push(`${projectRoot}/app/${name}/model/load.ts`); + wouldCreate.push(`${projectRoot}/app/${name}/mcp_client/client.ts`); } return { diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index a21ece491..c5c5048f5 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -221,10 +221,10 @@ export const registerCreate = (program: Command) => { .option('--no-agent', 'Skip agent creation [non-interactive]') .option('--defaults', 'Use defaults (Python, Strands, Bedrock, no memory) [non-interactive]') .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') - .option('--language ', 'Target language (default: Python) [non-interactive]') + .option('--language ', 'Target language: Python or TypeScript (default: Python) [non-interactive]') .option( '--framework ', - 'Agent framework (Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents) [non-interactive]' + 'Agent framework (Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents, VercelAI) [non-interactive]' ) .option('--model-provider ', 'Model provider (Bedrock, Anthropic, OpenAI, Gemini) [non-interactive]') .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index a59c7d752..9cc6964dd 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -113,6 +113,14 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val } } + // TypeScript only supports HTTP today; MCP and A2A templates have not been authored yet + if (protocol !== 'HTTP' && options.language === 'TypeScript') { + return { + valid: false, + error: `${protocol} protocol is not yet supported for TypeScript. Use --protocol HTTP or --language Python.`, + }; + } + // MCP protocol: only name, language, and build type required if (protocol === 'MCP') { if (options.framework) { @@ -161,7 +169,7 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val // Validate language const langResult = TargetLanguageSchema.safeParse(options.language); if (!langResult.success) { - return { valid: false, error: `Invalid language: ${options.language}. Use Python` }; + return { valid: false, error: `Invalid language: ${options.language}. Use Python or TypeScript` }; } // Validate framework @@ -184,9 +192,12 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: false, error: `Invalid model provider: ${options.modelProvider}` }; } - // Validate language is supported - if (options.language === 'TypeScript') { - return { valid: false, error: 'TypeScript is not yet supported. Currently supported: Python' }; + // TypeScript supports Strands and Vercel AI only + if (options.language === 'TypeScript' && fwResult.data !== 'Strands' && fwResult.data !== 'VercelAI') { + return { + valid: false, + error: `Framework ${options.framework} is not yet available for TypeScript. Only Strands and Vercel AI SDK are supported.`, + }; } // Validate framework/model compatibility diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 33a040d3f..eba2ab113 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -657,7 +657,9 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS]; const notes: string[] = []; - if (agentNames.length > 0 || hasGateways) { + const hasPythonAgent = + context.projectSpec.runtimes?.some(a => a.entrypoint?.endsWith('.py') || a.entrypoint?.includes('.py:')) ?? false; + if ((agentNames.length > 0 || hasGateways) && hasPythonAgent) { try { const tsResult = await setupTransactionSearch({ region: target.region, diff --git a/src/cli/commands/dev/__tests__/dev.test.ts b/src/cli/commands/dev/__tests__/dev.test.ts index 5b86e647b..626ed23fc 100644 --- a/src/cli/commands/dev/__tests__/dev.test.ts +++ b/src/cli/commands/dev/__tests__/dev.test.ts @@ -36,9 +36,12 @@ describe('dev command', () => { }); describe('positional prompt invoke', () => { + // Use a port that is very unlikely to have a server running + const unusedPort = '19876'; + it('exits with helpful error when no server running and no project found', async () => { // With no dev server running, invoke path shows connection error - const result = await runCLI(['dev', 'Hello agent'], process.cwd()); + const result = await runCLI(['dev', 'Hello agent', '--port', unusedPort], process.cwd()); expect(result.exitCode).toBe(1); const output = result.stderr.toLowerCase(); @@ -51,7 +54,7 @@ describe('dev command', () => { it('does not go through requireProject guard when invoking', async () => { // Invoke path uses loadProjectConfig (soft check), not requireProject() // The error should mention the dev server, not the generic project guard message - const result = await runCLI(['dev', 'test prompt'], process.cwd()); + const result = await runCLI(['dev', 'test prompt', '--port', unusedPort], process.cwd()); expect(result.exitCode).toBe(1); const output = result.stderr.toLowerCase(); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index eac485d8c..9e3e941e6 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -283,9 +283,7 @@ export const registerDev = (program: Command) => { const supportedAgents = getDevSupportedAgents(project); if (supportedAgents.length === 0) { - render( - - ); + render(); process.exit(1); } diff --git a/src/cli/commands/traces/action.ts b/src/cli/commands/traces/action.ts index c69cc1853..96c5dc37d 100644 --- a/src/cli/commands/traces/action.ts +++ b/src/cli/commands/traces/action.ts @@ -23,6 +23,19 @@ export async function handleTracesList( const { agent } = resolved; + // Traces are only supported for Python agents + const runtimeSpec = context.project.runtimes.find(r => r.name === agent.agentName); + const isPython = + (runtimeSpec?.entrypoint?.endsWith('.py') ?? false) || (runtimeSpec?.entrypoint?.includes('.py:') ?? false); + if (!isPython) { + return { + success: false, + error: new ValidationError( + 'Traces are only supported for Python agents. TypeScript agents do not support observability traces.' + ), + }; + } + const consoleUrl = buildTraceConsoleUrl({ region: agent.region, accountId: agent.accountId, @@ -85,6 +98,19 @@ export async function handleTracesGet( const { agent } = resolved; + // Traces are only supported for Python agents + const runtimeSpec = context.project.runtimes.find(r => r.name === agent.agentName); + const isPython = + (runtimeSpec?.entrypoint?.endsWith('.py') ?? false) || (runtimeSpec?.entrypoint?.includes('.py:') ?? false); + if (!isPython) { + return { + success: false, + error: new ValidationError( + 'Traces are only supported for Python agents. TypeScript agents do not support observability traces.' + ), + }; + } + const consoleUrl = buildTraceConsoleUrl({ region: agent.region, accountId: agent.accountId, diff --git a/src/cli/external-requirements/__tests__/detect.test.ts b/src/cli/external-requirements/__tests__/detect.test.ts index dfaa1894e..2c3634ad5 100644 --- a/src/cli/external-requirements/__tests__/detect.test.ts +++ b/src/cli/external-requirements/__tests__/detect.test.ts @@ -20,6 +20,7 @@ describe('detectContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -36,6 +37,7 @@ describe('detectContainerRuntime', () => { mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => { if (bin === 'podman' && args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' }); + if (bin === 'podman' && args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -57,6 +59,7 @@ describe('detectContainerRuntime', () => { if (bin === 'docker' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' }); if (bin === 'podman' && args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' }); + if (bin === 'podman' && args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); // finch --version also fails if (bin === 'finch' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); @@ -71,6 +74,7 @@ describe('detectContainerRuntime', () => { mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\nExtra info line\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -82,6 +86,7 @@ describe('detectContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -90,20 +95,30 @@ describe('detectContainerRuntime', () => { expect(result.runtime?.version).toBe(''); }); - it('does not call docker info to check daemon status', async () => { + it('skips runtime when build --help check fails with non-zero exit', async () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { - if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); + if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '1.0.0\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 1, stdout: '', stderr: 'unknown command "build"' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); - await detectContainerRuntime(); + const result = await detectContainerRuntime(); + expect(result.runtime).toBeNull(); + }); + + it('skips runtime when build --help exits 0 but stderr indicates unknown command (shim/wrapper)', async () => { + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { + // Shim exits 0 for everything but prints error to stderr + if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '1.0.0\n', stderr: '' }); + if (args[0] === 'build') + return Promise.resolve({ code: 0, stdout: '', stderr: 'Error: unknown command "build" for "ada"' }); + return Promise.resolve({ code: 0, stdout: '', stderr: '' }); + }); - // Verify 'info' was never called — this is the key behavioral change - const infoCalls = mockRunSubprocessCapture.mock.calls.filter( - (call: unknown[]) => (call[1] as string[])[0] === 'info' - ); - expect(infoCalls).toHaveLength(0); + const result = await detectContainerRuntime(); + expect(result.runtime).toBeNull(); }); }); @@ -112,6 +127,7 @@ describe('requireContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); + if (args[0] === 'build') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); diff --git a/src/cli/external-requirements/detect.ts b/src/cli/external-requirements/detect.ts index 054a4d82b..7e8a4bb12 100644 --- a/src/cli/external-requirements/detect.ts +++ b/src/cli/external-requirements/detect.ts @@ -20,11 +20,8 @@ export interface DetectionResult { /** * Detect available container runtime. - * Checks docker, podman, finch in order; returns the first that is installed. - * Does not probe the daemon (e.g., `docker info`) — that would require socket - * access and can trigger OS password prompts on systems where the user is not - * in the docker group. Actual daemon availability is validated when the runtime - * is used (build, run, etc.). + * Checks docker, podman, finch in order; returns the first that is installed + * and capable of running container operations. */ export async function detectContainerRuntime(): Promise { for (const runtime of CONTAINER_RUNTIMES) { @@ -36,6 +33,14 @@ export async function detectContainerRuntime(): Promise { const result = await runSubprocessCapture(runtime, ['--version']); if (result.code !== 0) continue; + // Validate the binary actually supports container operations. + // Some environments have shims (e.g., toolbox wrappers) that respond to + // --version but don't support build/run commands. These shims may exit 0 + // even on failure, so also check stderr for error indicators. + const buildCheck = await runSubprocessCapture(runtime, ['build', '--help']); + if (buildCheck.code !== 0) continue; + if (buildCheck.stderr && /unknown command|not found/i.test(buildCheck.stderr)) continue; + const version = result.stdout.trim().split('\n')[0] ?? 'unknown'; return { runtime: { runtime, binary: runtime, version } }; } diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 3ed449236..cc7be2a53 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -9,7 +9,12 @@ import type { MemoryStrategyType, ModelProvider, } from '../../../../schema'; -import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES } from '../../../../schema'; +import { + DEFAULT_ENTRYPOINT_BY_LANGUAGE, + DEFAULT_EPISODIC_REFLECTION_NAMESPACES, + DEFAULT_RUNTIME_BY_LANGUAGE, + DEFAULT_STRATEGY_NAMESPACES, +} from '../../../../schema'; import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive'; import { buildAuthorizerConfigFromJwtConfig } from '../../../primitives/auth-utils'; import { @@ -116,9 +121,11 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { name: config.projectName, build: config.buildType ?? 'CodeZip', ...(config.dockerfile && { dockerfile: config.dockerfile }), - entrypoint: DEFAULT_PYTHON_ENTRYPOINT as FilePath, + entrypoint: (config.language === 'TypeScript' + ? DEFAULT_ENTRYPOINT_BY_LANGUAGE.TypeScript + : DEFAULT_PYTHON_ENTRYPOINT) as FilePath, codeLocation: codeLocation as DirectoryPath, - runtimeVersion: DEFAULT_PYTHON_VERSION, + runtimeVersion: config.language === 'TypeScript' ? DEFAULT_RUNTIME_BY_LANGUAGE.TypeScript : DEFAULT_PYTHON_VERSION, networkMode, protocol, ...(networkMode === 'VPC' && @@ -150,7 +157,6 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { ...(config.sessionStorageMountPath && { filesystemConfigurations: [{ sessionStorage: { mountPath: config.sessionStorageMountPath } }], }), - // MCP uses mcp.run() which is incompatible with the opentelemetry-instrument wrapper ...(protocol === 'MCP' && { instrumentation: { enableOtel: false } }), }; } @@ -264,19 +270,22 @@ export async function mapGenerateConfigToRenderConfig( ): Promise { const isMcp = config.protocol === 'MCP'; const gatewayProviders = isMcp ? [] : await mapGatewaysToGatewayProviders(); - const enableOtel = !isMcp; + const enableOtel = !isMcp && config.language !== 'TypeScript'; return { name: config.projectName, sdkFramework: config.sdk, targetLanguage: config.language, modelProvider: config.modelProvider, - hasMemory: isMcp ? false : config.memory !== 'none', + hasMemory: isMcp || config.language === 'TypeScript' ? false : config.memory !== 'none', hasIdentity: isMcp ? false : identityProviders.length > 0, hasGateway: gatewayProviders.length > 0, isVpc: config.networkMode === 'VPC', buildType: config.buildType, - memoryProviders: isMcp ? [] : mapMemoryOptionToMemoryProviders(config.memory, config.projectName), + memoryProviders: + isMcp || config.language === 'TypeScript' + ? [] + : mapMemoryOptionToMemoryProviders(config.memory, config.projectName), identityProviders: isMcp ? [] : identityProviders, gatewayProviders, gatewayAuthTypes: [...new Set(gatewayProviders.map(g => g.authType))], diff --git a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts index 35e06ecd5..ea00b4615 100644 --- a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts @@ -1,7 +1,9 @@ import { CodeZipDevServer } from '../codezip-dev-server'; import type { DevConfig } from '../config'; import type { DevServerCallbacks, DevServerOptions } from '../dev-server'; +import { spawnSync } from 'child_process'; import { EventEmitter } from 'events'; +import { existsSync } from 'fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockSpawn = vi.fn(); @@ -14,6 +16,9 @@ vi.mock('fs', () => ({ existsSync: vi.fn(() => true), })); +const mockSpawnSync = vi.mocked(spawnSync); +const mockExistsSync = vi.mocked(existsSync); + vi.mock('../../../../lib/utils/platform', () => ({ getVenvExecutable: (venvPath: string, executable: string) => `${venvPath}/bin/${executable}`, })); @@ -121,6 +126,82 @@ describe('CodeZipDevServer spawn config', () => { expect(env.MY_KEY).toBe('secret'); }); + it('TypeScript HTTP: uses npx tsx watch with the entry file', async () => { + const config: DevConfig = { + agentName: 'TsAgent', + module: 'main.ts', + directory: '/project/app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + await server.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'npx', + ['tsx', 'watch', 'main.ts'], + expect.objectContaining({ cwd: '/project/app' }) + ); + const env = mockSpawn.mock.calls[0]![2].env; + expect(env.PORT).toBe('8080'); + expect(env.LOCAL_DEV).toBe('1'); + }); + + it('TypeScript: installs node dependencies when node_modules missing', async () => { + mockExistsSync.mockImplementation((p: unknown) => { + const s = String(p); + if (s.endsWith('node_modules')) return false; + if (s.endsWith('pnpm-lock.yaml')) return false; + if (s.endsWith('yarn.lock')) return false; + return true; + }); + mockSpawnSync.mockClear(); + mockSpawnSync.mockReturnValue({ + status: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as any); + + const config: DevConfig = { + agentName: 'TsAgent', + module: 'main.ts', + directory: '/project/app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + await server.start(); + + expect(mockSpawnSync).toHaveBeenCalledWith('npm', ['install'], expect.objectContaining({ cwd: '/project/app' })); + mockExistsSync.mockImplementation(() => true); + }); + + it('TypeScript: skips install when node_modules exists', async () => { + mockExistsSync.mockImplementation(() => true); + mockSpawnSync.mockClear(); + + const config: DevConfig = { + agentName: 'TsAgent', + module: 'main.ts', + directory: '/project/app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + await server.start(); + + expect(mockSpawnSync).not.toHaveBeenCalledWith('npm', ['install'], expect.anything()); + }); + it('MCP: extracts file from module:function entrypoint', async () => { const config: DevConfig = { agentName: 'McpAgent', diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 844ab437c..3d942ca7c 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -35,13 +35,14 @@ describe('getDevConfig', () => { name: 'TestProject', version: 1, managedBy: 'CDK' as const, + // Agent with no entrypoint — not dev-supported runtimes: [ { - name: 'NodeAgent', + name: 'BrokenAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), // Not a Python agent - codeLocation: dirPath('./agents/node'), + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath(''), + codeLocation: dirPath('./agents/broken'), protocol: 'HTTP', }, ], @@ -127,18 +128,18 @@ describe('getDevConfig', () => { ); }); - it('throws when specified agent is not Python', () => { + it('returns TypeScript config when project has a Node agent with .ts entrypoint', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', version: 1, managedBy: 'CDK' as const, runtimes: [ { - name: 'NodeAgent', + name: 'TsAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), - codeLocation: dirPath('./agents/node'), + runtimeVersion: 'NODE_22', + entrypoint: filePath('main.ts'), + codeLocation: dirPath('./agents/ts'), protocol: 'HTTP', }, ], @@ -153,7 +154,10 @@ describe('getDevConfig', () => { httpGateways: [], }; - expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); + const config = getDevConfig(workingDir, project, undefined, 'TsAgent'); + expect(config).not.toBeNull(); + expect(config?.agentName).toBe('TsAgent'); + expect(config?.isPython).toBe(false); }); it('resolves directory from codeLocation relative to configRoot', () => { @@ -529,7 +533,7 @@ describe('getDevSupportedAgents', () => { expect(getDevSupportedAgents(project)).toEqual([]); }); - it('returns empty array when no agents are Python', () => { + it('returns Node agents as dev-supported alongside Python', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', version: 1, @@ -538,8 +542,8 @@ describe('getDevSupportedAgents', () => { { name: 'NodeAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), + runtimeVersion: 'NODE_22', + entrypoint: filePath('main.ts'), codeLocation: dirPath('./agents/node'), protocol: 'HTTP', }, @@ -555,10 +559,12 @@ describe('getDevSupportedAgents', () => { httpGateways: [], }; - expect(getDevSupportedAgents(project)).toEqual([]); + const supported = getDevSupportedAgents(project); + expect(supported).toHaveLength(1); + expect(supported[0]?.name).toBe('NodeAgent'); }); - it('returns only Python agents with entrypoints', () => { + it('returns both Python and Node agents with entrypoints', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', version: 1, @@ -575,8 +581,8 @@ describe('getDevSupportedAgents', () => { { name: 'NodeAgent', build: 'CodeZip', - runtimeVersion: 'NODE_20', - entrypoint: filePath('index.js'), + runtimeVersion: 'NODE_22', + entrypoint: filePath('main.ts'), codeLocation: dirPath('./agents/node'), protocol: 'HTTP', }, @@ -593,8 +599,7 @@ describe('getDevSupportedAgents', () => { }; const supported = getDevSupportedAgents(project); - expect(supported).toHaveLength(1); - expect(supported[0]?.name).toBe('PythonAgent'); + expect(supported.map(a => a.name)).toEqual(['PythonAgent', 'NodeAgent']); }); it('includes Container agents with entrypoints', () => { diff --git a/src/cli/operations/dev/__tests__/container-dev-server.test.ts b/src/cli/operations/dev/__tests__/container-dev-server.test.ts index e8510ce94..bfd374e91 100644 --- a/src/cli/operations/dev/__tests__/container-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/container-dev-server.test.ts @@ -215,8 +215,8 @@ describe('ContainerDevServer', () => { const server = new ContainerDevServer(defaultConfig, defaultOptions); await server.start(); - // spawnSync only called once for rm (build uses async spawn) - expect(mockSpawnSync).toHaveBeenCalledTimes(1); + // rm call uses spawnSync (build uses async spawn); resolveHostCredentials may also call spawnSync + expect(mockSpawnSync).toHaveBeenCalledWith('docker', expect.arrayContaining(['rm', '-f']), expect.anything()); // First spawn call is the build const buildCall = mockSpawn.mock.calls[0]!; const buildArgs = buildCall[1] as string[]; @@ -377,7 +377,24 @@ describe('ContainerDevServer', () => { expect(spawnArgs).toContain('AWS_SESSION_TOKEN=FwoGZXIvYXdzEBY'); expect(spawnArgs).toContain('AWS_REGION=us-east-1'); expect(spawnArgs).toContain('AWS_DEFAULT_REGION=us-west-2'); + expect(spawnArgs).not.toContain('AWS_PROFILE=dev-profile'); + }); + + it('forwards AWS_PROFILE when no explicit credentials are set', async () => { + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + process.env.AWS_REGION = 'us-east-1'; + process.env.AWS_PROFILE = 'dev-profile'; + + mockSuccessfulPrepare(); + + const server = new ContainerDevServer(defaultConfig, defaultOptions); + await server.start(); + + const spawnArgs = getSpawnArgs(); expect(spawnArgs).toContain('AWS_PROFILE=dev-profile'); + expect(spawnArgs).toContain('AWS_REGION=us-east-1'); }); it('does not include AWS env vars when not set', async () => { diff --git a/src/cli/operations/dev/codezip-dev-server.ts b/src/cli/operations/dev/codezip-dev-server.ts index 31804a036..024d251fb 100644 --- a/src/cli/operations/dev/codezip-dev-server.ts +++ b/src/cli/operations/dev/codezip-dev-server.ts @@ -67,6 +67,35 @@ function ensurePythonVenv( return true; } +/** + * Ensures Node dependencies are installed. Runs the appropriate package manager + * install if `node_modules` is missing. Detects pnpm/yarn via lockfile, else npm. + */ +function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) => void): boolean { + if (existsSync(join(cwd, 'node_modules'))) { + return true; + } + + let cmd = 'npm'; + let args = ['install']; + if (existsSync(join(cwd, 'pnpm-lock.yaml'))) { + cmd = 'pnpm'; + args = ['install']; + } else if (existsSync(join(cwd, 'yarn.lock'))) { + cmd = 'yarn'; + args = ['install']; + } + + onLog('system', 'Installing Node dependencies...'); + const result = spawnSync(cmd, args, { cwd, stdio: 'pipe' }); + if (result.status !== 0) { + onLog('error', `Failed to install Node dependencies: ${result.stderr?.toString() || 'unknown error'}`); + return false; + } + onLog('system', 'Node dependencies ready'); + return true; +} + /** * Locate the directory containing OpenTelemetry's auto-instrumentation sitecustomize.py. * When this directory is prepended to PYTHONPATH, Python will execute sitecustomize.py @@ -99,7 +128,7 @@ export class CodeZipDevServer extends DevServer { return Promise.resolve( this.config.isPython ? ensurePythonVenv(this.config.directory, this.options.callbacks.onLog, this.config.protocol) - : true + : ensureNodeDeps(this.config.directory, this.options.callbacks.onLog) ); } @@ -118,9 +147,11 @@ export class CodeZipDevServer extends DevServer { } if (!isPython) { + // TS entrypoint is already a file path like "main.ts" — pass it straight to tsx. + const entryFile = module.split(':')[0] ?? module; return { cmd: 'npx', - args: ['tsx', 'watch', (module.split(':')[0] ?? module).replace(/\./g, '/') + '.ts'], + args: ['tsx', 'watch', entryFile], cwd: directory, env, }; diff --git a/src/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index fd13637bb..95b855124 100644 --- a/src/cli/operations/dev/config.ts +++ b/src/cli/operations/dev/config.ts @@ -29,8 +29,7 @@ function isPythonAgent(agent: AgentEnvSpec): boolean { * Checks if dev mode is supported for the given agent. * * Requirements: - * - Agent must target Python (TypeScript support not yet implemented) - * - CodeZip agents must have entrypoint + * - Agent must have an entrypoint */ function isDevSupported(agent: AgentEnvSpec): DevSupportResult { if (!agent.entrypoint) { @@ -40,19 +39,6 @@ function isDevSupported(agent: AgentEnvSpec): DevSupportResult { }; } - // Container agents are supported for dev mode (requires local container runtime) - if (agent.build === 'Container') { - return { supported: true }; - } - - // Currently only Python is supported for CodeZip dev mode - if (!isPythonAgent(agent)) { - return { - supported: false, - reason: `Dev mode only supports Python agents. Agent "${agent.name}" does not appear to be a Python agent.`, - }; - } - return { supported: true }; } diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index 10ef21b3f..5fb21ee29 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -123,17 +123,69 @@ export class ContainerDevServer extends DevServer { }); } + /** + * Resolve AWS credentials on the host and return them as plain env vars. + * + * Why: Container Dockerfiles run as `USER bedrock_agentcore` (non-root, different + * uid from the host user). Mounted ~/.aws files have 600 permissions owned by the + * host uid, so the container user cannot read them. Additionally, credential_process + * tools like `ada` are not installed inside the image. + * + * By resolving credentials on the host (where ada/SSO/profiles work) and injecting + * the resulting AWS_ACCESS_KEY_ID/SECRET/TOKEN as container env vars, we avoid both + * problems. This applies to all container agents (Python and TypeScript). + * + * Security: acceptable for local dev — credentials are short-lived STS session + * tokens visible only on the developer's machine (same as `docker run -e`). + */ + private resolveHostCredentials(): Record | null { + const profile = process.env.AWS_PROFILE ?? 'default'; + try { + const result = spawnSync('aws', ['configure', 'export-credentials', '--format', 'env', '--profile', profile], { + encoding: 'utf-8', + timeout: 10_000, + env: { ...process.env }, + }); + if (result.status !== 0 || !result.stdout) return null; + + const creds: Record = {}; + for (const line of result.stdout.split('\n')) { + const match = /^export\s+(AWS_\w+)=(.+)$/.exec(line); + if (match?.[1] && match[2]) creds[match[1]] = match[2]; + } + return creds.AWS_ACCESS_KEY_ID ? creds : null; + } catch { + return null; + } + } + protected getSpawnConfig(): SpawnConfig { const { port, envVars = {} } = this.options; - // Forward AWS credentials from host environment into the container + // Forward AWS credentials from host environment into the container. + // When explicit credentials are present, omit AWS_PROFILE so SDK credential + // chains prefer the env var credentials over profile-based resolution (which + // can fail when the container user cannot read the mounted ~/.aws files). + // If no explicit creds exist, resolve them on the host via `aws configure + // export-credentials` so containers don't need tools like ada/SSO browsers. + let hasExplicitCreds = !!process.env.AWS_ACCESS_KEY_ID; + if (!hasExplicitCreds) { + const resolved = this.resolveHostCredentials(); + if (resolved) { + for (const [k, v] of Object.entries(resolved)) { + process.env[k] = v; + } + hasExplicitCreds = true; + } + } + const awsEnvKeys = [ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION', - 'AWS_PROFILE', + ...(hasExplicitCreds ? [] : ['AWS_PROFILE']), ]; const awsEnvVars: Record = {}; for (const key of awsEnvKeys) { @@ -142,18 +194,19 @@ export class ContainerDevServer extends DevServer { } } - // Mount ~/.aws to a neutral path accessible by any container user, and set - // AWS SDK env vars to point to it. This supports SSO, profiles, and credential files - // regardless of what USER the Dockerfile specifies. + // Mount ~/.aws only when we couldn't resolve explicit credentials. + // This avoids containers hitting credential_process commands (e.g. ada) + // that aren't installed inside the image. const awsDir = join(homedir(), '.aws'); const awsContainerPath = '/aws-config'; - const awsMountArgs = existsSync(awsDir) ? ['-v', `${awsDir}:${awsContainerPath}:ro`] : []; - const awsConfigEnv = existsSync(awsDir) - ? { - AWS_CONFIG_FILE: `${awsContainerPath}/config`, - AWS_SHARED_CREDENTIALS_FILE: `${awsContainerPath}/credentials`, - } - : {}; + const awsMountArgs = !hasExplicitCreds && existsSync(awsDir) ? ['-v', `${awsDir}:${awsContainerPath}:ro`] : []; + const awsConfigEnv = + !hasExplicitCreds && existsSync(awsDir) + ? { + AWS_CONFIG_FILE: `${awsContainerPath}/config`, + AWS_SHARED_CREDENTIALS_FILE: `${awsContainerPath}/credentials`, + } + : {}; // Environment variables: AWS creds + config paths + user env + container-specific overrides. // OTEL env vars (endpoint + protocol) are passed via envVars from the caller, diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts index 41857bda1..52c9c129e 100644 --- a/src/cli/operations/dev/web-ui/handlers/start.ts +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -91,29 +91,24 @@ async function doStartAgent( const agentIndex = ctx.options.agents.findIndex(a => a.name === agentName); const { onLog } = ctx.options; - // A2A agents use a fixed framework port (9000) that can't be overridden via env vars — - // serve_a2a() accepts port as a function parameter, not from the environment. - // MCP agents (FastMCP) also use a fixed port: FastMCP.__init__ passes port=8000 as a - // pydantic BaseSettings init kwarg, which takes priority over the FASTMCP_PORT env var - // we set. So MCP agents always bind to 8000 regardless of environment configuration. + // Several frameworks bind to a fixed port that ignores the PORT env var: + // - A2A: serve_a2a() accepts port as a function parameter, not from env → 9000 + // - MCP (FastMCP): pydantic BaseSettings init kwarg overrides env → 8000 + // TS HTTP agents read PORT env var so we can assign any available port. + // For Python HTTP agents, uvicorn takes --port as a CLI arg so we can assign any port. const isA2A = config.protocol === 'A2A'; const isMCP = config.protocol === 'MCP'; - const targetPort = isA2A ? 9000 : isMCP ? 8000 : ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0); + const fixedPort = isA2A ? 9000 : isMCP ? 8000 : undefined; + const isTsHttp = !config.isPython && config.protocol === 'HTTP'; + const targetPort = fixedPort ?? ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0); const agentPort = await findAvailablePort(targetPort); - if (isA2A && agentPort !== 9000) { + if (fixedPort && agentPort !== fixedPort) { + const reason = isA2A ? 'A2A agents require port 9000.' : 'MCP agents require port 8000 (FastMCP default).'; return { success: false, name: agentName, port: 0, - error: `Port 9000 is in use. A2A agents require port 9000.`, - }; - } - if (isMCP && agentPort !== 8000) { - return { - success: false, - name: agentName, - port: 0, - error: `Port 8000 is in use. MCP agents require port 8000 (FastMCP default).`, + error: `Port ${fixedPort} is in use. ${reason}`, }; } if (agentPort !== targetPort) { @@ -150,7 +145,11 @@ async function doStartAgent( }; const baseEnvVars = ctx.options.getEnvVars ? await ctx.options.getEnvVars() : (ctx.options.envVars ?? {}); - const agentEnvVars = { ...baseEnvVars, OTEL_SERVICE_NAME: agentName }; + const agentEnvVars = { + ...baseEnvVars, + OTEL_SERVICE_NAME: agentName, + ...(isTsHttp ? { PORT: String(agentPort) } : {}), + }; const agentServer = createDevServer(config, { port: agentPort, diff --git a/src/cli/operations/index.ts b/src/cli/operations/index.ts index c09332659..f16f1b2cc 100644 --- a/src/cli/operations/index.ts +++ b/src/cli/operations/index.ts @@ -4,6 +4,7 @@ export * from './dev'; export * from './fetch-access'; export * from './init'; export * from './mcp'; +export * from './node'; export * from './python'; export * from './remove'; export * from './resolve-agent'; diff --git a/src/cli/operations/node/__tests__/setup.test.ts b/src/cli/operations/node/__tests__/setup.test.ts new file mode 100644 index 000000000..4f30b36f9 --- /dev/null +++ b/src/cli/operations/node/__tests__/setup.test.ts @@ -0,0 +1,107 @@ +import * as lib from '../../../../lib/index.js'; +import { checkNpmAvailable, installNodeDependencies, setupNodeProject } from '../setup.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../lib/index.js', async () => { + const actual = await vi.importActual('../../../../lib/index.js'); + return { + ...actual, + checkSubprocess: vi.fn(), + runSubprocessCapture: vi.fn(), + }; +}); + +const mockCheckSubprocess = vi.mocked(lib.checkSubprocess); +const mockRunSubprocessCapture = vi.mocked(lib.runSubprocessCapture); + +describe('checkNpmAvailable', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns true when npm is available', async () => { + mockCheckSubprocess.mockResolvedValue(true); + + expect(await checkNpmAvailable()).toBe(true); + expect(mockCheckSubprocess).toHaveBeenCalledWith('npm', ['--version']); + }); + + it('returns false when npm is not available', async () => { + mockCheckSubprocess.mockResolvedValue(false); + + expect(await checkNpmAvailable()).toBe(false); + }); +}); + +describe('installNodeDependencies', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns success when install succeeds', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + const result = await installNodeDependencies('/project'); + + expect(result.status).toBe('success'); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('npm', ['install'], { cwd: '/project' }); + }); + + it('returns install_failed on error', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: 'some output', stderr: '', signal: null }); + + const result = await installNodeDependencies('/project'); + + expect(result.status).toBe('install_failed'); + expect(result.error).toBe('some output'); + }); +}); + +describe('setupNodeProject', () => { + const origEnv = process.env.AGENTCORE_SKIP_INSTALL; + + afterEach(() => { + vi.clearAllMocks(); + if (origEnv !== undefined) process.env.AGENTCORE_SKIP_INSTALL = origEnv; + else delete process.env.AGENTCORE_SKIP_INSTALL; + }); + + it('skips install when AGENTCORE_SKIP_INSTALL is set', async () => { + process.env.AGENTCORE_SKIP_INSTALL = '1'; + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('success'); + expect(mockCheckSubprocess).not.toHaveBeenCalled(); + }); + + it('returns npm_not_found when npm is not available', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(false); + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('npm_not_found'); + expect(result.error).toContain('npm'); + }); + + it('returns install_failed when npm install fails', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: '', stderr: 'npm fail', signal: null }); + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('install_failed'); + }); + + it('returns success when full setup succeeds', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + const result = await setupNodeProject({ projectDir: '/project' }); + + expect(result.status).toBe('success'); + }); +}); diff --git a/src/cli/operations/node/index.ts b/src/cli/operations/node/index.ts new file mode 100644 index 000000000..79b911e54 --- /dev/null +++ b/src/cli/operations/node/index.ts @@ -0,0 +1,8 @@ +export { + checkNpmAvailable, + installNodeDependencies, + setupNodeProject, + type NodeSetupResult, + type NodeSetupStatus, + type NodeSetupOptions, +} from './setup'; diff --git a/src/cli/operations/node/setup.ts b/src/cli/operations/node/setup.ts new file mode 100644 index 000000000..5be415478 --- /dev/null +++ b/src/cli/operations/node/setup.ts @@ -0,0 +1,51 @@ +import { checkSubprocess, runSubprocessCapture } from '../../../lib'; + +export type NodeSetupStatus = 'success' | 'npm_not_found' | 'install_failed'; + +export interface NodeSetupResult { + status: NodeSetupStatus; + error?: string; +} + +export interface NodeSetupOptions { + projectDir: string; +} + +/** + * Check if npm is available on the system. + */ +export async function checkNpmAvailable(): Promise { + return checkSubprocess('npm', ['--version']); +} + +/** + * Install dependencies using npm install. + * Uses `npm install` (not `npm ci`) because fresh scaffolds don't ship a lockfile. + */ +export async function installNodeDependencies(projectDir: string): Promise { + const result = await runSubprocessCapture('npm', ['install'], { cwd: projectDir }); + if (result.code === 0) { + return { status: 'success' }; + } + return { status: 'install_failed', error: result.stderr || result.stdout }; +} + +/** + * Set up a Node.js project: run `npm install`. + * Returns a result with status and optional error details. + */ +export async function setupNodeProject(options: NodeSetupOptions): Promise { + if (process.env.AGENTCORE_SKIP_INSTALL) return { status: 'success' }; + + const { projectDir } = options; + + const npmAvailable = await checkNpmAvailable(); + if (!npmAvailable) { + return { + status: 'npm_not_found', + error: "'npm' not found. Install Node.js from https://nodejs.org/", + }; + } + + return installNodeDependencies(projectDir); +} diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index d82d9e808..0bd7e3ca4 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -243,7 +243,10 @@ export class AgentPrimitive extends BasePrimitive', 'Agent type: create, byo, or import [non-interactive]', 'create') .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') .option('--language ', 'Language: Python (create), or Python/TypeScript/Other (BYO) [non-interactive]') - .option('--framework ', 'Framework: Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents [non-interactive]') + .option( + '--framework ', + 'Framework: Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents, VercelAI [non-interactive]' + ) .option('--model-provider ', 'Model provider: Bedrock, Anthropic, OpenAI, Gemini [non-interactive]') .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') .option('--memory ', 'Memory: none, shortTerm, longAndShortTerm (create path only) [non-interactive]') diff --git a/src/cli/templates/VercelAIRenderer.ts b/src/cli/templates/VercelAIRenderer.ts new file mode 100644 index 000000000..0fd1f9f93 --- /dev/null +++ b/src/cli/templates/VercelAIRenderer.ts @@ -0,0 +1,9 @@ +import { BaseRenderer } from './BaseRenderer'; +import { TEMPLATE_ROOT } from './templateRoot'; +import type { AgentRenderConfig } from './types'; + +export class VercelAIRenderer extends BaseRenderer { + constructor(config: AgentRenderConfig) { + super(config, 'vercelai', TEMPLATE_ROOT, config.protocol ?? 'http'); + } +} diff --git a/src/cli/templates/index.ts b/src/cli/templates/index.ts index 3e57beb8d..e41e563b3 100644 --- a/src/cli/templates/index.ts +++ b/src/cli/templates/index.ts @@ -4,6 +4,7 @@ import { LangGraphRenderer } from './LangGraphRenderer'; import { McpRenderer } from './McpRenderer'; import { OpenAIAgentsRenderer } from './OpenAIAgentsRenderer'; import { StrandsRenderer } from './StrandsRenderer'; +import { VercelAIRenderer } from './VercelAIRenderer'; import type { AgentRenderConfig } from './types'; export { BaseRenderer, type RendererContext } from './BaseRenderer'; @@ -14,6 +15,7 @@ export { LangGraphRenderer } from './LangGraphRenderer'; export { McpRenderer } from './McpRenderer'; export { OpenAIAgentsRenderer } from './OpenAIAgentsRenderer'; export { StrandsRenderer } from './StrandsRenderer'; +export { VercelAIRenderer } from './VercelAIRenderer'; export type { AgentRenderConfig } from './types'; /** @@ -34,6 +36,8 @@ export function createRenderer(config: AgentRenderConfig): BaseRenderer { return new LangGraphRenderer(config); case 'OpenAIAgents': return new OpenAIAgentsRenderer(config); + case 'VercelAI': + return new VercelAIRenderer(config); default: { const _exhaustive: never = config.sdkFramework; throw new Error(`Unsupported SDK framework: ${String(_exhaustive)}`); diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index a580b8477..c964b2419 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -195,7 +195,9 @@ export function useDevServer(options: { // Detect when server is actually ready (only once) if ( !serverReady && - (message.includes('Application startup complete') || message.includes('Uvicorn running')) + (message.includes('Application startup complete') || + message.includes('Uvicorn running') || + message.includes('Server listening')) ) { serverReady = true; setStatus('running'); diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index c708bcac0..f4ad9ae27 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -156,7 +156,7 @@ export const AGENT_TYPE_OPTIONS = [ export const LANGUAGE_OPTIONS = [ { id: 'Python', title: 'Python' }, - { id: 'TypeScript', title: 'TypeScript (coming soon)', disabled: true }, + { id: 'TypeScript', title: 'TypeScript' }, { id: 'Other', title: 'Other' }, ] as const; diff --git a/src/cli/tui/screens/create/CreateScreen.tsx b/src/cli/tui/screens/create/CreateScreen.tsx index 801dfc8d8..03ff31fbd 100644 --- a/src/cli/tui/screens/create/CreateScreen.tsx +++ b/src/cli/tui/screens/create/CreateScreen.tsx @@ -312,11 +312,6 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS {phase === 'create-prompt' && ( <> - - - Project: {flow.projectName} - - Would you like to add an agent now? diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index cff113610..f335c9a6e 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -10,7 +10,7 @@ import { import type { DeployedState } from '../../../../schema'; import { getErrorMessage } from '../../../errors'; import { CreateLogger } from '../../../logging'; -import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../../operations'; +import { initGitRepo, setupNodeProject, setupPythonProject, writeEnvFile, writeGitignore } from '../../../operations'; import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, @@ -83,6 +83,9 @@ function getCreateSteps(projectName: string, agentConfig: AddAgentConfig | null) if (agentConfig.language === 'Python' && agentConfig.agentType === 'create') { steps.push({ label: 'Set up Python environment', status: 'pending' }); } + if (agentConfig.language === 'TypeScript' && agentConfig.agentType === 'create') { + steps.push({ label: 'Set up Node environment', status: 'pending' }); + } } steps.push({ label: 'Prepare agentcore/ directory', status: 'pending' }); @@ -489,6 +492,36 @@ export function useCreateFlow(cwd: string): CreateFlowState { } stepIndex++; } + + // Step: Set up Node environment (if TypeScript and create path) + if (addAgentConfig.language === 'TypeScript' && addAgentConfig.agentType === 'create') { + logger.startStep('Set up Node environment'); + updateStep(stepIndex, { status: 'running' }); + const agentDir = join(projectRoot, APP_DIR, addAgentConfig.name); + logger.logSubStep(`Agent directory: ${agentDir}`); + logger.logSubStep('Running npm install...'); + const result = await setupNodeProject({ projectDir: agentDir }); + + if (result.status === 'success') { + logger.endStep('success'); + updateStep(stepIndex, { status: 'success' }); + } else { + const firstLine = (result.error ?? '').split('\n').find(l => l.trim().length > 0) ?? ''; + const shortReason = firstLine.replace(/^npm (error|warn) /i, '').slice(0, 160); + const warnMsg = + result.status === 'npm_not_found' + ? 'npm not found on PATH. Install Node.js 20+ from https://nodejs.org/ and rerun `npm install` in the agent directory.' + : `npm install failed${shortReason ? `: ${shortReason}` : ''}. Run \`npm install\` in ${agentDir} to see the full error.`; + if (result.error) { + for (const line of result.error.split('\n')) { + if (line.trim().length > 0) logger.logSubStep(line); + } + } + logger.endStep('warn', warnMsg); + updateStep(stepIndex, { status: 'warn', warn: warnMsg }); + } + stepIndex++; + } } // Step: Create CDK project diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 637a5d832..e65aa77f5 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -644,7 +644,12 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const targetRegion = context?.awsTargets[0]?.region; const targetAccount = context?.awsTargets[0]?.account; const hasGateways = (context?.projectSpec.agentCoreGateways?.length ?? 0) > 0; - if ((agentNames.length > 0 || hasGateways) && targetRegion && targetAccount) { + const hasPythonAgent = + context?.projectSpec.runtimes?.some( + (a: { entrypoint?: string }) => + (a.entrypoint?.endsWith('.py') ?? false) || (a.entrypoint?.includes('.py:') ?? false) + ) ?? false; + if ((agentNames.length > 0 || hasGateways) && hasPythonAgent && targetRegion && targetAccount) { try { const tsResult = await setupTransactionSearch({ region: targetRegion, diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index 0040aaf30..dfe798549 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -427,7 +427,7 @@ export function DevScreen(props: DevScreenProps) { No agents defined in project. - Dev mode requires at least one Python agent with an entrypoint. + Dev mode requires at least one agent with an entrypoint. Run agentcore add agent to create one. diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 9c6c79599..96f5fb4e1 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -32,6 +32,7 @@ import { PROTOCOL_OPTIONS, STEP_LABELS, getModelProviderOptionsForSdk, + getProtocolOptionsForLanguage, getSDKOptionsForProtocol, } from './types'; import type { useGenerateWizard } from './useGenerateWizard'; @@ -77,14 +78,17 @@ export function GenerateWizardUI({ return LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, - disabled: 'disabled' in o ? o.disabled : undefined, })); case 'buildType': return BUILD_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); case 'protocol': - return PROTOCOL_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); + return getProtocolOptionsForLanguage(wizard.config.language).map(o => ({ + id: o.id, + title: o.title, + description: o.description, + })); case 'sdk': - return getSDKOptionsForProtocol(wizard.config.protocol).map(o => ({ + return getSDKOptionsForProtocol(wizard.config.protocol, wizard.config.language).map(o => ({ id: o.id, title: o.title, description: o.description, diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 1b764ea80..2f9a304fd 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -106,7 +106,7 @@ export const STEP_LABELS: Record = { export const LANGUAGE_OPTIONS = [ { id: 'Python', title: 'Python' }, - { id: 'TypeScript', title: 'TypeScript (coming soon)', disabled: true }, + { id: 'TypeScript', title: 'TypeScript' }, ] as const; export const BUILD_TYPE_OPTIONS = [ @@ -121,19 +121,36 @@ export const PROTOCOL_OPTIONS = [ { id: 'AGUI', title: 'AG-UI', description: 'Stream rich agent events to frontends' }, ] as const; +/** + * Get protocol options filtered by target language. + * TypeScript only supports HTTP. + */ +export function getProtocolOptionsForLanguage(language?: TargetLanguage) { + if (language === 'TypeScript') { + return PROTOCOL_OPTIONS.filter(option => option.id === 'HTTP'); + } + return [...PROTOCOL_OPTIONS]; +} + export const SDK_OPTIONS = [ { id: 'Strands', title: 'Strands Agents SDK', description: 'AWS native agent framework' }, { id: 'LangChain_LangGraph', title: 'LangChain + LangGraph', description: 'Popular open-source frameworks' }, { id: 'GoogleADK', title: 'Google ADK', description: 'Google Agent Development Kit' }, { id: 'OpenAIAgents', title: 'OpenAI Agents', description: 'OpenAI native agent SDK' }, + { id: 'VercelAI', title: 'Vercel AI SDK', description: 'Vercel AI SDK for TypeScript agents' }, ] as const; /** - * Get SDK options filtered by protocol compatibility. + * Get SDK options filtered by protocol compatibility and target language. + * TypeScript currently only supports Strands. */ -export function getSDKOptionsForProtocol(protocol: ProtocolMode) { +export function getSDKOptionsForProtocol(protocol: ProtocolMode, language?: TargetLanguage) { const supportedFrameworks = PROTOCOL_FRAMEWORK_MATRIX[protocol]; - return SDK_OPTIONS.filter(option => supportedFrameworks.includes(option.id)); + const byProtocol = SDK_OPTIONS.filter(option => supportedFrameworks.includes(option.id)); + if (language === 'TypeScript') { + return byProtocol.filter(option => option.id === 'Strands' || option.id === 'VercelAI'); + } + return byProtocol; } export const MODEL_PROVIDER_OPTIONS = [ diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 411cfb151..2267856c5 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -53,7 +53,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { if (config.modelProvider === 'Bedrock') { filtered = filtered.filter(s => s !== 'apiKey'); } - if (sdkSelected && config.sdk === 'Strands') { + if (sdkSelected && config.sdk === 'Strands' && config.language !== 'TypeScript') { const advancedIndex = filtered.indexOf('advanced'); filtered = [...filtered.slice(0, advancedIndex), 'memory', ...filtered.slice(advancedIndex)]; } @@ -102,6 +102,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { config.buildType, config.modelProvider, config.sdk, + config.language, config.protocol, config.networkMode, config.authorizerType, @@ -126,7 +127,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { }, []); const setLanguage = useCallback((language: GenerateConfig['language']) => { - setConfig(c => ({ ...c, language })); + setConfig(c => ({ ...c, language, memory: language === 'TypeScript' ? 'none' : c.memory })); setStep('buildType'); }, []); @@ -164,34 +165,34 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { // Non-Bedrock providers need API key step if (modelProvider !== 'Bedrock') { setStep('apiKey'); - } else if (config.sdk === 'Strands') { + } else if (config.sdk === 'Strands' && config.language !== 'TypeScript') { setStep('memory'); } else { setStep('advanced'); } }, - [config.sdk] + [config.sdk, config.language] ); const setApiKey = useCallback( (apiKey: string | undefined) => { setConfig(c => ({ ...c, apiKey })); - if (config.sdk === 'Strands') { + if (config.sdk === 'Strands' && config.language !== 'TypeScript') { setStep('memory'); } else { setStep('advanced'); } }, - [config.sdk] + [config.sdk, config.language] ); const skipApiKey = useCallback(() => { - if (config.sdk === 'Strands') { + if (config.sdk === 'Strands' && config.language !== 'TypeScript') { setStep('memory'); } else { setStep('advanced'); } - }, [config.sdk]); + }, [config.sdk, config.language]); const setMemory = useCallback((memory: MemoryOption) => { setConfig(c => ({ ...c, memory })); diff --git a/src/cli/tui/screens/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index d5e70330c..b4c7557d0 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -365,7 +365,7 @@ export function InvokeScreen({ const agent = config.runtimes[selectedAgent]; const traceUrl = - mode !== 'select-agent' && agent + mode !== 'select-agent' && agent?.supportsTraces ? buildTraceConsoleUrl({ region: config.target.region, accountId: config.target.account, diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 0202d6dab..25dc838ab 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -43,6 +43,7 @@ export interface InvokeConfig { protocol?: ProtocolMode; authorizerType?: RuntimeAuthorizerType; baggage?: string; + supportsTraces: boolean; }[]; target: AwsDeploymentTarget; targetName: string; @@ -155,6 +156,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState } } + const supportsTraces = agent.entrypoint?.endsWith('.py') || agent.entrypoint?.includes('.py:') || false; runtimes.push({ name: agent.name, state, @@ -163,6 +165,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState protocol: agent.protocol, authorizerType: agent.authorizerType, baggage, + supportsTraces, }); } diff --git a/src/lib/packaging/__tests__/node-packager.test.ts b/src/lib/packaging/__tests__/node-packager.test.ts index 5ef98a079..e13d40bb8 100644 --- a/src/lib/packaging/__tests__/node-packager.test.ts +++ b/src/lib/packaging/__tests__/node-packager.test.ts @@ -1,35 +1,37 @@ import { NodeCodeZipPackager, NodeCodeZipPackagerSync } from '../node.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; -const mockRunSubprocessCapture = vi.fn(); -const mockRunSubprocessCaptureSync = vi.fn(); -const mockResolveProjectPaths = vi.fn(); -const mockResolveProjectPathsSync = vi.fn(); -const mockEnsureBinaryAvailable = vi.fn(); -const mockEnsureBinaryAvailableSync = vi.fn(); +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), + cpSync: vi.fn(), + }; +}); + +const mockBuild = vi.fn(); +const mockBuildSync = vi.fn(); +const mockResolveNodeProjectPaths = vi.fn(); +const mockResolveNodeProjectPathsSync = vi.fn(); const mockEnsureDirClean = vi.fn(); const mockEnsureDirCleanSync = vi.fn(); -const mockCopySourceTree = vi.fn(); -const mockCopySourceTreeSync = vi.fn(); const mockCreateZipFromDir = vi.fn(); const mockCreateZipFromDirSync = vi.fn(); const mockEnforceZipSizeLimit = vi.fn(); const mockEnforceZipSizeLimitSync = vi.fn(); -vi.mock('../../utils/subprocess', () => ({ - runSubprocessCapture: (...args: unknown[]) => mockRunSubprocessCapture(...args), - runSubprocessCaptureSync: (...args: unknown[]) => mockRunSubprocessCaptureSync(...args), +vi.mock('esbuild', () => ({ + build: (...args: unknown[]) => mockBuild(...args), + buildSync: (...args: unknown[]) => mockBuildSync(...args), })); vi.mock('../helpers', () => ({ - resolveProjectPaths: (...args: unknown[]) => mockResolveProjectPaths(...args), - resolveProjectPathsSync: (...args: unknown[]) => mockResolveProjectPathsSync(...args), - ensureBinaryAvailable: (...args: unknown[]) => mockEnsureBinaryAvailable(...args), - ensureBinaryAvailableSync: (...args: unknown[]) => mockEnsureBinaryAvailableSync(...args), + resolveNodeProjectPaths: (...args: unknown[]) => mockResolveNodeProjectPaths(...args), + resolveNodeProjectPathsSync: (...args: unknown[]) => mockResolveNodeProjectPathsSync(...args), ensureDirClean: (...args: unknown[]) => mockEnsureDirClean(...args), ensureDirCleanSync: (...args: unknown[]) => mockEnsureDirCleanSync(...args), - copySourceTree: (...args: unknown[]) => mockCopySourceTree(...args), - copySourceTreeSync: (...args: unknown[]) => mockCopySourceTreeSync(...args), createZipFromDir: (...args: unknown[]) => mockCreateZipFromDir(...args), createZipFromDirSync: (...args: unknown[]) => mockCreateZipFromDirSync(...args), enforceZipSizeLimit: (...args: unknown[]) => mockEnforceZipSizeLimit(...args), @@ -42,7 +44,7 @@ const defaultPaths = { srcDir: '/project/src', stagingDir: '/project/.staging', artifactsDir: '/project/artifacts', - pyprojectPath: '', + pyprojectPath: '/project/package.json', }; describe('NodeCodeZipPackager', () => { @@ -62,12 +64,10 @@ describe('NodeCodeZipPackager', () => { ); }); - it('packs successfully', async () => { - mockResolveProjectPaths.mockResolvedValue(defaultPaths); - mockEnsureBinaryAvailable.mockResolvedValue(undefined); + it('packs successfully using esbuild', async () => { + mockResolveNodeProjectPaths.mockResolvedValue(defaultPaths); mockEnsureDirClean.mockResolvedValue(undefined); - mockCopySourceTree.mockResolvedValue(undefined); - mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + mockBuild.mockResolvedValue(undefined); mockCreateZipFromDir.mockResolvedValue(undefined); mockEnforceZipSizeLimit.mockResolvedValue(1024); @@ -75,22 +75,24 @@ describe('NodeCodeZipPackager', () => { expect(result.sizeBytes).toBe(1024); expect(result.stagingPath).toBe('/project/.staging'); - expect(mockRunSubprocessCapture).toHaveBeenCalledWith( - 'npm', - expect.arrayContaining(['install', '--omit=dev']), - expect.any(Object) + expect(mockBuild).toHaveBeenCalledWith( + expect.objectContaining({ + entryPoints: ['/project/src/main.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + }) ); }); - it('throws when npm install fails', async () => { - mockResolveProjectPaths.mockResolvedValue(defaultPaths); - mockEnsureBinaryAvailable.mockResolvedValue(undefined); + it('throws when esbuild fails', async () => { + mockResolveNodeProjectPaths.mockResolvedValue(defaultPaths); mockEnsureDirClean.mockResolvedValue(undefined); - mockCopySourceTree.mockResolvedValue(undefined); - mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: 'error output', stderr: '', signal: null }); + mockBuild.mockRejectedValue(new Error('Build failed: could not resolve module')); await expect(packager.pack({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'a' } as any)).rejects.toThrow( - 'error output' + 'could not resolve module' ); }); }); @@ -106,29 +108,36 @@ describe('NodeCodeZipPackagerSync', () => { ); }); - it('packs successfully', () => { - mockResolveProjectPathsSync.mockReturnValue(defaultPaths); - mockEnsureBinaryAvailableSync.mockReturnValue(undefined); + it('packs successfully using esbuild', () => { + mockResolveNodeProjectPathsSync.mockReturnValue(defaultPaths); mockEnsureDirCleanSync.mockReturnValue(undefined); - mockCopySourceTreeSync.mockReturnValue(undefined); - mockRunSubprocessCaptureSync.mockReturnValue({ code: 0, stdout: '', stderr: '', signal: null }); + mockBuildSync.mockReturnValue(undefined); mockCreateZipFromDirSync.mockReturnValue(undefined); mockEnforceZipSizeLimitSync.mockReturnValue(2048); const result = packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'myAgent' } as any); expect(result.sizeBytes).toBe(2048); + expect(mockBuildSync).toHaveBeenCalledWith( + expect.objectContaining({ + entryPoints: ['/project/src/main.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + }) + ); }); - it('throws when npm install fails', () => { - mockResolveProjectPathsSync.mockReturnValue(defaultPaths); - mockEnsureBinaryAvailableSync.mockReturnValue(undefined); + it('throws when esbuild fails', () => { + mockResolveNodeProjectPathsSync.mockReturnValue(defaultPaths); mockEnsureDirCleanSync.mockReturnValue(undefined); - mockCopySourceTreeSync.mockReturnValue(undefined); - mockRunSubprocessCaptureSync.mockReturnValue({ code: 1, stdout: '', stderr: 'install failed', signal: null }); + mockBuildSync.mockImplementation(() => { + throw new Error('Build failed'); + }); expect(() => packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'a' } as any)).toThrow( - 'install failed' + 'Build failed' ); }); }); diff --git a/src/lib/packaging/helpers.ts b/src/lib/packaging/helpers.ts index 31c74b298..6f871c410 100644 --- a/src/lib/packaging/helpers.ts +++ b/src/lib/packaging/helpers.ts @@ -277,6 +277,79 @@ export function resolveProjectPathsSync(options: PackageOptions = {}, agentName? }; } +/** + * Resolve filesystem paths for a Node.js/TypeScript agent project during packaging. + * + * Locates the nearest package.json (equivalent of pyproject.toml for Python agents), + * then derives: + * - projectRoot: directory containing package.json + * - srcDir: source directory (defaults to projectRoot) + * - artifactDir: where CDK config and build outputs live (agentcore/ directory) + * - buildDir/stagingDir: per-agent temp directories used during CodeZip packaging + * + * Note: `pyprojectPath` in the return type is reused from the Python ResolvedPaths + * interface — for Node projects it points to package.json. + */ +export async function resolveNodeProjectPaths( + options: PackageOptions = {}, + agentName?: string +): Promise { + const startDir = options.projectRoot ? resolve(options.projectRoot) : process.cwd(); + const candidatePackageJson = await findUp('package.json', startDir); + + if (!candidatePackageJson || !(await pathExists(candidatePackageJson))) { + throw new MissingProjectFileError(join(startDir, 'package.json')); + } + + const projectRoot = options.projectRoot ? resolve(options.projectRoot) : dirname(candidatePackageJson); + const srcDir = resolve(projectRoot, options.srcDir ?? '.'); + const artifactDir = resolve(options.artifactDir ?? join(projectRoot, CONFIG_DIR)); + + const name = agentName ?? 'default'; + const buildDir = join(artifactDir, name); + const stagingDir = join(buildDir, 'staging'); + const artifactsDir = artifactDir; + + return { + projectRoot, + srcDir, + pyprojectPath: candidatePackageJson, + artifactDir, + buildDir, + stagingDir, + artifactsDir, + }; +} + +/** Synchronous version of resolveNodeProjectPaths — used in contexts where async is not available. */ +export function resolveNodeProjectPathsSync(options: PackageOptions = {}, agentName?: string): ResolvedPaths { + const startDir = options.projectRoot ? resolve(options.projectRoot) : process.cwd(); + const candidatePackageJson = findUpSync('package.json', startDir); + + if (!candidatePackageJson || !pathExistsSync(candidatePackageJson)) { + throw new MissingProjectFileError(join(startDir, 'package.json')); + } + + const projectRoot = options.projectRoot ? resolve(options.projectRoot) : dirname(candidatePackageJson); + const srcDir = resolve(projectRoot, options.srcDir ?? '.'); + const artifactDir = resolve(options.artifactDir ?? join(projectRoot, CONFIG_DIR)); + + const name = agentName ?? 'default'; + const buildDir = join(artifactDir, name); + const stagingDir = join(buildDir, 'staging'); + const artifactsDir = artifactDir; + + return { + projectRoot, + srcDir, + pyprojectPath: candidatePackageJson, + artifactDir, + buildDir, + stagingDir, + artifactsDir, + }; +} + export function ensureDirCleanSync(dir: string): void { rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); diff --git a/src/lib/packaging/node.ts b/src/lib/packaging/node.ts index c02e1511f..2d12e5cd7 100644 --- a/src/lib/packaging/node.ts +++ b/src/lib/packaging/node.ts @@ -1,23 +1,20 @@ import type { AgentEnvSpec, NodeRuntime, RuntimeVersion } from '../../schema'; -import { NPM_INSTALL_HINT, getArtifactZipName } from '../constants'; -import { runSubprocessCapture, runSubprocessCaptureSync } from '../utils/subprocess'; +import { getArtifactZipName } from '../constants'; import { PackagingError } from './errors'; import { - copySourceTree, - copySourceTreeSync, createZipFromDir, createZipFromDirSync, enforceZipSizeLimit, enforceZipSizeLimitSync, - ensureBinaryAvailable, - ensureBinaryAvailableSync, ensureDirClean, ensureDirCleanSync, isNodeRuntime, - resolveProjectPaths, - resolveProjectPathsSync, + resolveNodeProjectPaths, + resolveNodeProjectPathsSync, } from './helpers'; import type { ArtifactResult, CodeZipPackager, PackageOptions, RuntimePackager } from './types/packaging'; +import { build, buildSync } from 'esbuild'; +import { cpSync, existsSync, writeFileSync } from 'fs'; import { join } from 'path'; const NODE_RUNTIME_REGEX = /NODE_(\d+)/; @@ -45,8 +42,40 @@ export function extractNodeVersion(runtime: NodeRuntime): string { return major; } +const DYNAMIC_REQUIRE_PACKAGES = [ + '@fastify/sse', + '@fastify/websocket', + 'duplexify', + 'end-of-stream', + 'fastify-plugin', + 'inherits', + 'once', + 'readable-stream', + 'safe-buffer', + 'stream-shift', + 'string_decoder', + 'util-deprecate', + 'wrappy', + 'ws', +]; + +const DEPS_DIR = '_deps'; + +function copyDynamicDeps(srcDir: string, stagingDir: string): void { + const srcNodeModules = join(srcDir, 'node_modules'); + if (!existsSync(srcNodeModules)) return; + + for (const pkg of DYNAMIC_REQUIRE_PACKAGES) { + const pkgPath = join(srcNodeModules, pkg); + if (existsSync(pkgPath)) { + cpSync(pkgPath, join(stagingDir, DEPS_DIR, pkg), { recursive: true }); + } + } +} + /** * Async Node/TypeScript packager for CLI usage. + * Bundles TypeScript source into a single JS file using esbuild. */ export class NodeCodeZipPackager implements RuntimePackager { async pack(spec: AgentEnvSpec, options: PackageOptions = {}): Promise { @@ -59,16 +88,34 @@ export class NodeCodeZipPackager implements RuntimePackager { } const agentName = options.agentName ?? spec.name; - const { projectRoot, srcDir, stagingDir, artifactsDir } = await resolveProjectPaths(options, agentName); + const { srcDir, stagingDir, artifactsDir } = await resolveNodeProjectPaths(options, agentName); - await ensureBinaryAvailable('npm', NPM_INSTALL_HINT); await ensureDirClean(stagingDir); - // Copy source files - await copySourceTree(srcDir, stagingDir); + const entryFile = join(srcDir, 'main.ts'); + const runtimeVersion = spec.runtimeVersion; + const nodeTarget = `node${extractNodeVersion(runtimeVersion)}`; + const cjsBanner = + 'const importMetaUrl = require("url").pathToFileURL(__filename).href;' + + '(function(){var M=require("module"),p=require("path"),f=require("fs"),d=p.join(__dirname,"_deps"),o=M._resolveFilename;' + + 'M._resolveFilename=function(r,P,i,O){try{return o.call(this,r,P,i,O)}catch(e){' + + 'var dp=p.join(d,r);if(f.existsSync(dp)){var pk=p.join(dp,"package.json");' + + 'if(f.existsSync(pk)){var m=JSON.parse(f.readFileSync(pk,"utf8")).main||"index.js";return p.resolve(dp,m)}' + + 'return p.resolve(dp,"index.js")}throw e}};})();'; + await build({ + entryPoints: [entryFile], + outfile: join(stagingDir, 'main.js'), + bundle: true, + platform: 'node', + format: 'cjs', + minify: true, + target: nodeTarget, + banner: { js: cjsBanner }, + define: { 'import.meta.url': 'importMetaUrl' }, + }); - // Install production dependencies - await this.installDependencies(projectRoot, stagingDir); + writeFileSync(join(stagingDir, 'package.json'), '{"type":"commonjs"}'); + copyDynamicDeps(srcDir, stagingDir); const artifactPath = options.outputPath ?? join(artifactsDir, getArtifactZipName(agentName)); await createZipFromDir(stagingDir, artifactPath); @@ -80,22 +127,11 @@ export class NodeCodeZipPackager implements RuntimePackager { stagingPath: stagingDir, }; } - - private async installDependencies(projectRoot: string, stagingDir: string): Promise { - // Copy package.json to staging - const result = await runSubprocessCapture('npm', ['install', '--omit=dev', '--prefix', stagingDir], { - cwd: projectRoot, - }); - - if (result.code !== 0) { - const combined = `${result.stdout}\n${result.stderr}`.trim(); - throw new PackagingError(combined.length > 0 ? combined : `npm install failed with exit code ${result.code}`); - } - } } /** * Sync Node/TypeScript packager for CDK bundling. + * Bundles TypeScript source into a single JS file using esbuild. */ export class NodeCodeZipPackagerSync implements CodeZipPackager { packCodeZip(config: AgentEnvSpec, options: PackageOptions = {}): ArtifactResult { @@ -106,16 +142,33 @@ export class NodeCodeZipPackagerSync implements CodeZipPackager { } const agentName = options.agentName ?? config.name ?? 'asset'; - const { projectRoot, srcDir, stagingDir, artifactsDir } = resolveProjectPathsSync(options, agentName); + const { srcDir, stagingDir, artifactsDir } = resolveNodeProjectPathsSync(options, agentName); - ensureBinaryAvailableSync('npm', NPM_INSTALL_HINT); ensureDirCleanSync(stagingDir); - // Copy source files - copySourceTreeSync(srcDir, stagingDir); + const entryFile = join(srcDir, 'main.ts'); + const nodeTarget = `node${extractNodeVersion(runtimeVersion)}`; + const cjsBanner = + 'const importMetaUrl = require("url").pathToFileURL(__filename).href;' + + '(function(){var M=require("module"),p=require("path"),f=require("fs"),d=p.join(__dirname,"_deps"),o=M._resolveFilename;' + + 'M._resolveFilename=function(r,P,i,O){try{return o.call(this,r,P,i,O)}catch(e){' + + 'var dp=p.join(d,r);if(f.existsSync(dp)){var pk=p.join(dp,"package.json");' + + 'if(f.existsSync(pk)){var m=JSON.parse(f.readFileSync(pk,"utf8")).main||"index.js";return p.resolve(dp,m)}' + + 'return p.resolve(dp,"index.js")}throw e}};})();'; + buildSync({ + entryPoints: [entryFile], + outfile: join(stagingDir, 'main.js'), + bundle: true, + platform: 'node', + format: 'cjs', + minify: true, + target: nodeTarget, + banner: { js: cjsBanner }, + define: { 'import.meta.url': 'importMetaUrl' }, + }); - // Install production dependencies - this.installDependenciesSync(projectRoot, stagingDir); + writeFileSync(join(stagingDir, 'package.json'), '{"type":"commonjs"}'); + copyDynamicDeps(srcDir, stagingDir); const artifactPath = options.outputPath ?? join(artifactsDir, getArtifactZipName(agentName)); createZipFromDirSync(stagingDir, artifactPath); @@ -127,15 +180,4 @@ export class NodeCodeZipPackagerSync implements CodeZipPackager { stagingPath: stagingDir, }; } - - private installDependenciesSync(projectRoot: string, stagingDir: string): void { - const result = runSubprocessCaptureSync('npm', ['install', '--omit=dev', '--prefix', stagingDir], { - cwd: projectRoot, - }); - - if (result.code !== 0) { - const combined = `${result.stdout}\n${result.stderr}`.trim(); - throw new PackagingError(combined.length > 0 ? combined : `npm install failed with exit code ${result.code}`); - } - } } diff --git a/src/schema/constants.ts b/src/schema/constants.ts index d235a0df1..ca8732b45 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; // Feature Constants (shared across all schemas) // ============================================================================ -export const SDKFrameworkSchema = z.enum(['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents']); +export const SDKFrameworkSchema = z.enum(['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents', 'VercelAI']); export type SDKFramework = z.infer; export const TargetLanguageSchema = z.enum(['Python', 'TypeScript', 'Other']); @@ -47,6 +47,7 @@ export const SDK_MODEL_PROVIDER_MATRIX: Record; +/** Default Node.js runtime version for new TypeScript agents */ +export const DEFAULT_NODE_VERSION: NodeRuntime = 'NODE_22'; + /** Combined runtime version schema supporting both Python and Node/TypeScript runtimes */ export const RuntimeVersionSchema = z.union([PythonRuntimeSchema, NodeRuntimeSchema]); export type RuntimeVersion = z.infer; +/** Default entrypoint filename for each target language (create path). */ +export const DEFAULT_ENTRYPOINT_BY_LANGUAGE: Record<'Python' | 'TypeScript', string> = { + Python: 'main.py', + TypeScript: 'main.js', +}; + +/** Default runtime version for each target language (create path). */ +export const DEFAULT_RUNTIME_BY_LANGUAGE: Record<'Python' | 'TypeScript', RuntimeVersion> = { + Python: DEFAULT_PYTHON_VERSION, + TypeScript: DEFAULT_NODE_VERSION, +}; + export const NetworkModeSchema = z.enum(['PUBLIC', 'VPC']); export type NetworkMode = z.infer; @@ -171,7 +189,7 @@ export type ProtocolMode = z.infer; * MCP is a standalone tool server with no framework. */ export const PROTOCOL_FRAMEWORK_MATRIX: Record = { - HTTP: ['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents'] as const, + HTTP: ['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents', 'VercelAI'] as const, MCP: [] as const, A2A: ['Strands', 'GoogleADK', 'LangChain_LangGraph'] as const, AGUI: ['Strands', 'LangChain_LangGraph', 'GoogleADK'] as const, diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index eac076ec1..a2482616c 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -96,7 +96,7 @@ export const ProjectNameSchema = z 'Project name must start with a letter and contain only alphanumeric characters' ) .refine(name => !isReservedProjectName(name), { - message: 'This name conflicts with a Python package dependency. Please choose a different name.', + message: 'This name conflicts with a reserved package dependency. Please choose a different name.', }); // ============================================================================