diff --git a/README.md b/README.md index 84f21fcb5..3062eb29d 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,17 @@ agentcore harness invoke --id --prompt "hello" # Scaffold runtime code instead (pass a template or framework flags). agentcore project create --name MyAgent --template strands-python -# Wrap an existing Amazon Bedrock Agent as a runtime: a generated proxy -# forwards prompts to the agent, so it deploys and invokes like any other -# runtime. --region names the Bedrock Agent's region. Also available as -# `project add runtime --type import` inside a project. -agentcore project create --name MyProxy --type import \ - --agent-id A1B2C3D4E5 --agent-alias-id TSTALIASID --region us-east-1 +# Translate an existing Amazon Bedrock Agent version into editable runtime code. +# The selected alias identifies the immutable source version; generated code +# invokes models and translated tools directly rather than proxying the alias. +# Use --framework strands (default) or langgraph and optionally select target +# AgentCore Memory. Also available as `project add runtime --type import`. +# The alias must point at a prepared version, not the mutable DRAFT that the +# built-in test alias (TSTALIASID) routes to. Anything that could not be +# translated automatically is listed in the generated IMPORT_NOTES.md. +agentcore project create --name MyImportedAgent --type import \ + --agent-id A1B2C3D4E5 --agent-alias-id XYZ123ABC4 --region us-east-1 \ + --framework strands --memory none ``` ```bash diff --git a/src/assets/templates/bedrock-agent-proxy-python/README.md b/src/assets/templates/bedrock-agent-proxy-python/README.md deleted file mode 100644 index 3afa6e14e..000000000 --- a/src/assets/templates/bedrock-agent-proxy-python/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# {{name}} - -An AgentCore Runtime that proxies the existing Amazon Bedrock Agent -**{{agentName}}** (`{{agentId}}`, alias `{{agentAliasId}}`, region -`{{agentRegion}}`). Invocations of this runtime forward the payload's `prompt` -to the Bedrock Agent and stream its reply back, so the agent can be deployed -and invoked through AgentCore without changing it. - -- `main.py` — the proxy entrypoint. The agent id, alias id, and region are - baked in at import time and can be overridden with the `BEDROCK_AGENT_ID`, - `BEDROCK_AGENT_ALIAS_ID`, and `BEDROCK_AGENT_REGION` environment variables. -- `bedrock-agent-policy.json` — grants the runtime's execution role - `bedrock:InvokeAgent` on the imported agent's alias. It is wired in through - the runtime's `additionalPolicies` entry in `agentcore/agentcore.json`. - -Invoke it with a JSON payload like `{"prompt": "hello"}`. diff --git a/src/assets/templates/bedrock-agent-proxy-python/bedrock-agent-policy.json b/src/assets/templates/bedrock-agent-proxy-python/bedrock-agent-policy.json deleted file mode 100644 index 2834af958..000000000 --- a/src/assets/templates/bedrock-agent-proxy-python/bedrock-agent-policy.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "InvokeImportedBedrockAgent", - "Effect": "Allow", - "Action": "bedrock:InvokeAgent", - "Resource": "{{agentAliasArn}}" - } - ] -} diff --git a/src/assets/templates/bedrock-agent-proxy-python/main.py b/src/assets/templates/bedrock-agent-proxy-python/main.py deleted file mode 100644 index e61b4958f..000000000 --- a/src/assets/templates/bedrock-agent-proxy-python/main.py +++ /dev/null @@ -1,45 +0,0 @@ -# Proxy runtime for the imported Amazon Bedrock Agent "{{agentName}}". -# Generated by `agentcore project create/add runtime --type import`. Requests to -# this runtime are forwarded to the Bedrock Agent, and its reply is streamed -# back — edit or replace this file to take ownership of the behavior. - -import os -import uuid - -import boto3 -from bedrock_agentcore.runtime import BedrockAgentCoreApp - -AGENT_ID = os.environ.get("BEDROCK_AGENT_ID", "{{agentId}}") -AGENT_ALIAS_ID = os.environ.get("BEDROCK_AGENT_ALIAS_ID", "{{agentAliasId}}") -AGENT_REGION = os.environ.get("BEDROCK_AGENT_REGION", "{{agentRegion}}") - -app = BedrockAgentCoreApp() -client = boto3.client("bedrock-agent-runtime", region_name=AGENT_REGION) - - -@app.entrypoint -async def invoke(payload, context): - """Forward the prompt to the Bedrock Agent and stream its completion.""" - prompt = payload.get("prompt", "") - if not isinstance(prompt, str) or not prompt: - yield "No query provided; include a 'prompt' field in the payload." - return - - # Bedrock Agent sessions require ids of 2+ chars; reuse the runtime session - # so multi-turn conversations keep the agent's own memory of the exchange. - session_id = context.session_id or payload.get("sessionId") or uuid.uuid4().hex - - response = client.invoke_agent( - agentId=AGENT_ID, - agentAliasId=AGENT_ALIAS_ID, - sessionId=session_id, - inputText=prompt, - ) - for event in response["completion"]: - chunk = event.get("chunk") - if chunk and "bytes" in chunk: - yield chunk["bytes"].decode("utf-8") - - -if __name__ == "__main__": - app.run() diff --git a/src/assets/templates/bedrock-agent-proxy-python/pyproject.toml b/src/assets/templates/bedrock-agent-proxy-python/pyproject.toml deleted file mode 100644 index 45ba4f61a..000000000 --- a/src/assets/templates/bedrock-agent-proxy-python/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "{{name}}" -version = "0.1.0" -description = "AgentCore Runtime proxy for the Amazon Bedrock Agent {{agentName}}" -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "aws-opentelemetry-distro ~= 0.18.0", - "bedrock-agentcore ~= 1.9.1", - "boto3 ~= 1.43.0", - "botocore[crt] ~= 1.43.0", -] - -[tool.hatch.build.targets.wheel] -packages = ["."] diff --git a/src/core/index.tsx b/src/core/index.tsx index 7207457fb..b395361f3 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -24,7 +24,7 @@ import type { import type { Logger } from "../logging"; import type { ProjectManager } from "../handlers/project/types"; import { FsProjectManager } from "./project"; -import { describeBedrockAgent, type DescribeBedrockAgent } from "./project/bedrockAgent"; +import { BedrockAgentImporter, type CoreBedrockAgentImporter } from "./project/bedrockAgentImport"; export type { AwsClients, @@ -47,7 +47,7 @@ type CoreClientConfig = { fetch?: CoreFetch; newSessionId?: () => string; now?: () => number; - describeBedrockAgent?: DescribeBedrockAgent; + bedrockAgentImporter?: CoreBedrockAgentImporter; }; // CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the @@ -76,7 +76,7 @@ export class CoreClient implements AwsClients { readonly observability: ObservabilityClient; readonly projectManager: ProjectManager; - readonly describeBedrockAgent: DescribeBedrockAgent; + readonly bedrockAgentImporter: CoreBedrockAgentImporter; readonly fetch: CoreFetch; constructor(config: CoreClientConfig) { @@ -114,7 +114,7 @@ export class CoreClient implements AwsClients { createCloudFormationClient: config.createCloudFormationClient, identity: this.identity, }); - this.describeBedrockAgent = config.describeBedrockAgent ?? describeBedrockAgent; + this.bedrockAgentImporter = config.bedrockAgentImporter ?? new BedrockAgentImporter(); } // control returns the control-plane client for `config`, creating and caching it diff --git a/src/core/project/bedrockAgent.ts b/src/core/project/bedrockAgent.ts deleted file mode 100644 index b4a003b52..000000000 --- a/src/core/project/bedrockAgent.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { InputValidationError, MalformedServiceResponseError } from "../../errors"; - -/** - * Regions where an Amazon Bedrock Agent can live for `--type import`, - * mirroring the original CLI's supported-region list. - */ -export const BEDROCK_AGENT_IMPORT_REGIONS = [ - "us-east-1", - "us-west-2", - "eu-west-1", - "eu-central-1", - "ap-southeast-1", - "ap-northeast-1", - "ap-south-1", - "ca-central-1", - "sa-east-1", - "us-gov-west-1", -] as const; - -export type BedrockAgentImportRegion = (typeof BEDROCK_AGENT_IMPORT_REGIONS)[number]; - -export type DescribeBedrockAgentInput = { - region: string; - agentId: string; - agentAliasId: string; -}; - -/** What the proxy scaffold needs to know about the imported agent. */ -export type BedrockAgentMetadata = { - agentName: string; - agentStatus: string; - agentAliasArn: string; - agentAliasName: string; - agentAliasStatus: string; - foundationModel?: string; - description?: string; -}; - -export type DescribeBedrockAgent = ( - input: DescribeBedrockAgentInput, -) => Promise; - -function isNamedError(error: unknown, name: string): boolean { - return error instanceof Error && error.name === name; -} - -/** - * Describes the agent and its alias through the Bedrock Agent control plane, - * both to fail fast on a nonexistent agent/alias and to capture the metadata - * the scaffolded proxy embeds. - */ -export const describeBedrockAgent: DescribeBedrockAgent = async (input) => { - const { BedrockAgentClient, GetAgentCommand, GetAgentAliasCommand } = - await import("@aws-sdk/client-bedrock-agent"); - const client = new BedrockAgentClient({ region: input.region }); - - let agent; - try { - ({ agent } = await client.send(new GetAgentCommand({ agentId: input.agentId }))); - } catch (error) { - if (isNamedError(error, "ResourceNotFoundException")) { - throw new InputValidationError( - `no Bedrock Agent with id '${input.agentId}' exists in ${input.region}; ` + - `check --agent-id and --region`, - { cause: error }, - ); - } - throw error; - } - - let agentAlias; - try { - ({ agentAlias } = await client.send( - new GetAgentAliasCommand({ agentId: input.agentId, agentAliasId: input.agentAliasId }), - )); - } catch (error) { - if (isNamedError(error, "ResourceNotFoundException")) { - throw new InputValidationError( - `Bedrock Agent '${input.agentId}' has no alias with id '${input.agentAliasId}' in ` + - `${input.region}; check --agent-alias-id`, - { cause: error }, - ); - } - throw error; - } - - if (!agent?.agentName || !agentAlias?.agentAliasArn || !agentAlias.agentAliasName) { - throw new MalformedServiceResponseError( - `the Bedrock Agent service returned an incomplete description for agent ` + - `'${input.agentId}' / alias '${input.agentAliasId}'`, - ); - } - - return { - agentName: agent.agentName, - agentStatus: agent.agentStatus ?? "UNKNOWN", - agentAliasArn: agentAlias.agentAliasArn, - agentAliasName: agentAlias.agentAliasName, - agentAliasStatus: agentAlias.agentAliasStatus ?? "UNKNOWN", - foundationModel: agent.foundationModel, - description: agent.description, - }; -}; diff --git a/src/core/project/bedrockAgentImport/baseTranslator.ts b/src/core/project/bedrockAgentImport/baseTranslator.ts new file mode 100644 index 000000000..ee0fccec6 --- /dev/null +++ b/src/core/project/bedrockAgentImport/baseTranslator.ts @@ -0,0 +1,260 @@ +import { IMPORT_NOTES_FILE, renderImportNotes } from "./notes"; +import { generateImportPyproject } from "./pyproject"; +import type { + BedrockAgentImportNote, + BedrockAgentImportPlan, + BedrockAgentImportRequest, + BedrockAgentSnapshot, + ImportedFunctionParameter, +} from "./types"; + +const PYTHON_KEYWORDS = new Set([ + "False", + "None", + "True", + "and", + "as", + "assert", + "async", + "await", + "break", + "class", + "continue", + "def", + "del", + "elif", + "else", + "except", + "finally", + "for", + "from", + "global", + "if", + "import", + "in", + "is", + "lambda", + "nonlocal", + "not", + "or", + "pass", + "raise", + "return", + "try", + "while", + "with", + "yield", +]); + +export abstract class BaseBedrockAgentTranslator { + protected readonly notes: BedrockAgentImportNote[]; + + constructor( + protected readonly snapshot: BedrockAgentSnapshot, + protected readonly request: BedrockAgentImportRequest, + ) { + this.notes = snapshot.notes.map((note) => ({ ...note })); + } + + abstract translate(): BedrockAgentImportPlan; + + protected buildPlan( + mainPy: string, + collaboratorFiles: Record, + frameworkNotes: BedrockAgentImportNote[] = [], + ): BedrockAgentImportPlan { + this.notes.push(...frameworkNotes); + // Every agent in the tree contributes follow-up notes; a collaborator's unimplemented action + // groups and knowledge-base permissions are the customer's problem too. + for (const snapshot of snapshotTree(this.snapshot)) this.addCommonNotes(snapshot); + + const files: Record = { + "main.py": mainPy, + ...collaboratorFiles, + "pyproject.toml": generateImportPyproject({ + runtimeName: this.request.runtimeName, + framework: this.request.framework, + hasMemory: this.request.memory !== "none", + hasCodeInterpreter: this.hasCodeInterpreter(), + }), + }; + + files[IMPORT_NOTES_FILE] = renderImportNotes(this.snapshot, this.notes); + return { + framework: this.request.framework, + sourceAgentId: this.snapshot.sourceAgentId, + // From the request, which always carries it, rather than a non-null assertion on the snapshot. + sourceAgentAliasId: this.request.agentAliasId, + sourceAgentVersion: this.snapshot.sourceAgentVersion, + description: this.snapshot.description, + files, + notes: this.notes, + }; + } + + protected generateSystemPrompt(): string { + return `SYSTEM_PROMPT = """${escapePythonTripleQuoted(this.snapshot.instruction)}"""`; + } + + protected generateFunctionTools(): string { + const functions = this.snapshot.actionGroups.flatMap((group) => + group.functions.map((fn) => ({ groupName: group.name, fn })), + ); + if (functions.length === 0) return ""; + + const sections = ["# Action group tools"]; + for (const { groupName, fn } of functions) { + const parameters = Object.entries(fn.parameters) + .sort(([, left], [, right]) => Number(right.required) - Number(left.required)) + .map(([name, parameter]) => pythonParameter(name, parameter)) + .join(", "); + sections.push(`@tool +def ${pythonIdentifier(fn.name)}(${parameters}) -> str: + """${escapePythonTripleQuoted(fn.description ?? `Function from ${groupName}`)}""" + # TODO: Implement the original Bedrock Agent action group behavior. + return json.dumps({"status": "not_implemented", "function": "${escapePythonString(fn.name)}"})`); + } + return sections.join("\n\n"); + } + + // Tree-wide: a collaborator-only code interpreter still needs the dependency in pyproject.toml. + protected hasCodeInterpreter(): boolean { + return snapshotTree(this.snapshot).some((snapshot) => + snapshot.actionGroups.some( + (group) => group.parentActionSignature === "AMAZON.CodeInterpreter", + ), + ); + } + + protected functionToolNames(): string[] { + return this.snapshot.actionGroups.flatMap((group) => + group.functions.map((fn) => pythonIdentifier(fn.name)), + ); + } + + protected addCommonNotes(snapshot: BedrockAgentSnapshot): void { + const agent = snapshot === this.snapshot ? "" : ` (collaborator '${snapshot.agentName}')`; + for (const actionGroup of snapshot.actionGroups) { + if (actionGroup.functions.length > 0) { + this.notes.push({ + category: `action-group implementation${agent}`, + message: + `Generated typed stubs for action group '${actionGroup.name}'. ` + + "Implement their business logic before relying on them in production.", + }); + } + const unsupportedFeatures = [ + actionGroup.hasApiSchema ? "OpenAPI schema" : undefined, + actionGroup.hasLambdaExecutor ? "Lambda executor" : undefined, + actionGroup.returnsControl ? "return-control behavior" : undefined, + ].filter(Boolean); + if (unsupportedFeatures.length > 0) { + this.notes.push({ + category: `action-group integration${agent}`, + message: + `Action group '${actionGroup.name}' used ${unsupportedFeatures.join(", ")}. ` + + "Implement the equivalent integration manually.", + }); + } + if (actionGroup.parentActionSignature === "AMAZON.UserInput") { + this.notes.push({ + category: `user-input action${agent}`, + message: + `Action group '${actionGroup.name}' used AMAZON.UserInput, which has no direct ` + + "standalone-agent equivalent.", + }); + } + if ( + actionGroup.parentActionSignature && + actionGroup.parentActionSignature !== "AMAZON.UserInput" && + actionGroup.parentActionSignature !== "AMAZON.CodeInterpreter" + ) { + this.notes.push({ + category: `built-in action${agent}`, + message: + `Action group '${actionGroup.name}' used '${actionGroup.parentActionSignature}' and ` + + "was not translated automatically.", + }); + } + } + + if (snapshot.hasPromptOverrides) { + this.notes.push({ + category: `prompt overrides${agent}`, + message: + "The generated agent uses the source instruction as its system prompt and the " + + "ORCHESTRATION inference settings. Migrate custom prompt templates, parser Lambdas, " + + "additional model fields, and pre/post-processing prompt steps manually.", + }); + } + + if (snapshot.knowledgeBases.length > 0) { + const resources = snapshot.knowledgeBases.map( + (knowledgeBase) => + knowledgeBase.arn ?? + `arn:*:bedrock:${snapshot.region}:*:knowledge-base/${knowledgeBase.id}`, + ); + this.notes.push({ + category: `knowledge-base IAM${agent}`, + message: + "Grant the Runtime execution role bedrock:Retrieve on: " + resources.join(", ") + ".", + }); + } + + if (snapshot.sourceMemoryEnabled && this.request.memory === "none") { + this.notes.push({ + category: `memory disabled${agent}`, + message: + "The source Bedrock Agent used memory, but the import selected --memory none. " + + "The generated Runtime is stateless across invocations.", + }); + } + } +} + +/** + * A knowledge base can live in a different region than the agent, so prefer the region encoded in + * its ARN and fall back to the agent's region only when the ARN was unavailable. + */ +export function knowledgeBaseRegion(knowledgeBase: { arn?: string }, agentRegion: string): string { + return knowledgeBase.arn?.split(":")[3] || agentRegion; +} + +/** The root snapshot followed by every collaborator reachable from it, depth-first. */ +export function snapshotTree(root: BedrockAgentSnapshot): BedrockAgentSnapshot[] { + return [root, ...root.collaborators.flatMap((collaborator) => snapshotTree(collaborator.agent))]; +} + +export function pythonIdentifier(value: string): string { + const sanitized = value.replace(/[^a-zA-Z0-9_]/g, "_"); + const identifier = /^\d/.test(sanitized) ? `_${sanitized}` : sanitized || "unnamed"; + return PYTHON_KEYWORDS.has(identifier) ? `${identifier}_` : identifier; +} + +export function escapePythonString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "\\n"); +} + +export function escapePythonTripleQuoted(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"""/g, '\\"\\"\\"'); +} + +function pythonParameter(name: string, parameter: ImportedFunctionParameter): string { + const type = pythonType(parameter.type); + return `${pythonIdentifier(name)}: ${parameter.required ? type : `${type} | None = None`}`; +} + +function pythonType(value: string): string { + switch (value) { + case "integer": + return "int"; + case "number": + return "float"; + case "boolean": + return "bool"; + case "array": + return "list"; + default: + return "str"; + } +} diff --git a/src/core/project/bedrockAgentImport/index.test.ts b/src/core/project/bedrockAgentImport/index.test.ts new file mode 100644 index 000000000..3dbab36c6 --- /dev/null +++ b/src/core/project/bedrockAgentImport/index.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { + BedrockAgentClient, + GetAgentAliasCommand, + GetAgentVersionCommand, + ListAgentActionGroupsCommand, + ListAgentCollaboratorsCommand, + ListAgentKnowledgeBasesCommand, +} from "@aws-sdk/client-bedrock-agent"; +import { InputValidationError } from "../../../errors"; +import { BedrockAgentImporter } from "."; +import type { BedrockAgentImportRequest } from "./types"; + +class FakeClient { + async send(command: unknown): Promise { + if (command instanceof GetAgentAliasCommand) { + return { + agentAlias: { + agentId: "A1B2C3D4E5", + agentAliasId: "TSTALIASID", + agentAliasName: "live", + routingConfiguration: [{ agentVersion: "7" }], + }, + }; + } + if (command instanceof GetAgentVersionCommand) { + return { + agentVersion: { + agentId: "A1B2C3D4E5", + agentName: "SupportAgent", + agentArn: "arn:aws:bedrock:us-east-1:111122223333:agent/A1B2C3D4E5", + version: "7", + agentStatus: "PREPARED", + foundationModel: "us.amazon.nova-lite-v1:0", + instruction: "Be helpful.", + idleSessionTTLInSeconds: 600, + agentResourceRoleArn: "arn:aws:iam::111122223333:role/BedrockAgentRole", + createdAt: new Date(0), + updatedAt: new Date(0), + }, + }; + } + if (command instanceof ListAgentActionGroupsCommand) { + return { actionGroupSummaries: [] }; + } + if (command instanceof ListAgentKnowledgeBasesCommand) { + return { agentKnowledgeBaseSummaries: [] }; + } + if (command instanceof ListAgentCollaboratorsCommand) { + return { agentCollaboratorSummaries: [] }; + } + throw new Error("unexpected command"); + } +} + +describe("BedrockAgentImporter", () => { + const request: BedrockAgentImportRequest = { + runtimeName: "support", + region: "us-east-1", + agentId: "A1B2C3D4E5", + agentAliasId: "TSTALIASID", + framework: "strands", + memory: "none", + }; + + test("translates the alias-selected version with the Strands translator", async () => { + const importer = new BedrockAgentImporter({ + createClient: () => new FakeClient() as unknown as BedrockAgentClient, + }); + + const plan = await importer.import(request); + + expect(plan).toMatchObject({ + framework: "strands", + sourceAgentId: "A1B2C3D4E5", + sourceAgentAliasId: "TSTALIASID", + sourceAgentVersion: "7", + }); + expect(plan.files["main.py"]).toContain("from strands import Agent"); + expect(plan.files["main.py"]).toContain('SYSTEM_PROMPT = """Be helpful."""'); + expect(plan.files["pyproject.toml"]).toContain("strands-agents"); + expect(plan.files["IMPORT_NOTES.md"]).toContain("Source version: `7`"); + }); + + test("translates with the LangGraph translator when requested", async () => { + const importer = new BedrockAgentImporter({ + createClient: () => new FakeClient() as unknown as BedrockAgentClient, + }); + + const plan = await importer.import({ ...request, framework: "langgraph" }); + + expect(plan.framework).toBe("langgraph"); + expect(plan.files["main.py"]).toContain("from langgraph.prebuilt import create_react_agent"); + expect(plan.files["main.py"]).not.toContain("from strands import"); + }); + + test("rejects unsupported regions before creating a service client", async () => { + let clientCreated = false; + const importer = new BedrockAgentImporter({ + createClient: () => { + clientCreated = true; + return new FakeClient() as unknown as BedrockAgentClient; + }, + }); + + await expect( + importer.import({ + runtimeName: "support", + region: "eu-north-1", + agentId: "A1B2C3D4E5", + agentAliasId: "TSTALIASID", + framework: "strands", + memory: "none", + }), + ).rejects.toBeInstanceOf(InputValidationError); + expect(clientCreated).toBe(false); + }); +}); diff --git a/src/core/project/bedrockAgentImport/index.ts b/src/core/project/bedrockAgentImport/index.ts new file mode 100644 index 000000000..3272c208c --- /dev/null +++ b/src/core/project/bedrockAgentImport/index.ts @@ -0,0 +1,44 @@ +import { InputValidationError } from "../../../errors"; +import { LangGraphBedrockAgentTranslator } from "./langGraphTranslator"; +import { BedrockAgentSnapshotLoader, type CreateBedrockAgentClient } from "./loader"; +import { StrandsBedrockAgentTranslator } from "./strandsTranslator"; +import { + BEDROCK_AGENT_IMPORT_REGIONS, + type BedrockAgentImportPlan, + type BedrockAgentImportRequest, + type CoreBedrockAgentImporter, +} from "./types"; + +type BedrockAgentImporterConfig = { + createClient?: CreateBedrockAgentClient; +}; + +export class BedrockAgentImporter implements CoreBedrockAgentImporter { + private readonly loader: BedrockAgentSnapshotLoader; + + constructor(config: BedrockAgentImporterConfig = {}) { + this.loader = new BedrockAgentSnapshotLoader(config.createClient); + } + + async import(request: BedrockAgentImportRequest): Promise { + const region = BEDROCK_AGENT_IMPORT_REGIONS.find((candidate) => candidate === request.region); + if (!region) { + throw new InputValidationError( + `'${request.region}' is not a supported Bedrock Agent region for import. ` + + `Supported regions: ${BEDROCK_AGENT_IMPORT_REGIONS.join(", ")}. ` + + "Pass --region to select the source agent's region.", + ); + } + + const snapshot = await this.loader.load({ + region, + agentId: request.agentId, + agentAliasId: request.agentAliasId, + }); + return request.framework === "strands" + ? new StrandsBedrockAgentTranslator(snapshot, request).translate() + : new LangGraphBedrockAgentTranslator(snapshot, request).translate(); + } +} + +export * from "./types"; diff --git a/src/core/project/bedrockAgentImport/langGraphTranslator.ts b/src/core/project/bedrockAgentImport/langGraphTranslator.ts new file mode 100644 index 000000000..05e8353d0 --- /dev/null +++ b/src/core/project/bedrockAgentImport/langGraphTranslator.ts @@ -0,0 +1,296 @@ +import { + BaseBedrockAgentTranslator, + escapePythonString, + escapePythonTripleQuoted, + knowledgeBaseRegion, + pythonIdentifier, +} from "./baseTranslator"; +import type { + BedrockAgentImportNote, + BedrockAgentImportPlan, + BedrockAgentImportRequest, + BedrockAgentSnapshot, +} from "./types"; + +export class LangGraphBedrockAgentTranslator extends BaseBedrockAgentTranslator { + translate(): BedrockAgentImportPlan { + const rendered = this.renderModule(this.snapshot, true); + return this.buildPlan(rendered.code, rendered.files, rendered.notes); + } + + private renderModule( + snapshot: BedrockAgentSnapshot, + isRoot: boolean, + ): { + code: string; + files: Record; + notes: BedrockAgentImportNote[]; + } { + const translator = + snapshot === this.snapshot + ? this + : new LangGraphBedrockAgentTranslator(snapshot, this.requestFor(snapshot)); + const files: Record = {}; + const notes: BedrockAgentImportNote[] = []; + const collaboratorImports: string[] = []; + const collaboratorTools: string[] = []; + const collaboratorToolNames: string[] = []; + + for (const collaborator of snapshot.collaborators) { + const name = pythonIdentifier(collaborator.name); + const moduleName = `langgraph_collaborator_${name}`; + const child = translator.renderModule(collaborator.agent, false); + files[`${moduleName}.py`] = child.code; + Object.assign(files, child.files); + notes.push(...child.notes); + collaboratorImports.push(`from ${moduleName} import invoke_agent as invoke_${name}_agent`); + collaboratorTools.push(`@tool +def invoke_${name}(query: str) -> str: + """${escapePythonTripleQuoted(collaborator.instruction)}""" + return invoke_${name}_agent(query, COLLABORATOR_SESSION, COLLABORATOR_SESSION)`); + collaboratorToolNames.push(`invoke_${name}`); + notes.push({ + category: "collaborator session scope", + message: + `Collaborator '${collaborator.name}' runs under a shared session rather than the ` + + "caller's session, so its own history is not isolated per end user." + + (collaborator.relayConversationHistory === "TO_COLLABORATOR" + ? " The source agent also requested relayed conversation history, which the generated " + + "tool does not copy; it delegates only the current query." + : ""), + }); + } + + const functionTools = translator.generateFunctionTools(); + const knowledgeBaseTools = translator.generateKnowledgeBaseTools(snapshot); + const toolNames = [ + ...translator.functionToolNames(), + ...snapshot.knowledgeBases.map( + (knowledgeBase) => `retrieve_${pythonIdentifier(knowledgeBase.name)}`, + ), + ...collaboratorToolNames, + ]; + if ( + snapshot.actionGroups.some( + (group) => group.parentActionSignature === "AMAZON.CodeInterpreter", + ) + ) { + notes.push({ + category: "LangGraph code interpreter", + message: + "The source agent used AMAZON.CodeInterpreter. The generated LangGraph agent does " + + "not wire an AgentCore Code Interpreter tool automatically.", + }); + } + + const modelDefinition = translator.generateModelDefinition(snapshot); + const memoryCode = + this.request.memory === "none" ? "" : generateLangGraphMemoryCode(this.request); + const imports = [ + "import asyncio", + ...(functionTools ? ["import json"] : []), + ...(this.request.memory === "none" ? [] : ["import os"]), + ...(isRoot ? ["import uuid"] : []), + "from collections import OrderedDict", + "", + ...(isRoot ? ["from bedrock_agentcore.runtime import BedrockAgentCoreApp"] : []), + ...(this.request.memory === "none" + ? [] + : ["from bedrock_agentcore.memory import MemoryClient"]), + snapshot.knowledgeBases.length > 0 + ? "from langchain_aws import AmazonKnowledgeBasesRetriever, ChatBedrock" + : "from langchain_aws import ChatBedrock", + ...(toolNames.length > 0 ? ["from langchain_core.tools import tool"] : []), + "from langgraph.checkpoint.memory import InMemorySaver", + "from langgraph.prebuilt import create_react_agent", + ...collaboratorImports, + ].join("\n"); + + const code = `# Generated from Bedrock Agent "${escapePythonString(snapshot.agentName)}" version "${escapePythonString(snapshot.sourceAgentVersion)}". +# Review IMPORT_NOTES.md before deploying. + +${imports} +${isRoot ? "\napp = BedrockAgentCoreApp()" : ""} +${collaboratorToolNames.length > 0 ? 'COLLABORATOR_SESSION = "collaborator"\n' : ""} + +${modelDefinition} + +${translator.generateSystemPrompt()} + +${functionTools} + +${knowledgeBaseTools} + +${collaboratorTools.join("\n\n")} + +${memoryCode} + +tools = [${toolNames.join(", ")}] +# Reuses one agent per session/user so each session keeps its own history, capped at 128 +# entries with LRU eviction so a process serving many sessions cannot leak history +# between them or grow without limit. +_agents = OrderedDict() + + +def get_or_create_agent(session_id: str, user_id: str): + key = f"{session_id}/{user_id}" + if key in _agents: + _agents.move_to_end(key) + return _agents[key] + if len(_agents) >= 128: + _agents.popitem(last=False) + system_prompt = SYSTEM_PROMPT +${this.request.memory === "none" ? "" : " system_prompt += retrieve_memory_context(session_id, user_id)\n"} _agents[key] = create_react_agent( + model=llm, + prompt=system_prompt, + tools=tools, + checkpointer=InMemorySaver(), + ) + return _agents[key] + + +def invoke_agent(question: str, session_id: str, user_id: str) -> str: + agent = get_or_create_agent(session_id, user_id) + response = asyncio.run( + agent.ainvoke( + {"messages": [{"role": "user", "content": question}]}, + {"configurable": {"thread_id": session_id}}, + ) + ) + result = response["messages"][-1].content +${this.request.memory === "none" ? "" : " store_memory_event(session_id, user_id, question, result)\n"} return str(result) +${ + isRoot + ? ` + +def _validate_payload(payload): + if not isinstance(payload, dict): + raise ValueError("expected a JSON object with a non-empty 'prompt' field") + prompt = payload.get("prompt") + if not isinstance(prompt, str) or not prompt: + raise ValueError("expected a JSON object with a non-empty 'prompt' field") + return prompt + + +@app.entrypoint +async def invoke(payload, context): + try: + prompt = _validate_payload(payload) + except ValueError as error: + yield f"Invalid payload; {error}." + return + + session_id = getattr(context, "session_id", None) or payload.get("sessionId") or uuid.uuid4().hex + user_id = getattr(context, "user_id", None) or payload.get("userId") or "default-user" + # LangGraph runs synchronously; offload it so it cannot block the Runtime event loop. + yield await asyncio.to_thread(invoke_agent, prompt, session_id, user_id) + + +if __name__ == "__main__": + app.run() +` + : "" +}`; + return { code, files, notes }; + } + + private generateKnowledgeBaseTools(snapshot: BedrockAgentSnapshot): string { + return snapshot.knowledgeBases + .map( + (knowledgeBase) => `_retriever_${pythonIdentifier( + knowledgeBase.name, + )} = AmazonKnowledgeBasesRetriever( + knowledge_base_id="${escapePythonString(knowledgeBase.id)}", + retrieval_config={"vectorSearchConfiguration": {"numberOfResults": 10}}, + region_name="${escapePythonString(knowledgeBaseRegion(knowledgeBase, snapshot.region))}", +) + +@tool +def retrieve_${pythonIdentifier(knowledgeBase.name)}(query: str) -> str: + """${escapePythonTripleQuoted( + knowledgeBase.description ?? `Retrieve from ${knowledgeBase.name}`, + )}""" + documents = _retriever_${pythonIdentifier(knowledgeBase.name)}.invoke(query) + return "\\n\\n".join(document.page_content for document in documents)`, + ) + .join("\n\n"); + } + + private generateModelDefinition(snapshot: BedrockAgentSnapshot): string { + const inference = snapshot.inferenceConfiguration; + const modelKwargs = { + ...(inference?.temperature !== undefined && { temperature: inference.temperature }), + ...(inference?.maximumLength !== undefined && { max_tokens: inference.maximumLength }), + ...(inference?.topP !== undefined && { top_p: inference.topP }), + ...(inference?.topK !== undefined && { top_k: inference.topK }), + ...(inference?.stopSequences !== undefined && { stop_sequences: inference.stopSequences }), + }; + const guardrails = snapshot.guardrail + ? `,\n guardrails=${JSON.stringify({ + guardrailIdentifier: snapshot.guardrail.identifier, + guardrailVersion: snapshot.guardrail.version, + })}` + : ""; + return `llm = ChatBedrock( + model_id="${escapePythonString(snapshot.foundationModel)}", + region_name="${escapePythonString(snapshot.region)}", + model_kwargs=${JSON.stringify(modelKwargs)}${guardrails} +)`; + } + + private requestFor(snapshot: BedrockAgentSnapshot): BedrockAgentImportRequest { + return { + ...this.request, + runtimeName: pythonIdentifier(snapshot.agentName), + }; + } +} + +function generateLangGraphMemoryCode(request: BedrockAgentImportRequest): string { + const memoryEnv = `MEMORY_${request.runtimeName + .replace(/[^a-zA-Z0-9]/g, "_") + .toUpperCase()}MEMORY_ID`; + const retrieval = + request.memory === "longAndShortTerm" + ? ` memories = [] + for namespace, query in ( + (f"/users/{user_id}/facts", "Retrieve relevant facts."), + (f"/users/{user_id}/preferences", "Retrieve user preferences."), + (f"/episodes/{user_id}/{session_id}", "Retrieve relevant episodes."), + (f"/summaries/{user_id}", "Retrieve recent summaries."), + ): + memories.extend( + memory_client.retrieve_memories( + memory_id=MEMORY_ID, + namespace_path=namespace, + query=query, + actor_id=user_id, + top_k=3, + ) + ) + text = "\\n".join( + memory.get("content", {}).get("text", "") for memory in memories + ) + return f"\\n\\nRelevant memory:\\n{text}" if text else "" +` + : ' return ""\n'; + return `MEMORY_ID = os.getenv("${memoryEnv}") +memory_client = MemoryClient() + + +def retrieve_memory_context(session_id: str, user_id: str) -> str: + if not MEMORY_ID: + return "" +${retrieval} + +def store_memory_event(session_id: str, user_id: str, prompt: str, response: str): + if not MEMORY_ID: + return + memory_client.create_event( + memory_id=MEMORY_ID, + actor_id=user_id, + session_id=session_id, + messages=[(prompt, "USER"), (response, "ASSISTANT")], + ) +`; +} diff --git a/src/core/project/bedrockAgentImport/loader.test.ts b/src/core/project/bedrockAgentImport/loader.test.ts new file mode 100644 index 000000000..fae8a4471 --- /dev/null +++ b/src/core/project/bedrockAgentImport/loader.test.ts @@ -0,0 +1,534 @@ +import { describe, expect, test } from "bun:test"; +import { + BedrockAgentClient, + GetAgentActionGroupCommand, + GetAgentAliasCommand, + GetAgentVersionCommand, + GetKnowledgeBaseCommand, + ListAgentActionGroupsCommand, + ListAgentCollaboratorsCommand, + ListAgentKnowledgeBasesCommand, + type AgentVersion, +} from "@aws-sdk/client-bedrock-agent"; +import { InputValidationError } from "../../../errors"; +import { BedrockAgentSnapshotLoader } from "./loader"; + +const AGENT_ID = "A1B2C3D4E5"; +const ALIAS_ID = "TSTALIASID"; +const AGENT_VERSION = "7"; + +type CommandHandler = (command: unknown) => unknown; + +class FakeBedrockAgentClient { + readonly commands: unknown[] = []; + + constructor(private readonly handler: CommandHandler) {} + + async send(command: unknown): Promise { + this.commands.push(command); + return this.handler(command); + } +} + +function validAgentVersion(agentId = AGENT_ID, version = AGENT_VERSION): AgentVersion { + return { + agentId, + agentName: agentId === AGENT_ID ? "SupportAgent" : "BillingAgent", + agentArn: `arn:aws:bedrock:us-east-1:111122223333:agent/${agentId}`, + version, + agentStatus: "PREPARED", + foundationModel: "us.amazon.nova-lite-v1:0", + instruction: "Answer support questions.", + idleSessionTTLInSeconds: 600, + agentResourceRoleArn: "arn:aws:iam::111122223333:role/BedrockAgentRole", + createdAt: new Date(0), + updatedAt: new Date(0), + promptOverrideConfiguration: { + promptConfigurations: [ + { + promptType: "ORCHESTRATION", + promptState: "ENABLED", + promptCreationMode: "OVERRIDDEN", + basePromptTemplate: "System: $instruction$", + inferenceConfiguration: { temperature: 0.2, maximumLength: 1024 }, + }, + { + promptType: "POST_PROCESSING", + promptState: "DISABLED", + basePromptTemplate: "disabled", + }, + ], + }, + }; +} + +function defaultHandler(command: unknown): unknown { + if (command instanceof GetAgentAliasCommand) { + return { + agentAlias: { + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + agentAliasName: "live", + routingConfiguration: [{ agentVersion: AGENT_VERSION }], + }, + }; + } + if (command instanceof GetAgentVersionCommand) { + return { + agentVersion: validAgentVersion(command.input.agentId, command.input.agentVersion), + }; + } + if (command instanceof ListAgentActionGroupsCommand) { + return { actionGroupSummaries: [] }; + } + if (command instanceof ListAgentKnowledgeBasesCommand) { + return { agentKnowledgeBaseSummaries: [] }; + } + if (command instanceof ListAgentCollaboratorsCommand) { + return { agentCollaboratorSummaries: [] }; + } + throw new Error(`unexpected command: ${String(command)}`); +} + +function loaderWith(handler: CommandHandler): { + loader: BedrockAgentSnapshotLoader; + client: FakeBedrockAgentClient; +} { + const client = new FakeBedrockAgentClient(handler); + return { + client, + loader: new BedrockAgentSnapshotLoader(() => client as unknown as BedrockAgentClient), + }; +} + +describe("BedrockAgentSnapshotLoader", () => { + test("loads the immutable version selected by the alias", async () => { + const subject = loaderWith(defaultHandler); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result).toMatchObject({ + sourceAgentId: AGENT_ID, + sourceAgentVersion: AGENT_VERSION, + agentName: "SupportAgent", + foundationModel: "us.amazon.nova-lite-v1:0", + inferenceConfiguration: { temperature: 0.2, maximumLength: 1024 }, + hasPromptOverrides: true, + }); + const versionCommand = subject.client.commands.find( + (command) => command instanceof GetAgentVersionCommand, + ) as GetAgentVersionCommand; + expect(versionCommand.input).toEqual({ + agentId: AGENT_ID, + agentVersion: AGENT_VERSION, + }); + }); + + test("rejects an alias that routes to the mutable DRAFT version", async () => { + const subject = loaderWith((command) => { + if (command instanceof GetAgentAliasCommand) { + return { + agentAlias: { + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + agentAliasName: "AgentTestAlias", + routingConfiguration: [{ agentVersion: "DRAFT" }], + }, + }; + } + return defaultHandler(command); + }); + + await expect( + subject.loader.load({ region: "us-east-1", agentId: AGENT_ID, agentAliasId: ALIAS_ID }), + ).rejects.toThrow(InputValidationError); + expect( + subject.client.commands.some((command) => command instanceof GetAgentVersionCommand), + ).toBe(false); + }); + + test("does not copy Bedrock's internal default orchestration template", async () => { + const subject = loaderWith((command) => { + if (command instanceof GetAgentVersionCommand) { + const agentVersion = validAgentVersion(command.input.agentId, command.input.agentVersion); + agentVersion.promptOverrideConfiguration = { + promptConfigurations: [ + { + promptType: "ORCHESTRATION", + promptState: "ENABLED", + promptCreationMode: "DEFAULT", + basePromptTemplate: '{"system":"$instruction$","messages":[]}', + inferenceConfiguration: { + temperature: 1, + stopSequences: [""], + }, + }, + ], + }; + return { agentVersion }; + } + return defaultHandler(command); + }); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result.instruction).toBe("Answer support questions."); + expect(result.hasPromptOverrides).toBe(false); + // Bedrock echoes its internal orchestration defaults for a DEFAULT prompt; neither the + // template nor its inference settings belong in the generated agent. + expect(result.inferenceConfiguration).toBeUndefined(); + }); + + // Shape observed live from GetAgentVersion for an agent with no customization at all: Bedrock + // reports every prompt type with DEFAULT modes and still populates additionalModelRequestFields + // on ORCHESTRATION. None of that is customer intent, so it must not raise a follow-up note. + test("reports no prompt overrides for an agent the customer never customized", async () => { + const subject = loaderWith((command) => { + if (command instanceof GetAgentVersionCommand) { + const agentVersion = validAgentVersion(command.input.agentId, command.input.agentVersion); + agentVersion.promptOverrideConfiguration = { + promptConfigurations: [ + { + promptType: "POST_PROCESSING", + promptState: "DISABLED", + promptCreationMode: "DEFAULT", + parserMode: "DEFAULT", + }, + { + promptType: "PRE_PROCESSING", + promptState: "DISABLED", + promptCreationMode: "DEFAULT", + parserMode: "DEFAULT", + }, + { + promptType: "ORCHESTRATION", + promptState: "ENABLED", + promptCreationMode: "DEFAULT", + parserMode: "DEFAULT", + additionalModelRequestFields: { anthropic_beta: ["computer-use-2024-10-22"] }, + }, + ], + }; + return { agentVersion }; + } + return defaultHandler(command); + }); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result.hasPromptOverrides).toBe(false); + expect(result.inferenceConfiguration).toBeUndefined(); + }); + + test("carries ORCHESTRATION inference settings the customer overrode", async () => { + const subject = loaderWith((command) => { + if (command instanceof GetAgentVersionCommand) { + const agentVersion = validAgentVersion(command.input.agentId, command.input.agentVersion); + agentVersion.promptOverrideConfiguration = { + promptConfigurations: [ + { + promptType: "ORCHESTRATION", + promptState: "ENABLED", + promptCreationMode: "OVERRIDDEN", + inferenceConfiguration: { temperature: 0.2, topP: 0.9, maximumLength: 1024 }, + }, + { + promptType: "POST_PROCESSING", + promptState: "ENABLED", + promptCreationMode: "OVERRIDDEN", + inferenceConfiguration: { temperature: 0.9 }, + }, + ], + }; + return { agentVersion }; + } + return defaultHandler(command); + }); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result.inferenceConfiguration).toEqual({ + temperature: 0.2, + topP: 0.9, + maximumLength: 1024, + }); + expect(result.hasPromptOverrides).toBe(true); + }); + + test("paginates and normalizes enabled action groups and knowledge bases", async () => { + const subject = loaderWith((command) => { + if (command instanceof ListAgentActionGroupsCommand) { + return command.input.nextToken + ? { + actionGroupSummaries: [ + { + actionGroupId: "disabled", + actionGroupState: "DISABLED", + }, + ], + } + : { + actionGroupSummaries: [ + { + actionGroupId: "weather", + actionGroupState: "ENABLED", + }, + ], + nextToken: "next-actions", + }; + } + if (command instanceof GetAgentActionGroupCommand) { + return { + agentActionGroup: { + agentId: AGENT_ID, + agentVersion: AGENT_VERSION, + actionGroupId: "weather", + actionGroupName: "weather-tools", + actionGroupState: "ENABLED", + functionSchema: { + functions: [ + { + name: "get_weather", + description: "Get weather", + parameters: { + city: { type: "string", required: true }, + }, + }, + ], + }, + }, + }; + } + if (command instanceof ListAgentKnowledgeBasesCommand) { + return command.input.nextToken + ? { agentKnowledgeBaseSummaries: [] } + : { + agentKnowledgeBaseSummaries: [ + { + knowledgeBaseId: "KB123", + knowledgeBaseState: "ENABLED", + description: "Product docs", + }, + ], + nextToken: "next-kbs", + }; + } + if (command instanceof GetKnowledgeBaseCommand) { + return { + knowledgeBase: { + name: "ProductDocs", + description: "Product docs", + knowledgeBaseArn: "arn:aws:bedrock:us-east-1:111122223333:knowledge-base/KB123", + }, + }; + } + return defaultHandler(command); + }); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result.actionGroups).toEqual([ + { + name: "weather-tools", + description: undefined, + parentActionSignature: undefined, + functions: [ + { + name: "get_weather", + description: "Get weather", + parameters: { + city: { type: "string", description: undefined, required: true }, + }, + }, + ], + hasApiSchema: false, + hasLambdaExecutor: false, + returnsControl: false, + }, + ]); + expect(result.knowledgeBases).toEqual([ + { + id: "KB123", + name: "ProductDocs", + description: "Product docs", + arn: "arn:aws:bedrock:us-east-1:111122223333:knowledge-base/KB123", + }, + ]); + }); + + test("loads collaborators at the version recorded in the parent snapshot", async () => { + const collaboratorId = "B1B2B3B4B5"; + const collaboratorVersion = "3"; + const subject = loaderWith((command) => { + if (command instanceof ListAgentCollaboratorsCommand && command.input.agentId === AGENT_ID) { + return { + agentCollaboratorSummaries: [ + { + agentId: collaboratorId, + agentVersion: collaboratorVersion, + agentDescriptor: { + aliasArn: + `arn:aws:bedrock:us-east-1:111122223333:agent-alias/` + + `${collaboratorId}/COLLABALIAS`, + }, + collaboratorName: "billing", + collaborationInstruction: "Handle billing questions.", + relayConversationHistory: "TO_COLLABORATOR", + }, + ], + }; + } + return defaultHandler(command); + }); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result.collaborators[0]).toMatchObject({ + name: "billing", + instruction: "Handle billing questions.", + relayConversationHistory: "TO_COLLABORATOR", + agent: { + sourceAgentId: collaboratorId, + sourceAgentVersion: collaboratorVersion, + agentName: "BillingAgent", + }, + }); + }); + + test("records and skips collaborator cycles", async () => { + const subject = loaderWith((command) => { + if (command instanceof ListAgentCollaboratorsCommand) { + return { + agentCollaboratorSummaries: [ + { + agentId: AGENT_ID, + agentVersion: AGENT_VERSION, + agentDescriptor: { + aliasArn: + `arn:aws:bedrock:us-east-1:111122223333:agent-alias/` + `${AGENT_ID}/${ALIAS_ID}`, + }, + collaboratorName: "self", + collaborationInstruction: "Delegate to self.", + }, + ], + }; + } + return defaultHandler(command); + }); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result.collaborators).toEqual([]); + expect(result.notes).toEqual([ + { + category: "collaborator-cycle", + message: + `Skipped collaborator 'self' because it creates a cycle at ` + + `${AGENT_ID}:${AGENT_VERSION}.`, + }, + ]); + }); + + test("does not treat a collaborator reused by sibling entries as a cycle", async () => { + const collaboratorId = "B1B2B3B4B5"; + const collaboratorVersion = "3"; + const subject = loaderWith((command) => { + if (command instanceof ListAgentCollaboratorsCommand && command.input.agentId === AGENT_ID) { + return { + agentCollaboratorSummaries: ["billing-primary", "billing-backup"].map( + (collaboratorName) => ({ + agentId: collaboratorId, + agentVersion: collaboratorVersion, + collaboratorName, + collaborationInstruction: "Handle billing questions.", + }), + ), + }; + } + return defaultHandler(command); + }); + + const result = await subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }); + + expect(result.collaborators.map(({ name }) => name)).toEqual([ + "billing-primary", + "billing-backup", + ]); + expect(result.notes).toEqual([]); + }); + + test("rejects an alias without exactly one routed version", async () => { + const subject = loaderWith((command) => { + if (command instanceof GetAgentAliasCommand) { + return { + agentAlias: { + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + agentAliasName: "detached", + routingConfiguration: [], + }, + }; + } + return defaultHandler(command); + }); + + await expect( + subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }), + ).rejects.toBeInstanceOf(InputValidationError); + }); + + test("maps a missing alias to an input error", async () => { + const subject = loaderWith((command) => { + if (command instanceof GetAgentAliasCommand) { + throw Object.assign(new Error("not found"), { + name: "ResourceNotFoundException", + }); + } + return defaultHandler(command); + }); + + await expect( + subject.loader.load({ + region: "us-east-1", + agentId: AGENT_ID, + agentAliasId: ALIAS_ID, + }), + ).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/core/project/bedrockAgentImport/loader.ts b/src/core/project/bedrockAgentImport/loader.ts new file mode 100644 index 000000000..617d9b97e --- /dev/null +++ b/src/core/project/bedrockAgentImport/loader.ts @@ -0,0 +1,437 @@ +import { + BedrockAgentClient, + GetAgentActionGroupCommand, + GetAgentAliasCommand, + GetAgentVersionCommand, + GetKnowledgeBaseCommand, + ListAgentActionGroupsCommand, + ListAgentCollaboratorsCommand, + ListAgentKnowledgeBasesCommand, + type AgentActionGroup, + type AgentVersion, +} from "@aws-sdk/client-bedrock-agent"; +import { InputValidationError, MalformedServiceResponseError } from "../../../errors"; +import type { + BedrockAgentImportNote, + BedrockAgentSnapshot, + ImportedActionGroup, + ImportedCollaborator, + ImportedInferenceConfiguration, + ImportedKnowledgeBase, +} from "./types"; + +export type CreateBedrockAgentClient = (region: string) => BedrockAgentClient; + +const ALIAS_ARN_PATTERN = /^arn:[^:]+:bedrock:[^:]+:[^:]+:agent-alias\/([^/]+)\/([^/]+)$/; + +export class BedrockAgentSnapshotLoader { + constructor( + private readonly createClient: CreateBedrockAgentClient = (region) => + new BedrockAgentClient({ region }), + ) {} + + async load(input: { + region: string; + agentId: string; + agentAliasId: string; + }): Promise { + const client = this.createClient(input.region); + let alias; + try { + ({ agentAlias: alias } = await client.send( + new GetAgentAliasCommand({ + agentId: input.agentId, + agentAliasId: input.agentAliasId, + }), + )); + } catch (error) { + if (isNamedError(error, "ResourceNotFoundException")) { + throw new InputValidationError( + `Bedrock Agent '${input.agentId}' has no alias with id '${input.agentAliasId}' in ` + + `${input.region}; check --agent-id, --agent-alias-id, and --region`, + { cause: error }, + ); + } + throw error; + } + + if ( + !alias || + alias.agentId !== input.agentId || + alias.agentAliasId !== input.agentAliasId || + !alias.agentAliasName + ) { + throw new MalformedServiceResponseError( + `the Bedrock Agent service returned an incomplete alias description for agent ` + + `'${input.agentId}' / alias '${input.agentAliasId}'`, + ); + } + + const routing = alias.routingConfiguration ?? []; + const sourceVersion = routing.length === 1 ? routing[0]?.agentVersion : undefined; + if (!sourceVersion) { + throw new InputValidationError( + `Bedrock Agent alias '${alias.agentAliasName}' does not route to exactly one agent version`, + ); + } + // The built-in test alias (TSTALIASID) routes to DRAFT, which is mutable and has no + // GetAgentVersion representation. Import needs an immutable snapshot. + if (sourceVersion === "DRAFT") { + throw new InputValidationError( + `Bedrock Agent alias '${alias.agentAliasName}' routes to the mutable DRAFT version; ` + + "import requires a prepared version. Create a version and an alias that points at it, " + + "then pass that alias with --agent-alias-id", + ); + } + + const snapshot = await this.loadVersion( + client, + { + region: input.region, + agentId: input.agentId, + agentVersion: sourceVersion, + }, + new Set(), + ); + if (!snapshot) { + throw new MalformedServiceResponseError( + `the selected Bedrock Agent version '${input.agentId}:${sourceVersion}' could not be loaded`, + ); + } + return snapshot; + } + + private async loadVersion( + client: BedrockAgentClient, + input: { + region: string; + agentId: string; + agentVersion: string; + }, + visited: Set, + ): Promise { + const visitKey = `${input.agentId}:${input.agentVersion}`; + if (visited.has(visitKey)) return undefined; + visited.add(visitKey); + + let agentVersion: AgentVersion | undefined; + try { + ({ agentVersion } = await client.send( + new GetAgentVersionCommand({ + agentId: input.agentId, + agentVersion: input.agentVersion, + }), + )); + } catch (error) { + if (isNamedError(error, "ResourceNotFoundException")) { + throw new InputValidationError( + `Bedrock Agent '${input.agentId}' has no version '${input.agentVersion}' in ${input.region}`, + { cause: error }, + ); + } + throw error; + } + + if ( + !agentVersion || + agentVersion.agentId !== input.agentId || + agentVersion.version !== input.agentVersion || + !agentVersion.agentName || + !agentVersion.foundationModel + ) { + throw new MalformedServiceResponseError( + `the Bedrock Agent service returned an incomplete description for agent ` + + `'${input.agentId}' version '${input.agentVersion}'`, + ); + } + + const notes: BedrockAgentImportNote[] = []; + const [actionGroups, knowledgeBases, collaborators] = await Promise.all([ + this.loadActionGroups(client, input.agentId, input.agentVersion), + this.loadKnowledgeBases(client, input.agentId, input.agentVersion, notes), + this.loadCollaborators( + client, + input.region, + input.agentId, + input.agentVersion, + visited, + notes, + ), + ]); + + return { + region: input.region, + sourceAgentId: input.agentId, + sourceAgentVersion: input.agentVersion, + agentName: agentVersion.agentName, + description: agentVersion.description, + foundationModel: agentVersion.foundationModel, + instruction: agentVersion.instruction ?? "", + inferenceConfiguration: orchestrationInference(agentVersion), + hasPromptOverrides: hasPromptOverrides(agentVersion), + guardrail: + agentVersion.guardrailConfiguration?.guardrailIdentifier && + agentVersion.guardrailConfiguration.guardrailVersion + ? { + identifier: agentVersion.guardrailConfiguration.guardrailIdentifier, + version: agentVersion.guardrailConfiguration.guardrailVersion, + } + : undefined, + sourceMemoryEnabled: (agentVersion.memoryConfiguration?.enabledMemoryTypes?.length ?? 0) > 0, + actionGroups, + knowledgeBases, + collaborators, + notes, + }; + } + + private async loadActionGroups( + client: BedrockAgentClient, + agentId: string, + agentVersion: string, + ): Promise { + const actionGroupIds: string[] = []; + let nextToken: string | undefined; + do { + const page = await client.send( + new ListAgentActionGroupsCommand({ agentId, agentVersion, nextToken }), + ); + for (const summary of page.actionGroupSummaries ?? []) { + if (summary.actionGroupState === "ENABLED" && summary.actionGroupId) { + actionGroupIds.push(summary.actionGroupId); + } + } + nextToken = page.nextToken; + } while (nextToken); + + return Promise.all( + actionGroupIds.map(async (actionGroupId) => { + const response = await client.send( + new GetAgentActionGroupCommand({ agentId, agentVersion, actionGroupId }), + ); + return normalizeActionGroup(response.agentActionGroup, { + agentId, + agentVersion, + actionGroupId, + }); + }), + ); + } + + private async loadKnowledgeBases( + client: BedrockAgentClient, + agentId: string, + agentVersion: string, + notes: BedrockAgentImportNote[], + ): Promise { + const summaries: { id: string; description?: string }[] = []; + let nextToken: string | undefined; + do { + const page = await client.send( + new ListAgentKnowledgeBasesCommand({ agentId, agentVersion, nextToken }), + ); + for (const summary of page.agentKnowledgeBaseSummaries ?? []) { + if (summary.knowledgeBaseState === "ENABLED" && summary.knowledgeBaseId) { + summaries.push({ + id: summary.knowledgeBaseId, + description: summary.description, + }); + } + } + nextToken = page.nextToken; + } while (nextToken); + + return Promise.all( + summaries.map(async (summary) => { + try { + const response = await client.send( + new GetKnowledgeBaseCommand({ knowledgeBaseId: summary.id }), + ); + return { + id: summary.id, + name: response.knowledgeBase?.name ?? summary.id, + description: summary.description ?? response.knowledgeBase?.description, + arn: response.knowledgeBase?.knowledgeBaseArn, + }; + } catch (error) { + notes.push({ + category: "knowledge-base-details", + message: + `Could not read details for knowledge base '${summary.id}': ` + + `${error instanceof Error ? error.message : String(error)}. ` + + "The generated tool uses the ID and requires manual IAM verification.", + }); + return { + id: summary.id, + name: summary.id, + description: summary.description, + }; + } + }), + ); + } + + private async loadCollaborators( + client: BedrockAgentClient, + region: string, + agentId: string, + agentVersion: string, + visited: Set, + notes: BedrockAgentImportNote[], + ): Promise { + const summaries = []; + let nextToken: string | undefined; + do { + const page = await client.send( + new ListAgentCollaboratorsCommand({ agentId, agentVersion, nextToken }), + ); + summaries.push(...(page.agentCollaboratorSummaries ?? [])); + nextToken = page.nextToken; + } while (nextToken); + + const collaborators: ImportedCollaborator[] = []; + for (const summary of summaries) { + const aliasArn = summary.agentDescriptor?.aliasArn; + const arnMatch = aliasArn ? ALIAS_ARN_PATTERN.exec(aliasArn) : undefined; + const collaboratorAgentId = summary.agentId ?? arnMatch?.[1]; + const collaboratorVersion = summary.agentVersion; + if ( + !summary.collaboratorName || + !summary.collaborationInstruction || + !collaboratorAgentId || + !collaboratorVersion + ) { + throw new MalformedServiceResponseError( + `the Bedrock Agent service returned an incomplete collaborator for agent ` + + `'${agentId}' version '${agentVersion}'`, + ); + } + + const collaborator = await this.loadVersion( + client, + { + region, + agentId: collaboratorAgentId, + agentVersion: collaboratorVersion, + }, + new Set(visited), + ); + if (!collaborator) { + notes.push({ + category: "collaborator-cycle", + message: + `Skipped collaborator '${summary.collaboratorName}' because it creates a cycle at ` + + `${collaboratorAgentId}:${collaboratorVersion}.`, + }); + continue; + } + + collaborators.push({ + name: summary.collaboratorName, + instruction: summary.collaborationInstruction, + relayConversationHistory: summary.relayConversationHistory, + agent: collaborator, + }); + } + return collaborators; + } +} + +// Only ORCHESTRATION inference settings carry over: that is the prompt the generated agent +// actually runs. Pre/post-processing and knowledge-base prompt types have no equivalent in a +// single-model Strands/LangGraph agent, so their settings are reported as manual follow-up instead. +// +// The OVERRIDDEN gate matters: Bedrock echoes its own internal orchestration defaults (for +// example temperature 1 and a '' stop sequence) even when the customer never set them, +// and copying those into a plain Strands/LangGraph agent changes its behavior for the worse. +function orchestrationInference( + agentVersion: AgentVersion, +): ImportedInferenceConfiguration | undefined { + const inference = (agentVersion.promptOverrideConfiguration?.promptConfigurations ?? []).find( + (prompt) => prompt.promptType === "ORCHESTRATION" && prompt.promptCreationMode === "OVERRIDDEN", + )?.inferenceConfiguration; + if (!inference) return undefined; + + const configuration: ImportedInferenceConfiguration = { + ...(inference.temperature !== undefined && { temperature: inference.temperature }), + ...(inference.topP !== undefined && { topP: inference.topP }), + ...(inference.topK !== undefined && { topK: inference.topK }), + ...(inference.maximumLength !== undefined && { maximumLength: inference.maximumLength }), + ...(inference.stopSequences !== undefined && { stopSequences: inference.stopSequences }), + }; + return Object.keys(configuration).length > 0 ? configuration : undefined; +} + +function hasPromptOverrides(agentVersion: AgentVersion): boolean { + const configuration = agentVersion.promptOverrideConfiguration; + return ( + configuration?.overrideLambda !== undefined || + // Only an explicit OVERRIDDEN mode means the customer customized anything. Bedrock populates + // additionalModelRequestFields on the ORCHESTRATION prompt of even a fully default agent, so + // its presence is not evidence of customization and would raise a note about nothing. + (configuration?.promptConfigurations ?? []).some( + (prompt) => + prompt.promptState !== "DISABLED" && + (prompt.promptCreationMode === "OVERRIDDEN" || prompt.parserMode === "OVERRIDDEN"), + ) + ); +} + +function normalizeActionGroup( + actionGroup: AgentActionGroup | undefined, + expected: { agentId: string; agentVersion: string; actionGroupId: string }, +): ImportedActionGroup { + if ( + !actionGroup || + actionGroup.agentId !== expected.agentId || + actionGroup.agentVersion !== expected.agentVersion || + actionGroup.actionGroupId !== expected.actionGroupId || + !actionGroup.actionGroupName + ) { + throw new MalformedServiceResponseError( + `the Bedrock Agent service returned an incomplete action group ` + + `'${expected.actionGroupId}' for agent '${expected.agentId}' version ` + + `'${expected.agentVersion}'`, + ); + } + + const functionSchema = + actionGroup.functionSchema && "functions" in actionGroup.functionSchema + ? actionGroup.functionSchema.functions + : undefined; + const executor = actionGroup.actionGroupExecutor; + + return { + name: actionGroup.actionGroupName, + description: actionGroup.description, + parentActionSignature: actionGroup.parentActionSignature, + functions: (functionSchema ?? []).flatMap((fn) => + fn.name + ? [ + { + name: fn.name, + description: fn.description, + parameters: Object.fromEntries( + Object.entries(fn.parameters ?? {}).map(([name, parameter]) => [ + name, + { + type: parameter.type ?? "string", + description: parameter.description, + required: parameter.required ?? false, + }, + ]), + ), + }, + ] + : [], + ), + hasApiSchema: actionGroup.apiSchema !== undefined, + hasLambdaExecutor: !!executor && "lambda" in executor && !!executor.lambda, + returnsControl: + !!executor && "customControl" in executor && executor.customControl === "RETURN_CONTROL", + }; +} + +function isNamedError(error: unknown, name: string): boolean { + return error instanceof Error && error.name === name; +} diff --git a/src/core/project/bedrockAgentImport/notes.ts b/src/core/project/bedrockAgentImport/notes.ts new file mode 100644 index 000000000..89e1b758a --- /dev/null +++ b/src/core/project/bedrockAgentImport/notes.ts @@ -0,0 +1,30 @@ +import type { BedrockAgentImportNote, BedrockAgentSnapshot } from "./types"; + +export const IMPORT_NOTES_FILE = "IMPORT_NOTES.md"; + +export function renderImportNotes( + snapshot: BedrockAgentSnapshot, + notes: BedrockAgentImportNote[], +): string { + const lines = [ + "# Bedrock Agent Import Notes", + "", + `Source agent: \`${snapshot.agentName}\` (\`${snapshot.sourceAgentId}\`)`, + `Source version: \`${snapshot.sourceAgentVersion}\``, + "", + "The generated application is an editable translation of the selected Bedrock Agent version.", + "It does not invoke the source agent alias at runtime.", + "", + ]; + + if (notes.length === 0) { + lines.push("No manual follow-up was identified."); + return `${lines.join("\n")}\n`; + } + + lines.push("## Manual Follow-up", ""); + for (const note of notes) { + lines.push(`- **${note.category}:** ${note.message}`); + } + return `${lines.join("\n")}\n`; +} diff --git a/src/core/project/bedrockAgentImport/pyproject.ts b/src/core/project/bedrockAgentImport/pyproject.ts new file mode 100644 index 000000000..56de893d1 --- /dev/null +++ b/src/core/project/bedrockAgentImport/pyproject.ts @@ -0,0 +1,54 @@ +import type { BedrockAgentImportFramework } from "./types"; + +export function generateImportPyproject(input: { + runtimeName: string; + framework: BedrockAgentImportFramework; + hasMemory: boolean; + hasCodeInterpreter: boolean; +}): string { + // Pinned to the minor, not the patch, unlike the scaffolded templates: `langgraph ~= 1.0.2` + // resolves to 1.0.10, whose langgraph-prebuilt fails to import, generating a broken project. + const dependencies = [ + "aws-opentelemetry-distro ~= 0.18.0", + `bedrock-agentcore${input.hasMemory ? "[memory]" : ""} ~= 1.9`, + "boto3 ~= 1.43", + "botocore[crt] ~= 1.43", + ]; + + if (input.framework === "strands") { + dependencies.push("strands-agents ~= 1.15"); + if (input.hasCodeInterpreter) dependencies.push("strands-agents-tools ~= 0.1.0"); + } else { + dependencies.push( + "langchain ~= 1.0", + "langchain-aws ~= 1.0", + "langgraph ~= 1.0", + "opentelemetry-instrumentation-langchain ~= 0.59.0", + ); + } + + return `[build-system] +requires = ["hatchling ~= 1.27"] +build-backend = "hatchling.build" + +[project] +name = "${pythonPackageName(input.runtimeName)}" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ +${dependencies.map((dependency) => ` "${dependency}",`).join("\n")} +] + +[tool.hatch.build.targets.wheel] +packages = ["."] +`; +} + +function pythonPackageName(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "imported-agent" + ); +} diff --git a/src/core/project/bedrockAgentImport/strandsTranslator.ts b/src/core/project/bedrockAgentImport/strandsTranslator.ts new file mode 100644 index 000000000..2b664d040 --- /dev/null +++ b/src/core/project/bedrockAgentImport/strandsTranslator.ts @@ -0,0 +1,268 @@ +import { + BaseBedrockAgentTranslator, + escapePythonString, + escapePythonTripleQuoted, + knowledgeBaseRegion, + pythonIdentifier, +} from "./baseTranslator"; +import type { + BedrockAgentImportNote, + BedrockAgentImportPlan, + BedrockAgentImportRequest, + BedrockAgentSnapshot, +} from "./types"; + +export class StrandsBedrockAgentTranslator extends BaseBedrockAgentTranslator { + translate(): BedrockAgentImportPlan { + const rendered = this.renderModule(this.snapshot, true); + return this.buildPlan(rendered.code, rendered.files, rendered.notes); + } + + private renderModule( + snapshot: BedrockAgentSnapshot, + isRoot: boolean, + ): { code: string; files: Record; notes: BedrockAgentImportNote[] } { + const translator = + snapshot === this.snapshot + ? this + : new StrandsBedrockAgentTranslator(snapshot, this.requestFor(snapshot)); + const files: Record = {}; + const notes: BedrockAgentImportNote[] = []; + const collaboratorImports: string[] = []; + const collaboratorTools: string[] = []; + const collaboratorToolNames: string[] = []; + + for (const collaborator of snapshot.collaborators) { + const name = pythonIdentifier(collaborator.name); + const moduleName = `strands_collaborator_${name}`; + const child = translator.renderModule(collaborator.agent, false); + files[`${moduleName}.py`] = child.code; + Object.assign(files, child.files); + notes.push(...child.notes); + collaboratorImports.push(`from ${moduleName} import invoke_agent as invoke_${name}_agent`); + collaboratorTools.push(`@tool +def invoke_${name}(query: str) -> str: + """${escapePythonTripleQuoted(collaborator.instruction)}""" + return invoke_${name}_agent(query, COLLABORATOR_SESSION, COLLABORATOR_SESSION)`); + collaboratorToolNames.push(`invoke_${name}`); + notes.push({ + category: "collaborator session scope", + message: + `Collaborator '${collaborator.name}' runs under a shared session rather than the ` + + "caller's session, so its own history is not isolated per end user." + + (collaborator.relayConversationHistory === "TO_COLLABORATOR" + ? " The source agent also requested relayed conversation history, which the generated " + + "tool does not copy; it delegates only the current query." + : ""), + }); + } + + const functionTools = translator.generateFunctionTools(); + const knowledgeBaseTools = translator.generateKnowledgeBaseTools(snapshot); + const codeInterpreter = snapshot.actionGroups.some( + (group) => group.parentActionSignature === "AMAZON.CodeInterpreter", + ); + const toolNames = [ + ...translator.functionToolNames(), + ...snapshot.knowledgeBases.map( + (knowledgeBase) => `retrieve_${pythonIdentifier(knowledgeBase.name)}`, + ), + ...collaboratorToolNames, + ...(codeInterpreter ? ["AgentCoreCodeInterpreter().code_interpreter"] : []), + ]; + if (snapshot.guardrail) { + notes.push({ + category: "Strands guardrail", + message: + `Guardrail '${snapshot.guardrail.identifier}' version ` + + `'${snapshot.guardrail.version}' was not attached because Strands BedrockModel ` + + "does not expose equivalent guardrail configuration.", + }); + } + + const modelDefinition = translator.generateModelDefinition(snapshot); + const memoryModule = + this.request.memory === "none" ? undefined : generateStrandsMemoryModule(this.request); + if (isRoot && memoryModule) files["memory.py"] = memoryModule; + + const imports = [ + ...(isRoot ? ["import asyncio"] : []), + ...(functionTools ? ["import json"] : []), + ...(isRoot ? ["import uuid"] : []), + "from collections import OrderedDict", + "", + ...(snapshot.knowledgeBases.length > 0 ? ["import boto3"] : []), + ...(isRoot ? ["from bedrock_agentcore.runtime import BedrockAgentCoreApp"] : []), + toolNames.length > 0 ? "from strands import Agent, tool" : "from strands import Agent", + "from strands.models import BedrockModel", + ...(codeInterpreter + ? ["from strands_tools.code_interpreter import AgentCoreCodeInterpreter"] + : []), + ...(memoryModule ? ["from memory import get_memory_session_manager"] : []), + ...collaboratorImports, + ].join("\n"); + + // Only the root module owns the Runtime entrypoint. A collaborator module is imported by the + // root, so a second BedrockAgentCoreApp()/@app.entrypoint there would register at import time. + const code = `# Generated from Bedrock Agent "${escapePythonString(snapshot.agentName)}" version "${escapePythonString(snapshot.sourceAgentVersion)}". +# Review IMPORT_NOTES.md before deploying. + +${imports} +${isRoot ? "\napp = BedrockAgentCoreApp()" : ""} +${collaboratorToolNames.length > 0 ? 'COLLABORATOR_SESSION = "collaborator"\n' : ""} + +${modelDefinition} + +${translator.generateSystemPrompt()} + +${functionTools} + +${knowledgeBaseTools} + +${collaboratorTools.join("\n\n")} + +tools = [${toolNames.join(", ")}] +# Reuses one agent per session/user so each session keeps its own history, capped at 128 +# entries with LRU eviction so a process serving many sessions cannot leak history +# between them or grow without limit. +_agents = OrderedDict() + + +def get_or_create_agent(session_id: str, user_id: str): + key = f"{session_id}/{user_id}" + if key in _agents: + _agents.move_to_end(key) + return _agents[key] + if len(_agents) >= 128: + _agents.popitem(last=False) + _agents[key] = Agent( + model=llm, + system_prompt=SYSTEM_PROMPT, + tools=tools, +${memoryModule ? " session_manager=get_memory_session_manager(session_id, user_id),\n" : ""} ) + return _agents[key] + + +def invoke_agent(question: str, session_id: str, user_id: str) -> str: + agent = get_or_create_agent(session_id, user_id) + return str(agent(question)) +${ + isRoot + ? ` + +def _validate_payload(payload): + if not isinstance(payload, dict): + raise ValueError("expected a JSON object with a non-empty 'prompt' field") + prompt = payload.get("prompt") + if not isinstance(prompt, str) or not prompt: + raise ValueError("expected a JSON object with a non-empty 'prompt' field") + return prompt + + +@app.entrypoint +async def invoke(payload, context): + try: + prompt = _validate_payload(payload) + except ValueError as error: + yield f"Invalid payload; {error}." + return + + session_id = getattr(context, "session_id", None) or payload.get("sessionId") or uuid.uuid4().hex + user_id = getattr(context, "user_id", None) or payload.get("userId") or "default-user" + # Strands runs synchronously; offload it so it cannot block the Runtime event loop. + yield await asyncio.to_thread(invoke_agent, prompt, session_id, user_id) + + +if __name__ == "__main__": + app.run() +` + : "" +}`; + return { code, files, notes }; + } + + private generateKnowledgeBaseTools(snapshot: BedrockAgentSnapshot): string { + return snapshot.knowledgeBases + .map( + (knowledgeBase) => `@tool +def retrieve_${pythonIdentifier(knowledgeBase.name)}(query: str): + """${escapePythonTripleQuoted( + knowledgeBase.description ?? `Retrieve from ${knowledgeBase.name}`, + )}""" + client = boto3.client("bedrock-agent-runtime", region_name="${escapePythonString( + knowledgeBaseRegion(knowledgeBase, snapshot.region), + )}") + return client.retrieve( + retrievalQuery={"text": query}, + knowledgeBaseId="${escapePythonString(knowledgeBase.id)}", + retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": 10}}, + ).get("retrievalResults", [])`, + ) + .join("\n\n"); + } + + private generateModelDefinition(snapshot: BedrockAgentSnapshot): string { + const inference = snapshot.inferenceConfiguration; + const args = [ + `model_id="${escapePythonString(snapshot.foundationModel)}"`, + `region_name="${escapePythonString(snapshot.region)}"`, + inference?.temperature !== undefined ? `temperature=${inference.temperature}` : undefined, + inference?.maximumLength !== undefined ? `max_tokens=${inference.maximumLength}` : undefined, + inference?.topP !== undefined ? `top_p=${inference.topP}` : undefined, + inference?.topK !== undefined ? `top_k=${inference.topK}` : undefined, + inference?.stopSequences !== undefined + ? `stop_sequences=${JSON.stringify(inference.stopSequences)}` + : undefined, + ].filter(Boolean); + return `llm = BedrockModel( + ${args.join(",\n ")} +)`; + } + + private requestFor(snapshot: BedrockAgentSnapshot): BedrockAgentImportRequest { + return { + ...this.request, + runtimeName: pythonIdentifier(snapshot.agentName), + }; + } +} + +function generateStrandsMemoryModule(request: BedrockAgentImportRequest): string { + const memoryEnv = `MEMORY_${request.runtimeName + .replace(/[^a-zA-Z0-9]/g, "_") + .toUpperCase()}MEMORY_ID`; + const retrievalConfig = + request.memory === "longAndShortTerm" + ? ` retrieval_config = { + f"/users/{actor_id}/facts": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), + f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + } +` + : " retrieval_config = {}\n"; + return `import os + +from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig, RetrievalConfig +from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager + +MEMORY_ID = os.getenv("${memoryEnv}") +REGION = os.getenv("AWS_REGION") + + +def get_memory_session_manager(session_id: str, actor_id: str): + if not MEMORY_ID: + return None + +${retrievalConfig} + return AgentCoreMemorySessionManager( + AgentCoreMemoryConfig( + memory_id=MEMORY_ID, + session_id=session_id, + actor_id=actor_id, + retrieval_config=retrieval_config, + ), + REGION, + ) +`; +} diff --git a/src/core/project/bedrockAgentImport/translator.test.ts b/src/core/project/bedrockAgentImport/translator.test.ts new file mode 100644 index 000000000..54476892c --- /dev/null +++ b/src/core/project/bedrockAgentImport/translator.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test"; +import { LangGraphBedrockAgentTranslator } from "./langGraphTranslator"; +import { StrandsBedrockAgentTranslator } from "./strandsTranslator"; +import type { BedrockAgentImportRequest, BedrockAgentSnapshot } from "./types"; + +const request: BedrockAgentImportRequest = { + runtimeName: "ImportedSupport", + region: "us-east-1", + agentId: "A1B2C3D4E5", + agentAliasId: "TSTALIASID", + framework: "strands", + memory: "longAndShortTerm", +}; + +function snapshot(overrides: Partial = {}): BedrockAgentSnapshot { + return { + region: "us-east-1", + sourceAgentId: "A1B2C3D4E5", + sourceAgentVersion: "7", + agentName: "SupportAgent", + description: "Handles customer support.", + foundationModel: "us.amazon.nova-lite-v1:0", + instruction: 'Answer support questions. Never emit """.', + inferenceConfiguration: { temperature: 0.2, topP: 0.9, maximumLength: 1024 }, + hasPromptOverrides: true, + guardrail: { identifier: "GR123", version: "1" }, + sourceMemoryEnabled: true, + actionGroups: [ + { + name: "weather", + functions: [ + { + name: "get-weather", + description: "Get current weather.", + parameters: { + city: { type: "string", required: true }, + days: { type: "integer", required: false }, + }, + }, + ], + hasApiSchema: false, + hasLambdaExecutor: true, + returnsControl: false, + }, + { + name: "code-interpreter", + parentActionSignature: "AMAZON.CodeInterpreter", + functions: [], + hasApiSchema: false, + hasLambdaExecutor: false, + returnsControl: false, + }, + ], + knowledgeBases: [ + { + id: "KB123", + name: "Product Docs", + description: "Product documentation.", + arn: "arn:aws:bedrock:us-east-1:111122223333:knowledge-base/KB123", + }, + ], + collaborators: [], + notes: [], + ...overrides, + }; +} + +describe("StrandsBedrockAgentTranslator", () => { + test("generates owned Strands code and documents required permissions", () => { + const source = snapshot(); + const plan = new StrandsBedrockAgentTranslator(source, request).translate(); + + expect(plan.files["main.py"]).toContain("from strands import Agent, tool"); + expect(plan.files["main.py"]).toContain('model_id="us.amazon.nova-lite-v1:0"'); + expect(plan.files["main.py"]).toContain("temperature=0.2"); + expect(plan.files["main.py"]).toContain("max_tokens=1024"); + expect(plan.files["main.py"]).toContain("def get_weather(city: str, days: int | None = None)"); + expect(plan.files["main.py"]).toContain("def retrieve_Product_Docs(query: str)"); + expect(plan.files["main.py"]).toContain("AgentCoreCodeInterpreter().code_interpreter"); + expect(plan.files["main.py"]).toContain("await asyncio.to_thread"); + // The generated agent must never route back through the source Bedrock Agent. + expect(plan.files["main.py"]).not.toContain("invoke_agent(" + "agentId="); + expect(plan.files["main.py"]).not.toContain("client.invoke_agent"); + expect(plan.files["main.py"]).not.toContain("bedrock:InvokeAgent"); + expect(plan.files["memory.py"]).toContain( + 'MEMORY_ID = os.getenv("MEMORY_IMPORTEDSUPPORTMEMORY_ID")', + ); + // Knowledge-base access is documented as manual follow-up, not generated as an IAM policy. + expect(Object.keys(plan.files)).not.toContain("bedrock-knowledge-base-policy.json"); + expect(plan.files["IMPORT_NOTES.md"]).toContain( + "Grant the Runtime execution role bedrock:Retrieve on: " + + "arn:aws:bedrock:us-east-1:111122223333:knowledge-base/KB123.", + ); + expect(plan.files["IMPORT_NOTES.md"]).toContain("action-group implementation"); + expect(plan.files["IMPORT_NOTES.md"]).toContain("Lambda executor"); + expect(plan.files["IMPORT_NOTES.md"]).toContain("Strands guardrail"); + }); + + test("keeps generated state to the repository's per-session agent cache", () => { + const main = new StrandsBedrockAgentTranslator(snapshot(), request).translate().files[ + "main.py" + ]!; + + expect(main).toContain("_agents = OrderedDict()"); + expect(main).toContain('key = f"{session_id}/{user_id}"'); + expect(main).not.toContain("contextvars"); + expect(main).not.toContain("threading"); + expect(main).not.toContain("_session_locks"); + expect(main.match(/asyncio\.to_thread/g)).toHaveLength(1); + }); + + test("escapes service-controlled strings embedded in Python", () => { + const source = snapshot(); + const main = new StrandsBedrockAgentTranslator(source, { + ...request, + memory: "none", + }).translate().files["main.py"]!; + + expect(main).toContain('SYSTEM_PROMPT = """Answer support questions. Never emit \\"\\"\\"."""'); + // An unescaped instruction would terminate the triple-quoted literal early. + expect(main).not.toContain('Never emit """.'); + }); + + test("generates collaborator modules without invoking the source alias", () => { + const source = snapshot({ + collaborators: [ + { + name: "billing", + instruction: "Handle billing.", + relayConversationHistory: "TO_COLLABORATOR", + agent: snapshot({ + sourceAgentId: "B1B2B3B4B5", + sourceAgentVersion: "2", + agentName: "BillingAgent", + collaborators: [], + }), + }, + ], + }); + const plan = new StrandsBedrockAgentTranslator(source, { + ...request, + memory: "none", + }).translate(); + + expect(plan.files["strands_collaborator_billing.py"]).toContain( + 'Generated from Bedrock Agent "BillingAgent" version "2"', + ); + expect(plan.files["main.py"]).toContain( + "from strands_collaborator_billing import invoke_agent as invoke_billing_agent", + ); + expect(plan.files["main.py"]).toContain( + "invoke_billing_agent(query, COLLABORATOR_SESSION, COLLABORATOR_SESSION)", + ); + expect(plan.files["main.py"]).not.toContain("contextvars"); + expect(plan.files["IMPORT_NOTES.md"]).toContain("collaborator session scope"); + expect(plan.files["IMPORT_NOTES.md"]).toContain("relayed conversation history"); + }); + + test("gives only the root module the Runtime entrypoint", () => { + const plan = new StrandsBedrockAgentTranslator( + snapshot({ + collaborators: [ + { + name: "billing", + instruction: "Handle billing.", + agent: snapshot({ agentName: "BillingAgent", collaborators: [] }), + }, + ], + }), + { ...request, memory: "none" }, + ).translate(); + + expect(plan.files["main.py"]).toContain("app = BedrockAgentCoreApp()"); + expect(plan.files["main.py"]).toContain("@app.entrypoint"); + + // The root imports this module, so a second app/entrypoint here would register on import. + const collaborator = plan.files["strands_collaborator_billing.py"]!; + expect(collaborator).not.toContain("BedrockAgentCoreApp"); + expect(collaborator).not.toContain("@app.entrypoint"); + expect(collaborator).not.toContain("__main__"); + expect(collaborator).toContain("def invoke_agent("); + }); + + test("reports collaborator follow-up alongside the root's", () => { + const plan = new StrandsBedrockAgentTranslator( + snapshot({ + knowledgeBases: [], + actionGroups: [], + collaborators: [ + { + name: "billing", + instruction: "Handle billing.", + agent: snapshot({ + agentName: "BillingAgent", + collaborators: [], + knowledgeBases: [ + { id: "KB999", name: "Billing Docs", arn: "arn:aws:bedrock:eu-west-1:1:kb/KB999" }, + ], + }), + }, + ], + }), + { ...request, memory: "none" }, + ).translate(); + + const notes = plan.files["IMPORT_NOTES.md"]!; + expect(notes).toContain("knowledge-base IAM (collaborator 'BillingAgent')"); + expect(notes).toContain("arn:aws:bedrock:eu-west-1:1:kb/KB999"); + }); + + test("retrieves a knowledge base from its own region, not the agent's", () => { + const main = new StrandsBedrockAgentTranslator( + snapshot({ + knowledgeBases: [ + { + id: "KB123", + name: "Docs", + arn: "arn:aws:bedrock:eu-west-1:111122223333:knowledge-base/KB123", + }, + ], + }), + { ...request, memory: "none" }, + ).translate().files["main.py"]!; + + expect(main).toContain('region_name="eu-west-1"'); + }); + + test("depends on the code-interpreter package when only a collaborator uses it", () => { + const plan = new StrandsBedrockAgentTranslator( + snapshot({ + actionGroups: [], + collaborators: [ + { + name: "coder", + instruction: "Run code.", + agent: snapshot({ + agentName: "CoderAgent", + collaborators: [], + actionGroups: [ + { + name: "ci", + parentActionSignature: "AMAZON.CodeInterpreter", + functions: [], + hasApiSchema: false, + hasLambdaExecutor: false, + returnsControl: false, + }, + ], + }), + }, + ], + }), + { ...request, memory: "none" }, + ).translate(); + + expect(plan.files["main.py"]).not.toContain("AgentCoreCodeInterpreter"); + expect(plan.files["strands_collaborator_coder.py"]).toContain("AgentCoreCodeInterpreter"); + expect(plan.files["pyproject.toml"]).toContain("strands-agents-tools"); + }); +}); + +describe("LangGraphBedrockAgentTranslator", () => { + test("generates session-isolated LangGraph code with guardrails", () => { + const source = snapshot(); + const plan = new LangGraphBedrockAgentTranslator(source, { + ...request, + framework: "langgraph", + memory: "none", + }).translate(); + + expect(plan.files["main.py"]).toContain("from langgraph.prebuilt import create_react_agent"); + expect(plan.files["main.py"]).toContain('{"configurable": {"thread_id": session_id}}'); + expect(plan.files["main.py"]).not.toContain('"thread_id": "1"'); + expect(plan.files["main.py"]).toContain( + 'guardrails={"guardrailIdentifier":"GR123","guardrailVersion":"1"}', + ); + expect(plan.files["main.py"]).toContain("await asyncio.to_thread"); + expect(plan.files["IMPORT_NOTES.md"]).toContain("LangGraph code interpreter"); + // Compatible-release pinning, matching the repository's scaffolded templates. + expect(plan.files["pyproject.toml"]).toContain("langgraph ~= 1.0"); + expect(plan.files["pyproject.toml"]).not.toContain("strands-agents ~="); + expect(plan.files["pyproject.toml"]).not.toMatch(/[a-z-]+ >=/); + }); +}); diff --git a/src/core/project/bedrockAgentImport/types.ts b/src/core/project/bedrockAgentImport/types.ts new file mode 100644 index 000000000..ac41eef3a --- /dev/null +++ b/src/core/project/bedrockAgentImport/types.ts @@ -0,0 +1,123 @@ +import z from "zod"; + +export const BEDROCK_AGENT_IMPORT_REGIONS = [ + "ap-northeast-1", + "ap-northeast-2", + "ap-south-1", + "ap-southeast-1", + "ap-southeast-2", + "ca-central-1", + "eu-central-1", + "eu-central-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-gov-west-1", + "us-west-2", +] as const; + +export const BedrockAgentImportFrameworkSchema = z.enum(["strands", "langgraph"]); +export type BedrockAgentImportFramework = z.infer; + +export const BedrockAgentImportMemorySchema = z.enum(["none", "shortTerm", "longAndShortTerm"]); +export type BedrockAgentImportMemory = z.infer; + +export type BedrockAgentImportRequest = { + runtimeName: string; + region: string; + agentId: string; + agentAliasId: string; + framework: BedrockAgentImportFramework; + memory: BedrockAgentImportMemory; +}; + +export type ImportedInferenceConfiguration = { + temperature?: number; + topP?: number; + topK?: number; + maximumLength?: number; + stopSequences?: string[]; +}; + +export type ImportedFunctionParameter = { + type: string; + description?: string; + required: boolean; +}; + +export type ImportedFunction = { + name: string; + description?: string; + parameters: Record; +}; + +export type ImportedActionGroup = { + name: string; + description?: string; + parentActionSignature?: string; + functions: ImportedFunction[]; + hasApiSchema: boolean; + hasLambdaExecutor: boolean; + returnsControl: boolean; +}; + +export type ImportedKnowledgeBase = { + id: string; + name: string; + description?: string; + arn?: string; +}; + +export type ImportedGuardrail = { + identifier: string; + version: string; +}; + +export type ImportedCollaborator = { + name: string; + instruction: string; + relayConversationHistory?: string; + agent: BedrockAgentSnapshot; +}; + +export type BedrockAgentSnapshot = { + region: string; + sourceAgentId: string; + sourceAgentVersion: string; + agentName: string; + description?: string; + foundationModel: string; + instruction: string; + inferenceConfiguration?: ImportedInferenceConfiguration; + hasPromptOverrides: boolean; + guardrail?: ImportedGuardrail; + sourceMemoryEnabled: boolean; + actionGroups: ImportedActionGroup[]; + knowledgeBases: ImportedKnowledgeBase[]; + collaborators: ImportedCollaborator[]; + notes: BedrockAgentImportNote[]; +}; + +export const BedrockAgentImportNoteSchema = z.object({ + category: z.string().min(1), + message: z.string().min(1), +}); +export type BedrockAgentImportNote = z.infer; + +export const BedrockAgentImportPlanSchema = z.object({ + framework: BedrockAgentImportFrameworkSchema, + sourceAgentId: z.string().min(1), + sourceAgentAliasId: z.string().min(1), + sourceAgentVersion: z.string().min(1), + description: z.string().optional(), + files: z.record(z.string().min(1), z.string()), + notes: z.array(BedrockAgentImportNoteSchema), +}); +export type BedrockAgentImportPlan = z.infer; + +export interface CoreBedrockAgentImporter { + import(request: BedrockAgentImportRequest): Promise; +} diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index ed9ad0d8f..567fe8690 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -62,48 +62,30 @@ function buildResolverKey( return `${framework}/${language}/${protocol ?? "HTTP"}`; } -// The IAM policy file the proxy template vends; wired into the runtime's -// additionalPolicies so the execution role may call bedrock:InvokeAgent. -const BEDROCK_AGENT_POLICY_FILE = "bedrock-agent-policy.json"; +const importBedrockAgentResolver = () => async (input: RuntimeResourceConfig) => { + const imported = input.importBedrockAgent!; + if (input.protocol !== undefined && input.protocol !== "HTTP") + throw new InputValidationError("an imported Bedrock Agent only supports HTTP"); -const importBedrockAgentResolver = - (assetSource: AssetSource, templateRenderer: TemplateRenderer) => - async (input: RuntimeResourceConfig) => { - const imported = input.importBedrockAgent!; - if (input.protocol !== undefined && input.protocol !== "HTTP") - throw new InputValidationError("an imported Bedrock Agent proxy only supports HTTP"); - - const context = { - name: toPythonPackageName(input.name), - agentId: imported.agentId, - agentAliasId: imported.agentAliasId, - agentRegion: imported.region, - agentName: imported.agentName, - agentAliasArn: imported.agentAliasArn, - }; - const tree = await FsTreeNode.fromAssetSource( - { assetSource }, - { assetDir: "templates/bedrock-agent-proxy-python" }, - { - rootDirName: input.name, - transformContent: (raw) => templateRenderer.render(raw, context), - }, - ); + const tree = FsTreeNode.createDirectory( + input.name, + Object.entries(imported.files).map(([name, content]) => { + if (name.includes("/") || name === "." || name === "..") { + throw new InputValidationError(`unsafe imported file name: '${name}'`); + } + return FsTreeNode.createFile(name, async () => content); + }), + ); - const base = buildRuntimeSpec(input); - return { - tree, - spec: { - runtimes: [ - { - ...base, - protocol: "HTTP" as const, - additionalPolicies: [...(base.additionalPolicies ?? []), BEDROCK_AGENT_POLICY_FILE], - }, - ], - }, - }; + const memory = input.scaffoldRuntimeInput.memory; + return { + tree, + spec: { + runtimes: [{ ...buildRuntimeSpec(input), protocol: "HTTP" as const }], + ...(memory && { memories: [memory] }), + }, }; +}; const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: TemplateRenderer) => ({ [buildResolverKey("none", "Python", "HTTP")]: async (input: RuntimeResourceConfig) => { @@ -345,10 +327,10 @@ export function getRuntimeTemplateResolver( config: GetRuntimeTemplateResolverConfig, input: RuntimeResourceConfig, ): TemplateResolver | undefined { - // An imported Bedrock Agent always scaffolds the proxy template, regardless - // of the framework/language key. + // An imported Bedrock Agent carries a complete translated file plan, so it + // bypasses the normal framework/language template lookup. if (input.importBedrockAgent) { - return { resolve: importBedrockAgentResolver(config.assetSource, config.templateRenderer) }; + return { resolve: importBedrockAgentResolver() }; } const { framework, language, protocol } = input.scaffoldRuntimeInput; diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index d8def91ef..b0bcf0e42 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -10,6 +10,7 @@ import { testIO, } from "../../../../testing"; import { InputValidationError } from "../../../../errors"; +import type { BedrockAgentImportPlan } from "../../../../core/project/bedrockAgentImport"; const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -48,6 +49,34 @@ async function inProject(name = "TestProject"): Promise { return projectRoot; } +function translatedImportPlan( + overrides: Partial = {}, +): BedrockAgentImportPlan { + return { + framework: "strands", + sourceAgentId: "A1B2C3D4E5", + sourceAgentAliasId: "TSTALIASID", + sourceAgentVersion: "7", + files: { + "main.py": "from strands import Agent\n# translated agent", + "pyproject.toml": '[project]\nname = "support-proxy"\n', + "IMPORT_NOTES.md": + "# Bedrock Agent Import Notes\n\n" + + "- **knowledge-base IAM:** Grant the Runtime execution role bedrock:Retrieve on: " + + "arn:aws:bedrock:us-east-1:111122223333:knowledge-base/KB123.\n", + }, + notes: [ + { + category: "knowledge-base IAM", + message: + "Grant the Runtime execution role bedrock:Retrieve on: " + + "arn:aws:bedrock:us-east-1:111122223333:knowledge-base/KB123.", + }, + ], + ...overrides, + }; +} + describe("project add runtime", () => { const template = ["--template", "hello-world-python"]; @@ -757,15 +786,6 @@ describe("project add runtime", () => { }); describe("project add runtime --type import", () => { - const metadata = { - agentName: "SupportAgent", - agentStatus: "PREPARED", - agentAliasArn: "arn:aws:bedrock:us-east-1:111122223333:agent-alias/A1B2C3D4E5/TSTALIASID", - agentAliasName: "live", - agentAliasStatus: "PREPARED", - foundationModel: "us.amazon.nova-lite-v1:0", - }; - const importArgs = [ "add", "runtime", @@ -781,15 +801,22 @@ describe("project add runtime --type import", () => { "us-east-1", ]; - test("scaffolds a proxy runtime wrapping the described Bedrock Agent", async () => { + test("scaffolds owned runtime code translated from the selected agent version", async () => { const projectRoot = await inProject(); const core = new TestCoreClient(); - core.bedrockAgentDescriptions["A1B2C3D4E5/TSTALIASID"] = metadata; + core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); await run(importArgs, { core }); - expect(core.describedBedrockAgents).toEqual([ - { region: "us-east-1", agentId: "A1B2C3D4E5", agentAliasId: "TSTALIASID" }, + expect(core.importedBedrockAgents).toEqual([ + { + runtimeName: "support_proxy", + region: "us-east-1", + agentId: "A1B2C3D4E5", + agentAliasId: "TSTALIASID", + framework: "strands", + memory: "none", + }, ]); const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -800,37 +827,70 @@ describe("project add runtime --type import", () => { codeLocation: "app/support_proxy", runtimeVersion: "PYTHON_3_14", protocol: "HTTP", - additionalPolicies: ["bedrock-agent-policy.json"], }); + expect(spec.runtimes[0].additionalPolicies).toBeUndefined(); const appDir = join(projectRoot, "app", "support_proxy"); const main = await Bun.file(join(appDir, "main.py")).text(); - expect(main).toContain('"A1B2C3D4E5"'); - expect(main).toContain('"TSTALIASID"'); - expect(main).toContain('"us-east-1"'); - expect(main).toContain("invoke_agent"); - - const policy = await Bun.file(join(appDir, "bedrock-agent-policy.json")).json(); - expect(policy.Statement[0]).toMatchObject({ - Action: "bedrock:InvokeAgent", - Resource: metadata.agentAliasArn, - }); + expect(main).toContain("translated agent"); + expect(main).not.toContain("client.invoke_agent"); + + const notes = await Bun.file(join(appDir, "IMPORT_NOTES.md")).text(); + expect(notes).toContain("bedrock:Retrieve"); const pyproject = await Bun.file(join(appDir, "pyproject.toml")).text(); - expect(pyproject).toContain('name = "support_proxy"'); - expect(pyproject).toContain("boto3"); + expect(pyproject).toContain('name = "support-proxy"'); }); - test("warns when the agent is not PREPARED", async () => { + test("supports LangGraph translation and target memory", async () => { + const projectRoot = await inProject(); + const core = new TestCoreClient(); + core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan({ + framework: "langgraph", + }); + + await run([...importArgs, "--framework", "langgraph", "--memory", "longAndShortTerm"], { + core, + }); + + expect(core.importedBedrockAgents[0]).toMatchObject({ + framework: "langgraph", + memory: "longAndShortTerm", + }); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.memories[0]).toMatchObject({ + name: "support_proxyMemory", + strategies: expect.any(Array), + }); + }); + + test("rejects a non-HTTP protocol before importing the agent", async () => { await inProject(); const core = new TestCoreClient(); - core.bedrockAgentDescriptions["A1B2C3D4E5/TSTALIASID"] = { - ...metadata, - agentStatus: "NOT_PREPARED", - }; - const { io } = await run(importArgs, { core }); - expect(io.stderr()).toContain("not PREPARED"); + await expect(run([...importArgs, "--protocol", "MCP"], { core })).rejects.toThrow( + /only supports HTTP/, + ); + expect(core.importedBedrockAgents).toEqual([]); + }); + + test("documents required permissions instead of generating policies for a caller-owned role", async () => { + const projectRoot = await inProject(); + const core = new TestCoreClient(); + core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); + const roleArn = "arn:aws:iam::111122223333:role/ExistingRuntimeRole"; + + const { io } = await run([...importArgs, "--role-arn", roleArn], { core }); + + expect(io.stderr()).toContain("IMPORT_NOTES.md"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes[0]).toMatchObject({ executionRoleArn: roleArn }); + expect(spec.runtimes[0].additionalPolicies).toBeUndefined(); + + const appDir = join(projectRoot, "app", "support_proxy"); + const notes = await Bun.file(join(appDir, "IMPORT_NOTES.md")).text(); + expect(notes).toContain("bedrock:Retrieve"); + expect(await Bun.file(join(appDir, "bedrock-knowledge-base-policy.json")).exists()).toBe(false); }); test("rejects a nonexistent agent with the describe error", async () => { @@ -843,7 +903,7 @@ describe("project add runtime --type import", () => { const core = new TestCoreClient(); const args = [...importArgs.slice(0, -2), "--region", "eu-north-1"]; await expect(run(args, { core })).rejects.toThrow(/not a supported Bedrock Agent region/); - expect(core.describedBedrockAgents).toEqual([]); + expect(core.importedBedrockAgents).toEqual([]); }); test("requires --agent-id and --agent-alias-id with --type import", async () => { @@ -860,13 +920,16 @@ describe("project add runtime --type import", () => { ); }); - test("rejects scaffolding flags combined with --type import", async () => { + test("accepts translation flags and rejects incompatible scaffolding flags", async () => { await inProject(); - await expect(run([...importArgs, "--framework", "strands"])).rejects.toThrow( - /--framework is a scaffolding flag/, - ); + const core = new TestCoreClient(); + core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); + await expect(run([...importArgs, "--framework", "strands"], { core })).resolves.toBeDefined(); await expect(run([...importArgs, "--template", "hello-world-python"])).rejects.toThrow( - /--template is a scaffolding flag/, + /--template cannot be combined/, + ); + await expect(run([...importArgs, "--build", "Container"])).rejects.toThrow( + /--build cannot be combined/, ); }); }); diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 94176a02c..f3148e219 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -16,7 +16,6 @@ import { } from "../../shortcuts"; import { ScaffoldRuntimeInputSchema, type ScaffoldRuntimeInput } from "../../types"; import { RuntimeResourceConfigSchema, type ImportBedrockAgentInput } from "./types"; -import { describeBedrockAgent } from "../../../../core/project/bedrockAgent"; import { importScaffoldRuntimeInput, resolveImportBedrockAgentInput, @@ -32,7 +31,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flag("description", "an optional description of the runtime", z.string().optional()), flag( "type", - "create scaffolds new agent code (the default); import wraps an existing Bedrock Agent", + "create scaffolds new agent code (the default); import translates a Bedrock Agent version", z.enum(["create", "import"]).optional(), ), flag( @@ -42,7 +41,8 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => ), flag( "agent-alias-id", - "Bedrock Agent Alias ID to import (requires --type import)", + "Bedrock Agent Alias ID selecting the version to import; must point at a prepared " + + "version, not DRAFT (requires --type import)", z.string().optional(), ), flag( @@ -58,8 +58,8 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => ), flag( "framework", - "agent framework for the scaffolded runtime code", - z.enum(["strands", "none"]).optional(), + "agent framework: strands or none for create; strands or langgraph for import", + z.enum(["strands", "langgraph", "none"]).optional(), ), flag( "model-provider", @@ -149,16 +149,28 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => } const isImport = flags["type"] === "import"; - if (isImport && (isTemplate || presentScaffoldingFlags.length > 0)) { - const offending = isTemplate ? "template" : presentScaffoldingFlags[0]; + const importIncompatibleFlags = ( + ["build", "language", "model-provider", "api-key"] as const + ).filter((flagName) => flags[flagName] !== undefined); + if (isImport && (isTemplate || importIncompatibleFlags.length > 0)) { + const offending = isTemplate ? "template" : importIncompatibleFlags[0]; throw new InputValidationError( - `--type import wraps an existing Bedrock Agent; --${offending} is a scaffolding ` + - `flag and cannot be combined with it`, + `--type import translates a Bedrock Agent into Python CodeZip runtime code; ` + + `--${offending} cannot be combined with it`, ); } if (!isImport && (flags["agent-id"] !== undefined || flags["agent-alias-id"] !== undefined)) { throw new InputValidationError("--agent-id and --agent-alias-id require --type import"); } + if (isImport && flags.framework === "none") { + throw new InputValidationError("--type import supports --framework strands or langgraph"); + } + if (!isImport && flags.framework === "langgraph") { + throw new InputValidationError("--framework langgraph requires --type import"); + } + if (isImport && flags.protocol !== undefined && flags.protocol !== "HTTP") { + throw new InputValidationError("an imported Bedrock Agent only supports HTTP"); + } const isCustom = presentScaffoldingFlags.length > 0; @@ -166,22 +178,31 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => const apiKey = await source.resolveSecret("api-key", flags["api-key"]); const runtimeName = flags.name; + const importMemory = flags.memory ?? "none"; let importBedrockAgent: ImportBedrockAgentInput | undefined; if (isImport) { - const { imported, warnings } = await resolveImportBedrockAgentInput({ - describeBedrockAgent: config.describeBedrockAgent ?? describeBedrockAgent, + importBedrockAgent = await resolveImportBedrockAgentInput({ + importer: config.bedrockAgentImporter, + runtimeName, region: ctx.require(RegionKey), agentId: flags["agent-id"], agentAliasId: flags["agent-alias-id"], + framework: flags.framework === "langgraph" ? "langgraph" : "strands", + memory: importMemory, }); - importBedrockAgent = imported; - for (const warning of warnings) config.io.stderr.write(`${warning}\n`); + if (importBedrockAgent.notes.length > 0) { + config.io.stderr.write( + `Import generated ${importBedrockAgent.notes.length} manual follow-up ` + + `${importBedrockAgent.notes.length === 1 ? "item" : "items"} in ` + + `app/${runtimeName}/IMPORT_NOTES.md.\n`, + ); + } } const defaultMemory = flags.framework === "strands" ? "longAndShortTerm" : "none"; const scaffoldRuntimeInput: ScaffoldRuntimeInput = isImport - ? importScaffoldRuntimeInput(runtimeName) + ? importScaffoldRuntimeInput(runtimeName, MEMORY_SHORTCUTS[importMemory](runtimeName)) : isTemplate ? resolveRuntimeTemplateShortcut(flags.template!, { runtimeName: flags.name, @@ -195,7 +216,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => runtimeName, build: flags.build, language: flags.language, - framework: flags.framework, + framework: flags.framework === "langgraph" ? undefined : flags.framework, protocol: flags.protocol, modelProvider: flags["model-provider"], apiKey, diff --git a/src/handlers/project/add/runtime/types.ts b/src/handlers/project/add/runtime/types.ts index 286be2999..d87fb3be5 100644 --- a/src/handlers/project/add/runtime/types.ts +++ b/src/handlers/project/add/runtime/types.ts @@ -1,21 +1,9 @@ import z from "zod"; import { ProjectRuntimeSchema } from "../../../../projectSchemas/runtime"; import { ScaffoldRuntimeInputSchema } from "../../types"; -import { BEDROCK_AGENT_IMPORT_REGIONS } from "../../../../core/project/bedrockAgent"; +import { BedrockAgentImportPlanSchema } from "../../../../core/project/bedrockAgentImport"; -/** - * The imported Bedrock Agent a proxy runtime wraps: the caller-provided - * addressing plus the metadata captured from the describe calls. - */ -export const ImportBedrockAgentInputSchema = z.object({ - agentId: z.string().min(1), - agentAliasId: z.string().min(1), - region: z.enum(BEDROCK_AGENT_IMPORT_REGIONS), - agentName: z.string().min(1), - agentAliasArn: z.string().min(1), - foundationModel: z.string().optional(), - description: z.string().optional(), -}); +export const ImportBedrockAgentInputSchema = BedrockAgentImportPlanSchema; export type ImportBedrockAgentInput = z.infer; const RuntimeInfraConfigSchema = z.object({ @@ -37,7 +25,7 @@ const RuntimeInfraConfigSchema = z.object({ export const RuntimeResourceConfigSchema = RuntimeInfraConfigSchema.extend({ scaffoldRuntimeInput: ScaffoldRuntimeInputSchema, - /** Present when the runtime is a proxy for an imported Bedrock Agent. */ + /** Present when runtime files were translated from a Bedrock Agent version. */ importBedrockAgent: ImportBedrockAgentInputSchema.optional(), }); export type RuntimeResourceConfig = z.infer; diff --git a/src/handlers/project/add/types.ts b/src/handlers/project/add/types.ts index 9937d58d0..3201bf88e 100644 --- a/src/handlers/project/add/types.ts +++ b/src/handlers/project/add/types.ts @@ -1,10 +1,9 @@ import type { AppIO } from "../../../io"; -import type { DescribeBedrockAgent } from "../../../core/project/bedrockAgent"; +import type { CoreBedrockAgentImporter } from "../../../core/project/bedrockAgentImport"; import type { ProjectManager } from "../types"; export type AddProjectResourceConfig = { projectManager: ProjectManager; io: AppIO; - /** Describes a Bedrock Agent for --type import; injectable for tests. */ - describeBedrockAgent?: DescribeBedrockAgent; + bedrockAgentImporter: CoreBedrockAgentImporter; }; diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 28514f9d6..4277f34a1 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -25,10 +25,7 @@ import { import { InputValidationError } from "../../../errors"; import { parseJsonFlag } from "../../utils"; import { DEFAULT_HARNESS_MODEL } from "../add/harness"; -import { - describeBedrockAgent, - type DescribeBedrockAgent, -} from "../../../core/project/bedrockAgent"; +import type { CoreBedrockAgentImporter } from "../../../core/project/bedrockAgentImport"; import { importScaffoldRuntimeInput, resolveImportBedrockAgentInput } from "../importBedrockAgent"; import type { ImportBedrockAgentInput } from "../add/runtime/types"; import { RegionKey } from "../../keys"; @@ -36,8 +33,7 @@ import { RegionKey } from "../../keys"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; io: AppIO; - /** Describes a Bedrock Agent for --type import; injectable for tests. */ - describeBedrockAgent?: DescribeBedrockAgent; + bedrockAgentImporter: CoreBedrockAgentImporter; }; // Flags that select the runtime-scaffolding path. Any of these (or --template) @@ -110,8 +106,8 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = ), flag( "framework", - "agent framework for the scaffolded runtime code", - z.enum(["strands", "none"]).optional(), + "agent framework: strands or none for create; strands or langgraph for import", + z.enum(["strands", "langgraph", "none"]).optional(), ), flag( "protocol", @@ -137,7 +133,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = flag("runtime-name", "name of the scaffolded runtime", z.string().max(42).optional()), flag( "type", - "create scaffolds new agent code (the default); import wraps an existing Bedrock Agent", + "create scaffolds new agent code (the default); import translates a Bedrock Agent version", z.enum(["create", "import"]).optional(), ), flag( @@ -147,7 +143,8 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = ), flag( "agent-alias-id", - "Bedrock Agent Alias ID to import (requires --type import)", + "Bedrock Agent Alias ID selecting the version to import; must point at a prepared " + + "version, not DRAFT (requires --type import)", z.string().optional(), ), flag("model-id", "model ID for the created harness", z.string().optional()), @@ -230,40 +227,54 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = } const isImport = flags["type"] === "import"; - const scaffoldingChoiceFlags = ( - [ - "build", - "language", - "framework", - "protocol", - "model-provider", - "api-key", - "memory", - ] as const - ).filter((f) => flags[f] !== undefined); + const scaffoldingChoiceFlags = + // --framework and --memory are import inputs, not scaffolding choices, so they are + // validated below instead of rejected here. + (["build", "language", "model-provider", "api-key"] as const).filter( + (f) => flags[f] !== undefined, + ); if (isImport && (isTemplate || scaffoldingChoiceFlags.length > 0)) { const offending = isTemplate ? "template" : scaffoldingChoiceFlags[0]; throw new InputValidationError( - `--type import wraps an existing Bedrock Agent; --${offending} is a scaffolding ` + - `flag and cannot be combined with it`, + `--type import translates a Bedrock Agent into Python CodeZip runtime code; ` + + `--${offending} cannot be combined with it`, ); } if (!isImport && (flags["agent-id"] !== undefined || flags["agent-alias-id"] !== undefined)) { throw new InputValidationError("--agent-id and --agent-alias-id require --type import"); } + if (isImport && flags["framework"] === "none") { + throw new InputValidationError("--type import supports --framework strands or langgraph"); + } + if (!isImport && flags["framework"] === "langgraph") { + throw new InputValidationError("--framework langgraph requires --type import"); + } + if (isImport && flags["protocol"] !== undefined && flags["protocol"] !== "HTTP") { + throw new InputValidationError("an imported Bedrock Agent only supports HTTP"); + } const isRuntimePath = presentRuntimeFlags.length > 0; let importBedrockAgent: ImportBedrockAgentInput | undefined; + const runtimeName = flags["runtime-name"] ?? name; + const importMemory = flags["memory"] ?? "none"; if (isImport) { - const { imported, warnings } = await resolveImportBedrockAgentInput({ - describeBedrockAgent: config.describeBedrockAgent ?? describeBedrockAgent, + importBedrockAgent = await resolveImportBedrockAgentInput({ + importer: config.bedrockAgentImporter, + runtimeName, region: ctx.require(RegionKey), agentId: flags["agent-id"], agentAliasId: flags["agent-alias-id"], + framework: flags["framework"] === "langgraph" ? "langgraph" : "strands", + memory: importMemory, }); - importBedrockAgent = imported; - for (const warning of warnings) config.io.stderr.write(`${warning}\n`); + if (importBedrockAgent.notes.length > 0) { + config.io.stderr.write( + `Import generated ${importBedrockAgent.notes.length} manual follow-up ` + + `${importBedrockAgent.notes.length === 1 ? "item" : "items"} in ` + + `app/${runtimeName}/IMPORT_NOTES.md.\n`, + ); + } } const createInput: CreateProjectInput = isRuntimePath @@ -272,7 +283,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = skipInstall: flags["skip-install"], skipGit: flags["skip-git"], scaffoldRuntimeInput: isImport - ? importScaffoldRuntimeInput(flags["runtime-name"] ?? name) + ? importScaffoldRuntimeInput(runtimeName, MEMORY_SHORTCUTS[importMemory](runtimeName)) : await resolveScaffoldRuntimeInput(config, { ...flags, name }), importBedrockAgent, } @@ -303,7 +314,7 @@ type RuntimePathFlagValues = { template?: (typeof RUNTIME_TEMPLATE_SHORTCUT_NAMES)[number]; build?: "CodeZip" | "Container"; language?: "Python" | "TypeScript"; - framework?: "strands" | "none"; + framework?: "strands" | "langgraph" | "none"; protocol?: "HTTP" | "MCP" | "A2A"; "model-provider"?: ModelProviderFlag; "api-key"?: string; @@ -348,7 +359,7 @@ async function resolveScaffoldRuntimeInput( runtimeName, build: flags["build"], language: flags["language"], - framework: flags["framework"], + framework: flags["framework"] === "langgraph" ? undefined : flags["framework"], protocol: flags["protocol"], modelProvider, apiKey, diff --git a/src/handlers/project/importBedrockAgent.test.ts b/src/handlers/project/importBedrockAgent.test.ts new file mode 100644 index 000000000..2d32a49cf --- /dev/null +++ b/src/handlers/project/importBedrockAgent.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import type { + BedrockAgentImportPlan, + BedrockAgentImportRequest, + CoreBedrockAgentImporter, +} from "../../core/project/bedrockAgentImport"; +import { InputValidationError } from "../../errors"; +import { MEMORY_SHORTCUTS } from "./shortcuts"; +import { importScaffoldRuntimeInput, resolveImportBedrockAgentInput } from "./importBedrockAgent"; + +const plan: BedrockAgentImportPlan = { + framework: "strands", + sourceAgentId: "A1B2C3D4E5", + sourceAgentAliasId: "TSTALIASID", + sourceAgentVersion: "7", + files: { + "main.py": "app = object()", + "pyproject.toml": "[project]", + "IMPORT_NOTES.md": "# Notes\n", + }, + notes: [], +}; + +function importer(): { + importer: CoreBedrockAgentImporter; + calls: BedrockAgentImportRequest[]; +} { + const calls: BedrockAgentImportRequest[] = []; + return { + calls, + importer: { + import: async (input) => { + calls.push(input); + return plan; + }, + }, + }; +} + +describe("resolveImportBedrockAgentInput", () => { + test("forwards the alias-pinned translation request", async () => { + const subject = importer(); + + const result = await resolveImportBedrockAgentInput({ + importer: subject.importer, + runtimeName: "support", + region: "us-east-1", + agentId: "A1B2C3D4E5", + agentAliasId: "TSTALIASID", + framework: "strands", + memory: "longAndShortTerm", + }); + + expect(result).toBe(plan); + expect(subject.calls).toEqual([ + { + runtimeName: "support", + region: "us-east-1", + agentId: "A1B2C3D4E5", + agentAliasId: "TSTALIASID", + framework: "strands", + memory: "longAndShortTerm", + }, + ]); + }); + + test("requires both source identifiers before calling the importer", async () => { + const subject = importer(); + + await expect( + resolveImportBedrockAgentInput({ + importer: subject.importer, + runtimeName: "support", + region: "us-east-1", + agentId: "A1B2C3D4E5", + framework: "strands", + memory: "none", + }), + ).rejects.toBeInstanceOf(InputValidationError); + expect(subject.calls).toEqual([]); + }); +}); + +describe("importScaffoldRuntimeInput", () => { + test("uses the fixed Python CodeZip runtime shape and selected memory", () => { + const memory = MEMORY_SHORTCUTS.longAndShortTerm("support"); + + expect(importScaffoldRuntimeInput("support", memory)).toEqual({ + runtimeName: "support", + build: "CodeZip", + language: "Python", + framework: "none", + modelProvider: "Bedrock", + memory, + runtimeVersion: "PYTHON_3_14", + }); + }); +}); diff --git a/src/handlers/project/importBedrockAgent.ts b/src/handlers/project/importBedrockAgent.ts index 3759e9ef2..9fdcf6870 100644 --- a/src/handlers/project/importBedrockAgent.ts +++ b/src/handlers/project/importBedrockAgent.ts @@ -1,79 +1,57 @@ import { InputValidationError } from "../../errors"; -import { - BEDROCK_AGENT_IMPORT_REGIONS, - type DescribeBedrockAgent, -} from "../../core/project/bedrockAgent"; -import type { ImportBedrockAgentInput } from "./add/runtime/types"; +import type { + BedrockAgentImportFramework, + BedrockAgentImportMemory, + CoreBedrockAgentImporter, +} from "../../core/project/bedrockAgentImport"; +import type { Memory } from "../../projectSchemas/memory"; import type { ScaffoldRuntimeInput } from "./types"; /** - * The fixed scaffold shape of a Bedrock Agent proxy runtime: plain Python, - * CodeZip. The proxy template supplies the code; these values only shape the - * runtime spec entry. + * Imported Bedrock Agents become Python CodeZip runtimes. The translation plan + * supplies the code while this input supplies the normal project memory entry. */ -export function importScaffoldRuntimeInput(runtimeName: string): ScaffoldRuntimeInput { +export function importScaffoldRuntimeInput( + runtimeName: string, + memory?: Memory, +): ScaffoldRuntimeInput { return { runtimeName, build: "CodeZip", language: "Python", framework: "none", modelProvider: "Bedrock", + memory, runtimeVersion: "PYTHON_3_14", }; } export type ResolveImportInput = { - describeBedrockAgent: DescribeBedrockAgent; - /** The CLI's effective region (--region flag, env, shared config). */ + importer: CoreBedrockAgentImporter; + runtimeName: string; region: string; agentId?: string; agentAliasId?: string; + framework: BedrockAgentImportFramework; + memory: BedrockAgentImportMemory; }; /** - * Validates the import addressing, describes the agent and alias through the - * service, and returns the proxy scaffold's input plus any advisory warnings. + * Validates import addressing and resolves an alias-pinned translation plan. */ export async function resolveImportBedrockAgentInput( input: ResolveImportInput, -): Promise<{ imported: ImportBedrockAgentInput; warnings: string[] }> { +): Promise>> { if (!input.agentId || !input.agentAliasId) { throw new InputValidationError("--type import requires both --agent-id and --agent-alias-id"); } - const region = BEDROCK_AGENT_IMPORT_REGIONS.find((candidate) => candidate === input.region); - if (!region) { - throw new InputValidationError( - `'${input.region}' is not a supported Bedrock Agent region for import. ` + - `Supported regions: ${BEDROCK_AGENT_IMPORT_REGIONS.join(", ")}. ` + - `Pass --region to select the agent's region.`, - ); - } - - const metadata = await input.describeBedrockAgent({ - region, + return input.importer.import({ + runtimeName: input.runtimeName, + region: input.region, agentId: input.agentId, agentAliasId: input.agentAliasId, + framework: input.framework, + memory: input.memory, }); - - const warnings: string[] = []; - if (metadata.agentStatus !== "PREPARED") { - warnings.push( - `Warning: Bedrock Agent '${metadata.agentName}' is in status ${metadata.agentStatus} ` + - `(not PREPARED); invocations may fail until it is prepared.`, - ); - } - - return { - imported: { - agentId: input.agentId, - agentAliasId: input.agentAliasId, - region, - agentName: metadata.agentName, - agentAliasArn: metadata.agentAliasArn, - ...(metadata.foundationModel && { foundationModel: metadata.foundationModel }), - ...(metadata.description && { description: metadata.description }), - }, - warnings, - }; } diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 7547fe6d8..3aabbf426 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -26,7 +26,7 @@ type ProjectHandlerConfig = { export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router { const projectManager: ProjectManager = core.projectManager; - const config = { projectManager, io, describeBedrockAgent: core.describeBedrockAgent }; + const config = { projectManager, io, bedrockAgentImporter: core.bedrockAgentImporter }; const project = new Router("project", "manage an AgentCore project"); // Without a default, a bare `agentcore project` falls back to Commander's help @@ -41,7 +41,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router const createProject = createCreateProjectHandler({ projectManager, io, - describeBedrockAgent: core.describeBedrockAgent, + bedrockAgentImporter: core.bedrockAgentImporter, }); const createProjectWithWizard = withTuiOnEmptyFlagsAndArgs(core, io)(createProject); const isInteractive = () => io.stdin.isTTY === true && io.stdout.isTTY === true; diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 669723379..74c185ce1 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -11,6 +11,7 @@ import { testIO, } from "../../testing"; import { InputValidationError } from "../../errors"; +import type { BedrockAgentImportPlan } from "../../core/project/bedrockAgentImport"; async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) { const io = testIO({ stdin: opts?.stdin }); @@ -38,6 +39,21 @@ test("project dev requires an AgentCore project", async () => { const originalCwd = process.cwd(); const tempDirectories: string[] = []; +function translatedImportPlan(): BedrockAgentImportPlan { + return { + framework: "strands", + sourceAgentId: "A1B2C3D4E5", + sourceAgentAliasId: "TSTALIASID", + sourceAgentVersion: "7", + files: { + "main.py": "from strands import Agent\n# translated", + "pyproject.toml": '[project]\nname = "my-import"\n', + "IMPORT_NOTES.md": "# Bedrock Agent Import Notes\n", + }, + notes: [], + }; +} + async function inTempDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), "agentcore-project-")); tempDirectories.push(directory); @@ -259,16 +275,10 @@ describe("project create", () => { ]); }); - test("--type import scaffolds a Bedrock Agent proxy project", async () => { + test("--type import scaffolds a translated Bedrock Agent project", async () => { const directory = await inTempDirectory(); const core = new TestCoreClient(); - core.bedrockAgentDescriptions["A1B2C3D4E5/TSTALIASID"] = { - agentName: "SupportAgent", - agentStatus: "PREPARED", - agentAliasArn: "arn:aws:bedrock:us-east-1:111122223333:agent-alias/A1B2C3D4E5/TSTALIASID", - agentAliasName: "live", - agentAliasStatus: "PREPARED", - }; + core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); await run( [ @@ -294,20 +304,19 @@ describe("project create", () => { name: "MyImport", build: "CodeZip", runtimeVersion: "PYTHON_3_14", - additionalPolicies: ["bedrock-agent-policy.json"], }); const main = await Bun.file(join(projectRoot, "app", "MyImport", "main.py")).text(); - expect(main).toContain('"A1B2C3D4E5"'); - const policy = await Bun.file( - join(projectRoot, "app", "MyImport", "bedrock-agent-policy.json"), - ).json(); - expect(policy.Statement[0].Resource).toBe( - "arn:aws:bedrock:us-east-1:111122223333:agent-alias/A1B2C3D4E5/TSTALIASID", - ); + expect(main).toContain("# translated"); + expect(main).not.toContain("client.invoke_agent"); + expect(core.importedBedrockAgents[0]).toMatchObject({ + runtimeName: "MyImport", + framework: "strands", + memory: "none", + }); }); - test("--type import conflicts with harness-only and scaffolding flags", async () => { + test("--type import conflicts with harness-only and incompatible scaffolding flags", async () => { await inTempDirectory(); await expect( run(["create", "--name", "MyImport", "--type", "import", "--model-id", "x"]), @@ -323,10 +332,10 @@ describe("project create", () => { "A", "--agent-alias-id", "B", - "--framework", - "strands", + "--build", + "Container", ]), - ).rejects.toThrow(/--framework is a scaffolding flag/); + ).rejects.toThrow(/--build cannot be combined/); await expect(run(["create", "--name", "MyImport", "--agent-id", "A"])).rejects.toThrow( /--agent-id and --agent-alias-id require --type import/, ); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 59f97a578..91fc70546 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -85,7 +85,7 @@ export type CreateProjectInput = CreateProjectInputBase & | { /** The resolved template parameters. The handler maps --template to these before calling the manager. */ scaffoldRuntimeInput: ScaffoldRuntimeInput; - /** Present when the runtime proxies an imported Bedrock Agent. */ + /** Present when runtime files were translated from a Bedrock Agent version. */ importBedrockAgent?: ImportBedrockAgentInput; scaffoldHarnessInput?: undefined; } diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 570ccb1b0..fa2924169 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -7,7 +7,7 @@ import type { CoreObservabilityClient, CoreRuntimeClient } from "./runtime/types import type { Context } from "../router"; import type { CoreFetch } from "../core/types"; import type { ProjectManager } from "./project/types.ts"; -import type { DescribeBedrockAgent } from "../core/project/bedrockAgent"; +import type { CoreBedrockAgentImporter } from "../core/project/bedrockAgentImport"; export interface Core { harness: CoreHarnessClient; @@ -18,8 +18,8 @@ export interface Core { eval: CoreEvalClient; observability: CoreObservabilityClient; projectManager: ProjectManager; - /** Describes a Bedrock Agent + alias for `--type import`. */ - describeBedrockAgent: DescribeBedrockAgent; + /** Imports an alias-pinned Bedrock Agent definition into owned runtime code. */ + bedrockAgentImporter: CoreBedrockAgentImporter; /** Shared outbound HTTP for handlers that call non-AWS APIs directly (e.g. feedback → Aperture). */ fetch: CoreFetch; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 7cbe6b005..85cd484ef 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -184,10 +184,11 @@ import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; import { FsProjectManager, type ProjectBackend } from "../core/project"; import type { - BedrockAgentMetadata, - DescribeBedrockAgent, - DescribeBedrockAgentInput, -} from "../core/project/bedrockAgent"; + BedrockAgentImportPlan, + BedrockAgentImportRequest, + CoreBedrockAgentImporter, +} from "../core/project/bedrockAgentImport"; +import { BEDROCK_AGENT_IMPORT_REGIONS } from "../core/project/bedrockAgentImport"; import type { ManagedBy } from "../projectSchemas/project"; import { InputValidationError } from "../errors"; @@ -2395,20 +2396,25 @@ export class TestCoreClient implements Core { // recorded instead of spawned so tests stay fast and hermetic. readonly projectCommands: { command: string[]; cwd: string }[] = []; - // Seed with `agentId/agentAliasId` keys to make Bedrock Agents resolvable - // through describeBedrockAgent; unseeded ids reject like the service would. - readonly bedrockAgentDescriptions: Record = {}; - readonly describedBedrockAgents: DescribeBedrockAgentInput[] = []; - readonly describeBedrockAgent: DescribeBedrockAgent = async (input) => { - this.describedBedrockAgents.push(input); - const metadata = this.bedrockAgentDescriptions[`${input.agentId}/${input.agentAliasId}`]; - if (!metadata) { + // Seed with `agentId/agentAliasId` keys to make Bedrock Agent imports + // resolvable; unseeded ids reject like the service would. + readonly bedrockAgentImportPlans: Record = {}; + readonly importedBedrockAgents: BedrockAgentImportRequest[] = []; + readonly bedrockAgentImporter: CoreBedrockAgentImporter = { + import: async (input) => { + if (!(BEDROCK_AGENT_IMPORT_REGIONS as readonly string[]).includes(input.region)) { + throw new InputValidationError( + `'${input.region}' is not a supported Bedrock Agent region for import`, + ); + } + this.importedBedrockAgents.push(input); + const plan = this.bedrockAgentImportPlans[`${input.agentId}/${input.agentAliasId}`]; + if (plan) return plan; throw new InputValidationError( `no Bedrock Agent with id '${input.agentId}' exists in ${input.region}; ` + `check --agent-id and --region`, ); - } - return metadata; + }, }; constructor(options?: TestCoreClientOptions) {