diff --git a/README.md b/README.md index 3845176a7a..3135b17154 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Create a managed Harness project, deploy it, and send a prompt: agentcore create --name MyAssistant cd MyAssistant agentcore deploy -agentcore invoke harness --prompt "Hey, what can you do for me?" +agentcore invoke --harness MyAssistant --prompt "Hey, what can you do for me?" ``` To start with code you own instead, create a Runtime project from a template. @@ -64,8 +64,8 @@ agentcore invoke --help ``` Supported bare commands open their interactive flows in a terminal. Operation -flags select headless behavior for most commands. Project invoke commands can -use `--name` and `--target` to select a deployed agent for an interactive session. +flags select headless behavior for most commands. `invoke` can use `--runtime`, +`--harness`, `--gateway`, and `--target` to select a resource for an interactive session. Run `agentcore create` for guided setup. To create a default project without the wizard, run `agentcore create --name MyAssistant`. diff --git a/command.md b/command.md index 88b03fdb33..65d3cd898b 100644 --- a/command.md +++ b/command.md @@ -38,8 +38,6 @@ This reference was generated from `agentcore --help` for version `1.0.0-rc.4`. - [`agentcore dev`](#agentcore-dev) - [`agentcore deploy`](#agentcore-deploy) - [`agentcore invoke`](#agentcore-invoke) - - [`agentcore invoke runtime`](#agentcore-invoke-runtime) - - [`agentcore invoke harness`](#agentcore-invoke-harness) - [`agentcore log`](#agentcore-log) - [`agentcore log runtime`](#agentcore-log-runtime) - [`agentcore log harness`](#agentcore-log-harness) @@ -647,58 +645,39 @@ deploy the project to AWS ### `agentcore invoke` ```text -agentcore invoke [options] [command] +agentcore invoke [options] ``` -invoke a Runtime or harness from the current project - -#### `agentcore invoke runtime` - -```text -agentcore invoke runtime [options] -``` - -invoke a Runtime from the current project +invoke a Runtime, harness, or Gateway **Options** -- `--name `: the logical project Runtime name -- `--local`: invoke a local Runtime development server (default: false) -- `--port `: local Runtime development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000) -- `--target `: project deployment target (default: default) +- `--runtime `: the Runtime to invoke: its name in this project, its ID, or its ARN +- `--harness `: the harness to invoke: its name in this project, its ID, or its ARN +- `--gateway `: the Gateway to invoke: its name in this project, its ID, or its ARN +- `--target `: project deployment target (default: "default") +- `--local`: invoke the local development server (project Runtime only) (default: false) +- `--port `: local development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000) - `--payload `: the inline payload to send - `--qualifier `: the Runtime endpoint qualifier - `--content-type `: the payload content type - `--accept `: the accepted response content type - `--session-id `: the Runtime session ID -- `--user-id `: the Runtime user ID (default "default") - `--header `: an ordered application header - `--bearer-token `: the CUSTOM\_JWT bearer token - `--mcp-session-id `: the MCP session ID - `--mcp-protocol-version `: the MCP protocol version +- `--output-file `: the response output file +- `--user-id `: the Runtime user ID (default "default") - `--mcp-method `: the MCP method - `--mcp-name `: the MCP tool, resource, or prompt name - `--trace-id `: the X-Ray trace ID - `--trace-parent `: the W3C trace parent - `--trace-state `: the W3C trace state - `--baggage `: the W3C baggage -- `--output-file `: the response output file - -#### `agentcore invoke harness` - -```text -agentcore invoke harness [options] -``` - -invoke a harness from the current project - -**Options** - -- `--name `: the logical project harness name -- `--target `: project deployment target (default: "default") - `--prompt `: the message to send to the harness -- `--session-id `: the Runtime session ID to continue (33-100 characters) -- `--qualifier `: the harness endpoint qualifier to invoke (default DEFAULT) +- `--path `: the path relative to the Gateway origin +- `--method `: the HTTP request method ### `agentcore log` diff --git a/e2eTest/project/templates.test.ts b/e2eTest/project/templates.test.ts index 66d2e98aa2..e4b9b4764e 100644 --- a/e2eTest/project/templates.test.ts +++ b/e2eTest/project/templates.test.ts @@ -244,10 +244,9 @@ describe( await cli.run( [ "invoke", - "runtime", - "--local", - "--name", + "--runtime", runtime.name, + "--local", "--port", String(port), "--session-id", @@ -288,8 +287,7 @@ describe( await cli.run( [ "invoke", - "runtime", - "--name", + "--runtime", runtime.name, "--session-id", sessionId, diff --git a/src/assets/templates/agent-python-langchain/README.md b/src/assets/templates/agent-python-langchain/README.md index fe41ce72f7..edda56d76b 100644 --- a/src/assets/templates/agent-python-langchain/README.md +++ b/src/assets/templates/agent-python-langchain/README.md @@ -22,7 +22,7 @@ Run the agent locally from the project root: ```bash agentcore dev -agentcore invoke runtime --local --name {{name}} --payload '{"prompt":"What is 2 plus 3?"}' +agentcore invoke --runtime {{name}} --local --payload '{"prompt":"What is 2 plus 3?"}' ``` Environment variables for local development go in `agentcore/.env.local` @@ -32,7 +32,7 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore deploy -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` Traces are collected automatically: AgentCore Runtime starts the agent under diff --git a/src/assets/templates/agent-python-minimal/README.md b/src/assets/templates/agent-python-minimal/README.md index b8bc638f7b..1843460390 100644 --- a/src/assets/templates/agent-python-minimal/README.md +++ b/src/assets/templates/agent-python-minimal/README.md @@ -27,5 +27,5 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore deploy -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-python-strands/README.md b/src/assets/templates/agent-python-strands/README.md index 98120c90b3..51db8b5ddd 100644 --- a/src/assets/templates/agent-python-strands/README.md +++ b/src/assets/templates/agent-python-strands/README.md @@ -56,5 +56,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-typescript-strands/README.md b/src/assets/templates/agent-typescript-strands/README.md index 07d4845fb8..f92e83925f 100644 --- a/src/assets/templates/agent-typescript-strands/README.md +++ b/src/assets/templates/agent-typescript-strands/README.md @@ -46,5 +46,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke runtime --payload '{"prompt":"Hello!","actorId":"user-123"}' +agentcore invoke --runtime {{runtimeName}} --payload '{"prompt":"Hello!","actorId":"user-123"}' ``` diff --git a/src/assets/templates/agui-python-strands/README.md b/src/assets/templates/agui-python-strands/README.md index 7b84681720..b1662331f5 100644 --- a/src/assets/templates/agui-python-strands/README.md +++ b/src/assets/templates/agui-python-strands/README.md @@ -32,7 +32,7 @@ health at `http://127.0.0.1:8080/ping`. runtime with an AG-UI `RunAgentInput` payload: ```bash -agentcore invoke runtime --name {{ name }} \ +agentcore invoke --runtime {{ name }} \ --payload '{"threadId":"t1","runId":"r1","state":{},"messages":[{"id":"m1","role":"user","content":"Hello!"}],"tools":[],"context":[],"forwardedProps":{}}' ``` diff --git a/src/assets/templates/export-harness-python/README.md b/src/assets/templates/export-harness-python/README.md index 391f294ea2..bf32c7616d 100644 --- a/src/assets/templates/export-harness-python/README.md +++ b/src/assets/templates/export-harness-python/README.md @@ -42,5 +42,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/mcp-python-fastmcp/README.md b/src/assets/templates/mcp-python-fastmcp/README.md index 1497916786..2fca1de442 100644 --- a/src/assets/templates/mcp-python-fastmcp/README.md +++ b/src/assets/templates/mcp-python-fastmcp/README.md @@ -29,4 +29,4 @@ sending JSON-RPC to `http://127.0.0.1:8000/mcp`. ## Deployment `agentcore deploy` deploys the server into Amazon Bedrock AgentCore. Invoke it with -`agentcore invoke runtime`, supplying an MCP JSON-RPC payload (e.g. `tools/list`, `tools/call`). +`agentcore invoke --runtime {{name}}`, supplying an MCP JSON-RPC payload (e.g. `tools/list`, `tools/call`). diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 59ddd6cc6e..588c35afaf 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1015,6 +1015,7 @@ describe("CdkBackend.resolveDeployedResources", () => { }, ], harnesses: [{ name: "support_agent", path: "app/support_agent" }], + agentCoreGateways: [{ name: "tools-gateway", targets: [] }], }); await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); const subject = harness({ @@ -1031,6 +1032,10 @@ describe("CdkBackend.resolveDeployedResources", () => { ExportName: "AgentCore-example-default-Harness-support-agent-Id", OutputValue: "support_agent-AbCdEf1234", }, + { + ExportName: "AgentCore-example-default-Gateway-tools-gateway-Id", + OutputValue: "tools-gateway-abcdef1234", + }, ], }, }); @@ -1052,6 +1057,13 @@ describe("CdkBackend.resolveDeployedResources", () => { target: TARGET, credentialProvider: subject.credentials, }, + { + resourceType: "gateway", + name: "tools-gateway", + id: "tools-gateway-abcdef1234", + target: TARGET, + credentialProvider: subject.credentials, + }, ]); expect(subject.stackReads).toHaveLength(1); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 2ffeae274a..bc4fb90ed7 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -108,10 +108,11 @@ function findDeployedResourceId( ): string | undefined { if (!stack.StackName) return undefined; const exportResourceName = input.name.replaceAll("_", "-"); - const exportName = - input.resourceType === "runtime" - ? `${stack.StackName}-${exportResourceName}-RuntimeId` - : `${stack.StackName}-Harness-${exportResourceName}-Id`; + const exportName = { + runtime: `${stack.StackName}-${exportResourceName}-RuntimeId`, + harness: `${stack.StackName}-Harness-${exportResourceName}-Id`, + gateway: `${stack.StackName}-Gateway-${exportResourceName}-Id`, + }[input.resourceType]; return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; } @@ -506,6 +507,10 @@ export class CdkBackend implements ProjectBackend { const resources = [ ...project.spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })), ...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })), + ...project.spec.agentCoreGateways.map(({ name }) => ({ + resourceType: "gateway" as const, + name, + })), ]; return resources.flatMap((resource) => { const id = findDeployedResourceId(stack, resource); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 22f6b8f03a..927be565fb 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -24,6 +24,7 @@ import type { RemoveResourceResult, RemoveResourcesResult, } from "../../handlers/project/types"; +import { RESOURCE_LABELS } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { FsReadWriteJson, @@ -1044,12 +1045,12 @@ export class FsProjectManager implements ProjectManager { ({ resourceType, name }) => resourceType === input.resourceType && name === input.name, ); // The declared target wins over the copy on the item: the manager resolved it - // from aws-targets.json, and both invoke handlers pin the AWS region from this - // value while reusing the backend's verified credential provider. Trusting a - // backend's target echo would let it redirect the call. + // from aws-targets.json, and the invoke, log, and traces handlers pin the AWS + // region from this value while reusing the backend's verified credential + // provider. Trusting a backend's target echo would let it redirect the call. if (resource) return { ...resource, target: resolved.target }; - const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; + const label = RESOURCE_LABELS[input.resourceType]; throw new ProjectStateError( `${label} '${input.name}' is not deployed to target '${input.target}'. ` + `Run 'agentcore deploy --target ${input.target}' first.`, diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 15db1fef0a..59322b1797 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -122,7 +122,11 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa const tree = await FsTreeNode.fromAssetSource( { assetSource }, { assetDir: "templates/agent-python-minimal" }, - { rootDirName: input.name }, + { + rootDirName: input.name, + transformContent: (raw) => + templateRenderer.render(raw, { name: toPythonPackageName(input.name) }), + }, ); return { tree, spec: { runtimes: [buildRuntimeSpec(input)] } }; }, @@ -186,6 +190,7 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa const modelScaffold = resolveModelProviderScaffold(input); const context = { name: toNpmPackageName(input.name), + runtimeName: input.name, memoryEnvVarName: memory ? memoryEnvVarName(memory.name) : undefined, ...modelScaffold.templateRenderContext, }; diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx index 30614c5273..a025429c67 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -19,6 +19,34 @@ import { import { writeGatewayInvokeResponse } from "./response"; import { GatewayInvokeLaunchContextKey } from "./launchContext"; +export const invokeGatewayFlags = [ + flag("id", "the ID of the Gateway", gatewayIdSchema), + flag( + "path", + "the path relative to the Gateway origin", + z.string().min(1, "requires a nonempty path").optional(), + { sensitive: true }, + ), + flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional()), + flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the Gateway bearer token", z.string().optional(), { + sensitive: true, + }), + flag("session-id", "the Runtime target session ID", z.string().optional()), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), +] as const; + export const createInvokeGatewayHandler = ( core: Core, io: AppIO, @@ -27,33 +55,7 @@ export const createInvokeGatewayHandler = ( createHandler({ name: "invoke", description: "invoke an AgentCore Gateway", - flags: [ - flag("id", "the ID of the Gateway", gatewayIdSchema), - flag( - "path", - "the path relative to the Gateway origin", - z.string().min(1, "requires a nonempty path").optional(), - { sensitive: true }, - ), - flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional()), - flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the Gateway bearer token", z.string().optional(), { - sensitive: true, - }), - flag("session-id", "the Runtime target session ID", z.string().optional()), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], + flags: invokeGatewayFlags, handle: async (ctx, flags) => { const jsonOutput = ctx.require(JsonKey); if (flags.payload === undefined) { diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 6cdd905e91..28fd900fb4 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -161,9 +161,16 @@ export function GatewayInvokeScreen(props: ScreenProps) { type GatewayInvokeConsoleProps = ScreenProps & { gatewayId: string; initialContext?: GatewayInvokeLaunchContext; + onBack?: () => void; }; -function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayInvokeConsoleProps) { +export function GatewayInvokeConsole({ + ctx, + core, + gatewayId, + initialContext, + onBack, +}: GatewayInvokeConsoleProps) { const navigate = useNavigate(); const opts = coreOptsFromCtx(ctx); const { columns, rows } = useWindowSize(); @@ -385,6 +392,7 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI } if (key.escape) { if (abortRef.current) abortRef.current.abort(); + else if (onBack) onBack(); else navigate(invokePath()); return; } diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx index a18e263dfb..0eacc44ab9 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -9,24 +9,26 @@ import { runWithProgress } from "../../../tui/progress"; import { InputValidationError } from "../../../errors"; import { invokeHarnessTurn } from "./operation.ts"; +export const invokeHarnessFlags = [ + flag("id", "the ID of the harness", z.string().min(1).max(48)), + flag("prompt", "the message to send to the harness", z.string().optional()), + flag( + "session-id", + "the Runtime session ID to continue (33-100 characters)", + z.string().min(33).max(100).optional(), + ), + flag( + "qualifier", + "the harness endpoint qualifier to invoke (default DEFAULT)", + z.string().optional(), + ), +] as const; + export const createInvokeHarnessHandler = (core: Core, io: AppIO) => createHandler({ name: "invoke", description: "invoke a harness", - flags: [ - flag("id", "the ID of the harness", z.string().min(1).max(48)), - flag("prompt", "the message to send to the harness", z.string().optional()), - flag( - "session-id", - "the Runtime session ID to continue (33-100 characters)", - z.string().min(33).max(100).optional(), - ), - flag( - "qualifier", - "the harness endpoint qualifier to invoke (default DEFAULT)", - z.string().optional(), - ), - ], + flags: invokeHarnessFlags, handle: async (ctx, flags) => { // Without a prompt, open the interactive chat at this harness — resuming // the given session and targeting the given qualifier when passed. The diff --git a/src/handlers/project/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx deleted file mode 100644 index 0821e6b38f..0000000000 --- a/src/handlers/project/invoke/harness.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../errors"; -import type { AppIO } from "../../../io"; -import { createHandler, flag, ProjectKey } from "../../../router"; -import { JsonRendererKey, renderTuiAt } from "../../../tui"; -import { runWithProgress } from "../../../tui/progress"; -import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; -import { invokeHarnessTurn } from "../../harness/invoke/operation"; -import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; -import { selectProjectResource } from "../selection"; - -export const createProjectInvokeHarnessHandler = ( - core: Core, - io: AppIO, - renderInvokeTui: typeof renderTuiAt = renderTuiAt, -) => - createHandler({ - name: "harness", - description: "invoke a harness from the current project", - flags: [ - flag("name", "the logical project harness name", z.string().optional()), - flag("target", "project deployment target", z.string().default("default")), - flag("prompt", "the message to send to the harness", z.string().optional()), - flag( - "session-id", - "the Runtime session ID to continue (33-100 characters)", - z.string().min(33).max(100).optional(), - ), - flag( - "qualifier", - "the harness endpoint qualifier to invoke (default DEFAULT)", - z.string().optional(), - ), - ], - handle: async (ctx, flags) => { - const project = ctx.require(ProjectKey); - const name = selectProjectResource(project, "harness", flags.name, "invoke"); - const deployed = await core.projectManager.resolveDeployedResource(project, { - target: flags.target, - resourceType: "harness", - name, - }); - const invokeCtx = ctx - .withValue(RegionKey, deployed.target.region) - .withValue(AwsCredentialProviderKey, deployed.credentialProvider); - - if (!flags.prompt) { - if (invokeCtx.require(JsonKey)) { - throw new InputValidationError("required option '--prompt ' not specified"); - } - let path = `/agentcore/harness/invoke/${encodeURIComponent(deployed.id)}`; - if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; - if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; - await renderInvokeTui(path, invokeCtx, core, io); - return; - } - - const prompt = flags.prompt; - const invoke = () => - invokeHarnessTurn( - core.harness, - { - harnessId: deployed.id, - prompt, - qualifier: flags.qualifier, - sessionId: flags["session-id"], - }, - coreOptsFromCtx(invokeCtx), - ); - const result = await runWithProgress(invoke, { - io, - label: "Invoking harness...", - interactive: !ctx.require(JsonKey), - }); - invokeCtx.require(JsonRendererKey).renderJson(result); - }, - }); diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index b8125865db..031586280a 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -2,17 +2,16 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { PassThrough } from "node:stream"; -import type { InvokeHarnessRequest } from "@aws-sdk/client-bedrock-agentcore"; import type { GetAgentRuntimeResponse, + GetGatewayResponse, GetHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { ProjectBackend, ResolveDeployedResourcesBackendInput } from "../../../core/project"; +import type { ProjectBackend } from "../../../core/project"; import { ExitCode } from "../../../errors"; import { startHttpServer, type HttpServerHandle } from "../../../io"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { runWithExitCode } from "../../../runnable"; -import { ProjectKey, ValueContext, type Context } from "../../../router"; import { createSilentLogger, TestCoreClient, @@ -23,14 +22,9 @@ import { tick, waitFor, } from "../../../testing"; +import * as tui from "../../../tui"; import { createRootHandler } from "../../index"; -import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; -import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; -import type { RuntimeInvokeRequest } from "../../runtime/types"; -import type { Project } from "../types"; -import { createProjectInvokeHandler } from "."; -import { createProjectInvokeHarnessHandler } from "./harness"; -import { createProjectInvokeRuntimeHandler } from "./runtime"; +import { RegionKey } from "../../keys"; const servers: HttpServerHandle[] = []; const cleanups: Array<() => Promise> = []; @@ -40,14 +34,15 @@ const TARGET = { account: "111122223333", region: "eu-west-1", } as const; +const arn = (resource: string) => + `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:${resource}`; const TARGET_CREDENTIALS = async () => ({ accessKeyId: "target-access-key", secretAccessKey: "target-secret-key", }); const RUNTIME_ID = "checkout-AbCdEf1234"; -const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:runtime/${RUNTIME_ID}`; const HARNESS_ID = "support-AbCdEf1234"; -const HARNESS_ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:harness/${HARNESS_ID}`; +const GATEWAY_ID = "tools-AbCdEf1234"; const RUNTIME = { name: "checkout", build: "CodeZip", @@ -56,33 +51,23 @@ const RUNTIME = { runtimeVersion: "PYTHON_3_14", } as const; const HARNESS = { name: "support", path: "app/support" } as const; +const GATEWAY = { name: "tools", targets: [] } as const; -function body(...chunks: Uint8Array[]): AsyncIterable { - return (async function* () { - yield* chunks; - })(); -} +type Resources = { + runtimes?: readonly unknown[]; + harnesses?: readonly unknown[]; + agentCoreGateways?: readonly unknown[]; +}; function header(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value.join(", ") : value; } -async function inProject( - resources: { - runtimes?: unknown[]; - harnesses?: unknown[]; - }, - options: { writeTargets?: boolean } = {}, -): Promise { - const { path: root, cleanup } = await inTempDirectory("agentcore-project-invoke-reduced-"); +async function inProject(resources: Resources, options: { writeTargets?: boolean } = {}) { + const { path: root, cleanup } = await inTempDirectory("agentcore-project-invoke-"); cleanups.push(cleanup); await mkdir(join(root, "agentcore"), { recursive: true }); - const spec = ProjectSpecSchema.parse({ - name: "orders", - version: 2, - runtimes: resources.runtimes ?? [], - harnesses: resources.harnesses ?? [], - }); + const spec = ProjectSpecSchema.parse({ name: "orders", version: 2, ...resources }); await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); if (options.writeTargets !== false) { await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify([TARGET])); @@ -90,7 +75,7 @@ async function inProject( } function backend() { - const calls: ResolveDeployedResourcesBackendInput[] = []; + const targets: string[] = []; const value: ProjectBackend = { async *build() {}, async *deploy() { @@ -98,54 +83,84 @@ function backend() { return { outputs: {} }; }, async resolveDeployedResources(project, input) { - calls.push(input); - return [ - ...project.spec.runtimes.map(({ name }) => ({ - resourceType: "runtime" as const, - name, - id: RUNTIME_ID, - target: input.target, - credentialProvider: TARGET_CREDENTIALS, - })), - ...project.spec.harnesses.map(({ name }) => ({ - resourceType: "harness" as const, + targets.push(input.target.name); + const ids = { runtime: RUNTIME_ID, harness: HARNESS_ID, gateway: GATEWAY_ID }; + return ( + [ + ["runtime", project.spec.runtimes], + ["harness", project.spec.harnesses], + ["gateway", project.spec.agentCoreGateways], + ] as const + ).flatMap(([resourceType, resources]) => + resources.map(({ name }) => ({ + resourceType, name, - id: HARNESS_ID, + id: ids[resourceType], target: input.target, credentialProvider: TARGET_CREDENTIALS, })), - ]; + ); }, async resolveProjectResources() { - throw new Error("project invoke resolves deployed resources, not project resources"); + throw new Error("invoke resolves deployed resources, not project resources"); }, }; - return { calls, value }; + return { targets, value }; } -function configureCore(core: TestCoreClient): void { +async function routedCommand( + args: readonly string[], + resources: Resources | undefined, + options: { writeTargets?: boolean; isTTY?: boolean } = {}, +) { + if (resources) { + await inProject(resources, options); + } else { + cleanups.push((await inTempDirectory("agentcore-invoke-outside-")).cleanup); + } + const resolved = backend(); + const core = new TestCoreClient({ backends: { CDK: resolved.value } }); core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setGetResponse({ agentRuntimeArn: arn(`runtime/${RUNTIME_ID}`) } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "text/plain", - body: body(Buffer.from("runtime response")), + body: (async function* () { + yield Buffer.from("runtime response"); + })(), }); core.harness - .setGetResponse({ - harness: { harnessId: HARNESS_ID, harnessName: "support", arn: HARNESS_ARN }, - } as GetHarnessResponse) + .setGetResponse({ harness: { arn: arn(`harness/${HARNESS_ID}`) } } as GetHarnessResponse) .setInvokeEvents( { messageStart: { role: "assistant" } }, - { contentBlockDelta: { contentBlockIndex: 0, delta: { text: "harness response" } } }, - { contentBlockStop: { contentBlockIndex: 0 } }, { messageStop: { stopReason: "end_turn" } }, ); + core.gateway + .setGetResponse({ + gatewayArn: arn(`gateway/${GATEWAY_ID}`), + gatewayUrl: "https://tools.gateway.example.test/mcp", + authorizerType: "NONE", + } as GetGatewayResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: (async function* () { + yield Buffer.from("gateway response"); + })(), + }); + const io = testIO({ isTTY: options.isTTY }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const route = () => root.route(["node", "agentcore", "invoke", ...args, "--region", "us-east-1"]); + return { core, io, resolved, route }; } async function run( - args: string[], - resources: { runtimes?: unknown[]; harnesses?: unknown[] }, + args: readonly string[], + resources: Resources | undefined, options: { writeTargets?: boolean } = {}, ) { const subject = await routedCommand(args, resources, options); @@ -153,30 +168,17 @@ async function run( return subject; } -async function routedCommand( - args: string[], - resources: { runtimes?: unknown[]; harnesses?: unknown[] }, - options: { writeTargets?: boolean; isTTY?: boolean } = {}, -) { - await inProject(resources, options); - const resolved = backend(); - const core = new TestCoreClient({ backends: { CDK: resolved.value } }); - configureCore(core); - const io = testIO({ isTTY: options.isTTY }); - const root = createRootHandler(core, { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - const route = () => root.route(["node", "agentcore", "invoke", ...args]); - return { core, io, resolved, route }; -} - -function context(project: Project): Context { - return ValueContext.EmptyContext() - .withValue(ProjectKey, project) - .withValue(JsonKey, false) - .withValue(RegionKey, "us-east-1"); +async function launches(args: readonly string[], resources: Resources | undefined) { + const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); + try { + const subject = await run(args, resources); + return { + ...subject, + launches: render.mock.calls.map(([path, ctx]) => ({ path, region: ctx.value(RegionKey) })), + }; + } finally { + render.mockRestore(); + } } afterEach(async () => { @@ -184,73 +186,161 @@ afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); -describe("project invoke", () => { - test.each([false, true])( - "reads stdin before progress, then hands off to the first Runtime chunk (local=%s)", - async (local) => { - const stream = new StreamController(); - const server = local - ? await startHttpServer(() => ({ - status: 200, - headers: { "Content-Type": "text/plain" }, - body: stream, - })) - : undefined; - if (server) servers.push(server); - const subject = await routedCommand( - [ - "runtime", - "--payload", - "-", - ...(server ? ["--local", "--port", String(server.port)] : []), - ], - { runtimes: [RUNTIME] }, - { isTTY: true }, - ); - subject.core.runtime.setInvokeResponse({ - statusCode: 200, - contentType: "text/plain", - body: stream, +describe("invoke", () => { + test.each([ + ["runtime", ["--runtime", "checkout", "--payload", "{}"], "runtime", "invokeRuntime"], + ["harness", ["--harness", "support", "--prompt", "hi"], "harness", "invokeHarness"], + [ + "gateway", + ["--gateway", "tools", "--path", "/mcp", "--payload", "{}"], + "gateway", + "invokeGateway", + ], + ] as const)( + "invokes a project %s by name in its deployed region", + async (_type, args, client, method) => { + const { core, resolved } = await run([...args], { + runtimes: [RUNTIME], + harnesses: [HARNESS], + agentCoreGateways: [GATEWAY], }); - const stdin = subject.io.io.stdin as unknown as PassThrough; - const pending = subject.route(); - try { - await waitFor(() => stdin.listenerCount("readable") > 0); - stdin.write("{}"); - await tick(120); - expect(subject.io.stderr()).toBe(""); - stdin.end(); - await waitFor(() => subject.io.stderr().includes("Invoking runtime...")); - expect(subject.io.stdout()).toBe(""); - stream.emit(Buffer.from("first")); - await waitFor(() => subject.io.stdout() === "first"); - const afterFirstChunk = subject.io.stderr(); - await tick(120); - expect(subject.io.stderr()).toBe(afterFirstChunk); - } finally { - stdin.end(); - stream.emit(Buffer.from("second")); - stream.end(); - await pending; - } - expect(subject.io.stdout()).toBe("firstsecond"); + + const invoke = core[client].calls.find((call) => call.method === method)!; + expect(invoke.args[1]).toEqual({ region: TARGET.region, credentials: TARGET_CREDENTIALS }); + expect(resolved.targets).toEqual(["default"]); }, ); - test.each([false, true])("keeps machine output free of progress (json=%s)", async (json) => { + test.each([ + [["--runtime", "checkout"], `/agentcore/runtime/invoke/${RUNTIME_ID}`], + [ + ["--harness", "support", "--qualifier", "prod"], + `/agentcore/harness/invoke/${HARNESS_ID}?qualifier=prod`, + ], + [["--gateway", "tools"], `/agentcore/gateway/invoke/${GATEWAY_ID}`], + ])("opens the TUI for %j", async (args, path) => { + const subject = await launches(args, { + runtimes: [RUNTIME], + harnesses: [HARNESS], + agentCoreGateways: [GATEWAY], + }); + + expect(subject.launches).toEqual([{ path, region: TARGET.region }]); + }); + + test.each([ + ["one resource", { runtimes: [RUNTIME] }], + ["no project", undefined], + ] as const)("a bare interactive invoke opens the picker with %s", async (_name, resources) => { + const subject = await launches([], resources); + + expect(subject.launches.map((launch) => launch.path)).toEqual(["/agentcore/invoke"]); + }); + + test("resolves the --target deployment target for project names", async () => { + const { resolved } = await run( + ["--runtime", "checkout", "--payload", "{}", "--target", "default"], + { + runtimes: [RUNTIME], + }, + ); + + expect(resolved.targets).toEqual(["default"]); + }); + + test.each([ + { + name: "more than one resource flag", + args: ["--runtime", "checkout", "--harness", "support"], + resources: { runtimes: [RUNTIME], harnesses: [HARNESS] }, + message: "specify exactly one of --runtime, --harness, --gateway", + }, + { + name: "a headless invoke with no resource flag, even when the project has one resource", + args: ["--payload", "{}"], + resources: { runtimes: [RUNTIME] }, + message: "specify exactly one of --runtime, --harness, --gateway", + }, + { + name: "a bare --json invoke outside a project", + args: ["--json"], + resources: undefined, + message: "specify exactly one of --runtime, --harness, --gateway", + }, + { + name: "a flag that belongs to another resource type", + args: ["--runtime", "checkout", "--prompt", "hi"], + resources: { runtimes: [RUNTIME] }, + message: "--prompt does not apply to a Runtime", + }, + { + name: "--local for a harness", + args: ["--harness", "support", "--local"], + resources: { harnesses: [HARNESS] }, + message: "--local does not apply to a Harness", + }, + { + name: "--target with an ID", + args: ["--gateway", GATEWAY_ID, "--target", "default"], + resources: { agentCoreGateways: [GATEWAY] }, + message: "--target only applies to project resources", + }, + { + name: "--local outside a project", + args: ["--runtime", RUNTIME_ID, "--local", "--payload", "{}"], + resources: undefined, + message: "--local only applies to project resources", + }, + { + name: "a harness session ID that is too short", + args: ["--harness", "support", "--prompt", "hi", "--session-id", "short"], + resources: { harnesses: [HARNESS] }, + message: "Invalid value for option '--session-id'", + }, + ])("rejects $name", async ({ args, resources, message }) => { + const subject = await routedCommand(args, resources); + + await expect(subject.route()).rejects.toThrow(message); + }); + + test("reads stdin before progress, then hands off to the first local Runtime chunk", async () => { + const stream = new StreamController(); + const server = await startHttpServer(() => ({ + status: 200, + headers: { "Content-Type": "text/plain" }, + body: stream, + })); + servers.push(server); const subject = await routedCommand( - ["runtime", "--payload", "{}", ...(json ? ["--json"] : [])], + ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", "-"], { runtimes: [RUNTIME] }, - { isTTY: json }, + { isTTY: true }, ); - await subject.route(); - expect(subject.io.stderr()).not.toContain("Invoking"); - expect(subject.io.stderr()).not.toContain("\u001b"); - if (json) expect(JSON.parse(subject.io.stdout()).body).toBe("runtime response"); - else expect(subject.io.stdout()).toBe("runtime response"); + const stdin = subject.io.io.stdin as unknown as PassThrough; + const pending = subject.route(); + try { + await waitFor(() => stdin.listenerCount("readable") > 0); + stdin.write("{}"); + await tick(120); + expect(subject.io.stderr()).toBe(""); + stdin.end(); + await waitFor(() => subject.io.stderr().includes("Invoking runtime...")); + expect(subject.io.stdout()).toBe(""); + stream.emit(Buffer.from("first")); + await waitFor(() => subject.io.stdout() === "first"); + const afterFirstChunk = subject.io.stderr(); + await tick(120); + expect(subject.io.stderr()).toBe(afterFirstChunk); + } finally { + stdin.end(); + stream.emit(Buffer.from("second")); + stream.end(); + await pending; + } + expect(subject.io.stdout()).toBe("firstsecond"); }); - test("auto-selects the sole local Runtime without resolving deployed resources", async () => { + test("invokes a local Runtime without resolving deployed resources", async () => { let request: | { method: string; @@ -282,7 +372,7 @@ describe("project invoke", () => { const payload = '{"prompt":"hi"}'; const { core, io, resolved } = await run( - ["runtime", "--local", "--port", String(server.port), "--payload", payload], + ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", payload], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -300,7 +390,7 @@ describe("project invoke", () => { }); expect(io.stdout()).toBe("local response"); expect(io.stderr()).toContain(`runtime-session-id=${request?.sessionId}`); - expect(resolved.calls).toEqual([]); + expect(resolved.targets).toEqual([]); expect(core.runtime.calls).toEqual([]); }); @@ -346,10 +436,9 @@ describe("project invoke", () => { await run( [ - "runtime", - "--local", - "--name", + "--runtime", RUNTIME.name, + "--local", "--port", String(server.port), "--payload", @@ -392,7 +481,7 @@ describe("project invoke", () => { try { const code = await runWithExitCode(async () => { await run( - ["runtime", "--local", "--name", RUNTIME.name, "--port", String(port), "--payload", "{}"], + ["--runtime", RUNTIME.name, "--local", "--port", String(port), "--payload", "{}"], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -416,16 +505,7 @@ describe("project invoke", () => { })); servers.push(server); const subject = await routedCommand( - [ - "runtime", - "--local", - "--name", - RUNTIME.name, - "--port", - String(server.port), - "--payload", - "{}", - ], + ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", "{}"], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -489,10 +569,9 @@ describe("project invoke", () => { await run( [ - "runtime", - "--local", - "--name", + "--runtime", name, + "--local", "--port", String(server.port), "--payload", @@ -517,31 +596,22 @@ describe("project invoke", () => { test.each([ { name: "requires --local with --port", - args: ["runtime", "--port", "8081", "--payload", "{}"], + args: ["--runtime", RUNTIME.name, "--port", "8081", "--payload", "{}"], message: "--port requires --local", }, { name: "rejects deployed-only flags locally", - args: ["runtime", "--local", "--name", RUNTIME.name, "--payload", "{}", "--target", "prod"], + args: ["--runtime", RUNTIME.name, "--local", "--payload", "{}", "--target", "prod"], message: "--target cannot be used with --local", }, { name: "requires a local payload", - args: ["runtime", "--local", "--name", RUNTIME.name], + args: ["--runtime", RUNTIME.name, "--local"], message: "required option '--payload ' not specified", }, { name: "rejects MCP options for a local non-MCP Runtime", - args: [ - "runtime", - "--local", - "--name", - RUNTIME.name, - "--payload", - "{}", - "--mcp-method", - "tools/list", - ], + args: ["--runtime", RUNTIME.name, "--local", "--payload", "{}", "--mcp-method", "tools/list"], message: "MCP options are only valid for MCP Runtimes", }, ])("$name", async ({ args, message }) => { @@ -549,168 +619,4 @@ describe("project invoke", () => { message, ); }); - - test("requires --name when invoking one of multiple local Runtimes", async () => { - await expect( - run( - ["runtime", "--local", "--payload", "{}"], - { - runtimes: [RUNTIME, { ...RUNTIME, name: "inventory" }], - }, - { writeTargets: false }, - ), - ).rejects.toThrow("Project has multiple Runtimes. Specify --name: checkout, inventory."); - }); - - test("invokes the sole Runtime with its existing payload contract in the target region", async () => { - const payload = '{"custom":"wire shape"}'; - const { core, io, resolved } = await run( - ["runtime", "--payload", payload, "--content-type", "application/custom+json"], - { runtimes: [RUNTIME] }, - ); - - const request = core.runtime.calls.find(({ method }) => method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(new TextDecoder().decode(request.payload)).toBe(payload); - expect(request.contentType).toBe("application/custom+json"); - expect(request.runtimeUserId).toBe("default"); - expect(core.runtime.calls.at(-1)!.args[1]).toEqual({ - region: TARGET.region, - credentials: TARGET_CREDENTIALS, - }); - expect(io.stdout()).toBe("runtime response"); - expect(resolved.calls).toEqual([{ target: TARGET }]); - }); - - test("invokes a named Harness with its existing prompt contract in the target region", async () => { - const { core, io } = await run(["harness", "--name", "support", "--prompt", "hello"], { - harnesses: [HARNESS], - }); - - const request = core.harness.calls.find(({ method }) => method === "invokeHarness")! - .args[0] as InvokeHarnessRequest; - expect(request).toMatchObject({ - harnessArn: HARNESS_ARN, - qualifier: "DEFAULT", - messages: [{ role: "user", content: [{ text: "hello" }] }], - }); - expect(core.harness.calls.at(-1)!.args[1]).toEqual({ - region: TARGET.region, - credentials: TARGET_CREDENTIALS, - }); - expect(JSON.parse(io.stdout()).transcript).toContainEqual({ - kind: "text", - text: "harness response", - streaming: false, - }); - }); - - test("requires --name when the project has multiple Runtimes", async () => { - await expect( - run(["runtime", "--payload", "{}"], { - runtimes: [RUNTIME, { ...RUNTIME, name: "inventory" }], - }), - ).rejects.toThrow(/multiple Runtimes.*--name.*checkout, inventory/s); - }); - - test("opens the existing Runtime TUI for bare and TUI-compatible Runtime invokes", async () => { - await inProject({ runtimes: [RUNTIME] }); - const resolved = backend(); - const core = new TestCoreClient({ backends: { CDK: resolved.value } }); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const launches: { path: string; context: Context }[] = []; - const handler = createProjectInvokeRuntimeHandler(core, testIO().io, async (path, ctx) => { - launches.push({ path, context: ctx }); - }); - - const bareFlags = { - name: undefined, - local: false, - port: undefined, - target: undefined, - payload: undefined, - qualifier: undefined, - "content-type": undefined, - accept: undefined, - "session-id": undefined, - "user-id": undefined, - header: undefined, - "bearer-token": undefined, - "mcp-session-id": undefined, - "mcp-protocol-version": undefined, - "mcp-method": undefined, - "mcp-name": undefined, - "trace-id": undefined, - "trace-parent": undefined, - "trace-state": undefined, - baggage: undefined, - "output-file": undefined, - }; - - await handler.handle(context(project!), bareFlags, {}); - - expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}`); - expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); - expect(launches[0]!.context.require(AwsCredentialProviderKey)).toBe(TARGET_CREDENTIALS); - expect(launches[0]!.context.require(RuntimeInvokeLaunchContextKey)).toMatchObject({ - runtimeId: RUNTIME_ID, - }); - - await handler.handle( - context(project!), - { - ...bareFlags, - name: "checkout", - target: "default", - "session-id": "project-session", - }, - {}, - ); - - expect(launches[1]!.context.require(RuntimeInvokeLaunchContextKey)).toMatchObject({ - runtimeId: RUNTIME_ID, - runtimeSessionId: "project-session", - }); - }); - - test("opens the existing Harness TUI with the resolved project Harness", async () => { - await inProject({ harnesses: [HARNESS] }); - const resolved = backend(); - const core = new TestCoreClient({ backends: { CDK: resolved.value } }); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const launches: { path: string; context: Context }[] = []; - const handler = createProjectInvokeHarnessHandler(core, testIO().io, async (path, ctx) => { - launches.push({ path, context: ctx }); - }); - - await handler.handle( - context(project!), - { - name: "support", - target: "default", - prompt: undefined, - "session-id": undefined, - qualifier: "prod", - }, - {}, - ); - - expect(launches[0]!.path).toBe(`/agentcore/harness/invoke/${HARNESS_ID}?qualifier=prod`); - expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); - expect(launches[0]!.context.require(AwsCredentialProviderKey)).toBe(TARGET_CREDENTIALS); - }); - - test("bare project invoke opens the project resource picker", async () => { - await inProject({ runtimes: [RUNTIME], harnesses: [HARNESS] }); - const core = new TestCoreClient(); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const launches: string[] = []; - const handler = createProjectInvokeHandler(core, testIO().io, async (path) => { - launches.push(path); - }); - - await handler.defaultHandler()!.handle(context(project!), {}, {}); - - expect(launches).toEqual(["/agentcore/invoke"]); - }); }); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 3dbdfc82a3..c9bd1d8d27 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -1,28 +1,187 @@ -import type { AppIO } from "../../../io"; +import z from "zod"; import { InputValidationError } from "../../../errors"; -import { Router } from "../../../router"; -import { renderTuiAt } from "../../../tui"; +import type { AppIO } from "../../../io"; import { withProject } from "../../../middleware"; -import { JsonKey } from "../../keys"; +import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { + createHandler, + flag, + PathKey, + ProjectKey, + type FlagsOf, + type Handler, +} from "../../../router"; +import { attributeName, parseFlags } from "../../../router/flags"; +import { renderTuiAt } from "../../../tui"; +import { createInvokeGatewayHandler, invokeGatewayFlags } from "../../gateway/invoke"; +import { createInvokeHarnessHandler, invokeHarnessFlags } from "../../harness/invoke"; +import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; -import { createProjectInvokeHarnessHandler } from "./harness"; -import { createProjectInvokeRuntimeHandler } from "./runtime"; - -export function createProjectInvokeHandler( - core: Core, - io: AppIO, - renderInvokeTui: typeof renderTuiAt = renderTuiAt, -): Router { - return new Router("invoke", "invoke a Runtime or harness from the current project") - .use(withProject({ projectManager: core.projectManager })) - .handler(createProjectInvokeRuntimeHandler(core, io, renderInvokeTui)) - .handler(createProjectInvokeHarnessHandler(core, io, renderInvokeTui)) - .default((ctx) => { - if (ctx.require(JsonKey)) { +import { assertMutuallyExclusiveFlags, resolveResource } from "../../utils"; +import { projectResourceNames } from "../selection"; +import { RESOURCE_LABELS, type ProjectInvokableResource } from "../types"; +import { createInvokeRuntimeHandler, invokeRuntimeFlags } from "../../runtime/invoke"; +import { invokeProjectRuntimeLocally } from "./runtime"; + +const RESOURCE = "Resource options:"; +const RESOURCE_TYPES = ["runtime", "harness", "gateway"] as const; + +const selectionFlags = [ + flag( + "runtime", + "the Runtime to invoke: its name in this project, its ID, or its ARN", + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag( + "harness", + "the harness to invoke: its name in this project, its ID, or its ARN", + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag( + "gateway", + "the Gateway to invoke: its name in this project, its ID, or its ARN", + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag( + "target", + `project deployment target (default: "${DEFAULT_TARGET_NAME}")`, + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag("local", "invoke the local development server (project Runtime only)", z.boolean(), { + group: RESOURCE, + }), + flag( + "port", + "local development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000)", + z.coerce.number().int().min(1).max(65535).optional(), + { group: RESOURCE }, + ), +] as const; + +const HANDLER_FLAGS = { + runtime: invokeRuntimeFlags, + harness: invokeHarnessFlags, + gateway: invokeGatewayFlags, +} as const; + +type HandlerFlag = Exclude< + (typeof HANDLER_FLAGS)[ProjectInvokableResource][number], + { name: "id" } +>; + +/** + The flags of the runtime, harness, and gateway invoke commands, one per name, grouped by the types that accept them. +**/ +function handlerFlags(): HandlerFlag[] { + const merged = new Map(); + for (const resourceType of RESOURCE_TYPES) { + for (const handlerFlag of HANDLER_FLAGS[resourceType]) { + if (handlerFlag.name === "id") continue; + const entry = merged.get(handlerFlag.name) ?? { flag: handlerFlag, owners: [] }; + entry.owners.push(resourceType); + merged.set(handlerFlag.name, entry); + } + } + return [...merged.values()].map(({ flag, owners }) => ({ + ...flag, + group: owners.length === 1 ? `${RESOURCE_LABELS[owners[0]!]} options:` : "Request options:", + })); +} + +const invokeFlags: readonly ((typeof selectionFlags)[number] | HandlerFlag)[] = [ + ...selectionFlags, + ...handlerFlags(), +]; + +export type InvokeFlags = FlagsOf; + +type ResourceSelection = { resourceType: ProjectInvokableResource; identifier: string }; + +const isSet = (value: unknown) => value !== undefined && value !== false; + +function flaggedResource(flags: InvokeFlags, headless: boolean): ResourceSelection | undefined { + assertMutuallyExclusiveFlags(flags, RESOURCE_TYPES, { exactlyOne: headless }); + const resourceType = RESOURCE_TYPES.find((type) => flags[type] !== undefined); + return resourceType && { resourceType, identifier: flags[resourceType]! }; +} + +export function createProjectInvokeHandler(core: Core, io: AppIO) { + const handlers: Record = { + runtime: createInvokeRuntimeHandler(core, io), + harness: createInvokeHarnessHandler(core, io), + gateway: createInvokeGatewayHandler(core, io), + }; + return createHandler({ + name: "invoke", + description: "invoke a Runtime, harness, or Gateway", + flags: invokeFlags, + middlewares: [withProject({ projectManager: core.projectManager, optional: true })], + handle: async (ctx, flags) => { + const project = ctx.value(ProjectKey); + const headless = ctx.require(JsonKey) || Object.values(flags).some(isSet); + const selection = flaggedResource(flags, headless); + if (!selection) { + await renderTuiAt("/agentcore/invoke", ctx, core, io); + return; + } + + const { resourceType, identifier } = selection; + const handler = handlers[resourceType]; + const acceptedFlags = [ + ...RESOURCE_TYPES, + "target", + ...(resourceType === "runtime" ? ["local", "port"] : []), + ...handler.flags().map(({ name }) => name), + ]; + const unsupported = Object.entries(flags).find( + ([name, value]) => isSet(value) && !acceptedFlags.includes(name), + ); + if (unsupported) { throw new InputValidationError( - "a Runtime or harness invoke subcommand is required with --json", + `--${unsupported[0]} does not apply to a ${RESOURCE_LABELS[resourceType]}`, ); } - return renderInvokeTui("/agentcore/invoke", ctx, core, io); - }); + const isProjectName = + project && projectResourceNames(project, resourceType).includes(identifier); + for (const name of ["target", "local"] as const) { + if (isSet(flags[name]) && !isProjectName) { + throw new InputValidationError(`--${name} only applies to project resources`); + } + } + if (!flags.local && flags.port !== undefined) { + throw new InputValidationError("--port requires --local"); + } + if (flags.local) { + const runtime = project!.spec.runtimes.find(({ name }) => name === identifier)!; + await invokeProjectRuntimeLocally(io, ctx, runtime, flags); + return; + } + + const resource = await resolveResource( + core, + ctx, + resourceType, + identifier, + flags.target ?? DEFAULT_TARGET_NAME, + ); + const rawHandlerFlags: Record = { ...flags, id: resource.id }; + const parsedHandlerFlags = parseFlags( + handler.flags(), + Object.fromEntries( + handler.flags().map(({ name }) => [attributeName(name), rawHandlerFlags[name]]), + ), + ); + let handlerCtx = ctx + .withValue(RegionKey, resource.region) + .withValue(PathKey, `/agentcore/${resourceType}/invoke`); + if (resource.credentials) { + handlerCtx = handlerCtx.withValue(AwsCredentialProviderKey, resource.credentials); + } + await handler.handle(handlerCtx, parsedHandlerFlags, {}); + }, + }); } diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index f93760d815..91a8da4762 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AgentRuntimeEndpoint, GetAgentRuntimeResponse, + GetGatewayResponse, GetHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; @@ -38,6 +39,7 @@ const project: Project = { }, ], harnesses: [{ name: "support", path: "app/support" }], + agentCoreGateways: [{ name: "tools", targets: [] }], }), }; @@ -79,6 +81,13 @@ const DEPLOYED_RESOURCES: ResolvedDeployedResource[] = [ target: TARGET, credentialProvider: TARGET_CREDENTIALS, }, + { + resourceType: "gateway", + name: "tools", + id: "gateway-123", + target: TARGET, + credentialProvider: TARGET_CREDENTIALS, + }, ]; function core( @@ -107,6 +116,12 @@ function core( arn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:harness/harness-123", }, } as GetHarnessResponse); + value.gateway.setGetResponse({ + gatewayId: "gateway-123", + gatewayUrl: "https://gateway-123.gateway.example.test/mcp", + authorizerType: "NONE", + status: "READY", + } as GetGatewayResponse); return value; } @@ -209,7 +224,7 @@ describe("project invoke picker", () => { expect(screen.lastFrame()).toContain("support"); }); - test("lists project Runtime and Harness resources", async () => { + test("lists project Runtime, Harness, and Gateway resources", async () => { const screen = renderScreen("/agentcore/invoke", { core: core(), withContext: (ctx) => ctx.withValue(ProjectKey, project), @@ -222,6 +237,29 @@ describe("project invoke picker", () => { expect(screen.lastFrame()).toContain("support"); expect(screen.lastFrame()).toContain("Harness"); expect(screen.lastFrame()).toContain("app/support"); + expect(screen.lastFrame()).toContain("tools"); + expect(screen.lastFrame()).toContain("Gateway"); + }); + + test("opens the selected Gateway console with the target's credentials", async () => { + const value = core(); + const screen = renderScreen("/agentcore/invoke", { + core: value, + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("down"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "gateway-123"); + expect(value.gateway.calls.find(({ method }) => method === "getGateway")?.args[1]).toEqual({ + region: TARGET.region, + endpointUrl: undefined, + credentials: TARGET_CREDENTIALS, + }); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a project resource to invoke"); }); test("opens the selected Harness chat in the same TUI", async () => { diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx index 113f0c63df..c43c60a91f 100644 --- a/src/handlers/project/invoke/runtime.tsx +++ b/src/handlers/project/invoke/runtime.tsx @@ -1,267 +1,105 @@ -import z from "zod"; import { ExitCode, InputValidationError, RuntimeInvokeResponseError } from "../../../errors"; import { invokeLocalRuntime } from "../../../core/dev/localInvoke"; import { DEV_PORTS } from "../../../core/dev/port"; import type { AppIO } from "../../../io"; +import type { Context } from "../../../router"; import { withUserCancellation } from "../../../runnable"; -import { createHandler, flag, ProjectKey } from "../../../router"; -import { renderTuiAt } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; -import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; -import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; -import { invokeRuntimeTarget } from "../../runtime/invoke/operation"; +import { JsonKey } from "../../keys"; import { parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, - resolveRuntimeInvokeTuiBearerToken, } from "../../runtime/invoke/request"; import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; -import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; -import { selectProjectResource } from "../selection"; +import type { Project } from "../types"; +import type { InvokeFlags } from "."; -export const createProjectInvokeRuntimeHandler = ( - core: Core, +export async function invokeProjectRuntimeLocally( io: AppIO, - renderInvokeTui: typeof renderTuiAt = renderTuiAt, -) => - createHandler({ - name: "runtime", - description: "invoke a Runtime from the current project", - flags: [ - flag("name", "the logical project Runtime name", z.string().optional()), - flag("local", "invoke a local Runtime development server", z.boolean()), - flag( - "port", - "local Runtime development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000)", - z.coerce.number().int().min(1).max(65535).optional(), - ), - flag("target", "project deployment target (default: default)", z.string().optional()), - flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), - flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("session-id", "the Runtime session ID", z.string().optional()), - flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { - sensitive: true, - }), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag("mcp-method", "the MCP method", z.string().optional()), - flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), - flag("trace-id", "the X-Ray trace ID", z.string().optional()), - flag("trace-parent", "the W3C trace parent", z.string().optional()), - flag("trace-state", "the W3C trace state", z.string().optional()), - flag("baggage", "the W3C baggage", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], - handle: async (ctx, flags) => { - const project = ctx.require(ProjectKey); - const jsonOutput = ctx.require(JsonKey); + ctx: Context, + runtime: Project["spec"]["runtimes"][number], + flags: InvokeFlags, +): Promise { + const jsonOutput = ctx.require(JsonKey); + if (jsonOutput && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } + const protocol = runtime.protocol ?? "HTTP"; + const unsupportedFlag = Object.entries({ + target: flags.target, + qualifier: flags.qualifier, + "bearer-token": flags["bearer-token"], + }).find(([, value]) => value !== undefined)?.[0]; + if (unsupportedFlag !== undefined) { + throw new InputValidationError(`--${unsupportedFlag} cannot be used with --local`); + } + if ( + protocol !== "MCP" && + [ + flags["mcp-session-id"], + flags["mcp-protocol-version"], + flags["mcp-method"], + flags["mcp-name"], + ].some((value) => value !== undefined) + ) { + throw new InputValidationError("MCP options are only valid for MCP Runtimes"); + } + if (flags.payload === undefined) { + throw new InputValidationError("required option '--payload ' not specified", { + exitCode: ExitCode.USAGE, + }); + } - if (!flags.local && flags.port !== undefined) { - throw new InputValidationError("--port requires --local"); - } - if (flags.local) { - if (jsonOutput && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } - const name = selectProjectResource(project, "runtime", flags.name, "invoke"); - const runtime = project.spec.runtimes.find((candidate) => candidate.name === name)!; - const protocol = runtime.protocol ?? "HTTP"; - const unsupportedFlag = Object.entries({ - target: flags.target, - qualifier: flags.qualifier, - "bearer-token": flags["bearer-token"], - }).find(([, value]) => value !== undefined)?.[0]; - if (unsupportedFlag !== undefined) { - throw new InputValidationError(`--${unsupportedFlag} cannot be used with --local`); - } - if ( - protocol !== "MCP" && - [ - flags["mcp-session-id"], - flags["mcp-protocol-version"], - flags["mcp-method"], - flags["mcp-name"], - ].some((value) => value !== undefined) - ) { - throw new InputValidationError("MCP options are only valid for MCP Runtimes"); - } - if (flags.payload === undefined) { - throw new InputValidationError("required option '--payload ' not specified", { - exitCode: ExitCode.USAGE, - }); - } - - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const response = await invokeLocalRuntime( - { - port: flags.port ?? DEV_PORTS[protocol], - protocol, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }, - signal, - ); - // Local agent error bodies are useful diagnostics, so write them before returning nonzero. - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new RuntimeInvokeResponseError(`HTTP ${response.statusCode}`); - } - }; - await withUserCancellation(async (signal) => { - const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload! }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking runtime...", - interactive: !jsonOutput, - }); - }); - return; - } - - const name = selectProjectResource(project, "runtime", flags.name, "invoke"); - const deployed = await core.projectManager.resolveDeployedResource(project, { - target: flags.target ?? "default", - resourceType: "runtime", - name, - }); - const invokeCtx = ctx - .withValue(RegionKey, deployed.target.region) - .withValue(AwsCredentialProviderKey, deployed.credentialProvider); - - if (flags.payload === undefined) { - const hasHeadlessOnlyFlag = Object.entries(flags).some( - ([flagName, value]) => - ![ - "name", - "local", - "target", - "qualifier", - "payload", - "session-id", - "user-id", - "header", - "bearer-token", - ].includes(flagName) && value !== undefined, - ); - if (invokeCtx.require(JsonKey) || hasHeadlessOnlyFlag) { - throw new InputValidationError("required option '--payload ' not specified", { - exitCode: ExitCode.USAGE, - }); - } - let path = `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}`; - if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const bearerToken = await resolveRuntimeInvokeTuiBearerToken( - flags["bearer-token"], - io.stdin, - ); - await renderInvokeTui( - path, - invokeCtx.withValue(RuntimeInvokeLaunchContextKey, { - runtimeId: deployed.id, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken, - }), - core, - io, - ); - return; - } - - if (jsonOutput && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const response = await invokeRuntimeTarget( - core.runtime, - { - runtimeId: deployed.id, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }, - coreOptsFromCtx(invokeCtx), - signal, - ); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - }; - await withUserCancellation(async (signal) => { - const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload!, bearerToken: flags["bearer-token"] }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking runtime...", - interactive: !jsonOutput, - }); - }); - }, + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const invoke = async ( + signal: AbortSignal, + beforeOutput: () => Promise, + sources: Awaited>, + ) => { + const response = await invokeLocalRuntime( + { + port: flags.port ?? DEV_PORTS[protocol], + protocol, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }, + signal, + ); + // Local agent error bodies are useful diagnostics, so write them before returning nonzero. + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal, + beforeOutput, + }); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new RuntimeInvokeResponseError(`HTTP ${response.statusCode}`); + } + }; + await withUserCancellation(async (signal) => { + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload! }, + io.stdin, + signal, + ); + return runWithProgress((stop) => invoke(signal, stop, sources), { + io, + label: "Invoking runtime...", + interactive: !jsonOutput, + }); }); +} diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 96ea15319b..c004a069e6 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -8,21 +8,25 @@ import { DataTable, type DataTableColumn } from "../../../components/ui/data-tab import { Spinner } from "../../../components/ui/spinner"; import { glyphs } from "../../../components/ui/_core.js"; import { ProjectKey, type Context } from "../../../router"; +import { GatewayInvokeConsole } from "../../gateway/invoke/screen"; import { HarnessChat } from "../../harness/invoke/screen"; -import { AwsCredentialProviderKey } from "../../keys"; +import { AwsCredentialProviderKey, RegionKey } from "../../keys"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; -import type { Project, ResolvedDeployedResources } from "../types"; +import { + RESOURCE_LABELS, + type Project, + type ResolvedDeployedResource, + type ResolvedDeployedResources, +} from "../types"; import { ProjectGate } from "../ProjectGate"; -type ProjectInvokableRow = Record & { - resourceType: "runtime" | "harness"; - type: "Runtime" | "Harness"; - name: string; - id: string; - protocol: string; - source: string; -}; +type ProjectInvokableRow = Record & + ResolvedDeployedResource & { + type: string; + protocol: string; + source: string; + }; const columns = [ { key: "type", header: "type", width: 10 }, @@ -33,10 +37,10 @@ const columns = [ type Destination = | { resourceType: "runtime"; id: string; ctx: Context; qualifier?: string } - | { resourceType: "harness"; id: string; ctx: Context }; + | { resourceType: "harness" | "gateway"; id: string; ctx: Context }; const BREADCRUMB = ["agentcore", "invoke"]; -const DESCRIPTION = "invoke a Runtime or harness from the current project"; +const DESCRIPTION = "invoke a Runtime, harness, or Gateway from the current project"; const PROJECT_MENU = "/agentcore"; // The project comes from the launch context when a project command opened the @@ -109,21 +113,19 @@ function ProjectInvokePicker({ const rows = useMemo( () => (deployed?.resources ?? []).map((resource) => { - if (resource.resourceType === "runtime") { - const configured = project.spec.runtimes.find(({ name }) => name === resource.name); - return { - ...resource, - type: "Runtime" as const, - protocol: configured?.protocol ?? "HTTP", - source: configured?.codeLocation ?? "-", - }; - } - const configured = project.spec.harnesses.find(({ name }) => name === resource.name); + const runtime = + resource.resourceType === "runtime" + ? project.spec.runtimes.find(({ name }) => name === resource.name) + : undefined; + const harness = + resource.resourceType === "harness" + ? project.spec.harnesses.find(({ name }) => name === resource.name) + : undefined; return { ...resource, - type: "Harness" as const, - protocol: "-", - source: configured?.path ?? "-", + type: RESOURCE_LABELS[resource.resourceType], + protocol: runtime ? (runtime.protocol ?? "HTTP") : "-", + source: runtime?.codeLocation ?? harness?.path ?? "-", }; }), [deployed, project], @@ -133,7 +135,9 @@ function ProjectInvokePicker({ setDestination({ resourceType: row.resourceType, id: row.id, - ctx: ctx.withValue(AwsCredentialProviderKey, row.credentialProvider), + ctx: ctx + .withValue(RegionKey, deployed!.target.region) + .withValue(AwsCredentialProviderKey, row.credentialProvider), }); }; @@ -178,6 +182,17 @@ function ProjectInvokePicker({ ); } + if (destination?.resourceType === "gateway") { + return ( + setDestination(undefined)} + /> + ); + } + if (error !== undefined) { return ( diff --git a/src/handlers/project/selection.ts b/src/handlers/project/selection.ts index e83b460120..449ccd7ec7 100644 --- a/src/handlers/project/selection.ts +++ b/src/handlers/project/selection.ts @@ -1,13 +1,16 @@ import { InputValidationError, ResourceNotFoundError } from "../../errors"; -import type { Project, ProjectInvokableResource } from "./types"; +import { RESOURCE_LABELS, type Project, type ProjectInvokableResource } from "./types"; export function projectResourceNames( project: Project, resourceType: ProjectInvokableResource, ): string[] { - return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( - ({ name }) => name, - ); + const resources = { + runtime: project.spec.runtimes, + harness: project.spec.harnesses, + gateway: project.spec.agentCoreGateways, + }[resourceType]; + return resources.map(({ name }) => name); } export function selectProjectResource( @@ -17,7 +20,7 @@ export function selectProjectResource( operation: string, ): string { const names = projectResourceNames(project, resourceType); - const label = resourceType === "runtime" ? "Runtime" : "Harness"; + const label = RESOURCE_LABELS[resourceType]; if (name !== undefined) { if (names.includes(name)) return name; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index fdd0e4ff47..43439ea7d4 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -233,7 +233,7 @@ export type ResolveProjectResourcesInput = { /** * Every resource type a project can declare and deploy. Broader than * {@link ProjectInvokableResource}: status reports all of them, while invoke only - * addresses the two that accept a payload. + * addresses the three that accept a request. */ export type DeployableResource = | "runtime" @@ -398,7 +398,13 @@ export type ExportHarnessResult = { notes: ExportNote[]; }; -export type ProjectInvokableResource = Extract; +export type ProjectInvokableResource = Extract; + +export const RESOURCE_LABELS: Record = { + runtime: "Runtime", + harness: "Harness", + gateway: "Gateway", +}; export type RemoveResourceInput = | { @@ -487,7 +493,7 @@ export interface ProjectManager { input: ResolveDeployedResourceInput, ): Promise; - /** Resolve every configured Runtime and Harness present in the deployed target stack. */ + /** Resolve every configured Runtime, Harness, and Gateway present in the deployed target stack. */ resolveDeployedResources( project: Project, input: ResolveDeployedResourcesInput, diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 9ee0d9c77f..35203e3a1b 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -18,40 +18,42 @@ import { writeRuntimeInvokeResponse } from "./response"; import { RuntimeInvokeLaunchContextKey } from "./launchContext"; import { invokeRuntimeTarget } from "./operation"; +export const invokeRuntimeFlags = [ + flag("id", "the ID of the Runtime", runtimeIdSchema), + flag("payload", "the inline payload to send", z.string().optional(), { + sensitive: true, + }), + flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("session-id", "the Runtime session ID", z.string().optional()), + flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag("mcp-method", "the MCP method", z.string().optional()), + flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), + flag("trace-id", "the X-Ray trace ID", z.string().optional()), + flag("trace-parent", "the W3C trace parent", z.string().optional()), + flag("trace-state", "the W3C trace state", z.string().optional()), + flag("baggage", "the W3C baggage", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), +] as const; + export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => createHandler({ name: "invoke", description: "invoke a Runtime", - flags: [ - flag("id", "the ID of the Runtime", runtimeIdSchema), - flag("payload", "the inline payload to send", z.string().optional(), { - sensitive: true, - }), - flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("session-id", "the Runtime session ID", z.string().optional()), - flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { - sensitive: true, - }), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag("mcp-method", "the MCP method", z.string().optional()), - flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), - flag("trace-id", "the X-Ray trace ID", z.string().optional()), - flag("trace-parent", "the W3C trace parent", z.string().optional()), - flag("trace-state", "the W3C trace state", z.string().optional()), - flag("baggage", "the W3C baggage", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], + flags: invokeRuntimeFlags, handle: async (ctx, flags) => { if (flags.payload === undefined) { const hasHeadlessOnlyFlag = Object.entries(flags).some( diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx index 52ba6071b4..a0b834d3fb 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,9 +1,15 @@ import { describe, expect, test } from "bun:test"; +import { ProjectSpecSchema } from "../projectSchemas/project"; +import { ProjectKey, ValueContext } from "../router"; +import { TestCoreClient } from "../testing"; +import { RegionKey } from "./keys"; +import type { ProjectManager, ResolveDeployedResourceInput } from "./project/types"; import { assertMutuallyExclusiveFlags, parseJsonArrayFlag, parseJsonObjectFlag, parseTags, + resolveResource, } from "./utils"; describe("structured JSON flags", () => { @@ -115,3 +121,65 @@ describe("assertMutuallyExclusiveFlags", () => { expect(() => assertMutuallyExclusiveFlags(flags, names, options)).toThrow(message); }); }); + +describe("resolveResource", () => { + const credentials = async () => ({ accessKeyId: "target", secretAccessKey: "secret" }); + const project = { + name: "orders", + rootPath: "/orders", + spec: ProjectSpecSchema.parse({ + name: "orders", + version: 2, + agentCoreGateways: [{ name: "tools", targets: [] }], + }), + }; + + function setup() { + const core = new TestCoreClient(); + const lookups: ResolveDeployedResourceInput[] = []; + Object.assign(core, { + projectManager: { + resolveDeployedResource: async (_project, input) => { + lookups.push(input); + return { + resourceType: input.resourceType, + name: input.name, + id: "tools-AbCd", + target: { name: input.target, account: "111122223333", region: "eu-west-1" }, + credentialProvider: credentials, + }; + }, + } as Partial, + }); + const ctx = ValueContext.EmptyContext() + .withValue(RegionKey, "us-west-2") + .withValue(ProjectKey, project); + return { core, ctx, lookups }; + } + + test("resolves a project name with the target's region and credentials", async () => { + const { core, ctx, lookups } = setup(); + + expect(await resolveResource(core, ctx, "gateway", "tools", "prod")).toEqual({ + id: "tools-AbCd", + region: "eu-west-1", + credentials, + }); + expect(lookups).toEqual([{ target: "prod", resourceType: "gateway", name: "tools" }]); + }); + + test.each([ + ["an ID in the current region", "gw-1", { id: "gw-1", region: "us-west-2" }], + [ + "an ARN in its own region", + "arn:aws:bedrock-agentcore:ap-south-1:111122223333:gateway/gw-1", + { id: "gw-1", region: "ap-south-1" }, + ], + ])("resolves %s without any lookup", async (_name, identifier, expected) => { + const { core, ctx, lookups } = setup(); + + expect(await resolveResource(core, ctx, "gateway", identifier, "prod")).toEqual(expected); + expect(lookups).toEqual([]); + expect(core.gateway.calls).toEqual([]); + }); +}); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 82d18ae391..5ff4dccf4b 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,12 +1,16 @@ import { createContext, useContext, useEffect } from "react"; -import type { Context } from "../router"; +import { ProjectKey, type Context } from "../router"; import type z from "zod"; -import type { CoreOptions } from "../core/types"; +import type { AwsCredentialProvider, CoreOptions } from "../core/types"; import type { AppIO } from "../io"; +import { regionFromArn, serviceIdFromArn } from "../core/arn"; import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../errors"; import { formatZodError } from "../router/schema"; import { AwsCredentialProviderKey, EndpointKey, JsonKey, RegionKey } from "./keys"; import { JsonRendererKey } from "../tui"; +import { projectResourceNames } from "./project/selection"; +import type { ProjectInvokableResource } from "./project/types"; +import type { Core } from "./types"; // coreOptsFromCtx builds the standard CoreOptions handed to Core operations from // the values pinned on the context: the resolved region (always present, see the @@ -22,6 +26,38 @@ export function coreOptsFromCtx(ctx: Context): CoreOptions { }; } +export type ResolvedResource = { + id: string; + region: string; + credentials?: AwsCredentialProvider; +}; + +export async function resolveResource( + core: Core, + ctx: Context, + resourceType: ProjectInvokableResource, + identifier: string, + target: string, +): Promise { + const project = ctx.value(ProjectKey); + if (project && projectResourceNames(project, resourceType).includes(identifier)) { + const deployed = await core.projectManager.resolveDeployedResource(project, { + target, + resourceType, + name: identifier, + }); + return { + id: deployed.id, + region: deployed.target.region, + credentials: deployed.credentialProvider, + }; + } + return { + id: serviceIdFromArn(identifier), + region: regionFromArn(identifier) ?? ctx.require(RegionKey), + }; +} + // A pinned region replaces RegionKey on every route's context, so a screen that // shows a resource living outside the launch region pins it and everything it // opens next fetches there. The pin lasts until the user navigates back past diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx index d49f8ccc6d..4ef7ef5416 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -6,6 +6,7 @@ interface WithProjectConfig { projectManager: ProjectManager; /** Directory to search upwards from. Defaults to the cwd at invocation time. */ cwd?: string; + optional?: boolean; } /** @@ -37,10 +38,10 @@ export function withProject(config: WithProjectConfig): Middleware { // actually ran in is the one searched. const from = config.cwd ?? process.cwd(); const project = await config.projectManager.resolve({ filePath: from }); - if (!project) { + if (!project && !config.optional) { throw new ProjectStateError(projectNotFoundMessage(from)); } - await h.handle(ctx.withValue(ProjectKey, project), flags, args); + await h.handle(project ? ctx.withValue(ProjectKey, project) : ctx, flags, args); }, }); } diff --git a/src/router/handler.tsx b/src/router/handler.tsx index 5fd343ad7b..95f63a1a6a 100644 --- a/src/router/handler.tsx +++ b/src/router/handler.tsx @@ -60,7 +60,7 @@ export function globalFlag( // FlagsOf maps a tuple of Flags to a typed object keyed by each flag's literal // name, with values typed by z.infer of each schema. -type FlagsOf[]> = { +export type FlagsOf[]> = { [E in F[number] as E["name"]]: E extends Flag ? T : never; }; diff --git a/src/router/index.tsx b/src/router/index.tsx index f1c6b78a4a..b0d7fe9e0f 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -19,6 +19,7 @@ export { type Flag, type GlobalFlag, type Argument, + type FlagsOf, createHandler, flag, globalFlag,