diff --git a/src/cli/commands/invoke/__tests__/resolve.test.ts b/src/cli/commands/invoke/__tests__/resolve.test.ts new file mode 100644 index 000000000..02c2f5359 --- /dev/null +++ b/src/cli/commands/invoke/__tests__/resolve.test.ts @@ -0,0 +1,479 @@ +import { ResourceNotFoundError, ValidationError } from '../../../../lib'; +import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../../schema'; +import { canFetchRuntimeToken, fetchRuntimeToken } from '../../../operations/fetch-access'; +import { resolveInvokeTarget } from '../resolve'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../operations/fetch-access', () => ({ + canFetchRuntimeToken: vi.fn(), + fetchRuntimeToken: vi.fn(), +})); + +vi.mock('../../../operations/session', () => ({ + generateSessionId: vi.fn(() => 'generated-session-id'), +})); + +const mockedCanFetch = vi.mocked(canFetchRuntimeToken); +const mockedFetchToken = vi.mocked(fetchRuntimeToken); + +function makeProject(overrides: Partial = {}): AgentCoreProjectSpec { + return { + name: 'test-project', + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip', + codeLocation: './agents/my-agent', + entrypoint: 'main.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + ], + credentials: [], + ...overrides, + } as AgentCoreProjectSpec; +} + +function makeDeployedState(overrides: Partial = {}): DeployedState { + return { + targets: { + default: { + resources: { + runtimes: { + 'my-agent': { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123', + roleArn: 'arn:aws:iam::123456789:role/test-role', + }, + }, + }, + }, + }, + ...overrides, + } as DeployedState; +} + +function makeAwsTargets(): AwsDeploymentTargets { + return [{ name: 'default', account: '123456789', region: 'us-east-1' }]; +} + +describe('resolveInvokeTarget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('resolves successfully with default target and single agent', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.agentSpec.name).toBe('my-agent'); + expect(result.targetName).toBe('default'); + expect(result.region).toBe('us-east-1'); + expect(result.runtimeArn).toBe('arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123'); + }); + + it('returns error when no deployed targets exist', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: { targets: {} } as DeployedState, + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain('No deployed targets found'); + }); + + it('returns error when specified target name does not exist', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + targetName: 'nonexistent', + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain("'nonexistent' not found"); + }); + + it('returns error when target config is missing from aws-targets', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: [], + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain("Target config 'default' not found"); + }); + + it('returns error when no runtimes are defined', async () => { + const result = await resolveInvokeTarget({ + project: makeProject({ runtimes: [] }), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('No agents defined'); + }); + + it('returns error when multiple runtimes exist but no agentName specified', async () => { + const project = makeProject({ + runtimes: [ + { + name: 'agent-a', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'a.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + { + name: 'agent-b', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'b.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + + const result = await resolveInvokeTarget({ + project, + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('Multiple runtimes found'); + }); + + it('returns error when specified agent name does not exist', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + agentName: 'nonexistent', + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain("'nonexistent' not found"); + }); + + it('returns error when agent is not deployed to the target', async () => { + const deployedState = makeDeployedState({ + targets: { + default: { + resources: { + runtimes: {}, + }, + }, + }, + } as unknown as DeployedState); + + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState, + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain("'my-agent' is not deployed"); + }); + + it('resolves specific agent by name', async () => { + const project = makeProject({ + runtimes: [ + { + name: 'agent-a', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'a.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + { + name: 'agent-b', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'b.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + + const deployedState = { + targets: { + default: { + resources: { + runtimes: { + 'agent-a': { + runtimeId: 'rt-a', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-a', + roleArn: 'arn:aws:iam::123:role/r', + }, + 'agent-b': { + runtimeId: 'rt-b', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-b', + roleArn: 'arn:aws:iam::123:role/r', + }, + }, + }, + }, + }, + } as unknown as DeployedState; + + const result = await resolveInvokeTarget({ + project, + deployedState, + awsTargets: makeAwsTargets(), + agentName: 'agent-b', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.agentSpec.name).toBe('agent-b'); + expect(result.runtimeArn).toContain('rt-b'); + }); + + describe('CUSTOM_JWT token resolution', () => { + function makeJwtProject(): AgentCoreProjectSpec { + return makeProject({ + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'main.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { issuerUrl: 'https://issuer.example.com', audiences: ['aud'] }, + }, + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + } + + it('auto-fetches bearer token for CUSTOM_JWT agents', async () => { + mockedCanFetch.mockResolvedValue(true); + mockedFetchToken.mockResolvedValue({ token: 'jwt-token-123', expiresIn: 3600 }); + + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.bearerToken).toBe('jwt-token-123'); + expect(mockedCanFetch).toHaveBeenCalledWith('my-agent', undefined); + expect(mockedFetchToken).toHaveBeenCalledWith('my-agent', { deployTarget: 'default' }); + }); + + it('skips token fetch when bearerToken is already provided', async () => { + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + bearerToken: 'pre-existing-token', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.bearerToken).toBe('pre-existing-token'); + expect(mockedCanFetch).not.toHaveBeenCalled(); + }); + + it('returns error when canFetchRuntimeToken is false', async () => { + mockedCanFetch.mockResolvedValue(false); + + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('no bearer token is available'); + }); + + it('returns error when fetchRuntimeToken throws', async () => { + mockedCanFetch.mockResolvedValue(true); + mockedFetchToken.mockRejectedValue(new Error('token endpoint unreachable')); + + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('Auto-fetch failed'); + expect(result.error.message).toContain('token endpoint unreachable'); + }); + }); + + describe('session ID generation', () => { + it('generates session ID when bearer token is present and no session ID provided', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + bearerToken: 'some-token', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.sessionId).toBe('generated-session-id'); + }); + + it('preserves provided session ID even with bearer token', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + bearerToken: 'some-token', + sessionId: 'my-session', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.sessionId).toBe('my-session'); + }); + + it('does not generate session ID when no bearer token', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.sessionId).toBeUndefined(); + }); + }); + + describe('config bundle baggage', () => { + it('constructs baggage when a config bundle is associated with the agent', async () => { + const project = makeProject({ + configBundles: [ + { + name: 'my-bundle', + components: { '{{runtime:my-agent}}': { type: 'inference-profile' } }, + }, + ], + } as unknown as Partial); + + const deployedState = { + targets: { + default: { + resources: { + runtimes: { + 'my-agent': { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123', + roleArn: 'arn:aws:iam::123456789:role/test-role', + }, + }, + configBundles: { + 'my-bundle': { + bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:123:config-bundle/cb-1', + versionId: 'v2', + }, + }, + }, + }, + }, + } as unknown as DeployedState; + + const result = await resolveInvokeTarget({ + project, + deployedState, + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.baggage).toContain('aws.agentcore.configbundle_arn='); + expect(result.baggage).toContain('aws.agentcore.configbundle_version='); + expect(result.baggage).toContain( + encodeURIComponent('arn:aws:bedrock-agentcore:us-east-1:123:config-bundle/cb-1') + ); + expect(result.baggage).toContain(encodeURIComponent('v2')); + }); + + it('returns no baggage when no config bundle is associated', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.baggage).toBeUndefined(); + }); + }); + + it('passes configIO to token fetch functions when provided', async () => { + const project = makeProject({ + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'main.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { issuerUrl: 'https://issuer.example.com', audiences: ['aud'] }, + }, + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + + mockedCanFetch.mockResolvedValue(true); + mockedFetchToken.mockResolvedValue({ token: 'tok', expiresIn: 3600 }); + + const fakeConfigIO = {} as any; + await resolveInvokeTarget({ + project, + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + configIO: fakeConfigIO, + }); + + expect(mockedCanFetch).toHaveBeenCalledWith('my-agent', { configIO: fakeConfigIO }); + expect(mockedFetchToken).toHaveBeenCalledWith('my-agent', { configIO: fakeConfigIO, deployTarget: 'default' }); + }); +}); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index ad21c7113..70523565f 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -1,4 +1,4 @@ -import { ConfigIO, ResourceNotFoundError, ValidationError } from '../../../lib'; +import { ConfigIO, ValidationError } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; import { buildAguiRunInput, @@ -13,8 +13,7 @@ import { } from '../../aws'; import { InvokeLogger } from '../../logging'; import { formatMcpToolList } from '../../operations/dev/utils'; -import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; -import { generateSessionId } from '../../operations/session'; +import { resolveInvokeTarget } from './resolve'; import type { InvokeOptions, InvokeResult } from './types'; export interface InvokeContext { @@ -40,62 +39,26 @@ export async function loadInvokeConfig(configIO: ConfigIO = new ConfigIO()): Pro export async function handleInvoke(context: InvokeContext, options: InvokeOptions = {}): Promise { const { project, deployedState, awsTargets } = context; - // Resolve target - const targetNames = Object.keys(deployedState.targets); - if (targetNames.length === 0) { - return { - success: false, - error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), - }; - } - - const selectedTargetName = options.targetName ?? targetNames[0]!; - - if (options.targetName && !targetNames.includes(options.targetName)) { - return { - success: false, - error: new ResourceNotFoundError( - `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}` - ), - }; - } - - const targetState = deployedState.targets[selectedTargetName]; - const targetConfig = awsTargets.find(t => t.name === selectedTargetName); - - if (!targetConfig) { - return { - success: false, - error: new ResourceNotFoundError(`Target config '${selectedTargetName}' not found in aws-targets`), - }; - } - - if (project.runtimes.length === 0) { - return { success: false, error: new ValidationError('No agents defined in configuration') }; - } - - // Resolve agent - const agentNames = project.runtimes.map(a => a.name); - - if (!options.agentName && project.runtimes.length > 1) { - return { - success: false, - error: new ValidationError(`Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}`), - }; - } - - const agentSpec = options.agentName ? project.runtimes.find(a => a.name === options.agentName) : project.runtimes[0]; + const resolved = await resolveInvokeTarget({ + project, + deployedState, + awsTargets, + agentName: options.agentName, + targetName: options.targetName, + bearerToken: options.bearerToken, + sessionId: options.sessionId, + }); - if (options.agentName && !agentSpec) { - return { - success: false, - error: new ResourceNotFoundError(`Agent '${options.agentName}' not found. Available: ${agentNames.join(', ')}`), - }; + if (!resolved.success) { + return { success: false, error: resolved.error }; } - if (!agentSpec) { - return { success: false, error: new ValidationError('No agents defined in configuration') }; - } + const { agentSpec, targetName: selectedTargetName, targetConfig, runtimeArn, baggage } = resolved; + options = { + ...options, + bearerToken: resolved.bearerToken ?? options.bearerToken, + sessionId: resolved.sessionId ?? options.sessionId, + }; // Warn about VPC mode endpoint requirements if (agentSpec.networkMode === 'VPC') { @@ -104,69 +67,11 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption ); } - // Get the deployed state for this specific agent - const agentState = targetState?.resources?.runtimes?.[agentSpec.name]; - - if (!agentState) { - return { - success: false, - error: new ValidationError(`Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'`), - }; - } - - // Build config bundle baggage if a bundle is associated with this agent - const deployedBundles = targetState?.resources?.configBundles ?? {}; - let baggage: string | undefined; - const bundleSpec = project.configBundles?.find(b => { - const keys = Object.keys(b.components ?? {}); - return keys.some(k => k === `{{runtime:${agentSpec.name}}}`); - }); - if (bundleSpec) { - const bundleState = deployedBundles[bundleSpec.name]; - if (bundleState?.bundleArn && bundleState?.versionId) { - baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; - } - } - - // Auto-fetch bearer token for CUSTOM_JWT agents when not provided - if (agentSpec.authorizerType === 'CUSTOM_JWT' && !options.bearerToken) { - const canFetch = await canFetchRuntimeToken(agentSpec.name); - if (canFetch) { - try { - const tokenResult = await fetchRuntimeToken(agentSpec.name, { deployTarget: selectedTargetName }); - options = { ...options, bearerToken: tokenResult.token }; - } catch (err) { - return { - success: false, - error: new ValidationError( - `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`, - { cause: err } - ), - }; - } - } else { - return { - success: false, - error: new ValidationError( - `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.` - ), - }; - } - } - - // When invoking with a bearer token (OAuth/CUSTOM_JWT), AgentCore does not - // auto-generate a runtime session ID the way it does for SigV4 callers. Templates - // that wire up AgentCoreMemorySessionManager require a non-null session_id, so - // generate one here if the caller didn't pass --session-id. - if (options.bearerToken && !options.sessionId) { - options = { ...options, sessionId: generateSessionId() }; - } - // Exec mode: run shell command in runtime container if (options.exec) { const logger = new InvokeLogger({ agentName: agentSpec.name, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, region: targetConfig.region, sessionId: options.sessionId, }); @@ -179,7 +84,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption try { const result = await executeBashCommand({ region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, command, sessionId: options.sessionId, timeout: options.timeout, @@ -276,7 +181,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (agentSpec.protocol === 'MCP') { const mcpOpts = { region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, userId: options.userId, headers: options.headers, bearerToken: options.bearerToken, @@ -360,7 +265,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption const a2aResult = await invokeA2ARuntime( { region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, userId: options.userId, sessionId: options.sessionId, headers: options.headers, @@ -395,7 +300,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (agentSpec.protocol === 'AGUI') { const logger = new InvokeLogger({ agentName: agentSpec.name, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, region: targetConfig.region, }); @@ -406,7 +311,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption const aguiResult = await invokeAguiRuntime( { region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, sessionId: options.sessionId, userId: options.userId, logger, @@ -464,7 +369,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption // Create logger for this invocation const logger = new InvokeLogger({ agentName: agentSpec.name, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, region: targetConfig.region, sessionId: options.sessionId, }); @@ -477,7 +382,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption try { const result = await invokeAgentRuntimeStreaming({ region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, payload: options.prompt, sessionId: options.sessionId, userId: options.userId, @@ -512,7 +417,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption // Non-streaming mode const response = await invokeAgentRuntime({ region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, payload: options.prompt, sessionId: options.sessionId, userId: options.userId, diff --git a/src/cli/commands/invoke/index.ts b/src/cli/commands/invoke/index.ts index a4b4bd48e..9847bd3f0 100644 --- a/src/cli/commands/invoke/index.ts +++ b/src/cli/commands/invoke/index.ts @@ -1,4 +1,6 @@ export { registerInvoke } from './command'; export { handleInvoke, loadInvokeConfig } from './action'; export type { InvokeContext } from './action'; +export { resolveInvokeTarget } from './resolve'; +export type { ResolveInvokeInput, ResolvedInvokeTarget, ResolveInvokeResult } from './resolve'; export type { InvokeResult, InvokeOptions } from './types'; diff --git a/src/cli/commands/invoke/resolve.ts b/src/cli/commands/invoke/resolve.ts new file mode 100644 index 000000000..4b76c4a6d --- /dev/null +++ b/src/cli/commands/invoke/resolve.ts @@ -0,0 +1,164 @@ +import { ResourceNotFoundError, ValidationError } from '../../../lib'; +import type { ConfigIO } from '../../../lib'; +import type { + AgentCoreProjectSpec, + AgentEnvSpec, + AwsDeploymentTarget, + AwsDeploymentTargets, + DeployedState, +} from '../../../schema'; +import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; +import { generateSessionId } from '../../operations/session'; + +export interface ResolveInvokeInput { + project: AgentCoreProjectSpec; + deployedState: DeployedState; + awsTargets: AwsDeploymentTargets; + agentName?: string; + targetName?: string; + bearerToken?: string; + sessionId?: string; + configIO?: ConfigIO; +} + +export interface ResolvedInvokeTarget { + agentSpec: AgentEnvSpec; + targetName: string; + targetConfig: AwsDeploymentTarget; + region: string; + runtimeArn: string; + bearerToken?: string; + sessionId?: string; + baggage?: string; +} + +export type ResolveInvokeResult = ({ success: true } & ResolvedInvokeTarget) | { success: false; error: Error }; + +export async function resolveInvokeTarget(input: ResolveInvokeInput): Promise { + const { project, deployedState, awsTargets } = input; + + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + return { + success: false, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; + } + + const selectedTargetName = input.targetName ?? targetNames[0]!; + + if (input.targetName && !targetNames.includes(input.targetName)) { + return { + success: false, + error: new ResourceNotFoundError(`Target '${input.targetName}' not found. Available: ${targetNames.join(', ')}`), + }; + } + + const targetState = deployedState.targets[selectedTargetName]; + const targetConfig = awsTargets.find(t => t.name === selectedTargetName); + + if (!targetConfig) { + return { + success: false, + error: new ResourceNotFoundError(`Target config '${selectedTargetName}' not found in aws-targets`), + }; + } + + if (project.runtimes.length === 0) { + return { success: false, error: new ValidationError('No agents defined in configuration') }; + } + + const agentNames = project.runtimes.map(a => a.name); + + if (!input.agentName && project.runtimes.length > 1) { + return { + success: false, + error: new ValidationError(`Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}`), + }; + } + + const agentSpec = input.agentName ? project.runtimes.find(a => a.name === input.agentName) : project.runtimes[0]; + + if (input.agentName && !agentSpec) { + return { + success: false, + error: new ResourceNotFoundError(`Agent '${input.agentName}' not found. Available: ${agentNames.join(', ')}`), + }; + } + + if (!agentSpec) { + return { success: false, error: new ValidationError('No agents defined in configuration') }; + } + + const agentState = targetState?.resources?.runtimes?.[agentSpec.name]; + + if (!agentState) { + return { + success: false, + error: new ValidationError(`Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'`), + }; + } + + // Build config bundle baggage if a bundle is associated with this agent + const deployedBundles = targetState?.resources?.configBundles ?? {}; + let baggage: string | undefined; + const bundleSpec = project.configBundles?.find(b => { + const keys = Object.keys(b.components ?? {}); + return keys.some(k => k === `{{runtime:${agentSpec.name}}}`); + }); + if (bundleSpec) { + const bundleState = deployedBundles[bundleSpec.name]; + if (bundleState?.bundleArn && bundleState?.versionId) { + baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; + } + } + + // Auto-fetch bearer token for CUSTOM_JWT agents when not provided + let bearerToken = input.bearerToken; + if (agentSpec.authorizerType === 'CUSTOM_JWT' && !bearerToken) { + const fetchOpts = input.configIO ? { configIO: input.configIO } : undefined; + const canFetch = await canFetchRuntimeToken(agentSpec.name, fetchOpts); + if (canFetch) { + try { + const tokenResult = await fetchRuntimeToken(agentSpec.name, { + ...fetchOpts, + deployTarget: selectedTargetName, + }); + bearerToken = tokenResult.token; + } catch (err) { + return { + success: false, + error: new ValidationError( + `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`, + { cause: err } + ), + }; + } + } else { + return { + success: false, + error: new ValidationError( + `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.` + ), + }; + } + } + + // When invoking with a bearer token, generate a session ID if not provided + let sessionId = input.sessionId; + if (bearerToken && !sessionId) { + sessionId = generateSessionId(); + } + + return { + success: true, + agentSpec, + targetName: selectedTargetName, + targetConfig, + region: targetConfig.region, + runtimeArn: agentState.runtimeArn, + bearerToken, + sessionId, + baggage, + }; +} diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index 3a6b70ed9..da6335101 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -1,5 +1,9 @@ +import { ConfigIO } from '../../../../../lib'; +import { invokeA2ARuntime, invokeAgentRuntimeStreaming, invokeAguiRuntime } from '../../../../aws/agentcore'; +import { buildAguiRunInput } from '../../../../aws/agui-types'; +import { resolveInvokeTarget } from '../../../../commands/invoke/resolve'; import { extractSSEEventText, extractTaskText, isStatusUpdateEvent } from '../../invoke-a2a'; -import type { RouteContext } from './route-context'; +import { type RouteContext, parseRequestUrl } from './route-context'; import { randomUUID } from 'node:crypto'; import { type IncomingMessage, type ServerResponse, request as httpRequest } from 'node:http'; @@ -8,6 +12,7 @@ let a2aRequestId = 1; /** * POST /invocations — proxy to the selected agent. * Body must include agentName to route to the correct running agent. + * When ?target=deployed is set, invokes the deployed runtime via the AWS SDK. */ export async function handleInvocations( ctx: RouteContext, @@ -15,6 +20,11 @@ export async function handleInvocations( res: ServerResponse, origin?: string ): Promise { + const { param } = parseRequestUrl(req); + if (param('target') === 'deployed') { + return handleDeployedInvocation(ctx, req, res, origin); + } + const body = await ctx.readBody(req); let agentPort: number | undefined; @@ -331,3 +341,244 @@ async function handleAguiInvocation( proxyReq.end(); }); } + +/** + * Invoke a deployed agent runtime via the AWS SDK. + * Reuses the same SSE response format as local invocations so the frontend + * can parse both paths identically. + */ +async function handleDeployedInvocation( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { configRoot } = ctx.options; + if (!configRoot) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'No agentcore project found' })); + return; + } + + const rawBody = await ctx.readBody(req); + let agentName: string | undefined; + let prompt: string | undefined; + let sessionId: string | undefined; + let userId: string | undefined; + try { + const parsed = JSON.parse(rawBody) as { + agentName?: string; + prompt?: string; + sessionId?: string; + userId?: string; + }; + agentName = parsed.agentName; + prompt = parsed.prompt; + sessionId = parsed.sessionId; + userId = parsed.userId; + } catch { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Invalid JSON body' })); + return; + } + + if (!prompt) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'prompt is required' })); + return; + } + + const configIO = new ConfigIO({ baseDir: configRoot }); + let project; + let deployedState; + let awsTargets; + try { + project = await configIO.readProjectSpec(); + deployedState = await configIO.readDeployedState(); + awsTargets = await configIO.readAWSDeploymentTargets(); + } catch (err) { + ctx.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: `Failed to load config: ${err instanceof Error ? err.message : String(err)}`, + }) + ); + return; + } + + const resolved = await resolveInvokeTarget({ + project, + deployedState, + awsTargets, + agentName, + sessionId, + configIO, + }); + + if (!resolved.success) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: resolved.error.message })); + return; + } + + sessionId ??= resolved.sessionId ?? randomUUID(); + + try { + const protocol = resolved.agentSpec.protocol ?? 'HTTP'; + + if (protocol === 'A2A') { + await handleDeployedA2AInvocation(ctx, res, origin, { + region: resolved.region, + runtimeArn: resolved.runtimeArn, + prompt, + sessionId, + userId, + }); + } else if (protocol === 'AGUI') { + await handleDeployedAguiInvocation(ctx, res, origin, { + region: resolved.region, + runtimeArn: resolved.runtimeArn, + prompt, + sessionId, + userId, + bearerToken: resolved.bearerToken, + }); + } else { + await handleDeployedHttpInvocation(ctx, res, origin, { + region: resolved.region, + runtimeArn: resolved.runtimeArn, + prompt, + sessionId, + userId, + bearerToken: resolved.bearerToken, + }); + } + } catch (err) { + if (!res.headersSent) { + ctx.setCorsHeaders(res, origin); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: `Invoke failed: ${err instanceof Error ? err.message : String(err)}`, + }) + ); + } + } +} + +interface DeployedInvokeParams { + region: string; + runtimeArn: string; + prompt: string; + sessionId?: string; + userId?: string; + bearerToken?: string; +} + +async function handleDeployedHttpInvocation( + ctx: RouteContext, + res: ServerResponse, + origin: string | undefined, + params: DeployedInvokeParams +): Promise { + const result = await invokeAgentRuntimeStreaming({ + region: params.region, + runtimeArn: params.runtimeArn, + payload: params.prompt, + sessionId: params.sessionId, + userId: params.userId, + bearerToken: params.bearerToken, + }); + + ctx.setCorsHeaders(res, origin); + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + if (result.sessionId) { + headers['x-session-id'] = result.sessionId; + } + res.writeHead(200, headers); + + for await (const chunk of result.stream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end(); +} + +async function handleDeployedA2AInvocation( + ctx: RouteContext, + res: ServerResponse, + origin: string | undefined, + params: DeployedInvokeParams +): Promise { + const result = await invokeA2ARuntime( + { + region: params.region, + runtimeArn: params.runtimeArn, + userId: params.userId, + sessionId: params.sessionId, + }, + params.prompt + ); + + ctx.setCorsHeaders(res, origin); + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + if (result.sessionId) { + headers['x-session-id'] = result.sessionId; + } + res.writeHead(200, headers); + + for await (const chunk of result.stream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end(); +} + +async function handleDeployedAguiInvocation( + ctx: RouteContext, + res: ServerResponse, + origin: string | undefined, + params: DeployedInvokeParams +): Promise { + const input = buildAguiRunInput(params.prompt, params.sessionId); + const result = await invokeAguiRuntime( + { + region: params.region, + runtimeArn: params.runtimeArn, + sessionId: params.sessionId, + userId: params.userId, + bearerToken: params.bearerToken, + }, + input + ); + + ctx.setCorsHeaders(res, origin); + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + if (result.sessionId) { + headers['x-session-id'] = result.sessionId; + } + res.writeHead(200, headers); + + // Pipe raw AGUI events through — frontend parses them directly + for await (const chunk of result.textStream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end(); +} diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index fe845194f..f5332715a 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -340,7 +340,7 @@ 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 === '/invocations') { + } else if (req.method === 'POST' && (req.url === '/invocations' || req.url?.startsWith('/invocations?'))) { await handleInvocations(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/api/mcp') { await handleMcpProxy(ctx, req, res, origin);