diff --git a/e2e-tests/harness-bedrock.test.ts b/e2e-tests/harness-bedrock.test.ts new file mode 100644 index 000000000..7b53e18bb --- /dev/null +++ b/e2e-tests/harness-bedrock.test.ts @@ -0,0 +1,3 @@ +import { createHarnessE2ESuite } from './harness-e2e-helper.js'; + +createHarnessE2ESuite({ modelProvider: 'bedrock' }); diff --git a/e2e-tests/harness-e2e-helper.ts b/e2e-tests/harness-e2e-helper.ts new file mode 100644 index 000000000..a0ee882c9 --- /dev/null +++ b/e2e-tests/harness-e2e-helper.ts @@ -0,0 +1,165 @@ +import { hasAwsCredentials, parseJsonOutput, prereqs, retry, spawnAndCollect } from '../src/test-utils/index.js'; +import { + cleanupStaleCredentialProviders, + installCdkTarball, + runAgentCoreCLI, + teardownE2EProject, + writeAwsTargets, +} from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const hasAws = hasAwsCredentials(); +// Harness features are only available in preview builds (BUILD_PREVIEW=1). +const isPreviewBuild = process.env.BUILD_PREVIEW === '1'; +const baseCanRun = prereqs.npm && prereqs.git && hasAws && isPreviewBuild; + +interface HarnessE2EConfig { + modelProvider: 'bedrock' | 'open_ai' | 'gemini'; + requiredEnvVar?: string; + skipMemory?: boolean; +} + +export function createHarnessE2ESuite(cfg: HarnessE2EConfig) { + const hasRequiredVar = !cfg.requiredEnvVar || !!process.env[cfg.requiredEnvVar]; + const canRun = baseCanRun && hasRequiredVar; + + const providerLabel = + cfg.modelProvider === 'open_ai' ? 'OpenAI' : cfg.modelProvider === 'gemini' ? 'Gemini' : 'Bedrock'; + + describe.sequential(`e2e: harness/${providerLabel} โ€” create โ†’ deploy โ†’ invoke`, () => { + let testDir: string; + let projectPath: string; + let harnessName: string; + + beforeAll(async () => { + if (!canRun) return; + + await cleanupStaleCredentialProviders(); + + testDir = join(tmpdir(), `agentcore-e2e-harness-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const providerSlug = cfg.modelProvider.replace('_', '').slice(0, 4); + harnessName = `E2eHrns${providerSlug}${String(Date.now()).slice(-8)}`; + + const createArgs = [ + 'create', + '--name', + harnessName, + '--model-provider', + cfg.modelProvider, + '--json', + '--skip-git', + ]; + + if (cfg.requiredEnvVar && process.env[cfg.requiredEnvVar]) { + createArgs.push('--api-key-arn', process.env[cfg.requiredEnvVar]!); + } + + if (cfg.skipMemory) { + createArgs.push('--no-harness-memory'); + } + + const result = await runAgentCoreCLI(createArgs, testDir); + + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { projectPath: string }; + projectPath = json.projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 300000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, harnessName, cfg.modelProvider); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + it.skipIf(!canRun)( + 'deploys to AWS successfully', + async () => { + expect(projectPath, 'Project should have been created').toBeTruthy(); + + await retry( + async () => { + const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath); + + if (result.exitCode !== 0) { + console.log('Deploy stdout:', result.stdout); + console.log('Deploy stderr:', result.stderr); + } + + expect(result.exitCode, `Deploy failed (stderr: ${result.stderr}, stdout: ${result.stdout})`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success, 'Deploy should report success').toBe(true); + }, + 1, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'invokes the deployed harness', + async () => { + expect(projectPath, 'Project should have been created').toBeTruthy(); + + await retry( + async () => { + const result = await runAgentCoreCLI( + ['invoke', '--harness', harnessName, '--prompt', 'Say hello', '--json'], + projectPath + ); + + if (result.exitCode !== 0) { + console.log('Invoke stdout:', result.stdout); + console.log('Invoke stderr:', result.stderr); + } + + expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success, 'Invoke should report success').toBe(true); + }, + 3, + 15000 + ); + }, + 180000 + ); + + it.skipIf(!canRun)( + 'status shows the deployed harness', + async () => { + const statusResult = await spawnAndCollect('agentcore', ['status', '--json'], projectPath); + + expect(statusResult.exitCode, `Status failed: ${statusResult.stderr}`).toBe(0); + + const json = parseJsonOutput(statusResult.stdout) as { + success: boolean; + resources: { + resourceType: string; + name: string; + deploymentState: string; + identifier?: string; + }[]; + }; + expect(json.success).toBe(true); + + const harness = json.resources.find(r => r.resourceType === 'harness' && r.name === harnessName); + expect(harness, `Harness "${harnessName}" should appear in status`).toBeDefined(); + expect(harness!.deploymentState).toBe('deployed'); + expect(harness!.identifier, 'Deployed harness should have a harnessArn').toBeTruthy(); + }, + 120000 + ); + }); +} diff --git a/e2e-tests/harness-gemini.test.ts b/e2e-tests/harness-gemini.test.ts new file mode 100644 index 000000000..8fd024147 --- /dev/null +++ b/e2e-tests/harness-gemini.test.ts @@ -0,0 +1,3 @@ +import { createHarnessE2ESuite } from './harness-e2e-helper.js'; + +createHarnessE2ESuite({ modelProvider: 'gemini', requiredEnvVar: 'GEMINI_API_KEY_ARN', skipMemory: true }); diff --git a/e2e-tests/harness-openai.test.ts b/e2e-tests/harness-openai.test.ts new file mode 100644 index 000000000..bdb9c3772 --- /dev/null +++ b/e2e-tests/harness-openai.test.ts @@ -0,0 +1,3 @@ +import { createHarnessE2ESuite } from './harness-e2e-helper.js'; + +createHarnessE2ESuite({ modelProvider: 'open_ai', requiredEnvVar: 'OPENAI_API_KEY_ARN', skipMemory: true }); diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 2cbd5b81f..2ae7cbd3f 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -41,14 +41,19 @@ const textLoaderPlugin = { }, }; +const outfile = process.env.ESBUILD_OUTFILE || './dist/cli/index.mjs'; + await esbuild.build({ entryPoints: ['./src/cli/index.ts'], - outfile: './dist/cli/index.mjs', + outfile, bundle: true, platform: 'node', format: 'esm', minify: true, jsx: 'automatic', + define: { + __PREVIEW__: process.env.BUILD_PREVIEW === '1' ? 'true' : 'false', + }, // Inject require shim for ESM compatibility with CommonJS dependencies banner: { js: `import { createRequire } from 'module'; import { fileURLToPath as __ef } from 'url'; import { dirname as __ed } from 'path'; const require = createRequire(import.meta.url); const __filename = __ef(import.meta.url); const __dirname = __ed(__filename);`, @@ -58,9 +63,9 @@ await esbuild.build({ }); // Make executable -fs.chmodSync('./dist/cli/index.mjs', '755'); +fs.chmodSync(outfile, '755'); -console.log('CLI build complete: dist/cli/index.mjs'); +console.log(`CLI build complete: ${outfile}`); // --------------------------------------------------------------------------- // MCP harness build โ€” opt-in via BUILD_HARNESS=1 diff --git a/integ-tests/add-remove-harness.test.ts b/integ-tests/add-remove-harness.test.ts new file mode 100644 index 000000000..099f17959 --- /dev/null +++ b/integ-tests/add-remove-harness.test.ts @@ -0,0 +1,222 @@ +import { createTestProject, exists, readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// Harness features are only available in preview builds (BUILD_PREVIEW=1). +// The standard CI build is GA, so skip these tests unless the preview bundle is present. +const isPreviewBuild = process.env.BUILD_PREVIEW === '1'; + +async function readHarnessSpec(projectPath: string, harnessName: string) { + return JSON.parse(await readFile(join(projectPath, `app/${harnessName}/harness.json`), 'utf-8')); +} + +describe.skipIf(!isPreviewBuild)('integration: harness add/remove lifecycle', () => { + let project: TestProject; + const harnessName = 'TestHarness'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('adds a harness with defaults', async () => { + const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const harness = config.harnesses?.find((h: { name: string }) => h.name === harnessName); + expect(harness, `Harness "${harnessName}" should be in agentcore.json`).toBeTruthy(); + expect(harness!.path).toBe(`app/${harnessName}`); + }); + + it('creates harness.json with correct model config', async () => { + const spec = await readHarnessSpec(project.projectPath, harnessName); + expect(spec.model).toBeDefined(); + expect(spec.model.provider).toBe('bedrock'); + expect(spec.model.modelId).toBeTruthy(); + }); + + it('creates system-prompt.md', async () => { + const promptPath = join(project.projectPath, `app/${harnessName}/system-prompt.md`); + expect(await exists(promptPath), 'system-prompt.md should exist').toBe(true); + }); + + it('auto-creates memory resource', async () => { + const config = await readProjectConfig(project.projectPath); + const memories = config.memories ?? []; + expect(memories.length, 'Should have auto-created memory').toBeGreaterThan(0); + }); + + it('rejects duplicate harness name', async () => { + const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath); + expect(result.exitCode).not.toBe(0); + }); + + it('removes the harness', async () => { + const result = await runCLI(['remove', 'harness', '--name', harnessName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const found = config.harnesses?.find((h: { name: string }) => h.name === harnessName); + expect(found, `Harness "${harnessName}" should be removed`).toBeFalsy(); + + const associatedMemory = (config.memories ?? []).find((m: { name: string }) => m.name === `${harnessName}Memory`); + expect(associatedMemory, 'Associated memory should be removed with harness').toBeFalsy(); + }); + + it('re-adds harness after removal without duplicate memory error', async () => { + const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + // Clean up for next tests + await runCLI(['remove', 'harness', '--name', harnessName, '--json'], project.projectPath); + }); +}); + +describe.skipIf(!isPreviewBuild)('integration: harness configuration options', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('adds harness with truncation strategy', async () => { + const name = 'TruncHarness'; + const result = await runCLI( + ['add', 'harness', '--name', name, '--truncation-strategy', 'sliding_window', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const spec = await readHarnessSpec(project.projectPath, name); + expect(spec.truncation?.strategy).toBe('sliding_window'); + }); + + it('adds harness with lifecycle config', async () => { + const name = 'LifecycleHarness'; + const result = await runCLI( + ['add', 'harness', '--name', name, '--idle-timeout', '300', '--max-lifetime', '3600', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const spec = await readHarnessSpec(project.projectPath, name); + expect(spec.lifecycleConfig?.idleRuntimeSessionTimeout).toBe(300); + expect(spec.lifecycleConfig?.maxLifetime).toBe(3600); + }); + + it('adds harness without memory when --no-memory is set', async () => { + const name = 'NoMemHarness'; + const configBefore = await readProjectConfig(project.projectPath); + const memoriesBefore = (configBefore.memories ?? []).length; + + const result = await runCLI(['add', 'harness', '--name', name, '--no-memory', '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const configAfter = await readProjectConfig(project.projectPath); + const memoriesAfter = (configAfter.memories ?? []).length; + expect(memoriesAfter).toBe(memoriesBefore); + }); + + it('adds harness with non-bedrock model provider', async () => { + const name = 'OpenAIHarness'; + const result = await runCLI( + [ + 'add', + 'harness', + '--name', + name, + '--model-provider', + 'open_ai', + '--model-id', + 'gpt-5', + '--api-key-arn', + 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const spec = await readHarnessSpec(project.projectPath, name); + expect(spec.model.provider).toBe('open_ai'); + expect(spec.model.modelId).toBe('gpt-5'); + expect(spec.model.apiKeyArn).toBe('arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key'); + }); +}); + +describe.skipIf(!isPreviewBuild)('integration: harness validation errors', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('rejects invalid harness name with special characters', async () => { + const result = await runCLI(['add', 'harness', '--name', 'bad-name!', '--json'], project.projectPath); + expect(result.exitCode).not.toBe(0); + }); + + it('rejects harness name starting with a number', async () => { + const result = await runCLI(['add', 'harness', '--name', '1BadName', '--json'], project.projectPath); + expect(result.exitCode).not.toBe(0); + }); + + it('rejects add harness without --name when --json is passed', async () => { + const result = await runCLI(['add', 'harness', '--json'], project.projectPath); + expect(result.exitCode).not.toBe(0); + }); +}); + +describe.skipIf(!isPreviewBuild)('integration: create project with harness', () => { + let project: TestProject; + const harnessName = 'CreateHarness'; + + beforeAll(async () => { + project = await createTestProject({ name: harnessName, noAgent: true }); + await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('has correct project scaffolding', async () => { + expect(await exists(join(project.projectPath, 'agentcore/agentcore.json'))).toBe(true); + expect(await exists(join(project.projectPath, 'agentcore/cdk'))).toBe(true); + expect(await exists(join(project.projectPath, `app/${harnessName}/harness.json`))).toBe(true); + expect(await exists(join(project.projectPath, `app/${harnessName}/system-prompt.md`))).toBe(true); + }); + + it('has harness registered in project config', async () => { + const config = await readProjectConfig(project.projectPath); + const harness = config.harnesses?.find((h: { name: string }) => h.name === harnessName); + expect(harness).toBeTruthy(); + }); +}); diff --git a/package.json b/package.json index eb4661183..005384970 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "build:lib": "tsc -p tsconfig.build.json", "build:cli": "node esbuild.config.mjs", "build:assets": "node scripts/copy-assets.mjs", + "build:preview": "BUILD_PREVIEW=1 node esbuild.config.mjs", "build:harness": "BUILD_HARNESS=1 node esbuild.config.mjs", "cli": "npx tsx src/cli/index.ts", "typecheck": "tsc --noEmit", diff --git a/preview-version.json b/preview-version.json new file mode 100644 index 000000000..5e961b434 --- /dev/null +++ b/preview-version.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0-preview.0" +} diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index 29cb5d745..992c8505f 100644 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -156,10 +156,54 @@ try { const cliTarballName = `aws-agentcore-${cliVersionInfo.bumpedVersion}.tgz`; const cliTarballPath = path.join(cliRoot, cliTarballName); -if (fs.existsSync(cliTarballPath)) { - log(`Done! Tarball: ${cliTarballPath}`); - log(`Install with: npm install ${cliTarballPath}`); - log('When you run agentcore create, the bundled CDK constructs will be installed automatically.'); -} else { - log(`Done! Check ${cliRoot} for the .tgz file.`); +if (!fs.existsSync(cliTarballPath)) { + console.error(`ERROR: Expected GA tarball at ${cliTarballPath} but not found.`); + process.exit(1); +} + +const gaTarballPath = cliTarballPath; + +// Step 6: Rebuild CLI with BUILD_PREVIEW=1 +log('Rebuilding CLI with BUILD_PREVIEW=1 for preview tarball...'); +run('npm', ['run', 'build'], { cwd: cliRoot, env: { ...process.env, BUILD_PREVIEW: '1' } }); + +// Copy CDK tarball into dist/assets/ again (rebuild wipes dist/) +fs.copyFileSync(cdkTarballSrc, bundledTarballDest); +log(`Placed CDK tarball at ${bundledTarballDest}`); + +// Step 7: Bump version to preview variant +function bumpPreviewVersion(pkgDir) { + const pkgJsonPath = path.join(pkgDir, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const originalVersion = pkg.version; + const baseVersion = originalVersion.split('-')[0]; + pkg.version = `${baseVersion}-preview-${timestamp}`; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n'); + log(`Bumped ${pkg.name} version: ${originalVersion} -> ${pkg.version}`); + return { pkgJsonPath, originalVersion, bumpedVersion: pkg.version }; } + +const previewVersionInfo = bumpPreviewVersion(cliRoot); + +// Step 8: Pack preview tarball +try { + log('Packing CLI preview tarball...'); + run('npm', ['pack'], { cwd: cliRoot }); +} finally { + restoreVersion(previewVersionInfo); +} + +const previewTarballName = `aws-agentcore-${previewVersionInfo.bumpedVersion}.tgz`; +const previewTarballPath = path.join(cliRoot, previewTarballName); + +if (!fs.existsSync(previewTarballPath)) { + console.error(`ERROR: Expected preview tarball at ${previewTarballPath} but not found.`); + process.exit(1); +} + +// Final output +log(`GA tarball: ${gaTarballPath}`); +log(`Preview tarball: ${previewTarballPath}`); +log(`Install GA: npm install -g ${gaTarballPath}`); +log(`Install Preview: npm install -g ${previewTarballPath}`); +log('When you run agentcore create, the bundled CDK constructs will be installed automatically.'); diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 9fad266c2..258551d41 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -99,6 +99,42 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } + // Read harness configs for role creation. + const projectRoot = path.resolve(configRoot, '..'); + const harnessConfigs: { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; + tools?: { type: string; name: string }[]; + apiKeyArn?: string; + }[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.resolve(harnessDir, 'harness.json'); + try { + const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + memoryName: harnessSpec.memory?.name, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, + tools: harnessSpec.tools, + apiKeyArn: harnessSpec.model?.apiKeyArn, + }); + } catch (err) { + throw new Error( + \`Could not read harness.json for "\${entry.name}" at \${harnessPath}: \${err instanceof Error ? err.message : err}\` + ); + } + } + const app = new App(); for (const target of targets) { @@ -118,6 +154,7 @@ async function main() { spec, mcpSpec, credentials, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -265,6 +302,18 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; +export interface HarnessConfig { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; + tools?: { type: string; name: string }[]; + apiKeyArn?: string; +} + export interface AgentCoreStackProps extends StackProps { /** * The AgentCore project specification containing agents, memories, and credentials. @@ -278,6 +327,10 @@ export interface AgentCoreStackProps extends StackProps { * Credential provider ARNs from deployed state, keyed by credential name. */ credentials?: Record; + /** + * Harness role configurations. + */ + harnesses?: HarnessConfig[]; } /** @@ -293,12 +346,15 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials } = props; + const { spec, mcpSpec, credentials, harnesses } = props; - // Create AgentCoreApplication with all agents - this.application = new AgentCoreApplication(this, 'Application', { - spec, - }); + // Create AgentCoreApplication with all agents and harness roles + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appProps: Record = { spec }; + if (harnesses?.length) { + appProps.harnesses = harnesses; + } + this.application = new AgentCoreApplication(this, 'Application', appProps as any); // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { @@ -451,6 +507,7 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "evaluators/python-lambda/execution-role-policy.json", "evaluators/python-lambda/lambda_function.py", "evaluators/python-lambda/pyproject.toml", + "harness/invoke.py.template", "mcp/python-lambda/README.md", "mcp/python-lambda/handler.py", "mcp/python-lambda/pyproject.toml", diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 7a78b71cd..7969869c3 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -54,6 +54,42 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } + // Read harness configs for role creation. + const projectRoot = path.resolve(configRoot, '..'); + const harnessConfigs: { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; + tools?: { type: string; name: string }[]; + apiKeyArn?: string; + }[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.resolve(harnessDir, 'harness.json'); + try { + const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + memoryName: harnessSpec.memory?.name, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, + tools: harnessSpec.tools, + apiKeyArn: harnessSpec.model?.apiKeyArn, + }); + } catch (err) { + throw new Error( + `Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}` + ); + } + } + const app = new App(); for (const target of targets) { @@ -73,6 +109,7 @@ async function main() { spec, mcpSpec, credentials, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index a4d277821..23b070896 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -7,6 +7,18 @@ import { import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; +export interface HarnessConfig { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; + tools?: { type: string; name: string }[]; + apiKeyArn?: string; +} + export interface AgentCoreStackProps extends StackProps { /** * The AgentCore project specification containing agents, memories, and credentials. @@ -20,6 +32,10 @@ export interface AgentCoreStackProps extends StackProps { * Credential provider ARNs from deployed state, keyed by credential name. */ credentials?: Record; + /** + * Harness role configurations. + */ + harnesses?: HarnessConfig[]; } /** @@ -35,12 +51,15 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials } = props; + const { spec, mcpSpec, credentials, harnesses } = props; - // Create AgentCoreApplication with all agents - this.application = new AgentCoreApplication(this, 'Application', { - spec, - }); + // Create AgentCoreApplication with all agents and harness roles + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appProps: Record = { spec }; + if (harnesses?.length) { + appProps.harnesses = harnesses; + } + this.application = new AgentCoreApplication(this, 'Application', appProps as any); // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { diff --git a/src/assets/harness/invoke.py.template b/src/assets/harness/invoke.py.template new file mode 100644 index 000000000..cf2527f44 --- /dev/null +++ b/src/assets/harness/invoke.py.template @@ -0,0 +1,74 @@ +""" +Standalone invoke script for AgentCore Harness. +Generated by: agentcore create --with-invoke-script + +Usage: + pip install boto3 + export HARNESS_ARN="arn:aws:bedrock-agentcore:::harness/" + python invoke.py "Hello, what can you do?" + python invoke.py --raw-events "Hello" +""" + +import argparse +import json +import os +import sys +import uuid + +import boto3 + +# --- Configuration --- +HARNESS_ARN = os.environ.get("HARNESS_ARN", "{{HARNESS_ARN}}") +REGION = os.environ.get("AWS_REGION", "{{REGION}}") +SESSION_ID = os.environ.get("SESSION_ID", str(uuid.uuid4())) + +parser = argparse.ArgumentParser(description="Invoke an AgentCore Harness") +parser.add_argument("prompt", nargs="?", default="Hello!", help="Prompt to send to the agent") +parser.add_argument("--raw-events", action="store_true", help="Print raw streaming events as JSON") +parser.add_argument("--session-id", default=SESSION_ID, help="Session ID for conversation continuity") +args = parser.parse_args() + +client = boto3.client("bedrock-agentcore", region_name=REGION) + +response = client.invoke_harness( + harnessArn=HARNESS_ARN, + runtimeSessionId=args.session_id, + messages=[ + { + "role": "user", + "content": [{"text": args.prompt}], + } + ], +) + +for event in response["stream"]: + if args.raw_events: + print(json.dumps(event, default=str)) + else: + if "contentBlockStart" in event: + start = event["contentBlockStart"].get("start", {}) + if "toolUse" in start: + tool = start["toolUse"] + print(f"\n๐Ÿ”ง Tool: {tool.get('name', 'unknown')}", flush=True) + elif "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + print(delta["text"], end="", flush=True) + elif "messageStop" in event: + stop_reason = event["messageStop"].get("stopReason", "") + if stop_reason == "end_turn": + print() + elif "metadata" in event: + usage = event["metadata"].get("usage", {}) + metrics = event["metadata"].get("metrics", {}) + latency = metrics.get("latencyMs", 0) / 1000 + print( + f"\nโšก {usage.get('inputTokens', 0)} in ยท " + f"{usage.get('outputTokens', 0)} out ยท " + f"{latency:.1f}s", + file=sys.stderr, + ) + elif "internalServerException" in event: + print(f"\nError: {event['internalServerException']}", file=sys.stderr) + +print(f"\n๐Ÿ”— Session: {args.session_id}", file=sys.stderr) diff --git a/src/cli/__tests__/preview-flag.test.ts b/src/cli/__tests__/preview-flag.test.ts new file mode 100644 index 000000000..7a7aabf65 --- /dev/null +++ b/src/cli/__tests__/preview-flag.test.ts @@ -0,0 +1,48 @@ +import { execSync } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +describe('Preview feature flag', () => { + test('isPreviewEnabled returns false when __PREVIEW__ is false', async () => { + const { isPreviewEnabled } = await import('../feature-flags'); + expect(isPreviewEnabled()).toBe(false); + }); + + describe('dead code elimination', () => { + let tempDir: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'preview-flag-test-')); + }); + + afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('GA build contains no harness code', () => { + const outfile = join(tempDir, 'ga-bundle.mjs'); + execSync(`node esbuild.config.mjs --outfile=${outfile}`, { + cwd: process.cwd(), + env: { ...process.env, BUILD_PREVIEW: undefined, ESBUILD_OUTFILE: outfile }, + stdio: 'pipe', + }); + const bundle = readFileSync(outfile, 'utf-8'); + expect(bundle).not.toContain('HarnessPrimitive'); + expect(bundle).not.toContain('harness-deployer'); + expect(bundle).not.toContain('imperativeManager'); + }); + + test('Preview build contains harness code', () => { + const outfile = join(tempDir, 'preview-bundle.mjs'); + execSync(`node esbuild.config.mjs --outfile=${outfile}`, { + cwd: process.cwd(), + env: { ...process.env, BUILD_PREVIEW: '1', ESBUILD_OUTFILE: outfile }, + stdio: 'pipe', + }); + const bundle = readFileSync(outfile, 'utf-8'); + expect(bundle).toContain('harness'); + }); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore-harness.test.ts b/src/cli/aws/__tests__/agentcore-harness.test.ts new file mode 100644 index 000000000..7d14d776c --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-harness.test.ts @@ -0,0 +1,451 @@ +import { + createHarness, + deleteHarness, + getHarness, + invokeHarness, + listAllHarnesses, + listHarnesses, + updateHarness, +} from '../agentcore-harness.js'; +import { EventStreamCodec } from '@smithy/eventstream-codec'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockRequest, mockRequestRaw } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + mockRequestRaw: vi.fn(), +})); + +vi.mock('../api-client', () => ({ + AgentCoreApiClient: class { + request = mockRequest; + requestRaw = mockRequestRaw; + }, + AgentCoreApiError: class extends Error { + statusCode: number; + requestId: string | undefined; + errorBody: string; + constructor(statusCode: number, errorBody: string, requestId?: string) { + super(`AgentCore API error (${statusCode}): ${errorBody}`); + this.statusCode = statusCode; + this.requestId = requestId; + this.errorBody = errorBody; + } + }, +})); + +describe('Harness control plane operations', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createHarness', () => { + it('sends POST /harnesses with correct body', async () => { + const harness = { harnessId: 'h-123', harnessName: 'test', status: 'CREATING' }; + mockRequest.mockResolvedValue({ harness }); + + const result = await createHarness({ + region: 'us-west-2', + harnessName: 'test', + executionRoleArn: 'arn:aws:iam::123:role/TestRole', + model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } }, + systemPrompt: [{ text: 'You are helpful.' }], + tools: [{ type: 'agentcore_browser', name: 'browser' }], + maxIterations: 75, + }); + + expect(result.harness.harnessId).toBe('h-123'); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/harnesses', + body: expect.objectContaining({ + harnessName: 'test', + executionRoleArn: 'arn:aws:iam::123:role/TestRole', + clientToken: expect.any(String), + model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } }, + systemPrompt: [{ text: 'You are helpful.' }], + tools: [{ type: 'agentcore_browser', name: 'browser' }], + maxIterations: 75, + }), + }) + ); + }); + + it('omits optional fields when not provided', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-1' } }); + + await createHarness({ + region: 'us-west-2', + harnessName: 'minimal', + executionRoleArn: 'arn:aws:iam::123:role/R', + }); + + const body = mockRequest.mock.calls[0]![0].body; + expect(body.model).toBeUndefined(); + expect(body.tools).toBeUndefined(); + expect(body.memory).toBeUndefined(); + expect(body.maxIterations).toBeUndefined(); + }); + }); + + describe('getHarness', () => { + it('sends GET /harnesses/{harnessId}', async () => { + const harness = { harnessId: 'h-123', status: 'READY' }; + mockRequest.mockResolvedValue({ harness }); + + const result = await getHarness({ region: 'us-west-2', harnessId: 'h-123' }); + + expect(result.harness.status).toBe('READY'); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/harnesses/h-123', + }) + ); + }); + }); + + describe('updateHarness', () => { + it('sends PATCH /harnesses/{harnessId}', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'UPDATING' } }); + + await updateHarness({ + region: 'us-west-2', + harnessId: 'h-123', + model: { bedrockModelConfig: { modelId: 'new-model' } }, + maxTokens: 4096, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PATCH', + path: '/harnesses/h-123', + body: expect.objectContaining({ + clientToken: expect.any(String), + model: { bedrockModelConfig: { modelId: 'new-model' } }, + maxTokens: 4096, + }), + }) + ); + }); + + it('passes nullable wrapper fields for memory and environmentArtifact', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123' } }); + + await updateHarness({ + region: 'us-west-2', + harnessId: 'h-123', + memory: { optionalValue: null }, + environmentArtifact: { optionalValue: null }, + }); + + const body = mockRequest.mock.calls[0]![0].body; + expect(body.memory).toEqual({ optionalValue: null }); + expect(body.environmentArtifact).toEqual({ optionalValue: null }); + }); + }); + + describe('deleteHarness', () => { + it('sends DELETE /harnesses/{harnessId} with clientToken query param', async () => { + mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'DELETING' } }); + + await deleteHarness({ region: 'us-west-2', harnessId: 'h-123' }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + path: '/harnesses/h-123', + query: { clientToken: expect.any(String) }, + }) + ); + }); + }); + + describe('listHarnesses', () => { + it('sends GET /harnesses with query params', async () => { + mockRequest.mockResolvedValue({ + harnesses: [{ harnessId: 'h-1', harnessName: 'one' }], + nextToken: undefined, + }); + + const result = await listHarnesses({ region: 'us-west-2', maxResults: 10 }); + + expect(result.harnesses).toHaveLength(1); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/harnesses', + query: { maxResults: '10' }, + }) + ); + }); + }); + + describe('listAllHarnesses', () => { + it('auto-paginates across multiple pages', async () => { + mockRequest + .mockResolvedValueOnce({ + harnesses: [{ harnessId: 'h-1' }], + nextToken: 'tok-1', + }) + .mockResolvedValueOnce({ + harnesses: [{ harnessId: 'h-2' }], + nextToken: undefined, + }); + + const all = await listAllHarnesses('us-west-2'); + + expect(all).toHaveLength(2); + expect(all[0]!.harnessId).toBe('h-1'); + expect(all[1]!.harnessId).toBe('h-2'); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('invokeHarness (streaming)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const toUtf8 = (input: Uint8Array) => new TextDecoder().decode(input); + const fromUtf8 = (input: string) => new TextEncoder().encode(input); + const codec = new EventStreamCodec(toUtf8, fromUtf8); + + function encodeEvent(eventType: string, payload: Record): Uint8Array { + return codec.encode({ + headers: { + ':event-type': { type: 'string', value: eventType }, + ':content-type': { type: 'string', value: 'application/json' }, + ':message-type': { type: 'string', value: 'event' }, + }, + body: fromUtf8(JSON.stringify(payload)), + }); + } + + function makeStreamResponse(frames: Uint8Array[]): Response { + let totalLen = 0; + for (const f of frames) totalLen += f.length; + const combined = new Uint8Array(totalLen); + let off = 0; + for (const f of frames) { + combined.set(f, off); + off += f.length; + } + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(combined); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + } + + it('yields messageStart events', async () => { + mockRequestRaw.mockResolvedValue(makeStreamResponse([encodeEvent('messageStart', { role: 'assistant' })])); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/h-123', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hello' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ type: 'messageStart', role: 'assistant' }); + }); + + it('yields text deltas', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: 'Hello' } }), + encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: ' world' } }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ + type: 'contentBlockDelta', + contentBlockIndex: 0, + delta: { type: 'text', text: 'Hello' }, + }); + expect(events[1]).toEqual({ + type: 'contentBlockDelta', + contentBlockIndex: 0, + delta: { type: 'text', text: ' world' }, + }); + }); + + it('yields tool use start events', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + encodeEvent('contentBlockStart', { + contentBlockIndex: 1, + start: { toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' } }, + }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'search' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'contentBlockStart', + contentBlockIndex: 1, + start: { + type: 'toolUse', + toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' }, + }, + }); + }); + + it('yields messageStop with stopReason', async () => { + mockRequestRaw.mockResolvedValue(makeStreamResponse([encodeEvent('messageStop', { stopReason: 'end_turn' })])); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events[0]).toEqual({ type: 'messageStop', stopReason: 'end_turn' }); + }); + + it('yields metadata with token usage', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + encodeEvent('metadata', { + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + metrics: { latencyMs: 1200 }, + }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events[0]).toEqual({ + type: 'metadata', + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + metrics: { latencyMs: 1200 }, + }); + }); + + it('yields error events for exception event types', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([encodeEvent('internalServerException', { message: 'Something broke' })]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events[0]).toEqual({ + type: 'error', + errorType: 'internalServerException', + message: 'Something broke', + }); + }); + + it('passes override options in request body', async () => { + mockRequestRaw.mockResolvedValue(makeStreamResponse([])); + + for await (const _event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + model: { bedrockModelConfig: { modelId: 'override-model' } }, + maxIterations: 20, + skills: [{ path: './skills/research' }], + })) { + // drain + } + + expect(mockRequestRaw).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/harnesses/invoke', + query: { harnessArn: 'arn:harness' }, + headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-1' }, + body: expect.objectContaining({ + model: { bedrockModelConfig: { modelId: 'override-model' } }, + maxIterations: 20, + skills: [{ path: './skills/research' }], + }), + }) + ); + }); + + it('handles multiple event types in sequence', async () => { + mockRequestRaw.mockResolvedValue( + makeStreamResponse([ + encodeEvent('messageStart', { role: 'assistant' }), + encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: 'Hi' } }), + encodeEvent('contentBlockStop', { contentBlockIndex: 0 }), + encodeEvent('messageStop', { stopReason: 'end_turn' }), + encodeEvent('metadata', { + usage: { inputTokens: 10, outputTokens: 1, totalTokens: 11 }, + metrics: { latencyMs: 100 }, + }), + ]) + ); + + const events = []; + for await (const event of invokeHarness({ + region: 'us-west-2', + harnessArn: 'arn:harness', + runtimeSessionId: 'sess-1', + messages: [{ role: 'user', content: [{ text: 'hi' }] }], + })) { + events.push(event); + } + + expect(events).toHaveLength(5); + expect(events.map(e => e.type)).toEqual([ + 'messageStart', + 'contentBlockDelta', + 'contentBlockStop', + 'messageStop', + 'metadata', + ]); + }); +}); diff --git a/src/cli/aws/__tests__/api-client.test.ts b/src/cli/aws/__tests__/api-client.test.ts new file mode 100644 index 000000000..5ebc1ebd3 --- /dev/null +++ b/src/cli/aws/__tests__/api-client.test.ts @@ -0,0 +1,185 @@ +import { AgentCoreApiClient, AgentCoreApiError } from '../api-client.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSign } = vi.hoisted(() => ({ + mockSign: vi.fn(), +})); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + sign = mockSign; + }, +})); + +vi.mock('@smithy/protocol-http', () => ({ + HttpRequest: class { + constructor(public opts: unknown) {} + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn().mockReturnValue({}), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('AgentCoreApiClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.AGENTCORE_STAGE; + mockSign.mockResolvedValue({ headers: { host: 'example.com', 'content-type': 'application/json' } }); + }); + + describe('endpoint resolution', () => { + it('uses control plane prod endpoint by default', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('bedrock-agentcore-control.us-west-2.amazonaws.com'), + expect.anything() + ); + }); + + it('uses data plane prod endpoint', async () => { + const client = new AgentCoreApiClient({ region: 'us-east-1', plane: 'data' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('bedrock-agentcore.us-east-1.amazonaws.com'), + expect.anything() + ); + }); + + it('uses beta control plane endpoint when AGENTCORE_STAGE=beta', async () => { + process.env.AGENTCORE_STAGE = 'beta'; + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('beta.us-west-2.elcapcp.genesis-primitives.aws.dev'), + expect.anything() + ); + }); + + it('uses gamma data plane endpoint when AGENTCORE_STAGE=gamma', async () => { + process.env.AGENTCORE_STAGE = 'gamma'; + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('gamma.us-west-2.elcapdp.genesis-primitives.aws.dev'), + expect.anything() + ); + }); + }); + + describe('request()', () => { + it('returns parsed JSON on success', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ harnessId: 'h-123' }), { status: 200 })); + + const result = await client.request({ method: 'GET', path: '/harnesses/h-123' }); + + expect(result).toEqual({ harnessId: 'h-123' }); + }); + + it('returns empty object on 204', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(null, { status: 204 })); + + const result = await client.request({ method: 'DELETE', path: '/harnesses/h-123' }); + + expect(result).toEqual({}); + }); + + it('throws AgentCoreApiError on non-2xx', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue( + new Response('{"message":"Not found"}', { + status: 404, + headers: { 'x-amzn-requestid': 'req-abc' }, + }) + ); + + const err = await client.request({ method: 'GET', path: '/harnesses/bad' }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AgentCoreApiError); + const apiErr = err as AgentCoreApiError; + expect(apiErr.statusCode).toBe(404); + expect(apiErr.requestId).toBe('req-abc'); + expect(apiErr.errorBody).toContain('Not found'); + }); + + it('sends JSON body when provided', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 201 })); + + await client.request({ method: 'POST', path: '/harnesses', body: { harnessName: 'test' } }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ harnessName: 'test' }) }) + ); + }); + + it('appends query parameters to URL', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' }); + mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + await client.request({ method: 'GET', path: '/harnesses', query: { maxResults: '10' } }); + + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('maxResults=10'), expect.anything()); + }); + }); + + describe('requestRaw()', () => { + it('returns raw Response object', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' }); + const mockResponse = new Response('streaming data', { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const response = await client.requestRaw({ method: 'POST', path: '/harnesses/invoke' }); + + expect(response).toBe(mockResponse); + expect(response.status).toBe(200); + }); + + it('passes custom headers through', async () => { + const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' }); + mockFetch.mockResolvedValue(new Response('', { status: 200 })); + + await client.requestRaw({ + method: 'POST', + path: '/harnesses/invoke', + headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-123' }, + }); + + expect(mockSign).toHaveBeenCalledWith( + expect.objectContaining({ + opts: expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-123', + }), + }), + }) + ); + }); + }); +}); diff --git a/src/cli/aws/__tests__/poll.test.ts b/src/cli/aws/__tests__/poll.test.ts new file mode 100644 index 000000000..2894758d8 --- /dev/null +++ b/src/cli/aws/__tests__/poll.test.ts @@ -0,0 +1,92 @@ +import { PollFailureError, PollTimeoutError, pollUntilTerminal } from '../poll.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +interface MockStatus { + status: string; + reason?: string; +} + +describe('pollUntilTerminal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns immediately when first result is terminal', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'READY' }); + + const result = await pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => r.status === 'READY', + }); + + expect(result).toEqual({ status: 'READY' }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('polls until terminal status is reached', async () => { + const fn = vi + .fn() + .mockResolvedValueOnce({ status: 'CREATING' }) + .mockResolvedValueOnce({ status: 'CREATING' }) + .mockResolvedValueOnce({ status: 'READY' }); + + const result = await pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => ['READY', 'FAILED'].includes(r.status), + intervalMs: 10, + }); + + expect(result).toEqual({ status: 'READY' }); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('throws PollFailureError when failure state is detected', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'FAILED', reason: 'bad config' }); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => ['READY', 'FAILED'].includes(r.status), + isFailure: (r: MockStatus) => r.status === 'FAILED', + getFailureReason: (r: MockStatus) => `Harness failed: ${r.reason}`, + intervalMs: 10, + }) + ).rejects.toThrow(PollFailureError); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => ['READY', 'FAILED'].includes(r.status), + isFailure: (r: MockStatus) => r.status === 'FAILED', + getFailureReason: (r: MockStatus) => `Harness failed: ${r.reason}`, + intervalMs: 10, + }) + ).rejects.toThrow('Harness failed: bad config'); + }); + + it('throws PollTimeoutError when maxWaitMs exceeded', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'CREATING' }); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => r.status === 'READY', + intervalMs: 10, + maxWaitMs: 50, + }) + ).rejects.toThrow(PollTimeoutError); + }); + + it('uses default failure message when getFailureReason is not provided', async () => { + const fn = vi.fn().mockResolvedValue({ status: 'FAILED' }); + + await expect( + pollUntilTerminal({ + fn, + isTerminal: (r: MockStatus) => r.status === 'FAILED', + isFailure: (r: MockStatus) => r.status === 'FAILED', + intervalMs: 10, + }) + ).rejects.toThrow('Resource entered a failed state'); + }); +}); diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts new file mode 100644 index 000000000..608e52dde --- /dev/null +++ b/src/cli/aws/agentcore-harness.ts @@ -0,0 +1,656 @@ +/** + * Typed client wrappers for Harness control plane and data plane operations. + * + * Control plane: CreateHarness, GetHarness, UpdateHarness, DeleteHarness, ListHarnesses + * Data plane: InvokeHarness (streaming) + * TODO InvokeAgentRuntimeCommand + * + * Built on AgentCoreApiClient (shared SigV4 HTTP client). + * Migrate to @aws-sdk/client-bedrock-agentcore-control when Harness commands land in the SDK. + */ +import { AgentCoreApiClient, AgentCoreApiError, resolveEndpoint } from './api-client'; +import { randomUUID } from 'node:crypto'; + +// ============================================================================ +// Shared Types (from Smithy service model) +// ============================================================================ + +export type HarnessStatus = 'CREATING' | 'READY' | 'UPDATING' | 'DELETING' | 'DELETED' | 'FAILED'; + +export interface BedrockModelConfig { + modelId: string; + temperature?: number; + topP?: number; + maxTokens?: number; +} + +export interface OpenAiModelConfig { + modelId: string; + apiKeyArn?: string; + temperature?: number; + topP?: number; + maxTokens?: number; +} + +export interface GeminiModelConfig { + modelId: string; + apiKeyArn?: string; + temperature?: number; + topP?: number; + topK?: number; + maxTokens?: number; +} + +export interface HarnessModelConfiguration { + bedrockModelConfig?: BedrockModelConfig; + openAiModelConfig?: OpenAiModelConfig; + geminiModelConfig?: GeminiModelConfig; +} + +export type HarnessSystemPrompt = { text: string }[]; + +export interface HarnessTool { + type: string; + name: string; + browserArn?: string; + codeInterpreterArn?: string; + config?: Record; +} + +export interface HarnessSkill { + path: string; +} + +export interface HarnessAgentCoreMemoryConfiguration { + arn: string; + actorId?: string; + messagesCount?: number; + retrievalConfig?: Record; +} + +export interface HarnessMemoryConfiguration { + agentCoreMemoryConfiguration: HarnessAgentCoreMemoryConfiguration; +} + +export interface HarnessTruncationConfiguration { + strategy: string; + config: { slidingWindow?: { messagesCount: number } }; +} + +export interface HarnessEnvironmentArtifact { + containerConfiguration?: { containerUri: string }; +} + +export interface HarnessAgentCoreRuntimeEnvironment { + agentRuntimeArn?: string; + agentRuntimeId?: string; + agentRuntimeName?: string; + lifecycleConfiguration?: Record; + networkConfiguration?: Record; + filesystemConfigurations?: Record[]; +} + +export interface HarnessEnvironmentProvider { + agentCoreRuntimeEnvironment?: HarnessAgentCoreRuntimeEnvironment; +} + +export interface Harness { + harnessId: string; + harnessName: string; + arn: string; + status: HarnessStatus; + executionRoleArn: string; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + memory?: HarnessMemoryConfiguration; + truncation?: HarnessTruncationConfiguration; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + environment?: HarnessEnvironmentProvider; + environmentArtifact?: HarnessEnvironmentArtifact; + environmentVariables?: Record; + authorizerConfiguration?: Record; + tags?: Record; + createdAt: string; + updatedAt: string; +} + +export interface HarnessSummary { + harnessId: string; + harnessName: string; + arn: string; + status: HarnessStatus; + createdAt: string; + updatedAt: string; +} + +// ============================================================================ +// CreateHarness +// ============================================================================ + +export interface CreateHarnessOptions { + region: string; + harnessName: string; + executionRoleArn: string; + environment?: HarnessEnvironmentProvider; + environmentArtifact?: HarnessEnvironmentArtifact; + environmentVariables?: Record; + authorizerConfiguration?: Record; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + memory?: HarnessMemoryConfiguration; + truncation?: HarnessTruncationConfiguration; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + tags?: Record; +} + +export interface CreateHarnessResult { + harness: Harness; +} + +export async function createHarness(options: CreateHarnessOptions): Promise { + const { region, ...rest } = options; + const client = new AgentCoreApiClient({ region, plane: 'control' }); + + const body: Record = { + harnessName: rest.harnessName, + clientToken: randomUUID(), + executionRoleArn: rest.executionRoleArn, + }; + + if (rest.environment) body.environment = rest.environment; + if (rest.environmentArtifact) body.environmentArtifact = rest.environmentArtifact; + if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables; + if (rest.authorizerConfiguration) body.authorizerConfiguration = rest.authorizerConfiguration; + if (rest.model) body.model = rest.model; + if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt; + if (rest.tools) body.tools = rest.tools; + if (rest.skills) body.skills = rest.skills; + if (rest.allowedTools) body.allowedTools = rest.allowedTools; + if (rest.memory) body.memory = rest.memory; + if (rest.truncation) body.truncation = rest.truncation; + if (rest.maxIterations != null) body.maxIterations = rest.maxIterations; + if (rest.maxTokens != null) body.maxTokens = rest.maxTokens; + if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds; + if (rest.tags) body.tags = rest.tags; + + const result = await client.request({ method: 'POST', path: '/harnesses', body }); + return result as CreateHarnessResult; +} + +// ============================================================================ +// GetHarness +// ============================================================================ + +export interface GetHarnessOptions { + region: string; + harnessId: string; +} + +export interface GetHarnessResult { + harness: Harness; +} + +export async function getHarness(options: GetHarnessOptions): Promise { + const client = new AgentCoreApiClient({ region: options.region, plane: 'control' }); + const result = await client.request({ method: 'GET', path: `/harnesses/${options.harnessId}` }); + return result as GetHarnessResult; +} + +// ============================================================================ +// UpdateHarness +// ============================================================================ + +export interface UpdateHarnessOptions { + region: string; + harnessId: string; + executionRoleArn?: string; + environment?: HarnessEnvironmentProvider; + environmentArtifact?: { optionalValue: HarnessEnvironmentArtifact | null }; + environmentVariables?: Record; + authorizerConfiguration?: { optionalValue: Record | null }; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + memory?: { optionalValue: HarnessMemoryConfiguration | null }; + truncation?: HarnessTruncationConfiguration; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + tags?: Record; +} + +export interface UpdateHarnessResult { + harness: Harness; +} + +export async function updateHarness(options: UpdateHarnessOptions): Promise { + const { region, harnessId, ...rest } = options; + const client = new AgentCoreApiClient({ region, plane: 'control' }); + + const body: Record = { + clientToken: randomUUID(), + }; + + if (rest.executionRoleArn) body.executionRoleArn = rest.executionRoleArn; + if (rest.environment) body.environment = rest.environment; + if (rest.environmentArtifact !== undefined) body.environmentArtifact = rest.environmentArtifact; + if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables; + if (rest.authorizerConfiguration !== undefined) body.authorizerConfiguration = rest.authorizerConfiguration; + if (rest.model) body.model = rest.model; + if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt; + if (rest.tools) body.tools = rest.tools; + if (rest.skills) body.skills = rest.skills; + if (rest.allowedTools) body.allowedTools = rest.allowedTools; + if (rest.memory !== undefined) body.memory = rest.memory; + if (rest.truncation) body.truncation = rest.truncation; + if (rest.maxIterations != null) body.maxIterations = rest.maxIterations; + if (rest.maxTokens != null) body.maxTokens = rest.maxTokens; + if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds; + if (rest.tags) body.tags = rest.tags; + + const result = await client.request({ method: 'PATCH', path: `/harnesses/${harnessId}`, body }); + return result as UpdateHarnessResult; +} + +// ============================================================================ +// DeleteHarness +// ============================================================================ + +export interface DeleteHarnessOptions { + region: string; + harnessId: string; +} + +export interface DeleteHarnessResult { + harness: Harness; +} + +export async function deleteHarness(options: DeleteHarnessOptions): Promise { + const client = new AgentCoreApiClient({ region: options.region, plane: 'control' }); + const result = await client.request({ + method: 'DELETE', + path: `/harnesses/${options.harnessId}`, + query: { clientToken: randomUUID() }, + }); + return result as DeleteHarnessResult; +} + +// ============================================================================ +// ListHarnesses +// ============================================================================ + +export interface ListHarnessesOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface ListHarnessesResult { + harnesses: HarnessSummary[]; + nextToken?: string; +} + +export async function listHarnesses(options: ListHarnessesOptions): Promise { + const client = new AgentCoreApiClient({ region: options.region, plane: 'control' }); + const query: Record = {}; + if (options.maxResults != null) query.maxResults = String(options.maxResults); + if (options.nextToken) query.nextToken = options.nextToken; + + const result = await client.request({ method: 'GET', path: '/harnesses', query }); + return result as ListHarnessesResult; +} + +export async function listAllHarnesses(region: string): Promise { + const all: HarnessSummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listHarnesses({ region, maxResults: 100, nextToken }); + all.push(...result.harnesses); + nextToken = result.nextToken; + } while (nextToken); + + return all; +} + +// ============================================================================ +// InvokeHarness (streaming, data plane) +// ============================================================================ + +export interface InvokeHarnessOptions { + region: string; + harnessArn: string; + runtimeSessionId: string; + messages: { role: string; content: Record[] }[]; + model?: HarnessModelConfiguration; + systemPrompt?: HarnessSystemPrompt; + tools?: HarnessTool[]; + skills?: HarnessSkill[]; + allowedTools?: string[]; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + actorId?: string; + /** Bearer token for CUSTOM_JWT auth (bypasses SigV4) */ + bearerToken?: string; +} + +// โ”€โ”€ Stream event types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export type HarnessStopReason = + | 'end_turn' + | 'tool_use' + | 'tool_result' + | 'max_tokens' + | 'stop_sequence' + | 'content_filtered' + | 'malformed_model_output' + | 'malformed_tool_use' + | 'interrupted' + | 'partial_turn' + | 'model_context_window_exceeded' + | 'max_iterations_exceeded' + | 'max_output_tokens_exceeded' + | 'timeout_exceeded'; + +export interface ToolUseBlockStart { + toolUseId: string; + name: string; + type?: string; + serverName?: string; +} + +export interface ToolResultBlockStart { + toolUseId: string; + status?: string; +} + +export type ContentBlockStart = + | { type: 'toolUse'; toolUse: ToolUseBlockStart } + | { type: 'toolResult'; toolResult: ToolResultBlockStart }; + +export type ContentBlockDelta = + | { type: 'text'; text: string } + | { type: 'toolUse'; input: string } + | { type: 'toolResult'; results: Record[] } + | { type: 'reasoningContent'; text?: string; signature?: string }; + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cacheReadInputTokens?: number; + cacheWriteInputTokens?: number; +} + +export interface StreamMetrics { + latencyMs: number; +} + +export type HarnessStreamEvent = + | { type: 'messageStart'; role: string } + | { type: 'contentBlockStart'; contentBlockIndex: number; start: ContentBlockStart } + | { type: 'contentBlockDelta'; contentBlockIndex: number; delta: ContentBlockDelta } + | { type: 'contentBlockStop'; contentBlockIndex: number } + | { type: 'messageStop'; stopReason: HarnessStopReason } + | { type: 'metadata'; usage: TokenUsage; metrics: StreamMetrics } + | { type: 'error'; errorType: string; message: string }; + +export async function* invokeHarness(options: InvokeHarnessOptions): AsyncGenerator { + const { region, harnessArn, runtimeSessionId, messages, bearerToken, ...overrides } = options; + + const body: Record = { messages }; + if (overrides.model) body.model = overrides.model; + if (overrides.systemPrompt) body.systemPrompt = overrides.systemPrompt; + if (overrides.tools) body.tools = overrides.tools; + if (overrides.skills) body.skills = overrides.skills; + if (overrides.allowedTools) body.allowedTools = overrides.allowedTools; + if (overrides.maxIterations != null) body.maxIterations = overrides.maxIterations; + if (overrides.maxTokens != null) body.maxTokens = overrides.maxTokens; + if (overrides.timeoutSeconds != null) body.timeoutSeconds = overrides.timeoutSeconds; + if (overrides.actorId) body.actorId = overrides.actorId; + + let response: Response; + if (bearerToken) { + response = await invokeHarnessWithBearerToken(region, harnessArn, runtimeSessionId, body, bearerToken); + } else { + const client = new AgentCoreApiClient({ region, plane: 'data' }); + response = await client.requestRaw({ + method: 'POST', + path: '/harnesses/invoke', + query: { harnessArn }, + headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': runtimeSessionId }, + body, + }); + } + + if (!response.ok) { + const errorBody = await response.text(); + const requestId = response.headers.get('x-amzn-requestid') ?? undefined; + throw new AgentCoreApiError(response.status, errorBody, requestId); + } + + if (!response.body) return; + + yield* parseEventStream(response.body); +} + +async function invokeHarnessWithBearerToken( + region: string, + harnessArn: string, + runtimeSessionId: string, + body: Record, + bearerToken: string +): Promise { + const endpoint = resolveEndpoint(region, 'data'); + const url = new URL('/harnesses/invoke', endpoint); + url.searchParams.set('harnessArn', harnessArn); + + return fetch(url.toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${bearerToken}`, + 'Content-Type': 'application/json', + 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': runtimeSessionId, + }, + body: JSON.stringify(body), + }); +} + +async function* parseEventStream(body: ReadableStream): AsyncGenerator { + const { EventStreamCodec } = await import('@smithy/eventstream-codec'); + const codec = new EventStreamCodec(toUtf8, fromUtf8); + const reader = body.getReader(); + let buffer: Uint8Array = new Uint8Array(0); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer = concatBuffers(buffer, new Uint8Array(value)); + + while (buffer.length >= 4) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const totalLength = view.getUint32(0); + if (buffer.length < totalLength) break; + + const frame = buffer.slice(0, totalLength); + buffer = buffer.slice(totalLength); + + try { + const message = codec.decode(frame); + const headers: Record = {}; + for (const [key, val] of Object.entries(message.headers)) { + headers[key] = String(val.value); + } + + if (headers[':message-type'] === 'error') { + yield { + type: 'error', + errorType: headers[':error-code'] ?? 'unknown', + message: headers[':error-message'] ?? 'Unknown error', + }; + continue; + } + + if (headers[':message-type'] === 'exception') { + const exBody = new TextDecoder().decode(message.body); + let msg = exBody; + try { + const parsed = JSON.parse(exBody) as { message?: string }; + msg = parsed.message ?? exBody; + } catch { + // use raw body + } + yield { + type: 'error', + errorType: headers[':exception-type'] ?? 'exception', + message: msg, + }; + continue; + } + + const eventType = headers[':event-type']; + if (!eventType) continue; + + const bodyText = new TextDecoder().decode(message.body); + if (!bodyText) continue; + + const event = parseEventPayload(eventType, bodyText); + if (event) yield event; + } catch { + // skip malformed frames + } + } + } + } finally { + reader.releaseLock(); + } +} + +function toUtf8(input: Uint8Array): string { + return new TextDecoder().decode(input); +} + +function fromUtf8(input: string): Uint8Array { + return new TextEncoder().encode(input); +} + +function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +function parseEventPayload(eventType: string, bodyText: string): HarnessStreamEvent | null { + let payload: Record; + try { + payload = JSON.parse(bodyText) as Record; + } catch { + return null; + } + + switch (eventType) { + case 'messageStart': + return { type: 'messageStart', role: (payload.role as string) ?? 'assistant' }; + + case 'contentBlockStart': { + const start = (payload.start as Record) ?? payload; + return { + type: 'contentBlockStart', + contentBlockIndex: (payload.contentBlockIndex as number) ?? 0, + start: parseContentBlockStart(start), + }; + } + + case 'contentBlockDelta': { + const delta = (payload.delta as Record) ?? payload; + return { + type: 'contentBlockDelta', + contentBlockIndex: (payload.contentBlockIndex as number) ?? 0, + delta: parseContentBlockDelta(delta), + }; + } + + case 'contentBlockStop': + return { type: 'contentBlockStop', contentBlockIndex: (payload.contentBlockIndex as number) ?? 0 }; + + case 'messageStop': + return { type: 'messageStop', stopReason: (payload.stopReason as HarnessStopReason) ?? 'end_turn' }; + + case 'metadata': + return { + type: 'metadata', + usage: (payload.usage as TokenUsage) ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + metrics: (payload.metrics as StreamMetrics) ?? { latencyMs: 0 }, + }; + + case 'internalServerException': + return { + type: 'error', + errorType: 'internalServerException', + message: (payload.message as string) ?? 'Internal server error', + }; + + case 'validationException': + return { + type: 'error', + errorType: 'validationException', + message: (payload.message as string) ?? 'Validation error', + }; + + case 'runtimeClientError': + return { + type: 'error', + errorType: 'runtimeClientError', + message: (payload.message as string) ?? 'Runtime client error', + }; + + default: + return null; + } +} + +function parseContentBlockStart(start: Record): ContentBlockStart { + if ('toolUse' in start) { + const tu = start.toolUse as ToolUseBlockStart; + return { type: 'toolUse', toolUse: tu }; + } + if ('toolResult' in start) { + const tr = start.toolResult as ToolResultBlockStart; + return { type: 'toolResult', toolResult: tr }; + } + return { type: 'toolUse', toolUse: { toolUseId: '', name: 'unknown' } }; +} + +function parseContentBlockDelta(delta: Record): ContentBlockDelta { + if ('text' in delta) { + return { type: 'text', text: delta.text as string }; + } + if ('toolUse' in delta) { + const tu = delta.toolUse as { input: string }; + return { type: 'toolUse', input: tu.input }; + } + if ('toolResult' in delta) { + return { type: 'toolResult', results: delta.toolResult as Record[] }; + } + if ('reasoningContent' in delta) { + const rc = delta.reasoningContent as { text?: string; signature?: string }; + return { type: 'reasoningContent', text: rc.text, signature: rc.signature }; + } + return { type: 'text', text: '' }; +} diff --git a/src/cli/aws/api-client.ts b/src/cli/aws/api-client.ts new file mode 100644 index 000000000..1354615a3 --- /dev/null +++ b/src/cli/aws/api-client.ts @@ -0,0 +1,128 @@ +/** + * Shared SigV4-signed HTTP client for AgentCore control plane and data plane APIs. + * When the SDK adds native commands for new APIs, we will migrate callers to the SDK client. + */ +import { getCredentialProvider } from './account'; +import { dnsSuffix } from './partition'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; + +const SERVICE = 'bedrock-agentcore'; + +export type ApiPlane = 'control' | 'data'; + +export interface ApiClientOptions { + region: string; + plane: ApiPlane; +} + +export interface RequestOptions { + method: string; + path: string; + body?: unknown; + query?: Record; + headers?: Record; +} + +export class AgentCoreApiError extends Error { + readonly statusCode: number; + readonly requestId: string | undefined; + readonly errorBody: string; + + constructor(statusCode: number, errorBody: string, requestId?: string) { + const reqIdSuffix = requestId ? ` [requestId: ${requestId}]` : ''; + super(`AgentCore API error (${statusCode}): ${errorBody}${reqIdSuffix}`); + this.name = 'AgentCoreApiError'; + this.statusCode = statusCode; + this.requestId = requestId; + this.errorBody = errorBody; + } +} + +export class AgentCoreApiClient { + private readonly region: string; + private readonly endpoint: string; + + constructor(options: ApiClientOptions) { + this.region = options.region; + this.endpoint = resolveEndpoint(options.region, options.plane); + } + + async request(options: RequestOptions): Promise { + const response = await this.requestRaw(options); + + if (!response.ok) { + const errorBody = await response.text(); + const requestId = response.headers.get('x-amzn-requestid') ?? undefined; + throw new AgentCoreApiError(response.status, errorBody, requestId); + } + + if (response.status === 204) return {}; + return response.json(); + } + + async requestRaw(options: RequestOptions): Promise { + const { method, path, body, query, headers: extraHeaders } = options; + + const url = new URL(path, this.endpoint); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + } + + const queryRecord: Record = {}; + url.searchParams.forEach((value, key) => { + queryRecord[key] = value; + }); + + const serializedBody = body != null ? JSON.stringify(body) : undefined; + + const httpRequest = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(queryRecord).length > 0 && { query: queryRecord }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + ...extraHeaders, + }, + ...(serializedBody && { body: serializedBody }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const signer = new SignatureV4({ + service: SERVICE, + region: this.region, + credentials, + sha256: Sha256, + }); + + const signed = await signer.sign(httpRequest); + + const fullUrl = `${this.endpoint}${url.pathname}${url.search}`; + return fetch(fullUrl, { + method, + headers: signed.headers as Record, + ...(serializedBody && { body: serializedBody }), + }); + } +} + +export function resolveEndpoint(region: string, plane: ApiPlane): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + + if (plane === 'control') { + if (stage === 'beta') return `https://beta.${region}.elcapcp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapcp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore-control.${region}.${dnsSuffix(region)}`; + } + + if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore.${region}.${dnsSuffix(region)}`; +} diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 7c87fec34..1dc59cf73 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -26,6 +26,35 @@ export { type GetPolicyGenerationOptions, type GetPolicyGenerationResult, } from './policy-generation'; +export { AgentCoreApiClient, AgentCoreApiError, type ApiClientOptions, type ApiPlane } from './api-client'; +export { pollUntilTerminal, PollTimeoutError, PollFailureError, type PollOptions } from './poll'; +export { + createHarness, + getHarness, + updateHarness, + deleteHarness, + listHarnesses, + listAllHarnesses, + invokeHarness, + type Harness, + type HarnessSummary, + type HarnessStatus, + type HarnessStreamEvent, + type HarnessStopReason, + type TokenUsage, + type StreamMetrics, + type CreateHarnessOptions, + type CreateHarnessResult, + type GetHarnessOptions, + type GetHarnessResult, + type UpdateHarnessOptions, + type UpdateHarnessResult, + type DeleteHarnessOptions, + type DeleteHarnessResult, + type ListHarnessesOptions, + type ListHarnessesResult, + type InvokeHarnessOptions, +} from './agentcore-harness'; export { DEFAULT_RUNTIME_USER_ID, executeBashCommand, diff --git a/src/cli/aws/poll.ts b/src/cli/aws/poll.ts new file mode 100644 index 000000000..0adfaca23 --- /dev/null +++ b/src/cli/aws/poll.ts @@ -0,0 +1,47 @@ +/** + * Generic polling utility for async AWS resource status transitions. + */ + +export interface PollOptions { + fn: () => Promise; + isTerminal: (result: T) => boolean; + isFailure?: (result: T) => boolean; + getFailureReason?: (result: T) => string; + intervalMs?: number; + maxWaitMs?: number; +} + +export class PollTimeoutError extends Error { + constructor(maxWaitMs: number) { + super(`Polling timed out after ${maxWaitMs}ms`); + this.name = 'PollTimeoutError'; + } +} + +export class PollFailureError extends Error { + constructor(reason: string) { + super(reason); + this.name = 'PollFailureError'; + } +} + +export async function pollUntilTerminal(options: PollOptions): Promise { + const { fn, isTerminal, isFailure, getFailureReason, intervalMs = 3000, maxWaitMs = 120_000 } = options; + const start = Date.now(); + + while (Date.now() - start < maxWaitMs) { + const result = await fn(); + + if (isTerminal(result)) { + if (isFailure?.(result)) { + const reason = getFailureReason?.(result) ?? 'Resource entered a failed state'; + throw new PollFailureError(reason); + } + return result; + } + + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + throw new PollTimeoutError(maxWaitMs); +} diff --git a/src/cli/cli.ts b/src/cli/cli.ts index e40732f52..319290025 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,6 +1,7 @@ import { getOrCreateInstallationId } from '../lib/schemas/io/global-config'; import { registerABTestCommand } from './commands/abtest'; import { registerAdd } from './commands/add'; +import { registerAddTool } from './commands/add/tool-command'; import { registerArchive } from './commands/archive'; import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; @@ -16,6 +17,7 @@ import { registerPackage } from './commands/package'; import { registerPause, registerPromote } from './commands/pause'; import { registerRecommendations } from './commands/recommendations'; import { registerRemove } from './commands/remove'; +import { registerRemoveTool } from './commands/remove/tool-command'; import { registerResume } from './commands/resume'; import { registerRun } from './commands/run'; import { registerStatus } from './commands/status'; @@ -25,6 +27,7 @@ import { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; import { PACKAGE_VERSION } from './constants'; +import { isPreviewEnabled } from './feature-flags'; import { ALL_PRIMITIVES } from './primitives'; import { TelemetryClientAccessor } from './telemetry'; import { App } from './tui/App'; @@ -206,6 +209,12 @@ export function registerCommands(program: Command) { primitive.registerCommands(addCmd, removeCmd); } + // Register standalone add/remove subcommands (preview-only) + if (isPreviewEnabled()) { + registerAddTool(addCmd); + registerRemoveTool(removeCmd); + } + // Register AB test detail command registerABTestCommand(program); } diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 377cc3e9d..2fcd3ee91 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -389,6 +389,18 @@ export interface BuildDeployedStateOptions { policyEngines?: Record; policies?: Record; runtimeEndpoints?: Record; + harnesses?: Record< + string, + { + harnessId: string; + harnessArn: string; + roleArn: string; + status: string; + agentRuntimeArn?: string; + memoryArn?: string; + configHash?: string; + } + >; } /** @@ -409,6 +421,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta policyEngines, policies, runtimeEndpoints, + harnesses, } = opts; const targetState: TargetDeployedState = { resources: { @@ -466,6 +479,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.httpGateways = existingHttpGateways; } + // Add harness state if harnesses exist + if (harnesses && Object.keys(harnesses).length > 0) { + targetState.resources!.harnesses = harnesses; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/add/tool-action.ts b/src/cli/commands/add/tool-action.ts new file mode 100644 index 000000000..cced82d81 --- /dev/null +++ b/src/cli/commands/add/tool-action.ts @@ -0,0 +1,177 @@ +import { ConfigIO } from '../../../lib'; +import type { HarnessGatewayOutboundAuth, HarnessSpec } from '../../../schema'; +import type { HarnessToolType } from '../../../schema/schemas/primitives/harness'; + +export interface AddToolOptions { + harness: string; + type: string; + name: string; + url?: string; + browserArn?: string; + codeInterpreterArn?: string; + gatewayArn?: string; + gateway?: string; + outboundAuth?: string; + providerArn?: string; + scopes?: string; + grantType?: string; + json?: boolean; +} + +const VALID_OUTBOUND_AUTH_TYPES = ['awsIam', 'none', 'oauth'] as const; +const VALID_GRANT_TYPES = ['CLIENT_CREDENTIALS', 'USER_FEDERATION'] as const; +const ARN_PATTERN = /^arn:[^:]+:/; + +export interface AddToolResult { + success: boolean; + error?: string; + harnessName?: string; + toolName?: string; +} + +const VALID_TOOL_TYPES: HarnessToolType[] = [ + 'agentcore_browser', + 'agentcore_code_interpreter', + 'remote_mcp', + 'agentcore_gateway', + 'inline_function', +]; + +export async function handleAddTool(options: AddToolOptions): Promise { + const { harness, type, name } = options; + + if (!VALID_TOOL_TYPES.includes(type as HarnessToolType)) { + return { + success: false, + error: `Invalid tool type '${type}'. Valid types: ${VALID_TOOL_TYPES.join(', ')}`, + }; + } + + const toolType = type as HarnessToolType; + + if (toolType === 'remote_mcp' && !options.url) { + return { success: false, error: '--url is required for remote_mcp tools' }; + } + + if (toolType === 'agentcore_gateway' && !options.gatewayArn && !options.gateway) { + return { success: false, error: '--gateway-arn or --gateway is required for agentcore_gateway tools' }; + } + + let outboundAuth: HarnessGatewayOutboundAuth | undefined; + if (options.outboundAuth !== undefined) { + if (toolType !== 'agentcore_gateway') { + return { success: false, error: '--outbound-auth is only valid for agentcore_gateway tools' }; + } + if (!VALID_OUTBOUND_AUTH_TYPES.includes(options.outboundAuth as (typeof VALID_OUTBOUND_AUTH_TYPES)[number])) { + return { + success: false, + error: `Invalid --outbound-auth '${options.outboundAuth}'. Valid: ${VALID_OUTBOUND_AUTH_TYPES.join(', ')}`, + }; + } + if (options.outboundAuth === 'awsIam' || options.outboundAuth === 'none') { + if (options.providerArn || options.scopes || options.grantType) { + return { + success: false, + error: '--provider-arn, --scopes, and --grant-type are only valid with --outbound-auth oauth', + }; + } + outboundAuth = options.outboundAuth === 'awsIam' ? { awsIam: {} } : { none: {} }; + } else { + if (!options.providerArn) { + return { success: false, error: '--provider-arn is required when --outbound-auth oauth' }; + } + if (!ARN_PATTERN.test(options.providerArn)) { + return { success: false, error: `Invalid --provider-arn '${options.providerArn}': must be a valid ARN` }; + } + if (!options.scopes) { + return { success: false, error: '--scopes is required when --outbound-auth oauth' }; + } + const scopes = options.scopes + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (scopes.length === 0) { + return { success: false, error: '--scopes must contain at least one scope' }; + } + if ( + options.grantType !== undefined && + !VALID_GRANT_TYPES.includes(options.grantType as (typeof VALID_GRANT_TYPES)[number]) + ) { + return { + success: false, + error: `Invalid --grant-type '${options.grantType}'. Valid: ${VALID_GRANT_TYPES.join(', ')}`, + }; + } + outboundAuth = { + oauth: { + providerArn: options.providerArn, + scopes, + ...(options.grantType && { grantType: options.grantType as (typeof VALID_GRANT_TYPES)[number] }), + }, + }; + } + } + + const configIO = new ConfigIO(); + + // Resolve --gateway (project name) to ARN from deployed-state + let resolvedGatewayArn = options.gatewayArn; + if (toolType === 'agentcore_gateway' && options.gateway && !resolvedGatewayArn) { + try { + const deployedState = await configIO.readDeployedState(); + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + return { success: false, error: 'No deployed targets found. Deploy the gateway first.' }; + } + const targetState = deployedState.targets[targetNames[0]!]; + const gatewayState = targetState?.resources?.mcp?.gateways?.[options.gateway]; + if (!gatewayState) { + return { + success: false, + error: `Gateway '${options.gateway}' not found in deployed state. Deploy it first or use --gateway-arn.`, + }; + } + resolvedGatewayArn = gatewayState.gatewayArn; + } catch { + return { success: false, error: 'Could not read deployed state. Deploy the gateway first or use --gateway-arn.' }; + } + } + + let harnessSpec: HarnessSpec; + try { + harnessSpec = await configIO.readHarnessSpec(harness); + } catch { + return { + success: false, + error: `Harness '${harness}' not found. Check the name or run 'agentcore add harness' first.`, + }; + } + + const existingTool = harnessSpec.tools.find(t => t.name === name); + if (existingTool) { + return { success: false, error: `Tool '${name}' already exists in harness '${harness}'` }; + } + + const toolEntry: HarnessSpec['tools'][number] = { type: toolType, name }; + + if (toolType === 'remote_mcp') { + toolEntry.config = { remoteMcp: { url: options.url! } }; + } else if (toolType === 'agentcore_browser' && options.browserArn) { + toolEntry.config = { agentCoreBrowser: { browserArn: options.browserArn } }; + } else if (toolType === 'agentcore_code_interpreter' && options.codeInterpreterArn) { + toolEntry.config = { agentCoreCodeInterpreter: { codeInterpreterArn: options.codeInterpreterArn } }; + } else if (toolType === 'agentcore_gateway') { + toolEntry.config = { + agentCoreGateway: { + gatewayArn: resolvedGatewayArn!, + ...(outboundAuth && { outboundAuth }), + }, + }; + } + + harnessSpec.tools.push(toolEntry); + + await configIO.writeHarnessSpec(harness, harnessSpec); + + return { success: true, harnessName: harness, toolName: name }; +} diff --git a/src/cli/commands/add/tool-command.ts b/src/cli/commands/add/tool-command.ts new file mode 100644 index 000000000..252c6a286 --- /dev/null +++ b/src/cli/commands/add/tool-command.ts @@ -0,0 +1,82 @@ +import { findConfigRoot } from '../../../lib'; +import { getErrorMessage } from '../../errors'; +import { handleAddTool } from './tool-action'; +import type { Command } from '@commander-js/extra-typings'; + +export function registerAddTool(addCmd: Command): void { + addCmd + .command('tool') + .description('Add a tool to a harness') + .requiredOption('--harness ', 'Target harness name') + .requiredOption( + '--type ', + 'Tool type: agentcore_browser, agentcore_code_interpreter, remote_mcp, agentcore_gateway, inline_function' + ) + .requiredOption('--name ', 'Tool name') + .option('--url ', 'MCP server URL (required for remote_mcp)') + .option('--browser-arn ', 'Custom browser ARN (optional for agentcore_browser)') + .option('--code-interpreter-arn ', 'Custom code interpreter ARN (optional for agentcore_code_interpreter)') + .option('--gateway-arn ', 'Gateway ARN (for agentcore_gateway)') + .option('--gateway ', 'Project gateway name โ€” resolves ARN from deployed state (for agentcore_gateway)') + .option( + '--outbound-auth ', + 'Gateway outbound auth: awsIam, none, or oauth (default: awsIam if omitted) [agentcore_gateway]' + ) + .option('--provider-arn ', 'OAuth credential provider ARN (required when --outbound-auth oauth)') + .option( + '--scopes ', + 'Comma-separated OAuth scopes (required when --outbound-auth oauth), e.g. "openid,profile" or "https://api.example.com/read"' + ) + .option( + '--grant-type ', + 'OAuth grant type: CLIENT_CREDENTIALS or USER_FEDERATION (for --outbound-auth oauth)' + ) + .option('--json', 'Output as JSON') + .action(async cliOptions => { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + try { + const result = await handleAddTool({ + harness: cliOptions.harness, + type: cliOptions.type, + name: cliOptions.name, + url: cliOptions.url, + browserArn: cliOptions.browserArn, + codeInterpreterArn: cliOptions.codeInterpreterArn, + gatewayArn: cliOptions.gatewayArn, + gateway: cliOptions.gateway, + outboundAuth: cliOptions.outboundAuth, + providerArn: cliOptions.providerArn, + scopes: cliOptions.scopes, + grantType: cliOptions.grantType, + json: cliOptions.json, + }); + + if (!result.success) { + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.error(result.error); + } + process.exit(1); + } + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.log(`Added tool '${result.toolName}' to harness '${result.harnessName}'.`); + console.log(`Run 'agentcore deploy' to apply changes.`); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index c1dd6641f..8a4df2b88 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -87,6 +87,44 @@ export interface AddGatewayTargetOptions { json?: boolean; } +// Harness types +export interface AddHarnessCliOptions { + name?: string; + modelProvider?: string; + modelId?: string; + apiKeyArn?: string; + container?: string; + memory?: boolean; + maxIterations?: number; + maxTokens?: number; + timeout?: number; + truncationStrategy?: string; + networkMode?: string; + subnets?: string; + securityGroups?: string; + idleTimeout?: number; + maxLifetime?: number; + sessionStorage?: string; + withInvokeScript?: boolean; + systemPrompt?: string; + tools?: string; + mcpName?: string; + mcpUrl?: string; + gatewayArn?: string; + gatewayOutboundAuth?: string; + gatewayProviderArn?: string; + gatewayScopes?: string; + authorizerType?: RuntimeAuthorizerType; + discoveryUrl?: string; + allowedAudience?: string; + allowedClients?: string; + allowedScopes?: string; + customClaims?: string; + clientId?: string; + clientSecret?: string; + json?: boolean; +} + // Memory types (v2: no owner/user concept) export interface AddMemoryOptions { name?: string; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index b39f89454..edc24ed7c 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -27,6 +27,7 @@ import type { AddCredentialOptions, AddGatewayOptions, AddGatewayTargetOptions, + AddHarnessCliOptions, AddMemoryOptions, } from './types'; import { existsSync, readFileSync } from 'fs'; @@ -812,3 +813,79 @@ export function validateAddCredentialOptions(options: AddCredentialOptions): Val return { valid: true }; } + +const VALID_HARNESS_TOOLS = [ + 'agentcore_browser', + 'agentcore_code_interpreter', + 'remote_mcp', + 'agentcore_gateway', +] as const; + +const VALID_GATEWAY_OUTBOUND_AUTH = ['awsIam', 'none', 'oauth'] as const; + +export function validateAddHarnessOptions(options: AddHarnessCliOptions): ValidationResult { + if (options.tools) { + const toolNames = options.tools.split(',').map(s => s.trim()); + for (const tool of toolNames) { + if (!VALID_HARNESS_TOOLS.includes(tool as (typeof VALID_HARNESS_TOOLS)[number])) { + return { + valid: false, + error: `Unknown tool '${tool}'. Valid tools: ${VALID_HARNESS_TOOLS.join(', ')}`, + }; + } + } + + if (toolNames.includes('remote_mcp')) { + if (!options.mcpName) { + return { valid: false, error: '--mcp-name is required when --tools includes remote_mcp' }; + } + if (!options.mcpUrl) { + return { valid: false, error: '--mcp-url is required when --tools includes remote_mcp' }; + } + } + + if (toolNames.includes('agentcore_gateway')) { + if (!options.gatewayArn) { + return { valid: false, error: '--gateway-arn is required when --tools includes agentcore_gateway' }; + } + } + } + + if (options.gatewayOutboundAuth) { + if ( + !VALID_GATEWAY_OUTBOUND_AUTH.includes(options.gatewayOutboundAuth as (typeof VALID_GATEWAY_OUTBOUND_AUTH)[number]) + ) { + return { + valid: false, + error: `Invalid --gateway-outbound-auth '${options.gatewayOutboundAuth}'. Use: ${VALID_GATEWAY_OUTBOUND_AUTH.join(', ')}`, + }; + } + + if (options.gatewayOutboundAuth === 'oauth') { + if (!options.gatewayProviderArn) { + return { valid: false, error: '--gateway-provider-arn is required when --gateway-outbound-auth is oauth' }; + } + if (!options.gatewayScopes) { + return { valid: false, error: '--gateway-scopes is required when --gateway-outbound-auth is oauth' }; + } + } + } + + if (options.authorizerType) { + const authResult = RuntimeAuthorizerTypeSchema.safeParse(options.authorizerType); + if (!authResult.success) { + return { valid: false, error: 'Invalid authorizer type. Use AWS_IAM or CUSTOM_JWT' }; + } + + if (options.authorizerType === 'CUSTOM_JWT') { + const jwtResult = validateJwtAuthorizerOptions(options); + if (!jwtResult.valid) return jwtResult; + } + } + + if (options.clientId && options.authorizerType !== 'CUSTOM_JWT') { + return { valid: false, error: 'OAuth client credentials are only valid with CUSTOM_JWT authorizer' }; + } + + return { valid: true }; +} diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 534e2e412..0bcac7e97 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -1,6 +1,7 @@ import { getWorkingDirectory, serializeResult } from '../../../lib'; import type { BuildType, + HarnessModelProvider, ModelProvider, NetworkMode, ProtocolMode, @@ -9,6 +10,8 @@ import type { } from '../../../schema'; import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; import { getErrorMessage } from '../../errors'; +import { isPreviewEnabled } from '../../feature-flags'; +import { harnessPrimitive } from '../../primitives/registry'; import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { AgentFramework, @@ -26,6 +29,8 @@ import { requireTTY } from '../../tui/guards'; import { CreateScreen } from '../../tui/screens/create'; import { parseCommaSeparatedList } from '../shared/vpc-utils'; import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action'; +import { createProjectWithHarness } from './harness-action'; +import { normalizeHarnessModelProvider, validateCreateHarnessOptions } from './harness-validate'; import type { CreateOptions } from './types'; import { validateCreateOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; @@ -85,6 +90,136 @@ function printCreateSummary( console.log(''); } +/** Flags that trigger the agent/runtime path (preview mode) */ +const AGENT_PATH_FLAGS = ['framework', 'language', 'build', 'protocol', 'type', 'agentId', 'agentAliasId'] as const; + +/** Flags that are harness-only (preview mode) */ +const HARNESS_ONLY_FLAGS = [ + 'modelId', + 'apiKeyArn', + 'maxIterations', + 'maxTokens', + 'timeout', + 'truncationStrategy', +] as const; + +/** Determines if the agent path should be taken based on provided flags (preview mode) */ +function isAgentPath(options: CreateOptions): boolean { + return AGENT_PATH_FLAGS.some(flag => options[flag] !== undefined); +} + +/** Determines if any harness-only flags are present (preview mode) */ +function hasHarnessOnlyFlags(options: CreateOptions): boolean { + return HARNESS_ONLY_FLAGS.some(flag => options[flag] !== undefined); +} + +/** Print completion summary after successful harness create (preview mode) */ +function printCreateHarnessSummary(projectName: string, harnessName: string): void { + const green = '\x1b[32m'; + const cyan = '\x1b[36m'; + const dim = '\x1b[2m'; + const reset = '\x1b[0m'; + + console.log(''); + + // Created summary + console.log(`${dim}Created:${reset}`); + console.log(` ${projectName}/`); + console.log(` agentcore/ ${dim}Config and CDK project${reset}`); + console.log(` app/${harnessName}/ ${dim}Harness config${reset}`); + console.log(''); + + // Success and next steps + console.log(`${green}Harness project created successfully!${reset}`); + console.log(''); + console.log('To continue:'); + console.log(` ${cyan}cd ${projectName}${reset}`); + console.log(` ${cyan}agentcore deploy${reset}`); + console.log(''); +} + +/** Handle CLI mode for the harness path (preview mode) */ +async function handleCreateHarnessCLI(options: CreateOptions): Promise { + const cwd = options.outputDir ?? getWorkingDirectory(); + const name = options.name ?? options.projectName; + const projectName = options.projectName ?? name; + + const validation = validateCreateHarnessOptions( + { + name, + projectName, + modelProvider: options.modelProvider, + modelId: options.modelId, + apiKeyArn: options.apiKeyArn, + }, + cwd + ); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + // Progress callback + const green = '\x1b[32m'; + const reset = '\x1b[0m'; + const onProgress: ProgressCallback | undefined = options.json + ? undefined + : (step, status) => { + if (status === 'done') console.log(`${green}[done]${reset} ${step}`); + else if (status === 'error') console.log(`\x1b[31m[error]${reset} ${step}`); + }; + + const provider = ( + options.modelProvider ? normalizeHarnessModelProvider(options.modelProvider) : 'bedrock' + ) as HarnessModelProvider; + const defaultModelIds: Record = { + bedrock: 'global.anthropic.claude-sonnet-4-6', + open_ai: 'gpt-5', + gemini: 'gemini-2.5-flash', + }; + const modelId = options.modelId ?? defaultModelIds[provider] ?? 'global.anthropic.claude-sonnet-4-6'; + + const containerOption = harnessPrimitive!.parseContainerFlag(options.container); + + const result = await createProjectWithHarness({ + name: name!, + projectName: projectName!, + cwd, + modelProvider: provider, + modelId, + apiKeyArn: options.apiKeyArn, + containerUri: containerOption.containerUri, + dockerfilePath: containerOption.dockerfilePath, + skipMemory: options.harnessMemory === false, + maxIterations: options.maxIterations ? Number(options.maxIterations) : undefined, + maxTokens: options.maxTokens ? Number(options.maxTokens) : undefined, + timeoutSeconds: options.timeout ? Number(options.timeout) : undefined, + truncationStrategy: options.truncationStrategy as 'sliding_window' | 'summarization' | undefined, + networkMode: options.networkMode as NetworkMode | undefined, + subnets: parseCommaSeparatedList(options.subnets), + securityGroups: parseCommaSeparatedList(options.securityGroups), + idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, + maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, + sessionStoragePath: options.sessionStorageMountPath, + skipGit: options.skipGit, + skipInstall: options.skipInstall, + onProgress, + }); + + if (options.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + printCreateHarnessSummary(projectName!, name!); + } else { + console.error(result.error); + } + process.exit(result.success ? 0 : 1); +} + /** Handle CLI mode with progress output */ async function handleCreateCLI(options: CreateOptions): Promise { const cwd = options.outputDir ?? getWorkingDirectory(); @@ -210,10 +345,10 @@ async function handleCreateCLI(options: CreateOptions): Promise { } export const registerCreate = (program: Command) => { - program + const createCmd = program .command('create') .description(COMMAND_DESCRIPTIONS.create) - .option('--name ', 'Resource name (agent or harness) [non-interactive]') + .option('--name ', 'Resource name [non-interactive]') .option( '--project-name ', 'Project name (start with letter, alphanumeric only, max 23 chars) [non-interactive]' @@ -255,9 +390,152 @@ export const registerCreate = (program: Command) => { .option('--skip-python-setup', 'Skip Python virtual environment setup [non-interactive]') .option('--skip-install', 'Skip all dependency installation (npm install, uv sync) [non-interactive]') .option('--dry-run', 'Preview what would be created without making changes [non-interactive]') - .option('--json', 'Output as JSON [non-interactive]') - .action(async options => { - try { + .option('--json', 'Output as JSON [non-interactive]'); + + if (isPreviewEnabled()) { + createCmd + .option('--model-id ', 'Model ID for harness [non-interactive] [preview]') + .option('--api-key-arn ', 'API key ARN for non-Bedrock harness providers [non-interactive] [preview]') + .option('--no-harness-memory', 'Skip auto-creating memory for harness [non-interactive] [preview]') + .option('--max-iterations ', 'Max agent loop iterations (harness) [non-interactive] [preview]') + .option('--max-tokens ', 'Max tokens per iteration (harness) [non-interactive] [preview]') + .option('--timeout ', 'Max execution duration in seconds (harness) [non-interactive] [preview]') + .option( + '--truncation-strategy ', + 'Truncation strategy: sliding_window or summarization (harness) [non-interactive] [preview]' + ) + .option( + '--container ', + 'Container image URI or Dockerfile path (harness) [non-interactive] [preview]' + ); + } + + createCmd.action(async (rawOptions: Record) => { + const options = rawOptions as Record & { + name?: string; + projectName?: string; + agent: boolean; + defaults?: true; + build?: string; + language?: string; + framework?: string; + modelProvider?: string; + apiKey?: string; + memory?: string; + protocol?: string; + type?: string; + agentId?: string; + agentAliasId?: string; + region?: string; + networkMode?: string; + subnets?: string; + securityGroups?: string; + idleTimeout?: string; + maxLifetime?: string; + sessionStorageMountPath?: string; + withConfigBundle?: true; + outputDir?: string; + skipGit?: true; + skipPythonSetup?: true; + skipInstall?: true; + dryRun?: true; + json?: true; + modelId?: string; + apiKeyArn?: string; + harnessMemory?: boolean; + maxIterations?: string; + maxTokens?: string; + timeout?: string; + truncationStrategy?: string; + container?: string; + }; + try { + if (isPreviewEnabled()) { + // Preview mode: fork between harness and agent paths + const hasAnyFlag = Boolean( + options.name ?? + options.projectName ?? + (options.agent === false ? true : null) ?? + options.defaults ?? + options.build ?? + options.language ?? + options.framework ?? + options.modelProvider ?? + options.apiKey ?? + options.memory ?? + options.protocol ?? + options.type ?? + options.agentId ?? + options.agentAliasId ?? + options.region ?? + options.networkMode ?? + options.subnets ?? + options.securityGroups ?? + options.idleTimeout ?? + options.maxLifetime ?? + options.outputDir ?? + options.skipGit ?? + options.skipPythonSetup ?? + options.skipInstall ?? + options.dryRun ?? + options.json ?? + options.modelId ?? + options.apiKeyArn ?? + (options.harnessMemory === false ? true : null) ?? + options.maxIterations ?? + options.maxTokens ?? + options.timeout ?? + options.truncationStrategy + ); + + if (!hasAnyFlag) { + requireTTY(); + handleCreateTUI(); + return; + } + + const opts = options as CreateOptions; + + // Conflict detection: agent-path flags + harness-only flags + if (isAgentPath(opts) && hasHarnessOnlyFlags(opts)) { + const error = + 'Cannot mix agent-path flags (--framework, --language, etc.) with harness-only flags (--model-id, --max-iterations, etc.)'; + if (opts.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + // --no-agent: bare project (no harness, no agent) + if (opts.agent === false) { + opts.language = opts.language ?? 'Python'; + await handleCreateCLI(opts); + return; + } + + // Agent path: any agent-specific flag triggers it + if (isAgentPath(opts)) { + if (opts.defaults) { + opts.language = opts.language ?? 'Python'; + opts.build = opts.build ?? 'CodeZip'; + opts.framework = opts.framework ?? 'Strands'; + opts.modelProvider = opts.modelProvider ?? 'Bedrock'; + opts.memory = opts.memory ?? 'none'; + } + opts.language = opts.language ?? 'Python'; + await handleCreateCLI(opts); + return; + } + + // Harness path (default in preview mode) + if (!opts.json && !opts.modelProvider && !hasHarnessOnlyFlags(opts)) { + console.log('Creating a harness project (pass --framework to create an agent project instead).'); + } + await handleCreateHarnessCLI(opts); + } else { + // GA mode: original behavior // Apply defaults if --defaults flag is set if (options.defaults) { options.language = options.language ?? 'Python'; @@ -295,9 +573,10 @@ export const registerCreate = (program: Command) => { requireTTY(); handleCreateTUI(); } - } catch (error) { - render(Error: {getErrorMessage(error)}); - process.exit(1); } - }); + } catch (error) { + render(Error: {getErrorMessage(error)}); + process.exit(1); + } + }); }; diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts new file mode 100644 index 000000000..28fdda684 --- /dev/null +++ b/src/cli/commands/create/harness-action.ts @@ -0,0 +1,100 @@ +import { CONFIG_DIR } from '../../../lib'; +import type { HarnessModelProvider, NetworkMode } from '../../../schema'; +import { harnessPrimitive } from '../../primitives/registry'; +import { type ProgressCallback, createProject } from './action'; +import type { CreateResult } from './types'; +import { toError } from '@/lib/errors/types'; +import { join } from 'path'; + +export interface CreateHarnessProjectOptions { + name: string; + projectName?: string; + cwd: string; + modelProvider: HarnessModelProvider; + modelId: string; + apiKeyArn?: string; + skipMemory?: boolean; + containerUri?: string; + dockerfilePath?: string; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + truncationStrategy?: 'sliding_window' | 'summarization'; + networkMode?: NetworkMode; + subnets?: string[]; + securityGroups?: string[]; + idleTimeout?: number; + maxLifetime?: number; + sessionStoragePath?: string; + skipGit?: boolean; + skipInstall?: boolean; + onProgress?: ProgressCallback; +} + +export async function createProjectWithHarness(options: CreateHarnessProjectOptions): Promise { + const { name, projectName: explicitProjectName, cwd, skipGit, skipInstall, onProgress } = options; + const projectName = explicitProjectName ?? name; + + const projectResult = await createProject({ + name: projectName, + cwd, + skipGit, + skipInstall, + onProgress, + }); + + if (!projectResult.success) { + return projectResult; + } + + const projectRoot = projectResult.projectPath!; + const configBaseDir = join(projectRoot, CONFIG_DIR); + + try { + onProgress?.('Add harness to project', 'start'); + + const harnessResult = await harnessPrimitive!.add({ + name: options.name, + modelProvider: options.modelProvider, + modelId: options.modelId, + apiKeyArn: options.apiKeyArn, + containerUri: options.containerUri, + dockerfilePath: options.dockerfilePath, + skipMemory: options.skipMemory, + maxIterations: options.maxIterations, + maxTokens: options.maxTokens, + timeoutSeconds: options.timeoutSeconds, + truncationStrategy: options.truncationStrategy, + networkMode: options.networkMode, + subnets: options.subnets, + securityGroups: options.securityGroups, + idleTimeout: options.idleTimeout, + maxLifetime: options.maxLifetime, + sessionStoragePath: options.sessionStoragePath, + configBaseDir, + }); + + if (!harnessResult.success) { + onProgress?.('Add harness to project', 'error'); + return { + success: false, + error: harnessResult.error, + warnings: projectResult.warnings, + }; + } + + onProgress?.('Add harness to project', 'done'); + + return { + success: true, + projectPath: projectRoot, + warnings: projectResult.warnings, + }; + } catch (err) { + return { + success: false, + error: toError(err), + warnings: projectResult.warnings, + }; + } +} diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts new file mode 100644 index 000000000..52f84d6e2 --- /dev/null +++ b/src/cli/commands/create/harness-validate.ts @@ -0,0 +1,94 @@ +import { HarnessNameSchema, ProjectNameSchema } from '../../../schema'; +import { validateFolderNotExists } from './validate'; + +export interface CreateHarnessCliOptions { + name?: string; + projectName?: string; + modelProvider?: string; + modelId?: string; + apiKeyArn?: string; + container?: string; + noMemory?: boolean; + maxIterations?: string; + maxTokens?: string; + timeout?: string; + truncationStrategy?: string; + networkMode?: string; + subnets?: string; + securityGroups?: string; + idleTimeout?: string; + maxLifetime?: string; + outputDir?: string; + skipGit?: boolean; + skipInstall?: boolean; + dryRun?: boolean; + json?: boolean; +} + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +const MODEL_PROVIDER_MAPPING: Record = { + bedrock: 'bedrock', + Bedrock: 'bedrock', + open_ai: 'open_ai', + openai: 'open_ai', + OpenAI: 'open_ai', + anthropic: 'bedrock', + Anthropic: 'bedrock', + gemini: 'gemini', + Gemini: 'gemini', +}; + +export function normalizeHarnessModelProvider(raw: string): string | undefined { + return MODEL_PROVIDER_MAPPING[raw]; +} + +export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, cwd?: string): ValidationResult { + if (!options.name) { + return { valid: false, error: '--name is required' }; + } + + const projectName = options.projectName ?? options.name; + const projectNameResult = ProjectNameSchema.safeParse(projectName); + if (!projectNameResult.success) { + return { valid: false, error: projectNameResult.error.issues[0]?.message ?? 'Invalid project name' }; + } + + const nameResult = HarnessNameSchema.safeParse(options.name); + if (!nameResult.success) { + return { valid: false, error: nameResult.error.issues[0]?.message ?? 'Invalid harness name' }; + } + + const folderCheck = validateFolderNotExists(projectName, cwd ?? process.cwd()); + if (folderCheck !== true) { + return { valid: false, error: folderCheck }; + } + + if (options.modelProvider) { + const normalized = normalizeHarnessModelProvider(options.modelProvider); + if (!normalized) { + return { + valid: false, + error: `Invalid model provider: ${options.modelProvider}. Use bedrock, open_ai, or gemini`, + }; + } + options.modelProvider = normalized; + } + options.modelProvider ??= 'bedrock'; + + const defaultModelIds: Record = { + bedrock: 'global.anthropic.claude-sonnet-4-6', + open_ai: 'gpt-5', + gemini: 'gemini-2.5-flash', + }; + options.modelId ??= defaultModelIds[options.modelProvider] ?? 'global.anthropic.claude-sonnet-4-6'; + + if (options.modelProvider !== 'bedrock' && !options.apiKeyArn) { + return { valid: false, error: `--api-key-arn is required for ${options.modelProvider} provider` }; + } + + return { valid: true }; +} diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index cd42c545b..814b06287 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -27,6 +27,15 @@ export interface CreateOptions extends VpcOptions { skipInstall?: boolean; dryRun?: boolean; json?: boolean; + // Harness-specific (preview only) + modelId?: string; + apiKeyArn?: string; + container?: string; + harnessMemory?: boolean; + maxIterations?: string; + maxTokens?: string; + timeout?: string; + truncationStrategy?: string; } export type CreateResult = Result<{ diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index eba2ab113..106b66022 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,5 +1,5 @@ import { ConfigIO, ResourceNotFoundError, SecureCredentials, ValidationError, toError } from '../../../lib'; -import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; +import type { AgentCoreMcpSpec, DeployedState, HarnessDeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; import { CdkToolkitWrapper, createSwitchableIoHost } from '../../cdk/toolkit-lib'; @@ -17,6 +17,7 @@ import { parseRuntimeEndpointOutputs, } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; +import { isPreviewEnabled } from '../../feature-flags'; import { ExecLogger } from '../../logging'; import { bootstrapEnvironment, @@ -33,7 +34,9 @@ import { synthesizeCdk, validateProject, } from '../../operations/deploy'; +import { computeProjectDeployHash } from '../../operations/deploy/change-detection'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; +import { type ImperativeDeployContext, createDeploymentManager } from '../../operations/deploy/imperative'; import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; import { resolveConfigBundleComponentKeys, @@ -53,6 +56,7 @@ export interface ValidatedDeployOptions { diff?: boolean; onProgress?: (step: string, status: 'start' | 'success' | 'error') => void; onResourceEvent?: (message: string) => void; + onDeployMessage?: (message: string) => void; } const AGENT_NEXT_STEPS = ['agentcore invoke', 'agentcore status']; @@ -268,7 +272,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { - options.onResourceEvent!(msg.message); + options.onResourceEvent?.(msg.message); + options.onDeployMessage?.(msg.message); }); switchableIoHost.setVerbose(true); } @@ -376,7 +381,37 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ targets: {} }) as DeployedState); + const teardownContext: ImperativeDeployContext = { + projectSpec: context.projectSpec, + target, + configIO, + deployedState: existingTeardownState, + onProgress: (step: string, status: 'start' | 'done' | 'error') => { + logger.log(`${step}: ${status}`); + }, + }; + + if (imperativeManager.hasDeployersForPhase('post-cdk', teardownContext)) { + startStep('Tear down imperative resources'); + const imperativeTeardown = await imperativeManager.teardownAll(teardownContext); + if (!imperativeTeardown.success) { + endStep('error', imperativeTeardown.error); + logger.finalize(false); + return { + success: false, + error: new Error(`Imperative teardown failed: ${imperativeTeardown.error}`), + logPath: logger.getRelativeLogPath(), + }; + } + endStep('success'); + } + } + startStep('Tear down stack'); const teardown = await performStackTeardown(target.name); if (!teardown.success) { @@ -463,6 +498,57 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise | undefined; + if (isPreviewEnabled()) { + const imperativeManager = createDeploymentManager(); + const existingImperativeState: DeployedState = await configIO.readDeployedState().catch(() => ({ targets: {} })); + const imperativeContext = { + projectSpec: context.projectSpec, + target, + configIO, + deployedState: existingImperativeState, + cdkOutputs: outputs, + onProgress: (step: string, status: 'start' | 'done' | 'error') => { + logger.log(`${step}: ${status}`); + }, + }; + + let harnessDeployError: string | undefined; + if (imperativeManager.hasDeployersForPhase('post-cdk', imperativeContext)) { + startStep('Deploy harnesses'); + const postCdkResult = await imperativeManager.runPhase('post-cdk', imperativeContext); + const harnessResult = postCdkResult.results.get('harness'); + if (harnessResult?.state) { + deployedHarnesses = harnessResult.state as Record; + } + if (!postCdkResult.success) { + endStep('error', postCdkResult.error); + harnessDeployError = postCdkResult.error; + } else { + endStep('success'); + } + } + + if (harnessDeployError) { + logger.finalize(false); + return { + success: false, + error: new Error(`Harness deployment failed: ${harnessDeployError}`), + logPath: logger.getRelativeLogPath(), + }; + } + } + + let deployHash: string | undefined; + try { + deployHash = await computeProjectDeployHash(configIO); + } catch { + // hash computation is best-effort + } + const existingState = await configIO.readDeployedState().catch(() => undefined); let deployedState = buildDeployedState({ targetName: target.name, @@ -477,8 +563,17 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS]; + const hasHarnesses = isPreviewEnabled() && (context.projectSpec.harnesses ?? []).length > 0; + const hasInvokable = agentNames.length > 0 || hasHarnesses; + const nextSteps = hasInvokable ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS]; const notes: string[] = []; const hasPythonAgent = context.projectSpec.runtimes?.some(a => a.entrypoint?.endsWith('.py') || a.entrypoint?.includes('.py:')) ?? false; diff --git a/src/cli/commands/deploy/progress.ts b/src/cli/commands/deploy/progress.ts new file mode 100644 index 000000000..a8dff1cd0 --- /dev/null +++ b/src/cli/commands/deploy/progress.ts @@ -0,0 +1,104 @@ +import { ConfigIO } from '../../../lib'; +import { detectAwsContext } from '../../aws/aws-context'; +import { getErrorMessage } from '../../errors'; +import { canSkipDeploy } from '../../operations/deploy/change-detection'; +import { handleDeploy } from './actions'; + +export const SPINNER_FRAMES = ['โ ‹', 'โ ™', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ‡', 'โ ']; + +export interface SpinnerProgress { + onProgress: (step: string, status: 'start' | 'success' | 'error') => void; + cleanup: () => void; +} + +export function createSpinnerProgress(): SpinnerProgress { + let spinner: NodeJS.Timeout | undefined; + + const clearSpinner = () => { + if (spinner) { + clearInterval(spinner); + spinner = undefined; + process.stdout.write('\r\x1b[K'); + } + }; + + const onProgress = (step: string, status: 'start' | 'success' | 'error') => { + clearSpinner(); + + if (status === 'start') { + let i = 0; + process.stdout.write(`${SPINNER_FRAMES[0]} ${step}...`); + spinner = setInterval(() => { + i = (i + 1) % SPINNER_FRAMES.length; + process.stdout.write(`\r${SPINNER_FRAMES[i]} ${step}...`); + }, 80); + } else if (status === 'success') { + console.log(`โœ“ ${step}`); + } else { + console.log(`โœ— ${step}`); + } + }; + + return { onProgress, cleanup: clearSpinner }; +} + +export async function runCliDeploy(): Promise { + console.log('Deploying project resources...'); + const { onProgress, cleanup } = createSpinnerProgress(); + + try { + // Auto-populate aws-targets.json if empty + const configIO = new ConfigIO(); + try { + const targets = await configIO.readAWSDeploymentTargets(); + if (targets.length === 0) { + const ctx = await detectAwsContext(); + if (ctx.accountId) { + await configIO.writeAWSDeploymentTargets([{ name: 'default', account: ctx.accountId, region: ctx.region }]); + } + } + } catch { + // aws-targets.json doesn't exist โ€” try to create it + try { + const ctx = await detectAwsContext(); + if (ctx.accountId) { + await configIO.writeAWSDeploymentTargets([{ name: 'default', account: ctx.accountId, region: ctx.region }]); + } + } catch { + // Can't detect โ€” let handleDeploy fail with a clear error + } + } + + const noChanges = await canSkipDeploy(configIO); + if (noChanges) { + onProgress('No changes detected โ€” skipping deploy', 'success'); + cleanup(); + console.log(''); + return; + } + + const result = await handleDeploy({ + target: 'default', + autoConfirm: true, + onProgress, + }); + cleanup(); + + if (result.success) { + console.log('Deploy complete.'); + if (result.logPath) { + console.log(`Deploy log: ${result.logPath}`); + } + console.log(''); + } else { + console.warn(`\x1b[33mDeploy failed: ${result.error}. Starting dev server anyway...\x1b[0m`); + if (result.logPath) { + console.warn(`Deploy log: ${result.logPath}`); + } + console.log(''); + } + } catch (deployErr) { + cleanup(); + console.warn(`\x1b[33mDeploy failed: ${getErrorMessage(deployErr)}. Starting dev server anyway...\x1b[0m\n`); + } +} diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 9bdcd6987..0a6b0885d 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -1,5 +1,6 @@ import { ConfigIO, findConfigRoot, getWorkingDirectory } from '../../../lib'; import type { AgentCoreProjectSpec } from '../../../schema'; +import { isPreviewEnabled } from '../../feature-flags'; import { getDevConfig, getDevSupportedAgents, loadDevEnv, loadProjectConfig } from '../../operations/dev'; import { type OtelCollector, startOtelCollector } from '../../operations/dev/otel'; import { @@ -8,10 +9,15 @@ import { type RetrieveMemoryRecordsHandler, runWebUI, } from '../../operations/dev/web-ui'; +import type { HarnessInfo } from '../../operations/dev/web-ui/constants'; import { listMemoryRecords, retrieveMemoryRecords } from '../../operations/memory'; -import { loadDeployedProjectConfig, resolveAgent } from '../../operations/resolve-agent'; +import { loadDeployedProjectConfig, resolveAgentOrHarness } from '../../operations/resolve-agent'; import { fetchTraceRecords, listTraces } from '../../operations/traces'; +import { LayoutProvider } from '../../tui/context'; +import { runCliDeploy } from '../deploy/progress'; +import { render } from 'ink'; import path from 'node:path'; +import React from 'react'; interface DeployedHandlers { onListMemoryRecords?: ListMemoryRecordsHandler; @@ -98,6 +104,7 @@ export interface BrowserModeOptions { project: AgentCoreProjectSpec; port: number; agentName?: string; + harnessName?: string; /** OTEL env vars to pass to dev servers (set by the dev command when collector is active) */ otelEnvVars?: Record; /** OTEL collector instance for local trace collection */ @@ -112,11 +119,23 @@ export async function launchBrowserDev(): Promise { const workingDir = getWorkingDirectory(); const project = await loadProjectConfig(workingDir); - if (!project?.runtimes || project.runtimes.length === 0) { - console.error('Error: No agents defined in project.'); + if (!project) { + console.error('Error: No agents or harnesses defined in project.'); process.exit(1); } + const hasRuntimes = project.runtimes.length > 0; + const hasHarnesses = isPreviewEnabled() && (project.harnesses ?? []).length > 0; + + if (!hasRuntimes && !hasHarnesses) { + console.error('Error: No agents or harnesses defined in project.'); + process.exit(1); + } + + if (hasHarnesses) { + await runCliDeploy(); + } + const configRoot = findConfigRoot(workingDir); const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); const { collector, otelEnvVars } = await startOtelCollector(persistTracesDir); @@ -131,14 +150,15 @@ export async function launchBrowserDev(): Promise { } export async function runBrowserMode(opts: BrowserModeOptions): Promise { - const { workingDir, project, agentName, otelEnvVars = {}, collector } = opts; + const { workingDir, project, agentName, harnessName, otelEnvVars = {}, collector } = opts; const configRoot = findConfigRoot(workingDir); const { envVars } = await loadDevEnv(workingDir); const supportedAgents = getDevSupportedAgents(project); + const projectHasHarnesses = isPreviewEnabled() && (project.harnesses ?? []).length > 0; - if (supportedAgents.length === 0) { + if (supportedAgents.length === 0 && !projectHasHarnesses) { console.error('Error: No dev-supported agents found.'); process.exit(1); } @@ -165,13 +185,52 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { // Handlers re-resolve on each call so newly deployed memories are picked up. const baseDir = configRoot ?? workingDir; + // Discover deployed harnesses from project config + deployed state (preview mode) + const harnessInfoList: HarnessInfo[] = []; + if (isPreviewEnabled()) { + try { + const configIO = new ConfigIO({ baseDir }); + if (configIO.configExists('state') && configIO.configExists('awsTargets')) { + const deployedState = await configIO.readDeployedState(); + const awsTargets = await configIO.readAWSDeploymentTargets(); + const targetName = Object.keys(deployedState.targets)[0]; + if (targetName) { + const targetState = deployedState.targets[targetName]; + const targetConfig = awsTargets.find(t => t.name === targetName); + if (targetConfig) { + for (const harness of project.harnesses ?? []) { + const state = targetState?.resources?.harnesses?.[harness.name]; + if (state) { + harnessInfoList.push({ + name: harness.name, + harnessArn: state.harnessArn, + region: targetConfig.region, + }); + } + } + if (harnessInfoList.length > 0) { + onLog( + 'info', + `Found ${harnessInfoList.length} deployed harness(es): ${harnessInfoList.map(h => h.name).join(', ')}` + ); + } + } + } + } + } catch { + // Harness discovery is best-effort โ€” local dev works without it + } + } + await runWebUI({ logLabel: 'dev', onLog, serverOptions: { mode: 'dev', agents: agentInfoList, + harnesses: harnessInfoList, selectedAgent: agentName, + selectedHarness: harnessName, envVars: mergedEnvVars, getEnvVars: async () => { const { envVars: freshEnvVars } = await loadDevEnv(workingDir); @@ -196,11 +255,11 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { ? (agentNameParam, startTime, endTime) => collector.listTraces(agentNameParam, startTime, endTime) : undefined, onGetTrace: collector ? (agentNameParam, traceId) => collector.getTraceSpans(agentNameParam, traceId) : undefined, - onListCloudWatchTraces: async (agentName, _harnessName, startTime, endTime) => { + onListCloudWatchTraces: async (agentName, harnessName, startTime, endTime) => { try { const configIO = new ConfigIO({ baseDir }); const context = await loadDeployedProjectConfig(configIO); - const resolved = resolveAgent(context, { runtime: agentName }); + const resolved = await resolveAgentOrHarness(context, { runtime: agentName, harness: harnessName }); if (!resolved.success) return { success: false, error: resolved.error }; const res = await listTraces({ region: resolved.agent.region, @@ -217,11 +276,11 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { }; } }, - onGetCloudWatchTrace: async (agentName, _harnessName, traceId, startTime, endTime) => { + onGetCloudWatchTrace: async (agentName, harnessName, traceId, startTime, endTime) => { try { const configIO = new ConfigIO({ baseDir }); const context = await loadDeployedProjectConfig(configIO); - const resolved = resolveAgent(context, { runtime: agentName }); + const resolved = await resolveAgentOrHarness(context, { runtime: agentName, harness: harnessName }); if (!resolved.success) return { success: false, error: resolved.error }; const res = await fetchTraceRecords({ region: resolved.agent.region, @@ -252,3 +311,51 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { }, }); } + +const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H'; +const EXIT_ALT_SCREEN = '\x1B[?1049l'; +const SHOW_CURSOR = '\x1B[?25h'; + +interface TuiPickerResult { + agentName?: string; + harnessName?: string; +} + +export async function launchTuiDevScreenWithPicker( + workingDir: string, + options?: { skipDeploy?: boolean } +): Promise { + process.stdout.write(ENTER_ALT_SCREEN); + + const exitAltScreen = () => { + process.stdout.write(EXIT_ALT_SCREEN); + process.stdout.write(SHOW_CURSOR); + }; + + let pickerResult: TuiPickerResult | undefined; + const { DevScreen } = await import('../../tui/screens/dev/DevScreen'); + const { unmount, waitUntilExit } = render( + React.createElement( + LayoutProvider, + null, + React.createElement(DevScreen, { + onBack: () => { + exitAltScreen(); + unmount(); + process.exit(0); + }, + workingDir, + skipDeploy: options?.skipDeploy, + onLaunchBrowser: (selection?: { agentName?: string; harnessName?: string }) => { + pickerResult = selection ?? {}; + exitAltScreen(); + unmount(); + }, + }) + ) + ); + + await waitUntilExit(); + exitAltScreen(); + return pickerResult; +} diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 0f67615fc..051377dfc 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -8,6 +8,7 @@ import { } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { detectContainerRuntime } from '../../external-requirements'; +import { isPreviewEnabled } from '../../feature-flags'; import { ExecLogger } from '../../logging'; import { callMcpTool, @@ -32,8 +33,9 @@ import { FatalError } from '../../tui/components'; import { LayoutProvider } from '../../tui/context'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; +import { runCliDeploy } from '../deploy/progress'; import { parseHeaderFlags } from '../shared/header-utils'; -import { runBrowserMode } from './browser-mode'; +import { launchTuiDevScreenWithPicker, runBrowserMode } from './browser-mode'; import type { Command } from '@commander-js/extra-typings'; import { spawn } from 'child_process'; import { render } from 'ink'; @@ -176,6 +178,7 @@ export const registerDev = (program: Command) => { .option('--exec', 'Execute a shell command in the running dev container (Container agents only) [non-interactive]') .option('--tool ', 'MCP tool name (used with "call-tool" prompt) [non-interactive]') .option('--input ', 'MCP tool arguments as JSON (used with --tool) [non-interactive]') + .option('--skip-deploy', 'Skip automatic resource deployment before starting dev server [preview]') .option( '-H, --header
', 'Custom header to forward to the agent (format: "Name: Value", repeatable) [non-interactive]', @@ -298,8 +301,13 @@ export const registerDev = (program: Command) => { process.exit(1); } - if (!project.runtimes || project.runtimes.length === 0) { - render(); + const hasRuntimes = project.runtimes && project.runtimes.length > 0; + const hasHarnesses = isPreviewEnabled() && project.harnesses && project.harnesses.length > 0; + + if (!hasRuntimes && !hasHarnesses) { + render( + + ); process.exit(1); } @@ -312,8 +320,10 @@ export const registerDev = (program: Command) => { } const supportedAgents = getDevSupportedAgents(project); - if (supportedAgents.length === 0) { - render(); + if (supportedAgents.length === 0 && !hasHarnesses) { + render( + + ); process.exit(1); } @@ -332,6 +342,22 @@ export const registerDev = (program: Command) => { // If --logs provided, run non-interactive mode if (opts.logs) { + // Preview: harness-only projects need deploy then print invoke instructions + if (isPreviewEnabled() && supportedAgents.length === 0 && hasHarnesses) { + if (!opts.skipDeploy) { + await runCliDeploy(); + } + const harnessNames = (project.harnesses ?? []).map(h => h.name); + console.log('Harness dev runs against the deployed service (no local server).'); + console.log(`If you changed the harness config, redeploy to pick up changes: agentcore deploy`); + console.log(`\nInvoke your harness:`); + for (const name of harnessNames) { + console.log(` agentcore invoke --harness ${name} "your prompt"`); + } + console.log(`\nOr use the interactive TUI: agentcore dev`); + process.exit(0); + } + // Require --agent if multiple agents if (project.runtimes.length > 1 && !opts.runtime) { const names = project.runtimes.map(a => a.name).join(', '); @@ -369,6 +395,11 @@ export const registerDev = (program: Command) => { // Get provider info from agent config const providerInfo = '(see agent code)'; + // Deploy resources before starting dev server (only when harnesses need it, preview mode) + if (isPreviewEnabled() && !opts.skipDeploy && hasHarnesses) { + await runCliDeploy(); + } + console.log(`Starting dev server...`); console.log(`Agent: ${config.agentName}`); if (config.protocol !== 'MCP') { @@ -462,6 +493,7 @@ export const registerDev = (program: Command) => { port={port} agentName={opts.runtime} headers={headers} + skipDeploy={opts.skipDeploy} /> ); @@ -476,11 +508,46 @@ export const registerDev = (program: Command) => { process.exit(0); } - // Default: launch web UI in browser - // NOTE: Do not copy this pattern. runBrowserMode blocks forever (internal - // await new Promise(() => {})) so we cannot use withCommandRunTelemetry here. - // We emit telemetry eagerly before the blocking call. - { + // Preview: show TUI deploy progress, then launch Agent Inspector in the browser + if (isPreviewEnabled()) { + const pickerResult = await launchTuiDevScreenWithPicker(workingDir, { + skipDeploy: opts.skipDeploy, + }); + + if (pickerResult != null) { + const client = await TelemetryClientAccessor.get().catch(() => undefined); + const devAttrs = { + action: 'server' as const, + ui_mode: 'browser' as const, + has_stream: false, + agent_protocol: standardize(AgentProtocol, (targetDevAgent?.protocol ?? 'http').toLowerCase()), + invoke_count: 0, + }; + if (client) { + client.emit('cli.command_run', 0, { + command_group: 'dev', + command: 'dev', + exit_reason: 'success', + dev_action: devAttrs.action, + ...devAttrs, + }); + await client.flush(); + } + await runBrowserMode({ + workingDir, + project, + port, + agentName: pickerResult.agentName, + harnessName: pickerResult.harnessName, + otelEnvVars, + collector, + }); + } + } else { + // GA: Default: launch web UI in browser + // NOTE: Do not copy this pattern. runBrowserMode blocks forever (internal + // await new Promise(() => {})) so we cannot use withCommandRunTelemetry here. + // We emit telemetry eagerly before the blocking call. const client = await TelemetryClientAccessor.get().catch(() => undefined); if (client) { client.emit('cli.command_run', 0, { diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index ad21c7113..ce3e4b68d 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -1,5 +1,5 @@ import { ConfigIO, ResourceNotFoundError, ValidationError } from '../../../lib'; -import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; +import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState, HarnessModel } from '../../../schema'; import { buildAguiRunInput, executeBashCommand, @@ -11,11 +11,19 @@ import { mcpInitSession, mcpListTools, } from '../../aws'; +import { invokeHarness } from '../../aws/agentcore-harness'; +import { isPreviewEnabled } from '../../feature-flags'; import { InvokeLogger } from '../../logging'; import { formatMcpToolList } from '../../operations/dev/utils'; -import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; +import { + canFetchHarnessToken, + canFetchRuntimeToken, + fetchHarnessToken, + fetchRuntimeToken, +} from '../../operations/fetch-access'; import { generateSessionId } from '../../operations/session'; import type { InvokeOptions, InvokeResult } from './types'; +import { randomUUID } from 'node:crypto'; export interface InvokeContext { project: AgentCoreProjectSpec; @@ -70,6 +78,29 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption }; } + // Preview: route to harness or runtime + if (isPreviewEnabled()) { + const harnessEntries = project.harnesses ?? []; + const isHarnessInvoke = options.harnessName != null || (harnessEntries.length > 0 && project.runtimes.length === 0); + + if (isHarnessInvoke) { + return handleHarnessInvoke(project, targetState, targetConfig, selectedTargetName, options); + } + + if (harnessEntries.length > 0 && project.runtimes.length > 0 && !options.agentName) { + const runtimeNames = project.runtimes.map(a => a.name); + const harnessNames = harnessEntries.map(h => h.name); + return { + success: false, + error: new ValidationError( + `Project has both runtimes and harnesses. Specify one:\n` + + ` --runtime: ${runtimeNames.join(', ')}\n` + + ` --harness: ${harnessNames.join(', ')}` + ), + }; + } + } + if (project.runtimes.length === 0) { return { success: false, error: new ValidationError('No agents defined in configuration') }; } @@ -532,3 +563,263 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logFilePath: logger.logFilePath, }; } + +// ============================================================================ +// Harness Invoke (preview mode) +// ============================================================================ + +export function buildHarnessBaseOpts( + options: InvokeOptions, + harnessSpec?: Partial +): Partial { + const baseOpts: Partial = {}; + if (options.modelId || options.modelProvider || options.apiKeyArn) { + const provider = options.modelProvider ?? harnessSpec?.provider; + const modelId = options.modelId ?? harnessSpec?.modelId ?? ''; + const apiKeyArn = options.apiKeyArn ?? harnessSpec?.apiKeyArn; + switch (provider) { + case 'open_ai': + baseOpts.model = { + openAiModelConfig: { modelId, ...(apiKeyArn && { apiKeyArn }) }, + }; + break; + case 'gemini': + baseOpts.model = { + geminiModelConfig: { modelId, ...(apiKeyArn && { apiKeyArn }) }, + }; + break; + default: + baseOpts.model = { + bedrockModelConfig: { modelId }, + }; + break; + } + } + if (options.tools) { + baseOpts.tools = options.tools.split(',').map(t => { + const type = t.trim(); + return { type, name: type }; + }); + } + if (options.maxIterations != null) baseOpts.maxIterations = options.maxIterations; + if (options.maxTokens != null) baseOpts.maxTokens = options.maxTokens; + if (options.harnessTimeout != null) baseOpts.timeoutSeconds = options.harnessTimeout; + if (options.systemPrompt) baseOpts.systemPrompt = [{ text: options.systemPrompt }]; + if (options.allowedTools) baseOpts.allowedTools = options.allowedTools.split(',').map(t => t.trim()); + if (options.actorId) baseOpts.actorId = options.actorId; + return baseOpts; +} + +export async function handleHarnessInvokeByArn( + harnessArn: string, + region: string, + options: InvokeOptions +): Promise { + if (!options.prompt) { + return { + success: false, + error: new ValidationError( + 'No prompt provided. Usage: agentcore invoke --harness-arn --region "your prompt"' + ), + }; + } + + const sessionId = options.sessionId ?? randomUUID(); + const logger = new InvokeLogger({ agentName: 'external-harness', runtimeArn: harnessArn, region, sessionId }); + logger.logPrompt(options.prompt, sessionId, options.userId); + + const baseOpts = buildHarnessBaseOpts(options); + return streamHarnessInvoke({ region, harnessArn, sessionId, prompt: options.prompt, options, logger, baseOpts }); +} + +interface StreamHarnessParams { + region: string; + harnessArn: string; + sessionId: string; + prompt: string; + options: InvokeOptions; + logger: InvokeLogger; + baseOpts: Partial; +} + +async function streamHarnessInvoke(params: StreamHarnessParams): Promise { + const { region, harnessArn, sessionId, prompt, options, logger, baseOpts } = params; + let fullResponse = ''; + + try { + const messages: { role: string; content: Record[] }[] = [ + { role: 'user', content: [{ text: prompt }] }, + ]; + + const stream = invokeHarness({ + region, + harnessArn, + runtimeSessionId: sessionId, + messages, + bearerToken: options.bearerToken, + ...baseOpts, + }); + + for await (const event of stream) { + if (options.verbose) { + console.log(JSON.stringify(event)); + continue; + } + + switch (event.type) { + case 'contentBlockDelta': + if (event.delta.type === 'text') { + fullResponse += event.delta.text; + if (!options.json) { + process.stdout.write(event.delta.text); + } + } + break; + case 'messageStop': + if (!options.json && event.stopReason !== 'tool_use' && event.stopReason !== 'tool_result') { + process.stdout.write('\n'); + } + break; + case 'error': + logger.logError(new Error(`${event.errorType}: ${event.message}`), 'stream error'); + if (options.json) { + return { success: false, error: new Error(`${event.errorType}: ${event.message}`) }; + } + process.stderr.write(`\nError: ${event.message}\n`); + break; + } + } + + logger.logResponse(fullResponse); + + if (options.json) { + return { + success: true, + response: JSON.stringify({ text: fullResponse, sessionId }), + sessionId, + logFilePath: logger.logFilePath, + }; + } + + return { success: true, sessionId, logFilePath: logger.logFilePath }; + } catch (err) { + logger.logError(err, 'harness invoke failed'); + return { + success: false, + error: new Error(`Harness invoke failed: ${err instanceof Error ? err.message : String(err)}`), + logFilePath: logger.logFilePath, + }; + } +} + +async function handleHarnessInvoke( + project: AgentCoreProjectSpec, + targetState: DeployedState['targets'][string] | undefined, + targetConfig: { region: string; name: string }, + selectedTargetName: string, + options: InvokeOptions +): Promise { + const harnessEntries = project.harnesses ?? []; + + if (harnessEntries.length === 0) { + return { success: false, error: new ValidationError('No harnesses defined in configuration') }; + } + + let harnessName = options.harnessName; + if (!harnessName) { + if (harnessEntries.length > 1) { + const names = harnessEntries.map(h => h.name); + return { + success: false, + error: new ValidationError(`Multiple harnesses found. Use --harness to specify one: ${names.join(', ')}`), + }; + } + harnessName = harnessEntries[0]!.name; + } + + const harnessEntry = harnessEntries.find(h => h.name === harnessName); + if (!harnessEntry) { + const names = harnessEntries.map(h => h.name); + return { + success: false, + error: new ResourceNotFoundError(`Harness '${harnessName}' not found. Available: ${names.join(', ')}`), + }; + } + + const harnessState = targetState?.resources?.harnesses?.[harnessName]; + if (!harnessState) { + return { + success: false, + error: new ValidationError( + `Harness '${harnessName}' is not deployed to target '${selectedTargetName}'. Run \`agentcore deploy\` first.` + ), + }; + } + + const sessionId = options.sessionId ?? randomUUID(); + const region = targetConfig.region; + + const logger = new InvokeLogger({ + agentName: harnessName, + runtimeArn: harnessState.harnessArn, + region, + sessionId, + }); + + // Read harness spec for auth config + const configIO = new ConfigIO(); + let harnessSpec; + try { + harnessSpec = await configIO.readHarnessSpec(harnessName); + } catch { + // spec read is best-effort + } + + // Auto-fetch bearer token for CUSTOM_JWT harnesses + if (harnessSpec?.authorizerType === 'CUSTOM_JWT' && !options.bearerToken) { + const canFetch = await canFetchHarnessToken(harnessName); + if (canFetch) { + try { + const tokenResult = await fetchHarnessToken(harnessName, { deployTarget: selectedTargetName }); + options = { ...options, bearerToken: tokenResult.token }; + } catch (err) { + return { + success: false, + error: new ValidationError( + `CUSTOM_JWT harness requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.` + ), + }; + } + } else { + return { + success: false, + error: new ValidationError( + `Harness '${harnessName}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the harness with --client-id and --client-secret to enable auto-fetch.` + ), + }; + } + } + + if (!options.prompt) { + return { + success: false, + error: new ValidationError('No prompt provided. Usage: agentcore invoke --harness "your prompt"'), + }; + } + + logger.logPrompt(options.prompt, sessionId, options.userId); + + const baseOpts = buildHarnessBaseOpts(options, harnessSpec?.model); + + const result = await streamHarnessInvoke({ + region, + harnessArn: harnessState.harnessArn, + sessionId, + prompt: options.prompt, + options, + logger, + baseOpts, + }); + + return { ...result, targetName: selectedTargetName }; +} diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 1359232c3..20d9bc40c 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,12 +1,13 @@ import { type Result, ValidationError, serializeResult } from '../../../lib'; import { getErrorMessage } from '../../errors'; +import { isPreviewEnabled } from '../../feature-flags'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; import { InvokeScreen } from '../../tui/screens/invoke'; import { parseHeaderFlags } from '../shared/header-utils'; -import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action'; +import { type InvokeContext, handleHarnessInvokeByArn, handleInvoke, loadInvokeConfig } from './action'; import { resolvePrompt } from './resolve-prompt'; import type { InvokeOptions, InvokeResult } from './types'; import { validateInvokeOptions } from './validate'; @@ -45,10 +46,30 @@ async function handleInvokeCLI(options: InvokeOptions, preloadedContext?: Invoke let spinner: NodeJS.Timeout | undefined; try { + // Preview: direct harness invoke by ARN (no project required) + if (isPreviewEnabled() && options.harnessArn) { + const region = options.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; + if (!region) { + const msg = '--region is required with --harness-arn (or set AWS_REGION)'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: msg })); + } else { + console.error(msg); + } + process.exit(1); + } + return handleHarnessInvokeByArn(options.harnessArn, region, options); + } + const context = preloadedContext ?? (await loadInvokeConfig()); // Show spinner for non-streaming, non-json, non-exec invocations - if (!options.stream && !options.json && !options.exec) { + // Harness invoke always streams directly to stdout, so skip spinner for harness + const isHarness = + isPreviewEnabled() && + (options.harnessName != null || + ((context.project.harnesses ?? []).length > 0 && context.project.runtimes.length === 0)); + if (!options.stream && !options.json && !options.exec && !isHarness) { spinner = startSpinner('Invoking agent...'); } @@ -97,7 +118,7 @@ function printInvokeResult(result: InvokeResult, options: InvokeOptions): void { } export const registerInvoke = (program: Command) => { - program + const invokeCmd = program .command('invoke') .alias('i') .description(COMMAND_DESCRIPTIONS.invoke) @@ -126,157 +147,226 @@ export const registerInvoke = (program: Command) => { (val: string, prev: string[]) => [...prev, val], [] as string[] ) - .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]') - .action( - async ( - positionalPrompt: string | undefined, - cliOptions: { - prompt?: string; - promptFile?: string; - runtime?: string; - target?: string; - sessionId?: string; - userId?: string; - json?: boolean; - stream?: boolean; - tool?: string; - input?: string; - exec?: boolean; - timeout?: number; - header?: string[]; - bearerToken?: string; - } - ) => { - try { - requireProject(); + .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]'); - // Load config once for protocol resolution and to pass into handleInvokeCLI - let invokeContext: InvokeContext | undefined; - let agentProtocol: string | undefined; - try { - invokeContext = await loadInvokeConfig(); - const agent = cliOptions.runtime - ? invokeContext.project.runtimes.find(a => a.name === cliOptions.runtime) - : invokeContext.project.runtimes[0]; - agentProtocol = agent?.protocol; - } catch { - // Config load failure will be caught again inside handleInvokeCLI - } + if (isPreviewEnabled()) { + invokeCmd + .option('--harness ', 'Select specific harness to invoke [non-interactive] [preview]') + .option('--harness-arn ', 'Invoke a harness by ARN (no project required) [non-interactive] [preview]') + .option( + '--region ', + 'AWS region (required with --harness-arn when no project) [non-interactive] [preview]' + ) + .option('--verbose', 'Print verbose streaming JSON events (harness only) [non-interactive] [preview]') + .option('--model-id ', 'Override model for this invocation (harness only) [non-interactive] [preview]') + .option( + '--model-provider ', + 'Override model provider: bedrock, open_ai, gemini (harness only) [non-interactive] [preview]' + ) + .option( + '--api-key-arn ', + 'Override API key ARN for open_ai/gemini (harness only) [non-interactive] [preview]' + ) + .option('--tools ', 'Override tools, comma-separated (harness only) [non-interactive] [preview]') + .option('--max-iterations ', 'Override max iterations (harness only) [non-interactive] [preview]', parseInt) + .option('--max-tokens ', 'Override max tokens (harness only) [non-interactive] [preview]', parseInt) + .option( + '--harness-timeout ', + 'Override timeout seconds (harness only) [non-interactive] [preview]', + parseInt + ) + .option('--system-prompt ', 'Override system prompt (harness only) [non-interactive] [preview]') + .option( + '--allowed-tools ', + 'Override allowed tools, comma-separated (harness only) [non-interactive] [preview]' + ) + .option('--actor-id ', 'Override memory actor ID (harness only) [non-interactive] [preview]'); + } - // Resolve prompt from flag / positional / --prompt-file / stdin - const resolved = await resolvePrompt({ - flag: cliOptions.prompt, - positional: positionalPrompt, - file: cliOptions.promptFile, - stdinPiped: !process.stdin.isTTY, - }); + invokeCmd.action( + async ( + positionalPrompt: string | undefined, + cliOptions: { + prompt?: string; + promptFile?: string; + runtime?: string; + target?: string; + sessionId?: string; + userId?: string; + json?: boolean; + stream?: boolean; + tool?: string; + input?: string; + exec?: boolean; + timeout?: number; + header?: string[]; + bearerToken?: string; + harness?: string; + harnessArn?: string; + region?: string; + verbose?: boolean; + modelId?: string; + modelProvider?: string; + apiKeyArn?: string; + tools?: string; + maxIterations?: number; + maxTokens?: number; + harnessTimeout?: number; + systemPrompt?: string; + allowedTools?: string; + actorId?: string; + } + ) => { + try { + // Skip requireProject when --harness-arn provided (preview mode) + if (!(isPreviewEnabled() && cliOptions.harnessArn)) { + requireProject(); + } - // CLI mode if any CLI-specific options provided, prompt resolved, or prompt resolution failed - // (follows deploy command pattern) - if ( - !resolved.success || - resolved.prompt !== undefined || - cliOptions.json || - cliOptions.target || - cliOptions.stream || - cliOptions.runtime || - cliOptions.tool || - cliOptions.exec || - cliOptions.bearerToken - ) { - const result = await withCommandRunTelemetry( - 'invoke', - { - has_stream: cliOptions.stream ?? false, - has_session_id: !!cliOptions.sessionId, - auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'), - agent_protocol: standardize( - AgentProtocol, - resolveProtocol({ tool: cliOptions.tool } as InvokeOptions, agentProtocol) - ), - }, - async (): Promise => { - if (!resolved.success) { - return { success: false, error: new ValidationError(resolved.error ?? 'Prompt resolution failed') }; - } + // Load config once for protocol resolution and to pass into handleInvokeCLI + let invokeContext: InvokeContext | undefined; + let agentProtocol: string | undefined; + try { + invokeContext = await loadInvokeConfig(); + const agent = cliOptions.runtime + ? invokeContext.project.runtimes.find(a => a.name === cliOptions.runtime) + : invokeContext.project.runtimes[0]; + agentProtocol = agent?.protocol; + } catch { + // Config load failure will be caught again inside handleInvokeCLI + } - // Parse custom headers - let headers: Record | undefined; - if (cliOptions.header && cliOptions.header.length > 0) { - headers = parseHeaderFlags(cliOptions.header); - } + // Resolve prompt from flag / positional / --prompt-file / stdin + const resolved = await resolvePrompt({ + flag: cliOptions.prompt, + positional: positionalPrompt, + file: cliOptions.promptFile, + stdinPiped: !process.stdin.isTTY, + }); - const options: InvokeOptions = { - prompt: resolved.prompt, - agentName: cliOptions.runtime, - targetName: cliOptions.target ?? 'default', - sessionId: cliOptions.sessionId, - userId: cliOptions.userId, - json: cliOptions.json, - stream: cliOptions.stream, - tool: cliOptions.tool, - input: cliOptions.input, - exec: cliOptions.exec, - timeout: cliOptions.timeout, - headers, - bearerToken: cliOptions.bearerToken, - }; + // CLI mode if any CLI-specific options provided, prompt resolved, or prompt resolution failed + // (follows deploy command pattern) + if ( + !resolved.success || + resolved.prompt !== undefined || + cliOptions.json || + cliOptions.target || + cliOptions.stream || + cliOptions.runtime || + cliOptions.tool || + cliOptions.exec || + cliOptions.bearerToken || + cliOptions.harness || + cliOptions.harnessArn || + cliOptions.verbose + ) { + const result = await withCommandRunTelemetry( + 'invoke', + { + has_stream: cliOptions.stream ?? false, + has_session_id: !!cliOptions.sessionId, + auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'), + agent_protocol: standardize( + AgentProtocol, + resolveProtocol({ tool: cliOptions.tool } as InvokeOptions, agentProtocol) + ), + }, + async (): Promise => { + if (!resolved.success) { + return { success: false, error: new ValidationError(resolved.error ?? 'Prompt resolution failed') }; + } - return handleInvokeCLI(options, invokeContext); + // Parse custom headers + let headers: Record | undefined; + if (cliOptions.header && cliOptions.header.length > 0) { + headers = parseHeaderFlags(cliOptions.header); } - ); - printInvokeResult(result, { - json: cliOptions.json, - stream: cliOptions.stream, - }); - process.exit(result.success ? 0 : 1); - } else { - // No CLI options - interactive TUI mode (headers still passed if provided) - requireTTY(); + const options: InvokeOptions = { + prompt: resolved.prompt, + agentName: cliOptions.runtime, + targetName: cliOptions.target ?? 'default', + sessionId: cliOptions.sessionId, + userId: cliOptions.userId, + json: cliOptions.json, + stream: cliOptions.stream, + tool: cliOptions.tool, + input: cliOptions.input, + exec: cliOptions.exec, + timeout: cliOptions.timeout, + headers, + bearerToken: cliOptions.bearerToken, + harnessName: cliOptions.harness, + harnessArn: cliOptions.harnessArn, + region: cliOptions.region, + verbose: cliOptions.verbose, + modelId: cliOptions.modelId, + modelProvider: cliOptions.modelProvider, + apiKeyArn: cliOptions.apiKeyArn, + tools: cliOptions.tools, + maxIterations: cliOptions.maxIterations, + maxTokens: cliOptions.maxTokens, + harnessTimeout: cliOptions.harnessTimeout, + systemPrompt: cliOptions.systemPrompt, + allowedTools: cliOptions.allowedTools, + actorId: cliOptions.actorId, + }; - // Parse custom headers for TUI mode - let headers: Record | undefined; - if (cliOptions.header && cliOptions.header.length > 0) { - headers = parseHeaderFlags(cliOptions.header); + return handleInvokeCLI(options, invokeContext); } + ); - const tuiResult = await withCommandRunTelemetry( - 'invoke', - { - has_stream: true, - has_session_id: !!cliOptions.sessionId, - auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'), - agent_protocol: standardize(AgentProtocol, resolveProtocol({}, agentProtocol)), - }, - async (): Promise => { - const { waitUntilExit, unmount } = render( - unmount()} - initialSessionId={cliOptions.sessionId} - initialUserId={cliOptions.userId} - initialHeaders={headers} - initialBearerToken={cliOptions.bearerToken} - /> - ); - await waitUntilExit(); - return { success: true }; - } - ); - if (!tuiResult.success) { - render(Error: {getErrorMessage(tuiResult.error)}); - process.exit(1); - } + printInvokeResult(result, { + json: cliOptions.json, + stream: cliOptions.stream, + }); + process.exit(result.success ? 0 : 1); + } else { + // No CLI options - interactive TUI mode (headers still passed if provided) + requireTTY(); + + // Parse custom headers for TUI mode + let headers: Record | undefined; + if (cliOptions.header && cliOptions.header.length > 0) { + headers = parseHeaderFlags(cliOptions.header); } - } catch (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); - } else { - render(Error: {getErrorMessage(error)}); + + const tuiResult = await withCommandRunTelemetry( + 'invoke', + { + has_stream: true, + has_session_id: !!cliOptions.sessionId, + auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'), + agent_protocol: standardize(AgentProtocol, resolveProtocol({}, agentProtocol)), + }, + async (): Promise => { + const { waitUntilExit, unmount } = render( + unmount()} + initialSessionId={cliOptions.sessionId} + initialUserId={cliOptions.userId} + initialHeaders={headers} + initialBearerToken={cliOptions.bearerToken} + /> + ); + await waitUntilExit(); + return { success: true }; + } + ); + if (!tuiResult.success) { + render(Error: {getErrorMessage(tuiResult.error)}); + process.exit(1); } - process.exit(1); } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); } - ); + } + ); }; diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 86411214c..31798b3de 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -2,6 +2,11 @@ import type { Result } from '../../../lib/result'; export interface InvokeOptions { agentName?: string; + harnessName?: string; + /** Direct harness ARN โ€” bypasses project config and deployed state resolution */ + harnessArn?: string; + /** AWS region (used with --harness-arn) */ + region?: string; targetName?: string; prompt?: string; /** Path to a file containing the prompt (alternative to --prompt / positional) */ @@ -22,6 +27,30 @@ export interface InvokeOptions { headers?: Record; /** Bearer token for CUSTOM_JWT auth (bypasses SigV4) */ bearerToken?: string; + /** Print verbose streaming JSON events instead of formatted text (harness only) */ + verbose?: boolean; + /** Override model ID for this invocation (harness only) */ + modelId?: string; + /** Override model provider for this invocation (harness only): bedrock, open_ai, gemini */ + modelProvider?: string; + /** Override API key ARN for this invocation (harness only, open_ai/gemini) */ + apiKeyArn?: string; + /** Override tools for this invocation (harness only, comma-separated) */ + tools?: string; + /** Override max iterations (harness only) */ + maxIterations?: number; + /** Override timeout seconds (harness only) */ + harnessTimeout?: number; + /** Override max tokens (harness only) */ + maxTokens?: number; + /** Skills to use (harness only, comma-separated paths) */ + skills?: string; + /** Override system prompt (harness only) */ + systemPrompt?: string; + /** Override allowed tools (harness only, comma-separated) */ + allowedTools?: string; + /** Override memory actor ID (harness only) */ + actorId?: string; } export type InvokeResult = Result & { diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 1cd58c625..ab9d240da 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -63,6 +63,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }, deployedState: { targets: { @@ -127,6 +128,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); @@ -171,6 +173,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }, deployedState: { targets: { @@ -225,6 +228,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 369a323d7..26d1d0217 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -38,6 +38,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise', 'Target harness name') + .requiredOption('--name ', 'Tool name to remove') + .option('--json', 'Output as JSON') + .action(async cliOptions => { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + try { + const configIO = new ConfigIO(); + let harnessSpec; + try { + harnessSpec = await configIO.readHarnessSpec(cliOptions.harness); + } catch { + const error = `Harness '${cliOptions.harness}' not found.`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + return; + } + + const toolIndex = harnessSpec.tools.findIndex(t => t.name === cliOptions.name); + if (toolIndex === -1) { + const error = `Tool '${cliOptions.name}' not found in harness '${cliOptions.harness}'`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + return; + } + + harnessSpec.tools.splice(toolIndex, 1); + await configIO.writeHarnessSpec(cliOptions.harness, harnessSpec); + + const result = { success: true, harnessName: cliOptions.harness, toolName: cliOptions.name }; + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.log(`Removed tool '${cliOptions.name}' from harness '${cliOptions.harness}'.`); + console.log(`Run 'agentcore deploy' to apply changes.`); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index b45c3ba4a..6a3171a90 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -2,6 +2,7 @@ import type { Result } from '../../../lib/result'; export type ResourceType = | 'agent' + | 'harness' | 'gateway' | 'gateway-target' | 'runtime-endpoint' diff --git a/src/cli/constants.ts b/src/cli/constants.ts index 48c3e0375..85d053817 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -30,11 +30,21 @@ export const DISTRO_CONFIG = { }, } as const; +export function getNpmDistTag(): string { + return PACKAGE_VERSION.includes('-') ? 'preview' : 'latest'; +} + /** * Get the current distribution configuration. */ export function getDistroConfig() { - return DISTRO_CONFIG[DISTRO_MODE]; + const base = DISTRO_CONFIG[DISTRO_MODE]; + const distTag = getNpmDistTag(); + return { + ...base, + distTag, + installCommand: base.installCommand.replace('@latest', `@${distTag}`), + }; } /** diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts index 462d9be14..ec4ec42e0 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -56,6 +56,7 @@ describe('requiresUv', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(requiresUv(project)).toBe(true); }); @@ -84,6 +85,7 @@ describe('requiresUv', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -103,6 +105,7 @@ describe('requiresUv', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(requiresUv(project)).toBe(false); }); @@ -133,6 +136,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -161,6 +165,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -180,6 +185,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -216,6 +222,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -286,6 +293,7 @@ describe('checkDependencyVersions', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -309,6 +317,7 @@ describe('checkDependencyVersions', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const result = await checkDependencyVersions(project); @@ -340,6 +349,7 @@ describe('checkDependencyVersions', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/feature-flags.ts b/src/cli/feature-flags.ts new file mode 100644 index 000000000..f6dce4f86 --- /dev/null +++ b/src/cli/feature-flags.ts @@ -0,0 +1,3 @@ +declare const __PREVIEW__: boolean; + +export const isPreviewEnabled = (): boolean => __PREVIEW__; diff --git a/src/cli/index.ts b/src/cli/index.ts index 9006973ee..33d64012b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,9 @@ import { main } from './cli.js'; import { getErrorMessage } from './errors.js'; +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any +(globalThis as any).__PREVIEW__ ??= process.env.BUILD_PREVIEW === '1'; + // Global safety net โ€” prevent raw stack traces from reaching the user process.on('uncaughtException', err => { console.error(`Error: ${getErrorMessage(err)}`); diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 54f8aa0ba..4f659c089 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -9,6 +9,7 @@ export interface RemoveLoggerOptions { /** Type of resource being removed */ resourceType: | 'agent' + | 'harness' | 'memory' | 'credential' | 'gateway' diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 38c89fd85..531885924 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -74,6 +74,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts index 75f36ebcc..bd02fb43c 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts @@ -69,6 +69,7 @@ function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCo configBundles: [], httpGateways: [], abTests, + harnesses: [], }; } diff --git a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts index ecfc285cd..f398c581e 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -507,6 +507,7 @@ describe('resolveConfigBundleComponentKeys', () => { configBundles, httpGateways: [], abTests: [], + harnesses: [], }; } diff --git a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts index 32c7e6252..327ade8da 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts @@ -81,6 +81,7 @@ function makeProjectSpec(httpGateways: AgentCoreProjectSpec['httpGateways'] = [] configBundles: [], abTests: [], httpGateways, + harnesses: [], }; } diff --git a/src/cli/operations/deploy/change-detection.ts b/src/cli/operations/deploy/change-detection.ts new file mode 100644 index 000000000..65a513142 --- /dev/null +++ b/src/cli/operations/deploy/change-detection.ts @@ -0,0 +1,75 @@ +import { ConfigIO } from '../../../lib'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +/** + * Computes a hash of the project configuration relevant to deploy. + * Includes agentcore.json, all harness.json files, system-prompt.md files, + * and aws-targets.json. + * + * Only used for harness-only projects โ€” runtime projects always need full + * deploy since source code changes aren't tracked here. + */ +export async function computeProjectDeployHash(configIO: ConfigIO): Promise { + const hash = createHash('sha256'); + + const projectSpec = await configIO.readProjectSpec(); + hash.update(JSON.stringify(projectSpec)); + + const configRoot = configIO.getConfigRoot(); + const projectRoot = dirname(configRoot); + + for (const harness of projectSpec.harnesses ?? []) { + const harnessDir = join(projectRoot, harness.path); + try { + const harnessJson = await readFile(join(harnessDir, 'harness.json'), 'utf-8'); + hash.update(harnessJson); + } catch { + // harness.json missing โ€” hash will differ from last deploy + } + try { + const prompt = await readFile(join(harnessDir, 'system-prompt.md'), 'utf-8'); + hash.update(prompt); + } catch { + // no system prompt + } + } + + const awsTargets = await configIO.readAWSDeploymentTargets(); + hash.update(JSON.stringify(awsTargets)); + + return hash.digest('hex').slice(0, 16); +} + +/** + * Checks if the project has changed since the last deploy. + * Returns true if deploy can be skipped. + * + * Only applies to harness-only projects. Projects with runtimes always + * need full deploy since source code changes aren't tracked by hash. + */ +export async function canSkipDeploy(configIO: ConfigIO): Promise { + try { + const projectSpec = await configIO.readProjectSpec(); + + if (projectSpec.runtimes.length > 0) { + return false; + } + + const currentHash = await computeProjectDeployHash(configIO); + const deployedState = await configIO.readDeployedState(); + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) return false; + + for (const targetName of targetNames) { + const targetState = deployedState.targets[targetName]; + const storedHash = targetState?.resources?.deployHash; + if (storedHash !== currentHash) return false; + } + + return true; + } catch { + return false; + } +} diff --git a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts new file mode 100644 index 000000000..ea5b31d5f --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts @@ -0,0 +1,359 @@ +/** + * HarnessDeployer - Post-CDK imperative deployer for Harness resources. + * + * Runs after CDK deploy to create, update, or delete harness resources + * via the SigV4 API client. Harness role ARNs are resolved from CDK + * stack outputs, and harness specs are read from disk (harness.json). + */ +import type { HarnessDeployedState, HarnessSpec } from '../../../../../schema'; +import { HarnessSpecSchema } from '../../../../../schema'; +import type { + CreateHarnessResult, + Harness, + UpdateHarnessOptions, + UpdateHarnessResult, +} from '../../../../aws/agentcore-harness'; +import { createHarness, deleteHarness, getHarness, updateHarness } from '../../../../aws/agentcore-harness'; +import { AgentCoreApiError } from '../../../../aws/api-client'; +import { toPascalId } from '../../../../cloudformation/logical-ids'; +import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from '../types'; +import { mapHarnessSpecToCreateOptions } from './harness-mapper'; +import { readFile } from 'fs/promises'; +import { createHash } from 'node:crypto'; +import { dirname, join } from 'path'; + +const ROLE_VALIDATION_RETRY_DELAYS_MS = [5_000, 10_000, 15_000, 20_000, 30_000]; +const READY_POLL_INTERVAL_MS = 3_000; +const READY_POLL_MAX_ATTEMPTS = 40; // 2 minutes max + +// ============================================================================ +// Types +// ============================================================================ + +type HarnessDeployedStateMap = Record; + +async function computeHarnessHash(harnessDir: string, harnessSpec: HarnessSpec, roleArn: string): Promise { + const hash = createHash('sha256'); + hash.update(JSON.stringify(harnessSpec)); + hash.update(roleArn); + try { + const promptContent = await readFile(join(harnessDir, 'system-prompt.md'), 'utf-8'); + hash.update(promptContent); + } catch { + // no system-prompt.md + } + if (harnessSpec.dockerfile) { + try { + const dockerfileContent = await readFile(join(harnessDir, harnessSpec.dockerfile), 'utf-8'); + hash.update(dockerfileContent); + } catch { + // Dockerfile missing โ€” preflight already validates existence before deploy + } + } + return hash.digest('hex').slice(0, 16); +} + +// ============================================================================ +// Deployer +// ============================================================================ + +export class HarnessDeployer implements ImperativeDeployer { + readonly name = 'harness'; + readonly label = 'Harnesses'; + readonly phase: DeployPhase = 'post-cdk'; + + shouldRun(context: ImperativeDeployContext): boolean { + const projectHarnesses = context.projectSpec.harnesses; + const hasProjectHarnesses = !!projectHarnesses && projectHarnesses.length > 0; + + const targetName = context.target.name; + const deployedHarnesses = context.deployedState.targets?.[targetName]?.resources?.harnesses; + const hasDeployedHarnesses = !!deployedHarnesses && Object.keys(deployedHarnesses).length > 0; + + return hasProjectHarnesses || hasDeployedHarnesses; + } + + async deploy(context: ImperativeDeployContext): Promise> { + const { projectSpec, target, configIO, deployedState, cdkOutputs } = context; + const region = target.region; + const targetName = target.name; + const projectName = projectSpec.name; + const configRoot = configIO.getConfigRoot(); + const projectRoot = dirname(configRoot); + + const projectHarnesses = projectSpec.harnesses ?? []; + const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {}; + const resultState: HarnessDeployedStateMap = { ...deployedHarnesses }; + const notes: string[] = []; + + // Build set of harness names in current project spec + const projectHarnessNames = new Set(projectHarnesses.map(h => h.name)); + + // Create or update each harness in the project spec + for (const entry of projectHarnesses) { + // Harness path is relative to project root (like agent codeLocation) + const harnessDir = join(projectRoot, entry.path); + + // Read harness.json from disk and validate + let harnessSpec: HarnessSpec; + try { + const raw = await readFile(join(harnessDir, 'harness.json'), 'utf-8'); + const parsed: unknown = JSON.parse(raw); + const validated = HarnessSpecSchema.safeParse(parsed); + if (!validated.success) { + return { + success: false, + error: `Invalid harness.json for "${entry.name}": ${validated.error.message}`, + state: resultState, + }; + } + harnessSpec = validated.data; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + success: false, + error: `Failed to read harness.json for "${entry.name}": ${message}`, + state: resultState, + }; + } + + // Resolve role ARN from CDK outputs + const roleArn = resolveRoleArn(entry.name, cdkOutputs); + if (!roleArn) { + return { + success: false, + error: `Could not find role ARN in CDK outputs for harness "${entry.name}". Expected output key starting with "ApplicationHarness${toPascalId(entry.name)}RoleArn" or "ApplicationHarness${toPascalId(entry.name)}RoleRoleArn".`, + state: resultState, + }; + } + + // Use executionRoleArn from harness spec if provided, otherwise use CDK output + const executionRoleArn = harnessSpec.executionRoleArn ?? roleArn; + + const deployedResources = deployedState.targets?.[targetName]?.resources; + const existingHarness = deployedHarnesses[entry.name]; + + const configHash = await computeHarnessHash(harnessDir, harnessSpec, executionRoleArn); + + if (existingHarness?.configHash === configHash) { + resultState[entry.name] = existingHarness; + notes.push(`Harness "${entry.name}" unchanged, skipped`); + context.onProgress?.(`Harness "${entry.name}": no changes`, 'done'); + continue; + } + + try { + if (existingHarness) { + // Update existing harness + const createOptions = await mapHarnessSpecToCreateOptions({ + harnessSpec, + harnessDir, + executionRoleArn, + region, + projectName, + deployedResources, + cdkOutputs, + }); + + // Memory uses { optionalValue: null } to explicitly clear it when removed from config, + // since the API treats an absent field as "no change" but null as "remove". + // environmentArtifact uses undefined (omit) because container config is immutable + // after creation โ€” it cannot be cleared via update, only set on create. + const updateOptions: UpdateHarnessOptions = { + region, + harnessId: existingHarness.harnessId, + executionRoleArn: createOptions.executionRoleArn, + model: createOptions.model, + systemPrompt: createOptions.systemPrompt, + tools: createOptions.tools, + skills: createOptions.skills, + allowedTools: createOptions.allowedTools, + memory: createOptions.memory ? { optionalValue: createOptions.memory } : { optionalValue: null }, + truncation: createOptions.truncation, + maxIterations: createOptions.maxIterations, + maxTokens: createOptions.maxTokens, + timeoutSeconds: createOptions.timeoutSeconds, + environment: createOptions.environment, + environmentArtifact: createOptions.environmentArtifact + ? { optionalValue: createOptions.environmentArtifact } + : undefined, + environmentVariables: createOptions.environmentVariables, + tags: createOptions.tags, + authorizerConfiguration: createOptions.authorizerConfiguration + ? { optionalValue: createOptions.authorizerConfiguration } + : { optionalValue: null }, + }; + + const updateResult: UpdateHarnessResult = await updateHarness(updateOptions); + const finalHarness = await waitForReady(region, updateResult.harness); + resultState[entry.name] = { + harnessId: finalHarness.harnessId, + harnessArn: finalHarness.arn, + roleArn: executionRoleArn, + status: finalHarness.status, + agentRuntimeArn: extractRuntimeArn(finalHarness), + memoryArn: createOptions.memory?.agentCoreMemoryConfiguration?.arn, + configHash, + }; + notes.push(`Updated harness "${entry.name}"`); + } else { + // Create new harness (with retry for IAM role propagation delay) + const createOptions = await mapHarnessSpecToCreateOptions({ + harnessSpec, + harnessDir, + executionRoleArn, + region, + projectName, + deployedResources, + cdkOutputs, + }); + + const createResult: CreateHarnessResult = await createWithRetry(createOptions); + const finalHarness = await waitForReady(region, createResult.harness); + resultState[entry.name] = { + harnessId: finalHarness.harnessId, + harnessArn: finalHarness.arn, + roleArn: executionRoleArn, + status: finalHarness.status, + agentRuntimeArn: extractRuntimeArn(finalHarness), + memoryArn: createOptions.memory?.agentCoreMemoryConfiguration?.arn, + configHash, + }; + notes.push(`Created harness "${entry.name}"`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const hint = getDeployErrorHint(err, region); + const errorMsg = hint + ? `Failed to deploy harness "${entry.name}": ${message}\n${hint}` + : `Failed to deploy harness "${entry.name}": ${message}`; + return { success: false, error: errorMsg, state: resultState }; + } + } + + // Delete harnesses that exist in deployed state but not in project spec + for (const [name, state] of Object.entries(deployedHarnesses)) { + if (!projectHarnessNames.has(name)) { + try { + await deleteHarness({ region, harnessId: state.harnessId }); + delete resultState[name]; + notes.push(`Deleted harness "${name}"`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to delete harness "${name}": ${message}`, state: resultState }; + } + } + } + + return { success: true, state: resultState, notes }; + } + + async teardown(context: ImperativeDeployContext): Promise> { + const { target, deployedState } = context; + const region = target.region; + const targetName = target.name; + + const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {}; + const notes: string[] = []; + + for (const [name, state] of Object.entries(deployedHarnesses)) { + try { + await deleteHarness({ region, harnessId: state.harnessId }); + notes.push(`Deleted harness "${name}"`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to delete harness "${name}": ${message}` }; + } + } + + return { success: true, state: {}, notes }; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Resolve the IAM role ARN for a harness from CDK stack outputs. + * + * Supports two construct tree layouts: + * Old (AgentCoreHarnessRole directly under Application): + * ApplicationHarness{PascalName}RoleArnOutput... + * New (AgentCoreHarnessEnvironment wrapping AgentCoreHarnessRole): + * ApplicationHarness{PascalName}RoleRoleArnOutput... + */ +function resolveRoleArn(harnessName: string, cdkOutputs?: Record): string | undefined { + if (!cdkOutputs) return undefined; + + const pascalName = toPascalId(harnessName); + // Longer prefix first โ€” RoleArn is a substring of RoleRoleArn, so checking it first would match both. + const prefixes = [`ApplicationHarness${pascalName}RoleRoleArn`, `ApplicationHarness${pascalName}RoleArn`]; + + for (const [key, value] of Object.entries(cdkOutputs)) { + if (prefixes.some(p => key.startsWith(p))) { + return value; + } + } + + return undefined; +} + +function isRoleValidationError(err: unknown): boolean { + return err instanceof AgentCoreApiError && err.statusCode === 400 && err.errorBody.includes('Role validation failed'); +} + +async function createWithRetry(options: Parameters[0]): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= ROLE_VALIDATION_RETRY_DELAYS_MS.length; attempt++) { + try { + return await createHarness(options); + } catch (err) { + if (!isRoleValidationError(err) || attempt === ROLE_VALIDATION_RETRY_DELAYS_MS.length) { + throw err; + } + lastError = err; + await sleep(ROLE_VALIDATION_RETRY_DELAYS_MS[attempt]!); + } + } + throw lastError; +} + +async function waitForReady(region: string, harness: Harness): Promise { + if (harness.status === 'READY' || harness.status === 'FAILED') return harness; + + for (let i = 0; i < READY_POLL_MAX_ATTEMPTS; i++) { + await sleep(READY_POLL_INTERVAL_MS); + const result = await getHarness({ region, harnessId: harness.harnessId }); + if (result.harness.status === 'READY' || result.harness.status === 'FAILED') return result.harness; + } + + return harness; +} + +function extractRuntimeArn(harness: Harness): string | undefined { + return harness.environment?.agentCoreRuntimeEnvironment?.agentRuntimeArn; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getDeployErrorHint(err: unknown, region: string): string | undefined { + if (!(err instanceof AgentCoreApiError)) return undefined; + const body = err.errorBody.toLowerCase(); + + if (err.statusCode === 403) { + return 'Check that your AWS credentials have permission to call the AgentCore Harness API.'; + } + if (body.includes('not available') || body.includes('not supported') || body.includes('endpoint')) { + return `Harness may not be available in ${region}. Try a different region (e.g., us-east-1, us-west-2).`; + } + if (err.statusCode === 429) { + return 'Too many requests. Wait a moment and try again.'; + } + if (err.statusCode >= 500) { + return 'This looks like a service-side issue. Wait a moment and redeploy.'; + } + return undefined; +} diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts new file mode 100644 index 000000000..818456a05 --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts @@ -0,0 +1,407 @@ +/** + * Maps user-facing HarnessSpec (harness.json) to the CreateHarness API wire format. + * + * Each transformation is a pure function that converts a section of the spec + * into the corresponding API field. The top-level mapHarnessSpecToCreateOptions + * orchestrates them and returns a complete CreateHarnessOptions object. + */ +import type { DeployedResourceState, HarnessSpec } from '../../../../../schema'; +import type { + CreateHarnessOptions, + HarnessEnvironmentArtifact, + HarnessEnvironmentProvider, + HarnessMemoryConfiguration, + HarnessModelConfiguration, + HarnessSkill, + HarnessSystemPrompt, + HarnessTool, + HarnessTruncationConfiguration, +} from '../../../../aws/agentcore-harness'; +import { toPascalId } from '../../../../cloudformation/logical-ids'; +import { readFile, stat } from 'fs/promises'; +import { join } from 'path'; + +const MAX_PROMPT_FILE_SIZE = 1024 * 1024; // 1 MB + +// ============================================================================ +// Public Interface +// ============================================================================ + +export interface MapHarnessOptions { + harnessSpec: HarnessSpec; + harnessDir: string; + executionRoleArn: string; + region: string; + projectName: string; + deployedResources?: DeployedResourceState; + cdkOutputs?: Record; +} + +/** + * Transform a HarnessSpec into CreateHarnessOptions for the control plane API. + */ +export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): Promise { + const { harnessSpec, harnessDir, executionRoleArn, region, projectName, deployedResources, cdkOutputs } = options; + + const result: CreateHarnessOptions = { + region, + harnessName: `${projectName}_${harnessSpec.name}`, + executionRoleArn, + }; + + // Model + result.model = mapModel(harnessSpec.model); + + // System prompt (may read from disk or auto-discover system-prompt.md) + if (harnessSpec.systemPrompt !== undefined) { + result.systemPrompt = await mapSystemPrompt(harnessSpec.systemPrompt, harnessDir); + } else { + // Auto-discover system-prompt.md if it exists + result.systemPrompt = await tryLoadSystemPromptFile(harnessDir); + } + + // Tools + if (harnessSpec.tools.length > 0) { + result.tools = mapTools(harnessSpec.tools); + } + + // Skills + if (harnessSpec.skills.length > 0) { + result.skills = mapSkills(harnessSpec.skills); + } + + // Allowed tools + if (harnessSpec.allowedTools) { + result.allowedTools = harnessSpec.allowedTools; + } + + // Memory + if (harnessSpec.memory) { + result.memory = mapMemory(harnessSpec.memory, deployedResources, cdkOutputs); + } + + // Truncation + if (harnessSpec.truncation) { + result.truncation = mapTruncation(harnessSpec.truncation); + } + + // Execution limits + if (harnessSpec.maxIterations !== undefined) { + result.maxIterations = harnessSpec.maxIterations; + } + if (harnessSpec.maxTokens !== undefined) { + result.maxTokens = harnessSpec.maxTokens; + } + if (harnessSpec.timeoutSeconds !== undefined) { + result.timeoutSeconds = harnessSpec.timeoutSeconds; + } + + // Container artifact + if (harnessSpec.containerUri) { + result.environmentArtifact = mapEnvironmentArtifact(harnessSpec.containerUri); + } else if (harnessSpec.dockerfile) { + const builtUri = resolveContainerUriFromOutputs(harnessSpec.name, cdkOutputs); + if (!builtUri) { + throw new Error( + `Harness "${harnessSpec.name}" specifies "dockerfile" but no container URI was found in CDK outputs. ` + + `Expected a CDK output key starting with "ApplicationHarness${toPascalId(harnessSpec.name)}ImageUri" or "Harness${toPascalId(harnessSpec.name)}ContainerUri".` + ); + } + result.environmentArtifact = mapEnvironmentArtifact(builtUri); + } + + // Environment provider (network + lifecycle) + const environmentProvider = mapEnvironmentProvider(harnessSpec); + if (environmentProvider) { + result.environment = environmentProvider; + } + + // Environment variables + if (harnessSpec.environmentVariables) { + result.environmentVariables = harnessSpec.environmentVariables; + } + + // Tags + if (harnessSpec.tags) { + result.tags = harnessSpec.tags; + } + + // Authorizer configuration โ€” authorizerType is inferred by the API from the + // presence of authorizerConfiguration, so only the configuration is forwarded. + if (harnessSpec.authorizerConfiguration?.customJwtAuthorizer) { + const jwt = harnessSpec.authorizerConfiguration.customJwtAuthorizer; + result.authorizerConfiguration = { + customJWTAuthorizer: { + discoveryUrl: jwt.discoveryUrl, + ...(jwt.allowedAudience && { allowedAudience: jwt.allowedAudience }), + ...(jwt.allowedClients && { allowedClients: jwt.allowedClients }), + ...(jwt.allowedScopes && { allowedScopes: jwt.allowedScopes }), + ...(jwt.customClaims && { customClaims: jwt.customClaims }), + }, + }; + } + + return result; +} + +// ============================================================================ +// Model Mapping +// ============================================================================ + +function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { + const { provider, modelId, apiKeyArn, temperature, topP, topK, maxTokens } = model; + + switch (provider) { + case 'bedrock': + return { + bedrockModelConfig: { + modelId, + ...(temperature !== undefined && { temperature }), + ...(topP !== undefined && { topP }), + ...(maxTokens !== undefined && { maxTokens }), + }, + }; + case 'open_ai': + return { + openAiModelConfig: { + modelId, + ...(apiKeyArn && { apiKeyArn }), + ...(temperature !== undefined && { temperature }), + ...(topP !== undefined && { topP }), + ...(maxTokens !== undefined && { maxTokens }), + }, + }; + case 'gemini': + return { + geminiModelConfig: { + modelId, + ...(apiKeyArn && { apiKeyArn }), + ...(temperature !== undefined && { temperature }), + ...(topP !== undefined && { topP }), + ...(topK !== undefined && { topK }), + ...(maxTokens !== undefined && { maxTokens }), + }, + }; + } +} + +// ============================================================================ +// System Prompt Mapping +// ============================================================================ + +const FILE_PATH_PATTERN = /^\.\.?\//; +const FILE_EXTENSION_PATTERN = /\.(md|txt)$/; + +function isFilePath(value: string): boolean { + return FILE_PATH_PATTERN.test(value) || FILE_EXTENSION_PATTERN.test(value); +} + +async function mapSystemPrompt(prompt: string, harnessDir: string): Promise { + let text: string; + + if (isFilePath(prompt)) { + const filePath = join(harnessDir, prompt); + const fileStats = await stat(filePath); + if (fileStats.size > MAX_PROMPT_FILE_SIZE) { + throw new Error( + `System prompt file "${prompt}" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.` + ); + } + text = await readFile(filePath, 'utf-8'); + } else { + text = prompt; + } + + return [{ text }]; +} + +/** + * Try to load system-prompt.md from harness directory. + * Returns undefined if file doesn't exist (harness will have no system prompt). + */ +async function tryLoadSystemPromptFile(harnessDir: string): Promise { + const promptPath = join(harnessDir, 'system-prompt.md'); + + try { + const fileStats = await stat(promptPath); + if (fileStats.size > MAX_PROMPT_FILE_SIZE) { + throw new Error( + `System prompt file "system-prompt.md" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.` + ); + } + const text = await readFile(promptPath, 'utf-8'); + return [{ text }]; + } catch (err) { + // File doesn't exist - return undefined (no system prompt) + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + // Other errors (permissions, etc.) should be thrown + throw err; + } +} + +// ============================================================================ +// Tools Mapping +// ============================================================================ + +function mapTools(tools: HarnessSpec['tools']): HarnessTool[] { + return tools.map(tool => ({ + type: tool.type, + name: tool.name, + ...(tool.config && { config: tool.config }), + })); +} + +// ============================================================================ +// Skills Mapping +// ============================================================================ + +function mapSkills(skills: string[]): HarnessSkill[] { + return skills.map(path => ({ path })); +} + +// ============================================================================ +// Memory Mapping +// ============================================================================ + +function mapMemory( + memory: NonNullable, + deployedResources?: DeployedResourceState, + cdkOutputs?: Record +): HarnessMemoryConfiguration | undefined { + let arn: string | undefined; + + // Direct ARN takes precedence + if (memory.arn) { + arn = memory.arn; + } else if (memory.name) { + // Resolve by name from deployed state or CDK outputs + const deployedMemory = deployedResources?.memories?.[memory.name]; + if (deployedMemory) { + arn = deployedMemory.memoryArn; + } else if (cdkOutputs) { + arn = resolveMemoryArnFromOutputs(memory.name, cdkOutputs); + } + + if (!arn) { + throw new Error( + `Memory "${memory.name}" referenced by harness is not in deployed state. Ensure the memory is defined in agentcore.json and has been deployed.` + ); + } + } + + if (!arn) { + return undefined; + } + + return { + agentCoreMemoryConfiguration: { + arn, + ...(memory.actorId && { actorId: memory.actorId }), + }, + }; +} + +/** + * Resolve memory ARN from CDK stack outputs. + * The CDK construct exports memory ARNs with keys matching: + * ApplicationMemory{PascalName}ArnOutput... + */ +function resolveMemoryArnFromOutputs(memoryName: string, cdkOutputs: Record): string | undefined { + const pascalName = toPascalId(memoryName); + const prefix = `ApplicationMemory${pascalName}ArnOutput`; + + for (const [key, value] of Object.entries(cdkOutputs)) { + if (key.startsWith(prefix)) { + return value; + } + } + + return undefined; +} + +// ============================================================================ +// Truncation Mapping +// ============================================================================ + +function mapTruncation(truncation: NonNullable): HarnessTruncationConfiguration { + return { + strategy: truncation.strategy, + config: truncation.config as HarnessTruncationConfiguration['config'], + }; +} + +// ============================================================================ +// Container URI Resolution (from CDK outputs for dockerfile-based harnesses) +// ============================================================================ + +/** + * Supports two construct tree layouts: + * Old (CfnOutput on stack root): + * Harness{PascalName}ContainerUri... + * New (CfnOutput inside AgentCoreHarnessEnvironment): + * ApplicationHarness{PascalName}ImageUriOutput... + */ +function resolveContainerUriFromOutputs(harnessName: string, cdkOutputs?: Record): string | undefined { + if (!cdkOutputs) return undefined; + + const pascalName = toPascalId(harnessName); + const prefixes = [`ApplicationHarness${pascalName}ImageUri`, `Harness${pascalName}ContainerUri`]; + + for (const [key, value] of Object.entries(cdkOutputs)) { + if (prefixes.some(p => key.startsWith(p))) { + return value; + } + } + + return undefined; +} + +// ============================================================================ +// Container / Environment Artifact Mapping +// ============================================================================ + +function mapEnvironmentArtifact(containerUri: string): HarnessEnvironmentArtifact { + return { + containerConfiguration: { containerUri }, + }; +} + +// ============================================================================ +// Environment Provider (Network + Lifecycle) Mapping +// ============================================================================ + +function mapEnvironmentProvider(spec: HarnessSpec): HarnessEnvironmentProvider | undefined { + const hasNetwork = !!spec.networkConfig; + const hasLifecycle = !!spec.lifecycleConfig; + const hasSessionStorage = !!spec.sessionStoragePath; + + if (!hasNetwork && !hasLifecycle && !hasSessionStorage) { + return undefined; + } + + const agentCoreRuntimeEnvironment: Record = {}; + + if (spec.networkConfig) { + agentCoreRuntimeEnvironment.networkConfiguration = { + networkMode: 'VPC', + networkModeConfig: { + subnets: spec.networkConfig.subnets, + securityGroups: spec.networkConfig.securityGroups, + }, + }; + } + + if (spec.lifecycleConfig) { + agentCoreRuntimeEnvironment.lifecycleConfiguration = spec.lifecycleConfig; + } + + if (spec.sessionStoragePath) { + agentCoreRuntimeEnvironment.filesystemConfigurations = [{ sessionStorage: { mountPath: spec.sessionStoragePath } }]; + } + + return { + agentCoreRuntimeEnvironment, + }; +} diff --git a/src/cli/operations/deploy/imperative/deployers/index.ts b/src/cli/operations/deploy/imperative/deployers/index.ts new file mode 100644 index 000000000..655785b10 --- /dev/null +++ b/src/cli/operations/deploy/imperative/deployers/index.ts @@ -0,0 +1,2 @@ +export { HarnessDeployer } from './harness-deployer'; +export { mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './harness-mapper'; diff --git a/src/cli/operations/deploy/imperative/index.ts b/src/cli/operations/deploy/imperative/index.ts new file mode 100644 index 000000000..930dfe094 --- /dev/null +++ b/src/cli/operations/deploy/imperative/index.ts @@ -0,0 +1,18 @@ +import { HarnessDeployer } from './deployers'; +import { ImperativeDeploymentManager } from './manager'; + +export type { + DeployPhase, + DeployProgress, + ImperativeDeployContext, + ImperativeDeployResult, + ImperativeDeployer, +} from './types'; + +export { ImperativeDeploymentManager, type ImperativePhaseResult } from './manager'; + +export { HarnessDeployer, mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './deployers'; + +export function createDeploymentManager(): ImperativeDeploymentManager { + return new ImperativeDeploymentManager().register(new HarnessDeployer()); +} diff --git a/src/cli/operations/deploy/imperative/manager.ts b/src/cli/operations/deploy/imperative/manager.ts new file mode 100644 index 000000000..b7e22ecda --- /dev/null +++ b/src/cli/operations/deploy/imperative/manager.ts @@ -0,0 +1,110 @@ +import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from './types'; + +export interface ImperativePhaseResult { + success: boolean; + results: Map; + error?: string; + notes: string[]; +} + +export class ImperativeDeploymentManager { + private readonly deployers: ImperativeDeployer[] = []; + + register(deployer: ImperativeDeployer): this { + this.deployers.push(deployer); + return this; + } + + async runPhase(phase: DeployPhase, context: ImperativeDeployContext): Promise { + const results = new Map(); + const notes: string[] = []; + + const applicable = this.deployers.filter(d => d.phase === phase && d.shouldRun(context)); + + for (const deployer of applicable) { + context.onProgress?.(deployer.label, 'start'); + + try { + const result = await deployer.deploy(context); + results.set(deployer.name, result); + + if (result.notes) { + notes.push(...result.notes); + } + + if (!result.success) { + context.onProgress?.(deployer.label, 'error'); + return { + success: false, + results, + error: result.error ?? `Deployer '${deployer.name}' failed`, + notes, + }; + } + + context.onProgress?.(deployer.label, 'done'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + results.set(deployer.name, { success: false, error: errorMessage }); + context.onProgress?.(deployer.label, 'error'); + return { + success: false, + results, + error: errorMessage, + notes, + }; + } + } + + return { success: true, results, notes }; + } + + async teardownAll(context: ImperativeDeployContext): Promise { + const results = new Map(); + const notes: string[] = []; + const errors: string[] = []; + + const applicable = this.deployers.filter(d => d.shouldRun(context)).reverse(); + + for (const deployer of applicable) { + context.onProgress?.(deployer.label, 'start'); + + try { + const result = await deployer.teardown(context); + results.set(deployer.name, result); + + if (result.notes) { + notes.push(...result.notes); + } + + if (!result.success) { + context.onProgress?.(deployer.label, 'error'); + errors.push(result.error ?? `Teardown of '${deployer.name}' failed`); + continue; + } + + context.onProgress?.(deployer.label, 'done'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + results.set(deployer.name, { success: false, error: errorMessage }); + context.onProgress?.(deployer.label, 'error'); + errors.push(errorMessage); + } + } + + if (errors.length > 0) { + return { + success: false, + results, + error: errors.join('; '), + notes, + }; + } + + return { success: true, results, notes }; + } + + hasDeployersForPhase(phase: DeployPhase, context: ImperativeDeployContext): boolean { + return this.deployers.some(d => d.phase === phase && d.shouldRun(context)); + } +} diff --git a/src/cli/operations/deploy/imperative/types.ts b/src/cli/operations/deploy/imperative/types.ts new file mode 100644 index 000000000..7efa13e7a --- /dev/null +++ b/src/cli/operations/deploy/imperative/types.ts @@ -0,0 +1,32 @@ +import type { ConfigIO } from '../../../../lib'; +import type { AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../../schema'; + +export type DeployPhase = 'pre-cdk' | 'post-cdk' | 'standalone'; + +export type DeployProgress = (step: string, status: 'start' | 'done' | 'error') => void; + +export interface ImperativeDeployContext { + projectSpec: AgentCoreProjectSpec; + target: AwsDeploymentTarget; + configIO: ConfigIO; + deployedState: DeployedState; + onProgress?: DeployProgress; + cdkOutputs?: Record; + autoConfirm?: boolean; +} + +export interface ImperativeDeployResult> { + success: boolean; + state?: TState; + notes?: string[]; + error?: string; +} + +export interface ImperativeDeployer> { + readonly name: string; + readonly label: string; + readonly phase: DeployPhase; + shouldRun(context: ImperativeDeployContext): boolean; + deploy(context: ImperativeDeployContext): Promise>; + teardown(context: ImperativeDeployContext): Promise>; +} diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index ba423a088..ca7a0e35d 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -86,11 +86,12 @@ export async function validateProject(): Promise { const hasMemories = projectSpec.memories && projectSpec.memories.length > 0; const hasEvaluators = projectSpec.evaluators && projectSpec.evaluators.length > 0; const hasPolicyEngines = projectSpec.policyEngines && projectSpec.policyEngines.length > 0; + const hasHarnesses = projectSpec.harnesses && projectSpec.harnesses.length > 0; // Check for gateways in agentcore.json const hasGateways = projectSpec.agentCoreGateways && projectSpec.agentCoreGateways.length > 0; - if (!hasAgents && !hasGateways && !hasMemories && !hasEvaluators && !hasPolicyEngines) { + if (!hasAgents && !hasGateways && !hasMemories && !hasEvaluators && !hasPolicyEngines && !hasHarnesses) { let hasExistingStack = false; try { const deployedState = await configIO.readDeployedState(); diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 3d942ca7c..cefb8dc64 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -24,6 +24,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -55,6 +56,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project); @@ -85,6 +87,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -121,6 +124,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -152,6 +156,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, undefined, 'TsAgent'); @@ -184,6 +189,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -216,6 +222,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; // No configRoot provided @@ -248,6 +255,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -280,6 +288,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -311,6 +320,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -342,6 +352,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -373,6 +384,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -404,6 +416,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -436,6 +449,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -481,6 +495,7 @@ describe('getAgentPort', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -502,6 +517,7 @@ describe('getAgentPort', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -528,6 +544,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -557,6 +574,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -596,6 +614,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -626,6 +645,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); @@ -665,6 +685,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/operations/dev/web-ui/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts index 5d4cc2d41..d5994c0a3 100644 --- a/src/cli/operations/dev/web-ui/api-types.ts +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -8,6 +8,7 @@ * TODO: Extract these types into a shared package so both repos import * from a single source of truth instead of manually duplicating. */ +import type { HarnessModelConfiguration, HarnessTool } from '../../../aws/agentcore-harness'; import type { CloudWatchSpanRecord, CloudWatchTraceRecord } from '../../traces/types'; // --------------------------------------------------------------------------- @@ -423,3 +424,43 @@ export interface A2AAgentCardResponse { success: true; card: A2AAgentCard; } + +// --------------------------------------------------------------------------- +// Harness invocation types +// --------------------------------------------------------------------------- + +export interface HarnessInvocationOverrides { + model?: HarnessModelConfiguration; + systemPrompt?: string; + skills?: { path: string }[]; + actorId?: string; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + allowedTools?: string[]; + tools?: HarnessTool[]; +} + +export interface HarnessToolResponseRequest { + harnessName: string; + sessionId: string; + messages: { role: string; content: Record[] }[]; + harnessOverrides?: HarnessInvocationOverrides; +} + +export interface StatusHarness { + name: string; +} + +export interface ResourceHarness { + name: string; + model: string; + tools: string[]; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedHarnessState; +} + +export interface DeployedHarnessState { + harnessId: string; + harnessArn: string; +} diff --git a/src/cli/operations/dev/web-ui/constants.ts b/src/cli/operations/dev/web-ui/constants.ts index 1ff6d9361..d3eafb498 100644 --- a/src/cli/operations/dev/web-ui/constants.ts +++ b/src/cli/operations/dev/web-ui/constants.ts @@ -16,3 +16,9 @@ export interface AgentError { message: string; timestamp: number; } + +export interface HarnessInfo { + name: string; + harnessArn: string; + region: string; +} diff --git a/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts b/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts new file mode 100644 index 000000000..4e4c464f1 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts @@ -0,0 +1,87 @@ +import { invokeHarness } from '../../../../aws/agentcore-harness'; +import type { InvokeHarnessOptions } from '../../../../aws/agentcore-harness'; +import type { HarnessInvocationOverrides } from '../api-types'; +import { buildInvokeOptions } from './harness-utils'; +import type { RouteContext } from './route-context'; +import { randomUUID } from 'node:crypto'; +import type { ServerResponse } from 'node:http'; + +interface ParsedHarnessRequest { + harnessName: string; + prompt: string; + sessionId: string; + userId?: string; + overrides?: HarnessInvocationOverrides; +} + +function parseRequest(raw: Record): { parsed?: ParsedHarnessRequest; error?: string } { + const harnessName = raw.harnessName as string | undefined; + if (!harnessName) return { error: 'harnessName is required' }; + + const prompt = raw.prompt as string | undefined; + if (!prompt) return { error: 'prompt is required' }; + + return { + parsed: { + harnessName, + prompt, + sessionId: (raw.sessionId as string) || randomUUID(), + userId: raw.userId as string | undefined, + overrides: raw.harnessOverrides as HarnessInvocationOverrides | undefined, + }, + }; +} + +export async function handleHarnessInvocation( + ctx: RouteContext, + body: Record, + res: ServerResponse, + origin?: string +): Promise { + const { parsed, error } = parseRequest(body); + if (!parsed) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error })); + return; + } + + const harness = (ctx.options.harnesses ?? []).find(h => h.name === parsed.harnessName); + if (!harness) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Harness "${parsed.harnessName}" not found` })); + return; + } + + const messages: InvokeHarnessOptions['messages'] = [{ role: 'user', content: [{ text: parsed.prompt }] }]; + + const invokeOpts = buildInvokeOptions( + harness.harnessArn, + harness.region, + parsed.sessionId, + messages, + parsed.overrides + ); + + ctx.setCorsHeaders(res, origin); + const sseHeaders: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'x-session-id': parsed.sessionId, + }; + res.writeHead(200, sseHeaders); + + try { + const stream = invokeHarness(invokeOpts); + for await (const event of stream) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.write(`data: ${JSON.stringify({ type: 'error', errorType: 'invocationError', message })}\n\n`); + } + + res.end(); +} diff --git a/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts b/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts new file mode 100644 index 000000000..369ad45d7 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts @@ -0,0 +1,92 @@ +import { invokeHarness } from '../../../../aws/agentcore-harness'; +import type { HarnessInvocationOverrides } from '../api-types'; +import { buildInvokeOptions } from './harness-utils'; +import type { RouteContext } from './route-context'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +interface ParsedToolResponseRequest { + harnessName: string; + sessionId: string; + messages: { role: string; content: Record[] }[]; + harnessOverrides?: HarnessInvocationOverrides; +} + +function parseToolResponseRequest(body: string): { + parsed?: ParsedToolResponseRequest; + error?: string; + status?: number; +} { + let raw: Record; + try { + raw = JSON.parse(body) as Record; + } catch { + return { error: 'Invalid JSON', status: 400 }; + } + + if (!raw.harnessName) return { error: 'harnessName is required', status: 400 }; + if (!raw.messages || !Array.isArray(raw.messages)) return { error: 'messages array is required', status: 400 }; + if (!raw.sessionId) return { error: 'sessionId is required', status: 400 }; + + return { + parsed: { + harnessName: raw.harnessName as string, + sessionId: raw.sessionId as string, + messages: raw.messages as ParsedToolResponseRequest['messages'], + harnessOverrides: raw.harnessOverrides as HarnessInvocationOverrides | undefined, + }, + }; +} + +export async function handleHarnessToolResponse( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const body = await ctx.readBody(req); + + const { parsed, error, status } = parseToolResponseRequest(body); + if (!parsed) { + ctx.setCorsHeaders(res, origin); + res.writeHead(status ?? 400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error })); + return; + } + + const harness = (ctx.options.harnesses ?? []).find(h => h.name === parsed.harnessName); + if (!harness) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Harness "${parsed.harnessName}" not found` })); + return; + } + + const invokeOpts = buildInvokeOptions( + harness.harnessArn, + harness.region, + parsed.sessionId, + parsed.messages, + parsed.harnessOverrides + ); + + ctx.setCorsHeaders(res, origin); + const sseHeaders: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'x-session-id': parsed.sessionId, + }; + res.writeHead(200, sseHeaders); + + try { + const stream = invokeHarness(invokeOpts); + for await (const event of stream) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.write(`data: ${JSON.stringify({ type: 'error', errorType: 'invocationError', message })}\n\n`); + } + + res.end(); +} diff --git a/src/cli/operations/dev/web-ui/handlers/harness-utils.ts b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts new file mode 100644 index 000000000..4a2947e9e --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts @@ -0,0 +1,31 @@ +import type { HarnessSystemPrompt, InvokeHarnessOptions } from '../../../../aws/agentcore-harness'; +import type { HarnessInvocationOverrides } from '../api-types'; + +const DEFAULT_MAX_ITERATIONS = 75; + +export function buildInvokeOptions( + harnessArn: string, + region: string, + sessionId: string, + messages: InvokeHarnessOptions['messages'], + overrides?: HarnessInvocationOverrides +): InvokeHarnessOptions { + const opts: InvokeHarnessOptions = { + region, + harnessArn, + runtimeSessionId: sessionId, + messages, + }; + + if (overrides?.model) opts.model = overrides.model; + if (overrides?.systemPrompt) opts.systemPrompt = [{ text: overrides.systemPrompt }] as HarnessSystemPrompt; + if (overrides?.skills) opts.skills = overrides.skills; + if (overrides?.actorId) opts.actorId = overrides.actorId; + opts.maxIterations = overrides?.maxIterations ?? DEFAULT_MAX_ITERATIONS; + if (overrides?.maxTokens != null) opts.maxTokens = overrides.maxTokens; + if (overrides?.timeoutSeconds != null) opts.timeoutSeconds = overrides.timeoutSeconds; + if (overrides?.allowedTools) opts.allowedTools = overrides.allowedTools; + if (overrides?.tools) opts.tools = overrides.tools; + + return opts; +} diff --git a/src/cli/operations/dev/web-ui/handlers/index.ts b/src/cli/operations/dev/web-ui/handlers/index.ts index 0ae7b4f67..8f45106e2 100644 --- a/src/cli/operations/dev/web-ui/handlers/index.ts +++ b/src/cli/operations/dev/web-ui/handlers/index.ts @@ -8,3 +8,5 @@ export { handleListCloudWatchTraces, handleGetCloudWatchTrace } from './cloudwat export { handleListMemoryRecords, handleRetrieveMemoryRecords } from './memory'; export { handleMcpProxy } from './mcp-proxy'; export { handleA2AAgentCard } from './a2a-proxy'; +export { handleHarnessInvocation } from './harness-invocation'; +export { handleHarnessToolResponse } from './harness-tool-response'; diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index 3a6b70ed9..8271fef6d 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -1,4 +1,5 @@ import { extractSSEEventText, extractTaskText, isStatusUpdateEvent } from '../../invoke-a2a'; +import { handleHarnessInvocation } from './harness-invocation'; import type { RouteContext } from './route-context'; import { randomUUID } from 'node:crypto'; import { type IncomingMessage, type ServerResponse, request as httpRequest } from 'node:http'; @@ -17,6 +18,16 @@ export async function handleInvocations( ): Promise { const body = await ctx.readBody(req); + // Route to harness handler if harnessName is present + try { + const parsedBody = JSON.parse(body) as Record; + if (parsedBody.harnessName) { + return handleHarnessInvocation(ctx, parsedBody, res, origin); + } + } catch { + // fall through to agent routing + } + let agentPort: number | undefined; let agentName: string | undefined; let agentProtocol: string | undefined; diff --git a/src/cli/operations/dev/web-ui/handlers/resources.ts b/src/cli/operations/dev/web-ui/handlers/resources.ts index 47c4e00ef..ffaa73d08 100644 --- a/src/cli/operations/dev/web-ui/handlers/resources.ts +++ b/src/cli/operations/dev/web-ui/handlers/resources.ts @@ -8,6 +8,7 @@ import type { ResourceDeploymentStatus, ResourceEvaluator, ResourceGateway, + ResourceHarness, ResourceMemory, ResourceOnlineEvalConfig, ResourcePolicyEngine, @@ -106,6 +107,42 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or } } + // Build harnesses from local config + const localHarnessNames = new Set((project.harnesses ?? []).map(h => h.name)); + const harnesses: ResourceHarness[] = []; + for (const h of project.harnesses ?? []) { + let model = ''; + let tools: string[] = []; + try { + const spec = await configIO.readHarnessSpec(h.name); + model = `${spec.model.provider}/${spec.model.modelId}`; + tools = spec.tools.map(t => t.name); + } catch { + // harness spec may be unreadable โ€” show what we can + } + const deployed = targetResources?.harnesses?.[h.name]; + harnesses.push({ + name: h.name, + model, + tools, + deploymentStatus: statusByTypeAndName.get(`harness:${h.name}`), + deployed: deployed ? { harnessId: deployed.harnessId, harnessArn: deployed.harnessArn } : undefined, + }); + } + + // Add pending-removal harnesses + for (const [name, deployed] of Object.entries(targetResources?.harnesses ?? {})) { + if (!localHarnessNames.has(name)) { + harnesses.push({ + name, + model: '', + tools: [], + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed: { harnessId: deployed.harnessId, harnessArn: deployed.harnessArn }, + }); + } + } + // Build memories from local config const localMemoryNames = new Set(project.memories.map(m => m.name)); const memories: ResourceMemory[] = project.memories.map(m => ({ @@ -274,6 +311,7 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or success: true, project: project.name, agents, + harnesses, memories, credentials, gateways, diff --git a/src/cli/operations/dev/web-ui/handlers/status.ts b/src/cli/operations/dev/web-ui/handlers/status.ts index 0fdb05500..b6fe0d1fc 100644 --- a/src/cli/operations/dev/web-ui/handlers/status.ts +++ b/src/cli/operations/dev/web-ui/handlers/status.ts @@ -1,8 +1,8 @@ -import type { StatusAgentError, StatusRunningAgent } from '../api-types'; +import type { StatusAgentError, StatusHarness, StatusRunningAgent } from '../api-types'; import type { RouteContext } from './route-context'; import type { ServerResponse } from 'node:http'; -/** GET /api/status โ€” returns available agents, which ones are running, and any errors */ +/** GET /api/status โ€” returns available agents, harnesses, which agents are running, and any errors */ export function handleStatus(ctx: RouteContext, res: ServerResponse, origin?: string): void { const { agents } = ctx.options; const running: StatusRunningAgent[] = []; @@ -11,15 +11,24 @@ export function handleStatus(ctx: RouteContext, res: ServerResponse, origin?: st running.push({ name, port }); } - // Collect per-agent errors const errors: StatusAgentError[] = []; for (const [name, agentError] of ctx.agentErrors) { errors.push({ name, message: agentError.message }); } + const harnesses: StatusHarness[] = (ctx.options.harnesses ?? []).map(h => ({ name: h.name })); + ctx.setCorsHeaders(res, origin); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( - JSON.stringify({ mode: ctx.options.mode, agents, running, errors, selectedAgent: ctx.options.selectedAgent }) + JSON.stringify({ + mode: ctx.options.mode, + agents, + harnesses, + running, + errors, + selectedAgent: ctx.options.selectedAgent, + selectedHarness: ctx.options.selectedHarness, + }) ); } diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index fe845194f..ef706c803 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -1,11 +1,12 @@ import type { DevConfig } from '../config'; import type { DevServer } from '../server'; -import { type AgentError, type AgentInfo, WEB_UI_LOCAL_URL } from './constants'; +import { type AgentError, type AgentInfo, type HarnessInfo, WEB_UI_LOCAL_URL } from './constants'; import { type RouteContext, handleA2AAgentCard, handleGetCloudWatchTrace, handleGetTrace, + handleHarnessToolResponse, handleInvocations, handleListCloudWatchTraces, handleListMemoryRecords, @@ -145,6 +146,8 @@ export interface WebUIOptions { uiPort: number; /** Available agents (metadata only โ€” servers are started on demand) */ agents: AgentInfo[]; + /** Deployed harnesses available for invocation (metadata only โ€” no local server needed) */ + harnesses?: HarnessInfo[]; /** Dev config factory โ€” called when an agent needs to be started. Required for dev mode, unused when onStart is provided. */ getDevConfig?: (agentName: string) => DevConfig | null | Promise; /** Env vars to pass to started agent servers */ @@ -173,6 +176,8 @@ export interface WebUIOptions { onRetrieveMemoryRecords?: RetrieveMemoryRecordsHandler; /** Agent to pre-select in the UI dropdown (set when --runtime is specified) */ selectedAgent?: string; + /** Harness to pre-select in the UI */ + selectedHarness?: string; /** Callback to reload the agents list from config. When provided, the server watches agentcore.json and calls this on change. */ reloadAgents?: () => Promise; } @@ -340,6 +345,8 @@ export class WebUIServer { await handleListCloudWatchTraces(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/api/start') { await handleStart(ctx, req, res, origin); + } else if (req.method === 'POST' && req.url === '/api/harness/tool-response') { + await handleHarnessToolResponse(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/invocations') { await handleInvocations(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/api/mcp') { diff --git a/src/cli/operations/fetch-access/fetch-harness-token.ts b/src/cli/operations/fetch-access/fetch-harness-token.ts new file mode 100644 index 000000000..654bc5c36 --- /dev/null +++ b/src/cli/operations/fetch-access/fetch-harness-token.ts @@ -0,0 +1,83 @@ +import { ConfigIO } from '../../../lib'; +import { readEnvFile } from '../../../lib/utils/env'; +import { + computeDefaultCredentialEnvVarName, + computeManagedOAuthCredentialName, +} from '../../primitives/credential-utils'; +import { fetchOAuthToken } from './oauth-token'; +import type { OAuthTokenResult } from './oauth-token'; + +/** + * Check whether auto-fetch is possible for a CUSTOM_JWT harness. + * Returns true only if the managed OAuth credential exists in the project + * spec AND the client secret is available in .env.local. + */ +export async function canFetchHarnessToken( + harnessName: string, + options: { configIO?: ConfigIO } = {} +): Promise { + try { + const configIO = options.configIO ?? new ConfigIO(); + const harnessSpec = await configIO.readHarnessSpec(harnessName); + + if (harnessSpec.authorizerType !== 'CUSTOM_JWT') return false; + if (!harnessSpec.authorizerConfiguration?.customJwtAuthorizer) return false; + + const projectSpec = await configIO.readProjectSpec(); + const credName = computeManagedOAuthCredentialName(harnessName); + const hasCredential = projectSpec.credentials.some( + c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName + ); + if (!hasCredential) return false; + + const envVarPrefix = computeDefaultCredentialEnvVarName(credName); + const envVars = await readEnvFile(); + return !!envVars[`${envVarPrefix}_CLIENT_SECRET`]; + } catch (err) { + if (process.env.DEBUG) console.error('[canFetchHarnessToken]', err); + return false; + } +} + +/** + * Fetch an OAuth access token for a CUSTOM_JWT harness. + * + * Performs OIDC discovery and client_credentials token fetch using the + * managed OAuth credential created during harness setup. + */ +export async function fetchHarnessToken( + harnessName: string, + options: { configIO?: ConfigIO; deployTarget?: string } = {} +): Promise { + const configIO = options.configIO ?? new ConfigIO(); + + const deployedState = await configIO.readDeployedState(); + const projectSpec = await configIO.readProjectSpec(); + const harnessSpec = await configIO.readHarnessSpec(harnessName); + + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + throw new Error('No deployed targets found. Run `agentcore deploy` first.'); + } + + const targetName = options.deployTarget ?? targetNames[0]!; + + if (harnessSpec.authorizerType !== 'CUSTOM_JWT') { + throw new Error(`Harness '${harnessName}' uses ${harnessSpec.authorizerType ?? 'AWS_IAM'} auth, not CUSTOM_JWT.`); + } + + const jwtConfig = harnessSpec.authorizerConfiguration?.customJwtAuthorizer; + if (!jwtConfig) { + throw new Error( + `Harness '${harnessName}' is configured as CUSTOM_JWT but has no customJwtAuthorizer configuration.` + ); + } + + return fetchOAuthToken({ + resourceName: harnessName, + jwtConfig, + deployedState, + targetName, + credentials: projectSpec.credentials, + }); +} diff --git a/src/cli/operations/fetch-access/index.ts b/src/cli/operations/fetch-access/index.ts index 06b7807a7..cbccf9c45 100644 --- a/src/cli/operations/fetch-access/index.ts +++ b/src/cli/operations/fetch-access/index.ts @@ -1,4 +1,5 @@ export { fetchGatewayToken } from './fetch-gateway-token'; +export { canFetchHarnessToken, fetchHarnessToken } from './fetch-harness-token'; export { canFetchRuntimeToken, fetchRuntimeToken } from './fetch-runtime-token'; export { fetchOAuthToken } from './oauth-token'; export type { OAuthTokenResult } from './oauth-token'; diff --git a/src/cli/operations/resolve-agent.ts b/src/cli/operations/resolve-agent.ts index 8f8ee6ba3..6b865f2c3 100644 --- a/src/cli/operations/resolve-agent.ts +++ b/src/cli/operations/resolve-agent.ts @@ -1,5 +1,6 @@ import { ConfigIO } from '../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../schema'; +import { getHarness } from '../aws/agentcore-harness'; export interface DeployedProjectConfig { project: AgentCoreProjectSpec; @@ -97,3 +98,126 @@ export function resolveAgent( }, }; } + +/** + * Resolves a harness to a ResolvedAgent by looking up deployed state and + * fetching the underlying agentRuntimeArn via the GetHarness API. + */ +export async function resolveHarness( + context: DeployedProjectConfig, + harnessName: string +): Promise<{ success: true; agent: ResolvedAgent } | { success: false; error: string }> { + const { project, deployedState, awsTargets } = context; + + const harnesses = project.harnesses ?? []; + const harnessSpec = harnesses.find(h => h.name === harnessName); + if (!harnessSpec) { + const available = harnesses.map(h => h.name); + return { + success: false, + error: + available.length > 0 + ? `Harness '${harnessName}' not found. Available: ${available.join(', ')}` + : 'No harnesses defined in agentcore.json', + }; + } + + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' }; + } + const selectedTargetName = targetNames[0]!; + + const targetState = deployedState.targets[selectedTargetName]; + const targetConfig = awsTargets.find(t => t.name === selectedTargetName); + + if (!targetConfig) { + return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; + } + + const harnessState = targetState?.resources?.harnesses?.[harnessName]; + if (!harnessState) { + return { + success: false, + error: `Harness '${harnessName}' is not deployed to target '${selectedTargetName}'. Run 'agentcore deploy' first.`, + }; + } + + let runtimeId: string | undefined; + + if (harnessState.agentRuntimeArn) { + const arnMatch = /runtime\/([^/]+)/.exec(harnessState.agentRuntimeArn); + if (arnMatch) { + runtimeId = arnMatch[1]; + } + } + + if (!runtimeId) { + try { + await getHarness({ region: targetConfig.region, harnessId: harnessState.harnessId }); + runtimeId = harnessState.harnessId; + } catch (err) { + return { + success: false, + error: `Failed to resolve runtime for harness '${harnessName}': ${(err as Error).message}`, + }; + } + } + + if (!runtimeId) { + return { + success: false, + error: `Could not resolve runtime ID for harness '${harnessName}'. Re-deploy to populate agentRuntimeArn.`, + }; + } + + return { + success: true, + agent: { + agentName: harnessName, + targetName: selectedTargetName, + region: targetConfig.region, + accountId: targetConfig.account, + runtimeId, + }, + }; +} + +/** + * Resolves either an agent runtime or a harness to a ResolvedAgent. + * - If --harness is specified, resolves that harness. + * - If --runtime is specified, resolves that runtime. + * - If neither is specified, auto-selects: single runtime wins, or if no runtimes + * but harnesses exist, auto-selects the single harness. + */ +export async function resolveAgentOrHarness( + context: DeployedProjectConfig, + options: { runtime?: string; harness?: string } +): Promise<{ success: true; agent: ResolvedAgent } | { success: false; error: string }> { + if (options.harness && options.runtime) { + return { success: false, error: 'Cannot specify both --harness and --runtime' }; + } + + if (options.harness) { + return resolveHarness(context, options.harness); + } + + if (options.runtime || context.project.runtimes.length > 0) { + return resolveAgent(context, options); + } + + const harnesses = context.project.harnesses ?? []; + if (harnesses.length === 0) { + return { success: false, error: 'No runtimes or harnesses defined in agentcore.json' }; + } + + if (harnesses.length > 1) { + const names = harnesses.map(h => h.name); + return { + success: false, + error: `Multiple harnesses found. Use --harness to specify one: ${names.join(', ')}`, + }; + } + + return resolveHarness(context, harnesses[0]!.name); +} diff --git a/src/cli/primitives/HarnessPrimitive.ts b/src/cli/primitives/HarnessPrimitive.ts new file mode 100644 index 000000000..0c78a0612 --- /dev/null +++ b/src/cli/primitives/HarnessPrimitive.ts @@ -0,0 +1,582 @@ +import { APP_DIR, ConfigIO, type Result, findConfigRoot } from '../../lib'; +import type { + HarnessGatewayOutboundAuth, + HarnessModelProvider, + HarnessSpec, + MemoryStrategy, + MemoryStrategyType, + NetworkMode, + RuntimeAuthorizerType, +} from '../../schema'; +import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES, HarnessSpecSchema } from '../../schema'; +import { deleteHarness } from '../aws/agentcore-harness'; +import { getErrorMessage } from '../errors'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; +import { getTemplatePath } from '../templates/templateRoot'; +import { DEFAULT_MEMORY_EXPIRY_DAYS } from '../tui/screens/generate/defaults'; +import { BasePrimitive } from './BasePrimitive'; +import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from './auth-utils'; +import type { JwtConfigOptions } from './auth-utils'; +import type { AddScreenComponent, RemovableResource } from './types'; +import { ResourceNotFoundError, toError } from '@/lib/errors/types'; +import type { Command } from '@commander-js/extra-typings'; +import { access, copyFile, mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { basename, dirname, isAbsolute, join, resolve } from 'path'; + +export interface AddHarnessOptions { + name: string; + modelProvider: HarnessModelProvider; + modelId: string; + apiKeyArn?: string; + systemPrompt?: string; + skipMemory?: boolean; + containerUri?: string; + dockerfilePath?: string; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + truncationStrategy?: 'sliding_window' | 'summarization'; + networkMode?: NetworkMode; + subnets?: string[]; + securityGroups?: string[]; + idleTimeout?: number; + maxLifetime?: number; + sessionStoragePath?: string; + withInvokeScript?: boolean; + selectedTools?: string[]; + mcpName?: string; + mcpUrl?: string; + gatewayArn?: string; + gatewayOutboundAuth?: 'awsIam' | 'none' | 'oauth'; + gatewayProviderArn?: string; + gatewayScopes?: string[]; + authorizerType?: RuntimeAuthorizerType; + jwtConfig?: JwtConfigOptions; + configBaseDir?: string; +} + +export type RemovableHarness = RemovableResource; + +export class HarnessPrimitive extends BasePrimitive { + readonly kind = 'harness' as const; + readonly label = 'Harness'; + readonly primitiveSchema = HarnessSpecSchema; + + async add(options: AddHarnessOptions): Promise> { + try { + const configBaseDir = options.configBaseDir ?? findConfigRoot(); + if (!configBaseDir) { + return { + success: false, + error: new ResourceNotFoundError('No agentcore project found. Run `agentcore create` first.'), + }; + } + + const configIO = new ConfigIO({ baseDir: configBaseDir }); + const project = await this.readProjectSpec(configIO); + + const harnesses = project.harnesses ?? []; + this.checkDuplicate(harnesses, options.name); + + const memoryName = options.skipMemory ? undefined : `${options.name}Memory`; + + let dockerfile: string | undefined; + if (options.dockerfilePath) { + const projectRoot = dirname(configBaseDir); + const srcPath = isAbsolute(options.dockerfilePath) + ? options.dockerfilePath + : resolve(projectRoot, options.dockerfilePath); + try { + await access(srcPath); + } catch { + return { success: false, error: new ResourceNotFoundError(`Dockerfile not found at: ${srcPath}`) }; + } + const appDir = join(projectRoot, APP_DIR, options.name); + await mkdir(appDir, { recursive: true }); + const destFilename = basename(srcPath); + await copyFile(srcPath, join(appDir, destFilename)); + dockerfile = destFilename; + } + + const tools: HarnessSpec['tools'] = []; + if (options.selectedTools) { + for (const toolType of options.selectedTools) { + if (toolType === 'agentcore_browser') { + tools.push({ type: 'agentcore_browser', name: 'browser' }); + } else if (toolType === 'agentcore_code_interpreter') { + tools.push({ type: 'agentcore_code_interpreter', name: 'code-interpreter' }); + } else if (toolType === 'remote_mcp' && options.mcpName && options.mcpUrl) { + tools.push({ + type: 'remote_mcp', + name: options.mcpName, + config: { remoteMcp: { url: options.mcpUrl } }, + }); + } else if (toolType === 'agentcore_gateway' && options.gatewayArn) { + let outboundAuth: HarnessGatewayOutboundAuth | undefined; + if (options.gatewayOutboundAuth === 'awsIam') { + outboundAuth = { awsIam: {} }; + } else if (options.gatewayOutboundAuth === 'none') { + outboundAuth = { none: {} }; + } else if ( + options.gatewayOutboundAuth === 'oauth' && + options.gatewayProviderArn && + options.gatewayScopes && + options.gatewayScopes.length > 0 + ) { + outboundAuth = { + oauth: { + providerArn: options.gatewayProviderArn, + scopes: options.gatewayScopes, + }, + }; + } + tools.push({ + type: 'agentcore_gateway', + name: 'gateway', + config: { + agentCoreGateway: { + gatewayArn: options.gatewayArn, + ...(outboundAuth && { outboundAuth }), + }, + }, + }); + } + } + } + + const harnessSpec: HarnessSpec = { + name: options.name, + model: { + provider: options.modelProvider, + modelId: options.modelId, + ...(options.apiKeyArn && { apiKeyArn: options.apiKeyArn }), + }, + tools, + skills: [], + ...(options.systemPrompt && { systemPrompt: options.systemPrompt }), + ...(memoryName && { memory: { name: memoryName } }), + ...(options.containerUri && { containerUri: options.containerUri }), + ...(dockerfile && { dockerfile }), + ...(options.maxIterations !== undefined && { maxIterations: options.maxIterations }), + ...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }), + ...(options.timeoutSeconds !== undefined && { timeoutSeconds: options.timeoutSeconds }), + ...(options.truncationStrategy && { truncation: { strategy: options.truncationStrategy } }), + ...(options.networkMode && { networkMode: options.networkMode }), + ...(options.networkMode === 'VPC' && + options.subnets && + options.securityGroups && { + networkConfig: { + subnets: options.subnets, + securityGroups: options.securityGroups, + }, + }), + ...(this.buildLifecycleConfig(options) && { lifecycleConfig: this.buildLifecycleConfig(options) }), + ...(options.sessionStoragePath && { sessionStoragePath: options.sessionStoragePath }), + ...(options.authorizerType && { authorizerType: options.authorizerType }), + ...(options.authorizerType === 'CUSTOM_JWT' && options.jwtConfig + ? { authorizerConfiguration: buildAuthorizerConfigFromJwtConfig(options.jwtConfig) } + : {}), + }; + + await configIO.writeHarnessSpec(options.name, harnessSpec); + + const pathResolver = configIO.getPathResolver(); + const harnessDir = pathResolver.getHarnessDir(options.name); + const systemPromptPath = join(harnessDir, 'system-prompt.md'); + const systemPromptContent = options.systemPrompt ?? 'You are a helpful assistant'; + await writeFile(systemPromptPath, systemPromptContent, 'utf-8'); + + if (options.withInvokeScript) { + const templatePath = getTemplatePath('harness', 'invoke.py.template'); + const invokeScriptPath = join(harnessDir, 'invoke.py'); + let template = await readFile(templatePath, 'utf-8'); + template = template.replace('{{HARNESS_ARN}}', ''); + template = template.replace('{{REGION}}', ''); + await writeFile(invokeScriptPath, template, 'utf-8'); + } + + if (memoryName) { + const strategyTypes: MemoryStrategyType[] = ['SEMANTIC', 'USER_PREFERENCE', 'SUMMARIZATION', 'EPISODIC']; + const strategies: MemoryStrategy[] = strategyTypes.map(type => ({ + type, + ...(DEFAULT_STRATEGY_NAMESPACES[type] && { namespaces: DEFAULT_STRATEGY_NAMESPACES[type] }), + ...(type === 'EPISODIC' && { reflectionNamespaces: DEFAULT_EPISODIC_REFLECTION_NAMESPACES }), + })); + + project.memories.push({ + name: memoryName, + eventExpiryDuration: DEFAULT_MEMORY_EXPIRY_DAYS, + strategies, + }); + } + + project.harnesses = [ + ...harnesses, + { + name: options.name, + path: `app/${options.name}`, + }, + ]; + + await this.writeProjectSpec(project, configIO); + + if (options.jwtConfig?.clientId && options.jwtConfig?.clientSecret) { + await createManagedOAuthCredential( + options.name, + options.jwtConfig, + spec => this.writeProjectSpec(spec, configIO), + () => this.readProjectSpec(configIO) + ); + } + + return { success: true, harnessName: options.name }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async remove(harnessName: string): Promise { + try { + const configRoot = findConfigRoot(); + if (!configRoot) { + return { success: false, error: new ResourceNotFoundError('No agentcore project found.') }; + } + + const configIO = new ConfigIO({ baseDir: configRoot }); + const project = await this.readProjectSpec(configIO); + + const harnesses = project.harnesses ?? []; + const harnessIndex = harnesses.findIndex(h => h.name === harnessName); + + if (harnessIndex === -1) { + return { success: false, error: new ResourceNotFoundError(`Harness "${harnessName}" not found.`) }; + } + + // Delete harness from AWS if it's deployed + try { + const deployedState = await configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const deployedHarness = target.resources?.harnesses?.[harnessName]; + if (deployedHarness) { + const targets = await configIO.resolveAWSDeploymentTargets(); + const region = targets[0]?.region; + if (region) { + await deleteHarness({ region, harnessId: deployedHarness.harnessId }); + } + delete target.resources!.harnesses![harnessName]; + await configIO.writeDeployedState(deployedState); + break; + } + } + } catch { + // AWS deletion is best-effort; next deploy will clean up + } + + harnesses.splice(harnessIndex, 1); + project.harnesses = harnesses; + + // Remove the associated memory (convention: Memory) + const associatedMemoryName = `${harnessName}Memory`; + if (project.memories) { + project.memories = project.memories.filter(m => m.name !== associatedMemoryName); + } + + await this.writeProjectSpec(project, configIO); + + const pathResolver = configIO.getPathResolver(); + const harnessDir = pathResolver.getHarnessDir(harnessName); + await rm(harnessDir, { recursive: true, force: true }); + + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async previewRemove(harnessName: string): Promise { + const project = await this.readProjectSpec(); + + const harnesses = project.harnesses ?? []; + const harness = harnesses.find(h => h.name === harnessName); + + if (!harness) { + throw new Error(`Harness "${harnessName}" not found.`); + } + + const associatedMemoryName = `${harnessName}Memory`; + const hasAssociatedMemory = (project.memories ?? []).some(m => m.name === associatedMemoryName); + + const summary: string[] = [`Removing harness: ${harnessName}`]; + if (hasAssociatedMemory) { + summary.push(`Removing associated memory: ${associatedMemoryName}`); + } + const directoriesToDelete: string[] = [`app/${harnessName}`]; + const schemaChanges: SchemaChange[] = []; + + const afterSpec = { + ...project, + harnesses: harnesses.filter(h => h.name !== harnessName), + ...(hasAssociatedMemory && { memories: (project.memories ?? []).filter(m => m.name !== associatedMemoryName) }), + }; + + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterSpec, + }); + + return { summary, directoriesToDelete, schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + const harnesses = project.harnesses ?? []; + return harnesses.map(h => ({ name: h.name })); + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('harness') + .description('Add a harness to the project') + .option('--name ', 'Harness name (start with letter, alphanumeric + underscores, max 48 chars)') + .option('--model-provider ', 'Model provider: bedrock, open_ai, gemini') + .option('--model-id ', 'Model ID (e.g., anthropic.claude-3-5-sonnet-20240620-v1:0)') + .option('--api-key-arn ', 'API key ARN for non-Bedrock providers') + .option('--container ', 'Container image URI or path to a Dockerfile') + .option('--no-memory', 'Skip auto-creating memory') + .option('--max-iterations ', 'Max iterations', parseInt) + .option('--max-tokens ', 'Max tokens', parseInt) + .option('--timeout ', 'Timeout in seconds', parseInt) + .option('--truncation-strategy ', 'Truncation strategy: sliding_window or summarization') + .option('--network-mode ', 'Network mode: PUBLIC or VPC') + .option('--subnets ', 'Comma-separated subnet IDs (for VPC mode)') + .option('--security-groups ', 'Comma-separated security group IDs (for VPC mode)') + .option('--idle-timeout ', 'Idle timeout in seconds', parseInt) + .option('--max-lifetime ', 'Max lifetime in seconds', parseInt) + .option('--session-storage ', 'Mount path for persistent session storage (e.g., /mnt/data/)') + .option('--with-invoke-script', 'Generate a standalone Python invoke script') + .option( + '--system-prompt ', + 'System prompt text (written to system-prompt.md; defaults to "You are a helpful assistant")' + ) + .option( + '--tools ', + 'Comma-separated tools: agentcore_browser, agentcore_code_interpreter, remote_mcp, agentcore_gateway' + ) + .option('--mcp-name ', 'Remote MCP tool name (required when --tools includes remote_mcp)') + .option('--mcp-url ', 'Remote MCP endpoint URL (required when --tools includes remote_mcp)') + .option('--gateway-arn ', 'Gateway ARN (required when --tools includes agentcore_gateway)') + .option( + '--gateway-outbound-auth ', + 'Gateway outbound auth: awsIam, none, oauth (requires --gateway-provider-arn and --gateway-scopes)' + ) + .option('--gateway-provider-arn ', 'OAuth provider ARN for gateway outbound auth') + .option('--gateway-scopes ', 'Comma-separated OAuth scopes for gateway outbound auth') + .option('--authorizer-type ', 'Authorizer type: AWS_IAM or CUSTOM_JWT') + .option('--discovery-url ', 'OIDC discovery URL (for CUSTOM_JWT)') + .option('--allowed-audience ', 'Comma-separated allowed audiences (for CUSTOM_JWT)') + .option('--allowed-clients ', 'Comma-separated allowed client IDs (for CUSTOM_JWT)') + .option('--allowed-scopes ', 'Comma-separated allowed scopes (for CUSTOM_JWT)') + .option('--custom-claims ', 'Custom claims JSON array (for CUSTOM_JWT)') + .option('--client-id ', 'OAuth client ID (for CUSTOM_JWT)') + .option('--client-secret ', 'OAuth client secret (for CUSTOM_JWT)') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + name?: string; + modelProvider?: string; + modelId?: string; + apiKeyArn?: string; + container?: string; + memory?: boolean; + maxIterations?: number; + maxTokens?: number; + timeout?: number; + truncationStrategy?: string; + networkMode?: string; + subnets?: string; + securityGroups?: string; + idleTimeout?: number; + maxLifetime?: number; + sessionStorage?: string; + withInvokeScript?: boolean; + systemPrompt?: string; + tools?: string; + mcpName?: string; + mcpUrl?: string; + gatewayArn?: string; + gatewayOutboundAuth?: string; + gatewayProviderArn?: string; + gatewayScopes?: string; + authorizerType?: string; + discoveryUrl?: string; + allowedAudience?: string; + allowedClients?: string; + allowedScopes?: string; + customClaims?: string; + clientId?: string; + clientSecret?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + // Validate auth options + const { validateAddHarnessOptions } = await import('../commands/add/validate'); + const authValidation = validateAddHarnessOptions({ + ...cliOptions, + authorizerType: cliOptions.authorizerType as RuntimeAuthorizerType | undefined, + }); + if (!authValidation.valid) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: authValidation.error })); + } else { + console.error(authValidation.error); + } + process.exit(1); + } + + if (cliOptions.name || cliOptions.json) { + if (!cliOptions.name) { + const error = '--name is required'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const { DEFAULT_MODEL_IDS } = await import('../tui/screens/harness/types'); + const provider = (cliOptions.modelProvider ?? 'bedrock') as HarnessModelProvider; + const modelId = cliOptions.modelId ?? DEFAULT_MODEL_IDS[provider]; + + const containerOption = this.parseContainerFlag(cliOptions.container); + + const result = await this.add({ + name: cliOptions.name, + modelProvider: provider, + modelId, + apiKeyArn: cliOptions.apiKeyArn, + containerUri: containerOption.containerUri, + dockerfilePath: containerOption.dockerfilePath, + skipMemory: cliOptions.memory === false, + maxIterations: cliOptions.maxIterations, + maxTokens: cliOptions.maxTokens, + timeoutSeconds: cliOptions.timeout, + truncationStrategy: cliOptions.truncationStrategy as 'sliding_window' | 'summarization' | undefined, + networkMode: cliOptions.networkMode as NetworkMode | undefined, + subnets: cliOptions.subnets?.split(',').map(s => s.trim()), + securityGroups: cliOptions.securityGroups?.split(',').map(s => s.trim()), + idleTimeout: cliOptions.idleTimeout, + maxLifetime: cliOptions.maxLifetime, + sessionStoragePath: cliOptions.sessionStorage, + withInvokeScript: cliOptions.withInvokeScript, + systemPrompt: cliOptions.systemPrompt, + selectedTools: cliOptions.tools?.split(',').map(s => s.trim()), + mcpName: cliOptions.mcpName, + mcpUrl: cliOptions.mcpUrl, + gatewayArn: cliOptions.gatewayArn, + gatewayOutboundAuth: cliOptions.gatewayOutboundAuth as 'awsIam' | 'none' | 'oauth' | undefined, + gatewayProviderArn: cliOptions.gatewayProviderArn, + gatewayScopes: cliOptions.gatewayScopes?.split(',').map(s => s.trim()), + authorizerType: cliOptions.authorizerType as RuntimeAuthorizerType | undefined, + jwtConfig: + cliOptions.authorizerType === 'CUSTOM_JWT' && cliOptions.discoveryUrl + ? { + discoveryUrl: cliOptions.discoveryUrl, + allowedAudience: cliOptions.allowedAudience?.split(',').map(s => s.trim()), + allowedClients: cliOptions.allowedClients?.split(',').map(s => s.trim()), + allowedScopes: cliOptions.allowedScopes?.split(',').map(s => s.trim()), + customClaims: cliOptions.customClaims + ? (JSON.parse(cliOptions.customClaims) as JwtConfigOptions['customClaims']) + : undefined, + clientId: cliOptions.clientId, + clientSecret: cliOptions.clientSecret, + } + : undefined, + }); + + if (!result.success) { + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.error(result.error); + } + process.exit(1); + } + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.log(`Added harness '${result.harnessName}'.`); + } + + process.exit(0); + } else { + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + initialResource: 'harness' as const, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + } + ); + + this.registerRemoveSubcommand(removeCmd); + } + + addScreen(): AddScreenComponent { + return null; + } + + parseContainerFlag(value?: string): { containerUri?: string; dockerfilePath?: string } { + if (!value) return {}; + // Treat as Dockerfile if it uses a relative path prefix or ends with a + // Dockerfile extension. Bare absolute paths like /my-org/image:tag are + // valid container URIs so we don't match on leading / alone. + const looksLikeDockerfile = + value.endsWith('Dockerfile') || + value.endsWith('.dockerfile') || + value.startsWith('./') || + value.startsWith('../'); + if (looksLikeDockerfile) { + return { dockerfilePath: value }; + } + return { containerUri: value }; + } + + private buildLifecycleConfig(options: { idleTimeout?: number; maxLifetime?: number }) { + if (options.idleTimeout === undefined && options.maxLifetime === undefined) return undefined; + return { + ...(options.idleTimeout !== undefined && { idleRuntimeSessionTimeout: options.idleTimeout }), + ...(options.maxLifetime !== undefined && { maxLifetime: options.maxLifetime }), + }; + } +} diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index fb53e095d..030479a38 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -16,6 +16,7 @@ const defaultProject: AgentCoreProjectSpec = { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index 5f0e1a7c9..106cb3637 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -96,6 +96,7 @@ describe('createManagedOAuthCredential', () => { configBundles: [], abTests: [], httpGateways: [], + harnesses: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 05d00f869..9d6100887 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -3,6 +3,7 @@ export { BasePrimitive } from './BasePrimitive'; export { MemoryPrimitive } from './MemoryPrimitive'; export { CredentialPrimitive } from './CredentialPrimitive'; export { AgentPrimitive } from './AgentPrimitive'; +export { HarnessPrimitive } from './HarnessPrimitive'; export { EvaluatorPrimitive } from './EvaluatorPrimitive'; export { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; export { GatewayPrimitive } from './GatewayPrimitive'; @@ -12,6 +13,7 @@ export type { AddRuntimeEndpointOptions, RemovableRuntimeEndpoint } from './Runt export { ALL_PRIMITIVES, agentPrimitive, + harnessPrimitive, memoryPrimitive, credentialPrimitive, evaluatorPrimitive, @@ -25,3 +27,4 @@ export { } from './registry'; export { SOURCE_CODE_NOTE } from './constants'; export type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, Result } from './types'; +export type { AddHarnessOptions } from './HarnessPrimitive'; diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index 754b4e182..8b4012e00 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -1,3 +1,4 @@ +import { isPreviewEnabled } from '../feature-flags'; import { ABTestPrimitive } from './ABTestPrimitive'; import { AgentPrimitive } from './AgentPrimitive'; import type { BasePrimitive } from './BasePrimitive'; @@ -6,6 +7,7 @@ import { CredentialPrimitive } from './CredentialPrimitive'; import { EvaluatorPrimitive } from './EvaluatorPrimitive'; import { GatewayPrimitive } from './GatewayPrimitive'; import { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; +import { HarnessPrimitive } from './HarnessPrimitive'; import { MemoryPrimitive } from './MemoryPrimitive'; import { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; import { PolicyEnginePrimitive } from './PolicyEnginePrimitive'; @@ -17,6 +19,7 @@ import type { RemovableResource } from './types'; * Singleton instances of all primitives. */ export const agentPrimitive = new AgentPrimitive(); +export const harnessPrimitive = isPreviewEnabled() ? new HarnessPrimitive() : undefined; export const memoryPrimitive = new MemoryPrimitive(); export const credentialPrimitive = new CredentialPrimitive(); export const evaluatorPrimitive = new EvaluatorPrimitive(); @@ -34,6 +37,7 @@ export const runtimeEndpointPrimitive = new RuntimeEndpointPrimitive(); */ export const ALL_PRIMITIVES: BasePrimitive[] = [ agentPrimitive, + ...(harnessPrimitive ? [harnessPrimitive] : []), memoryPrimitive, credentialPrimitive, evaluatorPrimitive, diff --git a/src/cli/project.ts b/src/cli/project.ts index 14ea7be3c..c6bf1f5d0 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -18,6 +18,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + harnesses: [], configBundles: [], abTests: [], httpGateways: [], diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 47e339b1a..61cc85174 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -182,6 +182,7 @@ export const COMMAND_SCHEMAS = { help: NoAttrs, 'remove.all': NoAttrs, 'remove.agent': NoAttrs, + 'remove.harness': NoAttrs, 'remove.memory': NoAttrs, 'remove.credential': NoAttrs, 'remove.evaluator': NoAttrs, diff --git a/src/cli/tui/components/TextInput.tsx b/src/cli/tui/components/TextInput.tsx index b72f0b662..02ffe2bf0 100644 --- a/src/cli/tui/components/TextInput.tsx +++ b/src/cli/tui/components/TextInput.tsx @@ -13,6 +13,8 @@ interface TextInputProps { onCancel: () => void; placeholder?: string; initialValue?: string; + /** Dimmed description displayed below the prompt */ + description?: string; /** Zod string schema for validation - error message is extracted from schema */ schema?: ZodString; /** Custom validation beyond schema - both validate function and error message are required together */ @@ -60,6 +62,7 @@ export function TextInput({ onCancel, placeholder, initialValue = '', + description, schema, customValidation, allowEmpty = false, @@ -114,6 +117,7 @@ export function TextInput({ return ( {prompt && {prompt}} + {description && {description}} {!hideArrow && > } {beforeCursorFull} @@ -177,6 +181,7 @@ export function TextInput({ return ( {prompt && {prompt}} + {description && {description}} {!hideArrow && > } {showEllipsisBefore && โ€ฆ} diff --git a/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx b/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx new file mode 100644 index 000000000..de56d0e29 --- /dev/null +++ b/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx @@ -0,0 +1,93 @@ +import { useDevDeploy } from '../useDevDeploy.js'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockHandleDeploy = vi.fn(); + +vi.mock('../../../commands/deploy/actions.js', () => ({ + handleDeploy: (...args: unknown[]) => mockHandleDeploy(...args), +})); + +function Harness({ skip }: { skip?: boolean }) { + const { steps, isComplete, error } = useDevDeploy({ skip }); + return ( + + steps:{steps.length} isComplete:{String(isComplete)} error:{error ?? 'null'} + + ); +} + +describe('useDevDeploy', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('calls handleDeploy on mount', async () => { + mockHandleDeploy.mockResolvedValue({ success: true }); + + const { lastFrame } = render(); + + await vi.waitFor(() => { + expect(mockHandleDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'default', + autoConfirm: true, + }) + ); + }); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('isComplete:true'); + }); + }); + + it('does not call handleDeploy when skip is true', async () => { + const { lastFrame } = render(); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('isComplete:true'); + }); + + expect(mockHandleDeploy).not.toHaveBeenCalled(); + }); + + it('captures error from failed deploy', async () => { + mockHandleDeploy.mockResolvedValue({ success: false, error: 'Stack failed' }); + + const { lastFrame } = render(); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('isComplete:true'); + expect(lastFrame()).toContain('error:Stack failed'); + }); + }); + + it('captures error from thrown exception', async () => { + mockHandleDeploy.mockRejectedValue(new Error('Network error')); + + const { lastFrame } = render(); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('isComplete:true'); + expect(lastFrame()).toContain('error:Network error'); + }); + }); + + it('populates steps from onProgress callback', async () => { + mockHandleDeploy.mockImplementation((opts: { onProgress?: (step: string, status: string) => void }) => { + opts.onProgress?.('Validate project', 'start'); + opts.onProgress?.('Validate project', 'success'); + opts.onProgress?.('Build CDK', 'start'); + opts.onProgress?.('Build CDK', 'success'); + return Promise.resolve({ success: true }); + }); + + const { lastFrame } = render(); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('steps:2'); + expect(lastFrame()).toContain('isComplete:true'); + }); + }); +}); diff --git a/src/cli/tui/hooks/useDevDeploy.ts b/src/cli/tui/hooks/useDevDeploy.ts new file mode 100644 index 000000000..f5a1da381 --- /dev/null +++ b/src/cli/tui/hooks/useDevDeploy.ts @@ -0,0 +1,127 @@ +import { ConfigIO } from '../../../lib'; +import { detectAwsContext } from '../../aws/aws-context'; +import type { DeployMessage } from '../../cdk/toolkit-lib'; +import { handleDeploy } from '../../commands/deploy/actions'; +import { getErrorMessage } from '../../errors'; +import { canSkipDeploy } from '../../operations/deploy/change-detection'; +import type { Step } from '../components/StepProgress'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface UseDevDeployOptions { + skip?: boolean; + ready?: boolean; +} + +export interface UseDevDeployResult { + steps: Step[]; + deployMessages: DeployMessage[]; + isComplete: boolean; + error: string | undefined; + logPath: string | undefined; +} + +export function useDevDeploy({ skip, ready = true }: UseDevDeployOptions = {}): UseDevDeployResult { + const [steps, setSteps] = useState([]); + const [deployMessages, setDeployMessages] = useState([]); + const [deployDone, setDeployDone] = useState(false); + const [error, setError] = useState(); + const [logPath, setLogPath] = useState(); + const hasStarted = useRef(false); + + const onProgress = useCallback((stepName: string, status: 'start' | 'success' | 'error') => { + setSteps(prev => { + if (status === 'start') { + return [...prev, { label: stepName, status: 'running' }]; + } + return prev.map(s => (s.label === stepName ? { ...s, status: status } : s)); + }); + }, []); + + const onDeployMessage = useCallback((msg: DeployMessage) => { + setDeployMessages(prev => [...prev, msg]); + }, []); + + useEffect(() => { + if (skip || !ready || hasStarted.current) return; + hasStarted.current = true; + + const run = async () => { + try { + const configIO = new ConfigIO(); + + // Only deploy if the project has harnesses (cloud-dependent resources). + // Plain agents (Strands, LangGraph, etc.) run locally and don't need deployment. + try { + const projectSpec = await configIO.readProjectSpec(); + const hasHarnesses = (projectSpec.harnesses ?? []).length > 0; + if (!hasHarnesses) { + onProgress('Local agent โ€” no deploy needed', 'success'); + return; + } + } catch { + // If we can't read project spec, proceed with deploy as a safe default + } + + // Auto-populate aws-targets.json if empty + try { + const targets = await configIO.readAWSDeploymentTargets(); + if (targets.length === 0) { + const ctx = await detectAwsContext(); + if (ctx.accountId) { + await configIO.writeAWSDeploymentTargets([ + { name: 'default', account: ctx.accountId, region: ctx.region }, + ]); + } + } + } catch { + try { + const ctx = await detectAwsContext(); + if (ctx.accountId) { + await configIO.writeAWSDeploymentTargets([ + { name: 'default', account: ctx.accountId, region: ctx.region }, + ]); + } + } catch { + // Can't detect โ€” let handleDeploy fail with a clear error + } + } + + const noChanges = await canSkipDeploy(configIO); + if (noChanges) { + onProgress('No changes detected โ€” skipping deploy', 'success'); + return; + } + + const result = await handleDeploy({ + target: 'default', + autoConfirm: true, + verbose: true, + onProgress, + onDeployMessage: (message: string) => + onDeployMessage({ code: '', message, level: 'info', timestamp: new Date() }), + onResourceEvent: (message: string) => + onDeployMessage({ code: '', message, level: 'info', timestamp: new Date() }), + }); + + if (result.logPath) { + setLogPath(result.logPath); + } + + if (!result.success) { + setError(result.error instanceof Error ? result.error.message : String(result.error)); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setDeployDone(true); + } + }; + + void run(); + }, [skip, ready, onProgress, onDeployMessage]); + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- skip is boolean, not nullable; || is the correct operator here + const isComplete = skip || deployDone; + + return { steps, deployMessages, isComplete, error, logPath }; +} diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index 9400ea2ad..2436071e8 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -14,6 +14,7 @@ import { evaluatorPrimitive, gatewayPrimitive, gatewayTargetPrimitive, + harnessPrimitive, memoryPrimitive, onlineEvalConfigPrimitive, policyEnginePrimitive, @@ -117,6 +118,13 @@ export function useRemovableAgents() { return { agents, ...rest }; } +export function useRemovableHarnesses() { + const { items: harnesses, ...rest } = useRemovableResources(() => + harnessPrimitive ? harnessPrimitive.getRemovable().then(r => r.map(h => h.name)) : Promise.resolve([]) + ); + return { harnesses, ...rest }; +} + export function useRemovableGateways() { const { items: gateways, ...rest } = useRemovableResources(() => gatewayPrimitive.getRemovable().then(r => r.map(g => g.name)) @@ -223,6 +231,10 @@ export function useRemovalPreview() { (name: string) => loadPreview(n => agentPrimitive.previewRemove(n), name), [loadPreview] ); + const loadHarnessPreview = useCallback( + (name: string) => loadPreview(n => harnessPrimitive!.previewRemove(n), name), + [loadPreview] + ); const loadGatewayPreview = useCallback( (name: string) => loadPreview(n => gatewayPrimitive.previewRemove(n), name), [loadPreview] @@ -277,6 +289,7 @@ export function useRemovalPreview() { return { ...state, loadAgentPreview, + loadHarnessPreview, loadGatewayPreview, loadGatewayTargetPreview, loadMemoryPreview, @@ -311,6 +324,14 @@ export function useRemoveAgent() { ); } +export function useRemoveHarness() { + return useRemoveResource( + (name: string) => harnessPrimitive!.remove(name), + 'harness', + name => name + ); +} + export function useRemoveGateway() { return useRemoveResource( (name: string) => gatewayPrimitive.remove(name), diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index eef7f4db2..fdf64bff1 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -10,6 +10,7 @@ import { FRAMEWORK_OPTIONS } from '../agent/types'; import { useAddAgent } from '../agent/useAddAgent'; import { AddConfigBundleFlow } from '../config-bundle'; import { AddEvaluatorFlow } from '../evaluator'; +import { AddHarnessFlow } from '../harness/AddHarnessFlow'; import { AddIdentityFlow } from '../identity'; import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; import { AddMemoryFlow } from '../memory/AddMemoryFlow'; @@ -25,6 +26,7 @@ import React, { useCallback, useEffect, useState } from 'react'; type FlowState = | { name: 'select' } + | { name: 'harness-wizard' } | { name: 'agent-wizard' } | { name: 'gateway-wizard' } | { name: 'tool-wizard' } @@ -169,6 +171,8 @@ interface AddFlowProps { function getInitialFlowState(resource?: AddResourceType): FlowState { switch (resource) { + case 'harness': + return { name: 'harness-wizard' }; case 'agent': return { name: 'agent-wizard' }; case 'gateway': @@ -214,6 +218,9 @@ export function AddFlow(props: AddFlowProps) { const handleSelectResource = useCallback((resourceType: AddResourceType) => { switch (resourceType) { + case 'harness': + setFlow({ name: 'harness-wizard' }); + break; case 'agent': setFlow({ name: 'agent-wizard' }); break; @@ -293,6 +300,18 @@ export function AddFlow(props: AddFlowProps) { return ; } + if (flow.name === 'harness-wizard') { + return ( + setFlow({ name: 'select' })} + onExit={props.onExit} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + // Agent wizard - now uses AddAgentFlow with mode selection if (flow.name === 'agent-wizard') { return ( diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index 04dceac97..ef4f33a87 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -1,7 +1,22 @@ +import { isPreviewEnabled } from '../../../feature-flags'; import type { SelectableItem } from '../../components'; import { SelectScreen } from '../../components'; -const ADD_RESOURCES = [ +export type AddResourceType = + | 'harness' + | 'agent' + | 'memory' + | 'credential' + | 'evaluator' + | 'online-eval' + | 'gateway' + | 'gateway-target' + | 'runtime-endpoint' + | 'policy' + | 'config-bundle' + | 'ab-test'; + +const BASE_ADD_RESOURCES: { id: AddResourceType; title: string; description: string }[] = [ { id: 'agent', title: 'Agent', description: 'Deploy an HTTP, MCP, A2A, or AG-UI agent' }, { id: 'memory', title: 'Memory', description: 'Persistent context storage' }, { id: 'credential', title: 'Credential', description: 'API key credential providers' }, @@ -13,16 +28,21 @@ const ADD_RESOURCES = [ { id: 'policy', title: 'Policy', description: 'Cedar policies for gateway tools' }, { id: 'config-bundle', title: 'Configuration Bundle [preview]', description: 'Versioned component configurations' }, { id: 'ab-test', title: 'AB Test [preview]', description: 'Compare agent configurations with traffic splitting' }, -] as const; +]; + +const ADD_RESOURCES: { id: AddResourceType; title: string; description: string }[] = [ + ...(isPreviewEnabled() + ? [{ id: 'harness' as const, title: 'Harness', description: 'Managed config-based agent loop, no code required' }] + : []), + ...BASE_ADD_RESOURCES, +]; const ADD_RESOURCE_ITEMS: SelectableItem[] = ADD_RESOURCES.map(r => ({ ...r, - disabled: Boolean('disabled' in r && r.disabled), + disabled: false, description: r.description, })); -export type AddResourceType = (typeof ADD_RESOURCES)[number]['id']; - interface AddScreenProps { onSelect: (resourceType: AddResourceType) => void; onExit: () => void; diff --git a/src/cli/tui/screens/create/CreateScreen.tsx b/src/cli/tui/screens/create/CreateScreen.tsx index 03ff31fbd..ac3ea6db6 100644 --- a/src/cli/tui/screens/create/CreateScreen.tsx +++ b/src/cli/tui/screens/create/CreateScreen.tsx @@ -1,6 +1,7 @@ import { DEFAULT_MODEL_IDS, ProjectNameSchema } from '../../../../schema'; import { validateFolderNotExists } from '../../../commands/create/validate'; import { VPC_ENDPOINT_WARNING } from '../../../commands/shared/vpc-utils'; +import { isPreviewEnabled } from '../../../feature-flags'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; import { LogLink, @@ -19,13 +20,20 @@ import { STATUS_COLORS } from '../../theme'; import { AddAgentScreen } from '../agent/AddAgentScreen'; import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; +import { AddHarnessScreen } from '../harness/AddHarnessScreen'; +import type { AddHarnessConfig } from '../harness/types'; import { useCreateFlow } from './useCreateFlow'; import { Box, Text, useApp } from 'ink'; import { join } from 'path'; import { useCallback, useEffect } from 'react'; /** Build a text representation of the completion screen for terminal output */ -function buildExitMessage(projectName: string, steps: Step[], agentConfig: AddAgentConfig | null): string { +function buildExitMessage( + projectName: string, + steps: Step[], + agentConfig: AddAgentConfig | null, + harnessConfig: AddHarnessConfig | null = null +): string { const lines: string[] = []; // Title @@ -63,6 +71,14 @@ function buildExitMessage(projectName: string, steps: Step[], agentConfig: AddAg const maxPathLen = Math.max(agentPath.length, agentcorePath.length); lines.push(` ${agentPath.padEnd(maxPathLen)} \x1b[2mAgent code location (empty)\x1b[0m`); lines.push(` ${agentcorePath.padEnd(maxPathLen)} \x1b[2mConfig and CDK project\x1b[0m`); + } else if (harnessConfig) { + const harnessPath = `app/${harnessConfig.name}/`; + const agentcorePath = 'agentcore/'; + const maxPathLen = Math.max(harnessPath.length, agentcorePath.length); + lines.push(` ${harnessPath.padEnd(maxPathLen)} \x1b[2mHarness\x1b[0m`); + lines.push(` ${agentcorePath.padEnd(maxPathLen)} \x1b[2mConfig and CDK project\x1b[0m`); + lines.push(''); + lines.push(`\x1b[2mModel:\x1b[0m ${harnessConfig.modelId} \x1b[2mvia ${harnessConfig.modelProvider}\x1b[0m`); } else { lines.push(` agentcore/ \x1b[2mConfig and CDK project\x1b[0m`); } @@ -135,8 +151,26 @@ const CREATE_PROMPT_ITEMS = [ { id: 'no', title: "No, I'll do it later" }, ]; +const CREATE_TYPE_ITEMS = [ + { id: 'harness', title: 'Harness (recommended)', description: 'Managed config-based agent loop, no code required' }, + { + id: 'agent', + title: 'Agent', + description: 'Start with a template or bring your own code hosted on AgentCore Runtime', + }, + { id: 'skip', title: 'Skip', description: "I'll add resources later" }, +]; + /** Tree-style display of created project structure */ -function CreatedSummary({ projectName, agentConfig }: { projectName: string; agentConfig: AddAgentConfig | null }) { +function CreatedSummary({ + projectName, + agentConfig, + harnessConfig, +}: { + projectName: string; + agentConfig: AddAgentConfig | null; + harnessConfig: AddHarnessConfig | null; +}) { const getFrameworkLabel = (framework: string) => { const option = FRAMEWORK_OPTIONS.find(o => o.id === framework); return option?.title ?? framework; @@ -145,8 +179,10 @@ function CreatedSummary({ projectName, agentConfig }: { projectName: string; age const isCreate = agentConfig?.agentType === 'create' || agentConfig?.agentType === 'import'; const isByo = agentConfig?.agentType === 'byo'; const agentPath = isCreate ? `app/${agentConfig.name}/` : isByo ? agentConfig.codeLocation : null; + const harnessPath = harnessConfig ? `app/${harnessConfig.name}/` : null; + const resourcePath = agentPath ?? harnessPath; const agentcorePath = 'agentcore/'; - const maxPathLen = agentPath ? Math.max(agentPath.length, agentcorePath.length) : agentcorePath.length; + const maxPathLen = resourcePath ? Math.max(resourcePath.length, agentcorePath.length) : agentcorePath.length; return ( @@ -172,6 +208,14 @@ function CreatedSummary({ projectName, agentConfig }: { projectName: string; age )} + {harnessConfig && harnessPath && ( + + + {harnessPath.padEnd(maxPathLen)} + {' '}Harness + + + )} {agentcorePath.padEnd(maxPathLen)} @@ -186,6 +230,13 @@ function CreatedSummary({ projectName, agentConfig }: { projectName: string; age via {agentConfig.modelProvider} )} + {harnessConfig && ( + + Model: + {harnessConfig.modelId} + via {harnessConfig.modelProvider} + + )} {isByo && agentConfig && ( @@ -205,6 +256,7 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS const flow = useCreateFlow(cwd); // Project root is cwd/projectName (new project directory) const projectRoot = join(cwd, flow.projectName); + const preview = isPreviewEnabled(); // Completion state for next steps const allSuccess = !flow.hasError && flow.isComplete; @@ -213,12 +265,21 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS const handleExit = useCallback(() => { if (allSuccess && isInteractive) { // Set message to be printed after TUI exits (full completion screen) - setExitMessage(buildExitMessage(flow.projectName, flow.steps, flow.addAgentConfig)); + setExitMessage(buildExitMessage(flow.projectName, flow.steps, flow.addAgentConfig, flow.addHarnessConfig)); exit(); } else { onExit(); } - }, [allSuccess, isInteractive, flow.projectName, flow.steps, flow.addAgentConfig, exit, onExit]); + }, [ + allSuccess, + isInteractive, + flow.projectName, + flow.steps, + flow.addAgentConfig, + flow.addHarnessConfig, + exit, + onExit, + ]); // Auto-exit when project creation completes successfully useEffect(() => { @@ -227,14 +288,24 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS } }, [allSuccess, handleExit]); - // Create prompt navigation + // GA mode: binary create prompt navigation const { selectedIndex: createPromptIndex } = useListNavigation({ items: CREATE_PROMPT_ITEMS, onSelect: item => { flow.setWantsCreate(item.id === 'yes'); }, onExit: handleExit, - isActive: flow.phase === 'create-prompt', + isActive: !preview && flow.phase === 'create-prompt', + }); + + // Preview mode: 3-option create type selection navigation + const { selectedIndex: createTypeIndex } = useListNavigation({ + items: CREATE_TYPE_ITEMS, + onSelect: item => { + flow.handleCreateTypeSelection(item.id as 'harness' | 'agent' | 'skip'); + }, + onExit: handleExit, + isActive: preview && flow.phase === 'create-type-prompt', }); // Checking phase: instant async check โ€” render nothing to avoid a flash before the real UI @@ -253,6 +324,17 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS ); } + // Harness wizard phase (preview only, separate component, no header conflict) + if (preview && flow.phase === 'harness-wizard') { + return ( + + ); + } + // All other phases share a single to prevent duplicate header flashes // when Ink transitions between different mounts. const phase = flow.phase; @@ -270,7 +352,7 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS ? 'Press Esc to exit' : phase === 'input' ? HELP_TEXT.TEXT_INPUT - : phase === 'create-prompt' + : phase === 'create-prompt' || phase === 'create-type-prompt' ? HELP_TEXT.NAVIGATE_SELECT : flow.hasError || allSuccess ? HELP_TEXT.EXIT @@ -310,7 +392,7 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS )} - {phase === 'create-prompt' && ( + {phase === 'create-prompt' && !preview && ( <> Would you like to add an agent now? @@ -321,12 +403,27 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS )} + {phase === 'create-type-prompt' && preview && ( + <> + + What would you like to build? + + + + + + )} + {phase === 'running' && } {allSuccess && flow.outputDir && ( - + {isInteractive ? ( Project created successfully! diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index f06c2f485..3c5386476 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -9,6 +9,7 @@ import { } from '../../../../lib'; import type { DeployedState } from '../../../../schema'; import { getErrorMessage } from '../../../errors'; +import { isPreviewEnabled } from '../../../feature-flags'; import { CreateLogger } from '../../../logging'; import { initGitRepo, setupNodeProject, setupPythonProject, writeEnvFile, writeGitignore } from '../../../operations'; import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; @@ -41,6 +42,7 @@ import { withMinDuration } from '../../utils'; import { mapByoConfigToAgent } from '../agent'; import type { AddAgentConfig } from '../agent/types'; import type { GenerateConfig } from '../generate/types'; +import type { AddHarnessConfig } from '../harness/types'; import { mkdir } from 'fs/promises'; import { basename, join } from 'path'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -50,7 +52,9 @@ type CreatePhase = | 'existing-project-error' | 'input' | 'create-prompt' + | 'create-type-prompt' | 'create-wizard' + | 'harness-wizard' | 'running' | 'complete'; @@ -66,16 +70,26 @@ interface CreateFlowState { // Project name actions setProjectName: (name: string) => void; confirmProjectName: () => void; - // Create prompt actions + // Create prompt actions (GA mode) wantsCreate: boolean; setWantsCreate: (wants: boolean) => void; + // Create type selection (preview mode) + handleCreateTypeSelection: (choice: 'harness' | 'agent' | 'skip') => void; // Add agent config (set when AddAgentScreen completes) addAgentConfig: AddAgentConfig | null; handleAddAgentComplete: (config: AddAgentConfig) => void; goBackFromAddAgent: () => void; + // Add harness config (preview mode, set when AddHarnessScreen completes) + addHarnessConfig: AddHarnessConfig | null; + handleAddHarnessComplete: (config: AddHarnessConfig) => void; + goBackFromHarnessWizard: () => void; } -function getCreateSteps(projectName: string, agentConfig: AddAgentConfig | null): Step[] { +function getCreateSteps( + projectName: string, + agentConfig: AddAgentConfig | null, + harnessConfig: AddHarnessConfig | null = null +): Step[] { const steps: Step[] = [{ label: `Create ${projectName}/ project directory`, status: 'pending' }]; if (agentConfig) { @@ -86,6 +100,8 @@ function getCreateSteps(projectName: string, agentConfig: AddAgentConfig | null) if (agentConfig.language === 'TypeScript' && agentConfig.agentType === 'create') { steps.push({ label: 'Set up Node environment', status: 'pending' }); } + } else if (harnessConfig) { + steps.push({ label: 'Add harness to project', status: 'pending' }); } steps.push({ label: 'Prepare agentcore/ directory', status: 'pending' }); @@ -137,6 +153,9 @@ export function useCreateFlow(cwd: string): CreateFlowState { // Add agent config (from AddAgentScreen) const [addAgentConfig, setAddAgentConfig] = useState(null); + // Add harness config (from AddHarnessScreen, preview mode) + const [addHarnessConfig, setAddHarnessConfig] = useState(null); + // Logger ref for the create operation const loggerRef = useRef(null); @@ -161,7 +180,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { }, [cwd, phase]); const confirmProjectName = useCallback(() => { - setPhase('create-prompt'); + setPhase(isPreviewEnabled() ? 'create-type-prompt' : 'create-prompt'); }, []); const updateStep = (index: number, update: Partial) => { @@ -197,7 +216,43 @@ export function useCreateFlow(cwd: string): CreateFlowState { // Go back from add agent wizard to create prompt const goBackFromAddAgent = useCallback(() => { - setPhase('create-prompt'); + setPhase(isPreviewEnabled() ? 'create-type-prompt' : 'create-prompt'); + }, []); + + // Preview mode: create type selection handler + const handleCreateTypeSelection = useCallback( + (choice: 'harness' | 'agent' | 'skip') => { + if (choice === 'harness') { + setAddAgentConfig(null); + setAddHarnessConfig(null); + setPhase('harness-wizard'); + } else if (choice === 'agent') { + setAddAgentConfig(null); + setAddHarnessConfig(null); + setPhase('create-wizard'); + } else { + setAddAgentConfig(null); + setAddHarnessConfig(null); + setSteps(getCreateSteps(projectName, null, null)); + setPhase('running'); + } + }, + [projectName] + ); + + // Preview mode: handle completion from AddHarnessScreen + const handleAddHarnessComplete = useCallback( + (config: AddHarnessConfig) => { + setAddHarnessConfig(config); + setSteps(getCreateSteps(projectName, null, config)); + setPhase('running'); + }, + [projectName] + ); + + // Preview mode: go back from harness wizard to create type prompt + const goBackFromHarnessWizard = useCallback(() => { + setPhase('create-type-prompt'); }, []); // Main running effect @@ -524,6 +579,66 @@ export function useCreateFlow(cwd: string): CreateFlowState { } } + // Step: Add harness to project (if addHarnessConfig is set, preview mode) + if (!addAgentConfig && addHarnessConfig) { + logger.startStep('Add harness to project'); + updateStep(stepIndex, { status: 'running' }); + try { + await withMinDuration(async () => { + logger.logSubStep(`Adding harness: ${addHarnessConfig.name}`); + const { harnessPrimitive: hp } = await import('../../../primitives/registry'); + const result = await hp!.add({ + name: addHarnessConfig.name, + modelProvider: addHarnessConfig.modelProvider, + modelId: addHarnessConfig.modelId, + apiKeyArn: addHarnessConfig.apiKeyArn, + skipMemory: addHarnessConfig.skipMemory, + containerUri: addHarnessConfig.containerUri, + dockerfilePath: addHarnessConfig.dockerfilePath, + maxIterations: addHarnessConfig.maxIterations, + maxTokens: addHarnessConfig.maxTokens, + timeoutSeconds: addHarnessConfig.timeoutSeconds, + truncationStrategy: addHarnessConfig.truncationStrategy, + networkMode: addHarnessConfig.networkMode, + subnets: addHarnessConfig.subnets, + securityGroups: addHarnessConfig.securityGroups, + idleTimeout: addHarnessConfig.idleTimeout, + maxLifetime: addHarnessConfig.maxLifetime, + sessionStoragePath: addHarnessConfig.sessionStoragePath, + selectedTools: addHarnessConfig.selectedTools, + mcpName: addHarnessConfig.mcpName, + mcpUrl: addHarnessConfig.mcpUrl, + gatewayArn: addHarnessConfig.gatewayArn, + authorizerType: addHarnessConfig.authorizerType, + jwtConfig: addHarnessConfig.jwtConfig + ? { + discoveryUrl: addHarnessConfig.jwtConfig.discoveryUrl, + allowedAudience: addHarnessConfig.jwtConfig.allowedAudience, + allowedClients: addHarnessConfig.jwtConfig.allowedClients, + allowedScopes: addHarnessConfig.jwtConfig.allowedScopes, + customClaims: addHarnessConfig.jwtConfig.customClaims, + clientId: addHarnessConfig.jwtConfig.clientId, + clientSecret: addHarnessConfig.jwtConfig.clientSecret, + } + : undefined, + configBaseDir, + }); + if (!result.success) { + throw result.error; + } + }); + logger.endStep('success'); + updateStep(stepIndex, { status: 'success' }); + stepIndex++; + } catch (err) { + const errMsg = getErrorMessage(err); + logger.endStep('error', errMsg); + updateStep(stepIndex, { status: 'error', error: errMsg }); + logger.finalize(false); + return { success: false, error: new Error(errMsg) }; + } + } + // Step: Create CDK project logger.startStep('Prepare agentcore/ directory (CDK project)'); updateStep(stepIndex, { status: 'running' }); @@ -597,12 +712,18 @@ export function useCreateFlow(cwd: string): CreateFlowState { logFilePath, setProjectName, confirmProjectName, - // Create prompt + // Create prompt (GA) wantsCreate, setWantsCreate: handleSetWantsCreate, + // Create type selection (preview) + handleCreateTypeSelection, // Add agent addAgentConfig, handleAddAgentComplete, goBackFromAddAgent, + // Add harness (preview) + addHarnessConfig, + handleAddHarnessComplete, + goBackFromHarnessWizard, }; } diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index dfe798549..aa3abd89d 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -1,11 +1,23 @@ import type { AgentEnvSpec } from '../../../../schema'; +import { isPreviewEnabled } from '../../../feature-flags'; import { getDevSupportedAgents, getEndpointUrl, loadProjectConfig } from '../../../operations/dev'; -import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components'; +import { + DeployStatus, + GradientText, + LogLink, + Panel, + Screen, + SelectList, + StepProgress, + TextInput, +} from '../../components'; +import { useDevDeploy } from '../../hooks/useDevDeploy'; import { type ConversationMessage, useDevServer } from '../../hooks/useDevServer'; +import { InvokeScreen } from '../invoke/InvokeScreen'; import { Box, Text, useInput, useStdout } from 'ink'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -type Mode = 'select-agent' | 'chat' | 'input'; +type Mode = 'select-agent' | 'chat' | 'input' | 'deploying' | 'harness'; interface DevScreenProps { onBack: () => void; @@ -15,6 +27,10 @@ interface DevScreenProps { agentName?: string; /** Custom headers to forward to the agent on every invocation */ headers?: Record; + /** Skip automatic resource deployment (preview) */ + skipDeploy?: boolean; + /** Called when deploy completes and browser mode should launch (preview) */ + onLaunchBrowser?: (selection?: { agentName?: string; harnessName?: string }) => void; } interface ColoredLine { @@ -120,6 +136,8 @@ function wrapColoredLines(lines: ColoredLine[], maxWidth: number): ColoredLine[] const MAX_VISIBLE_TOOLS = 5; export function DevScreen(props: DevScreenProps) { + const { onLaunchBrowser } = props; + const preview = isPreviewEnabled(); const [mode, setMode] = useState('select-agent'); const [isExiting, setIsExiting] = useState(false); const [scrollOffset, setScrollOffset] = useState(0); @@ -139,6 +157,10 @@ export function DevScreen(props: DevScreenProps) { const [isContainerExec, setIsContainerExec] = useState(false); const [execInputEmpty, setExecInputEmpty] = useState(true); + // Harness state (preview) + const [availableHarnesses, setAvailableHarnesses] = useState([]); + const [selectedHarness, setSelectedHarness] = useState(); + const workingDir = props.workingDir ?? process.cwd(); // Load project and get supported agents @@ -148,29 +170,38 @@ export function DevScreen(props: DevScreenProps) { const agents = getDevSupportedAgents(project); setSupportedAgents(agents); + const harnesses = preview ? (project?.harnesses ?? []).map(h => h.name) : []; + setAvailableHarnesses(harnesses); + // If agent name was provided via CLI, validate it if (props.agentName) { const found = agents.find(a => a.name === props.agentName); if (found) { setSelectedAgentName(props.agentName); - setMode('chat'); + if (!onLaunchBrowser) setMode('chat'); } else if (agents.length > 0) { - // Agent not found or not supported, show selection setSelectedAgentName(undefined); } - } else if (agents.length === 1 && agents[0]) { - // Auto-select if only one agent + } else if (agents.length === 1 && harnesses.length === 0 && agents[0]) { setSelectedAgentName(agents[0].name); - setMode('chat'); - } else if (agents.length === 0) { - // No supported agents, show error screen + if (!onLaunchBrowser) setMode('chat'); + } else if (harnesses.length === 1 && agents.length === 0) { + setSelectedHarness(harnesses[0]); + setMode('deploying'); + } else if (agents.length === 0 && harnesses.length === 0) { setNoAgentsError(true); } setAgentsLoaded(true); + + // If onLaunchBrowser is set and only agents (no harnesses), auto-select immediately. + // Harness projects need deploy first โ€” handled after deploy completes. + if (onLaunchBrowser && agents.length === 1 && harnesses.length === 0) { + queueMicrotask(() => onLaunchBrowser({ agentName: agents[0]?.name })); + } }; void load(); - }, [workingDir, props.agentName]); + }, [workingDir, props.agentName, preview, onLaunchBrowser]); const onServerReady = useCallback(() => setMode(prev => (prev === 'chat' ? 'input' : prev)), []); @@ -208,6 +239,28 @@ export function DevScreen(props: DevScreenProps) { headers: props.headers, }); + const { + steps: deploySteps, + deployMessages, + isComplete: deployComplete, + error: deployError, + logPath: deployLogPath, + } = useDevDeploy({ skip: props.skipDeploy, ready: mode === 'deploying' }); + + const hasTransitionedFromDeployRef = useRef(false); + useEffect(() => { + if (mode !== 'deploying' || !deployComplete || deployError || hasTransitionedFromDeployRef.current) return; + hasTransitionedFromDeployRef.current = true; + queueMicrotask(() => { + if (onLaunchBrowser) { + onLaunchBrowser({ harnessName: selectedHarness }); + } else { + setMode('harness'); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, deployComplete, deployError]); + // MCP: auto-list tools when server becomes ready, show hint in conversation const mcpFetchTriggeredRef = useRef(false); const [mcpToolsFetched, setMcpToolsFetched] = useState(false); @@ -337,21 +390,35 @@ export function DevScreen(props: DevScreenProps) { (input, key) => { // Agent selection mode if (mode === 'select-agent') { + const totalItems = supportedAgents.length + availableHarnesses.length; if (key.escape || (key.ctrl && input === 'q')) { handleExit(); return; } if (key.upArrow || input === 'k') { - setSelectedAgentIndex(prev => (prev - 1 + supportedAgents.length) % supportedAgents.length); + setSelectedAgentIndex(prev => (prev - 1 + totalItems) % totalItems); } if (key.downArrow || input === 'j') { - setSelectedAgentIndex(prev => (prev + 1) % supportedAgents.length); + setSelectedAgentIndex(prev => (prev + 1) % totalItems); } if (key.return) { - const agent = supportedAgents[selectedAgentIndex]; - if (agent) { - setSelectedAgentName(agent.name); - setMode('chat'); + if (selectedAgentIndex < supportedAgents.length) { + const agent = supportedAgents[selectedAgentIndex]; + if (agent) { + if (onLaunchBrowser) { + onLaunchBrowser({ agentName: agent.name }); + } else { + setSelectedAgentName(agent.name); + setMode('chat'); + } + } + } else if (preview) { + const harnessIdx = selectedAgentIndex - supportedAgents.length; + const harnessName = availableHarnesses[harnessIdx]; + if (harnessName) { + setSelectedHarness(harnessName); + setMode('deploying'); + } } } return; @@ -366,8 +433,8 @@ export function DevScreen(props: DevScreenProps) { justCancelledRef.current = false; return; } - // If multiple agents, go back to agent selection - if (supportedAgents.length > 1) { + // If multiple agents or harnesses, go back to selection + if (supportedAgents.length + availableHarnesses.length > 1) { stop(); setMode('select-agent'); setSelectedAgentName(undefined); @@ -413,11 +480,18 @@ export function DevScreen(props: DevScreenProps) { } } }, - { isActive: mode === 'chat' || mode === 'select-agent' } + { isActive: (mode === 'chat' || mode === 'select-agent') && !isExiting } ); - // Return null while loading - if (!agentsLoaded || (mode !== 'select-agent' && !noAgentsError && (!configLoaded || !config))) { + // Return null while loading (harness mode doesn't need dev server config) + if ( + !agentsLoaded || + (mode !== 'select-agent' && + mode !== 'deploying' && + mode !== 'harness' && + !noAgentsError && + (!configLoaded || !config)) + ) { return null; } @@ -426,8 +500,14 @@ export function DevScreen(props: DevScreenProps) { return ( - No agents defined in project. - Dev mode requires at least one agent with an entrypoint. + + {preview ? 'No agents or harnesses defined in project.' : 'No agents defined in project.'} + + + {preview + ? 'Dev mode requires at least one agent with an entrypoint or a harness.' + : 'Dev mode requires at least one agent with an entrypoint.'} + Run agentcore add agent to create one. @@ -436,13 +516,45 @@ export function DevScreen(props: DevScreenProps) { ); } + if (mode === 'deploying') { + const hasStartedCfn = deployMessages.length > 0; + const displaySteps = hasStartedCfn ? deploySteps.filter(s => s.label !== 'Deploy to AWS') : deploySteps; + + return ( + + + Deploying project resources... + + + + {hasStartedCfn && ( + + + + )} + {deployError && ( + + Deploy failed: {deployError} + + )} + {deployLogPath && } + + + ); + } + + // If harness mode (preview), render the InvokeScreen with the pre-selected harness + if (preview && mode === 'harness') { + return ; + } + const statusColor = { starting: 'yellow', running: 'green', error: 'red', stopped: 'gray' }[status]; // Visible lines for display const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight); // Dynamic help text - const backOrQuit = supportedAgents.length > 1 ? 'Esc back' : 'Esc quit'; + const backOrQuit = supportedAgents.length + availableHarnesses.length > 1 ? 'Esc back' : 'Esc quit'; const execHint = isContainer ? '! exec local ยท !! exec container' : '! exec'; const helpText = mode === 'select-agent' @@ -468,15 +580,25 @@ export function DevScreen(props: DevScreenProps) { // Agent selection screen if (mode === 'select-agent') { const agentItems = supportedAgents.map((agent, i) => ({ - id: String(i), + id: `agent-${i}`, title: agent.name, description: `${agent.runtimeVersion} ยท ${agent.build}`, })); + const harnessItems = preview + ? availableHarnesses.map((name, i) => ({ + id: `harness-${i}`, + title: name, + description: 'Harness', + })) + : []; + + const allItems = [...agentItems, ...harnessItems]; + return ( - - + 0 ? 'Select Target' : 'Select Agent'} fullWidth> + ); diff --git a/src/cli/tui/screens/harness/AddHarnessFlow.tsx b/src/cli/tui/screens/harness/AddHarnessFlow.tsx new file mode 100644 index 000000000..7b9bd26cd --- /dev/null +++ b/src/cli/tui/screens/harness/AddHarnessFlow.tsx @@ -0,0 +1,138 @@ +import { ErrorPrompt } from '../../components'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddHarnessScreen } from './AddHarnessScreen'; +import type { AddHarnessConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { name: 'create-success'; harnessName: string; loading?: boolean; loadingMessage?: string } + | { name: 'error'; message: string }; + +interface AddHarnessFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddHarnessFlowProps) { + const [flow, setFlow] = useState({ name: 'create-wizard' }); + const [existingNames, setExistingNames] = useState([]); + + useEffect(() => { + void (async () => { + try { + const { ConfigIO } = await import('../../../../lib'); + const configIO = new ConfigIO(); + if (configIO.hasProject()) { + const project = await configIO.readProjectSpec(); + setExistingNames((project.harnesses ?? []).map(h => h.name)); + } + } catch { + // ignore + } + })(); + }, []); + + useEffect(() => { + if (!isInteractive && flow.name === 'create-success' && !flow.loading) { + onExit(); + } + }, [isInteractive, flow, onExit]); + + const handleCreateComplete = useCallback(async (config: AddHarnessConfig) => { + setFlow({ name: 'create-success', harnessName: config.name, loading: true, loadingMessage: 'Creating harness...' }); + try { + const { harnessPrimitive } = await import('../../../primitives/registry'); + const result = await harnessPrimitive!.add({ + name: config.name, + modelProvider: config.modelProvider, + modelId: config.modelId, + apiKeyArn: config.apiKeyArn, + skipMemory: config.skipMemory, + containerUri: config.containerUri, + dockerfilePath: config.dockerfilePath, + maxIterations: config.maxIterations, + maxTokens: config.maxTokens, + timeoutSeconds: config.timeoutSeconds, + truncationStrategy: config.truncationStrategy, + networkMode: config.networkMode, + subnets: config.subnets, + securityGroups: config.securityGroups, + idleTimeout: config.idleTimeout, + maxLifetime: config.maxLifetime, + sessionStoragePath: config.sessionStoragePath, + selectedTools: config.selectedTools, + mcpName: config.mcpName, + mcpUrl: config.mcpUrl, + gatewayArn: config.gatewayArn, + gatewayOutboundAuth: config.gatewayOutboundAuth, + gatewayProviderArn: config.gatewayProviderArn, + gatewayScopes: config.gatewayScopes + ? config.gatewayScopes + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : undefined, + authorizerType: config.authorizerType, + jwtConfig: config.jwtConfig + ? { + discoveryUrl: config.jwtConfig.discoveryUrl, + allowedAudience: config.jwtConfig.allowedAudience, + allowedClients: config.jwtConfig.allowedClients, + allowedScopes: config.jwtConfig.allowedScopes, + customClaims: config.jwtConfig.customClaims, + clientId: config.jwtConfig.clientId, + clientSecret: config.jwtConfig.clientSecret, + } + : undefined, + }); + if (!result.success) { + setFlow({ name: 'error', message: result.error.message }); + return; + } + + setFlow({ name: 'create-success', harnessName: config.name }); + } catch (err) { + const { getErrorMessage } = await import('../../../errors'); + setFlow({ name: 'error', message: getErrorMessage(err) }); + } + }, []); + + if (flow.name === 'create-wizard') { + return ( + void handleCreateComplete(config)} + onExit={onBack} + /> + ); + } + + if (flow.name === 'create-success') { + return ( + + ); + } + + return ( + setFlow({ name: 'create-wizard' })} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/harness/AddHarnessScreen.tsx b/src/cli/tui/screens/harness/AddHarnessScreen.tsx new file mode 100644 index 000000000..13c3fc125 --- /dev/null +++ b/src/cli/tui/screens/harness/AddHarnessScreen.tsx @@ -0,0 +1,685 @@ +import type { HarnessModelProvider, RuntimeAuthorizerType } from '../../../../schema'; +import { NetworkModeSchema } from '../../../../schema'; +import { HarnessNameSchema, HarnessTruncationStrategySchema } from '../../../../schema/schemas/primitives/harness'; +import { ARN_VALIDATION_MESSAGE, isValidArn } from '../../../commands/shared/arn-utils'; +import { computeManagedOAuthCredentialName } from '../../../primitives/credential-utils'; +import { + ConfirmReview, + Panel, + Screen, + StepIndicator, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; +import type { SelectableItem } from '../../components'; +import { JwtConfigInput, useJwtConfigFlow } from '../../components/jwt-config'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import type { AddHarnessConfig, AdvancedSetting, ContainerMode } from './types'; +import { + ADVANCED_SETTING_OPTIONS, + AUTHORIZER_TYPE_OPTIONS, + CONTAINER_MODE_OPTIONS, + GATEWAY_OUTBOUND_AUTH_OPTIONS, + HARNESS_STEP_LABELS, + MEMORY_OPTIONS, + MODEL_PROVIDER_OPTIONS, + NETWORK_MODE_OPTIONS, + TOOL_SELECT_OPTIONS, + TRUNCATION_STRATEGY_OPTIONS, +} from './types'; +import { useAddHarnessWizard } from './useAddHarnessWizard'; +import React, { useMemo } from 'react'; + +interface AddHarnessScreenProps { + existingHarnessNames: string[]; + onComplete: (config: AddHarnessConfig) => void; + onExit: () => void; +} + +export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: AddHarnessScreenProps) { + const wizard = useAddHarnessWizard(); + + const jwtFlow = useJwtConfigFlow({ + onComplete: jwtConfig => wizard.setJwtConfig(jwtConfig), + onBack: () => wizard.goBack(), + }); + + const modelProviderItems: SelectableItem[] = useMemo( + () => MODEL_PROVIDER_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const containerModeItems: SelectableItem[] = useMemo( + () => CONTAINER_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const advancedSettingItems: SelectableItem[] = useMemo( + () => ADVANCED_SETTING_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const toolSelectItems: SelectableItem[] = useMemo( + () => TOOL_SELECT_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const memoryItems: SelectableItem[] = useMemo( + () => MEMORY_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const networkModeItems: SelectableItem[] = useMemo( + () => NETWORK_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const truncationStrategyItems: SelectableItem[] = useMemo( + () => TRUNCATION_STRATEGY_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const authorizerTypeItems: SelectableItem[] = useMemo( + () => AUTHORIZER_TYPE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const gatewayOutboundAuthItems: SelectableItem[] = useMemo( + () => GATEWAY_OUTBOUND_AUTH_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const isNameStep = wizard.step === 'name'; + const isModelProviderStep = wizard.step === 'model-provider'; + const isApiKeyArnStep = wizard.step === 'api-key-arn'; + const isContainerStep = wizard.step === 'container'; + const isContainerUriStep = wizard.step === 'container-uri'; + const isContainerDockerfileStep = wizard.step === 'container-dockerfile'; + const isAdvancedStep = wizard.step === 'advanced'; + const isToolsSelectStep = wizard.step === 'tools-select'; + const isMcpNameStep = wizard.step === 'mcp-name'; + const isMcpUrlStep = wizard.step === 'mcp-url'; + const isGatewayArnStep = wizard.step === 'gateway-arn'; + const isGatewayOutboundAuthStep = wizard.step === 'gateway-outbound-auth'; + const isGatewayProviderArnStep = wizard.step === 'gateway-provider-arn'; + const isGatewayScopesStep = wizard.step === 'gateway-scopes'; + const isMemoryStep = wizard.step === 'memory'; + const isAuthorizerTypeStep = wizard.step === 'authorizerType'; + const isJwtConfigStep = wizard.step === 'jwtConfig'; + const isNetworkModeStep = wizard.step === 'network-mode'; + const isSubnetsStep = wizard.step === 'subnets'; + const isSecurityGroupsStep = wizard.step === 'security-groups'; + const isIdleTimeoutStep = wizard.step === 'idle-timeout'; + const isMaxLifetimeStep = wizard.step === 'max-lifetime'; + const isMaxIterationsStep = wizard.step === 'max-iterations'; + const isMaxTokensStep = wizard.step === 'max-tokens'; + const isTimeoutStep = wizard.step === 'timeout'; + const isTruncationStrategyStep = wizard.step === 'truncation-strategy'; + const isSessionStoragePathStep = wizard.step === 'session-storage-path'; + const isConfirmStep = wizard.step === 'confirm'; + + const modelProviderNav = useListNavigation({ + items: modelProviderItems, + onSelect: item => wizard.setModelProvider(item.id as HarnessModelProvider), + onExit: () => wizard.goBack(), + isActive: isModelProviderStep, + }); + + const containerModeNav = useListNavigation({ + items: containerModeItems, + onSelect: item => wizard.setContainerMode(item.id as ContainerMode), + onExit: () => wizard.goBack(), + isActive: isContainerStep, + }); + + const advancedSettingsNav = useMultiSelectNavigation({ + items: advancedSettingItems, + getId: item => item.id, + onConfirm: ids => wizard.setAdvancedSettings(ids as AdvancedSetting[]), + onExit: () => wizard.goBack(), + isActive: isAdvancedStep, + requireSelection: false, + }); + + const toolsSelectNav = useMultiSelectNavigation({ + items: toolSelectItems, + getId: item => item.id, + onConfirm: ids => wizard.setSelectedTools(ids), + onExit: () => wizard.goBack(), + isActive: isToolsSelectStep, + requireSelection: false, + }); + + const memoryNav = useListNavigation({ + items: memoryItems, + onSelect: item => wizard.setMemoryEnabled(item.id === 'enabled'), + onExit: () => wizard.goBack(), + isActive: isMemoryStep, + }); + + const authorizerTypeNav = useListNavigation({ + items: authorizerTypeItems, + onSelect: item => wizard.setAuthorizerType(item.id as RuntimeAuthorizerType), + onExit: () => wizard.goBack(), + isActive: isAuthorizerTypeStep, + }); + + const gatewayOutboundAuthNav = useListNavigation({ + items: gatewayOutboundAuthItems, + onSelect: item => wizard.setGatewayOutboundAuth(item.id as 'awsIam' | 'none' | 'oauth'), + onExit: () => wizard.goBack(), + isActive: isGatewayOutboundAuthStep, + }); + + const networkModeNav = useListNavigation({ + items: networkModeItems, + onSelect: item => wizard.setNetworkMode(NetworkModeSchema.parse(item.id)), + onExit: () => wizard.goBack(), + isActive: isNetworkModeStep, + }); + + const truncationStrategyNav = useListNavigation({ + items: truncationStrategyItems, + onSelect: item => wizard.setTruncationStrategy(HarnessTruncationStrategySchema.parse(item.id)), + onExit: () => wizard.goBack(), + isActive: isTruncationStrategyStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = isJwtConfigStep + ? jwtFlow.subStep === 'constraintPicker' + ? HELP_TEXT.MULTI_SELECT + : jwtFlow.subStep === 'customClaims' + ? jwtFlow.claimsManagerMode === 'add' || jwtFlow.claimsManagerMode === 'edit' + ? 'โ†‘/โ†“ field ยท โ†/โ†’ cycle ยท Enter next/save ยท Esc cancel' + : 'Navigate ยท Enter select ยท Esc back' + : HELP_TEXT.TEXT_INPUT + : isAdvancedStep || isToolsSelectStep + ? 'Space toggle ยท Enter confirm ยท Esc back' + : isModelProviderStep || + isMemoryStep || + isContainerStep || + isNetworkModeStep || + isTruncationStrategyStep || + isAuthorizerTypeStep || + isGatewayOutboundAuthStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ; + + const confirmFields = useMemo(() => { + const fields = [ + { label: 'Name', value: wizard.config.name }, + { label: 'Model Provider', value: wizard.config.modelProvider }, + { label: 'Model ID', value: wizard.config.modelId }, + ]; + + if (wizard.config.apiKeyArn) { + fields.push({ label: 'API Key ARN', value: wizard.config.apiKeyArn }); + } + + if (wizard.config.skipMemory !== undefined) { + fields.push({ label: 'Memory', value: wizard.config.skipMemory ? 'Disabled' : 'Enabled' }); + } + + if (wizard.config.authorizerType) { + fields.push({ + label: 'Auth Type', + value: + AUTHORIZER_TYPE_OPTIONS.find(o => o.id === wizard.config.authorizerType)?.title ?? + wizard.config.authorizerType, + }); + } + if (wizard.config.authorizerType === 'CUSTOM_JWT' && wizard.config.jwtConfig) { + fields.push({ label: 'Discovery URL', value: wizard.config.jwtConfig.discoveryUrl }); + if (wizard.config.jwtConfig.allowedAudience?.length) { + fields.push({ label: 'Allowed Audience', value: wizard.config.jwtConfig.allowedAudience.join(', ') }); + } + if (wizard.config.jwtConfig.allowedClients?.length) { + fields.push({ label: 'Allowed Clients', value: wizard.config.jwtConfig.allowedClients.join(', ') }); + } + if (wizard.config.jwtConfig.allowedScopes?.length) { + fields.push({ label: 'Allowed Scopes', value: wizard.config.jwtConfig.allowedScopes.join(', ') }); + } + if (wizard.config.jwtConfig.customClaims?.length) { + fields.push({ + label: 'Custom Claims', + value: `${wizard.config.jwtConfig.customClaims.length} claim(s) configured`, + }); + } + if (wizard.config.jwtConfig.clientId) { + fields.push({ label: 'Harness Credential', value: computeManagedOAuthCredentialName(wizard.config.name) }); + } + } + + if (wizard.config.selectedTools?.length) { + const toolLabels = wizard.config.selectedTools.map(id => TOOL_SELECT_OPTIONS.find(o => o.id === id)?.title ?? id); + fields.push({ label: 'Tools', value: toolLabels.join(', ') }); + if (wizard.config.mcpName) { + fields.push({ label: 'MCP Server', value: `${wizard.config.mcpName} (${wizard.config.mcpUrl})` }); + } + if (wizard.config.gatewayArn) { + fields.push({ label: 'Gateway ARN', value: wizard.config.gatewayArn }); + } + if (wizard.config.gatewayOutboundAuth) { + fields.push({ + label: 'Gateway Auth', + value: + GATEWAY_OUTBOUND_AUTH_OPTIONS.find(o => o.id === wizard.config.gatewayOutboundAuth)?.title ?? + wizard.config.gatewayOutboundAuth, + }); + } + if (wizard.config.gatewayOutboundAuth === 'oauth') { + if (wizard.config.gatewayProviderArn) { + fields.push({ label: 'Provider ARN', value: wizard.config.gatewayProviderArn }); + } + if (wizard.config.gatewayScopes) { + fields.push({ label: 'OAuth Scopes', value: wizard.config.gatewayScopes }); + } + } + } + + if (wizard.config.containerUri) { + fields.push({ label: 'Container URI', value: wizard.config.containerUri }); + } + + if (wizard.config.dockerfilePath) { + fields.push({ label: 'Dockerfile', value: wizard.config.dockerfilePath }); + } + + if (wizard.config.networkMode) { + fields.push({ label: 'Network Mode', value: wizard.config.networkMode }); + if (wizard.config.networkMode === 'VPC') { + if (wizard.config.subnets) { + fields.push({ label: 'Subnets', value: wizard.config.subnets.join(', ') }); + } + if (wizard.config.securityGroups) { + fields.push({ label: 'Security Groups', value: wizard.config.securityGroups.join(', ') }); + } + } + } + + if (wizard.config.idleTimeout !== undefined) { + fields.push({ label: 'Idle Timeout', value: `${wizard.config.idleTimeout}s` }); + } + + if (wizard.config.maxLifetime !== undefined) { + fields.push({ label: 'Max Lifetime', value: `${wizard.config.maxLifetime}s` }); + } + + if (wizard.config.maxIterations !== undefined) { + fields.push({ label: 'Max Iterations', value: String(wizard.config.maxIterations) }); + } + + if (wizard.config.maxTokens !== undefined) { + fields.push({ label: 'Max Tokens', value: String(wizard.config.maxTokens) }); + } + + if (wizard.config.timeoutSeconds !== undefined) { + fields.push({ label: 'Timeout', value: `${wizard.config.timeoutSeconds}s` }); + } + + if (wizard.config.truncationStrategy) { + fields.push({ label: 'Truncation Strategy', value: wizard.config.truncationStrategy }); + } + + if (wizard.config.sessionStoragePath) { + fields.push({ label: 'Session Storage', value: wizard.config.sessionStoragePath }); + } + + return fields; + }, [wizard.config]); + + return ( + + + {isNameStep && ( + !existingHarnessNames.includes(value) || 'Harness name already exists'} + /> + )} + + {isModelProviderStep && ( + + )} + + {isApiKeyArnStep && ( + wizard.goBack()} + customValidation={value => isValidArn(value) || ARN_VALIDATION_MESSAGE} + /> + )} + + {isContainerStep && ( + + )} + + {isContainerUriStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'Container URI is required')} + /> + )} + + {isContainerDockerfileStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'Dockerfile path is required')} + /> + )} + + {isAdvancedStep && ( + + )} + + {isToolsSelectStep && ( + + )} + + {isMcpNameStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'MCP name is required')} + /> + )} + + {isMcpUrlStep && ( + wizard.goBack()} + customValidation={value => + value.startsWith('http://') || value.startsWith('https://') ? true : 'Must be a valid URL' + } + /> + )} + + {isGatewayArnStep && ( + wizard.goBack()} + customValidation={value => (isValidArn(value) ? true : ARN_VALIDATION_MESSAGE)} + /> + )} + + {isGatewayOutboundAuthStep && ( + + )} + + {isGatewayProviderArnStep && ( + wizard.goBack()} + customValidation={value => (isValidArn(value) ? true : ARN_VALIDATION_MESSAGE)} + /> + )} + + {isGatewayScopesStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'At least one scope is required')} + /> + )} + + {isMemoryStep && ( + + )} + + {isAuthorizerTypeStep && ( + + )} + + {isJwtConfigStep && ( + + )} + + {isNetworkModeStep && ( + + )} + + {isSubnetsStep && ( + wizard.goBack()} + customValidation={value => + value.trim().length > 0 ? true : 'At least one subnet is required for VPC mode' + } + /> + )} + + {isSecurityGroupsStep && ( + wizard.goBack()} + customValidation={value => + value.trim().length > 0 ? true : 'At least one security group is required for VPC mode' + } + /> + )} + + {isIdleTimeoutStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num >= 60 && num <= 28800 ? true : 'Must be between 60 and 28800'; + }} + /> + )} + + {isMaxLifetimeStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num >= 60 && num <= 28800 ? true : 'Must be between 60 and 28800'; + }} + /> + )} + + {isMaxIterationsStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isMaxTokensStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isTimeoutStep && ( + wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive number'; + }} + /> + )} + + {isTruncationStrategyStep && ( + + )} + + {isSessionStoragePathStep && ( + wizard.goBack()} + customValidation={value => (value.startsWith('/') ? true : 'Must be an absolute path')} + /> + )} + + {isConfirmStep && } + + + ); +} diff --git a/src/cli/tui/screens/harness/index.ts b/src/cli/tui/screens/harness/index.ts new file mode 100644 index 000000000..b2af2e47e --- /dev/null +++ b/src/cli/tui/screens/harness/index.ts @@ -0,0 +1,3 @@ +export { AddHarnessFlow } from './AddHarnessFlow'; +export { AddHarnessScreen } from './AddHarnessScreen'; +export type { AddHarnessConfig, AddHarnessStep } from './types'; diff --git a/src/cli/tui/screens/harness/types.ts b/src/cli/tui/screens/harness/types.ts new file mode 100644 index 000000000..e5166d1bd --- /dev/null +++ b/src/cli/tui/screens/harness/types.ts @@ -0,0 +1,174 @@ +import type { HarnessModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; +import type { JwtConfig } from '../../components/jwt-config'; + +export type ContainerMode = 'none' | 'uri' | 'dockerfile'; + +export type AddHarnessStep = + | 'name' + | 'model-provider' + | 'api-key-arn' + | 'container' + | 'container-uri' + | 'container-dockerfile' + | 'advanced' + | 'tools-select' + | 'mcp-name' + | 'mcp-url' + | 'gateway-arn' + | 'gateway-outbound-auth' + | 'gateway-provider-arn' + | 'gateway-scopes' + | 'memory' + | 'authorizerType' + | 'jwtConfig' + | 'network-mode' + | 'subnets' + | 'security-groups' + | 'idle-timeout' + | 'max-lifetime' + | 'max-iterations' + | 'max-tokens' + | 'timeout' + | 'truncation-strategy' + | 'session-storage-path' + | 'confirm'; + +export interface AddHarnessConfig { + name: string; + modelProvider: HarnessModelProvider; + modelId: string; + apiKeyArn?: string; + skipMemory?: boolean; + containerMode?: ContainerMode; + containerUri?: string; + dockerfilePath?: string; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + truncationStrategy?: 'sliding_window' | 'summarization'; + networkMode?: NetworkMode; + subnets?: string[]; + securityGroups?: string[]; + idleTimeout?: number; + maxLifetime?: number; + sessionStoragePath?: string; + authorizerType?: RuntimeAuthorizerType; + jwtConfig?: JwtConfig; + selectedTools?: string[]; + mcpName?: string; + mcpUrl?: string; + gatewayArn?: string; + gatewayOutboundAuth?: 'awsIam' | 'none' | 'oauth'; + gatewayProviderArn?: string; + gatewayScopes?: string; +} + +export const HARNESS_STEP_LABELS: Record = { + name: 'Name', + 'model-provider': 'Model provider', + 'api-key-arn': 'API key ARN', + container: 'Custom environment', + 'container-uri': 'Container URI', + 'container-dockerfile': 'Dockerfile path', + advanced: 'Advanced settings', + 'tools-select': 'Tools', + 'mcp-name': 'MCP name', + 'mcp-url': 'MCP URL', + 'gateway-arn': 'Gateway ARN', + 'gateway-outbound-auth': 'Gateway auth', + 'gateway-provider-arn': 'Provider ARN', + 'gateway-scopes': 'OAuth scopes', + memory: 'Memory', + authorizerType: 'Auth type', + jwtConfig: 'JWT config', + 'network-mode': 'Network mode', + subnets: 'Subnets', + 'security-groups': 'Security groups', + 'idle-timeout': 'Idle timeout', + 'max-lifetime': 'Max lifetime', + 'max-iterations': 'Max iterations', + 'max-tokens': 'Max tokens', + timeout: 'Timeout', + 'truncation-strategy': 'Truncation', + 'session-storage-path': 'Session storage path', + confirm: 'Confirm', +}; + +export const DEFAULT_MODEL_IDS: Record = { + bedrock: 'global.anthropic.claude-sonnet-4-6', + open_ai: 'gpt-5', + gemini: 'gemini-2.5-flash', +}; + +export const MODEL_PROVIDER_OPTIONS = [ + { id: 'bedrock' as const, title: 'Amazon Bedrock', description: `Default: ${DEFAULT_MODEL_IDS.bedrock}` }, + { + id: 'open_ai' as const, + title: 'OpenAI', + description: `Default: ${DEFAULT_MODEL_IDS.open_ai} (requires API key ARN)`, + }, + { + id: 'gemini' as const, + title: 'Google Gemini', + description: `Default: ${DEFAULT_MODEL_IDS.gemini} (requires API key ARN)`, + }, +] as const; + +export const TRUNCATION_STRATEGY_OPTIONS = [ + { id: 'sliding_window' as const, title: 'Sliding window', description: 'Keep most recent messages' }, + { id: 'summarization' as const, title: 'Summarization', description: 'Compress older context' }, +] as const; + +export const ADVANCED_SETTING_OPTIONS = [ + { id: 'tools', title: 'Tools', description: 'Add browser, code interpreter, MCP, or gateway tools' }, + { id: 'auth', title: 'Authentication', description: 'Inbound auth: AWS_IAM or Custom JWT' }, + { id: 'network', title: 'Network', description: 'Deploy inside a VPC with custom subnets and security groups' }, + { id: 'lifecycle', title: 'Lifecycle', description: 'Set idle timeout and max session lifetime' }, + { id: 'execution', title: 'Execution limits', description: 'Cap iterations, tokens, and per-turn timeout' }, + { id: 'truncation', title: 'Truncation', description: 'Choose how context is managed when it exceeds limits' }, + { id: 'session-storage', title: 'Session Storage', description: 'Mount persistent storage for session data' }, +] as const; + +export type AdvancedSetting = (typeof ADVANCED_SETTING_OPTIONS)[number]['id']; + +export const MEMORY_OPTIONS = [ + { + id: 'disabled' as const, + title: 'No persistent memory', + description: 'Harness does not retain context across sessions', + }, + { id: 'enabled' as const, title: 'Enabled', description: 'Create persistent memory for this harness' }, +] as const; + +export const CONTAINER_MODE_OPTIONS = [ + { id: 'none' as const, title: 'Default Environment', description: 'Includes Python, Bash, File tools' }, + { id: 'uri' as const, title: 'Container URI', description: 'Use a pre-built container image (ECR URI)' }, + { id: 'dockerfile' as const, title: 'Dockerfile', description: 'Bring your own Dockerfile' }, +] as const; + +export const TOOL_SELECT_OPTIONS = [ + { id: 'agentcore_browser' as const, title: 'AgentCore Browser', description: 'Web browsing and automation' }, + { + id: 'agentcore_code_interpreter' as const, + title: 'AgentCore Code Interpreter', + description: 'Sandboxed code execution', + }, + { id: 'agentcore_gateway' as const, title: 'AgentCore Gateway', description: 'Connect via gateway' }, + { id: 'remote_mcp' as const, title: 'Remote MCP Server', description: 'Connect to an MCP server' }, +] as const; + +export const NETWORK_MODE_OPTIONS = [ + { id: 'PUBLIC' as const, title: 'Public', description: 'Internet-facing' }, + { id: 'VPC' as const, title: 'VPC', description: 'Deploy within a VPC' }, +] as const; + +export const AUTHORIZER_TYPE_OPTIONS = [ + { id: 'AWS_IAM' as const, title: 'AWS IAM', description: 'Use AWS IAM authentication (default)' }, + { id: 'CUSTOM_JWT' as const, title: 'Custom JWT', description: 'Use a custom JWT authorizer (OIDC)' }, +] as const; + +export const GATEWAY_OUTBOUND_AUTH_OPTIONS = [ + { id: 'awsIam', title: 'AWS IAM (default)', description: 'SigV4 signing with the harness execution role' }, + { id: 'none', title: 'None', description: 'No authentication headers' }, + { id: 'oauth', title: 'OAuth', description: 'Bearer token via AgentCore Identity credential provider' }, +]; diff --git a/src/cli/tui/screens/harness/useAddHarnessWizard.ts b/src/cli/tui/screens/harness/useAddHarnessWizard.ts new file mode 100644 index 000000000..13325fe35 --- /dev/null +++ b/src/cli/tui/screens/harness/useAddHarnessWizard.ts @@ -0,0 +1,454 @@ +import type { HarnessModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; +import type { JwtConfig } from '../../components/jwt-config'; +import type { AddHarnessConfig, AddHarnessStep, AdvancedSetting, ContainerMode } from './types'; +import { DEFAULT_MODEL_IDS } from './types'; +import { useCallback, useMemo, useState } from 'react'; + +const ADVANCED_SETTING_ORDER: AdvancedSetting[] = [ + 'tools', + 'auth', + 'network', + 'lifecycle', + 'execution', + 'truncation', + 'session-storage', +]; + +const SETTING_TO_FIRST_STEP: Record = { + tools: 'tools-select', + auth: 'authorizerType', + network: 'network-mode', + lifecycle: 'idle-timeout', + execution: 'max-iterations', + truncation: 'truncation-strategy', + 'session-storage': 'session-storage-path', +}; + +function getFirstAdvancedStep(settings: AdvancedSetting[]): AddHarnessStep | undefined { + for (const setting of ADVANCED_SETTING_ORDER) { + if (settings.includes(setting)) return SETTING_TO_FIRST_STEP[setting]; + } + return undefined; +} + +function getNextAdvancedStep(settings: AdvancedSetting[], after: AdvancedSetting): AddHarnessStep | undefined { + const idx = ADVANCED_SETTING_ORDER.indexOf(after); + const remaining = ADVANCED_SETTING_ORDER.slice(idx + 1); + for (const setting of remaining) { + if (settings.includes(setting)) return SETTING_TO_FIRST_STEP[setting]; + } + return undefined; +} + +function getDefaultConfig(): AddHarnessConfig { + return { + name: '', + modelProvider: 'bedrock', + modelId: DEFAULT_MODEL_IDS.bedrock, + }; +} + +export function useAddHarnessWizard() { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('name'); + const [advancedSettings, setAdvancedSettingsState] = useState([]); + + const allSteps = useMemo(() => { + const steps: AddHarnessStep[] = ['name', 'model-provider']; + + if (config.modelProvider !== 'bedrock') { + steps.push('api-key-arn'); + } + + steps.push('container'); + if (config.containerMode === 'uri') { + steps.push('container-uri'); + } else if (config.containerMode === 'dockerfile') { + steps.push('container-dockerfile'); + } + + steps.push('memory'); + + steps.push('advanced'); + + if (advancedSettings.includes('tools')) { + steps.push('tools-select'); + if (config.selectedTools?.includes('remote_mcp')) { + steps.push('mcp-name', 'mcp-url'); + } + if (config.selectedTools?.includes('agentcore_gateway')) { + steps.push('gateway-arn'); + steps.push('gateway-outbound-auth'); + if (config.gatewayOutboundAuth === 'oauth') { + steps.push('gateway-provider-arn', 'gateway-scopes'); + } + } + } + + if (advancedSettings.includes('auth')) { + steps.push('authorizerType'); + if (config.authorizerType === 'CUSTOM_JWT') { + steps.push('jwtConfig'); + } + } + + if (advancedSettings.includes('network')) { + steps.push('network-mode'); + if (config.networkMode === 'VPC') { + steps.push('subnets', 'security-groups'); + } + } + + if (advancedSettings.includes('lifecycle')) { + steps.push('idle-timeout', 'max-lifetime'); + } + + if (advancedSettings.includes('execution')) { + steps.push('max-iterations', 'max-tokens', 'timeout'); + } + + if (advancedSettings.includes('truncation')) { + steps.push('truncation-strategy'); + } + + if (advancedSettings.includes('session-storage')) { + steps.push('session-storage-path'); + } + + steps.push('confirm'); + + return steps; + }, [ + config.modelProvider, + config.containerMode, + config.authorizerType, + config.networkMode, + config.selectedTools, + config.gatewayOutboundAuth, + advancedSettings, + ]); + + const currentIndex = allSteps.indexOf(step); + + const goBack = useCallback(() => { + const idx = allSteps.indexOf(step); + const prevStep = allSteps[idx - 1]; + if (prevStep) setStep(prevStep); + }, [allSteps, step]); + + const nextStep = useCallback( + (currentStep: AddHarnessStep): AddHarnessStep | undefined => { + const idx = allSteps.indexOf(currentStep); + return allSteps[idx + 1]; + }, + [allSteps] + ); + + const setName = useCallback( + (name: string) => { + setConfig(c => ({ ...c, name })); + const next = nextStep('name'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setModelProvider = useCallback((modelProvider: HarnessModelProvider) => { + setConfig(c => ({ ...c, modelProvider, modelId: DEFAULT_MODEL_IDS[modelProvider] })); + if (modelProvider !== 'bedrock') { + setStep('api-key-arn'); + } else { + setStep('container'); + } + }, []); + + const setApiKeyArn = useCallback( + (apiKeyArn: string) => { + setConfig(c => ({ ...c, apiKeyArn })); + const next = nextStep('api-key-arn'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setContainerMode = useCallback((containerMode: ContainerMode) => { + setConfig(c => ({ ...c, containerMode, containerUri: undefined, dockerfilePath: undefined })); + if (containerMode === 'uri') { + setStep('container-uri'); + } else if (containerMode === 'dockerfile') { + setStep('container-dockerfile'); + } else { + setStep('memory'); + } + }, []); + + const setContainerUri = useCallback( + (containerUri: string) => { + setConfig(c => ({ ...c, containerUri })); + const next = nextStep('container-uri'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setDockerfilePath = useCallback( + (dockerfilePath: string) => { + setConfig(c => ({ ...c, dockerfilePath })); + const next = nextStep('container-dockerfile'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setAdvancedSettings = useCallback((settings: AdvancedSetting[]) => { + setAdvancedSettingsState(settings); + const firstAdvancedStep = getFirstAdvancedStep(settings); + setStep(firstAdvancedStep ?? 'confirm'); + }, []); + + const setSelectedTools = useCallback( + (selectedTools: string[]) => { + setConfig(c => ({ ...c, selectedTools })); + if (selectedTools.includes('remote_mcp')) { + setStep('mcp-name'); + } else if (selectedTools.includes('agentcore_gateway')) { + setStep('gateway-arn'); + } else { + const next = getNextAdvancedStep(advancedSettings, 'tools'); + setStep(next ?? 'confirm'); + } + }, + [advancedSettings] + ); + + const setMcpName = useCallback( + (mcpName: string) => { + setConfig(c => ({ ...c, mcpName })); + const next = nextStep('mcp-name'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMcpUrl = useCallback( + (mcpUrl: string) => { + setConfig(c => ({ ...c, mcpUrl })); + if (config.selectedTools?.includes('agentcore_gateway')) { + setStep('gateway-arn'); + } else { + const next = getNextAdvancedStep(advancedSettings, 'tools'); + setStep(next ?? 'confirm'); + } + }, + [advancedSettings, config.selectedTools] + ); + + const setGatewayArn = useCallback((gatewayArn: string) => { + setConfig(c => ({ ...c, gatewayArn })); + setStep('gateway-outbound-auth'); + }, []); + + const setGatewayOutboundAuth = useCallback( + (authType: 'awsIam' | 'none' | 'oauth') => { + setConfig(c => ({ ...c, gatewayOutboundAuth: authType })); + if (authType === 'oauth') { + setStep('gateway-provider-arn'); + } else { + const next = getNextAdvancedStep(advancedSettings, 'tools'); + setStep(next ?? 'confirm'); + } + }, + [advancedSettings] + ); + + const setGatewayProviderArn = useCallback((gatewayProviderArn: string) => { + setConfig(c => ({ ...c, gatewayProviderArn })); + setStep('gateway-scopes'); + }, []); + + const setGatewayScopes = useCallback( + (gatewayScopes: string) => { + setConfig(c => ({ ...c, gatewayScopes })); + const next = getNextAdvancedStep(advancedSettings, 'tools'); + setStep(next ?? 'confirm'); + }, + [advancedSettings] + ); + + const setMemoryEnabled = useCallback((enabled: boolean) => { + setConfig(c => ({ ...c, skipMemory: !enabled })); + setStep('advanced'); + }, []); + + const setAuthorizerType = useCallback( + (authorizerType: RuntimeAuthorizerType) => { + setConfig(c => ({ ...c, authorizerType, jwtConfig: undefined })); + if (authorizerType === 'CUSTOM_JWT') { + setStep('jwtConfig'); + } else { + const next = getNextAdvancedStep(advancedSettings, 'auth'); + setStep(next ?? 'confirm'); + } + }, + [advancedSettings] + ); + + const setJwtConfig = useCallback( + (jwtConfig: JwtConfig) => { + setConfig(c => ({ ...c, jwtConfig })); + const next = getNextAdvancedStep(advancedSettings, 'auth'); + setStep(next ?? 'confirm'); + }, + [advancedSettings] + ); + + const setNetworkMode = useCallback( + (networkMode: NetworkMode) => { + setConfig(c => ({ ...c, networkMode })); + if (networkMode === 'VPC') { + setStep('subnets'); + } else { + const next = getNextAdvancedStep(advancedSettings, 'network'); + setStep(next ?? 'confirm'); + } + }, + [advancedSettings] + ); + + const setSubnets = useCallback( + (subnetsStr: string) => { + const subnets = subnetsStr + .split(',') + .map(s => s.trim()) + .filter(Boolean); + setConfig(c => ({ ...c, subnets })); + const next = nextStep('subnets'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setSecurityGroups = useCallback( + (sgStr: string) => { + const securityGroups = sgStr + .split(',') + .map(s => s.trim()) + .filter(Boolean); + setConfig(c => ({ ...c, securityGroups })); + const next = nextStep('security-groups'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setIdleTimeout = useCallback( + (idleTimeoutStr: string) => { + const idleTimeout = parseInt(idleTimeoutStr, 10); + setConfig(c => ({ ...c, idleTimeout })); + const next = nextStep('idle-timeout'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMaxLifetime = useCallback( + (maxLifetimeStr: string) => { + const maxLifetime = parseInt(maxLifetimeStr, 10); + setConfig(c => ({ ...c, maxLifetime })); + const next = nextStep('max-lifetime'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMaxIterations = useCallback( + (maxIterationsStr: string) => { + const maxIterations = parseInt(maxIterationsStr, 10); + setConfig(c => ({ ...c, maxIterations })); + const next = nextStep('max-iterations'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMaxTokens = useCallback( + (maxTokensStr: string) => { + const maxTokens = parseInt(maxTokensStr, 10); + setConfig(c => ({ ...c, maxTokens })); + const next = nextStep('max-tokens'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setTimeoutSeconds = useCallback( + (timeoutStr: string) => { + const timeoutSeconds = parseInt(timeoutStr, 10); + setConfig(c => ({ ...c, timeoutSeconds })); + const next = nextStep('timeout'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setTruncationStrategy = useCallback( + (truncationStrategy: 'sliding_window' | 'summarization') => { + setConfig(c => ({ ...c, truncationStrategy })); + const next = nextStep('truncation-strategy'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setSessionStoragePath = useCallback( + (sessionStoragePath: string) => { + setConfig(c => ({ ...c, sessionStoragePath })); + const next = nextStep('session-storage-path'); + if (next) setStep(next); + }, + [nextStep] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('name'); + setAdvancedSettingsState([]); + }, []); + + return { + config, + step, + steps: allSteps, + currentIndex, + advancedSettings, + goBack, + setName, + setModelProvider, + setApiKeyArn, + setContainerMode, + setContainerUri, + setDockerfilePath, + setAdvancedSettings, + setSelectedTools, + setMcpName, + setMcpUrl, + setGatewayArn, + setGatewayOutboundAuth, + setGatewayProviderArn, + setGatewayScopes, + setMemoryEnabled, + setAuthorizerType, + setJwtConfig, + setNetworkMode, + setSubnets, + setSecurityGroups, + setIdleTimeout, + setMaxLifetime, + setMaxIterations, + setMaxTokens, + setTimeoutSeconds, + setTruncationStrategy, + setSessionStoragePath, + reset, + }; +} diff --git a/src/cli/tui/screens/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index b4c7557d0..3e87d09b0 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -1,3 +1,4 @@ +import { isPreviewEnabled } from '../../../feature-flags'; import { buildTraceConsoleUrl } from '../../../operations/traces'; import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components'; import { setExitMessage } from '../../exit-message'; @@ -9,12 +10,16 @@ interface InvokeScreenProps { /** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */ isInteractive: boolean; onExit: () => void; + /** Override the screen title (defaults to "AgentCore Invoke") */ + title?: string; initialPrompt?: string; initialSessionId?: string; initialUserId?: string; /** Custom headers to forward to the agent runtime on every invocation */ initialHeaders?: Record; initialBearerToken?: string; + /** Pre-select a harness by name, skipping the agent selection screen (preview) */ + initialHarnessName?: string; } type Mode = 'select-agent' | 'chat' | 'input' | 'token-input'; @@ -49,6 +54,8 @@ function formatConversation( lines.push({ text: `> ${msg.content}`, color: 'blue' }); } else if (msg.isExec) { lines.push({ text: msg.content }); + } else if (msg.isHint) { + lines.push({ text: msg.content, color: 'gray' }); } else if (msg.parts && msg.parts.length > 0) { // Rich AGUI rendering: render each part with distinct visual treatment for (const part of msg.parts) { @@ -133,12 +140,15 @@ function wrapColoredLines(lines: ColoredLine[], maxWidth: number): ColoredLine[] export function InvokeScreen({ isInteractive: _isInteractive, onExit, + title: screenTitle = 'AgentCore Invoke', initialPrompt, initialSessionId, initialUserId, initialHeaders, initialBearerToken, + initialHarnessName, }: InvokeScreenProps) { + const preview = isPreviewEnabled(); const { phase, config, @@ -158,8 +168,14 @@ export function InvokeScreen({ execCommand, newSession, fetchMcpTools, - } = useInvokeFlow({ initialSessionId, initialUserId, headers: initialHeaders, initialBearerToken }); - const [mode, setMode] = useState('select-agent'); + } = useInvokeFlow({ + initialSessionId, + initialUserId, + headers: initialHeaders, + initialBearerToken, + initialHarnessName, + }); + const [mode, setMode] = useState(initialHarnessName ? 'input' : 'select-agent'); const [isExecInput, setIsExecInput] = useState(false); const [execInputEmpty, setExecInputEmpty] = useState(true); const [scrollOffset, setScrollOffset] = useState(0); @@ -177,16 +193,21 @@ export function InvokeScreen({ }, [sessionId, messages.length]); // Compute auth type early so hooks can reference it - const currentAgent = config?.runtimes[selectedAgent]; - const isCustomJwt = currentAgent?.authorizerType === 'CUSTOM_JWT'; - - // Handle initial prompt - skip agent selection if only one agent + const totalInvokables = (config?.runtimes.length ?? 0) + (preview ? (config?.harnesses.length ?? 0) : 0); + const runtimeCount = config?.runtimes.length ?? 0; + const currentAgent = selectedAgent < runtimeCount ? config?.runtimes[selectedAgent] : undefined; + const currentHarness = + preview && selectedAgent >= runtimeCount ? config?.harnesses[selectedAgent - runtimeCount] : undefined; + const isCustomJwt = (currentAgent?.authorizerType ?? currentHarness?.authorizerType) === 'CUSTOM_JWT'; + + // Handle initial prompt - skip agent selection if only one invokable useEffect(() => { if (config && phase === 'ready') { - if (config.runtimes.length === 1 && mode === 'select-agent') { + if (totalInvokables === 1 && mode === 'select-agent') { const agent = config.runtimes[0]; - const needsTokenScreen = agent?.authorizerType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; - // Defer setState to avoid cascading renders within effect + const harness = config.runtimes.length === 0 ? config.harnesses[0] : undefined; + const authType = agent?.authorizerType ?? harness?.authorizerType; + const needsTokenScreen = authType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; queueMicrotask(() => { setMode(needsTokenScreen ? 'token-input' : 'input'); }); @@ -195,7 +216,22 @@ export function InvokeScreen({ } } } - }, [config, phase, initialPrompt, messages.length, invoke, mode, bearerToken, initialBearerToken]); + }, [config, phase, initialPrompt, messages.length, invoke, mode, bearerToken, initialBearerToken, totalInvokables]); + + // When entering via initialHarnessName (dev mode), redirect to token-input once config loads + useEffect(() => { + if ( + initialHarnessName && + config && + phase === 'ready' && + mode === 'input' && + isCustomJwt && + !bearerToken && + !initialBearerToken + ) { + queueMicrotask(() => setMode('token-input')); + } + }, [initialHarnessName, config, phase, mode, isCustomJwt, bearerToken, initialBearerToken]); // Auto-exit when prompt was provided upfront and response completes useEffect(() => { @@ -282,11 +318,16 @@ export function InvokeScreen({ onExit(); return; } - if (key.upArrow) selectAgent((selectedAgent - 1 + config.runtimes.length) % config.runtimes.length); - if (key.downArrow) selectAgent((selectedAgent + 1) % config.runtimes.length); + if (key.upArrow) selectAgent((selectedAgent - 1 + totalInvokables) % totalInvokables); + if (key.downArrow) selectAgent((selectedAgent + 1) % totalInvokables); if (key.return) { const chosen = config.runtimes[selectedAgent]; - const needsTokenScreen = chosen?.authorizerType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; + const chosenHarness = + preview && selectedAgent >= config.runtimes.length + ? config.harnesses[selectedAgent - config.runtimes.length] + : undefined; + const authType = chosen?.authorizerType ?? chosenHarness?.authorizerType; + const needsTokenScreen = authType === 'CUSTOM_JWT' && !bearerToken && !initialBearerToken; setMode(needsTokenScreen ? 'token-input' : 'input'); } return; @@ -299,7 +340,7 @@ export function InvokeScreen({ justCancelledRef.current = false; return; } - if (config.runtimes.length > 1) { + if (totalInvokables > 1) { setMode('select-agent'); return; } @@ -316,7 +357,7 @@ export function InvokeScreen({ } // New session - if (input === 'n' && phase === 'ready') { + if (key.ctrl && input === 'n' && phase === 'ready') { newSession(); setScrollOffset(0); setUserScrolled(false); @@ -352,7 +393,7 @@ export function InvokeScreen({ // Error state - show error in main screen if (phase === 'error') { return ( - + {error} ); @@ -363,7 +404,10 @@ export function InvokeScreen({ return null; } - const agent = config.runtimes[selectedAgent]; + const isHarnessSelected = preview && selectedAgent >= config.runtimes.length; + const agent = isHarnessSelected ? undefined : config.runtimes[selectedAgent]; + const selectedHarness = isHarnessSelected ? config.harnesses[selectedAgent - config.runtimes.length] : undefined; + const selectedName = agent?.name ?? selectedHarness?.name; const traceUrl = mode !== 'select-agent' && agent?.supportsTraces ? buildTraceConsoleUrl({ @@ -373,18 +417,27 @@ export function InvokeScreen({ agentName: agent.name, }) : undefined; - const agentProtocol = agent?.protocol ?? 'HTTP'; - - const agentItems = config.runtimes.map((a, i) => ({ - id: String(i), - title: a.name, - description: `${a.protocol && a.protocol !== 'HTTP' ? `${a.protocol} ยท ` : ''}Runtime: ${a.state.runtimeId}`, - })); - - const isMcp = agentProtocol === 'MCP'; + const agentProtocol = isHarnessSelected ? undefined : (agent?.protocol ?? 'HTTP'); + + const agentItems = [ + ...config.runtimes.map((a, i) => ({ + id: String(i), + title: a.name, + description: `${a.protocol && a.protocol !== 'HTTP' ? `${a.protocol} ยท ` : ''}Agent`, + })), + ...(preview + ? config.harnesses.map((h, i) => ({ + id: String(config.runtimes.length + i), + title: h.name, + description: 'Harness', + })) + : []), + ]; + + const isMcp = !isHarnessSelected && agentProtocol === 'MCP'; // Dynamic help text - const backOrQuit = config.runtimes.length > 1 ? 'Esc back' : 'Esc quit'; + const backOrQuit = totalInvokables > 1 ? 'Esc back' : 'Esc quit'; const helpText = mode === 'select-agent' ? 'โ†‘โ†“ select ยท Enter confirm ยท Esc quit' @@ -399,9 +452,9 @@ export function InvokeScreen({ : phase === 'invoking' ? 'โ†‘โ†“ scroll' : messages.length > 0 - ? `โ†‘โ†“ scroll ยท Enter invoke ยท N new session ยท ${backOrQuit}` + ? `โ†‘โ†“ scroll ยท Enter invoke ยท Ctrl+N new session ยท ${backOrQuit}` : isMcp - ? `Enter to call a tool ยท N new session ยท ${backOrQuit}` + ? `Enter to call a tool ยท Ctrl+N new session ยท ${backOrQuit}` : `Enter to send a message ยท ${backOrQuit}`; const headerContent = ( @@ -412,11 +465,11 @@ export function InvokeScreen({ {mode !== 'select-agent' && ( - Agent: - {agent?.name} + {isHarnessSelected ? 'Harness: ' : 'Agent: '} + {selectedName} )} - {mode !== 'select-agent' && agentProtocol !== 'HTTP' && ( + {mode !== 'select-agent' && !isHarnessSelected && agentProtocol && agentProtocol !== 'HTTP' && ( Protocol: {agentProtocol} @@ -453,18 +506,21 @@ export function InvokeScreen({ )} {traceUrl && Note: Traces may take 2-3 minutes to appear in CloudWatch} - {mode !== 'select-agent' && agent?.networkMode === 'VPC' && ( + {mode !== 'select-agent' && !isHarnessSelected && agent?.networkMode === 'VPC' && ( This agent uses VPC network mode. Ensure your VPC endpoints are configured for invocation. )} + {mode !== 'select-agent' && isHarnessSelected && screenTitle === 'Dev' && ( + If you changed the harness config, redeploy to pick up changes: agentcore deploy + )} ); // Agent selection mode if (mode === 'select-agent') { return ( - + @@ -481,7 +537,7 @@ export function InvokeScreen({ return ( ))} {/* Thinking indicator - shows while waiting for response to start */} - {showThinking && } + {showThinking && } )} {/* Scroll indicator */} {needsScroll && ( - [{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}] + {effectiveOffset > 0 ? 'โ–ฒ ' : ' '} + โ†‘โ†“ scroll + {effectiveOffset < maxScroll ? ' โ–ผ' : ' '} )} diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 25dc838ab..953d44d2f 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -2,6 +2,7 @@ import { ConfigIO } from '../../../../lib'; import type { AgentCoreDeployedState, AwsDeploymentTarget, + HarnessDeployedState, ModelProvider, NetworkMode, ProtocolMode, @@ -20,10 +21,17 @@ import { mcpCallTool, mcpListTools, } from '../../../aws'; +import { invokeHarness } from '../../../aws/agentcore-harness'; import { getErrorMessage } from '../../../errors'; +import { isPreviewEnabled } from '../../../feature-flags'; import { InvokeLogger } from '../../../logging'; import { formatMcpToolList } from '../../../operations/dev/utils'; -import { canFetchRuntimeToken, fetchRuntimeToken } from '../../../operations/fetch-access'; +import { + canFetchHarnessToken, + canFetchRuntimeToken, + fetchHarnessToken, + fetchRuntimeToken, +} from '../../../operations/fetch-access'; import { generateSessionId } from '../../../operations/session'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -45,6 +53,11 @@ export interface InvokeConfig { baggage?: string; supportsTraces: boolean; }[]; + harnesses: { + name: string; + state: HarnessDeployedState; + authorizerType?: RuntimeAuthorizerType; + }[]; target: AwsDeploymentTarget; targetName: string; projectName: string; @@ -56,6 +69,8 @@ export interface InvokeFlowOptions { /** Custom headers to forward to the agent runtime on every invocation */ headers?: Record; initialBearerToken?: string; + /** Pre-select a harness by name, skipping the agent selection screen (preview) */ + initialHarnessName?: string; } export type TokenFetchState = 'idle' | 'fetching' | 'fetched' | 'error'; @@ -86,7 +101,7 @@ export interface InvokeFlowState { } export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState { - const { initialSessionId, initialUserId, headers, initialBearerToken } = options; + const { initialSessionId, initialUserId, headers, initialBearerToken, initialHarnessName } = options; const [phase, setPhase] = useState<'loading' | 'ready' | 'invoking' | 'error'>('loading'); const [config, setConfig] = useState(null); const [selectedAgent, setSelectedAgent] = useState(0); @@ -169,13 +184,36 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState }); } - if (runtimes.length === 0) { - setError('No deployed agents found. Run `agentcore deploy` first.'); + const harnesses: InvokeConfig['harnesses'] = []; + if (isPreviewEnabled()) { + for (const harness of project.harnesses ?? []) { + const state = targetState?.resources?.harnesses?.[harness.name]; + if (!state) continue; + let authorizerType: RuntimeAuthorizerType | undefined; + try { + const spec = await configIO.readHarnessSpec(harness.name); + authorizerType = spec.authorizerType; + } catch { + // spec read is best-effort + } + harnesses.push({ name: harness.name, state, authorizerType }); + } + } + + if (runtimes.length === 0 && harnesses.length === 0) { + setError('No deployed agents or harnesses found. Run `agentcore deploy` first.'); setPhase('error'); return; } - setConfig({ runtimes, target: targetConfig, targetName, projectName: project.name }); + setConfig({ runtimes, harnesses, target: targetConfig, targetName, projectName: project.name }); + + if (initialHarnessName) { + const harnessIdx = harnesses.findIndex(h => h.name === initialHarnessName); + if (harnessIdx >= 0) { + setSelectedAgent(runtimes.length + harnessIdx); + } + } // Initialize session ID - always generate fresh unless explicitly provided if (initialSessionId) { @@ -192,7 +230,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState } }; void load(); - }, [initialSessionId]); + }, [initialSessionId, initialHarnessName]); const getMcpInvokeOptions = useCallback(() => { if (!config) return null; @@ -232,15 +270,23 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const fetchBearerToken = useCallback(async () => { if (!config) return; - const agent = config.runtimes[selectedAgent]; - if (agent?.authorizerType !== 'CUSTOM_JWT') return; + + const isHarnessSelected = selectedAgent >= config.runtimes.length; + const agent = isHarnessSelected ? undefined : config.runtimes[selectedAgent]; + const harness = isHarnessSelected ? config.harnesses[selectedAgent - config.runtimes.length] : undefined; + const selectedAuthType = agent?.authorizerType ?? harness?.authorizerType; + const selectedName = agent?.name ?? harness?.name; + + if (selectedAuthType !== 'CUSTOM_JWT' || !selectedName) return; // Check if credentials are set up before attempting fetch - const canFetch = await canFetchRuntimeToken(agent.name); + const canFetch = isHarnessSelected + ? await canFetchHarnessToken(selectedName) + : await canFetchRuntimeToken(selectedName); if (!canFetch) { setTokenFetchState('error'); setTokenFetchError( - 'No OAuth credentials configured for auto-fetch. Press T to enter a bearer token manually, or re-add the agent with --client-id and --client-secret.' + 'No OAuth credentials configured for auto-fetch. Press T to enter a bearer token manually, or re-add with --client-id and --client-secret.' ); return; } @@ -248,7 +294,9 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState setTokenFetchState('fetching'); setTokenFetchError(null); try { - const result = await fetchRuntimeToken(agent.name, { deployTarget: config.targetName }); + const result = isHarnessSelected + ? await fetchHarnessToken(selectedName, { deployTarget: config.targetName }) + : await fetchRuntimeToken(selectedName, { deployTarget: config.targetName }); setBearerToken(result.token); setTokenExpiresIn(result.expiresIn); setTokenFetchState('fetched'); @@ -261,20 +309,158 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState // Track current streaming content to avoid stale closure issues const streamingContentRef = useRef(''); + const streamHarnessInvoke = useCallback( + async ( + region: string, + harnessArn: string, + runtimeSessionId: string, + harnessMessages: { role: string; content: Record[] }[] + ) => { + const logger = loggerRef.current; + let pendingToolUseId: string | undefined; + let pendingToolName: string | undefined; + let pendingToolInput = ''; + let lastMetadata: { inputTokens: number; outputTokens: number; latencyMs: number } | null = null; + + try { + const stream = invokeHarness({ + region, + harnessArn, + runtimeSessionId, + messages: harnessMessages, + bearerToken: bearerToken || undefined, + }); + + for await (const event of stream) { + switch (event.type) { + case 'contentBlockDelta': + if (event.delta.type === 'text') { + streamingContentRef.current += event.delta.text; + const currentContent = streamingContentRef.current; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: currentContent }; + } + return updated; + }); + } else if (event.delta.type === 'toolUse') { + pendingToolInput += event.delta.input; + } + break; + case 'contentBlockStart': + if (event.start.type === 'toolUse') { + pendingToolUseId = event.start.toolUse.toolUseId; + pendingToolName = event.start.toolUse.name; + pendingToolInput = ''; + const serverName = event.start.toolUse.serverName; + const label = serverName ? `${serverName}/${pendingToolName}` : pendingToolName; + logger?.logInfo(`Tool call: ${pendingToolName} (id: ${pendingToolUseId})`); + streamingContentRef.current += `\n\x1b[2m${label}`; + const currentContent = streamingContentRef.current; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: currentContent }; + } + return updated; + }); + } else if (event.start.type === 'toolResult') { + const status = event.start.toolResult.status; + const icon = status === 'error' ? ' \x1b[31m[error]\x1b[0m' : ' [ok]\x1b[0m'; + logger?.logInfo(`Tool result (${pendingToolName}): status=${status ?? 'success'}`); + streamingContentRef.current += `${icon}\n`; + const currentContent = streamingContentRef.current; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: currentContent }; + } + return updated; + }); + } + break; + case 'messageStop': + if (event.stopReason === 'tool_use' && pendingToolUseId) { + let inputObj: Record = {}; + try { + inputObj = JSON.parse(pendingToolInput) as Record; + } catch { + // use empty + } + logger?.logInfo(`Tool input (${pendingToolName}): ${JSON.stringify(inputObj)}`); + } + break; + case 'metadata': { + const { inputTokens, outputTokens } = event.usage; + logger?.logInfo(`Tokens: ${inputTokens} in, ${outputTokens} out | Latency: ${event.metrics.latencyMs}ms`); + lastMetadata = { inputTokens, outputTokens, latencyMs: event.metrics.latencyMs }; + break; + } + case 'error': + streamingContentRef.current += `\nError: ${event.message}`; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: streamingContentRef.current }; + } + return updated; + }); + break; + } + } + + if (lastMetadata) { + const latency = (lastMetadata.latencyMs / 1000).toFixed(1); + streamingContentRef.current += `\n\x1b[2m${lastMetadata.inputTokens} in / ${lastMetadata.outputTokens} out / ${latency}s\x1b[0m`; + const currentContent = streamingContentRef.current; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: currentContent }; + } + return updated; + }); + } + + setPhase('ready'); + } catch (err) { + const errMsg = getErrorMessage(err); + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: `Error: ${errMsg}` }; + } + return updated; + }); + setPhase('ready'); + } + }, + [bearerToken] + ); + const invoke = useCallback( async (prompt: string) => { if (!config || phase === 'invoking') return; + const isHarness = isPreviewEnabled() && selectedAgent >= config.runtimes.length; const agent = config.runtimes[selectedAgent]; - if (!agent) return; + if (!agent && !isHarness) return; - const isMcp = agent.protocol === 'MCP'; + const isMcp = !isHarness && agent?.protocol === 'MCP'; // Create logger on first invoke or if agent changed + const harnessForLog = isHarness ? config.harnesses[selectedAgent - config.runtimes.length] : undefined; if (!loggerRef.current) { loggerRef.current = new InvokeLogger({ - agentName: agent.name, - runtimeArn: agent.state.runtimeArn, + agentName: agent?.name ?? harnessForLog?.name ?? 'harness', + runtimeArn: agent?.state.runtimeArn ?? harnessForLog?.state.harnessArn ?? '', region: config.target.region, sessionId: sessionId ?? undefined, }); @@ -283,6 +469,27 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const logger = loggerRef.current; + // Harness invoke (preview) + if (isHarness) { + const harnessIdx = selectedAgent - config.runtimes.length; + const harness = config.harnesses[harnessIdx]; + if (!harness) return; + + setMessages(prev => [...prev, { role: 'user', content: prompt }, { role: 'assistant', content: '' }]); + setPhase('invoking'); + streamingContentRef.current = ''; + + logger.logPrompt(prompt, sessionId ?? undefined, userId); + await streamHarnessInvoke(config.target.region, harness.state.harnessArn, sessionId ?? generateSessionId(), [ + { role: 'user', content: [{ text: prompt }] }, + ]); + logger.logResponse(streamingContentRef.current); + return; + } + + // HTTP / A2A: streaming invoke (agent is guaranteed defined here -- harness path returned above) + if (!agent) return; + // MCP: handle tool calls if (isMcp) { // "list" refreshes the tool list @@ -528,21 +735,46 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState setPhase('ready'); } }, - [config, selectedAgent, phase, sessionId, userId, headers, bearerToken, fetchMcpTools, getMcpInvokeOptions] + [ + config, + selectedAgent, + phase, + sessionId, + userId, + headers, + bearerToken, + fetchMcpTools, + getMcpInvokeOptions, + streamHarnessInvoke, + ] ); const execCommand = useCallback( async (command: string) => { if (!config || phase === 'invoking') return; - const agent = config.runtimes[selectedAgent]; - if (!agent) return; + const isHarnessExec = isPreviewEnabled() && selectedAgent >= config.runtimes.length; + const agent = isHarnessExec ? undefined : config.runtimes[selectedAgent]; + if (!agent && !isHarnessExec) return; + + let execRuntimeArn: string | undefined; + let execName: string; + if (isHarnessExec) { + const harnessIdx = selectedAgent - config.runtimes.length; + const harness = config.harnesses[harnessIdx]; + if (!harness) return; + execRuntimeArn = harness.state.harnessArn; + execName = harness.name; + } else { + execRuntimeArn = agent!.state.runtimeArn; + execName = agent!.name; + } - // Create logger on first invoke or if agent changed + // Create logger on first exec or if agent changed if (!loggerRef.current) { loggerRef.current = new InvokeLogger({ - agentName: agent.name, - runtimeArn: agent.state.runtimeArn, + agentName: execName, + runtimeArn: execRuntimeArn, region: config.target.region, sessionId: sessionId ?? undefined, }); @@ -564,7 +796,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState try { const result = await executeBashCommand({ region: config.target.region, - runtimeArn: agent.state.runtimeArn, + runtimeArn: execRuntimeArn, command, sessionId: sessionId ?? undefined, headers, diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index 696107486..44742a1ef 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -7,6 +7,7 @@ import { useRemovableEvaluators, useRemovableGatewayTargets, useRemovableGateways, + useRemovableHarnesses, useRemovableIdentities, useRemovableMemories, useRemovableOnlineEvalConfigs, @@ -20,6 +21,7 @@ import { useRemoveEvaluator, useRemoveGateway, useRemoveGatewayTarget, + useRemoveHarness, useRemoveIdentity, useRemoveMemory, useRemoveOnlineEvalConfig, @@ -59,6 +61,8 @@ type FlowState = | { name: 'select-online-eval' } | { name: 'select-policy-engine' } | { name: 'select-policy' } + | { name: 'select-harness' } + | { name: 'confirm-harness'; harnessName: string; preview: RemovalPreview } | { name: 'select-config-bundle' } | { name: 'select-ab-test' } | { name: 'select-runtime-endpoint' } @@ -75,6 +79,7 @@ type FlowState = | { name: 'confirm-ab-test'; testName: string; preview: RemovalPreview } | { name: 'confirm-runtime-endpoint'; endpointName: string; preview: RemovalPreview } | { name: 'loading'; message: string } + | { name: 'harness-success'; harnessName: string; logFilePath?: string } | { name: 'agent-success'; agentName: string; logFilePath?: string } | { name: 'gateway-success'; gatewayName: string; logFilePath?: string } | { name: 'tool-success'; toolName: string; logFilePath?: string } @@ -101,6 +106,7 @@ interface RemoveFlowProps { /** Initial resource type to start at (for CLI subcommands) */ initialResourceType?: | 'agent' + | 'harness' | 'gateway' | 'gateway-target' | 'runtime-endpoint' @@ -129,6 +135,8 @@ export function RemoveFlow({ switch (initialResourceType) { case 'agent': return { name: 'select-agent' }; + case 'harness': + return { name: 'select-harness' }; case 'gateway': return { name: 'select-gateway' }; case 'gateway-target': @@ -159,6 +167,7 @@ export function RemoveFlow({ // Data hooks - need isLoading to avoid showing screen before data loads const { agents, isLoading: isLoadingAgents, refresh: refreshAgents } = useRemovableAgents(); + const { harnesses, isLoading: isLoadingHarnesses, refresh: refreshHarnesses } = useRemovableHarnesses(); const { gateways, isLoading: isLoadingGateways, refresh: refreshGateways } = useRemovableGateways(); const { tools: mcpTools, isLoading: isLoadingTools, refresh: refreshTools } = useRemovableGatewayTargets(); const { memories, isLoading: isLoadingMemories, refresh: refreshMemories } = useRemovableMemories(); @@ -190,6 +199,7 @@ export function RemoveFlow({ // Check if any data is still loading const isLoading = isLoadingAgents || + isLoadingHarnesses || isLoadingGateways || isLoadingTools || isLoadingMemories || @@ -204,6 +214,7 @@ export function RemoveFlow({ // Preview hook const { loadAgentPreview, + loadHarnessPreview, loadGatewayPreview, loadGatewayTargetPreview, loadMemoryPreview, @@ -220,6 +231,7 @@ export function RemoveFlow({ // Removal hooks const { remove: removeAgentOp, reset: resetRemoveAgent } = useRemoveAgent(); + const { remove: removeHarnessOp, reset: resetRemoveHarness } = useRemoveHarness(); const { remove: removeGatewayOp, reset: resetRemoveGateway } = useRemoveGateway(); const { remove: removeGatewayTargetOp, reset: resetRemoveGatewayTarget } = useRemoveGatewayTarget(); const { remove: removeMemoryOp, reset: resetRemoveMemory } = useRemoveMemory(); @@ -253,6 +265,7 @@ export function RemoveFlow({ if (!isInteractive) { const successStates = [ 'agent-success', + 'harness-success', 'gateway-success', 'tool-success', 'memory-success', @@ -279,6 +292,9 @@ export function RemoveFlow({ case 'agent': setFlow({ name: 'select-agent' }); break; + case 'harness': + setFlow({ name: 'select-harness' }); + break; case 'gateway': setFlow({ name: 'select-gateway' }); break; @@ -343,6 +359,28 @@ export function RemoveFlow({ [loadAgentPreview, force, removeAgentOp] ); + const handleSelectHarness = useCallback( + async (harnessName: string) => { + const result = await loadHarnessPreview(harnessName); + if (result.ok) { + if (force) { + setFlow({ name: 'loading', message: `Removing harness ${harnessName}...` }); + const removeResult = await removeHarnessOp(harnessName, result.preview); + if (removeResult.success) { + setFlow({ name: 'harness-success', harnessName }); + } else { + setFlow({ name: 'error', message: removeResult.error.message }); + } + } else { + setFlow({ name: 'confirm-harness', harnessName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadHarnessPreview, force, removeHarnessOp] + ); + const handleSelectGateway = useCallback( async (gatewayName: string) => { const result = await loadGatewayPreview(gatewayName); @@ -669,6 +707,22 @@ export function RemoveFlow({ [removeAgentOp] ); + const handleConfirmHarness = useCallback( + async (harnessName: string, preview: RemovalPreview) => { + pendingResultRef.current = null; + setResultReady(false); + setFlow({ name: 'loading', message: `Removing harness ${harnessName}...` }); + const result = await removeHarnessOp(harnessName, preview); + if (result.success) { + pendingResultRef.current = { name: 'harness-success', harnessName, logFilePath: result.logFilePath }; + } else { + pendingResultRef.current = { name: 'error', message: result.error.message }; + } + setResultReady(true); + }, + [removeHarnessOp] + ); + const handleConfirmGateway = useCallback( async (gatewayName: string, preview: RemovalPreview) => { pendingResultRef.current = null; @@ -848,6 +902,7 @@ export function RemoveFlow({ const resetAll = useCallback(() => { resetPreview(); resetRemoveAgent(); + resetRemoveHarness(); resetRemoveGateway(); resetRemoveGatewayTarget(); resetRemoveMemory(); @@ -862,6 +917,7 @@ export function RemoveFlow({ }, [ resetPreview, resetRemoveAgent, + resetRemoveHarness, resetRemoveGateway, resetRemoveGatewayTarget, resetRemoveMemory, @@ -878,6 +934,7 @@ export function RemoveFlow({ const refreshAll = useCallback(async () => { await Promise.all([ refreshAgents(), + refreshHarnesses(), refreshGateways(), refreshTools(), refreshMemories(), @@ -891,6 +948,7 @@ export function RemoveFlow({ ]); }, [ refreshAgents, + refreshHarnesses, refreshGateways, refreshTools, refreshMemories, @@ -913,6 +971,7 @@ export function RemoveFlow({ onSelect={handleSelectResource} onExit={onExit} agentCount={agents.length} + harnessCount={harnesses.length} gatewayCount={gateways.length} mcpToolCount={mcpTools.length} memoryCount={memories.length} @@ -957,6 +1016,19 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-harness') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectHarness(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + if (flow.name === 'select-gateway') { if (initialResourceName && isLoading) { return null; @@ -1109,6 +1181,17 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-harness') { + return ( + void handleConfirmHarness(flow.harnessName, flow.preview)} + onCancel={() => setFlow({ name: 'select-harness' })} + /> + ); + } + if (flow.name === 'confirm-gateway') { return ( { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + if (flow.name === 'gateway-success') { return ( void; onExit: () => void; /** Number of agents available for removal */ agentCount: number; + /** Number of harnesses available for removal */ + harnessCount: number; /** Number of gateways available for removal */ gatewayCount: number; /** Number of gateway targets available for removal */ @@ -53,6 +73,7 @@ export function RemoveScreen({ onSelect, onExit, agentCount, + harnessCount, gatewayCount, mcpToolCount, memoryCount, @@ -77,6 +98,12 @@ export function RemoveScreen({ description = 'No agents to remove'; } break; + case 'harness': + if (harnessCount === 0) { + disabled = true; + description = 'No harnesses to remove'; + } + break; case 'gateway': if (gatewayCount === 0) { disabled = true; @@ -152,6 +179,7 @@ export function RemoveScreen({ }); }, [ agentCount, + harnessCount, gatewayCount, mcpToolCount, memoryCount, diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index ccc59e9da..237ebe2fc 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -13,6 +13,7 @@ describe('RemoveScreen', () => { onSelect={onSelect} onExit={onExit} agentCount={1} + harnessCount={0} gatewayCount={1} mcpToolCount={1} memoryCount={1} @@ -46,6 +47,7 @@ describe('RemoveScreen', () => { onSelect={onSelect} onExit={onExit} agentCount={0} + harnessCount={0} gatewayCount={0} mcpToolCount={0} memoryCount={0} @@ -75,6 +77,7 @@ describe('RemoveScreen', () => { onSelect={onSelect} onExit={onExit} agentCount={0} + harnessCount={0} gatewayCount={0} mcpToolCount={0} memoryCount={0} @@ -102,6 +105,7 @@ describe('RemoveScreen', () => { onSelect={onSelect} onExit={onExit} agentCount={0} + harnessCount={0} gatewayCount={0} mcpToolCount={0} memoryCount={0} diff --git a/src/cli/update-notifier.ts b/src/cli/update-notifier.ts index dca990c15..4af5c7fb9 100644 --- a/src/cli/update-notifier.ts +++ b/src/cli/update-notifier.ts @@ -1,6 +1,6 @@ import { ONE_DAY_MS } from '../lib/time-constants.js'; import { compareVersions, fetchLatestVersion } from './commands/update/action.js'; -import { PACKAGE_VERSION } from './constants.js'; +import { PACKAGE_VERSION, getDistroConfig } from './constants.js'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { homedir } from 'os'; import { join } from 'path'; @@ -70,9 +70,10 @@ export function printUpdateNotification(result: UpdateCheckResult): void { const yellow = '\x1b[33m'; const cyan = '\x1b[36m'; const reset = '\x1b[0m'; + const { installCommand } = getDistroConfig(); process.stderr.write( `\n${yellow}Update available:${reset} ${PACKAGE_VERSION} โ†’ ${cyan}${result.latestVersion}${reset}\n` + - `Run ${cyan}\`npm install -g @aws/agentcore@latest\`${reset} to update.\n` + `Run ${cyan}\`${installCommand}\`${reset} to update.\n` ); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 48b5feb48..5ef7b5c34 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -10,6 +10,9 @@ export const CONFIG_DIR = 'agentcore'; export const APP_DIR = 'app'; export const MCP_APP_SUBDIR = 'mcp'; +// Harnesses directory +export const HARNESS_DIR = 'harnesses'; + // CLI system subdirectory (inside CONFIG_DIR) export const CLI_SYSTEM_DIR = '.cli'; export const CLI_LOGS_DIR = 'logs'; diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index 25841f4d8..5338b86ba 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -1,9 +1,16 @@ -import type { AgentCoreCliMcpDefs, AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../schema'; +import type { + AgentCoreCliMcpDefs, + AgentCoreProjectSpec, + AwsDeploymentTarget, + DeployedState, + HarnessSpec, +} from '../../../schema'; import { AgentCoreCliMcpDefsSchema, AgentCoreProjectSpecSchema, AgentCoreRegionSchema, AwsDeploymentTargetsSchema, + HarnessSpecSchema, createValidatedDeployedStateSchema, } from '../../../schema'; import { @@ -231,6 +238,22 @@ export class ConfigIO { await this.validateAndWrite(filePath, 'MCP Definitions', AgentCoreCliMcpDefsSchema, data); } + /** + * Read and validate a harness specification file + */ + async readHarnessSpec(harnessName: string): Promise { + const filePath = this.pathResolver.getHarnessConfigPath(harnessName); + return this.readAndValidate(filePath, 'Harness Spec', HarnessSpecSchema); + } + + /** + * Write and validate a harness specification file + */ + async writeHarnessSpec(harnessName: string, data: HarnessSpec): Promise { + const filePath = this.pathResolver.getHarnessConfigPath(harnessName); + await this.validateAndWrite(filePath, 'Harness Spec', HarnessSpecSchema, data); + } + /** * Check if the base directory exists */ diff --git a/src/lib/schemas/io/path-resolver.ts b/src/lib/schemas/io/path-resolver.ts index 81f7e254e..5d429542a 100644 --- a/src/lib/schemas/io/path-resolver.ts +++ b/src/lib/schemas/io/path-resolver.ts @@ -1,4 +1,4 @@ -import { CLI_LOGS_DIR, CLI_SYSTEM_DIR, CONFIG_DIR, CONFIG_FILES as _CONFIG_FILES } from '../../constants'; +import { APP_DIR, CLI_LOGS_DIR, CLI_SYSTEM_DIR, CONFIG_DIR, CONFIG_FILES as _CONFIG_FILES } from '../../constants'; import { NoProjectError } from '../../errors'; import { existsSync } from 'fs'; import { dirname, join } from 'path'; @@ -192,6 +192,27 @@ export class PathResolver { return join(this.config.baseDir, CONFIG_FILES.MCP_DEFS); } + /** + * Get the path to the harnesses directory (app/) + */ + getHarnessesDir(): string { + return join(this.getProjectRoot(), APP_DIR); + } + + /** + * Get the path to a specific harness directory (app//) + */ + getHarnessDir(harnessName: string): string { + return join(this.getProjectRoot(), APP_DIR, harnessName); + } + + /** + * Get the path to a specific harness config file (app//harness.json) + */ + getHarnessConfigPath(harnessName: string): string { + return join(this.getProjectRoot(), APP_DIR, harnessName, 'harness.json'); + } + /** * Update the base directory */ diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 9fca9b6d8..00c06ff0d 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -2,6 +2,7 @@ import { AgentCoreProjectSpecSchema, CredentialNameSchema, CredentialSchema, + HarnessRegistryEntrySchema, MemoryNameSchema, MemorySchema, ProjectNameSchema, @@ -378,6 +379,18 @@ describe('CredentialSchema', () => { }); }); +describe('HarnessRegistryEntrySchema', () => { + it('accepts valid entry', () => { + const result = HarnessRegistryEntrySchema.safeParse({ name: 'myHarness', path: './harnesses/myHarness' }); + expect(result.success).toBe(true); + }); + + it('rejects name starting with digit', () => { + const result = HarnessRegistryEntrySchema.safeParse({ name: '1harness', path: './harnesses/1harness' }); + expect(result.success).toBe(false); + }); +}); + describe('AgentCoreProjectSpecSchema', () => { const minimalProject = { name: 'TestProject', @@ -523,4 +536,48 @@ describe('AgentCoreProjectSpecSchema', () => { }); expect(result.success).toBe(false); }); + + it('accepts project with harnesses array', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [{ name: 'myHarness', path: './harnesses/myHarness' }], + }); + expect(result.success).toBe(true); + }); + + it('harnesses is undefined when not provided', () => { + const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.harnesses).toEqual([]); + } + }); + + it('rejects duplicate harness names', () => { + const harness = { name: 'myHarness', path: './harnesses/myHarness' }; + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [harness, harness], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate harness name'))).toBe(true); + } + }); + + it('rejects harness with empty name', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [{ name: '', path: './harnesses/empty' }], + }); + expect(result.success).toBe(false); + }); + + it('rejects harness with empty path', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + harnesses: [{ name: 'myHarness', path: '' }], + }); + expect(result.success).toBe(false); + }); }); diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index 4c4483392..4387dc63e 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -5,6 +5,7 @@ import { DeployedResourceStateSchema, DeployedStateSchema, GatewayDeployedStateSchema, + HarnessDeployedStateSchema, McpDeployedStateSchema, McpLambdaDeployedStateSchema, McpRuntimeDeployedStateSchema, @@ -302,6 +303,39 @@ describe('DeployedStateSchema', () => { }); }); +describe('HarnessDeployedStateSchema', () => { + it('accepts valid harness deployed state', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: 'abc123', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/abc123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + }); + expect(result.success).toBe(true); + }); + + it('rejects empty harnessId', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: '', + harnessArn: 'arn:aws:test', + roleArn: 'arn:aws:test', + status: 'READY', + }); + expect(result.success).toBe(false); + }); + + it('accepts optional memoryArn', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: 'abc123', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/abc123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + memoryArn: 'arn:aws:bedrock-agentcore:us-west-2:123:memory/def456', + }); + expect(result.success).toBe(true); + }); +}); + describe('createValidatedDeployedStateSchema', () => { it('accepts state with targets matching known target names', () => { const schema = createValidatedDeployedStateSchema(['dev', 'prod']); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index b3f4d3d6f..f45715e45 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -17,6 +17,7 @@ import { EvaluatorNameSchema, KmsKeyArnSchema, } from './primitives/evaluator'; +import { HarnessNameSchema } from './primitives/harness'; import { HttpGatewaySchema } from './primitives/http-gateway'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, @@ -73,6 +74,15 @@ export type { ABTestMode, TargetRef, GatewayFilter, PerVariantOnlineEvaluationCo export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primitives/ab-test'; export type { HttpGatewayTarget } from './primitives/http-gateway'; export { HttpGatewayTargetSchema } from './primitives/http-gateway'; +export type { HarnessGatewayOutboundAuth, HarnessModel, HarnessSpec, HarnessModelProvider } from './primitives/harness'; +export { + GatewayOAuthGrantTypeSchema, + HarnessGatewayOutboundAuthSchema, + HarnessNameSchema, + HarnessSpecSchema, + HarnessToolTypeSchema, + HarnessModelProviderSchema, +} from './primitives/harness'; // ============================================================================ // ManagedBy Schema @@ -231,6 +241,17 @@ export const EvaluatorSchema = z.object({ export type Evaluator = z.infer; +// ============================================================================ +// Harness Registry Schema +// ============================================================================ + +export const HarnessRegistryEntrySchema = z.object({ + name: HarnessNameSchema, + path: z.string().min(1, 'Path to harness config directory is required'), +}); + +export type HarnessRegistryEntry = z.infer; + // ============================================================================ // Project Schema (Top Level) // ============================================================================ @@ -368,6 +389,16 @@ export const AgentCoreProjectSpecSchema = z name => `Duplicate HTTP gateway name: ${name}` ) ), + + harnesses: z + .array(HarnessRegistryEntrySchema) + .default([]) + .superRefine( + uniqueBy( + harness => harness.name, + name => `Duplicate harness name: ${name}` + ) + ), }) .strict() .superRefine((spec, ctx) => { diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index a37469799..355bc560d 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -135,6 +135,22 @@ export const PolicyDeployedStateSchema = z.object({ export type PolicyDeployedState = z.infer; +// ============================================================================ +// Harness Deployed State +// ============================================================================ + +export const HarnessDeployedStateSchema = z.object({ + harnessId: z.string().min(1), + harnessArn: z.string().min(1), + roleArn: z.string().min(1), + status: z.string().min(1), + agentRuntimeArn: z.string().optional(), + memoryArn: z.string().optional(), + configHash: z.string().optional(), +}); + +export type HarnessDeployedState = z.infer; + // ============================================================================ // Credential Deployed State // ============================================================================ @@ -246,9 +262,11 @@ export const DeployedResourceStateSchema = z.object({ httpGateways: z.record(z.string(), HttpGatewayDeployedStateSchema).optional(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), + harnesses: z.record(z.string(), HarnessDeployedStateSchema).optional(), runtimeEndpoints: z.record(z.string(), RuntimeEndpointDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), + deployHash: z.string().optional(), }); export type DeployedResourceState = z.infer; diff --git a/src/schema/schemas/primitives/__tests__/harness-auth.test.ts b/src/schema/schemas/primitives/__tests__/harness-auth.test.ts new file mode 100644 index 000000000..401ed9b52 --- /dev/null +++ b/src/schema/schemas/primitives/__tests__/harness-auth.test.ts @@ -0,0 +1,97 @@ +import { HarnessSpecSchema } from '../harness'; +import { describe, expect, it } from 'vitest'; + +describe('HarnessSpecSchema โ€“ auth fields', () => { + const minimalHarness = { + name: 'myHarness', + model: { + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + }, + }; + + const validCustomJwtConfig = { + customJwtAuthorizer: { + discoveryUrl: 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_abc123/.well-known/openid-configuration', + allowedAudience: ['my-client-id'], + }, + }; + + it('accepts harness spec with no auth fields (backwards compat)', () => { + const result = HarnessSpecSchema.safeParse(minimalHarness); + expect(result.success).toBe(true); + }); + + it('accepts harness spec with authorizerType AWS_IAM only', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + authorizerType: 'AWS_IAM', + }); + expect(result.success).toBe(true); + }); + + it('accepts harness spec with authorizerType CUSTOM_JWT and proper authorizerConfiguration', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: validCustomJwtConfig, + }); + expect(result.success).toBe(true); + }); + + it('rejects authorizerType CUSTOM_JWT without authorizerConfiguration', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + authorizerType: 'CUSTOM_JWT', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some(i => + i.message.includes( + 'authorizerConfiguration with customJwtAuthorizer is required when authorizerType is CUSTOM_JWT' + ) + ) + ).toBe(true); + } + }); + + it('rejects authorizerConfiguration present without authorizerType CUSTOM_JWT', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + authorizerConfiguration: validCustomJwtConfig, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some(i => + i.message.includes('authorizerConfiguration is only allowed when authorizerType is CUSTOM_JWT') + ) + ).toBe(true); + } + }); + + it('rejects authorizerConfiguration with authorizerType AWS_IAM', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + authorizerType: 'AWS_IAM', + authorizerConfiguration: validCustomJwtConfig, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some(i => + i.message.includes('authorizerConfiguration is only allowed when authorizerType is CUSTOM_JWT') + ) + ).toBe(true); + } + }); + + it('rejects invalid authorizerType value', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + authorizerType: 'INVALID_VALUE', + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/primitives/__tests__/harness.test.ts b/src/schema/schemas/primitives/__tests__/harness.test.ts new file mode 100644 index 000000000..8ec96a1c8 --- /dev/null +++ b/src/schema/schemas/primitives/__tests__/harness.test.ts @@ -0,0 +1,694 @@ +import { + HarnessModelProviderSchema, + HarnessModelSchema, + HarnessNameSchema, + HarnessSpecSchema, + HarnessToolSchema, + HarnessToolTypeSchema, +} from '../harness'; +import { describe, expect, it } from 'vitest'; + +describe('HarnessNameSchema', () => { + it.each(['MyHarness', 'a', 'Agent1', 'my_harness_01'])('accepts valid name "%s"', name => { + expect(HarnessNameSchema.safeParse(name).success).toBe(true); + }); + + it('accepts 48-character name (max)', () => { + const name = 'A' + 'b'.repeat(47); + expect(name).toHaveLength(48); + expect(HarnessNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects 49-character name', () => { + const name = 'A' + 'b'.repeat(48); + expect(name).toHaveLength(49); + expect(HarnessNameSchema.safeParse(name).success).toBe(false); + }); + + it('rejects empty string', () => { + expect(HarnessNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(HarnessNameSchema.safeParse('1harness').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(HarnessNameSchema.safeParse('my-harness').success).toBe(false); + }); + + it('rejects name with spaces', () => { + expect(HarnessNameSchema.safeParse('my harness').success).toBe(false); + }); +}); + +describe('HarnessToolTypeSchema', () => { + it.each(['remote_mcp', 'agentcore_browser', 'agentcore_gateway', 'inline_function', 'agentcore_code_interpreter'])( + 'accepts "%s"', + type => { + expect(HarnessToolTypeSchema.safeParse(type).success).toBe(true); + } + ); + + it('rejects unknown tool type', () => { + expect(HarnessToolTypeSchema.safeParse('unknown_tool').success).toBe(false); + }); +}); + +describe('HarnessModelProviderSchema', () => { + it.each(['bedrock', 'open_ai', 'gemini'])('accepts "%s"', provider => { + expect(HarnessModelProviderSchema.safeParse(provider).success).toBe(true); + }); + + it('rejects unknown provider', () => { + expect(HarnessModelProviderSchema.safeParse('azure').success).toBe(false); + }); +}); + +describe('HarnessToolSchema', () => { + it('accepts browser tool with no config', () => { + const result = HarnessToolSchema.safeParse({ type: 'agentcore_browser', name: 'browser' }); + expect(result.success).toBe(true); + }); + + it('accepts browser tool with optional browserArn', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'browser', + config: { agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-west-2:123:browser/abc' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts code interpreter tool with no config', () => { + const result = HarnessToolSchema.safeParse({ type: 'agentcore_code_interpreter', name: 'code-interp' }); + expect(result.success).toBe(true); + }); + + it('accepts remote MCP tool with url', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'exa', + config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts remote MCP tool with headers', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'exa', + config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp', headers: { Authorization: 'Bearer tok' } } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts gateway tool with gatewayArn', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts gateway tool with outboundAuth awsIam', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { + agentCoreGateway: { + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc', + outboundAuth: { awsIam: {} }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts gateway tool with outboundAuth none', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { + agentCoreGateway: { + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc', + outboundAuth: { none: {} }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts gateway tool with outboundAuth oauth', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { + agentCoreGateway: { + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc', + outboundAuth: { + oauth: { + providerArn: + 'arn:aws:bedrock-agentcore:us-west-2:123:token-vault/default/oauth2credentialprovider/my-provider', + scopes: ['read', 'write'], + grantType: 'CLIENT_CREDENTIALS', + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts gateway tool without outboundAuth (defaults to SigV4)', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { + agentCoreGateway: { + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc', + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects gateway tool with invalid outboundAuth variant', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { + agentCoreGateway: { + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc', + outboundAuth: { unknownAuth: {} }, + }, + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects gateway tool with credentialProviderName and shows migration message', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { + agentCoreGateway: { + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc', + credentialProviderName: 'my-oauth', + }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('no longer supported'))).toBe(true); + } + }); + + it('accepts inline function tool', () => { + const result = HarnessToolSchema.safeParse({ + type: 'inline_function', + name: 'approve_purchase', + config: { + inlineFunction: { + description: 'Approve a purchase', + inputSchema: { + type: 'object', + properties: { amount: { type: 'number' } }, + required: ['amount'], + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects tool name longer than 64 chars', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'a'.repeat(65), + }); + expect(result.success).toBe(false); + }); + + it('rejects tool name with invalid characters', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'my tool!', + }); + expect(result.success).toBe(false); + }); + + it('rejects remote_mcp with agentCoreBrowser config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'mcp-server', + config: { agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-west-2:123:browser/abc' } }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('requires "remoteMcp" config'))).toBe(true); + } + }); + + it('rejects agentcore_gateway without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('requires a "agentCoreGateway" config'))).toBe(true); + } + }); + + it('rejects remote_mcp without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'remote_mcp', + name: 'exa', + }); + expect(result.success).toBe(false); + }); + + it('rejects inline_function without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'inline_function', + name: 'my-func', + }); + expect(result.success).toBe(false); + }); + + it('rejects agentcore_gateway with remoteMcp config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_gateway', + name: 'my-gw', + config: { remoteMcp: { url: 'https://example.com' } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects inline_function with agentCoreGateway config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'inline_function', + name: 'my-func', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }); + expect(result.success).toBe(false); + }); + + it('allows agentcore_browser without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_browser', + name: 'browser', + }); + expect(result.success).toBe(true); + }); + + it('allows agentcore_code_interpreter without config', () => { + const result = HarnessToolSchema.safeParse({ + type: 'agentcore_code_interpreter', + name: 'code-interp', + }); + expect(result.success).toBe(true); + }); +}); + +describe('HarnessModelSchema', () => { + it('accepts bedrock model with just modelId', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + }); + expect(result.success).toBe(true); + }); + + it('accepts bedrock model with optional inference params', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + temperature: 0.7, + topP: 0.9, + maxTokens: 4096, + }); + expect(result.success).toBe(true); + }); + + it('accepts open_ai model with apiKeyArn', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + }); + expect(result.success).toBe(true); + }); + + it('accepts gemini model with topK', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'gemini', + modelId: 'gemini-2.5-pro', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + topK: 0.5, + }); + expect(result.success).toBe(true); + }); + + it('rejects temperature above 2.0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + temperature: 2.1, + }); + expect(result.success).toBe(false); + }); + + it('rejects temperature below 0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + temperature: -0.1, + }); + expect(result.success).toBe(false); + }); + + it('rejects topP above 1.0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + topP: 1.1, + }); + expect(result.success).toBe(false); + }); + + it('rejects maxTokens of 0', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'test', + maxTokens: 0, + }); + expect(result.success).toBe(false); + }); + + it('requires modelId', () => { + const result = HarnessModelSchema.safeParse({ provider: 'bedrock' }); + expect(result.success).toBe(false); + }); + + it('rejects topK for bedrock provider', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + topK: 0.5, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some(i => i.message.includes('topK is only supported for the "gemini" provider')) + ).toBe(true); + } + }); + + it('rejects topK for open_ai provider', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + topK: 0.5, + }); + expect(result.success).toBe(false); + }); +}); + +describe('HarnessSpecSchema', () => { + const minimalHarness = { + name: 'myHarness', + model: { + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + }, + }; + + it('accepts minimal harness spec', () => { + const result = HarnessSpecSchema.safeParse(minimalHarness); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tools).toEqual([]); + expect(result.data.skills).toEqual([]); + } + }); + + it('accepts harness with system prompt file path', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + systemPrompt: './system-prompt.md', + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with tools', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + tools: [ + { type: 'agentcore_browser', name: 'browser' }, + { type: 'remote_mcp', name: 'exa', config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp' } } }, + { + type: 'agentcore_gateway', + name: 'my-gw', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects duplicate tool names', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + tools: [ + { type: 'agentcore_browser', name: 'browser' }, + { type: 'agentcore_code_interpreter', name: 'browser' }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate tool name'))).toBe(true); + } + }); + + it('accepts harness with skills as string paths', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + skills: ['./skills/research', '.agents/skills/xlsx'], + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with allowedTools', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + allowedTools: ['file_operations', 'browser'], + }); + expect(result.success).toBe(true); + }); + + it('accepts wildcard in allowedTools', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + allowedTools: ['*'], + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with memory reference', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { name: 'research_memory' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with memory arn override', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { arn: 'arn:aws:bedrock-agentcore:us-west-2:123:memory/abc' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with execution limits', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + maxIterations: 50, + timeoutSeconds: 1800, + maxTokens: 8192, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with sliding_window truncation', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { + strategy: 'sliding_window', + config: { slidingWindow: { messagesCount: 100 } }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with summarization truncation', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { + strategy: 'summarization', + config: { summarization: { summaryRatio: 0.3, preserveRecentMessages: 10 } }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects unknown truncation strategy', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { strategy: 'random', config: {} }, + }); + expect(result.success).toBe(false); + }); + + it('accepts harness with container config', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + containerUri: '123456789012.dkr.ecr.us-west-2.amazonaws.com/my-agent:latest', + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with dockerfile', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + dockerfile: 'Dockerfile', + }); + expect(result.success).toBe(true); + }); + + it('rejects containerUri and dockerfile together', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + containerUri: '123456789012.dkr.ecr.us-west-2.amazonaws.com/my-agent:latest', + dockerfile: 'Dockerfile', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('mutually exclusive'))).toBe(true); + } + }); + + it('accepts harness with VPC network config', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + networkMode: 'VPC', + networkConfig: { + subnets: ['subnet-abc12345'], + securityGroups: ['sg-abc12345'], + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects VPC mode without networkConfig', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + networkMode: 'VPC', + }); + expect(result.success).toBe(false); + }); + + it('rejects networkConfig without VPC mode', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + networkConfig: { + subnets: ['subnet-abc12345'], + securityGroups: ['sg-abc12345'], + }, + }); + expect(result.success).toBe(false); + }); + + it('accepts harness with lifecycle config', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + lifecycleConfig: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28800, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with environment variables', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + environmentVariables: { NODE_ENV: 'production', DEBUG: 'true' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with tags', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + tags: { team: 'platform', env: 'dev' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with executionRoleArn', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + executionRoleArn: 'arn:aws:iam::123456789012:role/MyRole', + }); + expect(result.success).toBe(true); + }); + + it('accepts fully-loaded harness spec', () => { + const result = HarnessSpecSchema.safeParse({ + name: 'research_agent', + model: { + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', + temperature: 0.7, + maxTokens: 4096, + }, + systemPrompt: './system-prompt.md', + tools: [ + { type: 'agentcore_browser', name: 'browser' }, + { type: 'agentcore_code_interpreter', name: 'code_interpreter' }, + { type: 'remote_mcp', name: 'exa', config: { remoteMcp: { url: 'https://mcp.exa.ai/mcp' } } }, + { + type: 'agentcore_gateway', + name: 'my_gateway', + config: { agentCoreGateway: { gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123:gateway/abc' } }, + }, + { + type: 'inline_function', + name: 'approve_purchase', + config: { + inlineFunction: { + description: 'Approve a purchase', + inputSchema: { type: 'object', properties: { amount: { type: 'number' } }, required: ['amount'] }, + }, + }, + }, + ], + skills: ['./skills/research'], + allowedTools: ['*'], + memory: { name: 'research_memory' }, + maxIterations: 75, + timeoutSeconds: 3600, + maxTokens: 16384, + truncation: { strategy: 'sliding_window', config: { slidingWindow: { messagesCount: 150 } } }, + lifecycleConfig: { idleRuntimeSessionTimeout: 900 }, + networkMode: 'PUBLIC', + tags: { team: 'research' }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/schema/schemas/primitives/harness.ts b/src/schema/schemas/primitives/harness.ts new file mode 100644 index 000000000..accf8055b --- /dev/null +++ b/src/schema/schemas/primitives/harness.ts @@ -0,0 +1,315 @@ +import { NetworkModeSchema } from '../../constants'; +import { LifecycleConfigurationSchema, NetworkConfigSchema } from '../agent-env'; +import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from '../auth'; +import { uniqueBy } from '../zod-util'; +import { TagsSchema } from './tags'; +import { z } from 'zod'; + +// ============================================================================ +// Harness Name +// ============================================================================ + +export const HarnessNameSchema = z + .string() + .min(1, 'Harness name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + ); + +// ============================================================================ +// Model Configuration +// ============================================================================ + +export const HarnessModelProviderSchema = z.enum(['bedrock', 'open_ai', 'gemini']); +export type HarnessModelProvider = z.infer; + +export const HarnessModelSchema = z + .object({ + provider: HarnessModelProviderSchema, + modelId: z.string().min(1, 'Model ID is required'), + apiKeyArn: z.string().optional(), + temperature: z.number().min(0).max(2).optional(), + topP: z.number().min(0).max(1).optional(), + topK: z.number().min(0).max(1).optional(), + maxTokens: z.number().int().min(1).optional(), + }) + .superRefine((model, ctx) => { + if (model.topK !== undefined && model.provider !== 'gemini') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'topK is only supported for the "gemini" provider', + path: ['topK'], + }); + } + }); + +export type HarnessModel = z.infer; + +// ============================================================================ +// Tool Configuration +// ============================================================================ + +export const HarnessToolTypeSchema = z.enum([ + 'remote_mcp', + 'agentcore_browser', + 'agentcore_gateway', + 'inline_function', + 'agentcore_code_interpreter', +]); +export type HarnessToolType = z.infer; + +export const HarnessToolNameSchema = z + .string() + .min(1) + .max(64) + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Tool name must contain only alphanumeric characters, hyphens, and underscores (1-64 chars)' + ); + +export const RemoteMcpConfigSchema = z.object({ + remoteMcp: z.object({ + url: z.string().min(1), + headers: z.record(z.string(), z.string()).optional(), + }), +}); + +export const AgentCoreBrowserConfigSchema = z.object({ + agentCoreBrowser: z.object({ + browserArn: z.string().optional(), + }), +}); + +export const AgentCoreCodeInterpreterConfigSchema = z.object({ + agentCoreCodeInterpreter: z.object({ + codeInterpreterArn: z.string().optional(), + }), +}); + +export const GatewayOAuthGrantTypeSchema = z.enum(['CLIENT_CREDENTIALS', 'USER_FEDERATION']); + +export const HarnessGatewayOutboundAuthSchema = z.union([ + z.object({ awsIam: z.object({}) }), + z.object({ none: z.object({}) }), + z.object({ + oauth: z.object({ + providerArn: z.string().min(1), + scopes: z.array(z.string().min(1)), + grantType: GatewayOAuthGrantTypeSchema.optional(), + customParameters: z.record(z.string(), z.string()).optional(), + }), + }), +]); + +export type HarnessGatewayOutboundAuth = z.infer; + +export const AgentCoreGatewayConfigSchema = z.object({ + agentCoreGateway: z + .object({ + gatewayArn: z.string().min(1), + outboundAuth: HarnessGatewayOutboundAuthSchema.optional(), + }) + .passthrough() + .superRefine((data, ctx) => { + if ('credentialProviderName' in data) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'credentialProviderName is no longer supported. Use outboundAuth instead. Example: outboundAuth: { awsIam: {} } or outboundAuth: { oauth: { providerArn: "...", scopes: [...] } }', + path: ['credentialProviderName'], + }); + } + }), +}); + +export const InlineFunctionConfigSchema = z.object({ + inlineFunction: z.object({ + description: z.string().min(1), + inputSchema: z.record(z.string(), z.unknown()), + }), +}); + +export const HarnessToolConfigSchema = z.union([ + RemoteMcpConfigSchema, + AgentCoreBrowserConfigSchema, + AgentCoreCodeInterpreterConfigSchema, + AgentCoreGatewayConfigSchema, + InlineFunctionConfigSchema, +]); + +const TOOL_TYPE_TO_CONFIG_KEY: Record = { + remote_mcp: 'remoteMcp', + agentcore_browser: 'agentCoreBrowser', + agentcore_gateway: 'agentCoreGateway', + inline_function: 'inlineFunction', + agentcore_code_interpreter: 'agentCoreCodeInterpreter', +}; + +const TOOL_TYPES_REQUIRING_CONFIG = new Set(['remote_mcp', 'agentcore_gateway', 'inline_function']); + +export const HarnessToolSchema = z + .object({ + type: HarnessToolTypeSchema, + name: HarnessToolNameSchema, + config: HarnessToolConfigSchema.optional(), + }) + .superRefine((tool, ctx) => { + const expectedKey = TOOL_TYPE_TO_CONFIG_KEY[tool.type]; + + if (!tool.config) { + if (TOOL_TYPES_REQUIRING_CONFIG.has(tool.type)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Tool type "${tool.type}" requires a "${expectedKey}" config`, + path: ['config'], + }); + } + return; + } + + const configKeys = Object.keys(tool.config); + if (configKeys.length !== 1 || configKeys[0] !== expectedKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Tool type "${tool.type}" requires "${expectedKey}" config, got "${configKeys[0]}"`, + path: ['config'], + }); + } + }); + +export type HarnessTool = z.infer; + +// ============================================================================ +// Memory Reference +// ============================================================================ + +export const HarnessMemoryRefSchema = z.object({ + name: z.string().min(1).optional(), + arn: z.string().min(1).optional(), + actorId: z.string().optional(), +}); + +export type HarnessMemoryRef = z.infer; + +// ============================================================================ +// Truncation Configuration +// ============================================================================ + +export const HarnessTruncationStrategySchema = z.enum(['sliding_window', 'summarization']); + +export const SlidingWindowConfigSchema = z.object({ + slidingWindow: z.object({ + messagesCount: z.number().int().min(1).optional(), + }), +}); + +export const SummarizationConfigSchema = z.object({ + summarization: z.object({ + summaryRatio: z.number().min(0).max(1).optional(), + preserveRecentMessages: z.number().int().min(0).optional(), + summarizationSystemPrompt: z.string().optional(), + }), +}); + +export const HarnessTruncationConfigSchema = z.object({ + strategy: HarnessTruncationStrategySchema, + config: z.union([SlidingWindowConfigSchema, SummarizationConfigSchema]).optional(), +}); + +export type HarnessTruncationConfig = z.infer; + +// ============================================================================ +// Allowed Tools +// ============================================================================ + +export const AllowedToolSchema = z + .string() + .min(1) + .max(64) + // eslint-disable-next-line security/detect-unsafe-regex -- safe: input is bounded to 64 chars by .max(64) + .regex(/^(\*|@?[^/]+(\/[^/]+)?)$/, 'Must be "*" or a tool name pattern (max 64 chars)'); + +// ============================================================================ +// HarnessSpec โ€” per-harness config file schema (harness.json) +// ============================================================================ + +export const HarnessSpecSchema = z + .object({ + name: HarnessNameSchema, + model: HarnessModelSchema, + systemPrompt: z.string().optional(), + tools: z + .array(HarnessToolSchema) + .default([]) + .superRefine( + uniqueBy( + tool => tool.name, + name => `Duplicate tool name: ${name}` + ) + ), + skills: z.array(z.string().min(1)).default([]), + allowedTools: z.array(AllowedToolSchema).optional(), + memory: HarnessMemoryRefSchema.optional(), + maxIterations: z.number().int().min(1).optional(), + maxTokens: z.number().int().min(1).optional(), + timeoutSeconds: z.number().int().min(1).optional(), + truncation: HarnessTruncationConfigSchema.optional(), + containerUri: z.string().min(1).optional(), + dockerfile: z.string().min(1).optional(), + executionRoleArn: z.string().optional(), + networkMode: NetworkModeSchema.optional(), + networkConfig: NetworkConfigSchema.optional(), + lifecycleConfig: LifecycleConfigurationSchema.optional(), + sessionStoragePath: z + .string() + .min(1) + .refine(val => val.startsWith('/mnt/'), { message: 'sessionStoragePath must be an absolute path under /mnt/' }) + .optional(), + environmentVariables: z.record(z.string(), z.string()).optional(), + /** Authorizer type for inbound requests. Defaults to AWS_IAM. */ + authorizerType: RuntimeAuthorizerTypeSchema.optional(), + /** Authorizer configuration. Required when authorizerType is CUSTOM_JWT. */ + authorizerConfiguration: AuthorizerConfigSchema.optional(), + tags: TagsSchema.optional(), + }) + .superRefine((data, ctx) => { + if (data.containerUri !== undefined && data.dockerfile !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'containerUri and dockerfile are mutually exclusive', + path: ['containerUri'], + }); + } + if (data.networkMode === 'VPC' && !data.networkConfig) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'networkConfig is required when networkMode is VPC', + path: ['networkConfig'], + }); + } + if (data.networkMode !== 'VPC' && data.networkConfig) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'networkConfig is only allowed when networkMode is VPC', + path: ['networkConfig'], + }); + } + if (data.authorizerType === 'CUSTOM_JWT' && !data.authorizerConfiguration?.customJwtAuthorizer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'authorizerConfiguration with customJwtAuthorizer is required when authorizerType is CUSTOM_JWT', + path: ['authorizerConfiguration'], + }); + } + if (data.authorizerType !== 'CUSTOM_JWT' && data.authorizerConfiguration) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'authorizerConfiguration is only allowed when authorizerType is CUSTOM_JWT', + path: ['authorizerConfiguration'], + }); + } + }); + +export type HarnessSpec = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index 38967a181..71ec1f65a 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -68,5 +68,32 @@ export { ValidationModeSchema, } from './policy'; +export type { + HarnessGatewayOutboundAuth, + HarnessMemoryRef, + HarnessModel, + HarnessModelProvider, + HarnessSpec, + HarnessTool, + HarnessToolType, + HarnessTruncationConfig, +} from './harness'; +export { + AllowedToolSchema, + GatewayOAuthGrantTypeSchema, + HarnessGatewayOutboundAuthSchema, + HarnessMemoryRefSchema, + HarnessModelProviderSchema, + HarnessModelSchema, + HarnessNameSchema, + HarnessSpecSchema, + HarnessToolConfigSchema, + HarnessToolNameSchema, + HarnessToolSchema, + HarnessToolTypeSchema, + HarnessTruncationConfigSchema, + HarnessTruncationStrategySchema, +} from './harness'; + export type { HttpGateway } from './http-gateway'; export { HttpGatewayNameSchema, HttpGatewaySchema } from './http-gateway'; diff --git a/vitest.config.ts b/vitest.config.ts index 87efb98d9..6b1e61e9c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,9 @@ const textLoaderPlugin = { }; export default defineConfig({ + define: { + __PREVIEW__: process.env.BUILD_PREVIEW === '1' ? 'true' : 'false', + }, resolve: { alias: { '@': path.resolve(__dirname, './src'),