diff --git a/README.md b/README.md index e43fe0988..84f21fcb5 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,13 @@ agentcore # interactive TUI │ │ # payment-manager, payment-connector — or `all`, which │ │ # empties every resource collection (y/N prompt; --yes │ │ # skips it for non-interactive use) -│ ├── build # synthesize the project's CloudFormation templates +│ ├── dev # run the project locally │ ├── deploy # deploy to AWS (auto-provisions the default target) -│ └── dev # run the project's agents locally +│ ├── invoke # invoke a deployed project resource +│ │ ├── runtime # use the existing Runtime invoke experience +│ │ └── harness # use the existing Harness invoke experience +│ ├── status # inspect deployed project resources +│ └── build # synthesize the project's CloudFormation templates └── config # read/write global config values ``` @@ -151,6 +155,26 @@ Global flags (declared at the root, available on every command): | `--debug` | Debug logging. | | `--endpoint-url` | Override the service endpoint URL (e.g. for testing against a stub). | +### Invoke a project resource + +Run `agentcore project invoke` from inside a project to choose a deployed +Runtime or Harness interactively. Headless invocation keeps each resource's +existing input contract: + +```bash +agentcore project invoke runtime \ + --name checkout \ + --payload '{"prompt":"Check order 123."}' \ + --content-type application/json + +agentcore project invoke harness \ + --name support \ + --prompt "Help with my account." +``` + +Use `--target` to select a deployment target. When a project declares exactly +one resource of the requested type, `--name` may be omitted. + ### Examples ```bash diff --git a/src/assets/templates/hello-world-python-container/README.md b/src/assets/templates/hello-world-python-container/README.md index 637390bb0..190d41163 100644 --- a/src/assets/templates/hello-world-python-container/README.md +++ b/src/assets/templates/hello-world-python-container/README.md @@ -25,3 +25,9 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore project deploy ``` + +Invoke the deployed Runtime with its native payload: + +```bash +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md index b43ddbbba..d2cadd33d 100644 --- a/src/assets/templates/hello-world-python/README.md +++ b/src/assets/templates/hello-world-python/README.md @@ -27,9 +27,6 @@ curl -X POST http://localhost:8080/invocations \ -d '{"prompt": "Hello!"}' ``` - - ## Build your agent Start in `main.py`: @@ -58,3 +55,8 @@ for multi-agent patterns, MCP tools, and model configuration. Deploy from the project root with the AgentCore CLI; the CDK app under `agentcore/cdk` provisions the Runtime that hosts this agent. + +```bash +agentcore project deploy +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` diff --git a/src/assets/templates/strands-http-python/README.md b/src/assets/templates/strands-http-python/README.md index eafaa1ec0..5714aafbf 100644 --- a/src/assets/templates/strands-http-python/README.md +++ b/src/assets/templates/strands-http-python/README.md @@ -38,3 +38,9 @@ Command Prompt, or `.\.venv\Scripts\activate.ps1` in Windows PowerShell. # Deployment After providing credentials, `agentcore project deploy` will deploy your project into Amazon Bedrock AgentCore. + +Invoke the deployed Runtime with its native payload: + +```bash +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 964690ef6..b05b27bef 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -109,6 +109,7 @@ import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx"; import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx"; +import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -147,6 +148,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { } /> + } + /> } /> {/* Bare `get` (no id) has nothing to show — send the user to the list. */} ({ @@ -161,10 +167,6 @@ function harness(options: HarnessOptions = {}) { if (options.bootstrapError) throw options.bootstrapError; return options.bootstrap ?? { kind: "current", version: 30 }; }, - stack: async (stackName) => { - stackProbes.push(stackName); - return options.stackExists ?? true; - }, cdk: async (operation, runOptions) => { runs.push({ operation, options: runOptions }); if (operation.kind === options.failOperation) { @@ -194,6 +196,17 @@ function harness(options: HarnessOptions = {}) { }, }; }, + describeStack: async (region, provider, stackName) => { + stackReads.push({ stackName, region, credentials: provider }); + if (options.describedStack === null) return undefined; + return ( + options.describedStack ?? { + StackName: stackName, + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + } + ); + }, }); return { @@ -206,7 +219,7 @@ function harness(options: HarnessOptions = {}) { credentialRegions, credentials, runs, - stackProbes, + stackReads, templateLoads: () => templateLoads, templateCleanups: () => templateCleanups, }; @@ -434,7 +447,13 @@ describe("CdkBackend.deploy", () => { expect(subject.runs.map(({ operation }) => operation)).toEqual([ { kind: "destroy", stackArtifactId: "AgentCore-example-default-0" }, ]); - expect(subject.stackProbes).toEqual(["AgentCore-example-default-0"]); + expect(subject.stackReads).toEqual([ + { + stackName: "AgentCore-example-default-0", + region: TARGET.region, + credentials: subject.credentials, + }, + ]); expect(JSON.parse(await Bun.file(statePath).text())).toEqual({ targets: { prod: { stackArn: "arn:stack:prod" } }, }); @@ -443,7 +462,7 @@ describe("CdkBackend.deploy", () => { test("says to add a resource when there is no stack to remove either", async () => { const input = await project(); await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY }); - const subject = harness({ stackExists: false }); + const subject = harness({ describedStack: null }); await expect( collectDeploy( @@ -460,7 +479,7 @@ describe("CdkBackend.deploy", () => { await collectDeploy(subject.backend.deploy(input, deployInput())); - expect(subject.stackProbes).toEqual([]); + expect(subject.stackReads).toEqual([]); }); test.each([ @@ -563,3 +582,117 @@ describe("CdkBackend.deploy", () => { expect(subject.runs.map(({ operation }) => operation.kind)).toEqual(["bootstrap"]); }); }); + +describe("CdkBackend.resolveDeployedResources", () => { + test("describes the stack once and returns only resources with deployed ID outputs", async () => { + const input = await project(); + input.spec = ProjectSpecSchema.parse({ + ...input.spec, + runtimes: [ + { + name: "checkout_agent", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout_agent", + runtimeVersion: "PYTHON_3_14", + }, + { + name: "inventory", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/inventory", + runtimeVersion: "PYTHON_3_14", + }, + ], + harnesses: [{ name: "support_agent", path: "app/support_agent" }], + }); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ + describedStack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: "AgentCore-example-default-checkout-agent-RuntimeId", + OutputValue: "checkout_agent-AbCdEf1234", + }, + { + ExportName: "AgentCore-example-default-Harness-support-agent-Id", + OutputValue: "support_agent-AbCdEf1234", + }, + ], + }, + }); + + const resources = await subject.backend.resolveDeployedResources(input, { target: TARGET }); + + expect(resources).toEqual([ + { resourceType: "runtime", name: "checkout_agent", id: "checkout_agent-AbCdEf1234" }, + { resourceType: "harness", name: "support_agent", id: "support_agent-AbCdEf1234" }, + ]); + expect(subject.stackReads).toHaveLength(1); + }); + + test("fails without reading AWS when the target has no deployed stack ARN", async () => { + const input = await project(); + const subject = harness({ describedStack: null }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET }), + ).rejects.toThrow(/not deployed.*project deploy --target default/s); + expect(subject.stackReads).toEqual([]); + expect(subject.accountCredentials).toEqual([]); + }); + + test("fails actionably when the recorded stack no longer exists", async () => { + const input = await project(); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ describedStack: null }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET }), + ).rejects.toThrow(/not deployed.*project deploy --target default/s); + expect(subject.stackReads[0]?.stackName).toBe(STACK_ARN); + }); + + test("omits configured resources that have no deployed ID output", async () => { + const input = await project(); + input.spec = ProjectSpecSchema.parse({ + ...input.spec, + runtimes: [ + { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", + }, + ], + }); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ + describedStack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [], + }, + }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET }), + ).resolves.toEqual([]); + }); + + test("rejects the wrong account before reading CloudFormation", async () => { + const input = await project(); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ account: "999900001111" }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET }), + ).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s); + expect(subject.stackReads).toEqual([]); + }); +}); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index c8e7fa525..78b5cac8d 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -1,7 +1,13 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import type { Stack } from "@aws-sdk/client-cloudformation"; import { MalformedServiceResponseError, ProjectStateError } from "../../../errors/errors"; -import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; +import type { + DeployedProjectResource, + DeployResult, + Project, + ProjectEvent, +} from "../../../handlers/project/types"; import { FsReadWriteJson, requireTool, @@ -10,7 +16,12 @@ import { type ReadWriteJson, } from "../../../io"; import type { Logger } from "../../../logging"; -import type { DeployBackendInput, ProjectBackend } from "./types"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import type { + DeployBackendInput, + ProjectBackend, + ResolveDeployedResourcesBackendInput, +} from "./types"; import { createCloudFormationClient } from "../../factories"; import type { CreateCloudFormationClient } from "../../types"; import { @@ -23,11 +34,9 @@ import { bootstrapStackReader, createCloudFormationStackReader, probeBootstrap, - probeStack, resolveAwsAccount, type AccountResolver, type BootstrapProbe, - type StackProbe, } from "./cdk/environment"; import { createCdkCredentialResolver, @@ -38,6 +47,22 @@ import { type CdkRunner, type CdkRunOptions, } from "./cdk/toolkit"; +import { describeStack } from "./cdk/stackReader"; + +type StackDescriber = typeof describeStack; + +function findDeployedResourceId( + stack: Stack, + input: Pick, +): 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`; + return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; +} export type CdkBackendConfig = { logger: Logger; @@ -48,9 +73,9 @@ export type CdkBackendConfig = { cdk?: CdkRunner; resolveCredentials?: CdkCredentialResolver; bootstrap?: BootstrapProbe; - stack?: StackProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; + describeStack?: StackDescriber; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -62,9 +87,9 @@ export class CdkBackend implements ProjectBackend { private readonly cdk: CdkRunner; private readonly resolveCredentials: CdkCredentialResolver; private readonly bootstrap: BootstrapProbe; - private readonly stack: StackProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; + private readonly describeStack: StackDescriber; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -81,11 +106,14 @@ export class CdkBackend implements ProjectBackend { this.bootstrap = config.bootstrap ?? ((region, credentials) => probeBootstrap(region, credentials, readBootstrapStack)); - this.stack = - config.stack ?? - ((stackName, region, credentials) => probeStack(stackName, region, credentials, readStack)); this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; + this.describeStack = + config.describeStack ?? + ((region, credentials, stackName) => + describeStack(region, credentials, stackName, (name) => + readStack(name, region, credentials), + )); } public async *build(project: Project): AsyncGenerator { @@ -115,14 +143,7 @@ export class CdkBackend implements ProjectBackend { ): AsyncGenerator { const { target } = input; yield { message: `Verifying AWS account ${target.account}` }; - const credentials = await this.resolveCredentials(target.region); - const account = await this.resolveAccount(target.region, credentials); - if (account !== target.account) { - throw new ProjectStateError( - `Deployment target '${target.name}' expects AWS account ${target.account}, ` + - `but the active credentials belong to ${account}.`, - ); - } + const credentials = await this.credentialsForTarget(target); // Validate any existing deployed state before mutating AWS. A malformed file // must fail here — not after bootstrap/deploy — so we never leave AWS changed @@ -212,7 +233,7 @@ export class CdkBackend implements ProjectBackend { options: CdkRunOptions; }): AsyncGenerator { const { target } = input; - if (!(await this.stack(artifact.stackName, target.region, options.credentials))) { + if (!(await this.describeStack(target.region, options.credentials, artifact.stackName))) { throw new ProjectStateError( `Project '${project.name}' declares no resources to deploy, and no stack ` + `'${artifact.stackName}' exists in ${target.account}/${target.region} to remove. ` + @@ -241,6 +262,51 @@ export class CdkBackend implements ProjectBackend { return { outputs: {}, tornDown: true }; } + public async resolveDeployedResources( + project: Project, + input: ResolveDeployedResourcesBackendInput, + ): Promise { + const { target } = input; + const deployedState = await readDeployedState(this.json, project.rootPath); + const stackArn = deployedState.targets[target.name]?.stackArn; + if (!stackArn) { + throw new ProjectStateError( + `Project '${project.name}' is not deployed to target '${target.name}'. ` + + `Run 'agentcore project deploy --target ${target.name}' first.`, + ); + } + + const credentials = await this.credentialsForTarget(target); + const stack = await this.describeStack(target.region, credentials, stackArn); + if (!stack) { + throw new ProjectStateError( + `Project '${project.name}' is not deployed to target '${target.name}'. ` + + `Run 'agentcore project deploy --target ${target.name}' first.`, + ); + } + + const resources = [ + ...project.spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })), + ...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })), + ]; + return resources.flatMap((resource) => { + const id = findDeployedResourceId(stack, resource); + return id ? [{ ...resource, id }] : []; + }); + } + + private async credentialsForTarget(target: AwsDeploymentTarget) { + const credentials = await this.resolveCredentials(target.region); + const account = await this.resolveAccount(target.region, credentials); + if (account !== target.account) { + throw new ProjectStateError( + `Deployment target '${target.name}' expects AWS account ${target.account}, ` + + `but the active credentials belong to ${account}.`, + ); + } + return credentials; + } + private cdkDirectory(project: Project): string { return join(project.rootPath, "agentcore", "cdk"); } diff --git a/src/core/project/backends/cdk/environment.test.ts b/src/core/project/backends/cdk/environment.test.ts index 4f6bae862..f9c95748b 100644 --- a/src/core/project/backends/cdk/environment.test.ts +++ b/src/core/project/backends/cdk/environment.test.ts @@ -5,7 +5,6 @@ import { createCloudFormationStackReader, isStackNotFound, probeBootstrap, - probeStack, readBootstrapState, } from "./environment"; import type { CdkCredentialProvider } from "./toolkit"; @@ -150,75 +149,3 @@ describe("probeBootstrap", () => { expect(providers).toEqual([credentials]); }); }); - -describe("probeStack", () => { - test("reports a stack CloudFormation still holds as present", async () => { - expect( - await probeStack("AgentCore-orders-default", "us-east-1", credentials, async () => [ - stack("CREATE_COMPLETE"), - ]), - ).toBe(true); - }); - - test.each([ - // A stack part-way through a failed change is still a stack the user needs a - // way to remove, so status must not narrow this to "healthy stacks only". - "ROLLBACK_COMPLETE", - "UPDATE_ROLLBACK_FAILED", - "DELETE_FAILED", - ] as const)("counts a stack in %s as present", async (status) => { - expect( - await probeStack("AgentCore-orders-default", "us-east-1", credentials, async () => [ - stack(status), - ]), - ).toBe(true); - }); - - test("reports a stack CloudFormation does not know about as absent", async () => { - expect( - await probeStack("AgentCore-orders-default", "us-east-1", credentials, async () => { - throw Object.assign(new Error("Stack with id AgentCore-orders-default does not exist"), { - name: "ValidationError", - }); - }), - ).toBe(false); - }); - - test("treats an empty response as absent rather than crashing", async () => { - expect( - await probeStack("AgentCore-orders-default", "us-east-1", credentials, async () => undefined), - ).toBe(false); - }); - - // Reporting "no stack" on a permissions or throttling failure would turn a - // teardown the user confirmed into an unexplained "add a resource" error. - test("propagates a failure that is not a missing stack", async () => { - const failure = Object.assign(new Error("User is not authorized"), { - name: "AccessDeniedException", - }); - - await expect( - probeStack("AgentCore-orders-default", "us-east-1", credentials, async () => { - throw failure; - }), - ).rejects.toBe(failure); - }); - - test("looks the stack up by name in the target region with the deployment credentials", async () => { - const reads: { stackName: string; region: string; provider: CdkCredentialProvider }[] = []; - - await probeStack( - "AgentCore-orders-prod", - "eu-west-1", - credentials, - async (stackName, region, provider) => { - reads.push({ stackName, region, provider }); - return [stack("CREATE_COMPLETE")]; - }, - ); - - expect(reads).toEqual([ - { stackName: "AgentCore-orders-prod", region: "eu-west-1", provider: credentials }, - ]); - }); -}); diff --git a/src/core/project/backends/cdk/environment.ts b/src/core/project/backends/cdk/environment.ts index 6cc6ae79c..050c240f5 100644 --- a/src/core/project/backends/cdk/environment.ts +++ b/src/core/project/backends/cdk/environment.ts @@ -37,11 +37,6 @@ export type StackReader = ( region: string, credentials: CdkCredentialProvider, ) => Promise; -export type StackProbe = ( - stackName: string, - region: string, - credentials: CdkCredentialProvider, -) => Promise; /** Shares CloudFormation connections for calls using the same credentials and region. */ export function createCloudFormationStackReader( @@ -127,29 +122,6 @@ export async function probeBootstrap( } } -/** - * Whether CloudFormation still holds a stack of this name, so a deploy with - * nothing left to deploy can tell "tear the stack down" from "there was never - * anything here". - * - * Any stack CloudFormation returns counts as present, whatever its status: a - * stack stuck mid-rollback is still a stack the user needs a way to remove. - * Deleted stacks are not returned when looked up by name, only by id. - */ -export async function probeStack( - stackName: string, - region: string, - credentials: CdkCredentialProvider, - read: StackReader, -): Promise { - try { - return ((await read(stackName, region, credentials)) ?? []).length > 0; - } catch (error) { - if (isStackNotFound(error)) return false; - throw error; - } -} - /** Omitting `credentials` resolves through the default AWS SDK provider chain. */ export const resolveAwsAccount = async ( region: string, diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index fb1ef5b20..77c2b0751 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -1,4 +1,5 @@ import type { + DeployedProjectResource, DeployResult, Project, ProjectEvent, @@ -13,8 +14,16 @@ export type DeployBackendInput = { confirmTeardown: TeardownConfirmationHandler; }; +export type ResolveDeployedResourcesBackendInput = { + target: AwsDeploymentTarget; +}; + /** Builds the deployable artifacts owned by a project's selected backend. */ export interface ProjectBackend { build(project: Project): AsyncGenerator; deploy(project: Project, input: DeployBackendInput): AsyncGenerator; + resolveDeployedResources( + project: Project, + input: ResolveDeployedResourcesBackendInput, + ): Promise; } diff --git a/src/core/project/index.tsx b/src/core/project/index.tsx index 830a3455d..a543d8165 100644 --- a/src/core/project/index.tsx +++ b/src/core/project/index.tsx @@ -1,3 +1,7 @@ export { FsProjectManager } from "./manager"; export { CdkBackend, type CdkBackendConfig } from "./backends/cdk"; -export type { DeployBackendInput, ProjectBackend } from "./backends/types"; +export type { + DeployBackendInput, + ProjectBackend, + ResolveDeployedResourcesBackendInput, +} from "./backends/types"; diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 90f3fe88d..951bdebf0 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -366,6 +366,9 @@ describe("FsProjectManager.deploy", () => { yield { message: "Backend deployment started" }; return { outputs: { RuntimeArn: "arn:runtime" } }; }, + async resolveDeployedResources() { + return []; + }, }; return { calls, diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 82dc14ab1..ae069cf42 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -8,6 +8,10 @@ import type { DeployResult, ExportHarnessInput, ExportHarnessResult, + ResolveDeployedResourceInput, + ResolveDeployedResourcesInput, + ResolvedDeployedResource, + ResolvedDeployedResources, ResolveProjectInput, Project, ProjectManager, @@ -873,6 +877,63 @@ export class FsProjectManager implements ProjectManager { }); } + public async resolveDeployedResource( + project: Project, + input: ResolveDeployedResourceInput, + ): Promise { + const resolved = await this.resolveDeployedResources(project, { target: input.target }); + const resource = resolved.resources.find( + ({ resourceType, name }) => resourceType === input.resourceType && name === input.name, + ); + if (resource) return { id: resource.id, target: resolved.target }; + + const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; + throw new ProjectStateError( + `${label} '${input.name}' is not deployed to target '${input.target}'. ` + + `Run 'agentcore project deploy --target ${input.target}' first.`, + ); + } + + public async resolveDeployedResources( + project: Project, + input: ResolveDeployedResourcesInput, + ): Promise { + const target = await this.resolveExistingTarget(project, input.target); + const resources = await this.backendFor(project).resolveDeployedResources(project, { target }); + return { resources, target }; + } + + private async resolveExistingTarget( + project: Project, + name: string, + ): Promise { + const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); + if (!existsSync(targetsPath)) { + throw new ProjectStateError( + `No deployment targets are configured for project '${project.name}'. ` + + `Add ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, + ); + } + + const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); + if (targets.length === 0) { + throw new ProjectStateError( + `No deployment targets are configured for project '${project.name}'. ` + + `Add at least one to ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, + ); + } + + const target = targets.find((candidate) => candidate.name === name); + if (!target) { + throw new ProjectStateError( + `Project '${project.name}' has no deployment target named '${name}'. ` + + `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, + ); + } + + return target; + } + /** * Builds the default deployment target from the environment — the active * credentials' account and the CLI's effective region — and persists it to diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx index 034d33107..11a63781a 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -6,13 +6,7 @@ import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonKey } from "../../keys.tsx"; import { JsonRendererKey, renderTuiAt } from "../../../tui"; import { InputValidationError } from "../../../errors"; -import { - applyEvent, - finishTurn, - newSessionId, - newTurn, - type TranscriptItem, -} from "./transcript.tsx"; +import { invokeHarnessTurn } from "./operation.ts"; export const createInvokeHarnessHandler = (core: Core, io: AppIO) => createHandler({ @@ -54,33 +48,17 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) => } const opts = coreOptsFromCtx(ctx); - const detail = await core.harness.getHarness(flags["id"], opts); - const sessionId = flags["session-id"] ?? newSessionId(); - - const response = await core.harness.invokeHarness( + const result = await invokeHarnessTurn( + core.harness, { - harnessArn: detail.harness?.arn, + harnessId: flags["id"], + prompt: flags["prompt"], qualifier: flags["qualifier"] ?? "DEFAULT", - runtimeSessionId: sessionId, - messages: [{ role: "user", content: [{ text: flags["prompt"] }] }], + sessionId: flags["session-id"], }, opts, ); - - const turn = newTurn(); - for await (const event of response.stream ?? []) { - applyEvent(turn, event); - } - finishTurn(turn); - - const transcript: TranscriptItem[] = [{ kind: "user", text: flags["prompt"] }, ...turn.items]; - ctx.require(JsonRendererKey).renderJson({ - sessionId, - stopReason: turn.stopReason, - usage: turn.usage, - latencyMs: turn.latencyMs, - transcript, - }); + ctx.require(JsonRendererKey).renderJson(result); }, }); diff --git a/src/handlers/harness/invoke/operation.ts b/src/handlers/harness/invoke/operation.ts new file mode 100644 index 000000000..7b55bfec3 --- /dev/null +++ b/src/handlers/harness/invoke/operation.ts @@ -0,0 +1,48 @@ +import type { CoreOptions } from "../../../core/types"; +import type { CoreHarnessClient } from "../types"; +import { applyEvent, finishTurn, newSessionId, newTurn, type TranscriptItem } from "./transcript"; + +export type HarnessInvokeResult = { + sessionId: string; + stopReason?: string; + usage?: ReturnType["usage"]; + latencyMs?: number; + transcript: TranscriptItem[]; +}; + +export async function invokeHarnessTurn( + client: CoreHarnessClient, + input: { + harnessId: string; + prompt: string; + qualifier?: string; + sessionId?: string; + }, + options: CoreOptions, + signal?: AbortSignal, +): Promise { + const detail = await client.getHarness(input.harnessId, options); + const sessionId = input.sessionId ?? newSessionId(); + const response = await client.invokeHarness( + { + harnessArn: detail.harness?.arn, + qualifier: input.qualifier ?? "DEFAULT", + runtimeSessionId: sessionId, + messages: [{ role: "user", content: [{ text: input.prompt }] }], + }, + options, + signal, + ); + + const turn = newTurn(); + for await (const event of response.stream ?? []) applyEvent(turn, event); + finishTurn(turn); + + return { + sessionId, + stopReason: turn.stopReason, + usage: turn.usage, + latencyMs: turn.latencyMs, + transcript: [{ kind: "user", text: input.prompt }, ...turn.items], + }; +} diff --git a/src/handlers/harness/invoke/screen.tsx b/src/handlers/harness/invoke/screen.tsx index 9d2bc1a7f..a01fd1d62 100644 --- a/src/handlers/harness/invoke/screen.tsx +++ b/src/handlers/harness/invoke/screen.tsx @@ -78,6 +78,7 @@ export interface HarnessChatProps extends ScreenProps { // variant is the command hosting the chat: it names the breadcrumb and picks // the starting mode ("exec" starts in exec mode; "invoke" in chat mode). variant: "invoke" | "exec"; + onBack?: () => void; } // HarnessChat is the conversation view shared by `invoke` and `exec`: a @@ -92,6 +93,7 @@ export function HarnessChat({ initialSessionId, initialQualifier, variant, + onBack, }: HarnessChatProps) { const opts = coreOptsFromCtx(ctx); const { columns, rows } = useWindowSize(); @@ -278,6 +280,7 @@ export function HarnessChat({ } if (key.escape) { if (streamingRef.current) abortRef.current?.abort(); + else if (onBack) onBack(); else navigate(-1); return; } diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index 8b6bc0038..f85a18711 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -60,6 +60,9 @@ function fakeBackend( yield* events; return result; }, + async resolveDeployedResources() { + return []; + }, }; return { calls, confirmations, backend }; } diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 094cbbd18..7547fe6d8 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -17,6 +17,7 @@ import { createBuildProjectHandler } from "./build"; import type { ProjectManager } from "./types"; import { createAddProjectResourceHandler } from "./add"; import { createExportProjectResourceHandler } from "./export"; +import { createProjectInvokeHandler } from "./invoke"; type ProjectHandlerConfig = { core: Core; @@ -89,6 +90,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }), ), ); + project.handler(createProjectInvokeHandler(core, io)); project.handler(createStatusProjectHandler()); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. diff --git a/src/handlers/project/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx new file mode 100644 index 000000000..b11c33f0b --- /dev/null +++ b/src/handlers/project/invoke/harness.tsx @@ -0,0 +1,68 @@ +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 { 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); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "harness", + name, + }); + const invokeCtx = ctx.withValue(RegionKey, deployed.target.region); + + 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 result = await invokeHarnessTurn( + core.harness, + { + harnessId: deployed.id, + prompt: flags.prompt, + qualifier: flags.qualifier, + sessionId: flags["session-id"], + }, + coreOptsFromCtx(invokeCtx), + ); + invokeCtx.require(JsonRendererKey).renderJson(result); + }, + }); diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx new file mode 100644 index 000000000..d5fb86b20 --- /dev/null +++ b/src/handlers/project/invoke/index.test.tsx @@ -0,0 +1,279 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { InvokeHarnessRequest } from "@aws-sdk/client-bedrock-agentcore"; +import type { + GetAgentRuntimeResponse, + GetHarnessResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { ProjectBackend, ResolveDeployedResourcesBackendInput } from "../../../core/project"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { ProjectKey, ValueContext, type Context } from "../../../router"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { createRootHandler } from "../../index"; +import { 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 originalCwd = process.cwd(); +const temporaryDirectories: string[] = []; + +const TARGET = { + name: "default", + account: "111122223333", + region: "eu-west-1", +} as const; +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 RUNTIME = { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", +} as const; +const HARNESS = { name: "support", path: "app/support" } as const; + +function body(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +async function inProject(resources: { + runtimes?: unknown[]; + harnesses?: unknown[]; +}): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-project-invoke-reduced-")); + temporaryDirectories.push(root); + await mkdir(join(root, "agentcore"), { recursive: true }); + const spec = ProjectSpecSchema.parse({ + name: "orders", + version: 1, + runtimes: resources.runtimes ?? [], + harnesses: resources.harnesses ?? [], + }); + await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); + await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify([TARGET])); + process.chdir(root); +} + +function backend() { + const calls: ResolveDeployedResourcesBackendInput[] = []; + const value: ProjectBackend = { + async *build() {}, + async *deploy() { + yield* []; + return { outputs: {} }; + }, + async resolveDeployedResources(project, input) { + calls.push(input); + return [ + ...project.spec.runtimes.map(({ name }) => ({ + resourceType: "runtime" as const, + name, + id: RUNTIME_ID, + })), + ...project.spec.harnesses.map(({ name }) => ({ + resourceType: "harness" as const, + name, + id: HARNESS_ID, + })), + ]; + }, + }; + return { calls, value }; +} + +function configureCore(core: TestCoreClient): void { + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: body(Buffer.from("runtime response")), + }); + core.harness + .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" } }, + ); +} + +async function run(args: string[], resources: { runtimes?: unknown[]; harnesses?: unknown[] }) { + await inProject(resources); + const resolved = backend(); + const core = new TestCoreClient({ backends: { CDK: resolved.value } }); + configureCore(core); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", "project", "invoke", ...args]); + return { core, io, resolved }; +} + +function context(project: Project): Context { + return ValueContext.EmptyContext() + .withValue(ProjectKey, project) + .withValue(JsonKey, false) + .withValue(RegionKey, "us-east-1"); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("project invoke", () => { + 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(core.runtime.calls.at(-1)!.args[1]).toEqual({ region: TARGET.region }); + 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 }); + 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 with the resolved project Runtime", 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 }); + }); + + await handler.handle( + context(project!), + { + name: "checkout", + target: "default", + payload: undefined, + qualifier: undefined, + "content-type": undefined, + accept: undefined, + "session-id": "project-session", + "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, + }, + {}, + ); + + expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}`); + expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); + expect(launches[0]!.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); + }); + + 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/project/invoke"]); + }); +}); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx new file mode 100644 index 000000000..5c996cc48 --- /dev/null +++ b/src/handlers/project/invoke/index.tsx @@ -0,0 +1,28 @@ +import type { AppIO } from "../../../io"; +import { InputValidationError } from "../../../errors"; +import { Router } from "../../../router"; +import { renderTuiAt } from "../../../tui"; +import { withProject } from "../../../middleware"; +import { JsonKey } 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)) { + throw new InputValidationError( + "a Runtime or Harness invoke subcommand is required with --json", + ); + } + return renderInvokeTui("/agentcore/project/invoke", ctx, core, io); + }); +} diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx new file mode 100644 index 000000000..a7c21e53d --- /dev/null +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + AgentRuntimeEndpoint, + GetAgentRuntimeResponse, + GetHarnessResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { ProjectKey } from "../../../router"; +import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing"; +import type { DeployedProjectResource, Project } from "../types"; + +afterEach(cleanupScreens); + +const project: Project = { + name: "orders", + rootPath: "/tmp/orders", + spec: ProjectSpecSchema.parse({ + name: "orders", + version: 1, + runtimes: [ + { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", + }, + ], + harnesses: [{ name: "support", path: "app/support" }], + }), +}; + +function endpoint(name: string): AgentRuntimeEndpoint { + return { + id: name, + name, + agentRuntimeEndpointArn: `arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime-endpoint/${name}`, + agentRuntimeArn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime/runtime-123", + createdAt: new Date(0), + liveVersion: "1", + targetVersion: "1", + status: "READY", + lastUpdatedAt: new Date(0), + }; +} + +const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const; + +const DEPLOYED_RESOURCES: DeployedProjectResource[] = [ + { resourceType: "runtime", name: "checkout", id: "runtime-123" }, + { resourceType: "harness", name: "support", id: "harness-123" }, +]; + +function core(resources: DeployedProjectResource[] = DEPLOYED_RESOURCES): TestCoreClient { + const value = new TestCoreClient(); + value.projectManager.resolveDeployedResource = async (_project, input) => ({ + id: input.resourceType === "runtime" ? "runtime-123" : "harness-123", + target: TARGET, + }); + value.projectManager.resolveDeployedResources = async () => ({ resources, target: TARGET }); + value.runtime + .setListEndpointsResponse({ runtimeEndpoints: [endpoint("DEFAULT")] }) + .setGetResponse({ + agentRuntimeArn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:runtime/runtime-123", + } as GetAgentRuntimeResponse); + value.harness.setGetResponse({ + harness: { + harnessId: "harness-123", + harnessName: "support", + arn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:harness/harness-123", + }, + } as GetHarnessResponse); + return value; +} + +describe("project invoke picker", () => { + test("lists only resources present in the deployed target", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core([{ resourceType: "harness", name: "support", id: "harness-123" }]), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "support"); + expect(screen.lastFrame()).not.toContain("checkout"); + }); + + test("esc returns to the project command menu", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core(), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "manage an AgentCore project"); + expect(screen.lastFrame()).toContain("invoke"); + }); + + test("shows deployment errors without listing configured resources", async () => { + const value = core(); + value.projectManager.resolveDeployedResources = async () => { + throw new Error("No deployment targets are configured for project 'orders'."); + }; + const screen = renderScreen("/agentcore/project/invoke", { + core: value, + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "No deployment targets are configured"); + expect(screen.lastFrame()).not.toContain("checkout"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "manage an AgentCore project"); + }); + + test("resolves the enclosing project when opened from the project menu", async () => { + const value = core(); + value.projectManager.resolve = async () => project; + const screen = renderScreen("/agentcore/project/invoke", { core: value }); + + await waitForText(screen.lastFrame, "checkout"); + expect(screen.lastFrame()).toContain("support"); + }); + + test("lists project Runtime and Harness resources", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core(), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + expect(screen.lastFrame()).toContain("Runtime"); + expect(screen.lastFrame()).toContain("HTTP"); + expect(screen.lastFrame()).toContain("app/checkout"); + expect(screen.lastFrame()).toContain("support"); + expect(screen.lastFrame()).toContain("Harness"); + expect(screen.lastFrame()).toContain("app/support"); + }); + + test("opens the selected Harness chat in the same TUI", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core(), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "send a message…"); + expect(screen.lastFrame()).toContain("harness-123"); + }); + + test("uses the existing Runtime endpoint picker before its JSON console", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core(), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("return"); + await waitForText(screen.lastFrame, "DEFAULT"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Enter JSON payload"); + expect(screen.lastFrame()).not.toContain("Enter prompt"); + }); +}); diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx new file mode 100644 index 000000000..a5dcaab05 --- /dev/null +++ b/src/handlers/project/invoke/runtime.tsx @@ -0,0 +1,151 @@ +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import type { AppIO } from "../../../io"; +import { ExitCode, withUserCancellation } from "../../../runnable"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { renderTuiAt } from "../../../tui"; +import { 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 { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { selectProjectResource } from "./selection"; + +export const createProjectInvokeRuntimeHandler = ( + core: Core, + 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("target", "project deployment target", z.string().default("default")), + 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", 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 name = selectProjectResource(project, "runtime", flags.name); + const deployed = await core.projectManager.resolveDeployedResource(project, { + target: flags.target, + resourceType: "runtime", + name, + }); + const invokeCtx = ctx.withValue(RegionKey, deployed.target.region); + + if (flags.payload === undefined) { + const hasHeadlessOnlyFlag = Object.entries(flags).some( + ([flagName, value]) => + ![ + "name", + "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 (invokeCtx.require(JsonKey) && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } + await withUserCancellation(async (signal) => { + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload!, bearerToken: flags["bearer-token"] }, + io.stdin, + signal, + ); + 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: invokeCtx.require(JsonKey), + signal, + }); + }); + }, + }); diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx new file mode 100644 index 000000000..00ba1386a --- /dev/null +++ b/src/handlers/project/invoke/screen.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { useNavigate } from "react-router"; +import { Layout } from "../../../components/Layout"; +import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; +import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; +import { Spinner } from "../../../components/ui/spinner"; +import { ProjectKey, type Context } from "../../../router"; +import { HarnessChat } from "../../harness/invoke/screen"; +import { RegionKey } from "../../keys"; +import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; +import type { ScreenProps } from "../../types"; +import type { Project, ResolvedDeployedResources } from "../types"; + +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 }, + { key: "name", header: "name", flex: true }, + { key: "protocol", header: "protocol", width: 10 }, + { key: "source", header: "source", width: 24 }, +] satisfies DataTableColumn[]; + +type Destination = + | { resourceType: "runtime"; id: string; ctx: Context; qualifier?: string } + | { resourceType: "harness"; id: string; ctx: Context }; + +export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + const [project, setProject] = useState(() => ctx.value(ProjectKey)); + const [deployed, setDeployed] = useState(); + const [destination, setDestination] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + if (project) return; + let active = true; + const from = process.cwd(); + void core.projectManager + .resolve({ filePath: from }) + .then((resolved) => { + if (!active) return; + if (!resolved) { + setError( + `No AgentCore project found at ${from} or any parent directory ` + + `(looked for agentcore/agentcore.json). ` + + `Run 'agentcore project create' to scaffold one.`, + ); + return; + } + setProject(resolved); + }) + .catch((cause: unknown) => { + if (active) setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => { + active = false; + }; + }, [core.projectManager, project]); + + useEffect(() => { + if (!project) return; + let active = true; + void core.projectManager + .resolveDeployedResources(project, { target: "default" }) + .then((resolved) => { + if (active) setDeployed(resolved); + }) + .catch((cause: unknown) => { + if (active) setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => { + active = false; + }; + }, [core.projectManager, project]); + + 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); + return { + ...resource, + type: "Harness" as const, + protocol: "-", + source: configured?.path ?? "-", + }; + }), + [deployed, project], + ); + + const select = (row: ProjectInvokableRow) => { + if (!deployed) return; + setDestination({ + resourceType: row.resourceType, + id: row.id, + ctx: ctx.withValue(RegionKey, deployed.target.region), + }); + }; + + const goBack = () => navigate("/agentcore/project"); + useInput((_input, key) => { + if (key.escape && (!project || !deployed || error !== undefined)) goBack(); + }); + + if (destination?.resourceType === "runtime") { + if (!destination.qualifier) { + return ( + setDestination({ ...destination, qualifier })} + onEscape={() => setDestination(undefined)} + /> + ); + } + return ( + setDestination(undefined)} + /> + ); + } + + if (destination?.resourceType === "harness") { + return ( + setDestination(undefined)} + /> + ); + } + + if (!project || (!deployed && !error)) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + + + + ); +} diff --git a/src/handlers/project/invoke/selection.ts b/src/handlers/project/invoke/selection.ts new file mode 100644 index 000000000..b0ec3275e --- /dev/null +++ b/src/handlers/project/invoke/selection.ts @@ -0,0 +1,34 @@ +import { InputValidationError, ResourceNotFoundError } from "../../../errors"; +import type { Project, ProjectInvokableResource } from "../types"; + +export function projectResourceNames( + project: Project, + resourceType: ProjectInvokableResource, +): string[] { + return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( + ({ name }) => name, + ); +} + +export function selectProjectResource( + project: Project, + resourceType: ProjectInvokableResource, + name: string | undefined, +): string { + const names = projectResourceNames(project, resourceType); + const label = resourceType === "runtime" ? "Runtime" : "Harness"; + + if (name !== undefined) { + if (names.includes(name)) return name; + throw new ResourceNotFoundError( + `${label} '${name}' was not found. Available ${label}s: ${names.join(", ") || "none"}.`, + ); + } + if (names.length === 1) return names[0]!; + if (names.length === 0) { + throw new InputValidationError(`This project has no ${label}s to invoke.`); + } + throw new InputValidationError( + `Project has multiple ${label}s. Specify --name: ${names.join(", ")}.`, + ); +} diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index a73d0b013..846c6d3ea 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -76,9 +76,9 @@ describe("project subcommands without a screen", () => { // Reading the cases off the router also guards Root's hand-written // PROJECT_COMMANDS: an unrouted subcommand hits the catch-all, which resolves // instead of rejecting. Frames can't detect that — the catch-all exits before - // painting, so it and this screen both render empty. `create` is excluded: - // it has a real screen now (the create wizard, see create/screen.tsx). - test.each(projectSubcommands().filter((command) => command !== "create"))( + // painting, so it and this screen both render empty. `create` and `invoke` + // are excluded because both have real screens. + test.each(projectSubcommands().filter((command) => command !== "create" && command !== "invoke"))( "%s tears down the TUI with NotImplementedError", async (command) => { const { streams } = ttyTestIO(); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 0963dbb35..3a691e734 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -14,6 +14,7 @@ import { AgentNameSchema, BuildTypeSchema, EntrypointSchema } from "../../projec import { RuntimeVersionSchema } from "../../projectSchemas/constants"; import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; import type { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; +import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets"; type CreateProjectInputBase = { /** The name of the project; also the directory it is scaffolded into. */ @@ -134,6 +135,32 @@ export type ResolveProjectInput = { filePath: string; }; +export type ResolveDeployedResourceInput = { + target: string; + resourceType: ProjectInvokableResource; + name: string; +}; + +export type ResolveDeployedResourcesInput = { + target: string; +}; + +export type DeployedProjectResource = { + resourceType: ProjectInvokableResource; + name: string; + id: string; +}; + +export type ResolvedDeployedResource = { + id: string; + target: AwsDeploymentTarget; +}; + +export type ResolvedDeployedResources = { + resources: DeployedProjectResource[]; + target: AwsDeploymentTarget; +}; + export type Project = { name: string; /** Absolute path to the project root (the parent of agentcore/). */ @@ -243,6 +270,8 @@ export type ExportHarnessResult = { notes: ExportNote[]; }; +export type ProjectInvokableResource = Extract; + export type RemoveResourceInput = | { resourceType: @@ -297,6 +326,18 @@ export interface ProjectManager { /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; + /** Resolve a logical project resource to its deployed physical ID and target. */ + resolveDeployedResource( + project: Project, + input: ResolveDeployedResourceInput, + ): Promise; + + /** Resolve every configured Runtime and Harness present in the deployed target stack. */ + resolveDeployedResources( + project: Project, + input: ResolveDeployedResourcesInput, + ): Promise; + /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator; diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index d19ad0103..147b476ea 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -8,7 +8,6 @@ import { JsonKey } from "../../keys"; import { ExitCode, withUserCancellation } from "../../../runnable"; import { renderTuiAt } from "../../../tui"; import { - normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, resolveRuntimeInvokeTuiBearerToken, @@ -16,6 +15,7 @@ import { } from "./request"; import { writeRuntimeInvokeResponse } from "./response"; import { RuntimeInvokeLaunchContextKey } from "./launchContext"; +import { invokeRuntimeTarget } from "./operation"; export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => createHandler({ @@ -114,27 +114,30 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => signal, ); const options = coreOptsFromCtx(ctx); - const runtime = await core.runtime.getRuntime(runtimeId, options, signal); - const request = normalizeRuntimeInvokeRequest(runtime, { - runtimeId, - 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, - }); - const response = await core.runtime.invokeRuntime(request, options, signal); + const response = await invokeRuntimeTarget( + core.runtime, + { + runtimeId, + 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, + }, + options, + signal, + ); await writeRuntimeInvokeResponse(response, { stdout: io.stdout, stderr: io.stderr, diff --git a/src/handlers/runtime/invoke/operation.ts b/src/handlers/runtime/invoke/operation.ts new file mode 100644 index 000000000..beab59e63 --- /dev/null +++ b/src/handlers/runtime/invoke/operation.ts @@ -0,0 +1,14 @@ +import type { CoreOptions } from "../../../core/types"; +import type { CoreRuntimeClient, RuntimeInvokeResponse } from "../types"; +import { normalizeRuntimeInvokeRequest, type RuntimeInvokeInput } from "./request"; + +export async function invokeRuntimeTarget( + client: CoreRuntimeClient, + input: RuntimeInvokeInput, + options: CoreOptions, + signal?: AbortSignal, +): Promise { + const runtime = await client.getRuntime(input.runtimeId, options, signal); + const request = normalizeRuntimeInvokeRequest(runtime, input); + return client.invokeRuntime(request, options, signal); +} diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 4da78c5ac..1633321a7 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -9,7 +9,10 @@ export const runtimeIdSchema = z .string() .refine((value) => !value.startsWith("arn:"), "must be a Runtime ID, not an ARN"); -type RuntimeInvokeInput = Omit & +export type RuntimeInvokeInput = Omit< + RuntimeInvokeRequest, + "accountId" | "qualifier" | "contentType" +> & Partial>; const CUSTOM_HEADER_PREFIX = "x-amzn-bedrock-agentcore-runtime-custom-"; diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index b73cc7862..6c7d123f9 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -140,20 +140,22 @@ export function RuntimeInvokeScreen(props: ScreenProps) { ); } -type RuntimeInvokeConsoleProps = ScreenProps & { +export type RuntimeInvokeConsoleProps = ScreenProps & { runtimeId: string; qualifier: string; initialContext?: RuntimeInvokeLaunchContext; returnOnEscape?: boolean; + onBack?: () => void; }; -function RuntimeInvokeConsole({ +export function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier, initialContext, returnOnEscape, + onBack, }: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); @@ -307,6 +309,7 @@ function RuntimeInvokeConsole({ } if (key.escape) { if (abortRef.current) abortRef.current.abort(); + else if (onBack) onBack(); else if (returnOnEscape) navigate(-1); else setTargetPicker({ stage: "endpoint", runtimeId: target.runtimeId }); return;