diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 07537202d..4c5501b6f 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -509,166 +509,64 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } // ============================================================================ -// Harness Invoke +// Shared Harness Helpers // ============================================================================ -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: 'No harnesses defined in configuration' }; - } - - // Resolve harness name — explicit flag, or auto-infer if only one - let harnessName = options.harnessName; - if (!harnessName) { - if (harnessEntries.length > 1) { - const names = harnessEntries.map(h => h.name); - return { - success: false, - error: `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: `Harness '${harnessName}' not found. Available: ${names.join(', ')}`, - }; - } - - // Get deployed state for this harness - const harnessState = targetState?.resources?.harnesses?.[harnessName]; - if (!harnessState) { - return { - success: false, - error: `Harness '${harnessName}' is not deployed to target '${selectedTargetName}'. Run \`agentcore deploy\` first.`, - }; - } - - // Read harness spec for auth config - const configIO = new ConfigIO(); - let harnessSpec; - try { - harnessSpec = await configIO.readHarnessSpec(harnessName); - } catch { - // If we can't read the spec, continue without auto-fetch - } - - // Auto-fetch bearer token for CUSTOM_JWT harnesses when not provided - 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: `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: `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.`, - }; - } - } - - // Exec mode: run shell command on harness VM via InvokeAgentRuntimeCommand - if (options.exec) { - const command = options.prompt; - if (!command) { - return { - success: false, - error: '--exec requires a command. Usage: agentcore invoke --exec --harness "ls -la"', - }; - } - - try { - const result = await executeBashCommand({ - region: targetConfig.region, - runtimeArn: harnessState.harnessArn, - command, - sessionId: options.sessionId, - timeout: options.timeout, - }); - - let stdout = ''; - let stderr = ''; - let exitCode: number | undefined; - let status: string | undefined; - - for await (const event of result.stream) { - switch (event.type) { - case 'stdout': - if (event.data) { - stdout += event.data; - if (!options.json) process.stdout.write(event.data); - } - break; - case 'stderr': - if (event.data) { - stderr += event.data; - if (!options.json) process.stderr.write(event.data); - } - break; - case 'stop': - exitCode = event.exitCode; - status = event.status; - break; - } - } - - if (options.json) { - return { - success: exitCode === 0, - targetName: selectedTargetName, - response: JSON.stringify({ stdout, stderr, exitCode, status }), - }; - } - - if (exitCode !== 0) { - return { - success: false, - targetName: selectedTargetName, - error: `Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`, - }; - } +interface HarnessModel { + provider?: string; + modelId?: string; + apiKeyArn?: string; +} - return { success: true, targetName: selectedTargetName }; - } catch (err) { - return { success: false, error: `Exec failed: ${err instanceof Error ? err.message : String(err)}` }; +function buildHarnessBaseOpts( + options: InvokeOptions, + harnessSpec?: HarnessModel +): 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.prompt) { - return { success: false, error: 'No prompt provided. Usage: agentcore invoke --harness "your prompt"' }; + if (options.tools) { + baseOpts.tools = options.tools.split(',').map(t => { + const type = t.trim(); + return { type, name: TOOL_TYPE_DEFAULT_NAMES[type] ?? 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.skills) baseOpts.skills = options.skills.split(',').map(p => ({ path: p.trim() })); + 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; +} - const sessionId = options.sessionId ?? randomUUID(); - const region = targetConfig.region; - - const logger = new InvokeLogger({ - agentName: harnessName, - runtimeArn: harnessState.harnessArn, - region, - sessionId, - }); - logger.logPrompt(options.prompt, sessionId, options.userId); +interface StreamHarnessParams { + region: string; + harnessArn: string; + sessionId: string; + prompt: string; + options: InvokeOptions; + logger: InvokeLogger; + baseOpts: Partial; +} - let fullResponse = ''; +async function streamHarnessInvoke(params: StreamHarnessParams): Promise { + const { region, harnessArn, sessionId, prompt, options, logger, baseOpts } = params; const dim = '\x1b[2m'; const reset = '\x1b[0m'; const cyan = '\x1b[36m'; @@ -693,42 +591,13 @@ async function handleHarnessInvoke( } }; + let fullResponse = ''; + try { const messages: { role: string; content: Record[] }[] = [ - { role: 'user', content: [{ text: options.prompt }] }, + { role: 'user', content: [{ text: prompt }] }, ]; - const baseOpts: Partial = {}; - if (options.modelId || options.modelProvider || options.apiKeyArn) { - const provider = options.modelProvider ?? harnessSpec?.model?.provider; - const modelId = options.modelId ?? harnessSpec?.model?.modelId ?? ''; - const apiKeyArn = options.apiKeyArn ?? harnessSpec?.model?.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: TOOL_TYPE_DEFAULT_NAMES[type] ?? 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.skills) baseOpts.skills = options.skills.split(',').map(p => ({ path: p.trim() })); - 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; - let pendingToolUseId: string | undefined; let pendingToolName: string | undefined; let pendingToolInput = ''; @@ -739,7 +608,7 @@ async function handleHarnessInvoke( const stream = invokeHarness({ region, - harnessArn: harnessState.harnessArn, + harnessArn, runtimeSessionId: sessionId, messages, bearerToken: options.bearerToken, @@ -847,13 +716,13 @@ async function handleHarnessInvoke( if (options.json) { return { success: true, - targetName: selectedTargetName, response: JSON.stringify({ text: fullResponse, sessionId }), + sessionId, logFilePath: logger.logFilePath, }; } - return { success: true, targetName: selectedTargetName, logFilePath: logger.logFilePath }; + return { success: true, logFilePath: logger.logFilePath }; } catch (err) { clearSpinner(); logger.logError(err, 'harness invoke failed'); @@ -864,3 +733,205 @@ async function handleHarnessInvoke( }; } } + +// ============================================================================ +// Direct Harness Invoke by ARN (no project required) +// ============================================================================ + +export async function handleHarnessInvokeByArn( + harnessArn: string, + region: string, + options: InvokeOptions +): Promise { + if (!options.prompt) { + return { + success: false, + error: '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 }); +} + +// ============================================================================ +// Harness Invoke +// ============================================================================ + +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: 'No harnesses defined in configuration' }; + } + + // Resolve harness name — explicit flag, or auto-infer if only one + let harnessName = options.harnessName; + if (!harnessName) { + if (harnessEntries.length > 1) { + const names = harnessEntries.map(h => h.name); + return { + success: false, + error: `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: `Harness '${harnessName}' not found. Available: ${names.join(', ')}`, + }; + } + + // Get deployed state for this harness + const harnessState = targetState?.resources?.harnesses?.[harnessName]; + if (!harnessState) { + return { + success: false, + error: `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 (err) { + logger.logInfo( + `Could not read harness spec for '${harnessName}': ${err instanceof Error ? err.message : String(err)}` + ); + } + + // Auto-fetch bearer token for CUSTOM_JWT harnesses when not provided + 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: `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: `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.`, + }; + } + } + + // Exec mode: run shell command on harness VM via InvokeAgentRuntimeCommand + if (options.exec) { + const command = options.prompt; + if (!command) { + return { + success: false, + error: '--exec requires a command. Usage: agentcore invoke --exec --harness "ls -la"', + }; + } + + try { + const result = await executeBashCommand({ + region: targetConfig.region, + runtimeArn: harnessState.harnessArn, + command, + sessionId: options.sessionId, + timeout: options.timeout, + }); + + let stdout = ''; + let stderr = ''; + let exitCode: number | undefined; + let status: string | undefined; + + for await (const event of result.stream) { + switch (event.type) { + case 'stdout': + if (event.data) { + stdout += event.data; + if (!options.json) process.stdout.write(event.data); + } + break; + case 'stderr': + if (event.data) { + stderr += event.data; + if (!options.json) process.stderr.write(event.data); + } + break; + case 'stop': + exitCode = event.exitCode; + status = event.status; + break; + } + } + + if (options.json) { + return { + success: exitCode === 0, + targetName: selectedTargetName, + response: JSON.stringify({ stdout, stderr, exitCode, status }), + }; + } + + if (exitCode !== 0) { + return { + success: false, + targetName: selectedTargetName, + error: `Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`, + }; + } + + return { success: true, targetName: selectedTargetName }; + } catch (err) { + return { success: false, error: `Exec failed: ${err instanceof Error ? err.message : String(err)}` }; + } + } + + if (!options.prompt) { + return { success: false, error: '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 13e91ccfb..390fe7b2e 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -3,7 +3,7 @@ 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 { handleInvoke, loadInvokeConfig } from './action'; +import { handleHarnessInvokeByArn, handleInvoke, loadInvokeConfig } from './action'; import { resolvePrompt } from './resolve-prompt'; import type { InvokeOptions } from './types'; import { validateInvokeOptions } from './validate'; @@ -41,6 +41,26 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { let spinner: NodeJS.Timeout | undefined; try { + if (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); + } + const result = await handleHarnessInvokeByArn(options.harnessArn, region, options); + if (options.json) { + console.log(JSON.stringify(result)); + } else if (!result.success && result.error) { + console.error(result.error); + } + process.exit(result.success ? 0 : 1); + } + const context = await loadInvokeConfig(); // Show spinner for non-streaming, non-json, non-exec invocations @@ -131,6 +151,8 @@ export const registerInvoke = (program: Command) => { ) .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]') .option('--harness ', 'Select specific harness to invoke [non-interactive]') + .option('--harness-arn ', 'Invoke a harness by ARN (no project required) [non-interactive]') + .option('--region ', 'AWS region (required with --harness-arn when no project) [non-interactive]') .option('--verbose', 'Print verbose streaming JSON events (harness only) [non-interactive]') .option('--model-id ', 'Override model for this invocation (harness only) [non-interactive]') .option( @@ -165,6 +187,8 @@ export const registerInvoke = (program: Command) => { header?: string[]; bearerToken?: string; harness?: string; + harnessArn?: string; + region?: string; verbose?: boolean; modelId?: string; modelProvider?: string; @@ -180,7 +204,9 @@ export const registerInvoke = (program: Command) => { } ) => { try { - requireProject(); + if (!cliOptions.harnessArn) { + requireProject(); + } // Resolve prompt from flag / positional / --prompt-file / stdin const resolved = await resolvePrompt({ flag: cliOptions.prompt, @@ -215,12 +241,15 @@ export const registerInvoke = (program: Command) => { cliOptions.exec || cliOptions.bearerToken || cliOptions.harness || + cliOptions.harnessArn || cliOptions.verbose ) { await handleInvokeCLI({ prompt, agentName: cliOptions.runtime, harnessName: cliOptions.harness, + harnessArn: cliOptions.harnessArn, + region: cliOptions.region, targetName: cliOptions.target ?? 'default', sessionId: cliOptions.sessionId, userId: cliOptions.userId, diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 12698c280..d88dc5f69 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -1,6 +1,10 @@ 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) */ diff --git a/src/cli/commands/invoke/validate.ts b/src/cli/commands/invoke/validate.ts index c931acc2a..bdeb6422f 100644 --- a/src/cli/commands/invoke/validate.ts +++ b/src/cli/commands/invoke/validate.ts @@ -6,11 +6,17 @@ export interface ValidationResult { } export function validateInvokeOptions(options: InvokeOptions): ValidationResult { + if (options.harnessArn && (options.harnessName || options.agentName)) { + return { valid: false, error: '--harness-arn cannot be combined with --harness or --runtime' }; + } + if (options.harnessArn && options.exec) { + return { valid: false, error: '--exec is not supported with --harness-arn' }; + } if (options.harnessName && options.agentName) { return { valid: false, error: '--harness and --runtime cannot be used together' }; } - if (options.verbose && !options.harnessName) { - return { valid: false, error: '--verbose is only supported with --harness' }; + if (options.verbose && !options.harnessName && !options.harnessArn) { + return { valid: false, error: '--verbose is only supported with --harness or --harness-arn' }; } if (options.exec && !options.prompt) { return { valid: false, error: 'A command is required with --exec. Usage: agentcore invoke --exec "ls -la"' };