diff --git a/README.md b/README.md index 3135b17154..3845176a7a 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 MyAssistant --prompt "Hey, what can you do for me?" +agentcore invoke harness --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. `invoke` can use `--runtime`, -`--harness`, `--gateway`, and `--target` to select a resource for an interactive session. +flags select headless behavior for most commands. Project invoke commands can +use `--name` and `--target` to select a deployed agent 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 65d3cd898b..88b03fdb33 100644 --- a/command.md +++ b/command.md @@ -38,6 +38,8 @@ 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) @@ -645,39 +647,58 @@ deploy the project to AWS ### `agentcore invoke` ```text -agentcore invoke [options] +agentcore invoke [options] [command] ``` -invoke a Runtime, harness, or Gateway +invoke a Runtime or harness from the current project + +#### `agentcore invoke runtime` + +```text +agentcore invoke runtime [options] +``` + +invoke a Runtime from the current project **Options** -- `--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) +- `--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) - `--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 -- `--path `: the path relative to the Gateway origin -- `--method `: the HTTP request method +- `--session-id `: the Runtime session ID to continue (33-100 characters) +- `--qualifier `: the harness endpoint qualifier to invoke (default DEFAULT) ### `agentcore log` diff --git a/e2eTest/project/templates.test.ts b/e2eTest/project/templates.test.ts index affe85d91d..0f0edb729d 100644 --- a/e2eTest/project/templates.test.ts +++ b/e2eTest/project/templates.test.ts @@ -239,9 +239,10 @@ describe( await cli.run( [ "invoke", - "--runtime", - runtime.name, + "runtime", "--local", + "--name", + runtime.name, "--port", String(port), "--session-id", @@ -282,7 +283,8 @@ describe( await cli.run( [ "invoke", - "--runtime", + "runtime", + "--name", 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 edda56d76b..fe41ce72f7 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 {{name}} --local --payload '{"prompt":"What is 2 plus 3?"}' +agentcore invoke runtime --local --name {{name}} --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 {{name}} --payload '{"prompt":"Hello!"}' +agentcore invoke runtime --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 1843460390..b8bc638f7b 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 {{name}} --payload '{"prompt":"Hello!"}' +agentcore invoke runtime --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-python-strands/README.md b/src/assets/templates/agent-python-strands/README.md index 51db8b5ddd..98120c90b3 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 {{name}} --payload '{"prompt":"Hello!"}' +agentcore invoke runtime --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-typescript-strands/README.md b/src/assets/templates/agent-typescript-strands/README.md index f92e83925f..07d4845fb8 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 {{runtimeName}} --payload '{"prompt":"Hello!","actorId":"user-123"}' +agentcore invoke runtime --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 b1662331f5..7b84681720 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 }} \ +agentcore invoke runtime --name {{ 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 bf32c7616d..391f294ea2 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 {{name}} --payload '{"prompt":"Hello!"}' +agentcore invoke runtime --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/mcp-python-fastmcp/README.md b/src/assets/templates/mcp-python-fastmcp/README.md index 2fca1de442..1497916786 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 {{name}}`, supplying an MCP JSON-RPC payload (e.g. `tools/list`, `tools/call`). +`agentcore invoke runtime`, 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 588c35afaf..59ddd6cc6e 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1015,7 +1015,6 @@ 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({ @@ -1032,10 +1031,6 @@ 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", - }, ], }, }); @@ -1057,13 +1052,6 @@ 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 bc4fb90ed7..2ffeae274a 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -108,11 +108,10 @@ function findDeployedResourceId( ): string | undefined { if (!stack.StackName) return undefined; const exportResourceName = input.name.replaceAll("_", "-"); - const exportName = { - runtime: `${stack.StackName}-${exportResourceName}-RuntimeId`, - harness: `${stack.StackName}-Harness-${exportResourceName}-Id`, - gateway: `${stack.StackName}-Gateway-${exportResourceName}-Id`, - }[input.resourceType]; + const exportName = + input.resourceType === "runtime" + ? `${stack.StackName}-${exportResourceName}-RuntimeId` + : `${stack.StackName}-Harness-${exportResourceName}-Id`; return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; } @@ -507,10 +506,6 @@ 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 927be565fb..22f6b8f03a 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -24,7 +24,6 @@ import type { RemoveResourceResult, RemoveResourcesResult, } from "../../handlers/project/types"; -import { RESOURCE_LABELS } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { FsReadWriteJson, @@ -1045,12 +1044,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 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. + // 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. if (resource) return { ...resource, target: resolved.target }; - const label = RESOURCE_LABELS[input.resourceType]; + const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; 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 59322b1797..15db1fef0a 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -122,11 +122,7 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa const tree = await FsTreeNode.fromAssetSource( { assetSource }, { assetDir: "templates/agent-python-minimal" }, - { - rootDirName: input.name, - transformContent: (raw) => - templateRenderer.render(raw, { name: toPythonPackageName(input.name) }), - }, + { rootDirName: input.name }, ); return { tree, spec: { runtimes: [buildRuntimeSpec(input)] } }; }, @@ -190,7 +186,6 @@ 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 a025429c67..30614c5273 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -19,34 +19,6 @@ 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, @@ -55,7 +27,33 @@ export const createInvokeGatewayHandler = ( createHandler({ name: "invoke", description: "invoke an AgentCore Gateway", - flags: invokeGatewayFlags, + 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(), + ), + ], 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 28fd900fb4..6cdd905e91 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -161,16 +161,9 @@ export function GatewayInvokeScreen(props: ScreenProps) { type GatewayInvokeConsoleProps = ScreenProps & { gatewayId: string; initialContext?: GatewayInvokeLaunchContext; - onBack?: () => void; }; -export function GatewayInvokeConsole({ - ctx, - core, - gatewayId, - initialContext, - onBack, -}: GatewayInvokeConsoleProps) { +function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayInvokeConsoleProps) { const navigate = useNavigate(); const opts = coreOptsFromCtx(ctx); const { columns, rows } = useWindowSize(); @@ -392,7 +385,6 @@ export function GatewayInvokeConsole({ } 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 0eacc44ab9..a18e263dfb 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -9,26 +9,24 @@ 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: invokeHarnessFlags, + 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(), + ), + ], 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 new file mode 100644 index 0000000000..0821e6b38f --- /dev/null +++ b/src/handlers/project/invoke/harness.tsx @@ -0,0 +1,78 @@ +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 031586280a..b8125865db 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -2,16 +2,17 @@ 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 } from "../../../core/project"; +import type { ProjectBackend, ResolveDeployedResourcesBackendInput } 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, @@ -22,9 +23,14 @@ import { tick, waitFor, } from "../../../testing"; -import * as tui from "../../../tui"; import { createRootHandler } from "../../index"; -import { RegionKey } from "../../keys"; +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"; const servers: HttpServerHandle[] = []; const cleanups: Array<() => Promise> = []; @@ -34,15 +40,14 @@ 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 GATEWAY_ID = "tools-AbCdEf1234"; +const HARNESS_ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:harness/${HARNESS_ID}`; const RUNTIME = { name: "checkout", build: "CodeZip", @@ -51,23 +56,33 @@ const RUNTIME = { runtimeVersion: "PYTHON_3_14", } as const; const HARNESS = { name: "support", path: "app/support" } as const; -const GATEWAY = { name: "tools", targets: [] } as const; -type Resources = { - runtimes?: readonly unknown[]; - harnesses?: readonly unknown[]; - agentCoreGateways?: readonly unknown[]; -}; +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} function header(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value.join(", ") : value; } -async function inProject(resources: Resources, options: { writeTargets?: boolean } = {}) { - const { path: root, cleanup } = await inTempDirectory("agentcore-project-invoke-"); +async function inProject( + resources: { + runtimes?: unknown[]; + harnesses?: unknown[]; + }, + options: { writeTargets?: boolean } = {}, +): Promise { + const { path: root, cleanup } = await inTempDirectory("agentcore-project-invoke-reduced-"); cleanups.push(cleanup); await mkdir(join(root, "agentcore"), { recursive: true }); - const spec = ProjectSpecSchema.parse({ name: "orders", version: 2, ...resources }); + const spec = ProjectSpecSchema.parse({ + name: "orders", + version: 2, + runtimes: resources.runtimes ?? [], + harnesses: resources.harnesses ?? [], + }); 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])); @@ -75,7 +90,7 @@ async function inProject(resources: Resources, options: { writeTargets?: boolean } function backend() { - const targets: string[] = []; + const calls: ResolveDeployedResourcesBackendInput[] = []; const value: ProjectBackend = { async *build() {}, async *deploy() { @@ -83,84 +98,54 @@ function backend() { return { outputs: {} }; }, async resolveDeployedResources(project, input) { - 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, + calls.push(input); + return [ + ...project.spec.runtimes.map(({ name }) => ({ + resourceType: "runtime" as const, name, - id: ids[resourceType], + id: RUNTIME_ID, target: input.target, credentialProvider: TARGET_CREDENTIALS, })), - ); + ...project.spec.harnesses.map(({ name }) => ({ + resourceType: "harness" as const, + name, + id: HARNESS_ID, + target: input.target, + credentialProvider: TARGET_CREDENTIALS, + })), + ]; }, async resolveProjectResources() { - throw new Error("invoke resolves deployed resources, not project resources"); + throw new Error("project invoke resolves deployed resources, not project resources"); }, }; - return { targets, value }; + return { calls, value }; } -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 } }); +function configureCore(core: TestCoreClient): void { core.runtime - .setGetResponse({ agentRuntimeArn: arn(`runtime/${RUNTIME_ID}`) } as GetAgentRuntimeResponse) + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "text/plain", - body: (async function* () { - yield Buffer.from("runtime response"); - })(), + body: body(Buffer.from("runtime response")), }); core.harness - .setGetResponse({ harness: { arn: arn(`harness/${HARNESS_ID}`) } } as GetHarnessResponse) + .setGetResponse({ + harness: { harnessId: HARNESS_ID, harnessName: "support", arn: HARNESS_ARN }, + } 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: readonly string[], - resources: Resources | undefined, + args: string[], + resources: { runtimes?: unknown[]; harnesses?: unknown[] }, options: { writeTargets?: boolean } = {}, ) { const subject = await routedCommand(args, resources, options); @@ -168,17 +153,30 @@ async function run( return subject; } -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(); - } +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"); } afterEach(async () => { @@ -186,161 +184,73 @@ afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); -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], +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, }); - - 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"]); + 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.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); + test.each([false, true])("keeps machine output free of progress (json=%s)", async (json) => { const subject = await routedCommand( - ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", "-"], + ["runtime", "--payload", "{}", ...(json ? ["--json"] : [])], { runtimes: [RUNTIME] }, - { isTTY: true }, + { isTTY: json }, ); - 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"); + 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"); }); - test("invokes a local Runtime without resolving deployed resources", async () => { + test("auto-selects the sole local Runtime without resolving deployed resources", async () => { let request: | { method: string; @@ -372,7 +282,7 @@ describe("invoke", () => { const payload = '{"prompt":"hi"}'; const { core, io, resolved } = await run( - ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", payload], + ["runtime", "--local", "--port", String(server.port), "--payload", payload], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -390,7 +300,7 @@ describe("invoke", () => { }); expect(io.stdout()).toBe("local response"); expect(io.stderr()).toContain(`runtime-session-id=${request?.sessionId}`); - expect(resolved.targets).toEqual([]); + expect(resolved.calls).toEqual([]); expect(core.runtime.calls).toEqual([]); }); @@ -436,9 +346,10 @@ describe("invoke", () => { await run( [ - "--runtime", - RUNTIME.name, + "runtime", "--local", + "--name", + RUNTIME.name, "--port", String(server.port), "--payload", @@ -481,7 +392,7 @@ describe("invoke", () => { try { const code = await runWithExitCode(async () => { await run( - ["--runtime", RUNTIME.name, "--local", "--port", String(port), "--payload", "{}"], + ["runtime", "--local", "--name", RUNTIME.name, "--port", String(port), "--payload", "{}"], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -505,7 +416,16 @@ describe("invoke", () => { })); servers.push(server); const subject = await routedCommand( - ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", "{}"], + [ + "runtime", + "--local", + "--name", + RUNTIME.name, + "--port", + String(server.port), + "--payload", + "{}", + ], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -569,9 +489,10 @@ describe("invoke", () => { await run( [ - "--runtime", - name, + "runtime", "--local", + "--name", + name, "--port", String(server.port), "--payload", @@ -596,22 +517,31 @@ describe("invoke", () => { test.each([ { name: "requires --local with --port", - args: ["--runtime", RUNTIME.name, "--port", "8081", "--payload", "{}"], + args: ["runtime", "--port", "8081", "--payload", "{}"], message: "--port requires --local", }, { name: "rejects deployed-only flags locally", - args: ["--runtime", RUNTIME.name, "--local", "--payload", "{}", "--target", "prod"], + args: ["runtime", "--local", "--name", RUNTIME.name, "--payload", "{}", "--target", "prod"], message: "--target cannot be used with --local", }, { name: "requires a local payload", - args: ["--runtime", RUNTIME.name, "--local"], + args: ["runtime", "--local", "--name", RUNTIME.name], message: "required option '--payload ' not specified", }, { name: "rejects MCP options for a local non-MCP Runtime", - args: ["--runtime", RUNTIME.name, "--local", "--payload", "{}", "--mcp-method", "tools/list"], + args: [ + "runtime", + "--local", + "--name", + RUNTIME.name, + "--payload", + "{}", + "--mcp-method", + "tools/list", + ], message: "MCP options are only valid for MCP Runtimes", }, ])("$name", async ({ args, message }) => { @@ -619,4 +549,168 @@ describe("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 c9bd1d8d27..3dbdfc82a3 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -1,187 +1,28 @@ -import z from "zod"; -import { InputValidationError } from "../../../errors"; import type { AppIO } from "../../../io"; -import { withProject } from "../../../middleware"; -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 { InputValidationError } from "../../../errors"; +import { Router } from "../../../router"; import { renderTuiAt } from "../../../tui"; -import { createInvokeGatewayHandler, invokeGatewayFlags } from "../../gateway/invoke"; -import { createInvokeHarnessHandler, invokeHarnessFlags } from "../../harness/invoke"; -import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; +import { withProject } from "../../../middleware"; +import { JsonKey } from "../../keys"; import type { Core } from "../../types"; -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) { +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)) { throw new InputValidationError( - `--${unsupported[0]} does not apply to a ${RESOURCE_LABELS[resourceType]}`, + "a Runtime or harness invoke subcommand is required with --json", ); } - 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, {}); - }, - }); + return renderInvokeTui("/agentcore/invoke", ctx, core, io); + }); } diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index 91a8da4762..f93760d815 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -2,7 +2,6 @@ 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"; @@ -39,7 +38,6 @@ const project: Project = { }, ], harnesses: [{ name: "support", path: "app/support" }], - agentCoreGateways: [{ name: "tools", targets: [] }], }), }; @@ -81,13 +79,6 @@ const DEPLOYED_RESOURCES: ResolvedDeployedResource[] = [ target: TARGET, credentialProvider: TARGET_CREDENTIALS, }, - { - resourceType: "gateway", - name: "tools", - id: "gateway-123", - target: TARGET, - credentialProvider: TARGET_CREDENTIALS, - }, ]; function core( @@ -116,12 +107,6 @@ 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; } @@ -224,7 +209,7 @@ describe("project invoke picker", () => { expect(screen.lastFrame()).toContain("support"); }); - test("lists project Runtime, Harness, and Gateway resources", async () => { + test("lists project Runtime and Harness resources", async () => { const screen = renderScreen("/agentcore/invoke", { core: core(), withContext: (ctx) => ctx.withValue(ProjectKey, project), @@ -237,29 +222,6 @@ 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 c43c60a91f..113f0c63df 100644 --- a/src/handlers/project/invoke/runtime.tsx +++ b/src/handlers/project/invoke/runtime.tsx @@ -1,105 +1,267 @@ +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 { JsonKey } from "../../keys"; +import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; +import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; +import { invokeRuntimeTarget } from "../../runtime/invoke/operation"; import { parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, + resolveRuntimeInvokeTuiBearerToken, } from "../../runtime/invoke/request"; import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; -import type { Project } from "../types"; -import type { InvokeFlags } from "."; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { selectProjectResource } from "../selection"; -export async function invokeProjectRuntimeLocally( +export const createProjectInvokeRuntimeHandler = ( + core: Core, io: AppIO, - 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, - }); - } + 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); - 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, - }); + 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, + }); + }); + }, }); -} diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index c004a069e6..96ea15319b 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -8,25 +8,21 @@ 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, RegionKey } from "../../keys"; +import { AwsCredentialProviderKey } from "../../keys"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; -import { - RESOURCE_LABELS, - type Project, - type ResolvedDeployedResource, - type ResolvedDeployedResources, -} from "../types"; +import type { Project, ResolvedDeployedResources } from "../types"; import { ProjectGate } from "../ProjectGate"; -type ProjectInvokableRow = Record & - ResolvedDeployedResource & { - type: string; - protocol: string; - source: string; - }; +type ProjectInvokableRow = Record & { + resourceType: "runtime" | "harness"; + type: "Runtime" | "Harness"; + name: string; + id: string; + protocol: string; + source: string; +}; const columns = [ { key: "type", header: "type", width: 10 }, @@ -37,10 +33,10 @@ const columns = [ type Destination = | { resourceType: "runtime"; id: string; ctx: Context; qualifier?: string } - | { resourceType: "harness" | "gateway"; id: string; ctx: Context }; + | { resourceType: "harness"; id: string; ctx: Context }; const BREADCRUMB = ["agentcore", "invoke"]; -const DESCRIPTION = "invoke a Runtime, harness, or Gateway from the current project"; +const DESCRIPTION = "invoke a Runtime or harness from the current project"; const PROJECT_MENU = "/agentcore"; // The project comes from the launch context when a project command opened the @@ -113,19 +109,21 @@ function ProjectInvokePicker({ const rows = useMemo( () => (deployed?.resources ?? []).map((resource) => { - 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; + 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); return { ...resource, - type: RESOURCE_LABELS[resource.resourceType], - protocol: runtime ? (runtime.protocol ?? "HTTP") : "-", - source: runtime?.codeLocation ?? harness?.path ?? "-", + type: "Harness" as const, + protocol: "-", + source: configured?.path ?? "-", }; }), [deployed, project], @@ -135,9 +133,7 @@ function ProjectInvokePicker({ setDestination({ resourceType: row.resourceType, id: row.id, - ctx: ctx - .withValue(RegionKey, deployed!.target.region) - .withValue(AwsCredentialProviderKey, row.credentialProvider), + ctx: ctx.withValue(AwsCredentialProviderKey, row.credentialProvider), }); }; @@ -182,17 +178,6 @@ 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 449ccd7ec7..e83b460120 100644 --- a/src/handlers/project/selection.ts +++ b/src/handlers/project/selection.ts @@ -1,16 +1,13 @@ import { InputValidationError, ResourceNotFoundError } from "../../errors"; -import { RESOURCE_LABELS, type Project, type ProjectInvokableResource } from "./types"; +import type { Project, ProjectInvokableResource } from "./types"; export function projectResourceNames( project: Project, resourceType: ProjectInvokableResource, ): string[] { - const resources = { - runtime: project.spec.runtimes, - harness: project.spec.harnesses, - gateway: project.spec.agentCoreGateways, - }[resourceType]; - return resources.map(({ name }) => name); + return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( + ({ name }) => name, + ); } export function selectProjectResource( @@ -20,7 +17,7 @@ export function selectProjectResource( operation: string, ): string { const names = projectResourceNames(project, resourceType); - const label = RESOURCE_LABELS[resourceType]; + const label = resourceType === "runtime" ? "Runtime" : "Harness"; if (name !== undefined) { if (names.includes(name)) return name; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 43439ea7d4..fdd0e4ff47 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 three that accept a request. + * addresses the two that accept a payload. */ export type DeployableResource = | "runtime" @@ -398,13 +398,7 @@ export type ExportHarnessResult = { notes: ExportNote[]; }; -export type ProjectInvokableResource = Extract; - -export const RESOURCE_LABELS: Record = { - runtime: "Runtime", - harness: "Harness", - gateway: "Gateway", -}; +export type ProjectInvokableResource = Extract; export type RemoveResourceInput = | { @@ -493,7 +487,7 @@ export interface ProjectManager { input: ResolveDeployedResourceInput, ): Promise; - /** Resolve every configured Runtime, Harness, and Gateway present in the deployed target stack. */ + /** Resolve every configured Runtime and Harness 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 35203e3a1b..9ee0d9c77f 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -18,42 +18,40 @@ 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: invokeRuntimeFlags, + 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(), + ), + ], 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 a0b834d3fb..52ba6071b4 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,15 +1,9 @@ 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", () => { @@ -121,65 +115,3 @@ 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 5ff4dccf4b..82d18ae391 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,16 +1,12 @@ import { createContext, useContext, useEffect } from "react"; -import { ProjectKey, type Context } from "../router"; +import type { Context } from "../router"; import type z from "zod"; -import type { AwsCredentialProvider, CoreOptions } from "../core/types"; +import type { 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 @@ -26,38 +22,6 @@ 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 4ef7ef5416..d49f8ccc6d 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -6,7 +6,6 @@ interface WithProjectConfig { projectManager: ProjectManager; /** Directory to search upwards from. Defaults to the cwd at invocation time. */ cwd?: string; - optional?: boolean; } /** @@ -38,10 +37,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 && !config.optional) { + if (!project) { throw new ProjectStateError(projectNotFoundMessage(from)); } - await h.handle(project ? ctx.withValue(ProjectKey, project) : ctx, flags, args); + await h.handle(ctx.withValue(ProjectKey, project), flags, args); }, }); } diff --git a/src/router/handler.tsx b/src/router/handler.tsx index 95f63a1a6a..5fd343ad7b 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. -export type FlagsOf[]> = { +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 b0d7fe9e0f..f1c6b78a4a 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -19,7 +19,6 @@ export { type Flag, type GlobalFlag, type Argument, - type FlagsOf, createHandler, flag, globalFlag,