From 8570ab288572d1df0902b891b022e087c4eeb707 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 31 Aug 2026 21:43:01 +0000 Subject: [PATCH 01/19] feat(core): add cached EC2 client support --- bun.lock | 1 + package.json | 1 + src/core/core.test.ts | 25 +++++++++++++++++++++++++ src/core/datasetDownload.test.ts | 8 +++++++- src/core/datasetUpdate.test.ts | 1 + src/core/factories.tsx | 4 ++++ src/core/gateway.test.ts | 3 +++ src/core/index.tsx | 18 ++++++++++++++++++ src/core/types.tsx | 3 +++ src/index.ts | 2 ++ 10 files changed, 65 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index a27d2b9f5..766fc6b31 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", + "@aws-sdk/client-ec2": "^3.1121.0", "@aws-sdk/client-iam": "^3.1080.0", "@aws-sdk/client-sts": "^3.1092.0", "@aws/agent-inspector": "0.6.1", diff --git a/package.json b/package.json index 1232e5277..9737c9e81 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", + "@aws-sdk/client-ec2": "^3.1121.0", "@aws-sdk/client-iam": "^3.1080.0", "@aws-sdk/client-sts": "^3.1092.0", "@aws/agent-inspector": "0.6.1", diff --git a/src/core/core.test.ts b/src/core/core.test.ts index e8390a470..e75b63532 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -5,6 +5,7 @@ import { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { IAMClient } from "@aws-sdk/client-iam"; import type { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; +import type { EC2Client } from "@aws-sdk/client-ec2"; import { GetEventCommand, GetMemoryRecordCommand, @@ -80,6 +81,9 @@ function fakeIam(config: ClientConfig): IAMClient { function fakeLogs(config: ClientConfig): CloudWatchLogsClient { return { config, kind: "logs" } as unknown as CloudWatchLogsClient; } +function fakeEc2(config: ClientConfig): EC2Client { + return { config, kind: "ec2" } as unknown as EC2Client; +} function coreWithDataSend( send: (command: unknown, options: unknown) => Promise, @@ -198,6 +202,27 @@ test("data() caches independently of control()", () => { expect(dataBuilt).toBe(1); }); +test("ec2() constructs a client once per config and caches it", () => { + let built = 0; + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: fakeData, + createEc2Client: (config) => { + built++; + return fakeEc2(config); + }, + createIamClient: fakeIam, + createLogsClient: fakeLogs, + logger: createSilentLogger(), + }); + + const first = core.ec2({ region: "us-east-1" }); + const second = core.ec2({ region: "us-east-1" }); + + expect(first).toBe(second); + expect(built).toBe(1); +}); + test("exposes feature sub-clients", () => { const core = new CoreClient({ createControlClient: fakeControl, diff --git a/src/core/datasetDownload.test.ts b/src/core/datasetDownload.test.ts index 790d9a22c..ed7ea3473 100644 --- a/src/core/datasetDownload.test.ts +++ b/src/core/datasetDownload.test.ts @@ -32,7 +32,13 @@ function stubClients(dataset: Record): AwsClients { throw new Error(`unexpected command: ${(command as object).constructor.name}`); }; const client = { send } as never; - return { control: () => client, data: () => client, iam: () => client, logs: () => client }; + return { + control: () => client, + data: () => client, + ec2: () => client, + iam: () => client, + logs: () => client, + }; } describe("EvalClient.downloadDataset", () => { diff --git a/src/core/datasetUpdate.test.ts b/src/core/datasetUpdate.test.ts index ca363c499..d7181962b 100644 --- a/src/core/datasetUpdate.test.ts +++ b/src/core/datasetUpdate.test.ts @@ -84,6 +84,7 @@ function stubClients(options: { return { control: () => client, data: () => client, + ec2: () => client, iam: () => client, logs: () => client, }; diff --git a/src/core/factories.tsx b/src/core/factories.tsx index 209b8da1c..05e8421a7 100644 --- a/src/core/factories.tsx +++ b/src/core/factories.tsx @@ -3,10 +3,12 @@ import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { IAMClient } from "@aws-sdk/client-iam"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; +import { EC2Client } from "@aws-sdk/client-ec2"; import type { CreateCloudFormationClient, CreateControlClient, CreateDataClient, + CreateEc2Client, CreateIamClient, CreateLogsClient, } from "./types"; @@ -27,5 +29,7 @@ export const createIamClient: CreateIamClient = (config) => new IAMClient({ ...c export const createLogsClient: CreateLogsClient = (config) => new CloudWatchLogsClient({ ...config }); +export const createEc2Client: CreateEc2Client = (config) => new EC2Client({ ...config }); + export const createCloudFormationClient: CreateCloudFormationClient = (config) => new CloudFormationClient({ ...config }); diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 5072bd745..f36b87f89 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -207,6 +207,9 @@ function recordingGatewayClient(responses: unknown[]): { data: () => { throw new Error("unexpected data client"); }, + ec2: () => { + throw new Error("unexpected EC2 client"); + }, iam: () => { throw new Error("unexpected IAM client"); }, diff --git a/src/core/index.tsx b/src/core/index.tsx index 5a6bab9fb..def3de365 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -2,6 +2,7 @@ import { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { IAMClient } from "@aws-sdk/client-iam"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; +import { EC2Client } from "@aws-sdk/client-ec2"; import { EvalClient } from "./eval"; import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; @@ -17,6 +18,7 @@ import type { CreateCloudFormationClient, CreateControlClient, CreateDataClient, + CreateEc2Client, CreateIamClient, CreateLogsClient, } from "./types"; @@ -24,6 +26,7 @@ import type { Logger } from "../logging"; import type { ProjectManager } from "../handlers/project/types"; import { FsProjectManager } from "./project"; import { describeBedrockAgent, type DescribeBedrockAgent } from "./project/bedrockAgent"; +import { createEc2Client as defaultCreateEc2Client } from "./factories"; export type { AwsClients, @@ -32,6 +35,7 @@ export type { CreateControlClient, CreateCloudFormationClient, CreateDataClient, + CreateEc2Client, CreateIamClient, CreateLogsClient, } from "./types"; @@ -40,6 +44,7 @@ type CoreClientConfig = { createCloudFormationClient?: CreateCloudFormationClient; createControlClient: CreateControlClient; createDataClient: CreateDataClient; + createEc2Client?: CreateEc2Client; createIamClient: CreateIamClient; createLogsClient: CreateLogsClient; logger: Logger; @@ -56,11 +61,13 @@ type CoreClientConfig = { export class CoreClient implements AwsClients { private controlClients = new Map(); private dataClients = new Map(); + private ec2Clients = new Map(); private iamClients = new Map(); private logsClients = new Map(); private readonly createControlClient: CreateControlClient; private readonly createDataClient: CreateDataClient; + private readonly createEc2Client: CreateEc2Client; private readonly createIamClient: CreateIamClient; private readonly createLogsClient: CreateLogsClient; private logger: Logger; @@ -80,6 +87,7 @@ export class CoreClient implements AwsClients { constructor(config: CoreClientConfig) { this.createControlClient = config.createControlClient; this.createDataClient = config.createDataClient; + this.createEc2Client = config.createEc2Client ?? defaultCreateEc2Client; this.createIamClient = config.createIamClient; this.createLogsClient = config.createLogsClient; this.logger = config.logger; @@ -137,6 +145,16 @@ export class CoreClient implements AwsClients { return client; } + ec2(config: ClientConfig): EC2Client { + const key = cacheKey(config); + let client = this.ec2Clients.get(key); + if (!client) { + client = this.createEc2Client(config); + this.ec2Clients.set(key, client); + } + return client; + } + // iam returns the IAM client for `config`, creating and caching it on first // use (used to provision default execution roles). iam(config: ClientConfig): IAMClient { diff --git a/src/core/types.tsx b/src/core/types.tsx index 9e36a7c17..aa39697a9 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -2,6 +2,7 @@ import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agen import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import type { IAMClient } from "@aws-sdk/client-iam"; import type { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; +import type { EC2Client } from "@aws-sdk/client-ec2"; import type { CloudFormationClient, CloudFormationClientConfig, @@ -36,6 +37,7 @@ export type CreateControlClient = (config: ClientConfig) => BedrockAgentCoreCont export type CreateDataClient = (config: ClientConfig) => BedrockAgentCoreClient; export type CreateIamClient = (config: ClientConfig) => IAMClient; export type CreateLogsClient = (config: ClientConfig) => CloudWatchLogsClient; +export type CreateEc2Client = (config: ClientConfig) => EC2Client; export type CreateCloudFormationClient = (config: CredentialedClientConfig) => CloudFormationClient; export type CoreFetch = ( ...args: Parameters @@ -54,4 +56,5 @@ export interface AwsClients { // results to. CloudWatch is a distinct service from the AgentCore data plane, // so it gets its own client/factory rather than reusing `data`. logs(config: ClientConfig): CloudWatchLogsClient; + ec2(config: ClientConfig): EC2Client; } diff --git a/src/index.ts b/src/index.ts index c46290b6d..9df2c875e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { createCloudFormationClient, createControlClient, createDataClient, + createEc2Client, createIamClient, createLogsClient, } from "./core/factories"; @@ -70,6 +71,7 @@ process.exit( createCloudFormationClient, createControlClient, createDataClient, + createEc2Client, createIamClient, createLogsClient, logger: rootLogger.child({ module: "core" }), From a65aeca93a7bc3bd6b6020cd06274525bd627990 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 31 Aug 2026 21:43:37 +0000 Subject: [PATCH 02/19] fix(export): preserve harness configuration fidelity --- src/core/project/templates/export.test.ts | 123 +++++++++++++++++++--- src/core/project/templates/export.ts | 88 +++++++++++++--- src/projectSchemas/harness.test.ts | 15 +++ src/projectSchemas/harness.ts | 7 -- 4 files changed, 196 insertions(+), 37 deletions(-) diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index 543a7f141..c9d998d28 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -3,6 +3,7 @@ import z from "zod"; import { InputValidationError } from "../../../errors/errors"; import { HarnessSpecSchema, type HarnessSpec } from "../../../projectSchemas/harness"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { credentialEnvVarName } from "../../../projectSchemas/credential"; import { ALLOWED_TOOLS_NOTE_CATEGORY, AWS_SKILLS_NOTE_CATEGORY, @@ -17,6 +18,7 @@ import { MCP_HEADER_CREDS_NOTE_CATEGORY, MEMORY_ARN_NOTE_CATEGORY, MEMORY_MANAGED_NOTE_CATEGORY, + MEMORY_MESSAGES_COUNT_NOTE_CATEGORY, MEMORY_NAME_NOT_FOUND_NOTE_CATEGORY, MISSING_DOCKERFILE_NOTE_CATEGORY, MODEL_API_KEY_NOTE_CATEGORY, @@ -83,7 +85,7 @@ describe("mapHarnessToExportPlan model mapping", () => { expect(result.context.modelTopP).toBe("0.9"); expect(result.context.modelMaxTokens).toBe("512"); expect(result.context.bedrockMantle).toBeUndefined(); - expect(result.hasExecutionLimits).toBe(true); + expect(result.context.hasExecutionLimits).toBe(true); expect(result.context.maxIterations).toBe(5); expect(result.context.maxTokens).toBe(2048); expect(result.context.timeoutSeconds).toBe(60); @@ -120,6 +122,11 @@ describe("mapHarnessToExportPlan model mapping", () => { model: { provider: "open_ai", modelId: "gpt-4.1", + apiFormat: "responses", + maxTokens: 768, + temperature: 0.2, + topP: 0.8, + additionalParams: { store: false }, apiKeyArn: "arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/MyOpenAiKey", }, @@ -127,6 +134,12 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("OpenAI"); + expect(result.context.strandsExtras).toBe("openai"); + expect(result.context.modelApiFormat).toBe("responses"); + expect(result.context.modelMaxTokens).toBe("768"); + expect(result.context.modelTemperature).toBe("0.2"); + expect(result.context.modelTopP).toBe("0.8"); + expect(result.context.modelAdditionalParams).toEqual({ store: false }); expect(result.context.hasIdentity).toBe(true); expect(result.context.identityProviders).toEqual([ { name: "MyOpenAiKey", envVarName: "AGENTCORE_CREDENTIAL_MYOPENAIKEY" }, @@ -153,6 +166,7 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("Gemini"); + expect(result.context.strandsExtras).toBe("gemini"); expect(result.credentials).toEqual([]); }); @@ -163,14 +177,21 @@ describe("mapHarnessToExportPlan model mapping", () => { provider: "lite_llm", modelId: "bedrock/us.amazon.nova-lite-v1:0", apiBase: "https://litellm.example", + maxTokens: 300, + temperature: 0.1, + topP: 0.7, additionalParams: { max_retries: 2 }, }, }), }); expect(result.context.modelProvider).toBe("LiteLLM"); + expect(result.context.strandsExtras).toBe("litellm"); expect(result.context.litellmApiBase).toBe("https://litellm.example"); - expect(result.context.litellmAdditionalParams).toEqual({ max_retries: 2 }); + expect(result.context.modelAdditionalParams).toEqual({ max_retries: 2 }); + expect(result.context.modelMaxTokens).toBe("300"); + expect(result.context.modelTemperature).toBe("0.1"); + expect(result.context.modelTopP).toBe("0.7"); expect(result.notes).toEqual([]); }); @@ -207,7 +228,12 @@ describe("mapHarnessToExportPlan tools", () => { }); expect(result.context.remoteMcpTools).toEqual([ - { name: "exa", url: "https://mcp.exa.ai/mcp", headerCredentials: undefined }, + { + name: "exa", + pythonName: expect.stringMatching(/^exa_[a-f0-9]{10}$/), + url: "https://mcp.exa.ai/mcp", + headerCredentials: undefined, + }, ]); expect(result.context.inlineFunctionTools).toEqual([ { @@ -238,26 +264,58 @@ describe("mapHarnessToExportPlan tools", () => { }); const tools = result.context.remoteMcpTools as { - headerCredentials?: { headerKey: string; credentialName: string; envVarName: string }[]; + headerCredentials?: { + headerKey: string; + credentialName: string; + envVarName: string; + pythonName: string; + }[]; }[]; - expect(tools[0]!.headerCredentials).toEqual([ - { - headerKey: "X-Api-Key", - credentialName: "ordersMcpinternalXApiKey", - envVarName: "AGENTCORE_CREDENTIAL_ORDERSMCPINTERNALXAPIKEY", - }, - ]); + const header = tools[0]!.headerCredentials![0]!; + expect(header.headerKey).toBe("X-Api-Key"); + expect(header.credentialName).toMatch(/^ordersMcpinternalX-Api-Key-[a-f0-9]{10}$/); + expect(header.envVarName).toBe(credentialEnvVarName(header.credentialName)); + expect(header.pythonName).toMatch(/^internal_x_api_key_[a-f0-9]{10}$/); expect(result.credentials).toEqual([ - { authorizerType: "ApiKeyCredentialProvider", name: "ordersMcpinternalXApiKey" }, + { authorizerType: "ApiKeyCredentialProvider", name: header.credentialName }, ]); expect(result.envEntries).toEqual([ { - key: "AGENTCORE_CREDENTIAL_ORDERSMCPINTERNALXAPIKEY", + key: header.envVarName, value: "s3cret", comment: '"X-Api-Key" header for MCP tool "internal" (exported from harness "assistant")', }, ]); expect(categories(result)).toEqual([MCP_HEADER_CREDS_NOTE_CATEGORY]); + expect(result.notes[0]!.message).toContain("exists in AgentCore Identity"); + }); + + test("keeps normalized header names distinct", () => { + const result = plan({ + spec: harness({ + tools: [ + { + type: "remote_mcp", + name: "internal", + config: { + remoteMcp: { + url: "https://mcp.internal.example", + headers: { "X-Api-Key": "first", X_Api_Key: "second" }, + }, + }, + }, + ], + }), + }); + + const names = result.credentials.map((credential) => credential.name); + expect(names).toHaveLength(2); + expect(new Set(names).size).toBe(2); + expect(new Set(result.envEntries.map((entry) => entry.key)).size).toBe(2); + const tools = result.context.remoteMcpTools as { + headerCredentials: { pythonName: string }[]; + }[]; + expect(new Set(tools[0]!.headerCredentials.map(({ pythonName }) => pythonName)).size).toBe(2); }); test("emits a follow-up note for each unmappable tool type instead of code", () => { @@ -316,7 +374,12 @@ describe("mapHarnessToExportPlan tools", () => { expect(restricted.context.hasShell).toBe(true); expect(restricted.context.hasFileOperations).toBe(false); expect(restricted.context.remoteMcpTools).toEqual([ - { name: "exa", url: "https://mcp.exa.ai/mcp", headerCredentials: undefined }, + { + name: "exa", + pythonName: expect.stringMatching(/^exa_[a-f0-9]{10}$/), + url: "https://mcp.exa.ai/mcp", + headerCredentials: undefined, + }, ]); expect(categories(restricted)).toEqual([ALLOWED_TOOLS_NOTE_CATEGORY]); }); @@ -355,6 +418,28 @@ describe("mapHarnessToExportPlan memory", () => { expect(result.notes).toEqual([]); }); + test("preserves retrieval tuning and notes an unmappable messagesCount", () => { + const result = plan({ + spec: harness({ + memory: { + mode: "existing", + name: "chat_history", + messagesCount: 12, + retrievalConfig: { topK: 7, relevanceScore: 0 }, + }, + }), + projectSpec: projectSpec({ + memories: [ + { name: "chat_history", eventExpiryDuration: 30, strategies: [{ type: "SEMANTIC" }] }, + ], + }), + }); + + expect(result.context.memoryRetrievalTopK).toBe("7"); + expect(result.context.memoryRetrievalRelevanceScore).toBe("0"); + expect(categories(result)).toEqual([MEMORY_MESSAGES_COUNT_NOTE_CATEGORY]); + }); + test("notes a by-name memory that is not in the project", () => { const result = plan({ spec: harness({ memory: { mode: "existing", name: "missing" } }), @@ -612,15 +697,21 @@ describe("mapHarnessToExportPlan runtime spec entry", () => { }); describe("export notes rendering", () => { + test("keeps notes collected while mapping a service harness", () => { + const sourceNote = { category: "Service field", message: "Review it." }; + const result = plan({ sourceNotes: [sourceNote] }); + expect(result.notes).toContainEqual(sourceNote); + }); + test("buildExportNotesMarkdown lists each note under its category", () => { const markdown = buildExportNotesMarkdown( [{ category: "A category", message: "Do the thing." }], "assistant", "assistantAgent", - "strands-agents ~= 1.15.0", + "strands-agents ~= 1.54.0", ); expect(markdown).toContain("# Export Notes — assistant → assistantAgent"); - expect(markdown).toContain("Strands version: strands-agents ~= 1.15.0"); + expect(markdown).toContain("Strands version: strands-agents ~= 1.54.0"); expect(markdown).toContain("## Items requiring manual follow-up"); expect(markdown).toContain("### A category"); expect(markdown).toContain("Do the thing."); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index 5cc289010..ee601dc9f 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import type { z } from "zod"; import type { BuildType, ProjectRuntime } from "../../../projectSchemas/runtime"; import type { HarnessMemoryRef, + HarnessMemoryRetrievalConfig, HarnessSkill, HarnessSkillGitSource, HarnessSkillPathSource, @@ -47,6 +49,8 @@ export interface HarnessExportInput { projectSpec: ProjectSpec; /** Build override from --build; when absent the harness spec decides. */ build?: BuildType; + /** Notes collected while converting a service response into a local harness spec. */ + sourceNotes?: ExportNote[]; /** * Whether the harness directory holds the Dockerfile that `spec.dockerfile` * names (local harnesses only; the caller checks the filesystem). @@ -79,8 +83,6 @@ export interface HarnessExportPlan { policyFiles: Record; /** Whether the render includes the memory/ module. */ hasMemory: boolean; - /** Whether the render includes hooks/execution_limits.py. */ - hasExecutionLimits: boolean; buildType: BuildType; dockerfilePlan: DockerfilePlan; notes: ExportNote[]; @@ -98,6 +100,8 @@ export const CODE_INTERPRETER_TOOL_NOTE_CATEGORY = export const MEMORY_ARN_NOTE_CATEGORY = "External memory reference not exported"; export const MEMORY_MANAGED_NOTE_CATEGORY = "Managed harness memory not exported"; export const MEMORY_NAME_NOT_FOUND_NOTE_CATEGORY = "Memory reference could not be resolved"; +export const MEMORY_MESSAGES_COUNT_NOTE_CATEGORY = + "Memory messagesCount is not directly portable to Strands"; export const PATH_SKILLS_NOTE_CATEGORY = "path skills require container filesystem"; export const GIT_SKILLS_CONTAINER_NOTE_CATEGORY = "git skills require git in container image"; export const GIT_SKILLS_AUTH_NOTE_CATEGORY = "git skill credential provider referenced"; @@ -119,7 +123,7 @@ export const MISSING_DOCKERFILE_NOTE_CATEGORY = "Dockerfile not found — create export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExportPlan { const { spec, targetAgentName, projectSpec } = input; - const notes: ExportNote[] = []; + const notes: ExportNote[] = [...(input.sourceNotes ?? [])]; const credentials: Credential[] = []; const envEntries: EnvLocalEntry[] = []; const policyFiles: Record = {}; @@ -198,6 +202,12 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport hasMemory: memory.provider !== undefined, memoryEnvVarName: memory.provider?.envVarName, memoryStrategies: memory.provider?.strategies ?? [], + memoryRetrievalTopK: + memory.retrievalConfig?.topK !== undefined ? String(memory.retrievalConfig.topK) : undefined, + memoryRetrievalRelevanceScore: + memory.retrievalConfig?.relevanceScore !== undefined + ? String(memory.retrievalConfig.relevanceScore) + : undefined, actorId: memory.actorId, // Gateways are never exported as code (see resolveTools); the template still // needs the keys so its conditionals resolve. @@ -269,7 +279,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport envEntries, policyFiles, hasMemory: memory.provider !== undefined, - hasExecutionLimits, buildType, dockerfilePlan, notes, @@ -310,10 +319,13 @@ function resolveModel( const model = spec.model; const context: Record = { modelId: model.modelId, + modelApiFormat: model.apiFormat, + modelAdditionalParams: model.additionalParams, // Stringified so a legal 0 (temperature/topP) stays truthy for {{#if}}. modelMaxTokens: model.maxTokens !== undefined ? String(model.maxTokens) : undefined, modelTemperature: model.temperature !== undefined ? String(model.temperature) : undefined, modelTopP: model.topP !== undefined ? String(model.topP) : undefined, + modelTopK: model.topK !== undefined ? String(model.topK) : undefined, hasIdentity: false, identityProviders: [] as { name: string; envVarName: string }[], }; @@ -323,6 +335,7 @@ function resolveModel( context.modelProvider = "Bedrock"; if (isBedrockMantleModel(spec)) { context.bedrockMantle = true; + context.strandsExtras = "openai"; context.mantleApiFormat = model.apiFormat; context.mantleProprietary = isProprietaryOpenAiModel(model.modelId); // Mantle is invoked via the bedrock-mantle service, not bedrock:InvokeModel, @@ -354,6 +367,7 @@ function resolveModel( case "open_ai": case "gemini": { context.modelProvider = model.provider === "open_ai" ? "OpenAI" : "Gemini"; + context.strandsExtras = model.provider === "open_ai" ? "openai" : "gemini"; // The schema guarantees apiKeyArn for these providers. attachIdentityProvider( context, @@ -367,10 +381,8 @@ function resolveModel( } case "lite_llm": { context.modelProvider = "LiteLLM"; + context.strandsExtras = "litellm"; if (model.apiBase) context.litellmApiBase = model.apiBase; - if (model.additionalParams && Object.keys(model.additionalParams).length > 0) { - context.litellmAdditionalParams = model.additionalParams; - } if (model.apiKeyArn) { attachIdentityProvider( context, @@ -440,6 +452,7 @@ function attachIdentityProvider( interface MemoryResolution { provider?: { name: string; envVarName: string; strategies: string[] }; actorId?: string; + retrievalConfig?: HarnessMemoryRetrievalConfig; } function resolveMemory( @@ -474,6 +487,16 @@ function resolveMemory( }); return { actorId: memory.actorId }; } + if (memory.messagesCount !== undefined) { + notes.push({ + category: MEMORY_MESSAGES_COUNT_NOTE_CATEGORY, + message: + `The harness restored at most ${memory.messagesCount} short-term memory messages. ` + + "AgentCoreMemorySessionManager restores the available session history and does not expose " + + "an equivalent message-count setting; use conversation truncation or customize " + + "memory/session.py if the exact restore limit is required.", + }); + } return { provider: { name: entry.name, @@ -482,6 +505,7 @@ function resolveMemory( strategies: entry.strategies.map(({ type }) => type), }, actorId: memory.actorId, + retrievalConfig: memory.retrievalConfig, }; } @@ -511,8 +535,14 @@ interface ToolsResolution { }[]; remoteMcpTools: { name: string; + pythonName: string; url: string; - headerCredentials?: { headerKey: string; credentialName: string; envVarName: string }[]; + headerCredentials?: { + headerKey: string; + credentialName: string; + envVarName: string; + pythonName: string; + }[]; }[]; hasShell: boolean; hasFileOperations: boolean; @@ -557,13 +587,18 @@ function resolveTools( if (!cfg) break; const headerKeys = Object.keys(cfg.headers ?? {}); let headerCredentials: ToolsResolution["remoteMcpTools"][number]["headerCredentials"]; + const toolPythonName = stablePythonIdentifier(tool.name); if (headerKeys.length > 0) { headerCredentials = []; - const toolPrefix = tool.name.replace(/[^A-Za-z0-9]/g, ""); for (const headerKey of headerKeys) { - const credentialName = `${projectSpec.name}Mcp${toolPrefix}${headerKey.replace(/[^A-Za-z0-9]/g, "")}`; + const credentialName = remoteMcpCredentialName(projectSpec.name, tool.name, headerKey); const envVarName = credentialEnvVarName(credentialName); - headerCredentials.push({ headerKey, credentialName, envVarName }); + headerCredentials.push({ + headerKey, + credentialName, + envVarName, + pythonName: stablePythonIdentifier(`${tool.name}-${headerKey}`), + }); if ( !projectSpec.credentials.some((c) => c.name === credentialName) && !credentials.some((c) => c.name === credentialName) @@ -584,14 +619,20 @@ function resolveTools( message: `MCP tool "${tool.name}" sends request headers whose values are managed via ` + `AgentCore Identity. Credential entries were added to agentcore.json and the header ` + - `values written to agentcore/.env.local; they are provisioned on ` + - `\`agentcore project deploy\`.\n\n` + + `values written to agentcore/.env.local. Ensure each named API-key credential provider ` + + `exists in AgentCore Identity before invoking the exported runtime; deployment wires ` + + `the provider references and runtime permissions.\n\n` + headerCredentials .map((h) => ` ${h.credentialName} (env var: ${h.envVarName})`) .join("\n"), }); } - result.remoteMcpTools.push({ name: tool.name, url: cfg.url, headerCredentials }); + result.remoteMcpTools.push({ + name: tool.name, + pythonName: toolPythonName, + url: cfg.url, + headerCredentials, + }); break; } case "agentcore_gateway": { @@ -645,6 +686,25 @@ function configOf(tool: HarnessTool, key: string): unknown { return (tool.config as Record)[key]; } +function stablePythonIdentifier(value: string): string { + const readable = + value + .replace(/[^a-zA-Z0-9]/g, "_") + .toLowerCase() + .slice(0, 48) || "value"; + return `${readable}_${shortHash(value)}`; +} + +function remoteMcpCredentialName(projectName: string, toolName: string, headerKey: string): string { + const readable = `${projectName}Mcp${toolName}${headerKey}`.replace(/[^a-zA-Z0-9_-]/g, ""); + const suffix = `-${shortHash(`${toolName}\0${headerKey}`)}`; + return `${readable.slice(0, 128 - suffix.length)}${suffix}`; +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 10); +} + // ============================================================================ // Skills // ============================================================================ diff --git a/src/projectSchemas/harness.test.ts b/src/projectSchemas/harness.test.ts index 66b7050f7..16bcf2b23 100644 --- a/src/projectSchemas/harness.test.ts +++ b/src/projectSchemas/harness.test.ts @@ -41,6 +41,21 @@ describe("harness custom validation", () => { }).success, ).toBe(false); }); + it("accepts provider-specific additional parameters for every harness model", () => { + for (const model of [ + { provider: "bedrock", modelId: "model" }, + { provider: "open_ai", modelId: "gpt", apiKeyArn: "arn:key" }, + { provider: "gemini", modelId: "gemini", apiKeyArn: "arn:key" }, + { provider: "lite_llm", modelId: "bedrock/model" }, + ]) { + expect( + HarnessModelSchema.safeParse({ + ...model, + additionalParams: { custom_parameter: true }, + }).success, + ).toBe(true); + } + }); it("validates provider-specific API formats through the shared helper", () => { expect(validateApiFormat("responses", "open_ai")).toEqual({ valid: true }); expect(validateApiFormat("converse_stream", "open_ai").valid).toBe(false); diff --git a/src/projectSchemas/harness.ts b/src/projectSchemas/harness.ts index fd058fed3..28754c40c 100644 --- a/src/projectSchemas/harness.ts +++ b/src/projectSchemas/harness.ts @@ -91,13 +91,6 @@ export const HarnessModelSchema = z path: ["apiBase"], }); } - if (model.additionalParams !== undefined && model.provider !== "lite_llm") { - ctx.addIssue({ - code: "custom", - message: 'additionalParams is only supported for the "lite_llm" provider', - path: ["additionalParams"], - }); - } }); export type HarnessModel = z.infer; export function validateApiFormat( From d97987aae5387e994acdf9e225122670807fb3b0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 31 Aug 2026 21:44:32 +0000 Subject: [PATCH 03/19] fix(export): validate service ARNs and restore VPC IDs --- src/core/harness.test.tsx | 48 +++++++ src/core/harness.tsx | 23 +++ src/handlers/harness/types.tsx | 1 + src/handlers/project/export/harness.test.ts | 50 ++++++- src/handlers/project/export/harness.ts | 22 ++- .../project/export/serviceHarness.test.ts | 65 ++++++++- src/handlers/project/export/serviceHarness.ts | 135 ++++++++++++++---- src/handlers/project/types.ts | 1 + src/testing/TestCoreClient.tsx | 12 ++ 9 files changed, 318 insertions(+), 39 deletions(-) create mode 100644 src/core/harness.test.tsx diff --git a/src/core/harness.test.tsx b/src/core/harness.test.tsx new file mode 100644 index 000000000..2441308fe --- /dev/null +++ b/src/core/harness.test.tsx @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import type { EC2Client } from "@aws-sdk/client-ec2"; +import { HarnessClient } from "./harness"; +import type { AwsClients } from "./types"; +import { InputValidationError, MalformedServiceResponseError } from "../errors"; + +function clientWithSubnets(subnets: { VpcId?: string }[]): HarnessClient { + const ec2 = { send: async () => ({ Subnets: subnets }) } as unknown as EC2Client; + const unexpected = () => { + throw new Error("unexpected client"); + }; + return new HarnessClient({ + control: unexpected, + data: unexpected, + ec2: () => ec2, + iam: unexpected, + logs: unexpected, + } as AwsClients); +} + +describe("HarnessClient.resolveVpcIdFromSubnets", () => { + test("returns the shared VPC ID", async () => { + const subject = clientWithSubnets([ + { VpcId: "vpc-0123456789abcdef0" }, + { VpcId: "vpc-0123456789abcdef0" }, + ]); + + await expect( + subject.resolveVpcIdFromSubnets(["subnet-a", "subnet-b"], { region: "us-east-1" }), + ).resolves.toBe("vpc-0123456789abcdef0"); + }); + + test("rejects subnets spanning multiple VPCs", async () => { + const subject = clientWithSubnets([{ VpcId: "vpc-a" }, { VpcId: "vpc-b" }]); + + await expect( + subject.resolveVpcIdFromSubnets(["subnet-a", "subnet-b"], { region: "us-east-1" }), + ).rejects.toBeInstanceOf(InputValidationError); + }); + + test("rejects an EC2 response without a VPC ID", async () => { + const subject = clientWithSubnets([{}]); + + await expect( + subject.resolveVpcIdFromSubnets(["subnet-a"], { region: "us-east-1" }), + ).rejects.toBeInstanceOf(MalformedServiceResponseError); + }); +}); diff --git a/src/core/harness.tsx b/src/core/harness.tsx index 7a5edeec9..ab5fab067 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -35,11 +35,13 @@ import { type InvokeHarnessRequest, type InvokeHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore"; +import { DescribeSubnetsCommand } from "@aws-sdk/client-ec2"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; import type { AwsClients, CoreOptions } from "./types"; import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; import { toClientConfig } from "./utils"; +import { InputValidationError, MalformedServiceResponseError } from "../errors"; // HarnessClient implements the harness-facing operations on top of the shared AWS // clients provided by CoreClient. It owns no clients of its own; it borrows the @@ -53,6 +55,27 @@ export class HarnessClient implements CoreHarnessClient { .send(new GetHarnessCommand({ harnessId: id })); } + async resolveVpcIdFromSubnets(subnetIds: string[], options: CoreOptions): Promise { + const response = await this.clients + .ec2({ region: options.region }) + .send(new DescribeSubnetsCommand({ SubnetIds: subnetIds })); + const vpcIds = new Set( + (response.Subnets ?? []).map((subnet) => subnet.VpcId).filter((vpcId) => vpcId !== undefined), + ); + if (vpcIds.size === 0) { + throw new MalformedServiceResponseError( + `EC2 returned no VPC ID for subnet${subnetIds.length === 1 ? "" : "s"} ${subnetIds.join(", ")}`, + ); + } + if (vpcIds.size > 1) { + throw new InputValidationError( + `the harness subnets span multiple VPCs (${[...vpcIds].join(", ")}); ` + + "a Container build requires all subnets to belong to one VPC", + ); + } + return [...vpcIds][0]!; + } + async getHarnessVersion( id: string, version: string, diff --git a/src/handlers/harness/types.tsx b/src/handlers/harness/types.tsx index 6f8ad3a08..f76684d1b 100644 --- a/src/handlers/harness/types.tsx +++ b/src/handlers/harness/types.tsx @@ -55,6 +55,7 @@ export interface CoreHarnessClient { options: CoreOptions, ): Promise; getHarness(id: string, options: CoreOptions): Promise; + resolveVpcIdFromSubnets(subnetIds: string[], options: CoreOptions): Promise; getHarnessVersion(id: string, version: string, options: CoreOptions): Promise; getHarnessEndpoint( id: string, diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index a34e1e4ee..1d1346bcd 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -106,9 +106,9 @@ describe("project export harness handler", () => { expect(await Bun.file(join(agentDir, "main.py")).text()).toContain( 'DEFAULT_SYSTEM_PROMPT = """You are a terse assistant."""', ); - expect(await Bun.file(join(agentDir, "model", "load.py")).text()).toContain( - 'BedrockModel(model_id="us.amazon.nova-lite-v1:0", max_tokens=256)', - ); + const loadModel = await Bun.file(join(agentDir, "model", "load.py")).text(); + expect(loadModel).toContain('model_id="us.amazon.nova-lite-v1:0"'); + expect(loadModel).toContain("max_tokens=256"); expect(await Bun.file(join(agentDir, "EXPORT_NOTES.md")).text()).toContain( "# Export Notes — exportme → exportmeAgent", ); @@ -248,6 +248,45 @@ describe("project export harness handler", () => { expect(existsSync(join(projectRoot, "app", "remote_harnessAgent", "main.py"))).toBe(true); }); + test("resolves and preserves the VPC ID for a service container harness", async () => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + subject.core.harness.setResolvedVpcId("vpc-0123456789abcdef0").setGetResponse({ + harness: { + harnessName: "remote_container", + model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, + environmentArtifact: { + containerConfiguration: { + containerUri: "111122223333.dkr.ecr.us-west-2.amazonaws.com/base:latest", + }, + }, + environment: { + agentCoreRuntimeEnvironment: { + networkConfiguration: { + networkMode: "VPC", + networkModeConfig: { + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }, + }, + }, + }, + }, + } as never); + + await subject.run(["--arn", HARNESS_ARN]); + + expect(subject.core.harness.calls).toContainEqual({ + method: "resolveVpcIdFromSubnets", + args: [["subnet-0123456789abcdef0"], { region: "us-west-2" }], + }); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + const runtime = spec.runtimes.find( + (candidate: { name: string }) => candidate.name === "remote_containerAgent", + ); + expect(runtime.networkConfig.vpcId).toBe("vpc-0123456789abcdef0"); + }); + test("validates the project before fetching from the service", async () => { const subject = testExportCommand(); await inTempDirectory(); // not a project @@ -264,5 +303,10 @@ describe("project export harness handler", () => { /not a valid harness ARN/, ); expect(subject.core.harness.calls).toEqual([]); + + await expect( + subject.run(["--arn", "arn:aws:lambda:us-west-2:111122223333:harness/h-abc123"]), + ).rejects.toThrow(/not a valid harness ARN/); + expect(subject.core.harness.calls).toEqual([]); }); }); diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index 388053239..a8db7642d 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -4,6 +4,7 @@ import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import { JsonKey } from "../../keys"; import { AgentNameSchema, BuildTypeSchema } from "../../../projectSchemas/runtime"; +import { isContainerBuild } from "../../../projectSchemas/constants"; import { formatExportNotes } from "../../../core/project/templates/export"; import { coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; @@ -48,17 +49,28 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) if (flags.arn) { config.io.stderr.write(`Fetching harness from the service\n`); const harnessId = harnessIdFromArn(flags.arn); - // The ARN names the region the harness lives in; fall back to the CLI's - // resolved region only when the ARN carries none. + // The ARN names the region the harness lives in and takes precedence over + // the CLI's resolved region, so service fetches never drift to ambient config. const coreOpts = coreOptsFromCtx(ctx); - const region = regionFromHarnessArn(flags.arn) ?? coreOpts.region; + const region = regionFromHarnessArn(flags.arn); const response = await config.core.harness.getHarness(harnessId, { ...coreOpts, region }); if (!response.harness) { throw new InputValidationError(`the service returned no harness for "${flags.arn}"`); } - const { spec, systemPrompt } = mapServiceHarnessToSpec(response.harness); + const { spec, systemPrompt, notes } = mapServiceHarnessToSpec(response.harness); + if ( + isContainerBuild(spec) && + spec.networkMode === "VPC" && + spec.networkConfig && + !spec.networkConfig.vpcId + ) { + spec.networkConfig.vpcId = await config.core.harness.resolveVpcIdFromSubnets( + spec.networkConfig.subnets, + { region }, + ); + } input = { - prefetched: { spec, systemPrompt }, + prefetched: { spec, systemPrompt, notes }, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), build: flags.build, }; diff --git a/src/handlers/project/export/serviceHarness.test.ts b/src/handlers/project/export/serviceHarness.test.ts index dedaae749..d67044ddb 100644 --- a/src/handlers/project/export/serviceHarness.test.ts +++ b/src/handlers/project/export/serviceHarness.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { Harness } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError, MalformedServiceResponseError } from "../../../errors"; -import { harnessIdFromArn, mapServiceHarnessToSpec, regionFromHarnessArn } from "./serviceHarness"; +import { + MEMORY_TUNING_NOTE_CATEGORY, + SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + harnessIdFromArn, + mapServiceHarnessToSpec, + regionFromHarnessArn, +} from "./serviceHarness"; const ARN = "arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-abc123"; @@ -37,9 +43,17 @@ describe("harness ARN helpers", () => { expect(regionFromHarnessArn(ARN)).toBe("us-west-2"); }); - test("rejects a malformed harness ARN and tolerates a missing region", () => { + test("accepts other AWS partitions and rejects malformed or wrong-service ARNs", () => { + const chinaArn = "arn:aws-cn:bedrock-agentcore:cn-north-1:111122223333:harness/h-abc123"; + expect(harnessIdFromArn(chinaArn)).toBe("h-abc123"); + expect(regionFromHarnessArn(chinaArn)).toBe("cn-north-1"); expect(() => harnessIdFromArn("arn:aws:foo:bar")).toThrow(InputValidationError); - expect(regionFromHarnessArn("not-an-arn")).toBeUndefined(); + expect(() => + harnessIdFromArn("arn:aws:lambda:us-east-1:111122223333:harness/h-abc123"), + ).toThrow(InputValidationError); + expect(() => + harnessIdFromArn("arn:aws:bedrock-agentcore::111122223333:harness/h-abc123"), + ).toThrow(InputValidationError); }); }); @@ -85,8 +99,8 @@ describe("mapServiceHarnessToSpec", () => { expect(spec.executionRoleArn).toBeUndefined(); }); - test("maps every skill source variant and drops unknown members", () => { - const { spec } = mapServiceHarnessToSpec( + test("maps every skill source variant and notes unknown members", () => { + const { spec, notes } = mapServiceHarnessToSpec( serviceHarness({ skills: [ { path: "local_skill" }, @@ -122,6 +136,7 @@ describe("mapServiceHarnessToSpec", () => { }, { awsSkills: { paths: ["aws/foo"] } }, ]); + expect(notes.map((note) => note.category)).toEqual([SERVICE_FIELD_OMITTED_NOTE_CATEGORY]); }); test("maps tools by passing their config through", () => { @@ -222,6 +237,46 @@ describe("mapServiceHarnessToSpec", () => { ]); }); + test("notes incomplete filesystem members instead of silently dropping them", () => { + const { spec, notes } = mapServiceHarnessToSpec( + serviceHarness({ + environment: { + agentCoreRuntimeEnvironment: { + filesystemConfigurations: [ + { efsAccessPoint: { mountPath: "/mnt/incomplete" } }, + { $unknown: ["futureFilesystem", {}] }, + ], + }, + }, + } as Partial), + ); + + expect(spec.efsAccessPoints).toBeUndefined(); + expect(notes.map((note) => note.category)).toEqual([ + SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + ]); + }); + + test("notes external-memory tuning that cannot be wired automatically", () => { + const { spec, notes } = mapServiceHarnessToSpec( + serviceHarness({ + memory: { + agentCoreMemoryConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:111122223333:memory/m-1", + messagesCount: 12, + retrievalConfig: { + "/users/{actorId}/facts": { topK: 8, relevanceScore: 0.7 }, + }, + }, + }, + } as Partial), + ); + + expect(spec.memory).toMatchObject({ mode: "existing", messagesCount: 12 }); + expect(notes.map((note) => note.category)).toEqual([MEMORY_TUNING_NOTE_CATEGORY]); + }); + test("rejects a VPC harness without explicit subnets/security groups before anything is written", () => { expect(() => mapServiceHarnessToSpec( diff --git a/src/handlers/project/export/serviceHarness.ts b/src/handlers/project/export/serviceHarness.ts index 1aeabab20..eb569fb44 100644 --- a/src/handlers/project/export/serviceHarness.ts +++ b/src/handlers/project/export/serviceHarness.ts @@ -5,26 +5,34 @@ import type { import z from "zod"; import { InputValidationError, MalformedServiceResponseError } from "../../../errors"; import { HarnessSpecSchema, type HarnessSpec } from "../../../projectSchemas/harness"; +import type { ExportNote } from "../../../core/project/templates/export"; -/** Extract the harness id from a harness ARN (`.../harness/` -> ``). */ -export function harnessIdFromArn(arn: string): string { - const match = /:harness\/([^/]+)$/.exec(arn); - if (!match?.[1]) { +export const SERVICE_FIELD_OMITTED_NOTE_CATEGORY = "Service harness field not exported"; +export const MEMORY_TUNING_NOTE_CATEGORY = "Harness memory tuning requires manual follow-up"; + +function parseHarnessArn(arn: string): { region: string; harnessId: string } { + const match = /^arn:[^:]+:bedrock-agentcore:([a-z0-9-]+):(\d{12}):harness\/([^/]+)$/.exec(arn); + if (!match?.[1] || !match[2] || !match[3]) { throw new InputValidationError( - `"${arn}" is not a valid harness ARN (expected ...:harness/)`, + `"${arn}" is not a valid harness ARN ` + + "(expected arn::bedrock-agentcore:::harness/)", ); } - return match[1]; + return { region: match[1], harnessId: match[3] }; +} + +/** Extract the harness id from a validated harness ARN. */ +export function harnessIdFromArn(arn: string): string { + return parseHarnessArn(arn).harnessId; } /** - * The region embedded in a harness ARN (`arn::bedrock-agentcore::...`), - * or undefined when the ARN carries none. The harness lives in this region, so - * it takes precedence over the CLI's resolved region for the export fetch. + * The region embedded in a harness ARN (`arn::bedrock-agentcore::...`). + * The harness lives in this region, so it takes precedence over the CLI's resolved + * region for the export fetch. */ -export function regionFromHarnessArn(arn: string): string | undefined { - const match = /^arn:[^:]+:bedrock-agentcore:([a-z0-9-]+):/.exec(arn); - return match?.[1] || undefined; +export function regionFromHarnessArn(arn: string): string { + return parseHarnessArn(arn).region; } /** @@ -36,7 +44,9 @@ export function regionFromHarnessArn(arn: string): string | undefined { export function mapServiceHarnessToSpec(harness: Harness): { spec: HarnessSpec; systemPrompt?: string; + notes: ExportNote[]; } { + const notes: ExportNote[] = []; const joinedPrompt = (harness.systemPrompt ?? []) .map((block) => ("text" in block ? block.text : undefined)) .filter((text): text is string => typeof text === "string" && text.length > 0) @@ -53,18 +63,20 @@ export function mapServiceHarnessToSpec(harness: Harness): { config: tool.config, }), ), - skills: (harness.skills ?? []).map(mapSkill).filter((skill) => skill !== undefined), + skills: (harness.skills ?? []) + .map((skill) => mapSkill(skill, notes)) + .filter((skill) => skill !== undefined), allowedTools: harness.allowedTools, - memory: mapMemory(harness.memory), + memory: mapMemory(harness.memory, notes), maxIterations: harness.maxIterations ?? undefined, maxTokens: harness.maxTokens ?? undefined, timeoutSeconds: harness.timeoutSeconds ?? undefined, truncation: harness.truncation, - containerUri: harness.environmentArtifact?.containerConfiguration?.containerUri, + containerUri: mapContainerUri(harness.environmentArtifact, notes), environmentVariables: harness.environmentVariables, // The harness's executionRoleArn is deliberately NOT carried: the exported // agent is a new runtime that gets its own CDK-managed execution role. - ...mapRuntimeEnvironment(harness), + ...mapRuntimeEnvironment(harness, notes), }); const parsed = HarnessSpecSchema.safeParse(candidate); @@ -74,7 +86,7 @@ export function mapServiceHarnessToSpec(harness: Harness): { { cause: parsed.error }, ); } - return { spec: parsed.data, systemPrompt }; + return { spec: parsed.data, systemPrompt, notes }; } function mapModel(model: Harness["model"]): Record { @@ -87,6 +99,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, + additionalParams: c.additionalParams, }); } if (model?.openAiModelConfig) { @@ -99,6 +112,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, + additionalParams: c.additionalParams, }); } if (model?.geminiModelConfig) { @@ -111,6 +125,7 @@ function mapModel(model: Harness["model"]): Record { topP: c.topP, topK: c.topK, maxTokens: c.maxTokens, + additionalParams: c.additionalParams, }); } if (model?.liteLlmModelConfig) { @@ -131,8 +146,11 @@ function mapModel(model: Harness["model"]): Record { ); } -/** Service skill union -> the flat local skill shape; unknown members are dropped. */ -function mapSkill(skill: ApiHarnessSkill): Record | undefined { +/** Service skill union -> the flat local skill shape. */ +function mapSkill( + skill: ApiHarnessSkill, + notes: ExportNote[], +): Record | undefined { if ("path" in skill && skill.path) return { path: skill.path }; if ("s3" in skill && skill.s3?.uri) return { s3Uri: skill.s3.uri }; if ("git" in skill && skill.git?.url) { @@ -148,6 +166,13 @@ function mapSkill(skill: ApiHarnessSkill): Record | undefined { if ("awsSkills" in skill && skill.awsSkills) { return { awsSkills: clean({ paths: skill.awsSkills.paths }) }; } + const unknown = unknownMemberName(skill); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `A harness skill${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "its service payload was unknown or incomplete.", + }); return undefined; } @@ -157,10 +182,22 @@ function mapSkill(skill: ApiHarnessSkill): Record | undefined { * bring-your-own memory; managed-without-ARN keeps the `managed` marker so the * export mapper can emit its follow-up note. */ -function mapMemory(memory: Harness["memory"]): Record | undefined { +function mapMemory( + memory: Harness["memory"], + notes: ExportNote[], +): Record | undefined { if (!memory) return undefined; if ("agentCoreMemoryConfiguration" in memory && memory.agentCoreMemoryConfiguration?.arn) { - const { arn, actorId, messagesCount } = memory.agentCoreMemoryConfiguration; + const { arn, actorId, messagesCount, retrievalConfig } = memory.agentCoreMemoryConfiguration; + if (messagesCount !== undefined || retrievalConfig !== undefined) { + notes.push({ + category: MEMORY_TUNING_NOTE_CATEGORY, + message: + `The service harness configured external memory${messagesCount !== undefined ? ` messagesCount=${messagesCount}` : ""}` + + `${retrievalConfig !== undefined ? " with per-namespace retrieval tuning" : ""}. ` + + "The exported runtime cannot apply those settings until the external memory is wired manually.", + }); + } return clean({ mode: "existing", arn, actorId, messagesCount }); } if ("managedMemoryConfiguration" in memory && memory.managedMemoryConfiguration) { @@ -169,6 +206,13 @@ function mapMemory(memory: Harness["memory"]): Record | undefin return { mode: "managed" }; } if ("disabled" in memory && memory.disabled) return { mode: "disabled" }; + const unknown = unknownMemberName(memory); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness memory configuration${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "the service payload was unknown or incomplete.", + }); return undefined; } @@ -178,11 +222,18 @@ function mapMemory(memory: Harness["memory"]): Record | undefin * cannot be expressed locally; fail here — before anything is written — with a * clear message instead of a downstream schema error. */ -function mapRuntimeEnvironment(harness: Harness): Record { - const env = - harness.environment && "agentCoreRuntimeEnvironment" in harness.environment - ? harness.environment.agentCoreRuntimeEnvironment - : undefined; +function mapRuntimeEnvironment(harness: Harness, notes: ExportNote[]): Record { + if (harness.environment && !("agentCoreRuntimeEnvironment" in harness.environment)) { + const unknown = unknownMemberName(harness.environment); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness environment${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "the service payload is not an AgentCore Runtime environment.", + }); + return {}; + } + const env = harness.environment?.agentCoreRuntimeEnvironment; if (!env) return {}; const out: Record = {}; @@ -232,6 +283,14 @@ function mapRuntimeEnvironment(harness: Harness): Record { accessPointArn: fs.s3FilesAccessPoint.accessPointArn, mountPath: fs.s3FilesAccessPoint.mountPath, }); + } else { + const unknown = unknownMemberName(fs); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `A filesystem configuration${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "its service payload was unknown or incomplete.", + }); } } if (efs.length) out.efsAccessPoints = efs; @@ -240,6 +299,30 @@ function mapRuntimeEnvironment(harness: Harness): Record { return out; } +function mapContainerUri( + artifact: Harness["environmentArtifact"], + notes: ExportNote[], +): string | undefined { + if (!artifact) return undefined; + if ("containerConfiguration" in artifact) { + return artifact.containerConfiguration?.containerUri; + } + const unknown = unknownMemberName(artifact); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness environment artifact${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "the service payload is not a container configuration.", + }); + return undefined; +} + +function unknownMemberName(value: unknown): string | undefined { + if (!value || typeof value !== "object" || !("$unknown" in value)) return undefined; + const unknown = (value as { $unknown?: unknown }).$unknown; + return Array.isArray(unknown) && typeof unknown[0] === "string" ? unknown[0] : undefined; +} + /** Drop undefined-valued keys so optional fields stay omitted. */ function clean>(obj: T): T { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 4e142deb5..69ea34aa2 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -250,6 +250,7 @@ export type ExportHarnessInput = { prefetched?: { spec: z.output; systemPrompt?: string; + notes?: ExportNote[]; }; /** Name of the runtime agent to generate. */ targetAgentName: string; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 6641c7159..f51d81d5d 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -356,6 +356,7 @@ export class TestHarnessClient implements CoreHarnessClient { private createEndpointResponse: CreateHarnessEndpointResponse = DEFAULT_CREATE_ENDPOINT_RESPONSE; private updateEndpointResponse: UpdateHarnessEndpointResponse = DEFAULT_UPDATE_ENDPOINT_RESPONSE; private deleteEndpointResponse: DeleteHarnessEndpointResponse = DEFAULT_DELETE_ENDPOINT_RESPONSE; + private resolvedVpcId = "vpc-0123456789abcdef0"; private error?: Error; // setListResponse sets what listHarnesses resolves to (when not erroring). @@ -468,6 +469,11 @@ export class TestHarnessClient implements CoreHarnessClient { return this; } + setResolvedVpcId(vpcId: string): this { + this.resolvedVpcId = vpcId; + return this; + } + // setError makes every subsequent call reject with `error`. Pass undefined to // clear it. setError(error: Error | undefined): this { @@ -535,6 +541,12 @@ export class TestHarnessClient implements CoreHarnessClient { return this.getResponse; } + async resolveVpcIdFromSubnets(subnetIds: string[], options: CoreOptions): Promise { + this.calls.push({ method: "resolveVpcIdFromSubnets", args: [subnetIds, options] }); + if (this.error) throw this.error; + return this.resolvedVpcId; + } + async getHarnessVersion( id: string, version: string, From bd2a5c0df32ecbea58157ae2e391f8062412ac34 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 31 Aug 2026 21:45:08 +0000 Subject: [PATCH 04/19] fix(templates): align generated provider dependencies --- .../strands-http-python/mcp_client/client.py | 19 ++--- .../strands-http-python/memory/session.py | 8 +-- .../strands-http-python/model/load.py | 69 +++++++++++++++++-- .../strands-http-python/pyproject.toml | 13 ++-- 4 files changed, 82 insertions(+), 27 deletions(-) diff --git a/src/assets/templates/strands-http-python/mcp_client/client.py b/src/assets/templates/strands-http-python/mcp_client/client.py index 4de07e43a..9cf57422d 100644 --- a/src/assets/templates/strands-http-python/mcp_client/client.py +++ b/src/assets/templates/strands-http-python/mcp_client/client.py @@ -69,21 +69,24 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]: {{#if headerCredentials}} {{#each headerCredentials}} @requires_api_key(provider_name="{{credentialName}}") -def _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(api_key: str) -> str: +def _get_{{pythonName}}_key(api_key: str) -> str: """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" return api_key {{/each}} {{/if}} -def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: +def get_{{pythonName}}_mcp_client() -> MCPClient | None: """Returns an MCP Client for the {{name}} remote MCP server.""" url = {{safeJson url}} {{#if headerCredentials}} - if os.getenv("LOCAL_DEV") == "1": - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } - else: - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(){{#unless @last}}, {{/unless}}{{/each}} } - return MCPClient(lambda: streamablehttp_client(url, headers=headers)) + def transport(): + if os.getenv("LOCAL_DEV") == "1": + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } + else: + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{pythonName}}_key(){{#unless @last}}, {{/unless}}{{/each}} } + return streamablehttp_client(url, headers=headers) + + return MCPClient(transport) {{else}} return MCPClient(lambda: streamablehttp_client(url)) {{/if}} @@ -91,7 +94,7 @@ def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: {{/each}} def get_all_remote_mcp_clients() -> list[MCPClient]: """Returns all configured remote MCP clients.""" - clients = [{{#each remoteMcpTools}}get_{{snakeCase name}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] + clients = [{{#each remoteMcpTools}}get_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] return [c for c in clients if c is not None] {{/if}} {{#unless (or hasGateway remoteMcpTools)}} diff --git a/src/assets/templates/strands-http-python/memory/session.py b/src/assets/templates/strands-http-python/memory/session.py index 20e105674..38bcf49f9 100644 --- a/src/assets/templates/strands-http-python/memory/session.py +++ b/src/assets/templates/strands-http-python/memory/session.py @@ -20,16 +20,16 @@ def get_memory_session_manager( {{#if memoryStrategies.length}} retrieval_config = { {{#if (includes memoryStrategies "SEMANTIC")}} - f"/users/{actor_id}/facts": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/users/{actor_id}/facts": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), {{/if}} {{#if (includes memoryStrategies "USER_PREFERENCE")}} - f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/users/{actor_id}/preferences": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), {{/if}} {{#if (includes memoryStrategies "EPISODIC")}} - f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}5{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), {{/if}} {{#if (includes memoryStrategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/summaries/{actor_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), {{/if}} } {{/if}} diff --git a/src/assets/templates/strands-http-python/model/load.py b/src/assets/templates/strands-http-python/model/load.py index 05da58b20..d54edd29e 100644 --- a/src/assets/templates/strands-http-python/model/load.py +++ b/src/assets/templates/strands-http-python/model/load.py @@ -1,6 +1,9 @@ {{#if (eq modelProvider "Bedrock")}} {{#if bedrockMantle}} import os +{{#if modelAdditionalParams}} +import json +{{/if}} from aws_bedrock_token_generator import provide_token {{#if (eq mantleApiFormat "chat_completions")}} @@ -34,7 +37,7 @@ def load_model(): {{/if}} client_args = {"api_key": token, "base_url": base_url} - params = {} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} {{#if modelMaxTokens}} {{#if (eq mantleApiFormat "chat_completions")}} params["max_completion_tokens"] = {{modelMaxTokens}} @@ -60,12 +63,22 @@ def load_model(): {{/if}} {{/if}} {{else}} +{{#if modelAdditionalParams}} +import json +{{/if}} from strands.models.bedrock import BedrockModel def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" - return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}"{{#if modelMaxTokens}}, max_tokens={{modelMaxTokens}}{{/if}}{{#if modelTemperature}}, temperature={{modelTemperature}}{{/if}}{{#if modelTopP}}, top_p={{modelTopP}}{{/if}}) + return BedrockModel( + model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}", + {{#if modelMaxTokens}}max_tokens={{modelMaxTokens}}, + {{/if}}{{#if modelTemperature}}temperature={{modelTemperature}}, + {{/if}}{{#if modelTopP}}top_p={{modelTopP}}, + {{/if}}{{#if modelAdditionalParams}}additional_request_fields=json.loads({{pyJsonStr modelAdditionalParams}}), + {{/if}} + ) {{/if}} {{/if}} {{#if (eq modelProvider "Anthropic")}} @@ -109,8 +122,15 @@ def load_model() -> AnthropicModel: {{/if}} {{#if (eq modelProvider "OpenAI")}} import os +{{#if modelAdditionalParams}} +import json +{{/if}} +{{#if (eq modelApiFormat "responses")}} +from strands.models.openai_responses import OpenAIResponsesModel +{{else}} from strands.models.openai import OpenAIModel +{{/if}} from bedrock_agentcore.identity.auth import requires_api_key IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" @@ -138,15 +158,29 @@ def _get_api_key() -> str: return _agentcore_identity_api_key_provider() -def load_model() -> OpenAIModel: +def load_model(): """Get authenticated OpenAI model client.""" - return OpenAIModel( + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + {{#if modelMaxTokens}} + params["{{#if (eq modelApiFormat "responses")}}max_output_tokens{{else}}max_completion_tokens{{/if}}"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + return {{#if (eq modelApiFormat "responses")}}OpenAIResponsesModel{{else}}OpenAIModel{{/if}}( client_args={"api_key": _get_api_key()}, model_id="{{#if modelId}}{{modelId}}{{else}}gpt-4.1{{/if}}", + params=params, ) {{/if}} {{#if (eq modelProvider "Gemini")}} import os +{{#if modelAdditionalParams}} +import json +{{/if}} from strands.models.gemini import GeminiModel from bedrock_agentcore.identity.auth import requires_api_key @@ -178,14 +212,28 @@ def _get_api_key() -> str: def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + {{#if modelMaxTokens}} + params["max_output_tokens"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + {{#if modelTopK}} + params["top_k"] = {{modelTopK}} + {{/if}} return GeminiModel( client_args={"api_key": _get_api_key()}, model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", + params=params, ) {{/if}} {{#if (eq modelProvider "LiteLLM")}} import os -{{#if litellmAdditionalParams}} +{{#if modelAdditionalParams}} import json {{/if}} @@ -230,7 +278,16 @@ def load_model() -> LiteLLMModel: {{#if litellmApiBase}} client_args["api_base"] = {{safeJson litellmApiBase}} {{/if}} - params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + {{#if modelMaxTokens}} + params["max_tokens"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} return LiteLLMModel( client_args=client_args, model_id="{{#if modelId}}{{modelId}}{{else}}bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0{{/if}}", diff --git a/src/assets/templates/strands-http-python/pyproject.toml b/src/assets/templates/strands-http-python/pyproject.toml index 26d4055ea..1a89b5846 100644 --- a/src/assets/templates/strands-http-python/pyproject.toml +++ b/src/assets/templates/strands-http-python/pyproject.toml @@ -9,17 +9,12 @@ description = "AgentCore Runtime Application using Strands SDK" readme = "README.md" requires-python = ">=3.10" dependencies = [ - {{#if (eq modelProvider "Anthropic")}}"anthropic ~= 0.30.0", - {{/if}}"aws-opentelemetry-distro ~= 0.17.0", + "aws-opentelemetry-distro ~= 0.17.0", "bedrock-agentcore ~= 1.9.1", "botocore[crt] ~= 1.43.0", - {{#if (eq modelProvider "Gemini")}}"google-genai ~= 1.0.0", - {{/if}}"mcp ~= 1.24.0", - {{#if (eq modelProvider "OpenAI")}}"openai ~= 1.0.0", - {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm ~= 1.0.0", - {{/if}}{{#if bedrockMantle}}"openai ~= 1.0.0", - "aws-bedrock-token-generator ~= 1.0.0", - {{/if}}"strands-agents ~= 1.15.0", + "mcp >= 1.23.0, < 2.0.0", + {{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0", + {{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} ~= 1.54.0", {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools ~= 0.1.0", {{/if}}{{#if hasBrowser}}"nest-asyncio ~= 1.5.0", "playwright ~= 1.42.0", From bee4e65d43793dfa43c679c6b1cac80d21baec30 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 31 Aug 2026 21:45:57 +0000 Subject: [PATCH 05/19] fix(templates): enforce harness limits per invocation --- .../hooks/execution_limits.py | 54 ------ .../templates/strands-http-python/main.py | 65 +++---- src/core/project/manager.export.test.ts | 167 ++++++++++++++++-- src/core/project/manager.tsx | 4 +- src/core/project/templates/runtime.ts | 4 - 5 files changed, 185 insertions(+), 109 deletions(-) delete mode 100644 src/assets/templates/strands-http-python/hooks/execution_limits.py diff --git a/src/assets/templates/strands-http-python/hooks/execution_limits.py b/src/assets/templates/strands-http-python/hooks/execution_limits.py deleted file mode 100644 index 057f348d8..000000000 --- a/src/assets/templates/strands-http-python/hooks/execution_limits.py +++ /dev/null @@ -1,54 +0,0 @@ -import time -from typing import Optional - -from strands.hooks import BeforeModelCallEvent -from strands.hooks.registry import HookProvider, HookRegistry -from strands.types.exceptions import EventLoopException - - -class ExecutionLimitExceeded(Exception): - def __init__(self, message: str) -> None: - super().__init__(message) - - -class ExecutionLimitsHook(HookProvider): - def __init__( - self, - max_iterations: Optional[int] = None, - max_tokens: Optional[int] = None, - timeout_seconds: Optional[float] = None, - ) -> None: - self._max_iterations = max_iterations - self._max_tokens = max_tokens - self._timeout_seconds = timeout_seconds - self._iteration_count = 0 - self._start_time = time.monotonic() - - def register_hooks(self, registry: HookRegistry, **kwargs) -> None: - registry.add_callback(BeforeModelCallEvent, self._check_limits) - - def _check_limits(self, event: BeforeModelCallEvent) -> None: - self._iteration_count += 1 - - if self._max_iterations is not None and self._iteration_count > self._max_iterations: - raise EventLoopException( - ExecutionLimitExceeded(f"Max iterations exceeded: {self._max_iterations}") - ) - - if self._timeout_seconds is not None: - elapsed = time.monotonic() - self._start_time - if elapsed > self._timeout_seconds: - raise EventLoopException( - ExecutionLimitExceeded( - f"Timeout exceeded: {self._timeout_seconds}s (elapsed {elapsed:.1f}s)" - ) - ) - - if self._max_tokens is not None: - used = event.agent.event_loop_metrics.accumulated_usage.get("outputTokens", 0) - if used >= self._max_tokens: - raise EventLoopException( - ExecutionLimitExceeded( - f"Max output tokens exceeded: {used}/{self._max_tokens}" - ) - ) diff --git a/src/assets/templates/strands-http-python/main.py b/src/assets/templates/strands-http-python/main.py index 69dca7e8e..ed5b0b792 100644 --- a/src/assets/templates/strands-http-python/main.py +++ b/src/assets/templates/strands-http-python/main.py @@ -17,23 +17,21 @@ {{/if}} {{/if}} import asyncio +{{#if timeoutSeconds}} +import threading +{{/if}} {{#if hasShell}} import subprocess {{/if}} {{#if hasFileOperations}} import os {{/if}} -{{#if hasExecutionLimits}} -from strands.tools.executors import SequentialToolExecutor -from strands.types.exceptions import EventLoopException -from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook -{{/if}} {{#if hasConfigBundle}} from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent {{/if}} {{#if truncationStrategy}} {{#if (eq truncationStrategy "sliding_window")}} -from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager +from strands.agent.conversation_manager import SlidingWindowConversationManager {{/if}} {{#if (eq truncationStrategy "summarization")}} from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager @@ -413,18 +411,7 @@ def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugi {{#if hasSkillsFetcher}} plugins=skill_plugins or None, {{/if}} - {{#if hasExecutionLimits}} - tool_executor=SequentialToolExecutor(), - callback_handler=None, - {{/if}} hooks=[ - {{#if hasExecutionLimits}} - ExecutionLimitsHook( - {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} - {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} - {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} - ), - {{/if}} {{#if hasConfigBundle}} ConfigBundleHook(), {{/if}} @@ -457,18 +444,7 @@ def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{ {{#if hasSkillsFetcher}} plugins=skill_plugins or None, {{/if}} - {{#if hasExecutionLimits}} - tool_executor=SequentialToolExecutor(), - callback_handler=None, - {{/if}} hooks=[ - {{#if hasExecutionLimits}} - ExecutionLimitsHook( - {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} - {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} - {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} - ), - {{/if}} {{#if hasConfigBundle}} ConfigBundleHook(), {{/if}} @@ -639,24 +615,36 @@ async def invoke(payload, context): {{/if}} {{#if hasExecutionLimits}} - timeout_seconds = {{#if timeoutSeconds}}{{timeoutSeconds}}{{else}}None{{/if}} + limits = { + {{#if maxIterations}}"turns": {{maxIterations}},{{/if}} + {{#if maxTokens}}"output_tokens": {{maxTokens}},{{/if}} + } or None + cancel_signal = {{#if timeoutSeconds}}threading.Event(){{else}}None{{/if}} timeout_fired = False watchdog_task = None - if timeout_seconds is not None: + {{#if timeoutSeconds}} + if cancel_signal is not None: async def _timeout_watchdog(): nonlocal timeout_fired - await asyncio.sleep(timeout_seconds) + await asyncio.sleep({{timeoutSeconds}}) timeout_fired = True - agent.cancel() + cancel_signal.set() watchdog_task = asyncio.create_task(_timeout_watchdog()) + {{/if}} try: + stop_reason = None {{#if inlineFunctionTools}} hit_inline_function = False {{/if}} async for event in agent.stream_async( prompt, + limits=limits, + cancel_signal=cancel_signal, ): + if isinstance(event, dict) and "result" in event: + stop_reason = getattr(event["result"], "stop_reason", None) + continue if not isinstance(event, dict) or "event" not in event: continue cbs = event["event"].get("contentBlockStart") @@ -674,11 +662,14 @@ async def _timeout_watchdog(): if timeout_fired: yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} - except EventLoopException as e: - if isinstance(e.original_exception, ExecutionLimitExceeded): - yield {"event": {"messageStop": {"stopReason": str(e.original_exception)}}} - return - raise + {{#if maxIterations}} + elif stop_reason == "limit_turns": + yield {"event": {"messageStop": {"stopReason": "Max iterations exceeded: {{maxIterations}}"}}} + {{/if}} + {{#if maxTokens}} + elif stop_reason == "limit_output_tokens": + yield {"event": {"messageStop": {"stopReason": "Max output tokens exceeded: {{maxTokens}}"}}} + {{/if}} finally: if watchdog_task is not None: watchdog_task.cancel() diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index 02b0de805..f0f3e98af 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -82,18 +82,24 @@ function exportInput(overrides: Partial = {}): ExportHarness } describe("FsProjectManager.exportHarness rendered tree", () => { - test("includes hooks/ only when the harness sets execution limits", async () => { + test("renders invocation-scoped native Strands limits without a custom hook", async () => { const { manager: subject } = manager(); - const project = await projectWithHarness(subject, { maxIterations: 3 }); + const project = await projectWithHarness(subject, { + maxIterations: 3, + maxTokens: 128, + timeoutSeconds: 5, + }); const result = await drain(subject.exportHarness(project, exportInput())); - expect(existsSync(join(result.agentPath, "hooks", "execution_limits.py"))).toBe(true); + expect(existsSync(join(result.agentPath, "hooks"))).toBe(false); const main = await Bun.file(join(result.agentPath, "main.py")).text(); - expect(main).toContain( - "from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook", - ); - expect(main).toContain("max_iterations=3,"); + expect(main).toContain('"turns": 3'); + expect(main).toContain('"output_tokens": 128'); + expect(main).toContain("cancel_signal = threading.Event()"); + expect(main).toContain("limits=limits"); + expect(main).not.toContain("ExecutionLimitsHook"); + expect(main).not.toContain("agent.cancel()"); }); test("leaves hooks/ and memory/ out of a plain export", async () => { @@ -133,6 +139,135 @@ describe("FsProjectManager.exportHarness rendered tree", () => { expect(result.notes).toEqual([]); }); + test("renders memory retrieval tuning and notes messagesCount", async () => { + const { manager: subject } = manager(); + let project = await projectWithHarness(subject, { + memory: { + mode: "existing", + name: "chat_history", + messagesCount: 12, + retrievalConfig: { topK: 8, relevanceScore: 0.7 }, + }, + }); + project = await drain( + subject.addResource(project, { + resourceType: "memory", + resourceConfig: { + name: "chat_history", + eventExpiryDuration: 30, + strategies: [{ type: "SEMANTIC" }], + }, + }), + ); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const session = await Bun.file(join(result.agentPath, "memory", "session.py")).text(); + expect(session).toContain("RetrievalConfig(top_k=8, relevance_score=0.7)"); + expect(result.notes.map((note) => note.category)).toContain( + "Memory messagesCount is not directly portable to Strands", + ); + }); + + test("renders OpenAI Responses settings with compatible Strands extras", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + model: { + provider: "open_ai", + modelId: "gpt-4.1", + apiKeyArn: + "arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/OpenAiKey", + apiFormat: "responses", + maxTokens: 512, + temperature: 0.2, + topP: 0.8, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain("from strands.models.openai_responses import OpenAIResponsesModel"); + expect(loadModel).toContain('params["max_output_tokens"] = 512'); + expect(loadModel).toContain('params["temperature"] = 0.2'); + expect(loadModel).toContain('params["top_p"] = 0.8'); + const pyproject = await Bun.file(join(result.agentPath, "pyproject.toml")).text(); + expect(pyproject).toContain('"strands-agents[openai] ~= 1.54.0"'); + expect(pyproject).not.toContain('"openai ~= 1.0.0"'); + }); + + test("renders Gemini sampling settings with the Gemini extra", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + model: { + provider: "gemini", + modelId: "gemini-2.5-flash", + apiKeyArn: + "arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/GeminiKey", + maxTokens: 400, + temperature: 0.3, + topP: 0.9, + topK: 20, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain('params["max_output_tokens"] = 400'); + expect(loadModel).toContain('params["temperature"] = 0.3'); + expect(loadModel).toContain('params["top_p"] = 0.9'); + expect(loadModel).toContain('params["top_k"] = 20'); + expect(await Bun.file(join(result.agentPath, "pyproject.toml")).text()).toContain( + '"strands-agents[gemini] ~= 1.54.0"', + ); + }); + + test("renders LiteLLM settings with the LiteLLM extra", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + model: { + provider: "lite_llm", + modelId: "bedrock/us.amazon.nova-lite-v1:0", + maxTokens: 300, + temperature: 0.1, + topP: 0.7, + additionalParams: { max_retries: 2 }, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain('params["max_tokens"] = 300'); + expect(loadModel).toContain('params["temperature"] = 0.1'); + expect(loadModel).toContain('params["top_p"] = 0.7'); + expect(loadModel).toContain('json.loads("{\\"max_retries\\":2}")'); + expect(await Bun.file(join(result.agentPath, "pyproject.toml")).text()).toContain( + '"strands-agents[litellm] ~= 1.54.0"', + ); + }); + + test("renders released skills and sliding-window APIs", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + skills: [{ path: "/opt/skills" }], + truncation: { + strategy: "sliding_window", + config: { slidingWindow: { messagesCount: 12 } }, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput({ build: "Container" }))); + + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).toContain("from strands import AgentSkills"); + expect(main).toContain('SlidingWindowConversationManager(**{"window_size":12}, per_turn=True)'); + expect(await Bun.file(join(result.agentPath, "pyproject.toml")).text()).toContain( + '"strands-agents ~= 1.54.0"', + ); + }); + test("renders the template Dockerfile for a plain Container export", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject); @@ -196,12 +331,20 @@ describe("FsProjectManager.exportHarness side effects", () => { await drain(subject.exportHarness(project, exportInput())); - const envLocal = await Bun.file(join(project.rootPath, "agentcore", ".env.local")).text(); - expect(envLocal).toContain("AGENTCORE_CREDENTIAL_ORDERSMCPINTERNALXAPIKEY='s3cret'"); const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json(); - expect(spec.credentials).toEqual([ - { authorizerType: "ApiKeyCredentialProvider", name: "ordersMcpinternalXApiKey" }, - ]); + const credential = spec.credentials[0]; + expect(credential.authorizerType).toBe("ApiKeyCredentialProvider"); + expect(credential.name).toMatch(/^ordersMcpinternalX-Api-Key-[a-f0-9]{10}$/); + const envLocal = await Bun.file(join(project.rootPath, "agentcore", ".env.local")).text(); + expect(envLocal).toContain( + `AGENTCORE_CREDENTIAL_${credential.name.replace(/-/g, "_").toUpperCase()}='s3cret'`, + ); + const mcpClient = await Bun.file( + join(project.rootPath, "app", "assistantAgent", "mcp_client", "client.py"), + ).text(); + expect(mcpClient).toMatch( + /def transport\(\):[\s\S]*headers = \{ "X-Api-Key": _get_[a-z0-9_]+_key\(\) \}[\s\S]*return streamablehttp_client/, + ); }); test("exports a prefetched (service) harness without touching harness files", async () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 45b99d4d6..683423bef 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -694,6 +694,7 @@ export class FsProjectManager implements ProjectManager { systemPrompt, projectSpec, build: input.build, + sourceNotes: input.prefetched?.notes, harnessDockerfileExists: spec.dockerfile !== undefined && harnessDir !== undefined && @@ -710,7 +711,6 @@ export class FsProjectManager implements ProjectManager { transformContent: (raw) => this.templateRenderer.render(raw, plan.context), filter: (name, isDir) => { if (isDir && name === "memory") return plan.hasMemory; - if (isDir && name === "hooks") return plan.hasExecutionLimits; // The template's own Dockerfile is used only for a plain Container // export; containerUri/custom-Dockerfile harnesses replace it below. if (name === "Dockerfile") @@ -1086,7 +1086,7 @@ function toProjectSpecKey(resourceType: ProjectResource) { async function readStrandsVersion(agentDir: string): Promise { try { const pyproject = await readFile(join(agentDir, "pyproject.toml"), "utf-8"); - const match = /strands-agents\s*([~><=]+\s*[\d.]+)/.exec(pyproject); + const match = /strands-agents(?:\[[^\]]+\])?\s*([~><=]+\s*[\d.]+)/.exec(pyproject); return match ? `strands-agents ${match[1]}` : "strands-agents (version unknown)"; } catch { return "strands-agents (version unknown)"; diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 10ab9cd7d..3a42bca9c 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -184,10 +184,6 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa transformContent: (raw) => templateRenderer.render(raw, context), filter: (name, isDir) => { if (isDir && name === "memory") return memory !== undefined; - // hooks/ carries the execution-limits capability, which only - // `project export harness` renders (harnesses can cap - // iterations/tokens/time; scaffolded runtimes cannot). - if (isDir && name === "hooks") return false; if (name === "Dockerfile" || name === ".dockerignore") return isContainer; return true; }, From b3fbb3ddefe399e3a27a97eac1fe774b1d3b490b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 16:17:08 +0000 Subject: [PATCH 06/19] refactor(export): remove EC2 VPC lookup --- bun.lock | 1 - package.json | 1 - src/core/core.test.ts | 26 ----------- src/core/datasetDownload.test.ts | 1 - src/core/datasetUpdate.test.ts | 1 - src/core/factories.tsx | 4 -- src/core/gateway.test.ts | 3 -- src/core/harness.test.tsx | 48 --------------------- src/core/harness.tsx | 23 ---------- src/core/index.tsx | 18 -------- src/core/types.tsx | 3 -- src/handlers/harness/types.tsx | 1 - src/handlers/project/export/harness.test.ts | 20 ++++++--- src/handlers/project/export/harness.ts | 12 ------ src/index.ts | 2 - src/testing/TestCoreClient.tsx | 12 ------ 16 files changed, 13 insertions(+), 163 deletions(-) delete mode 100644 src/core/harness.test.tsx diff --git a/bun.lock b/bun.lock index 766fc6b31..a27d2b9f5 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,6 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", - "@aws-sdk/client-ec2": "^3.1121.0", "@aws-sdk/client-iam": "^3.1080.0", "@aws-sdk/client-sts": "^3.1092.0", "@aws/agent-inspector": "0.6.1", diff --git a/package.json b/package.json index 9737c9e81..1232e5277 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", - "@aws-sdk/client-ec2": "^3.1121.0", "@aws-sdk/client-iam": "^3.1080.0", "@aws-sdk/client-sts": "^3.1092.0", "@aws/agent-inspector": "0.6.1", diff --git a/src/core/core.test.ts b/src/core/core.test.ts index e75b63532..a3b4edc45 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -5,7 +5,6 @@ import { } from "@aws-sdk/client-bedrock-agentcore-control"; import type { IAMClient } from "@aws-sdk/client-iam"; import type { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; -import type { EC2Client } from "@aws-sdk/client-ec2"; import { GetEventCommand, GetMemoryRecordCommand, @@ -81,10 +80,6 @@ function fakeIam(config: ClientConfig): IAMClient { function fakeLogs(config: ClientConfig): CloudWatchLogsClient { return { config, kind: "logs" } as unknown as CloudWatchLogsClient; } -function fakeEc2(config: ClientConfig): EC2Client { - return { config, kind: "ec2" } as unknown as EC2Client; -} - function coreWithDataSend( send: (command: unknown, options: unknown) => Promise, logger: Logger = createSilentLogger(), @@ -202,27 +197,6 @@ test("data() caches independently of control()", () => { expect(dataBuilt).toBe(1); }); -test("ec2() constructs a client once per config and caches it", () => { - let built = 0; - const core = new CoreClient({ - createControlClient: fakeControl, - createDataClient: fakeData, - createEc2Client: (config) => { - built++; - return fakeEc2(config); - }, - createIamClient: fakeIam, - createLogsClient: fakeLogs, - logger: createSilentLogger(), - }); - - const first = core.ec2({ region: "us-east-1" }); - const second = core.ec2({ region: "us-east-1" }); - - expect(first).toBe(second); - expect(built).toBe(1); -}); - test("exposes feature sub-clients", () => { const core = new CoreClient({ createControlClient: fakeControl, diff --git a/src/core/datasetDownload.test.ts b/src/core/datasetDownload.test.ts index ed7ea3473..a73094bee 100644 --- a/src/core/datasetDownload.test.ts +++ b/src/core/datasetDownload.test.ts @@ -35,7 +35,6 @@ function stubClients(dataset: Record): AwsClients { return { control: () => client, data: () => client, - ec2: () => client, iam: () => client, logs: () => client, }; diff --git a/src/core/datasetUpdate.test.ts b/src/core/datasetUpdate.test.ts index d7181962b..ca363c499 100644 --- a/src/core/datasetUpdate.test.ts +++ b/src/core/datasetUpdate.test.ts @@ -84,7 +84,6 @@ function stubClients(options: { return { control: () => client, data: () => client, - ec2: () => client, iam: () => client, logs: () => client, }; diff --git a/src/core/factories.tsx b/src/core/factories.tsx index 05e8421a7..209b8da1c 100644 --- a/src/core/factories.tsx +++ b/src/core/factories.tsx @@ -3,12 +3,10 @@ import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { IAMClient } from "@aws-sdk/client-iam"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; -import { EC2Client } from "@aws-sdk/client-ec2"; import type { CreateCloudFormationClient, CreateControlClient, CreateDataClient, - CreateEc2Client, CreateIamClient, CreateLogsClient, } from "./types"; @@ -29,7 +27,5 @@ export const createIamClient: CreateIamClient = (config) => new IAMClient({ ...c export const createLogsClient: CreateLogsClient = (config) => new CloudWatchLogsClient({ ...config }); -export const createEc2Client: CreateEc2Client = (config) => new EC2Client({ ...config }); - export const createCloudFormationClient: CreateCloudFormationClient = (config) => new CloudFormationClient({ ...config }); diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index f36b87f89..5072bd745 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -207,9 +207,6 @@ function recordingGatewayClient(responses: unknown[]): { data: () => { throw new Error("unexpected data client"); }, - ec2: () => { - throw new Error("unexpected EC2 client"); - }, iam: () => { throw new Error("unexpected IAM client"); }, diff --git a/src/core/harness.test.tsx b/src/core/harness.test.tsx deleted file mode 100644 index 2441308fe..000000000 --- a/src/core/harness.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { EC2Client } from "@aws-sdk/client-ec2"; -import { HarnessClient } from "./harness"; -import type { AwsClients } from "./types"; -import { InputValidationError, MalformedServiceResponseError } from "../errors"; - -function clientWithSubnets(subnets: { VpcId?: string }[]): HarnessClient { - const ec2 = { send: async () => ({ Subnets: subnets }) } as unknown as EC2Client; - const unexpected = () => { - throw new Error("unexpected client"); - }; - return new HarnessClient({ - control: unexpected, - data: unexpected, - ec2: () => ec2, - iam: unexpected, - logs: unexpected, - } as AwsClients); -} - -describe("HarnessClient.resolveVpcIdFromSubnets", () => { - test("returns the shared VPC ID", async () => { - const subject = clientWithSubnets([ - { VpcId: "vpc-0123456789abcdef0" }, - { VpcId: "vpc-0123456789abcdef0" }, - ]); - - await expect( - subject.resolveVpcIdFromSubnets(["subnet-a", "subnet-b"], { region: "us-east-1" }), - ).resolves.toBe("vpc-0123456789abcdef0"); - }); - - test("rejects subnets spanning multiple VPCs", async () => { - const subject = clientWithSubnets([{ VpcId: "vpc-a" }, { VpcId: "vpc-b" }]); - - await expect( - subject.resolveVpcIdFromSubnets(["subnet-a", "subnet-b"], { region: "us-east-1" }), - ).rejects.toBeInstanceOf(InputValidationError); - }); - - test("rejects an EC2 response without a VPC ID", async () => { - const subject = clientWithSubnets([{}]); - - await expect( - subject.resolveVpcIdFromSubnets(["subnet-a"], { region: "us-east-1" }), - ).rejects.toBeInstanceOf(MalformedServiceResponseError); - }); -}); diff --git a/src/core/harness.tsx b/src/core/harness.tsx index ab5fab067..7a5edeec9 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -35,13 +35,11 @@ import { type InvokeHarnessRequest, type InvokeHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore"; -import { DescribeSubnetsCommand } from "@aws-sdk/client-ec2"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; import type { AwsClients, CoreOptions } from "./types"; import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; import { toClientConfig } from "./utils"; -import { InputValidationError, MalformedServiceResponseError } from "../errors"; // HarnessClient implements the harness-facing operations on top of the shared AWS // clients provided by CoreClient. It owns no clients of its own; it borrows the @@ -55,27 +53,6 @@ export class HarnessClient implements CoreHarnessClient { .send(new GetHarnessCommand({ harnessId: id })); } - async resolveVpcIdFromSubnets(subnetIds: string[], options: CoreOptions): Promise { - const response = await this.clients - .ec2({ region: options.region }) - .send(new DescribeSubnetsCommand({ SubnetIds: subnetIds })); - const vpcIds = new Set( - (response.Subnets ?? []).map((subnet) => subnet.VpcId).filter((vpcId) => vpcId !== undefined), - ); - if (vpcIds.size === 0) { - throw new MalformedServiceResponseError( - `EC2 returned no VPC ID for subnet${subnetIds.length === 1 ? "" : "s"} ${subnetIds.join(", ")}`, - ); - } - if (vpcIds.size > 1) { - throw new InputValidationError( - `the harness subnets span multiple VPCs (${[...vpcIds].join(", ")}); ` + - "a Container build requires all subnets to belong to one VPC", - ); - } - return [...vpcIds][0]!; - } - async getHarnessVersion( id: string, version: string, diff --git a/src/core/index.tsx b/src/core/index.tsx index def3de365..5a6bab9fb 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -2,7 +2,6 @@ import { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { IAMClient } from "@aws-sdk/client-iam"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; -import { EC2Client } from "@aws-sdk/client-ec2"; import { EvalClient } from "./eval"; import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; @@ -18,7 +17,6 @@ import type { CreateCloudFormationClient, CreateControlClient, CreateDataClient, - CreateEc2Client, CreateIamClient, CreateLogsClient, } from "./types"; @@ -26,7 +24,6 @@ import type { Logger } from "../logging"; import type { ProjectManager } from "../handlers/project/types"; import { FsProjectManager } from "./project"; import { describeBedrockAgent, type DescribeBedrockAgent } from "./project/bedrockAgent"; -import { createEc2Client as defaultCreateEc2Client } from "./factories"; export type { AwsClients, @@ -35,7 +32,6 @@ export type { CreateControlClient, CreateCloudFormationClient, CreateDataClient, - CreateEc2Client, CreateIamClient, CreateLogsClient, } from "./types"; @@ -44,7 +40,6 @@ type CoreClientConfig = { createCloudFormationClient?: CreateCloudFormationClient; createControlClient: CreateControlClient; createDataClient: CreateDataClient; - createEc2Client?: CreateEc2Client; createIamClient: CreateIamClient; createLogsClient: CreateLogsClient; logger: Logger; @@ -61,13 +56,11 @@ type CoreClientConfig = { export class CoreClient implements AwsClients { private controlClients = new Map(); private dataClients = new Map(); - private ec2Clients = new Map(); private iamClients = new Map(); private logsClients = new Map(); private readonly createControlClient: CreateControlClient; private readonly createDataClient: CreateDataClient; - private readonly createEc2Client: CreateEc2Client; private readonly createIamClient: CreateIamClient; private readonly createLogsClient: CreateLogsClient; private logger: Logger; @@ -87,7 +80,6 @@ export class CoreClient implements AwsClients { constructor(config: CoreClientConfig) { this.createControlClient = config.createControlClient; this.createDataClient = config.createDataClient; - this.createEc2Client = config.createEc2Client ?? defaultCreateEc2Client; this.createIamClient = config.createIamClient; this.createLogsClient = config.createLogsClient; this.logger = config.logger; @@ -145,16 +137,6 @@ export class CoreClient implements AwsClients { return client; } - ec2(config: ClientConfig): EC2Client { - const key = cacheKey(config); - let client = this.ec2Clients.get(key); - if (!client) { - client = this.createEc2Client(config); - this.ec2Clients.set(key, client); - } - return client; - } - // iam returns the IAM client for `config`, creating and caching it on first // use (used to provision default execution roles). iam(config: ClientConfig): IAMClient { diff --git a/src/core/types.tsx b/src/core/types.tsx index aa39697a9..9e36a7c17 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -2,7 +2,6 @@ import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agen import type { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import type { IAMClient } from "@aws-sdk/client-iam"; import type { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; -import type { EC2Client } from "@aws-sdk/client-ec2"; import type { CloudFormationClient, CloudFormationClientConfig, @@ -37,7 +36,6 @@ export type CreateControlClient = (config: ClientConfig) => BedrockAgentCoreCont export type CreateDataClient = (config: ClientConfig) => BedrockAgentCoreClient; export type CreateIamClient = (config: ClientConfig) => IAMClient; export type CreateLogsClient = (config: ClientConfig) => CloudWatchLogsClient; -export type CreateEc2Client = (config: ClientConfig) => EC2Client; export type CreateCloudFormationClient = (config: CredentialedClientConfig) => CloudFormationClient; export type CoreFetch = ( ...args: Parameters @@ -56,5 +54,4 @@ export interface AwsClients { // results to. CloudWatch is a distinct service from the AgentCore data plane, // so it gets its own client/factory rather than reusing `data`. logs(config: ClientConfig): CloudWatchLogsClient; - ec2(config: ClientConfig): EC2Client; } diff --git a/src/handlers/harness/types.tsx b/src/handlers/harness/types.tsx index f76684d1b..6f8ad3a08 100644 --- a/src/handlers/harness/types.tsx +++ b/src/handlers/harness/types.tsx @@ -55,7 +55,6 @@ export interface CoreHarnessClient { options: CoreOptions, ): Promise; getHarness(id: string, options: CoreOptions): Promise; - resolveVpcIdFromSubnets(subnetIds: string[], options: CoreOptions): Promise; getHarnessVersion(id: string, version: string, options: CoreOptions): Promise; getHarnessEndpoint( id: string, diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index 1d1346bcd..bca0c6c5a 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -248,10 +248,10 @@ describe("project export harness handler", () => { expect(existsSync(join(projectRoot, "app", "remote_harnessAgent", "main.py"))).toBe(true); }); - test("resolves and preserves the VPC ID for a service container harness", async () => { + test("preserves service VPC configuration without additional lookups", async () => { const subject = testExportCommand(); const projectRoot = await inProjectWithHarness(subject); - subject.core.harness.setResolvedVpcId("vpc-0123456789abcdef0").setGetResponse({ + subject.core.harness.setGetResponse({ harness: { harnessName: "remote_container", model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, @@ -276,15 +276,21 @@ describe("project export harness handler", () => { await subject.run(["--arn", HARNESS_ARN]); - expect(subject.core.harness.calls).toContainEqual({ - method: "resolveVpcIdFromSubnets", - args: [["subnet-0123456789abcdef0"], { region: "us-west-2" }], - }); + expect(subject.core.harness.calls).toEqual([ + { + method: "getHarness", + args: ["h-abc123", expect.objectContaining({ region: "us-west-2" })], + }, + ]); const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); const runtime = spec.runtimes.find( (candidate: { name: string }) => candidate.name === "remote_containerAgent", ); - expect(runtime.networkConfig.vpcId).toBe("vpc-0123456789abcdef0"); + expect(runtime.build).toBe("Container"); + expect(runtime.networkConfig).toEqual({ + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }); }); test("validates the project before fetching from the service", async () => { diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index a8db7642d..0353e5a88 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -4,7 +4,6 @@ import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import { JsonKey } from "../../keys"; import { AgentNameSchema, BuildTypeSchema } from "../../../projectSchemas/runtime"; -import { isContainerBuild } from "../../../projectSchemas/constants"; import { formatExportNotes } from "../../../core/project/templates/export"; import { coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; @@ -58,17 +57,6 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) throw new InputValidationError(`the service returned no harness for "${flags.arn}"`); } const { spec, systemPrompt, notes } = mapServiceHarnessToSpec(response.harness); - if ( - isContainerBuild(spec) && - spec.networkMode === "VPC" && - spec.networkConfig && - !spec.networkConfig.vpcId - ) { - spec.networkConfig.vpcId = await config.core.harness.resolveVpcIdFromSubnets( - spec.networkConfig.subnets, - { region }, - ); - } input = { prefetched: { spec, systemPrompt, notes }, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), diff --git a/src/index.ts b/src/index.ts index 9df2c875e..c46290b6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,6 @@ import { createCloudFormationClient, createControlClient, createDataClient, - createEc2Client, createIamClient, createLogsClient, } from "./core/factories"; @@ -71,7 +70,6 @@ process.exit( createCloudFormationClient, createControlClient, createDataClient, - createEc2Client, createIamClient, createLogsClient, logger: rootLogger.child({ module: "core" }), diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index f51d81d5d..6641c7159 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -356,7 +356,6 @@ export class TestHarnessClient implements CoreHarnessClient { private createEndpointResponse: CreateHarnessEndpointResponse = DEFAULT_CREATE_ENDPOINT_RESPONSE; private updateEndpointResponse: UpdateHarnessEndpointResponse = DEFAULT_UPDATE_ENDPOINT_RESPONSE; private deleteEndpointResponse: DeleteHarnessEndpointResponse = DEFAULT_DELETE_ENDPOINT_RESPONSE; - private resolvedVpcId = "vpc-0123456789abcdef0"; private error?: Error; // setListResponse sets what listHarnesses resolves to (when not erroring). @@ -469,11 +468,6 @@ export class TestHarnessClient implements CoreHarnessClient { return this; } - setResolvedVpcId(vpcId: string): this { - this.resolvedVpcId = vpcId; - return this; - } - // setError makes every subsequent call reject with `error`. Pass undefined to // clear it. setError(error: Error | undefined): this { @@ -541,12 +535,6 @@ export class TestHarnessClient implements CoreHarnessClient { return this.getResponse; } - async resolveVpcIdFromSubnets(subnetIds: string[], options: CoreOptions): Promise { - this.calls.push({ method: "resolveVpcIdFromSubnets", args: [subnetIds, options] }); - if (this.error) throw this.error; - return this.resolvedVpcId; - } - async getHarnessVersion( id: string, version: string, From b178ac65007e15bb551e8fba9752ca3296e25f44 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 18:56:35 +0000 Subject: [PATCH 07/19] fix(schemas): restore the lite_llm-only additionalParams guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned @aws/agentcore-cdk rejects additionalParams on every provider but lite_llm, and re-parses harness.json at synth. Dropping the CLI refinement moved that failure from `project add harness` to `project build`, where it surfaces as a raw zod dump — reachable via `project create --additional-params`, whose provider defaults to bedrock. Restore the refinement, and drop the field with an export note on the --arn path instead of hard-failing, since a harness authored outside this CLI can carry it. --- src/core/project/templates/export.test.ts | 2 -- .../project/export/serviceHarness.test.ts | 32 +++++++++++++++++++ src/handlers/project/export/serviceHarness.ts | 28 +++++++++++++--- src/projectSchemas/harness.test.ts | 15 +++++++-- src/projectSchemas/harness.ts | 7 ++++ 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index c9d998d28..e8c8d4a04 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -126,7 +126,6 @@ describe("mapHarnessToExportPlan model mapping", () => { maxTokens: 768, temperature: 0.2, topP: 0.8, - additionalParams: { store: false }, apiKeyArn: "arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/MyOpenAiKey", }, @@ -139,7 +138,6 @@ describe("mapHarnessToExportPlan model mapping", () => { expect(result.context.modelMaxTokens).toBe("768"); expect(result.context.modelTemperature).toBe("0.2"); expect(result.context.modelTopP).toBe("0.8"); - expect(result.context.modelAdditionalParams).toEqual({ store: false }); expect(result.context.hasIdentity).toBe(true); expect(result.context.identityProviders).toEqual([ { name: "MyOpenAiKey", envVarName: "AGENTCORE_CREDENTIAL_MYOPENAIKEY" }, diff --git a/src/handlers/project/export/serviceHarness.test.ts b/src/handlers/project/export/serviceHarness.test.ts index d67044ddb..c641a1820 100644 --- a/src/handlers/project/export/serviceHarness.test.ts +++ b/src/handlers/project/export/serviceHarness.test.ts @@ -258,6 +258,38 @@ describe("mapServiceHarnessToSpec", () => { ]); }); + // The pinned CDK only maps additionalParams for lite_llm, so carrying it on another provider + // would produce a harness.json that fails at synth. Drop it with a note; keep it for lite_llm. + test("notes additionalParams the CDK cannot map, and keeps them for lite_llm", () => { + const dropped = mapServiceHarnessToSpec( + serviceHarness({ + model: { + bedrockModelConfig: { + modelId: "us.amazon.nova-lite-v1:0", + additionalParams: { custom_parameter: true }, + }, + }, + } as Partial), + ); + expect(dropped.spec.model.additionalParams).toBeUndefined(); + expect(dropped.notes.map((note) => note.category)).toEqual([ + SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + ]); + + const kept = mapServiceHarnessToSpec( + serviceHarness({ + model: { + liteLlmModelConfig: { + modelId: "bedrock/us.amazon.nova-lite-v1:0", + additionalParams: { max_retries: 2 }, + }, + }, + } as Partial), + ); + expect(kept.spec.model.additionalParams).toEqual({ max_retries: 2 }); + expect(kept.notes).toEqual([]); + }); + test("notes external-memory tuning that cannot be wired automatically", () => { const { spec, notes } = mapServiceHarnessToSpec( serviceHarness({ diff --git a/src/handlers/project/export/serviceHarness.ts b/src/handlers/project/export/serviceHarness.ts index eb569fb44..e8f95a871 100644 --- a/src/handlers/project/export/serviceHarness.ts +++ b/src/handlers/project/export/serviceHarness.ts @@ -55,7 +55,7 @@ export function mapServiceHarnessToSpec(harness: Harness): { const candidate = clean({ name: harness.harnessName, - model: mapModel(harness.model), + model: mapModel(harness.model, notes), tools: (harness.tools ?? []).map((tool) => clean({ type: tool.type, @@ -89,7 +89,7 @@ export function mapServiceHarnessToSpec(harness: Harness): { return { spec: parsed.data, systemPrompt, notes }; } -function mapModel(model: Harness["model"]): Record { +function mapModel(model: Harness["model"], notes: ExportNote[]): Record { if (model?.bedrockModelConfig) { const c = model.bedrockModelConfig; return clean({ @@ -99,7 +99,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, - additionalParams: c.additionalParams, + additionalParams: mapAdditionalParams("bedrock", c.additionalParams, notes), }); } if (model?.openAiModelConfig) { @@ -112,7 +112,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, - additionalParams: c.additionalParams, + additionalParams: mapAdditionalParams("open_ai", c.additionalParams, notes), }); } if (model?.geminiModelConfig) { @@ -125,7 +125,7 @@ function mapModel(model: Harness["model"]): Record { topP: c.topP, topK: c.topK, maxTokens: c.maxTokens, - additionalParams: c.additionalParams, + additionalParams: mapAdditionalParams("gemini", c.additionalParams, notes), }); } if (model?.liteLlmModelConfig) { @@ -146,6 +146,24 @@ function mapModel(model: Harness["model"]): Record { ); } +/** + * Only lite_llm carries additionalParams through to CFN — the CDK's harness schema rejects the + * field on every other provider, so mapping it verbatim would produce a spec that fails at synth. + * Drop it with a note instead of writing an undeployable harness. + */ +function mapAdditionalParams(provider: string, value: unknown, notes: ExportNote[]): unknown { + if (value === undefined) return undefined; + if (provider === "lite_llm") return value; + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness model's additionalParams were omitted because they are only supported for ` + + `the "lite_llm" provider (this harness uses "${provider}"). Set the equivalent options ` + + `directly in the generated model/load.py if the exported agent needs them.`, + }); + return undefined; +} + /** Service skill union -> the flat local skill shape. */ function mapSkill( skill: ApiHarnessSkill, diff --git a/src/projectSchemas/harness.test.ts b/src/projectSchemas/harness.test.ts index 16bcf2b23..b830337aa 100644 --- a/src/projectSchemas/harness.test.ts +++ b/src/projectSchemas/harness.test.ts @@ -41,19 +41,28 @@ describe("harness custom validation", () => { }).success, ).toBe(false); }); - it("accepts provider-specific additional parameters for every harness model", () => { + // The pinned @aws/agentcore-cdk rejects additionalParams on every provider but lite_llm, and + // re-parses harness.json at synth — so accepting it here would defer the failure to + // `project build` instead of surfacing it at authoring time. + it("accepts additional parameters only for the lite_llm provider", () => { + expect( + HarnessModelSchema.safeParse({ + provider: "lite_llm", + modelId: "bedrock/model", + additionalParams: { custom_parameter: true }, + }).success, + ).toBe(true); for (const model of [ { provider: "bedrock", modelId: "model" }, { provider: "open_ai", modelId: "gpt", apiKeyArn: "arn:key" }, { provider: "gemini", modelId: "gemini", apiKeyArn: "arn:key" }, - { provider: "lite_llm", modelId: "bedrock/model" }, ]) { expect( HarnessModelSchema.safeParse({ ...model, additionalParams: { custom_parameter: true }, }).success, - ).toBe(true); + ).toBe(false); } }); it("validates provider-specific API formats through the shared helper", () => { diff --git a/src/projectSchemas/harness.ts b/src/projectSchemas/harness.ts index 28754c40c..fd058fed3 100644 --- a/src/projectSchemas/harness.ts +++ b/src/projectSchemas/harness.ts @@ -91,6 +91,13 @@ export const HarnessModelSchema = z path: ["apiBase"], }); } + if (model.additionalParams !== undefined && model.provider !== "lite_llm") { + ctx.addIssue({ + code: "custom", + message: 'additionalParams is only supported for the "lite_llm" provider', + path: ["additionalParams"], + }); + } }); export type HarnessModel = z.infer; export function validateApiFormat( From 48cae1e62394521ad0e8b41bcd6d26a31d547ca7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 18:56:44 +0000 Subject: [PATCH 08/19] fix(export): require --vpc-id for container exports in VPC mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export layers the generated agent into the harness's image by writing a `FROM ` Dockerfile, which turns a no-build harness into a CodeBuild build. CodeBuild's CreateProject needs an explicit vpcId and cannot infer one from subnets, so the exported project failed at `project build` with a raw zod dump from CDK synth. Neither source of a harness carries a vpcId — the service's VpcConfig has no such field, and a local containerUri harness is never built so its schema rightly does not demand one. Export is what creates the requirement, so add --vpc-id and fail before writing anything when it is needed and absent. No AWS lookup is involved: getHarness remains the only request on the --arn path. --- src/core/project/manager.tsx | 1 + src/core/project/templates/export.ts | 19 +++++++++++++- src/handlers/project/export/harness.test.ts | 29 ++++++++++++++++++--- src/handlers/project/export/harness.ts | 8 ++++++ src/handlers/project/types.ts | 2 ++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 683423bef..f260a419b 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -694,6 +694,7 @@ export class FsProjectManager implements ProjectManager { systemPrompt, projectSpec, build: input.build, + vpcId: input.vpcId, sourceNotes: input.prefetched?.notes, harnessDockerfileExists: spec.dockerfile !== undefined && diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index ee601dc9f..d3c466bcd 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -49,6 +49,8 @@ export interface HarnessExportInput { projectSpec: ProjectSpec; /** Build override from --build; when absent the harness spec decides. */ build?: BuildType; + /** VPC id from --vpc-id, for Container builds in VPC mode (see mapHarnessToExportPlan). */ + vpcId?: string; /** Notes collected while converting a service response into a local harness spec. */ sourceNotes?: ExportNote[]; /** @@ -140,6 +142,21 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport ); } + // A Container build is produced by CodeBuild, whose CreateProject API needs an explicit vpcId + // and cannot infer one from subnets. Neither source of a harness carries one: the service's + // VpcConfig has no vpcId field, and a local containerUri harness is never built (so its schema + // rightly does not demand one). Export is what turns it into a build, so export must ask. + const networkConfig = + spec.networkMode === "VPC" && spec.networkConfig + ? { ...spec.networkConfig, ...(input.vpcId !== undefined && { vpcId: input.vpcId }) } + : undefined; + if (buildType === "Container" && networkConfig && networkConfig.vpcId === undefined) { + throw new InputValidationError( + `Harness "${spec.name}" runs in a VPC and exports as a Container build, which CodeBuild ` + + `cannot perform without an explicit VPC id. Re-export with --vpc-id vpc-xxxxxxxx.`, + ); + } + const allowedToolPatterns = spec.allowedTools ?? ["*"]; if (!(allowedToolPatterns.length === 1 && allowedToolPatterns[0] === "*")) { notes.push({ @@ -257,7 +274,7 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport ...(buildType === "Container" && { dockerfile: "Dockerfile" }), ...(envVars.length > 0 && { envVars }), ...(spec.networkMode && { networkMode: spec.networkMode }), - ...(spec.networkMode === "VPC" && spec.networkConfig && { networkConfig: spec.networkConfig }), + ...(networkConfig && { networkConfig }), ...(spec.authorizerType && { authorizerType: spec.authorizerType }), ...(spec.authorizerConfiguration && { authorizerConfiguration: spec.authorizerConfiguration, diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index bca0c6c5a..332a75525 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -248,9 +248,8 @@ describe("project export harness handler", () => { expect(existsSync(join(projectRoot, "app", "remote_harnessAgent", "main.py"))).toBe(true); }); - test("preserves service VPC configuration without additional lookups", async () => { - const subject = testExportCommand(); - const projectRoot = await inProjectWithHarness(subject); + /** A container harness in VPC mode, whose service VpcConfig carries no vpcId (the API has none). */ + function setVpcContainerHarness(subject: ReturnType) { subject.core.harness.setGetResponse({ harness: { harnessName: "remote_container", @@ -273,9 +272,17 @@ describe("project export harness handler", () => { }, }, } as never); + } - await subject.run(["--arn", HARNESS_ARN]); + test("preserves service VPC configuration without additional lookups", async () => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + setVpcContainerHarness(subject); + await subject.run(["--arn", HARNESS_ARN, "--vpc-id", "vpc-0123456789abcdef0"]); + + // The vpcId comes from the flag, never from an extra AWS call: the harness API's VpcConfig + // has no vpcId field, so getHarness must remain the only request. expect(subject.core.harness.calls).toEqual([ { method: "getHarness", @@ -290,9 +297,23 @@ describe("project export harness handler", () => { expect(runtime.networkConfig).toEqual({ subnets: ["subnet-0123456789abcdef0"], securityGroups: ["sg-0123456789abcdef0"], + vpcId: "vpc-0123456789abcdef0", }); }); + // Export turns a containerUri harness into a Dockerfile build so the agent code can be layered + // in, which makes CodeBuild's vpcId mandatory where the source harness never needed one. Fail + // here rather than writing a project that dies at `project build`. + test("requires --vpc-id for a container build in VPC mode", async () => { + const subject = testExportCommand(); + await inProjectWithHarness(subject); + setVpcContainerHarness(subject); + + await expect(subject.run(["--arn", HARNESS_ARN])).rejects.toThrow( + /runs in a VPC and exports as a Container build.*--vpc-id/s, + ); + }); + test("validates the project before fetching from the service", async () => { const subject = testExportCommand(); await inTempDirectory(); // not a project diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index 0353e5a88..3baeff6bd 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -4,6 +4,7 @@ import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import { JsonKey } from "../../keys"; import { AgentNameSchema, BuildTypeSchema } from "../../../projectSchemas/runtime"; +import { VPC_ID_PATTERN } from "../../../projectSchemas/constants"; import { formatExportNotes } from "../../../core/project/templates/export"; import { coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; @@ -31,6 +32,11 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) "build type for the exported agent: CodeZip or Container", BuildTypeSchema.optional(), ), + flag( + "vpc-id", + "VPC id for a Container build in VPC mode (CodeBuild cannot infer it from subnets)", + z.string().regex(VPC_ID_PATTERN, "Must be a VPC id (vpc-...)").optional(), + ), ], handle: async (ctx, flags) => { if (!!flags.name === !!flags.arn) { @@ -61,12 +67,14 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) prefetched: { spec, systemPrompt, notes }, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), build: flags.build, + vpcId: flags["vpc-id"], }; } else { input = { harnessName: flags.name!, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], flags.name!), build: flags.build, + vpcId: flags["vpc-id"], }; } diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 69ea34aa2..50c40f1f8 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -256,6 +256,8 @@ export type ExportHarnessInput = { targetAgentName: string; /** Build override; when absent the harness spec decides (CodeZip unless it demands Container). */ build?: BuildType; + /** VPC id for a Container build in VPC mode; CodeBuild cannot infer one from subnets. */ + vpcId?: string; }; /** Result of {@link ProjectManager.exportHarness}. */ From f82282b936397d64452b078db9abbce76b675318 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 19:14:34 +0000 Subject: [PATCH 09/19] refactor: drop formatting residue from the reverted EC2 client Commit 8570ab28 added an `ec2:` stub key and a fake client, which pushed one object past prettier's width and shifted a blank line. b3fbb3dd removed the EC2 code but left the reflowed formatting, so the PR still showed two core test files as changed with no bug behind them. Both files now match the base byte for byte. --- src/core/core.test.ts | 1 + src/core/datasetDownload.test.ts | 7 +------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/core/core.test.ts b/src/core/core.test.ts index a3b4edc45..e8390a470 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -80,6 +80,7 @@ function fakeIam(config: ClientConfig): IAMClient { function fakeLogs(config: ClientConfig): CloudWatchLogsClient { return { config, kind: "logs" } as unknown as CloudWatchLogsClient; } + function coreWithDataSend( send: (command: unknown, options: unknown) => Promise, logger: Logger = createSilentLogger(), diff --git a/src/core/datasetDownload.test.ts b/src/core/datasetDownload.test.ts index a73094bee..790d9a22c 100644 --- a/src/core/datasetDownload.test.ts +++ b/src/core/datasetDownload.test.ts @@ -32,12 +32,7 @@ function stubClients(dataset: Record): AwsClients { throw new Error(`unexpected command: ${(command as object).constructor.name}`); }; const client = { send } as never; - return { - control: () => client, - data: () => client, - iam: () => client, - logs: () => client, - }; + return { control: () => client, data: () => client, iam: () => client, logs: () => client }; } describe("EvalClient.downloadDataset", () => { From 60f499a1f82fa77cfd29aaf247a38b8b649cc0a6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 19:31:37 +0000 Subject: [PATCH 10/19] fix(export): align --vpc-id with repo conventions Three follow-ups from auditing the new commits against surrounding code: - reuse NetworkConfigSchema.shape.vpcId for the flag instead of restating its regex, matching how every other validated flag reuses a projectSchemas schema (BuildTypeSchema, ProtocolModeSchema, NetworkModeSchema); the inline regex was the only one in src/handlers - the remedy said `--vpc-id vpc-xxxxxxxx`, which VPC_ID_PATTERN rejects because x is not a hex digit, so copy-pasting it produced a second error; use the form the other free-form remedies use - document the flag in README, which enumerates this command's flags in prose - cover both new mapper branches in export.test.ts, which owns mapHarnessToExportPlan branch coverage and already tests the sibling throw --- README.md | 3 +++ src/core/project/templates/export.test.ts | 17 +++++++++++++++++ src/core/project/templates/export.ts | 2 +- src/handlers/project/export/harness.test.ts | 4 +--- src/handlers/project/export/harness.ts | 9 ++++++--- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 84f21fcb5..091d86f63 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,9 @@ mapped mechanically. Pass `--name ` for an in-project harness or `--arn ` to fetch a deployed one (the fetch uses the region embedded in the ARN); `--target-agent-name` overrides the default `Agent`, and `--build CodeZip|Container` overrides the build type. +A Container build in VPC mode also needs `--vpc-id `: the export layers +the agent onto the harness image with a generated Dockerfile, and the CodeBuild +project that builds it cannot infer the VPC from subnets alone. Global flags (declared at the root, available on every command): diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index e8c8d4a04..4257cb3b5 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -617,6 +617,23 @@ describe("mapHarnessToExportPlan build types and Dockerfiles", () => { ).toThrow(InputValidationError); }); + test("rejects a VPC container export with no vpcId and accepts one supplied by the caller", () => { + const vpcContainerHarness = harness({ + containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", + networkMode: "VPC", + networkConfig: { subnets: ["subnet-12345678"], securityGroups: ["sg-12345678"] }, + }); + + expect(() => plan({ spec: vpcContainerHarness })).toThrow(InputValidationError); + + const result = plan({ spec: vpcContainerHarness, vpcId: "vpc-12345678" }); + expect(result.runtime.networkConfig).toEqual({ + subnets: ["subnet-12345678"], + securityGroups: ["sg-12345678"], + vpcId: "vpc-12345678", + }); + }); + test("copies a custom harness Dockerfile with a build-layer note when it exists", () => { const result = plan({ spec: harness({ dockerfile: "Dockerfile" }), diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index d3c466bcd..e1b0e3e58 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -153,7 +153,7 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport if (buildType === "Container" && networkConfig && networkConfig.vpcId === undefined) { throw new InputValidationError( `Harness "${spec.name}" runs in a VPC and exports as a Container build, which CodeBuild ` + - `cannot perform without an explicit VPC id. Re-export with --vpc-id vpc-xxxxxxxx.`, + `cannot perform without an explicit VPC id. Re-export with --vpc-id .`, ); } diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index 332a75525..d3a2758b0 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -309,9 +309,7 @@ describe("project export harness handler", () => { await inProjectWithHarness(subject); setVpcContainerHarness(subject); - await expect(subject.run(["--arn", HARNESS_ARN])).rejects.toThrow( - /runs in a VPC and exports as a Container build.*--vpc-id/s, - ); + await expect(subject.run(["--arn", HARNESS_ARN])).rejects.toThrow(/without an explicit VPC id/); }); test("validates the project before fetching from the service", async () => { diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index 3baeff6bd..7e6ce0806 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -3,8 +3,11 @@ import { InputValidationError } from "../../../errors"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import { JsonKey } from "../../keys"; -import { AgentNameSchema, BuildTypeSchema } from "../../../projectSchemas/runtime"; -import { VPC_ID_PATTERN } from "../../../projectSchemas/constants"; +import { + AgentNameSchema, + BuildTypeSchema, + NetworkConfigSchema, +} from "../../../projectSchemas/runtime"; import { formatExportNotes } from "../../../core/project/templates/export"; import { coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; @@ -35,7 +38,7 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) flag( "vpc-id", "VPC id for a Container build in VPC mode (CodeBuild cannot infer it from subnets)", - z.string().regex(VPC_ID_PATTERN, "Must be a VPC id (vpc-...)").optional(), + NetworkConfigSchema.shape.vpcId, ), ], handle: async (ctx, flags) => { From e504774c6bfbe0ef72ed9466ca42e83c5d26adbe Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 19:51:36 +0000 Subject: [PATCH 11/19] test(export): trim a redundant assertion and close two coverage gaps Mutation-tested every test this PR adds by breaking the behaviour each one claims to cover and re-running it. Three results worth acting on: - the lite_llm half of "notes additionalParams..." asserted what the pre-existing "maps openai and litellm model configs" already asserts with the same fixture value; both fail on the same mutation, so it was pure duplication - "requires --vpc-id..." claimed in its comment to fail before writing anything but only asserted rejection, so relocating the throw after the write would have kept it green; now snapshots agentcore.json and checks the agent dir is absent - the ARN test never pinned the 12-digit account group; loosening \d{12} to \d+ passed. It now fails, verified by applying that mutation. Everything else detected its mutation and stays as is. --- src/handlers/project/export/harness.test.ts | 9 +++++- .../project/export/serviceHarness.test.ts | 28 ++++++------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index d3a2758b0..47d455bbe 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -306,10 +306,17 @@ describe("project export harness handler", () => { // here rather than writing a project that dies at `project build`. test("requires --vpc-id for a container build in VPC mode", async () => { const subject = testExportCommand(); - await inProjectWithHarness(subject); + const projectRoot = await inProjectWithHarness(subject); setVpcContainerHarness(subject); + const specBefore = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text(); await expect(subject.run(["--arn", HARNESS_ARN])).rejects.toThrow(/without an explicit VPC id/); + + // The point is failing before anything is written, so moving the throw later must break this. + expect(await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text()).toBe( + specBefore, + ); + expect(existsSync(join(projectRoot, "app", "remote_containerAgent"))).toBe(false); }); test("validates the project before fetching from the service", async () => { diff --git a/src/handlers/project/export/serviceHarness.test.ts b/src/handlers/project/export/serviceHarness.test.ts index c641a1820..fb8121f9c 100644 --- a/src/handlers/project/export/serviceHarness.test.ts +++ b/src/handlers/project/export/serviceHarness.test.ts @@ -54,6 +54,9 @@ describe("harness ARN helpers", () => { expect(() => harnessIdFromArn("arn:aws:bedrock-agentcore::111122223333:harness/h-abc123"), ).toThrow(InputValidationError); + expect(() => + harnessIdFromArn("arn:aws:bedrock-agentcore:us-west-2:12345:harness/h-abc123"), + ).toThrow(InputValidationError); }); }); @@ -259,9 +262,10 @@ describe("mapServiceHarnessToSpec", () => { }); // The pinned CDK only maps additionalParams for lite_llm, so carrying it on another provider - // would produce a harness.json that fails at synth. Drop it with a note; keep it for lite_llm. - test("notes additionalParams the CDK cannot map, and keeps them for lite_llm", () => { - const dropped = mapServiceHarnessToSpec( + // would produce a harness.json that fails at synth. The lite_llm keep-path is already asserted + // by "maps openai and litellm model configs" above. + test("notes additionalParams the CDK cannot map", () => { + const { spec, notes } = mapServiceHarnessToSpec( serviceHarness({ model: { bedrockModelConfig: { @@ -271,23 +275,9 @@ describe("mapServiceHarnessToSpec", () => { }, } as Partial), ); - expect(dropped.spec.model.additionalParams).toBeUndefined(); - expect(dropped.notes.map((note) => note.category)).toEqual([ - SERVICE_FIELD_OMITTED_NOTE_CATEGORY, - ]); - const kept = mapServiceHarnessToSpec( - serviceHarness({ - model: { - liteLlmModelConfig: { - modelId: "bedrock/us.amazon.nova-lite-v1:0", - additionalParams: { max_retries: 2 }, - }, - }, - } as Partial), - ); - expect(kept.spec.model.additionalParams).toEqual({ max_retries: 2 }); - expect(kept.notes).toEqual([]); + expect(spec.model.additionalParams).toBeUndefined(); + expect(notes.map((note) => note.category)).toEqual([SERVICE_FIELD_OMITTED_NOTE_CATEGORY]); }); test("notes external-memory tuning that cannot be wired automatically", () => { From 722f02b32ad7811311056b92079441d75f42bb98 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 21:15:26 +0000 Subject: [PATCH 12/19] fix(export): note unsupported system prompt blocks --- .../project/export/serviceHarness.test.ts | 20 +++++++++++++++++++ src/handlers/project/export/serviceHarness.ts | 13 +++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/handlers/project/export/serviceHarness.test.ts b/src/handlers/project/export/serviceHarness.test.ts index fb8121f9c..5f2e0d1f4 100644 --- a/src/handlers/project/export/serviceHarness.test.ts +++ b/src/handlers/project/export/serviceHarness.test.ts @@ -102,6 +102,26 @@ describe("mapServiceHarnessToSpec", () => { expect(spec.executionRoleArn).toBeUndefined(); }); + test("notes unknown system prompt blocks while preserving recognized text", () => { + const { systemPrompt, notes } = mapServiceHarnessToSpec( + serviceHarness({ + systemPrompt: [ + { text: "Be terse." }, + { $unknown: ["futurePrompt", {}] }, + ] as Harness["systemPrompt"], + }), + ); + + expect(systemPrompt).toBe("Be terse."); + expect(notes).toEqual([ + { + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + 'A system prompt block of type "futurePrompt" was omitted because its service payload was unknown or incomplete.', + }, + ]); + }); + test("maps every skill source variant and notes unknown members", () => { const { spec, notes } = mapServiceHarnessToSpec( serviceHarness({ diff --git a/src/handlers/project/export/serviceHarness.ts b/src/handlers/project/export/serviceHarness.ts index e8f95a871..bd8b6aebc 100644 --- a/src/handlers/project/export/serviceHarness.ts +++ b/src/handlers/project/export/serviceHarness.ts @@ -47,11 +47,22 @@ export function mapServiceHarnessToSpec(harness: Harness): { notes: ExportNote[]; } { const notes: ExportNote[] = []; - const joinedPrompt = (harness.systemPrompt ?? []) + const promptBlocks = harness.systemPrompt ?? []; + const joinedPrompt = promptBlocks .map((block) => ("text" in block ? block.text : undefined)) .filter((text): text is string => typeof text === "string" && text.length > 0) .join("\n"); const systemPrompt = joinedPrompt.length > 0 ? joinedPrompt : undefined; + for (const block of promptBlocks) { + if ("text" in block && typeof block.text === "string" && block.text.length > 0) continue; + const unknown = unknownMemberName(block); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `A system prompt block${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "its service payload was unknown or incomplete.", + }); + } const candidate = clean({ name: harness.harnessName, From 49b4f8badfe6a4021fa61d4139b0718b0fcac4e3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 1 Sep 2026 21:17:40 +0000 Subject: [PATCH 13/19] refactor(export): keep additional params LiteLLM-only --- src/core/project/templates/export.test.ts | 2 +- src/core/project/templates/export.ts | 4 +++- src/handlers/project/export/serviceHarness.ts | 13 ++++++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index 4257cb3b5..d9e9bcf68 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -186,7 +186,7 @@ describe("mapHarnessToExportPlan model mapping", () => { expect(result.context.modelProvider).toBe("LiteLLM"); expect(result.context.strandsExtras).toBe("litellm"); expect(result.context.litellmApiBase).toBe("https://litellm.example"); - expect(result.context.modelAdditionalParams).toEqual({ max_retries: 2 }); + expect(result.context.litellmAdditionalParams).toEqual({ max_retries: 2 }); expect(result.context.modelMaxTokens).toBe("300"); expect(result.context.modelTemperature).toBe("0.1"); expect(result.context.modelTopP).toBe("0.7"); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index e1b0e3e58..05b27d4dc 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -337,7 +337,6 @@ function resolveModel( const context: Record = { modelId: model.modelId, modelApiFormat: model.apiFormat, - modelAdditionalParams: model.additionalParams, // Stringified so a legal 0 (temperature/topP) stays truthy for {{#if}}. modelMaxTokens: model.maxTokens !== undefined ? String(model.maxTokens) : undefined, modelTemperature: model.temperature !== undefined ? String(model.temperature) : undefined, @@ -400,6 +399,9 @@ function resolveModel( context.modelProvider = "LiteLLM"; context.strandsExtras = "litellm"; if (model.apiBase) context.litellmApiBase = model.apiBase; + if (model.additionalParams && Object.keys(model.additionalParams).length > 0) { + context.litellmAdditionalParams = model.additionalParams; + } if (model.apiKeyArn) { attachIdentityProvider( context, diff --git a/src/handlers/project/export/serviceHarness.ts b/src/handlers/project/export/serviceHarness.ts index bd8b6aebc..79a333d79 100644 --- a/src/handlers/project/export/serviceHarness.ts +++ b/src/handlers/project/export/serviceHarness.ts @@ -110,7 +110,7 @@ function mapModel(model: Harness["model"], notes: ExportNote[]): Record Date: Tue, 1 Sep 2026 21:25:42 +0000 Subject: [PATCH 14/19] refactor(templates): remove unreachable provider params --- .../strands-http-python/model/load.py | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/assets/templates/strands-http-python/model/load.py b/src/assets/templates/strands-http-python/model/load.py index d54edd29e..c32134328 100644 --- a/src/assets/templates/strands-http-python/model/load.py +++ b/src/assets/templates/strands-http-python/model/load.py @@ -1,9 +1,6 @@ {{#if (eq modelProvider "Bedrock")}} {{#if bedrockMantle}} import os -{{#if modelAdditionalParams}} -import json -{{/if}} from aws_bedrock_token_generator import provide_token {{#if (eq mantleApiFormat "chat_completions")}} @@ -37,7 +34,7 @@ def load_model(): {{/if}} client_args = {"api_key": token, "base_url": base_url} - params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + params = {} {{#if modelMaxTokens}} {{#if (eq mantleApiFormat "chat_completions")}} params["max_completion_tokens"] = {{modelMaxTokens}} @@ -63,9 +60,6 @@ def load_model(): {{/if}} {{/if}} {{else}} -{{#if modelAdditionalParams}} -import json -{{/if}} from strands.models.bedrock import BedrockModel @@ -76,7 +70,6 @@ def load_model() -> BedrockModel: {{#if modelMaxTokens}}max_tokens={{modelMaxTokens}}, {{/if}}{{#if modelTemperature}}temperature={{modelTemperature}}, {{/if}}{{#if modelTopP}}top_p={{modelTopP}}, - {{/if}}{{#if modelAdditionalParams}}additional_request_fields=json.loads({{pyJsonStr modelAdditionalParams}}), {{/if}} ) {{/if}} @@ -122,9 +115,6 @@ def load_model() -> AnthropicModel: {{/if}} {{#if (eq modelProvider "OpenAI")}} import os -{{#if modelAdditionalParams}} -import json -{{/if}} {{#if (eq modelApiFormat "responses")}} from strands.models.openai_responses import OpenAIResponsesModel @@ -160,7 +150,7 @@ def _get_api_key() -> str: def load_model(): """Get authenticated OpenAI model client.""" - params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + params = {} {{#if modelMaxTokens}} params["{{#if (eq modelApiFormat "responses")}}max_output_tokens{{else}}max_completion_tokens{{/if}}"] = {{modelMaxTokens}} {{/if}} @@ -178,9 +168,6 @@ def load_model(): {{/if}} {{#if (eq modelProvider "Gemini")}} import os -{{#if modelAdditionalParams}} -import json -{{/if}} from strands.models.gemini import GeminiModel from bedrock_agentcore.identity.auth import requires_api_key @@ -212,7 +199,7 @@ def _get_api_key() -> str: def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" - params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + params = {} {{#if modelMaxTokens}} params["max_output_tokens"] = {{modelMaxTokens}} {{/if}} @@ -233,7 +220,7 @@ def load_model() -> GeminiModel: {{/if}} {{#if (eq modelProvider "LiteLLM")}} import os -{{#if modelAdditionalParams}} +{{#if litellmAdditionalParams}} import json {{/if}} @@ -278,7 +265,7 @@ def load_model() -> LiteLLMModel: {{#if litellmApiBase}} client_args["api_base"] = {{safeJson litellmApiBase}} {{/if}} - params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} {{#if modelMaxTokens}} params["max_tokens"] = {{modelMaxTokens}} {{/if}} From 879b1c29cc2127200851c10f02d41ac3b1243451 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 2 Sep 2026 14:34:06 +0000 Subject: [PATCH 15/19] fix(schemas): require vpcId for container builds in VPC mode ProjectRuntimeSchema already mirrors the CDK's CodeBuild security-group cap but not its vpcId requirement, so any writer of agentcore.json could emit a Container/VPC runtime that fails at synth. Adds the missing clause beside its sibling, using the CDK's `build === "Container"` predicate. The security-group cap test only asserted failure, which the new clause would satisfy on its own; it now pins the issue path so it still isolates the cap. --- src/projectSchemas/runtime.test.ts | 23 +++++++++++++++++++++-- src/projectSchemas/runtime.ts | 10 ++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/projectSchemas/runtime.test.ts b/src/projectSchemas/runtime.test.ts index d752c2f4d..967991cb1 100644 --- a/src/projectSchemas/runtime.test.ts +++ b/src/projectSchemas/runtime.test.ts @@ -144,9 +144,28 @@ describe("runtime custom validation", () => { ); const vpc = { networkMode: "VPC" as const, - networkConfig: { ...networkConfig, securityGroups }, + networkConfig: { ...networkConfig, securityGroups, vpcId: "vpc-0123456789abcdef0" }, }; - expect(ProjectRuntimeSchema.safeParse({ ...containerAgent, ...vpc }).success).toBe(false); + const capped = ProjectRuntimeSchema.safeParse({ ...containerAgent, ...vpc }); + expect(capped.success).toBe(false); + expect(capped.error?.issues[0]?.path).toEqual(["networkConfig", "securityGroups"]); expect(ProjectRuntimeSchema.safeParse({ ...codeZipAgent, ...vpc }).success).toBe(true); }); + it("requires a VPC ID for container builds in VPC mode only", () => { + const vpc = { networkMode: "VPC" as const, networkConfig }; + const missing = ProjectRuntimeSchema.safeParse({ ...containerAgent, ...vpc }); + expect(missing.success).toBe(false); + expect(missing.error?.issues[0]?.path).toEqual(["networkConfig", "vpcId"]); + expect( + ProjectRuntimeSchema.safeParse({ + ...containerAgent, + networkMode: "VPC", + networkConfig: { ...networkConfig, vpcId: "vpc-0123456789abcdef0" }, + }).success, + ).toBe(true); + + // CodeZip never reaches CodeBuild, so it needs no VPC ID. + expect(ProjectRuntimeSchema.safeParse({ ...codeZipAgent, ...vpc }).success).toBe(true); + expect(ProjectRuntimeSchema.safeParse(containerAgent).success).toBe(true); + }); }); diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 87f8836ec..d5bd8e2e5 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -290,6 +290,16 @@ export const ProjectRuntimeSchema = z path: ["networkConfig", "securityGroups"], }); } + // Mirrors the CDK, which feeds networkConfig.vpcId to the CodeBuild project's + // VpcConfig. Only `build: "Container"` reaches that path, so CodeZip is exempt. + if (data.networkMode === "VPC" && data.build === "Container" && !data.networkConfig?.vpcId) { + ctx.addIssue({ + code: "custom", + message: + "networkConfig.vpcId is required for Container builds in VPC mode (CodeBuild cannot infer the VPC from subnets)", + path: ["networkConfig", "vpcId"], + }); + } if ( data.authorizerType === "CUSTOM_JWT" && !data.authorizerConfiguration?.customJwtAuthorizer From 5cf624817fa25b6edb3c724cc6b2851481c35c99 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 2 Sep 2026 17:14:25 +0000 Subject: [PATCH 16/19] refactor(export): always export a CodeZip runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported agent is a self-contained Strands application: its dependencies come from its own pyproject.toml and it defines its own entrypoint, so nothing in it comes from the source harness's image. Deriving a Container build from containerUri/dockerfile therefore bought a CodeBuild project, an ECR pull grant, and a VPC id the Runtime API cannot supply, for a base layer the agent never used. Export now always emits CodeZip and reports a source image or Dockerfile as an export note. Removes --vpc-id and --build from `project export harness`, along with resolveBuildType, resolveDockerfilePlan and buildDockerfileStub. Path-based skills are now rejected rather than noted: they name directories on the harness image's filesystem, which the exported agent does not have, so the files were simply absent at invocation. Republishing them from s3 or git is the supported path. Container-based export can return additively if a real need for it appears. ProjectRuntimeSchema's vpcId rule is kept — it guards `project add runtime` and hand-edited specs, which can still declare a Container build. --- README.md | 9 +- src/core/project/manager.export.test.ts | 30 +-- src/core/project/manager.tsx | 25 +-- src/core/project/templates/export.test.ts | 99 +++------- src/core/project/templates/export.ts | 207 +++----------------- src/handlers/project/export/harness.test.ts | 31 +-- src/handlers/project/export/harness.ts | 20 +- src/handlers/project/types.ts | 5 - 8 files changed, 90 insertions(+), 336 deletions(-) diff --git a/README.md b/README.md index 091d86f63..acc74ba06 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,11 @@ new runtime in `agentcore.json` (the harness entry stays), and writes an mapped mechanically. Pass `--name ` for an in-project harness or `--arn ` to fetch a deployed one (the fetch uses the region embedded in the ARN); `--target-agent-name` overrides the default -`Agent`, and `--build CodeZip|Container` overrides the build type. -A Container build in VPC mode also needs `--vpc-id `: the export layers -the agent onto the harness image with a generated Dockerfile, and the CodeBuild -project that builds it cannot infer the VPC from subnets alone. +`Agent`. The exported agent is always a `CodeZip` runtime: it +declares its own dependencies, so it needs no image build. If the harness used a +pre-built container image or a custom Dockerfile, that is reported in +`EXPORT_NOTES.md` rather than rebuilt. Path-based skills are not supported, +since the exported agent has no container filesystem to read them from. Global flags (declared at the root, available on every command): diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index 852041019..1c54253cd 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -252,14 +252,14 @@ describe("FsProjectManager.exportHarness rendered tree", () => { test("renders released skills and sliding-window APIs", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject, { - skills: [{ path: "/opt/skills" }], + skills: [{ s3Uri: "s3://skills-bucket/team/" }], truncation: { strategy: "sliding_window", config: { slidingWindow: { messagesCount: 12 } }, }, }); - const result = await drain(subject.exportHarness(project, exportInput({ build: "Container" }))); + const result = await drain(subject.exportHarness(project, exportInput())); const main = await Bun.file(join(result.agentPath, "main.py")).text(); expect(main).toContain("from strands import AgentSkills"); @@ -269,21 +269,22 @@ describe("FsProjectManager.exportHarness rendered tree", () => { ); }); - test("renders the template Dockerfile for a plain Container export", async () => { + test("emits a CodeZip runtime with no container files", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject); - const result = await drain(subject.exportHarness(project, exportInput({ build: "Container" }))); + const result = await drain(subject.exportHarness(project, exportInput())); - expect(await Bun.file(join(result.agentPath, "Dockerfile")).text()).toContain("uv sync"); - expect(existsSync(join(result.agentPath, ".dockerignore"))).toBe(true); + expect(existsSync(join(result.agentPath, "Dockerfile"))).toBe(false); + expect(existsSync(join(result.agentPath, ".dockerignore"))).toBe(false); const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json(); const runtime = spec.runtimes.find((r: { name: string }) => r.name === "assistantAgent"); - expect(runtime.build).toBe("Container"); - expect(runtime.dockerfile).toBe("Dockerfile"); + expect(runtime.build).toBe("CodeZip"); + expect(runtime.runtimeVersion).toBe("PYTHON_3_14"); + expect(runtime.dockerfile).toBeUndefined(); }); - test("writes a FROM stub for a containerUri harness", async () => { + test("exports a containerUri harness as CodeZip and reports the dropped image", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject, { containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", @@ -291,12 +292,11 @@ describe("FsProjectManager.exportHarness rendered tree", () => { const result = await drain(subject.exportHarness(project, exportInput())); - expect(await Bun.file(join(result.agentPath, "Dockerfile")).text()).toContain( - "FROM 111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - ); - expect(result.notes.map((note) => note.category)).toEqual([ - "containerUri: verify Python in base image", - ]); + expect(existsSync(join(result.agentPath, "Dockerfile"))).toBe(false); + expect(result.notes.map((note) => note.category)).toEqual(["Container image not carried over"]); + const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json(); + const runtime = spec.runtimes.find((r: { name: string }) => r.name === "assistantAgent"); + expect(runtime.build).toBe("CodeZip"); }); test("writes generated IAM policy files next to the code", async () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c6e638a69..9c927e0ad 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { copyFile, readFile, rm, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { AddResourceInput, @@ -37,7 +37,6 @@ import { getRuntimeTemplateResolver } from "./templates/runtime"; import { DEFAULT_EXPORT_SYSTEM_PROMPT, EXPORT_NOTES_FILENAME, - buildDockerfileStub, buildExportNotesMarkdown, mapHarnessToExportPlan, } from "./templates/export"; @@ -726,16 +725,9 @@ export class FsProjectManager implements ProjectManager { spec, systemPrompt, projectSpec, - build: input.build, - vpcId: input.vpcId, sourceNotes: input.prefetched?.notes, - harnessDockerfileExists: - spec.dockerfile !== undefined && - harnessDir !== undefined && - existsSync(join(harnessDir, spec.dockerfile)), }); - const isContainer = plan.buildType === "Container"; yield { type: "step", message: `Rendering agent code at 'app/${targetAgentName}'` }; const tree = await FsTreeNode.fromAssetSource( { assetSource: this.assetSource }, @@ -745,11 +737,8 @@ export class FsProjectManager implements ProjectManager { transformContent: (raw) => this.templateRenderer.render(raw, plan.context), filter: (name, isDir) => { if (isDir && name === "memory") return plan.hasMemory; - // The template's own Dockerfile is used only for a plain Container - // export; containerUri/custom-Dockerfile harnesses replace it below. - if (name === "Dockerfile") - return isContainer && plan.dockerfilePlan.source === "template"; - if (name === ".dockerignore") return isContainer; + // Export always emits a CodeZip runtime, so the template's container files are never used. + if (name === "Dockerfile" || name === ".dockerignore") return false; return true; }, }, @@ -770,14 +759,6 @@ export class FsProjectManager implements ProjectManager { await tree.write(join(project.rootPath, "app")); // Post-render files the template cannot express. - if (plan.dockerfilePlan.source === "stub") { - await writeFile( - join(agentDir, "Dockerfile"), - buildDockerfileStub(plan.dockerfilePlan.containerUri), - ); - } else if (plan.dockerfilePlan.source === "harnessCopy") { - await copyFile(join(harnessDir!, spec.dockerfile!), join(agentDir, "Dockerfile")); - } for (const [fileName, policyDoc] of Object.entries(plan.policyFiles)) { await writeFile(join(agentDir, fileName), `${JSON.stringify(policyDoc, null, 2)}\n`); } diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index d9e9bcf68..df8135e0f 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -9,8 +9,7 @@ import { AWS_SKILLS_NOTE_CATEGORY, BROWSER_TOOL_NOTE_CATEGORY, CODE_INTERPRETER_TOOL_NOTE_CATEGORY, - CONTAINER_URI_NOTE_CATEGORY, - CUSTOM_DOCKERFILE_NOTE_CATEGORY, + CONTAINER_IMAGE_NOTE_CATEGORY, GATEWAY_TOOL_NOTE_CATEGORY, GIT_SKILLS_AUTH_NOTE_CATEGORY, LITELLM_NO_API_KEY_NOTE_CATEGORY, @@ -20,9 +19,7 @@ import { MEMORY_MANAGED_NOTE_CATEGORY, MEMORY_MESSAGES_COUNT_NOTE_CATEGORY, MEMORY_NAME_NOT_FOUND_NOTE_CATEGORY, - MISSING_DOCKERFILE_NOTE_CATEGORY, MODEL_API_KEY_NOTE_CATEGORY, - PATH_SKILLS_NOTE_CATEGORY, buildExportNotesMarkdown, formatExportNotes, mapHarnessToExportPlan, @@ -471,12 +468,11 @@ describe("mapHarnessToExportPlan memory", () => { }); describe("mapHarnessToExportPlan skills", () => { - test("maps path, s3, and git skills and generates the S3 read policy", () => { + test("maps s3 and git skills and generates the S3 read policy", () => { const result = plan({ spec: harness({ build: undefined, skills: [ - { path: "local_skill" }, { s3Uri: "s3://skills-bucket/team/" }, { gitUrl: "https://github.com/example/skills.git", path: "subdir" }, ], @@ -485,7 +481,6 @@ describe("mapHarnessToExportPlan skills", () => { expect(result.context.hasSkillsFetcher).toBe(true); expect(result.context.hasFetchedSkills).toBe(true); - expect(result.context.pathSkills).toEqual(["local_skill"]); expect(result.context.s3Skills).toEqual(["s3://skills-bucket/team/"]); expect(result.context.gitSkills).toEqual([ { url: "https://github.com/example/skills.git", path: "subdir" }, @@ -502,8 +497,7 @@ describe("mapHarnessToExportPlan skills", () => { ], }); expect(result.runtime.additionalPolicies).toEqual(["s3-skills-policy.json"]); - // CodeZip path skills need the container filesystem — flagged for follow-up. - expect(categories(result)).toEqual([PATH_SKILLS_NOTE_CATEGORY]); + expect(result.notes).toEqual([]); }); test("notes a malformed s3 URI instead of generating IAM for it", () => { @@ -575,83 +569,50 @@ describe("mapHarnessToExportPlan truncation", () => { }); }); -describe("mapHarnessToExportPlan build types and Dockerfiles", () => { - test("defaults to CodeZip with the PYTHON_3_14 runtime", () => { +describe("mapHarnessToExportPlan always exports a CodeZip runtime", () => { + const CONTAINER_URI = "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest"; + + test("emits CodeZip with the PYTHON_3_14 runtime and no Dockerfile", () => { const result = plan({}); - expect(result.buildType).toBe("CodeZip"); - expect(result.dockerfilePlan).toEqual({ source: "none" }); + expect(result.runtime.build).toBe("CodeZip"); expect(result.runtime.runtimeVersion).toBe("PYTHON_3_14"); expect(result.runtime.dockerfile).toBeUndefined(); }); - test("a plain --build Container uses the template Dockerfile", () => { - const result = plan({ build: "Container" }); - expect(result.buildType).toBe("Container"); - expect(result.dockerfilePlan).toEqual({ source: "template" }); - expect(result.runtime.dockerfile).toBe("Dockerfile"); - expect(result.runtime.runtimeVersion).toBeUndefined(); + test("a containerUri harness still exports as CodeZip, with a note that the image was dropped", () => { + const result = plan({ spec: harness({ containerUri: CONTAINER_URI }) }); + expect(result.runtime.build).toBe("CodeZip"); + expect(result.runtime.dockerfile).toBeUndefined(); + expect(categories(result)).toEqual([CONTAINER_IMAGE_NOTE_CATEGORY]); + expect(result.notes[0]?.message).toContain(CONTAINER_URI); }); - test("a containerUri harness gets a FROM-stub Dockerfile and a verify note", () => { - const result = plan({ - spec: harness({ - containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - }), - }); - expect(result.buildType).toBe("Container"); - expect(result.dockerfilePlan).toEqual({ - source: "stub", - containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - }); - expect(categories(result)).toEqual([CONTAINER_URI_NOTE_CATEGORY]); + test("a custom-Dockerfile harness also exports as CodeZip with the same note", () => { + const result = plan({ spec: harness({ dockerfile: "Dockerfile" }) }); + expect(result.runtime.build).toBe("CodeZip"); + expect(result.runtime.dockerfile).toBeUndefined(); + expect(categories(result)).toEqual([CONTAINER_IMAGE_NOTE_CATEGORY]); }); - test("rejects forcing CodeZip onto a containerUri harness", () => { - expect(() => - plan({ - build: "CodeZip", - spec: harness({ - containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - }), + test("a VPC harness keeps its subnets and security groups and needs no vpcId", () => { + const result = plan({ + spec: harness({ + containerUri: CONTAINER_URI, + networkMode: "VPC", + networkConfig: { subnets: ["subnet-12345678"], securityGroups: ["sg-12345678"] }, }), - ).toThrow(InputValidationError); - }); - - test("rejects a VPC container export with no vpcId and accepts one supplied by the caller", () => { - const vpcContainerHarness = harness({ - containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - networkMode: "VPC", - networkConfig: { subnets: ["subnet-12345678"], securityGroups: ["sg-12345678"] }, }); - - expect(() => plan({ spec: vpcContainerHarness })).toThrow(InputValidationError); - - const result = plan({ spec: vpcContainerHarness, vpcId: "vpc-12345678" }); + expect(result.runtime.build).toBe("CodeZip"); expect(result.runtime.networkConfig).toEqual({ subnets: ["subnet-12345678"], securityGroups: ["sg-12345678"], - vpcId: "vpc-12345678", }); }); - test("copies a custom harness Dockerfile with a build-layer note when it exists", () => { - const result = plan({ - spec: harness({ dockerfile: "Dockerfile" }), - harnessDockerfileExists: true, - }); - expect(result.dockerfilePlan).toEqual({ source: "harnessCopy" }); - expect(categories(result)).toEqual([CUSTOM_DOCKERFILE_NOTE_CATEGORY]); - }); - - test("notes a declared-but-missing harness Dockerfile", () => { - const result = plan({ - spec: harness({ dockerfile: "Dockerfile" }), - harnessDockerfileExists: false, - }); - expect(result.dockerfilePlan).toEqual({ source: "none" }); - expect(categories(result)).toEqual([MISSING_DOCKERFILE_NOTE_CATEGORY]); - // The runtime entry still expects the Dockerfile the user will create. - expect(result.runtime.dockerfile).toBe("Dockerfile"); + test("rejects path-based skills, which have no container filesystem to read from", () => { + expect(() => plan({ spec: harness({ skills: [{ path: "/opt/skills/research" }] }) })).toThrow( + InputValidationError, + ); }); }); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index 914a570ef..3c3221b99 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import type { z } from "zod"; -import type { BuildType, ProjectRuntime } from "../../../projectSchemas/runtime"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import type { HarnessMemoryRef, HarnessMemoryRetrievalConfig, @@ -47,30 +47,10 @@ export interface HarnessExportInput { systemPrompt: string; /** The current project spec, for memory lookups and credential dedup. */ projectSpec: ProjectSpec; - /** Build override from --build; when absent the harness spec decides. */ - build?: BuildType; - /** VPC id from --vpc-id, for Container builds in VPC mode (see mapHarnessToExportPlan). */ - vpcId?: string; /** Notes collected while converting a service response into a local harness spec. */ sourceNotes?: ExportNote[]; - /** - * Whether the harness directory holds the Dockerfile that `spec.dockerfile` - * names (local harnesses only; the caller checks the filesystem). - */ - harnessDockerfileExists?: boolean; } -/** How the exported agent's Dockerfile is produced (Container builds only). */ -export type DockerfilePlan = - /** Render the stock template Dockerfile (plain --build Container). */ - | { source: "template" } - /** Write a FROM- stub extending the harness's prebuilt image. */ - | { source: "stub"; containerUri: string } - /** Copy the harness's own Dockerfile from the harness directory. */ - | { source: "harnessCopy" } - /** CodeZip — no Dockerfile at all. */ - | { source: "none" }; - /** The pure mapping result; the project manager executes it against the filesystem. */ export interface HarnessExportPlan { /** Handlebars context for rendering the strands-http-python template. */ @@ -85,8 +65,6 @@ export interface HarnessExportPlan { policyFiles: Record; /** Whether the render includes the memory/ module. */ hasMemory: boolean; - buildType: BuildType; - dockerfilePlan: DockerfilePlan; notes: ExportNote[]; } @@ -114,10 +92,7 @@ export const MALFORMED_S3_SKILL_NOTE_CATEGORY = export const MCP_HEADER_CREDS_NOTE_CATEGORY = "MCP tool header credentials"; export const LITELLM_NO_API_KEY_NOTE_CATEGORY = "LiteLLM model may require an API key"; export const MODEL_API_KEY_NOTE_CATEGORY = "Model API key credential referenced"; -export const CONTAINER_URI_NOTE_CATEGORY = "containerUri: verify Python in base image"; -export const CUSTOM_DOCKERFILE_NOTE_CATEGORY = - "Custom harness Dockerfile needs the agent build layer"; -export const MISSING_DOCKERFILE_NOTE_CATEGORY = "Dockerfile not found — create it before deploying"; +export const CONTAINER_IMAGE_NOTE_CATEGORY = "Container image not carried over"; // ============================================================================ // Public entry point @@ -131,31 +106,26 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport const policyFiles: Record = {}; const additionalPolicies: string[] = []; - const buildType = resolveBuildType(spec, input.build); - if (buildType === "CodeZip" && (spec.containerUri || spec.dockerfile)) { + // Export always emits a CodeZip runtime. The generated agent is a self-contained Strands + // application whose dependencies come from its own pyproject.toml, so it needs no image build + // and never reaches CodeBuild. A source image or Dockerfile is reported rather than rebuilt. + if (spec.containerUri || spec.dockerfile) { const what = spec.containerUri - ? `containerUri (${spec.containerUri})` - : `dockerfile (${spec.dockerfile})`; - throw new InputValidationError( - `Harness "${spec.name}" uses ${what}, which requires a Container build. ` + - `Re-export with --build Container.`, - ); + ? `a pre-built container image (${spec.containerUri})` + : `a custom Dockerfile (${spec.dockerfile})`; + notes.push({ + category: CONTAINER_IMAGE_NOTE_CATEGORY, + message: + `The harness used ${what} as its execution environment. The exported agent does not ` + + `rebuild it: the generated Strands application declares its own dependencies and runs ` + + `on the managed Python runtime. If that image supplied anything the agent needs at ` + + `runtime — system packages, certificates, or files read from disk — add it to the ` + + `generated project yourself.`, + }); } - // A Container build is produced by CodeBuild, whose CreateProject API needs an explicit vpcId - // and cannot infer one from subnets. Neither source of a harness carries one: the service's - // VpcConfig has no vpcId field, and a local containerUri harness is never built (so its schema - // rightly does not demand one). Export is what turns it into a build, so export must ask. const networkConfig = - spec.networkMode === "VPC" && spec.networkConfig - ? { ...spec.networkConfig, ...(input.vpcId !== undefined && { vpcId: input.vpcId }) } - : undefined; - if (buildType === "Container" && networkConfig && networkConfig.vpcId === undefined) { - throw new InputValidationError( - `Harness "${spec.name}" runs in a VPC and exports as a Container build, which CodeBuild ` + - `cannot perform without an explicit VPC id. Re-export with --vpc-id .`, - ); - } + spec.networkMode === "VPC" && spec.networkConfig ? spec.networkConfig : undefined; const allowedToolPatterns = spec.allowedTools ?? ["*"]; if (!(allowedToolPatterns.length === 1 && allowedToolPatterns[0] === "*")) { @@ -178,7 +148,7 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport envEntries, notes, ); - const skills = resolveSkills(spec, buildType, targetAgentName, credentials, notes); + const skills = resolveSkills(spec, credentials, notes); for (const [file, doc] of Object.entries(skills.policyFiles)) policyFiles[file] = doc; if (model.policyFile) policyFiles[model.policyFile.name] = model.policyFile.doc; additionalPolicies.push(...Object.keys(policyFiles)); @@ -188,14 +158,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport spec.maxTokens !== undefined || spec.timeoutSeconds !== undefined; - const dockerfilePlan = resolveDockerfilePlan( - spec, - buildType, - targetAgentName, - input.harnessDockerfileExists ?? false, - notes, - ); - const filesystemConfigurations = buildFilesystemConfigurations(spec); const envVars = Object.entries(spec.environmentVariables ?? {}).map(([name, value]) => ({ name, @@ -266,12 +228,11 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport const runtime: ProjectRuntime = { name: targetAgentName, - build: buildType, + build: "CodeZip", entrypoint: "main.py", codeLocation: `app/${targetAgentName}` as ProjectRuntime["codeLocation"], protocol: "HTTP", - ...(buildType === "CodeZip" && { runtimeVersion: "PYTHON_3_14" as const }), - ...(buildType === "Container" && { dockerfile: "Dockerfile" }), + runtimeVersion: "PYTHON_3_14", ...(envVars.length > 0 && { envVars }), ...(spec.networkMode && { networkMode: spec.networkMode }), ...(networkConfig && { networkConfig }), @@ -296,8 +257,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport envEntries, policyFiles, hasMemory: memory.provider !== undefined, - buildType, - dockerfilePlan, notes, }; } @@ -755,8 +714,6 @@ function isAwsSkill(skill: HarnessSkill): skill is HarnessSkillAwsSkillsSource { function resolveSkills( spec: HarnessSpec, - buildType: BuildType, - targetAgentName: string, credentials: Credential[], notes: ExportNote[], ): SkillsResolution { @@ -766,25 +723,14 @@ function resolveSkills( const awsSkills = spec.skills.filter(isAwsSkill); const policyFiles: Record = {}; - if (pathSkills.length > 0 && buildType === "CodeZip") { - notes.push({ - category: PATH_SKILLS_NOTE_CATEGORY, - message: - `The following skill paths must exist on the container filesystem at runtime: ` + - `${pathSkills.join(", ")}. For CodeZip builds, path skills are not supported — switch to ` + - `a Container build and COPY the skill directory into app/${targetAgentName}/, or use ` + - `s3/git skill variants.`, - }); - } - - if (gitSkillSources.length > 0 && buildType === "Container") { - notes.push({ - category: GIT_SKILLS_CONTAINER_NOTE_CATEGORY, - message: - "The agent clones git skill repositories at runtime using `git`. The default Container " + - "base image does not include git. Add it to your Dockerfile before deploying:\n\n" + - " RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*", - }); + // A path skill is a directory on the harness image's filesystem. The exported agent runs on the + // managed runtime with no such image, so the files would simply be absent at invocation. + if (pathSkills.length > 0) { + throw new InputValidationError( + `Harness "${spec.name}" uses path-based skills (${pathSkills.join(", ")}), which export ` + + `does not support: the exported agent has no container filesystem to read them from. ` + + `Republish those skills from s3 or git, then export again.`, + ); } // The agent fetches S3 skills with boto3 at runtime, so the runtime execution @@ -893,101 +839,6 @@ export function parseS3SkillArns( return { bucket, bucketArn, objectArn }; } -// ============================================================================ -// Build type + Dockerfile -// ============================================================================ - -function resolveBuildType(spec: HarnessSpec, override?: BuildType): BuildType { - if (override) return override; - if (spec.containerUri || spec.dockerfile) return "Container"; - return "CodeZip"; -} - -function resolveDockerfilePlan( - spec: HarnessSpec, - buildType: BuildType, - targetAgentName: string, - harnessDockerfileExists: boolean, - notes: ExportNote[], -): DockerfilePlan { - if (buildType !== "Container") return { source: "none" }; - if (spec.containerUri) { - notes.push({ - category: CONTAINER_URI_NOTE_CATEGORY, - message: - `The harness used a pre-built container image as its execution environment ` + - `(${spec.containerUri}). The generated Dockerfile extends that image directly ` + - `(FROM ) and layers the Strands agent code on top. If your base image does ` + - `not include Python 3.12+ or uv, add an install step before the \`uv sync\` steps. If ` + - `the base image is a private ECR repository, also grant the CodeBuild project that ` + - `builds this agent permission to pull it.`, - }); - return { source: "stub", containerUri: spec.containerUri }; - } - if (spec.dockerfile) { - if (!harnessDockerfileExists) { - notes.push({ - category: MISSING_DOCKERFILE_NOTE_CATEGORY, - message: - `The harness declares a custom Dockerfile, but no Dockerfile was found in its ` + - `directory, so nothing was copied. Create app/${targetAgentName}/Dockerfile ` + - `(including the Strands agent build layer) before \`agentcore project deploy\`.`, - }); - return { source: "none" }; - } - notes.push({ - category: CUSTOM_DOCKERFILE_NOTE_CATEGORY, - message: - `The harness used a custom Dockerfile that describes its execution environment. It has ` + - `been copied to app/${targetAgentName}/Dockerfile unchanged, but the exported agent will ` + - `NOT run as-is: a harness Dockerfile has no dependency install, code copy, or startup ` + - `command (the harness runtime supplied those). Append the Strands agent build layer ` + - `before \`agentcore project deploy\` (adjust if your base image is not Python 3.12+/uv):\n\n` + - ` WORKDIR /app\n` + - ` RUN pip install --no-cache-dir uv\n` + - ` COPY pyproject.toml uv.lock ./\n` + - ` RUN uv sync --frozen --no-dev --no-install-project\n` + - ` COPY . .\n` + - ` RUN uv sync --frozen --no-dev\n` + - ` EXPOSE 8080\n` + - ` CMD ["opentelemetry-instrument", "python", "-m", "main"]`, - }); - return { source: "harnessCopy" }; - } - return { source: "template" }; -} - -/** Dockerfile stub for a containerUri harness: extend the image, layer the agent on top. */ -export function buildDockerfileStub(containerUri: string): string { - return [ - `# Base image from the source harness: ${containerUri}`, - "# The generated Strands agent is layered on top. If the base image does not", - "# include Python 3.12+ or uv, add install steps before the COPY/RUN below.", - `FROM ${containerUri}`, - "", - "RUN pip install --no-cache-dir uv", - "", - "WORKDIR /app", - "", - "ENV UV_SYSTEM_PYTHON=1 \\", - " UV_COMPILE_BYTECODE=1 \\", - " UV_NO_PROGRESS=1 \\", - " PYTHONUNBUFFERED=1 \\", - ' PATH="/app/.venv/bin:$PATH"', - "", - "COPY pyproject.toml uv.lock ./", - "RUN uv sync --frozen --no-dev --no-install-project", - "", - "COPY . .", - "RUN uv sync --frozen --no-dev", - "", - "EXPOSE 8080 8000 9000", - "", - 'CMD ["opentelemetry-instrument", "python", "-m", "main"]', - "", - ].join("\n"); -} - // ============================================================================ // Filesystem mounts // ============================================================================ diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index 47d455bbe..c3e2f9000 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -274,15 +274,15 @@ describe("project export harness handler", () => { } as never); } - test("preserves service VPC configuration without additional lookups", async () => { + // A container harness in a VPC exports as CodeZip: no image build, so no CodeBuild and no vpcId + // to supply. The service's subnets and security groups still carry over verbatim. + test("exports a VPC container harness as CodeZip without additional lookups", async () => { const subject = testExportCommand(); const projectRoot = await inProjectWithHarness(subject); setVpcContainerHarness(subject); - await subject.run(["--arn", HARNESS_ARN, "--vpc-id", "vpc-0123456789abcdef0"]); + await subject.run(["--arn", HARNESS_ARN]); - // The vpcId comes from the flag, never from an extra AWS call: the harness API's VpcConfig - // has no vpcId field, so getHarness must remain the only request. expect(subject.core.harness.calls).toEqual([ { method: "getHarness", @@ -293,30 +293,13 @@ describe("project export harness handler", () => { const runtime = spec.runtimes.find( (candidate: { name: string }) => candidate.name === "remote_containerAgent", ); - expect(runtime.build).toBe("Container"); + expect(runtime.build).toBe("CodeZip"); + expect(runtime.dockerfile).toBeUndefined(); expect(runtime.networkConfig).toEqual({ subnets: ["subnet-0123456789abcdef0"], securityGroups: ["sg-0123456789abcdef0"], - vpcId: "vpc-0123456789abcdef0", }); - }); - - // Export turns a containerUri harness into a Dockerfile build so the agent code can be layered - // in, which makes CodeBuild's vpcId mandatory where the source harness never needed one. Fail - // here rather than writing a project that dies at `project build`. - test("requires --vpc-id for a container build in VPC mode", async () => { - const subject = testExportCommand(); - const projectRoot = await inProjectWithHarness(subject); - setVpcContainerHarness(subject); - const specBefore = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text(); - - await expect(subject.run(["--arn", HARNESS_ARN])).rejects.toThrow(/without an explicit VPC id/); - - // The point is failing before anything is written, so moving the throw later must break this. - expect(await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text()).toBe( - specBefore, - ); - expect(existsSync(join(projectRoot, "app", "remote_containerAgent"))).toBe(false); + expect(existsSync(join(projectRoot, "app", "remote_containerAgent", "Dockerfile"))).toBe(false); }); test("validates the project before fetching from the service", async () => { diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index bbb8ee228..882c550df 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -3,11 +3,7 @@ import { InputValidationError } from "../../../errors"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import { JsonKey } from "../../keys"; -import { - AgentNameSchema, - BuildTypeSchema, - NetworkConfigSchema, -} from "../../../projectSchemas/runtime"; +import { AgentNameSchema } from "../../../projectSchemas/runtime"; import { formatExportNotes } from "../../../core/project/templates/export"; import { coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; @@ -30,16 +26,6 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) "the name of the generated runtime agent (default: Agent)", z.string().optional(), ), - flag( - "build", - "build type for the exported agent: CodeZip or Container", - BuildTypeSchema.optional(), - ), - flag( - "vpc-id", - "VPC id for a Container build in VPC mode (CodeBuild cannot infer it from subnets)", - NetworkConfigSchema.shape.vpcId, - ), ], handle: async (ctx, flags) => { if (!!flags.name === !!flags.arn) { @@ -69,15 +55,11 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) input = { prefetched: { spec, systemPrompt, notes }, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), - build: flags.build, - vpcId: flags["vpc-id"], }; } else { input = { harnessName: flags.name!, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], flags.name!), - build: flags.build, - vpcId: flags["vpc-id"], }; } diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 306a9402e..1b6afed07 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,5 +1,4 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; -import type { BuildType } from "../../projectSchemas/runtime"; import type { ExportNote } from "../../core/project/templates/export"; import type { CredentialSchema } from "../../projectSchemas/credential"; import type { PaymentConnectorSchema, PaymentManagerSchema } from "../../projectSchemas/payment"; @@ -282,10 +281,6 @@ export type ExportHarnessInput = { }; /** Name of the runtime agent to generate. */ targetAgentName: string; - /** Build override; when absent the harness spec decides (CodeZip unless it demands Container). */ - build?: BuildType; - /** VPC id for a Container build in VPC mode; CodeBuild cannot infer one from subnets. */ - vpcId?: string; }; /** Result of {@link ProjectManager.exportHarness}. */ From 73e79392e091ac8335e69f8b50e62b225bf9bf88 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 2 Sep 2026 17:43:33 +0000 Subject: [PATCH 17/19] fix(templates): keep the Bedrock model block well formed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optional provider params were rendered with inline conditionals whose leading indentation survived when every param was absent, so a plain `project create` scaffold — which sets none of them — emitted a stray closing paren at twelve spaces. Uses the standalone-conditional form the rest of the template already relies on. --- .../templates/strands-http-python/model/load.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/assets/templates/strands-http-python/model/load.py b/src/assets/templates/strands-http-python/model/load.py index c32134328..bd472ead7 100644 --- a/src/assets/templates/strands-http-python/model/load.py +++ b/src/assets/templates/strands-http-python/model/load.py @@ -67,10 +67,15 @@ def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" return BedrockModel( model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}", - {{#if modelMaxTokens}}max_tokens={{modelMaxTokens}}, - {{/if}}{{#if modelTemperature}}temperature={{modelTemperature}}, - {{/if}}{{#if modelTopP}}top_p={{modelTopP}}, - {{/if}} +{{#if modelMaxTokens}} + max_tokens={{modelMaxTokens}}, +{{/if}} +{{#if modelTemperature}} + temperature={{modelTemperature}}, +{{/if}} +{{#if modelTopP}} + top_p={{modelTopP}}, +{{/if}} ) {{/if}} {{/if}} From c2775434597822b1d9ba6ab7ded9110ff2932ee5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 2 Sep 2026 18:18:39 +0000 Subject: [PATCH 18/19] refactor(export): give harness export its own template Export rendered `templates/strands-http-python`, the same template `project create` scaffolds from, so every export fix landed in a file whose primary consumer is the regular Strands runtime. That coupling is why this PR was touching provider loading, MCP client construction and memory retrieval in a shared template to fix bugs only export could reach. Export now renders `templates/export-harness-python`, and `strands-http-python` is restored to its pre-#2146 state: the hooks/execution_limits.py that #2146 added is gone, and the Bedrock model line it extended with temperature/top_p is back to its original form. `project create` output is byte-identical to refactor, verified by rendering a scaffold on both commits and diffing. The export template also drops what export can never produce: the container Dockerfile and .dockerignore (export is always CodeZip) and the Anthropic provider block (export emits Bedrock, OpenAI, Gemini or LiteLLM). --- .../templates/export-harness-python/README.md | 46 ++ .../export-harness-python/gitignore.template | 41 + .../templates/export-harness-python/main.py | 705 ++++++++++++++++++ .../mcp_client/__init__.py | 1 + .../mcp_client/client.py | 119 +++ .../export-harness-python/memory/__init__.py | 0 .../export-harness-python/memory/session.py | 47 ++ .../export-harness-python/model/__init__.py | 1 + .../export-harness-python/model/load.py | 249 +++++++ .../model/mantle_compat.py | 21 + .../export-harness-python/pyproject.toml | 26 + .../export-harness-python/skills/fetcher.py | 279 +++++++ .../templates/strands-http-python/main.py | 65 +- .../strands-http-python/mcp_client/client.py | 19 +- .../strands-http-python/memory/session.py | 8 +- .../strands-http-python/model/load.py | 55 +- .../strands-http-python/pyproject.toml | 13 +- src/core/project/manager.tsx | 9 +- 18 files changed, 1598 insertions(+), 106 deletions(-) create mode 100644 src/assets/templates/export-harness-python/README.md create mode 100644 src/assets/templates/export-harness-python/gitignore.template create mode 100644 src/assets/templates/export-harness-python/main.py create mode 100644 src/assets/templates/export-harness-python/mcp_client/__init__.py create mode 100644 src/assets/templates/export-harness-python/mcp_client/client.py create mode 100644 src/assets/templates/export-harness-python/memory/__init__.py create mode 100644 src/assets/templates/export-harness-python/memory/session.py create mode 100644 src/assets/templates/export-harness-python/model/__init__.py create mode 100644 src/assets/templates/export-harness-python/model/load.py create mode 100644 src/assets/templates/export-harness-python/model/mantle_compat.py create mode 100644 src/assets/templates/export-harness-python/pyproject.toml create mode 100644 src/assets/templates/export-harness-python/skills/fetcher.py diff --git a/src/assets/templates/export-harness-python/README.md b/src/assets/templates/export-harness-python/README.md new file mode 100644 index 000000000..5714aafbf --- /dev/null +++ b/src/assets/templates/export-harness-python/README.md @@ -0,0 +1,46 @@ +This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a `.gitignore` file, an +`agentcore/` folder which represents the configurations and state associated with this project. Other `agentcore` +commands like `deploy`, `dev`, and `invoke` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in `main.py`. Using the AgentCore SDK `@app.entrypoint` decorator, this +file defines a Starlette ASGI app with the chosen Agent framework SDK running within. + +`model/load.py` instantiates your chosen model provider. + +## Input Validation + +Validate invocation input before forwarding it to Strands. Keep plain prompts typed as strings. If the app accepts a +caller-supplied message history, retain `strip_trailing_tool_use()`, which normalizes the history tail before +invoking the agent. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| `{{identityProviders.[0].envVarName}}` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| `LOCAL_DEV` | No | Set to `1` to use `.env.local` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, a virtual environment is already created with dependencies installed. + +Activate the environment with `source .venv/bin/activate` on macOS/Linux, `.venv\Scripts\activate.bat` in Windows +Command Prompt, or `.\.venv\Scripts\activate.ps1` in Windows PowerShell. + +`agentcore project dev` will start a local server on 0.0.0.0:8080. + +# Deployment + +After providing credentials, `agentcore project deploy` will deploy your project into Amazon Bedrock AgentCore. + +Invoke the deployed Runtime with its native payload: + +```bash +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` diff --git a/src/assets/templates/export-harness-python/gitignore.template b/src/assets/templates/export-harness-python/gitignore.template new file mode 100644 index 000000000..f36f968a0 --- /dev/null +++ b/src/assets/templates/export-harness-python/gitignore.template @@ -0,0 +1,41 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/src/assets/templates/export-harness-python/main.py b/src/assets/templates/export-harness-python/main.py new file mode 100644 index 000000000..ed5b0b792 --- /dev/null +++ b/src/assets/templates/export-harness-python/main.py @@ -0,0 +1,705 @@ +from typing import Any +from collections import OrderedDict +{{#if inlineFunctionTools}} +import json + +from strands.tools.tools import PythonAgentTool +from strands.types.tools import ToolResult, ToolUse +{{/if}} +from strands import Agent, tool +{{#if hasSkillsFetcher}} +from strands import AgentSkills +{{#if hasFetchedSkills}} +from skills.fetcher import resolve_s3_skills, resolve_git_skills +{{/if}} +{{#if (some gitSkills "credentialArn")}} +from bedrock_agentcore.services.identity import IdentityClient +{{/if}} +{{/if}} +import asyncio +{{#if timeoutSeconds}} +import threading +{{/if}} +{{#if hasShell}} +import subprocess +{{/if}} +{{#if hasFileOperations}} +import os +{{/if}} +{{#if hasConfigBundle}} +from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent +{{/if}} +{{#if truncationStrategy}} +{{#if (eq truncationStrategy "sliding_window")}} +from strands.agent.conversation_manager import SlidingWindowConversationManager +{{/if}} +{{#if (eq truncationStrategy "summarization")}} +from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager +{{/if}} +{{else}} +from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager +{{/if}} +{{#if hasConfigBundle}} +from bedrock_agentcore.runtime.context import BedrockAgentCoreContext +{{/if}} +{{#if hasBrowser}} +from strands_tools.browser import AgentCoreBrowser +{{/if}} +{{#if hasCodeInterpreter}} +from strands_tools.code_interpreter import AgentCoreCodeInterpreter +{{/if}} +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_clients +{{/if}} +{{#if remoteMcpTools}} +from mcp_client.client import get_all_remote_mcp_clients +{{/if}} +{{#unless (or hasGateway remoteMcpTools)}} +{{#unless isExportHarness}} +from mcp_client.client import get_streamable_http_mcp_client +{{/unless}} +{{/unless}} +{{#if hasMemory}} +from memory.session import get_memory_session_manager +{{/if}} +{{#unless hasFileOperations}} +{{#if (or needsOs browserIdentifierEnvVar codeInterpreterIdentifierEnvVar (some gitSkills "credentialArn"))}} +import os +{{/if}} +{{/unless}} +{{#if hasPayment}} +from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT +{{/if}} + +app = BedrockAgentCoreApp() +log = app.logger + +{{#if (or hasGateway remoteMcpTools)}} +# Define MCP clients for all configured MCP servers (gateways and/or remote MCP) +mcp_clients = [] +{{#if hasGateway}} +mcp_clients += get_all_gateway_mcp_clients() +{{/if}} +{{#if remoteMcpTools}} +mcp_clients += get_all_remote_mcp_clients() +{{/if}} +{{else}} +{{#unless isExportHarness}} +# Define a Streamable HTTP MCP Client +mcp_clients = [get_streamable_http_mcp_client()] +{{/unless}} +{{/if}} + +{{#if systemPromptText}} +DEFAULT_SYSTEM_PROMPT = """{{escapePyStr systemPromptText}}""" +{{else}} +DEFAULT_SYSTEM_PROMPT = """ +You are a helpful assistant. Use tools when appropriate. +{{#if needsOs}}{{#unless isExportHarness}} +You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths: +{{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends) +{{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts) +{{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3) +{{/each}}{{/unless}}{{/if}} +""" +{{/if}} + +{{#if hasConfigBundle}} +DEFAULT_TOOL_DESC = "Return the sum of two numbers" +{{/if}} + +# Define a collection of tools used by the model +tools = [] + +{{#if inlineFunctionTools}} +# Inline function tools — stop the agent loop so the tool call streams back to the caller +def _make_inline_tool(name: str, spec: dict) -> PythonAgentTool: + def _handler(tool: ToolUse, **kwargs: Any) -> ToolResult: + kwargs.get("request_state", {})["stop_event_loop"] = True + return {"toolUseId": tool["toolUseId"], "status": "success", "content": [{"text": " "}]} + _handler.__name__ = name + return PythonAgentTool(tool_name=name, tool_spec=spec, tool_func=_handler) + +{{#each inlineFunctionTools}} +_INLINE_SPEC_{{snakeCase name}} = { + "name": "{{name}}", + "description": {{safeJson description}}, + "inputSchema": {"json": json.loads({{pyJsonStr inputSchema}}) }, +} +tools.append(_make_inline_tool("{{name}}", _INLINE_SPEC_{{snakeCase name}})) +{{/each}} + +_INLINE_FUNCTION_NAMES = { {{#each inlineFunctionTools}}"{{name}}"{{#unless @last}}, {{/unless}}{{/each}} } + +{{else}} +_INLINE_FUNCTION_NAMES = set() + +{{#unless isExportHarness}} +# Define a simple function tool +{{#if hasConfigBundle}} +@tool(description=DEFAULT_TOOL_DESC) +{{else}} +@tool +{{/if}} +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers""" + return a+b +tools.append(add_numbers) + +{{/unless}} +{{/if}} +{{#if hasBrowser}} +{{#if browserIdentifierEnvVar}} +_browser_id = os.getenv("{{browserIdentifierEnvVar}}") +tools.append(AgentCoreBrowser(**({"identifier": _browser_id} if _browser_id else {})).browser) +{{else}} +tools.append(AgentCoreBrowser().browser) +{{/if}} +{{/if}} +{{#if hasCodeInterpreter}} +{{#if codeInterpreterIdentifierEnvVar}} +_code_interpreter_id = os.getenv("{{codeInterpreterIdentifierEnvVar}}") +tools.append(AgentCoreCodeInterpreter(**({"identifier": _code_interpreter_id} if _code_interpreter_id else {})).code_interpreter) +{{else}} +tools.append(AgentCoreCodeInterpreter().code_interpreter) +{{/if}} +{{/if}} +{{#if hasShell}} +@tool +def shell(command: str, timeout: int = 300) -> dict: + """Execute a bash command and return the results. + + Args: + command: The bash command to execute + timeout: Timeout in seconds (default: 300) + + Returns: + Dict with stdout, stderr, and exit_code + """ + result = subprocess.run( + command, shell=True, capture_output=True, text=True, timeout=timeout + ) + return {"stdout": result.stdout, "stderr": result.stderr, "exit_code": result.returncode} + +tools.append(shell) +{{/if}} +{{#if hasFileOperations}} +@tool +def file_operations( + command: str, + path: str, + old_str: str = None, + new_str: str = None, + file_text: str = None, + insert_line: int = None, + view_range: list = None, +) -> str: + """Text editor tool for viewing and modifying files. + + Args: + command: The command to execute ("view", "str_replace", "create", "insert") + path: Path to the file or directory + old_str: Text to replace (for str_replace command) + new_str: Replacement text (for str_replace and insert commands) + file_text: Content for new file (for create command) + insert_line: Line number to insert after (for insert command) + view_range: [start_line, end_line] for viewing specific lines (for view command) + + Returns: + Result of the operation + """ + try: + if command == "view": + if not os.path.exists(path): + return f"Error: Path '{path}' does not exist" + if os.path.isdir(path): + return "\n".join(os.listdir(path)) + with open(path) as f: + lines = f.read().splitlines() + if view_range: + start, end = view_range + start_idx = max(0, start - 1) + end_idx = len(lines) if end == -1 else min(len(lines), end) + lines = lines[start_idx:end_idx] + start_num = start_idx + 1 + else: + start_num = 1 + return "\n".join(f"{start_num + i}: {line}" for i, line in enumerate(lines)) + elif command == "str_replace": + if old_str is None or new_str is None: + return "Error: str_replace requires both old_str and new_str parameters" + if not os.path.exists(path): + return f"Error: File '{path}' does not exist" + content = open(path).read() + if old_str not in content: + return "Error: Text not found in file" + count = content.count(old_str) + if count > 1: + return f"Error: Text appears {count} times in file. Please be more specific." + open(path, "w").write(content.replace(old_str, new_str, 1)) + return f"Successfully replaced text in '{path}'" + elif command == "create": + if file_text is None: + return "Error: create requires file_text parameter" + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + open(path, "w").write(file_text) + return f"Successfully created file '{path}'" + elif command == "insert": + if new_str is None or insert_line is None: + return "Error: insert requires both new_str and insert_line parameters" + if not os.path.exists(path): + return f"Error: File '{path}' does not exist" + lines = open(path).read().splitlines(True) + if insert_line == 0: + lines.insert(0, new_str + "\n") + elif insert_line >= len(lines): + lines.append(new_str + "\n") + else: + lines.insert(insert_line, new_str + "\n") + open(path, "w").write("".join(lines)) + return f"Successfully inserted text in '{path}' at line {insert_line + 1}" + else: + return f"Error: Unknown command '{command}'" + except Exception as e: + return f"Error: {e}" + +tools.append(file_operations) +{{/if}} +{{#if needsOs}}{{#unless isExportHarness}} +_MOUNT_PATHS = [ + {{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}} + {{#each efsMounts}}"{{mountPath}}",{{/each}} + {{#each s3Mounts}}"{{mountPath}}",{{/each}} +] + +def _safe_resolve(path: str) -> str: + resolved = os.path.realpath(path) + if not any(resolved == os.path.realpath(m) or resolved.startswith(os.path.realpath(m) + os.sep) for m in _MOUNT_PATHS): + raise ValueError(f"Path '{path}' is not within any configured mount ({', '.join(_MOUNT_PATHS)})") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt).""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write a file to a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt).""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(path: str) -> str: + """List files in a mounted filesystem directory. Use the absolute path (e.g. /mnt/tools).""" + try: + full_path = _safe_resolve(path) + entries = os.listdir(full_path) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{path}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/unless}}{{/if}} + +{{#if (or hasGateway remoteMcpTools)}} +# Add MCP clients to tools +for mcp_client in mcp_clients: + if mcp_client: + tools.append(mcp_client) +{{else}} +{{#unless isExportHarness}} +# Add MCP client to tools if available +for mcp_client in mcp_clients: + if mcp_client: + tools.append(mcp_client) +{{/unless}} +{{/if}} + +{{#if hasConfigBundle}} + +class ConfigBundleHook(HookProvider): + """Injects config bundle values (system prompt, tool descriptions) before each invocation. + + BedrockAgentCoreContext.get_config_bundle() fetches the component configuration + for the current runtime ARN from the config bundle service. The SDK caches the + result and refreshes on bundle version changes. + """ + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + registry.add_callback(BeforeInvocationEvent, self._inject_system_prompt) + registry.add_callback(BeforeToolCallEvent, self._override_tool_desc) + + def _inject_system_prompt(self, event: BeforeInvocationEvent) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + prompt = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT) + + if prompt != event.agent.system_prompt: + event.agent.system_prompt = prompt + + def _override_tool_desc(self, event: BeforeToolCallEvent) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + tool_descs = config.get("toolDescriptions", {}) + + tool_name = event.tool_use["name"] + override = tool_descs.get(tool_name) + if override and event.selected_tool: + spec = event.selected_tool.tool_spec + if spec and "description" in spec: + spec["description"] = override + +{{/if}} + +def _make_conversation_manager(): +{{#if truncationStrategy}} +{{#if (eq truncationStrategy "sliding_window")}} +{{#if truncationConfig}} + return SlidingWindowConversationManager(**{{safeJson truncationConfig}}, per_turn=True) +{{else}} + return SlidingWindowConversationManager(per_turn=True) +{{/if}} +{{else}} +{{#if truncationConfig}} + return SummarizingConversationManager(**{{safeJson truncationConfig}}) +{{else}} + return SummarizingConversationManager() +{{/if}} +{{/if}} +{{else}} + return NullConversationManager() +{{/if}} + +{{#if hasMemory}} +{{#unless hasPayment}} +def agent_factory(): + cache = {} + def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}): + {{#if actorId}} + _actor_id = "{{actorId}}" + {{else}} + _actor_id = user_id + {{/if}} + key = f"{session_id}/{_actor_id}" + if key not in cache: + cache[key] = Agent( + model=load_model(), + session_manager=get_memory_session_manager(session_id, _actor_id), + conversation_manager=_make_conversation_manager(), + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools, + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} + hooks=[ + {{#if hasConfigBundle}} + ConfigBundleHook(), + {{/if}} + ], + ) + return cache[key] + return get_or_create_agent +get_or_create_agent = agent_factory() +{{/unless}} +{{else}} +{{#unless hasPayment}} +# Reuses one Agent per session_id so each session keeps its own in-process +# conversation history (best-effort; resets on cold start). The cache is bounded +# to 128 sessions with LRU eviction (least-recently-used is dropped and its +# history reset) so a single process serving many sessions cannot leak history +# between them or grow without limit. For durable history, attach a session manager. +def agent_factory(): + cache = OrderedDict() + def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}): + if session_id in cache: + cache.move_to_end(session_id) + return cache[session_id] + if len(cache) >= 128: + cache.popitem(last=False) + cache[session_id] = Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools, + conversation_manager=_make_conversation_manager(), + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} + hooks=[ + {{#if hasConfigBundle}} + ConfigBundleHook(), + {{/if}} + ], + ) + return cache[session_id] + return get_or_create_agent +get_or_create_agent = agent_factory() +{{/unless}} +{{/if}} + + +def strip_trailing_tool_use(messages: Any) -> list[dict]: + """Strip toolUse blocks from the tail until the last message has none.""" + if not isinstance(messages, list): + raise ValueError("messages must be a list") + + messages = list(messages) + while messages: + last = messages[-1] + if not isinstance(last, dict): + raise ValueError("each message must be an object") + original_content = last.get("content", []) + if not isinstance(original_content, list) or not all(isinstance(block, dict) for block in original_content): + raise ValueError("each message content value must be a list of content blocks") + + content = [block for block in original_content if "toolUse" not in block] + if len(content) == len(original_content): + break + if content: + messages[-1] = {**last, "content": content} + break + messages.pop() + + return messages + + +def _extract_prompt(payload: dict): + """Accept validated harness messages, tool results, or a plain prompt string.""" + if not isinstance(payload, dict): + raise ValueError("payload must be a JSON object") + if "messages" in payload: + return strip_trailing_tool_use(payload["messages"]) + if "tool_results" in payload: + tool_results = payload["tool_results"] + if not isinstance(tool_results, list) or not all( + isinstance(tool_result, dict) and isinstance(tool_result.get("toolUseId"), str) + for tool_result in tool_results + ): + raise ValueError("tool_results must contain objects with a toolUseId string") + return [{"role": "user", "content": [{"toolResult": { + "toolUseId": tr["toolUseId"], + "status": tr.get("status", "success"), + "content": tr.get("content", []), + }} for tr in tool_results]}] + prompt = payload.get("prompt", "") + if not isinstance(prompt, str): + raise ValueError("prompt must be a string") + return prompt + + +def _has_inline_function_call(messages) -> bool: + """Return True if messages contains an assistant toolUse for an inline function tool.""" + if not _INLINE_FUNCTION_NAMES or not isinstance(messages, list): + return False + for msg in messages: + if msg.get("role") == "assistant": + for block in msg.get("content", []): + if isinstance(block, dict) and block.get("toolUse", {}).get("name") in _INLINE_FUNCTION_NAMES: + return True + return False + + +def _is_inline_function_call(event: dict) -> bool: + """Check if a contentBlockStart event is for an inline function tool.""" + if not _INLINE_FUNCTION_NAMES: + return False + cbs = event.get("contentBlockStart", {}) + start = cbs.get("start", {}) + tool_use = start.get("toolUse") if isinstance(start, dict) else None + return tool_use is not None and tool_use.get("name") in _INLINE_FUNCTION_NAMES + + + +@app.entrypoint +async def invoke(payload, context): + log.info("Invoking Agent.....") + +{{#if hasPayment}} + user_id = payload.get("user_id") or getattr(context, "user_id", "default-user") + instrument_id = payload.get("payment_instrument_id") + session_id = payload.get("payment_session_id") + payments_plugin = create_payments_plugin(user_id, instrument_id, session_id) + plugins = [payments_plugin] if payments_plugin else [] +{{/if}} +{{#if hasSkillsFetcher}} + skill_paths = [{{#each pathSkills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] + {{#if s3Skills}} + s3_skill_sources = [{{#each s3Skills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] + skill_paths.extend(await asyncio.to_thread(resolve_s3_skills, s3_skill_sources, None)) + {{/if}} + {{#if gitSkills}} + git_skill_sources = [ + {{#each gitSkills}} + dict(url={{safeJson this.url}}{{#if this.path}}, path={{safeJson this.path}}{{/if}}{{#if this.credentialArn}}, credentialArn={{safeJson this.credentialArn}}{{#if this.username}}, username={{safeJson this.username}}{{/if}}{{/if}}), + {{/each}} + ] + {{#if (some gitSkills "credentialArn")}} + _git_identity_client = IdentityClient(os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1"))) + {{else}} + _git_identity_client = None + {{/if}} + skill_paths.extend(await asyncio.to_thread(resolve_git_skills, git_skill_sources, _git_identity_client)) + {{/if}} + _skill_plugins = [AgentSkills(skills=skill_paths)] if skill_paths else [] +{{/if}} + +{{#if hasMemory}} +{{#if hasPayment}} + mem_session_id = getattr(context, 'session_id', 'default-session') + {{#if actorId}} + mem_user_id = "{{actorId}}" + {{else}} + mem_user_id = getattr(context, 'user_id', 'default-user') + {{/if}} + agent = Agent( + model=load_model(), + session_manager=get_memory_session_manager(mem_session_id, mem_user_id), + system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, + tools=tools, + plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} + hooks=[ConfigBundleHook()],{{/if}} + ) +{{else}} + session_id = getattr(context, 'session_id', 'default-session') + {{#if actorId}} + user_id = "{{actorId}}" + {{else}} + user_id = getattr(context, 'user_id', 'default-user') + {{/if}} + agent = get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) +{{/if}} +{{else}} +{{#if hasPayment}} + agent = Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, + tools=tools, + plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} + hooks=[ConfigBundleHook()],{{/if}} + ) +{{else}} + session_id = getattr(context, 'session_id', 'default-session') + agent = get_or_create_agent(session_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) +{{/if}} +{{/if}} + + prompt = _extract_prompt(payload) + + {{#if inlineFunctionTools}} + # If Turn 2 carries the harness-style assistant(toolUse)+user(toolResult) pair, + # strip the placeholder turn Strands stored during Turn 1 so the real toolResult + # is injected cleanly — same protocol as the harness runtime. + if _has_inline_function_call(prompt): + msgs = agent.messages + if len(msgs) >= 2 and any("toolResult" in b for b in msgs[-1].get("content", [])): + del msgs[-2:] + {{/if}} + + {{#if hasExecutionLimits}} + limits = { + {{#if maxIterations}}"turns": {{maxIterations}},{{/if}} + {{#if maxTokens}}"output_tokens": {{maxTokens}},{{/if}} + } or None + cancel_signal = {{#if timeoutSeconds}}threading.Event(){{else}}None{{/if}} + timeout_fired = False + watchdog_task = None + {{#if timeoutSeconds}} + if cancel_signal is not None: + async def _timeout_watchdog(): + nonlocal timeout_fired + await asyncio.sleep({{timeoutSeconds}}) + timeout_fired = True + cancel_signal.set() + watchdog_task = asyncio.create_task(_timeout_watchdog()) + {{/if}} + + try: + stop_reason = None + {{#if inlineFunctionTools}} + hit_inline_function = False + {{/if}} + async for event in agent.stream_async( + prompt, + limits=limits, + cancel_signal=cancel_signal, + ): + if isinstance(event, dict) and "result" in event: + stop_reason = getattr(event["result"], "stop_reason", None) + continue + if not isinstance(event, dict) or "event" not in event: + continue + cbs = event["event"].get("contentBlockStart") + if cbs is not None and not cbs.get("start"): + continue + {{#if inlineFunctionTools}} + if not hit_inline_function: + hit_inline_function = _is_inline_function_call(event["event"]) + {{/if}} + yield event + {{#if inlineFunctionTools}} + if hit_inline_function and "messageStop" in event["event"]: + return + {{/if}} + + if timeout_fired: + yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} + {{#if maxIterations}} + elif stop_reason == "limit_turns": + yield {"event": {"messageStop": {"stopReason": "Max iterations exceeded: {{maxIterations}}"}}} + {{/if}} + {{#if maxTokens}} + elif stop_reason == "limit_output_tokens": + yield {"event": {"messageStop": {"stopReason": "Max output tokens exceeded: {{maxTokens}}"}}} + {{/if}} + finally: + if watchdog_task is not None: + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + {{else}} + {{#if inlineFunctionTools}} + hit_inline_function = False + {{/if}} + async for event in agent.stream_async( + prompt, + ): + if not isinstance(event, dict) or "event" not in event: + continue + cbs = event["event"].get("contentBlockStart") + if cbs is not None and not cbs.get("start"): + continue + {{#if inlineFunctionTools}} + if not hit_inline_function: + hit_inline_function = _is_inline_function_call(event["event"]) + {{/if}} + yield event + {{#if inlineFunctionTools}} + if hit_inline_function and "messageStop" in event["event"]: + return + {{/if}} + {{/if}} + + +if __name__ == "__main__": + app.run() diff --git a/src/assets/templates/export-harness-python/mcp_client/__init__.py b/src/assets/templates/export-harness-python/mcp_client/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/templates/export-harness-python/mcp_client/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/templates/export-harness-python/mcp_client/client.py b/src/assets/templates/export-harness-python/mcp_client/client.py new file mode 100644 index 000000000..9cf57422d --- /dev/null +++ b/src/assets/templates/export-harness-python/mcp_client/client.py @@ -0,0 +1,119 @@ +import os +import logging +from mcp.client.streamable_http import streamablehttp_client +from strands.tools.mcp.mcp_client import MCPClient + +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client +{{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="{{#if authFlow}}{{authFlow}}{{else}}M2M{{/if}}", +{{#if customParameters}} + custom_parameters={{safeJson customParameters}}, +{{/if}} +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token + +{{/if}} +{{/each}} +{{#each gatewayProviders}} +def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client connected to the {{name}} gateway.""" + {{#if hardcodedUrl}} + url = {{safeJson hardcodedUrl}} + {{else}} + url = os.environ.get("{{envVarName}}") + if not url: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + return None + {{/if}} + {{#if (eq authType "AWS_IAM")}} + return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}") + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else {} + return MCPClient(lambda: streamablehttp_client(url, headers=headers), prefix="{{snakeCase name}}") + {{else}} + return MCPClient(lambda: streamablehttp_client(url), prefix="{{snakeCase name}}") + {{/if}} + +{{/each}} +def get_all_gateway_mcp_clients() -> list[MCPClient]: + """Returns MCP clients for all configured gateways.""" + clients = [] + {{#each gatewayProviders}} + client = get_{{snakeCase name}}_mcp_client() + if client: + clients.append(client) + {{/each}} + return clients +{{/if}} +{{#if remoteMcpTools}} +{{#if (some remoteMcpTools "headerCredentials")}} +from bedrock_agentcore.identity.auth import requires_api_key +{{/if}} +{{#each remoteMcpTools}} +{{#if headerCredentials}} +{{#each headerCredentials}} +@requires_api_key(provider_name="{{credentialName}}") +def _get_{{pythonName}}_key(api_key: str) -> str: + """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" + return api_key + +{{/each}} +{{/if}} +def get_{{pythonName}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client for the {{name}} remote MCP server.""" + url = {{safeJson url}} + {{#if headerCredentials}} + def transport(): + if os.getenv("LOCAL_DEV") == "1": + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } + else: + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{pythonName}}_key(){{#unless @last}}, {{/unless}}{{/each}} } + return streamablehttp_client(url, headers=headers) + + return MCPClient(transport) + {{else}} + return MCPClient(lambda: streamablehttp_client(url)) + {{/if}} + +{{/each}} +def get_all_remote_mcp_clients() -> list[MCPClient]: + """Returns all configured remote MCP clients.""" + clients = [{{#each remoteMcpTools}}get_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] + return [c for c in clients if c is not None] +{{/if}} +{{#unless (or hasGateway remoteMcpTools)}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MCPClient | None: + """No MCP server configured. Add a gateway with `agentcore add gateway`.""" + return None +{{else}} +{{#unless isExportHarness}} +# ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication +EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" + +def get_streamable_http_mcp_client() -> MCPClient: + """Returns an MCP Client compatible with Strands""" + # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} + return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) +{{/unless}} +{{/if}} +{{/unless}} diff --git a/src/assets/templates/export-harness-python/memory/__init__.py b/src/assets/templates/export-harness-python/memory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/assets/templates/export-harness-python/memory/session.py b/src/assets/templates/export-harness-python/memory/session.py new file mode 100644 index 000000000..38bcf49f9 --- /dev/null +++ b/src/assets/templates/export-harness-python/memory/session.py @@ -0,0 +1,47 @@ +import os +import uuid +from typing import Optional + +from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryStrategies.length}}, RetrievalConfig{{/if}} +from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager + +MEMORY_ID = os.getenv("{{memoryEnvVarName}}") +REGION = os.getenv("AWS_REGION") + + +def get_memory_session_manager( + session_id: Optional[str], actor_id: str +) -> Optional[AgentCoreMemorySessionManager]: + if not MEMORY_ID: + return None + + session_id = session_id or uuid.uuid4().hex + +{{#if memoryStrategies.length}} + retrieval_config = { +{{#if (includes memoryStrategies "SEMANTIC")}} + f"/users/{actor_id}/facts": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} +{{#if (includes memoryStrategies "USER_PREFERENCE")}} + f"/users/{actor_id}/preferences": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} +{{#if (includes memoryStrategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}5{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} +{{#if (includes memoryStrategies "SUMMARIZATION")}} + f"/summaries/{actor_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} + } +{{/if}} + + return AgentCoreMemorySessionManager( + AgentCoreMemoryConfig( + memory_id=MEMORY_ID, + session_id=session_id, + actor_id=actor_id, +{{#if memoryStrategies.length}} + retrieval_config=retrieval_config, +{{/if}} + ), + REGION, + ) diff --git a/src/assets/templates/export-harness-python/model/__init__.py b/src/assets/templates/export-harness-python/model/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/templates/export-harness-python/model/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/templates/export-harness-python/model/load.py b/src/assets/templates/export-harness-python/model/load.py new file mode 100644 index 000000000..1cbcc4de9 --- /dev/null +++ b/src/assets/templates/export-harness-python/model/load.py @@ -0,0 +1,249 @@ +{{#if (eq modelProvider "Bedrock")}} +{{#if bedrockMantle}} +import os + +from aws_bedrock_token_generator import provide_token +{{#if (eq mantleApiFormat "chat_completions")}} +from strands.models.openai import OpenAIModel +{{else}} +{{#if mantleProprietary}} +from strands.models.openai_responses import OpenAIResponsesModel +{{else}} +from model.mantle_compat import MantleCompatResponsesModel +{{/if}} +{{/if}} + +MODEL_ID = "{{modelId}}" + + +def load_model(): + """ + Get a Bedrock Mantle model client. These OpenAI-compatible models (e.g. openai.gpt-5.5, + openai.gpt-oss-120b) are served via the Bedrock Mantle endpoint, NOT the Converse API — so they + are invoked through an OpenAI-style client authenticated with a short-lived Bedrock bearer token. + Region is read from AWS_REGION (set by the AgentCore runtime). + """ + region = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1")) + token = provide_token(region=region) + {{#if mantleProprietary}} + # Proprietary OpenAI models only work on the /openai/v1 Mantle path. + base_url = f"https://bedrock-mantle.{region}.api.aws/openai/v1" + {{else}} + # Open-source OpenAI models (gpt-oss-*) only work on the /v1 Mantle path. + base_url = f"https://bedrock-mantle.{region}.api.aws/v1" + {{/if}} + client_args = {"api_key": token, "base_url": base_url} + + params = {} + {{#if modelMaxTokens}} + {{#if (eq mantleApiFormat "chat_completions")}} + params["max_completion_tokens"] = {{modelMaxTokens}} + {{else}} + params["max_output_tokens"] = {{modelMaxTokens}} + {{/if}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + {{#if (eq mantleApiFormat "chat_completions")}} + return OpenAIModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{else}} + # Responses API: Mantle does not persist responses, so disable server-side storage. + params["store"] = False + {{#if mantleProprietary}} + return OpenAIResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{else}} + return MantleCompatResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{/if}} + {{/if}} +{{else}} +from strands.models.bedrock import BedrockModel + + +def load_model() -> BedrockModel: + """Get Bedrock model client using IAM credentials.""" + return BedrockModel( + model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}", +{{#if modelMaxTokens}} + max_tokens={{modelMaxTokens}}, +{{/if}} +{{#if modelTemperature}} + temperature={{modelTemperature}}, +{{/if}} +{{#if modelTopP}} + top_p={{modelTopP}}, +{{/if}} + ) +{{/if}} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import os + +{{#if (eq modelApiFormat "responses")}} +from strands.models.openai_responses import OpenAIResponsesModel +{{else}} +from strands.models.openai import OpenAIModel +{{/if}} +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model(): + """Get authenticated OpenAI model client.""" + params = {} + {{#if modelMaxTokens}} + params["{{#if (eq modelApiFormat "responses")}}max_output_tokens{{else}}max_completion_tokens{{/if}}"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + return {{#if (eq modelApiFormat "responses")}}OpenAIResponsesModel{{else}}OpenAIModel{{/if}}( + client_args={"api_key": _get_api_key()}, + model_id="{{#if modelId}}{{modelId}}{{else}}gpt-4.1{{/if}}", + params=params, + ) +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import os + +from strands.models.gemini import GeminiModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> GeminiModel: + """Get authenticated Gemini model client.""" + params = {} + {{#if modelMaxTokens}} + params["max_output_tokens"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + {{#if modelTopK}} + params["top_k"] = {{modelTopK}} + {{/if}} + return GeminiModel( + client_args={"api_key": _get_api_key()}, + model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", + params=params, + ) +{{/if}} +{{#if (eq modelProvider "LiteLLM")}} +import os +{{#if litellmAdditionalParams}} +import json +{{/if}} + +from strands.models.litellm import LiteLLMModel +{{#if identityProviders.[0].name}} +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() +{{/if}} + + + + +def load_model() -> LiteLLMModel: + """Get a LiteLLM model client (proxies to the provider encoded in model_id).""" + client_args = {} + {{#if identityProviders.[0].name}} + client_args["api_key"] = _get_api_key() + {{/if}} + {{#if litellmApiBase}} + client_args["api_base"] = {{safeJson litellmApiBase}} + {{/if}} + params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} + {{#if modelMaxTokens}} + params["max_tokens"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + return LiteLLMModel( + client_args=client_args, + model_id="{{#if modelId}}{{modelId}}{{else}}bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0{{/if}}", + params=params, + ) +{{/if}} diff --git a/src/assets/templates/export-harness-python/model/mantle_compat.py b/src/assets/templates/export-harness-python/model/mantle_compat.py new file mode 100644 index 000000000..4607a3517 --- /dev/null +++ b/src/assets/templates/export-harness-python/model/mantle_compat.py @@ -0,0 +1,21 @@ +from strands.models.openai_responses import OpenAIResponsesModel + + +class MantleCompatResponsesModel(OpenAIResponsesModel): + """Workaround for Bedrock Mantle rejecting output_text in EasyInputMessage content arrays. + + Mantle's Pydantic validation only accepts content as a plain string for assistant messages, while + real OpenAI accepts both formats. Flatten assistant content arrays to strings so multi-turn works. + Used for open-source OpenAI models (gpt-oss-*) on the /v1 Mantle path; proprietary models use the + plain OpenAIResponsesModel on /openai/v1. + """ + + @classmethod + def _format_request_messages(cls, messages): + formatted = super()._format_request_messages(messages) + for msg in formatted: + if msg.get("role") == "assistant" and isinstance(msg.get("content"), list): + msg["content"] = "".join( + part.get("text", "") for part in msg["content"] if part.get("type") == "output_text" + ) + return formatted diff --git a/src/assets/templates/export-harness-python/pyproject.toml b/src/assets/templates/export-harness-python/pyproject.toml new file mode 100644 index 000000000..612830ed1 --- /dev/null +++ b/src/assets/templates/export-harness-python/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["hatchling ~= 1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore Runtime Application using Strands SDK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "aws-opentelemetry-distro ~= 0.18.0", + "bedrock-agentcore ~= 1.9.1", + "botocore[crt] ~= 1.43.0", + "mcp >= 1.23.0, < 2.0.0", + {{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0", + {{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} ~= 1.54.0", + {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools ~= 0.1.0", + {{/if}}{{#if hasBrowser}}"nest-asyncio ~= 1.5.0", + "playwright ~= 1.42.0", + {{/if}}{{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws ~= 1.1.0", + {{/if}}{{/if}} +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/templates/export-harness-python/skills/fetcher.py b/src/assets/templates/export-harness-python/skills/fetcher.py new file mode 100644 index 000000000..2f82cd6c2 --- /dev/null +++ b/src/assets/templates/export-harness-python/skills/fetcher.py @@ -0,0 +1,279 @@ +"""Skill fetcher — downloads s3/git skills to local filesystem on first use. + +Resolved paths are passed to AgentSkills(skills=...) in main.py. +Cache directory: /.agents/skills/ — an absolute path under the system temp +directory (honors $TMPDIR, defaults to /tmp). The runtime working directory (e.g. +/var/task in a CodeZip runtime) is read-only, so the cache must live somewhere +guaranteed-writable. +""" + +import base64 +import hashlib +import json +import logging +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_SKILLS_BASE = Path(tempfile.gettempdir()) / ".agents" / "skills" +_GIT_TIMEOUT = 60 +_S3_MAX_SIZE_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB + + +def _stable_hash(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest()[:12] + + +def _cleanup(path: Path) -> None: + """Remove a partially-created skill directory so retries don't see stale state.""" + shutil.rmtree(path, ignore_errors=True) + + +def _read_map(type_dir: Path) -> dict: + map_file = type_dir / ".map.json" + return json.loads(map_file.read_text()) if map_file.exists() else {} + + +def _write_map(type_dir: Path, mapping: dict) -> None: + type_dir.mkdir(parents=True, exist_ok=True) + (type_dir / ".map.json").write_text(json.dumps(mapping)) + + +def _resolve_cached(type_dir: Path, source_hash: str) -> Optional[str]: + """Return the cached skill directory for a source hash, or None if not on disk.""" + mapping = _read_map(type_dir) + dir_name = mapping.get(source_hash) + if dir_name and (type_dir / dir_name).exists(): + return str(type_dir / dir_name) + return None + + +def _read_skill_name(skill_dir: Path) -> str: + """Extract the skill name from SKILL.md YAML frontmatter.""" + content = (skill_dir / "SKILL.md").read_text() + if not content.startswith("---"): + raise ValueError(f"SKILL.md in {skill_dir} has no YAML frontmatter (must start with ---)") + parts = content.split("---", 2) + if len(parts) < 3: + raise ValueError(f"SKILL.md in {skill_dir} has malformed frontmatter (missing closing ---)") + for line in parts[1].strip().splitlines(): + if line.startswith("name:"): + name = line[len("name:"):].strip().strip("\"'") + if name: + return name + raise ValueError(f"SKILL.md in {skill_dir} is missing a 'name' field in frontmatter") + + +def _pick_dir_name(type_dir: Path, name: str, source_hash: str) -> str: + """Pick a unique directory name, appending a hash suffix on collision.""" + if not (type_dir / name).exists(): + return name + return f"{name}-{source_hash[:8]}" + + +def _rename_and_cache_skill(type_dir: Path, temp_dir: Path, source_hash: str, skill_root: Path, + source_label: str = "") -> Path: + """Validate SKILL.md, rename the temp dir to the skill's declared name, and update the map. + + Raises ValueError if SKILL.md is missing or has invalid frontmatter. + """ + if not (skill_root / "SKILL.md").exists(): + _cleanup(temp_dir) + hint = f" (source: {source_label})" if source_label else "" + raise ValueError(f"No SKILL.md found in fetched skill{hint}") + + name = _read_skill_name(skill_root) + dir_name = _pick_dir_name(type_dir, name, source_hash) + final_dir = type_dir / dir_name + if final_dir != temp_dir: + temp_dir.rename(final_dir) + + mapping = _read_map(type_dir) + mapping[source_hash] = dir_name + _write_map(type_dir, mapping) + return final_dir + + +def _fetch_s3_skill(source: str, s3_client=None) -> Path: + """Download an s3:// skill prefix and return the local directory.""" + uri = source if source.endswith("/") else source + "/" + source_hash = _stable_hash(uri) + type_dir = _SKILLS_BASE / "s3" + + cached = _resolve_cached(type_dir, source_hash) + if cached: + return Path(cached) + + import boto3 + client = s3_client or boto3.client("s3") + bucket, _, prefix = uri[len("s3://"):].partition("/") + if not bucket: + raise ValueError(f"Invalid S3 URI (no bucket): {uri}") + + temp_dir = type_dir / source_hash + _cleanup(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + temp_root = temp_dir.resolve() + + paginator = client.get_paginator("list_objects_v2") + total = 0 + for page in paginator.paginate(Bucket=bucket, Prefix=prefix): + for obj in page.get("Contents", []): + total += obj["Size"] + if total > _S3_MAX_SIZE_BYTES: + _cleanup(temp_dir) + raise ValueError(f"S3 skill {uri} exceeds 1 GB size limit") + rel = obj["Key"][len(prefix):].lstrip("/") + if not rel: + continue + dest = (temp_dir / rel).resolve() + if dest != temp_root and not str(dest).startswith(str(temp_root) + os.sep): + _cleanup(temp_dir) + raise ValueError(f"Path traversal detected in S3 key: {obj['Key']}") + dest.parent.mkdir(parents=True, exist_ok=True) + client.download_file(bucket, obj["Key"], str(dest)) + + if total == 0: + _cleanup(temp_dir) + raise ValueError(f"No files found at S3 URI: {uri}") + + return _rename_and_cache_skill(type_dir, temp_dir, source_hash, temp_dir, source_label=uri) + + +def _resolve_credential_arn(credential_arn: str, identity_client) -> str: + """Resolve a Token Vault API-key credential ARN to its secret value via AgentCore Identity. + + ARN format: arn:

:bedrock-agentcore:::token-vault//apikeycredentialprovider/ + """ + from bedrock_agentcore.runtime.context import BedrockAgentCoreContext # noqa: PLC0415 + + provider_name = credential_arn.rsplit("/", 1)[-1] + if not provider_name: + raise ValueError(f"Invalid credential ARN: {credential_arn}") + workload_token = BedrockAgentCoreContext.get_workload_access_token() + if not workload_token: + raise ValueError("Credential ARN resolution requires a workload access token") + api_key = identity_client.dp_client.get_resource_api_key( + resourceCredentialProviderName=provider_name, + workloadIdentityToken=workload_token, + )["apiKey"] + if not api_key: + raise ValueError(f"Identity returned empty API key for provider: {provider_name}") + return api_key + + +def _build_git_auth_env(credential_arn: Optional[str], username: Optional[str], identity_client=None) -> dict: + """Build GIT_CONFIG_* env vars for HTTP Basic auth using a Token Vault credential ARN. + + Uses env vars instead of -c args to avoid leaking credentials in /proc/*/cmdline, + and so auth propagates to sub-commands (e.g. sparse-checkout triggering a fetch). + """ + if not credential_arn or not identity_client: + return {} + password = _resolve_credential_arn(credential_arn, identity_client) + user = username or "oauth2" + encoded = base64.b64encode(f"{user}:{password}".encode()).decode() + return { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraHeader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {encoded}", + } + + +def _fetch_git_skill(url: str, skill_path: str = "", credential_arn: Optional[str] = None, + username: Optional[str] = None, identity_client=None) -> Path: + """Shallow-clone a git skill repository and return the local skill directory. + + Returns the directory containing SKILL.md (the subdir itself for sparse checkouts). + """ + if skill_path and (os.path.isabs(skill_path) or ".." in Path(skill_path).parts): + raise ValueError(f"Path traversal detected in skill path: {skill_path}") + + source_hash = _stable_hash(f"{url}:{skill_path}") + type_dir = _SKILLS_BASE / "git" + + cached = _resolve_cached(type_dir, source_hash) + if cached: + return Path(cached) / skill_path if skill_path else Path(cached) + + temp_dir = type_dir / source_hash + _cleanup(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + + extra_env = _build_git_auth_env(credential_arn, username, identity_client) + git_env = {**os.environ, **extra_env} if extra_env else None + + try: + if skill_path: + subprocess.run( + ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", url, str(temp_dir)], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, + ) + subprocess.run( + ["git", "sparse-checkout", "set", skill_path], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, cwd=str(temp_dir), env=git_env, + ) + else: + subprocess.run( + ["git", "clone", "--depth", "1", url, str(temp_dir)], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, + ) + except Exception: + _cleanup(temp_dir) + raise + + if skill_path and not (temp_dir / skill_path).exists(): + _cleanup(temp_dir) + raise ValueError(f"Skill path '{skill_path}' not found in repository '{url}'") + + # SKILL.md lives inside the subdir for sparse checkouts. + skill_root = temp_dir / skill_path if skill_path else temp_dir + label = f"{url}:{skill_path}" if skill_path else url + final_dir = _rename_and_cache_skill(type_dir, temp_dir, source_hash, skill_root, source_label=label) + return final_dir / skill_path if skill_path else final_dir + + +def resolve_s3_skills(sources: list, s3_client=None) -> list: + """Resolve s3:// skill URIs to local filesystem paths. + + Any fetch failure raises and fails the invocation — a partial skill set + would silently run the agent without capabilities the harness declared. + """ + paths = [] + for uri in sources: + try: + skill_dir = _fetch_s3_skill(uri, s3_client) + except Exception as e: + raise ValueError(f"Failed to resolve S3 skill '{uri}': {e}") from e + paths.append(str(skill_dir.resolve())) + return paths + + +def resolve_git_skills(sources: list, identity_client=None) -> list: + """Resolve git skill dicts to local filesystem paths. + + Each source is a dict with keys: url (required), path (optional), + credentialArn (optional), username (optional). + + Any fetch failure raises and fails the invocation — a partial skill set + would silently run the agent without capabilities the harness declared. + """ + paths = [] + for source in sources: + try: + skill_dir = _fetch_git_skill( + url=source["url"], + skill_path=source.get("path") or "", + credential_arn=source.get("credentialArn"), + username=source.get("username"), + identity_client=identity_client, + ) + except Exception as e: + raise ValueError(f"Failed to resolve git skill '{source.get('url', source)}': {e}") from e + paths.append(str(skill_dir.resolve())) + return paths diff --git a/src/assets/templates/strands-http-python/main.py b/src/assets/templates/strands-http-python/main.py index ed5b0b792..69dca7e8e 100644 --- a/src/assets/templates/strands-http-python/main.py +++ b/src/assets/templates/strands-http-python/main.py @@ -17,21 +17,23 @@ {{/if}} {{/if}} import asyncio -{{#if timeoutSeconds}} -import threading -{{/if}} {{#if hasShell}} import subprocess {{/if}} {{#if hasFileOperations}} import os {{/if}} +{{#if hasExecutionLimits}} +from strands.tools.executors import SequentialToolExecutor +from strands.types.exceptions import EventLoopException +from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook +{{/if}} {{#if hasConfigBundle}} from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent {{/if}} {{#if truncationStrategy}} {{#if (eq truncationStrategy "sliding_window")}} -from strands.agent.conversation_manager import SlidingWindowConversationManager +from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager {{/if}} {{#if (eq truncationStrategy "summarization")}} from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager @@ -411,7 +413,18 @@ def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugi {{#if hasSkillsFetcher}} plugins=skill_plugins or None, {{/if}} + {{#if hasExecutionLimits}} + tool_executor=SequentialToolExecutor(), + callback_handler=None, + {{/if}} hooks=[ + {{#if hasExecutionLimits}} + ExecutionLimitsHook( + {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} + {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} + {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} + ), + {{/if}} {{#if hasConfigBundle}} ConfigBundleHook(), {{/if}} @@ -444,7 +457,18 @@ def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{ {{#if hasSkillsFetcher}} plugins=skill_plugins or None, {{/if}} + {{#if hasExecutionLimits}} + tool_executor=SequentialToolExecutor(), + callback_handler=None, + {{/if}} hooks=[ + {{#if hasExecutionLimits}} + ExecutionLimitsHook( + {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} + {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} + {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} + ), + {{/if}} {{#if hasConfigBundle}} ConfigBundleHook(), {{/if}} @@ -615,36 +639,24 @@ async def invoke(payload, context): {{/if}} {{#if hasExecutionLimits}} - limits = { - {{#if maxIterations}}"turns": {{maxIterations}},{{/if}} - {{#if maxTokens}}"output_tokens": {{maxTokens}},{{/if}} - } or None - cancel_signal = {{#if timeoutSeconds}}threading.Event(){{else}}None{{/if}} + timeout_seconds = {{#if timeoutSeconds}}{{timeoutSeconds}}{{else}}None{{/if}} timeout_fired = False watchdog_task = None - {{#if timeoutSeconds}} - if cancel_signal is not None: + if timeout_seconds is not None: async def _timeout_watchdog(): nonlocal timeout_fired - await asyncio.sleep({{timeoutSeconds}}) + await asyncio.sleep(timeout_seconds) timeout_fired = True - cancel_signal.set() + agent.cancel() watchdog_task = asyncio.create_task(_timeout_watchdog()) - {{/if}} try: - stop_reason = None {{#if inlineFunctionTools}} hit_inline_function = False {{/if}} async for event in agent.stream_async( prompt, - limits=limits, - cancel_signal=cancel_signal, ): - if isinstance(event, dict) and "result" in event: - stop_reason = getattr(event["result"], "stop_reason", None) - continue if not isinstance(event, dict) or "event" not in event: continue cbs = event["event"].get("contentBlockStart") @@ -662,14 +674,11 @@ async def _timeout_watchdog(): if timeout_fired: yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} - {{#if maxIterations}} - elif stop_reason == "limit_turns": - yield {"event": {"messageStop": {"stopReason": "Max iterations exceeded: {{maxIterations}}"}}} - {{/if}} - {{#if maxTokens}} - elif stop_reason == "limit_output_tokens": - yield {"event": {"messageStop": {"stopReason": "Max output tokens exceeded: {{maxTokens}}"}}} - {{/if}} + except EventLoopException as e: + if isinstance(e.original_exception, ExecutionLimitExceeded): + yield {"event": {"messageStop": {"stopReason": str(e.original_exception)}}} + return + raise finally: if watchdog_task is not None: watchdog_task.cancel() diff --git a/src/assets/templates/strands-http-python/mcp_client/client.py b/src/assets/templates/strands-http-python/mcp_client/client.py index 9cf57422d..4de07e43a 100644 --- a/src/assets/templates/strands-http-python/mcp_client/client.py +++ b/src/assets/templates/strands-http-python/mcp_client/client.py @@ -69,24 +69,21 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]: {{#if headerCredentials}} {{#each headerCredentials}} @requires_api_key(provider_name="{{credentialName}}") -def _get_{{pythonName}}_key(api_key: str) -> str: +def _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(api_key: str) -> str: """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" return api_key {{/each}} {{/if}} -def get_{{pythonName}}_mcp_client() -> MCPClient | None: +def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: """Returns an MCP Client for the {{name}} remote MCP server.""" url = {{safeJson url}} {{#if headerCredentials}} - def transport(): - if os.getenv("LOCAL_DEV") == "1": - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } - else: - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{pythonName}}_key(){{#unless @last}}, {{/unless}}{{/each}} } - return streamablehttp_client(url, headers=headers) - - return MCPClient(transport) + if os.getenv("LOCAL_DEV") == "1": + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } + else: + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(){{#unless @last}}, {{/unless}}{{/each}} } + return MCPClient(lambda: streamablehttp_client(url, headers=headers)) {{else}} return MCPClient(lambda: streamablehttp_client(url)) {{/if}} @@ -94,7 +91,7 @@ def transport(): {{/each}} def get_all_remote_mcp_clients() -> list[MCPClient]: """Returns all configured remote MCP clients.""" - clients = [{{#each remoteMcpTools}}get_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] + clients = [{{#each remoteMcpTools}}get_{{snakeCase name}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] return [c for c in clients if c is not None] {{/if}} {{#unless (or hasGateway remoteMcpTools)}} diff --git a/src/assets/templates/strands-http-python/memory/session.py b/src/assets/templates/strands-http-python/memory/session.py index 38bcf49f9..20e105674 100644 --- a/src/assets/templates/strands-http-python/memory/session.py +++ b/src/assets/templates/strands-http-python/memory/session.py @@ -20,16 +20,16 @@ def get_memory_session_manager( {{#if memoryStrategies.length}} retrieval_config = { {{#if (includes memoryStrategies "SEMANTIC")}} - f"/users/{actor_id}/facts": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), + f"/users/{actor_id}/facts": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} {{#if (includes memoryStrategies "USER_PREFERENCE")}} - f"/users/{actor_id}/preferences": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), + f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} {{#if (includes memoryStrategies "EPISODIC")}} - f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}5{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), {{/if}} {{#if (includes memoryStrategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), + f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} } {{/if}} diff --git a/src/assets/templates/strands-http-python/model/load.py b/src/assets/templates/strands-http-python/model/load.py index bd472ead7..0b3b23eac 100644 --- a/src/assets/templates/strands-http-python/model/load.py +++ b/src/assets/templates/strands-http-python/model/load.py @@ -65,18 +65,7 @@ def load_model(): def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" - return BedrockModel( - model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}", -{{#if modelMaxTokens}} - max_tokens={{modelMaxTokens}}, -{{/if}} -{{#if modelTemperature}} - temperature={{modelTemperature}}, -{{/if}} -{{#if modelTopP}} - top_p={{modelTopP}}, -{{/if}} - ) + return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}"{{#if modelMaxTokens}}, max_tokens={{modelMaxTokens}}{{/if}}) {{/if}} {{/if}} {{#if (eq modelProvider "Anthropic")}} @@ -121,11 +110,7 @@ def load_model() -> AnthropicModel: {{#if (eq modelProvider "OpenAI")}} import os -{{#if (eq modelApiFormat "responses")}} -from strands.models.openai_responses import OpenAIResponsesModel -{{else}} from strands.models.openai import OpenAIModel -{{/if}} from bedrock_agentcore.identity.auth import requires_api_key IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" @@ -153,22 +138,11 @@ def _get_api_key() -> str: return _agentcore_identity_api_key_provider() -def load_model(): +def load_model() -> OpenAIModel: """Get authenticated OpenAI model client.""" - params = {} - {{#if modelMaxTokens}} - params["{{#if (eq modelApiFormat "responses")}}max_output_tokens{{else}}max_completion_tokens{{/if}}"] = {{modelMaxTokens}} - {{/if}} - {{#if modelTemperature}} - params["temperature"] = {{modelTemperature}} - {{/if}} - {{#if modelTopP}} - params["top_p"] = {{modelTopP}} - {{/if}} - return {{#if (eq modelApiFormat "responses")}}OpenAIResponsesModel{{else}}OpenAIModel{{/if}}( + return OpenAIModel( client_args={"api_key": _get_api_key()}, model_id="{{#if modelId}}{{modelId}}{{else}}gpt-4.1{{/if}}", - params=params, ) {{/if}} {{#if (eq modelProvider "Gemini")}} @@ -204,23 +178,9 @@ def _get_api_key() -> str: def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" - params = {} - {{#if modelMaxTokens}} - params["max_output_tokens"] = {{modelMaxTokens}} - {{/if}} - {{#if modelTemperature}} - params["temperature"] = {{modelTemperature}} - {{/if}} - {{#if modelTopP}} - params["top_p"] = {{modelTopP}} - {{/if}} - {{#if modelTopK}} - params["top_k"] = {{modelTopK}} - {{/if}} return GeminiModel( client_args={"api_key": _get_api_key()}, model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", - params=params, ) {{/if}} {{#if (eq modelProvider "LiteLLM")}} @@ -271,15 +231,6 @@ def load_model() -> LiteLLMModel: client_args["api_base"] = {{safeJson litellmApiBase}} {{/if}} params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} - {{#if modelMaxTokens}} - params["max_tokens"] = {{modelMaxTokens}} - {{/if}} - {{#if modelTemperature}} - params["temperature"] = {{modelTemperature}} - {{/if}} - {{#if modelTopP}} - params["top_p"] = {{modelTopP}} - {{/if}} return LiteLLMModel( client_args=client_args, model_id="{{#if modelId}}{{modelId}}{{else}}bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0{{/if}}", diff --git a/src/assets/templates/strands-http-python/pyproject.toml b/src/assets/templates/strands-http-python/pyproject.toml index 612830ed1..0d3a70143 100644 --- a/src/assets/templates/strands-http-python/pyproject.toml +++ b/src/assets/templates/strands-http-python/pyproject.toml @@ -9,12 +9,17 @@ description = "AgentCore Runtime Application using Strands SDK" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "aws-opentelemetry-distro ~= 0.18.0", + {{#if (eq modelProvider "Anthropic")}}"anthropic ~= 0.30.0", + {{/if}}"aws-opentelemetry-distro ~= 0.18.0", "bedrock-agentcore ~= 1.9.1", "botocore[crt] ~= 1.43.0", - "mcp >= 1.23.0, < 2.0.0", - {{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0", - {{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} ~= 1.54.0", + {{#if (eq modelProvider "Gemini")}}"google-genai ~= 1.0.0", + {{/if}}"mcp ~= 1.24.0", + {{#if (eq modelProvider "OpenAI")}}"openai ~= 1.0.0", + {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm ~= 1.0.0", + {{/if}}{{#if bedrockMantle}}"openai ~= 1.0.0", + "aws-bedrock-token-generator ~= 1.0.0", + {{/if}}"strands-agents ~= 1.15.0", {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools ~= 0.1.0", {{/if}}{{#if hasBrowser}}"nest-asyncio ~= 1.5.0", "playwright ~= 1.42.0", diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 9c927e0ad..c502cbdae 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -731,16 +731,11 @@ export class FsProjectManager implements ProjectManager { yield { type: "step", message: `Rendering agent code at 'app/${targetAgentName}'` }; const tree = await FsTreeNode.fromAssetSource( { assetSource: this.assetSource }, - { assetDir: "templates/strands-http-python" }, + { assetDir: "templates/export-harness-python" }, { rootDirName: targetAgentName, transformContent: (raw) => this.templateRenderer.render(raw, plan.context), - filter: (name, isDir) => { - if (isDir && name === "memory") return plan.hasMemory; - // Export always emits a CodeZip runtime, so the template's container files are never used. - if (name === "Dockerfile" || name === ".dockerignore") return false; - return true; - }, + filter: (name, isDir) => (isDir && name === "memory" ? plan.hasMemory : true), }, ); From 00c46964bdaed72ddc98a385809f55da3baf2f08 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 2 Sep 2026 18:42:07 +0000 Subject: [PATCH 19/19] refactor(export): drop template branches export cannot reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export template started as a copy of strands-http-python, so it carried branches for capabilities the export mapper hardcodes off: gateway, browser and code-interpreter tools (all reported as manual follow-ups rather than generated), payment and config-bundle (no harness concept), and path skills (now rejected outright). Removes those blocks and the render-context keys that fed them. Verified by generating exports for four harness shapes — default Bedrock, execution limits with sliding-window truncation, LiteLLM, and remote MCP with colliding header names plus s3/git skills — before and after: byte-identical output, so only unreachable branches were removed. The generated agent still resolves strands-agents 1.54 via uv sync and imports. --- .../templates/export-harness-python/main.py | 131 +----------------- .../mcp_client/client.py | 58 +------- .../export-harness-python/pyproject.toml | 6 +- src/core/project/templates/export.test.ts | 8 +- src/core/project/templates/export.ts | 8 -- 5 files changed, 12 insertions(+), 199 deletions(-) diff --git a/src/assets/templates/export-harness-python/main.py b/src/assets/templates/export-harness-python/main.py index ed5b0b792..9424df446 100644 --- a/src/assets/templates/export-harness-python/main.py +++ b/src/assets/templates/export-harness-python/main.py @@ -26,9 +26,6 @@ {{#if hasFileOperations}} import os {{/if}} -{{#if hasConfigBundle}} -from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent -{{/if}} {{#if truncationStrategy}} {{#if (eq truncationStrategy "sliding_window")}} from strands.agent.conversation_manager import SlidingWindowConversationManager @@ -39,24 +36,12 @@ {{else}} from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager {{/if}} -{{#if hasConfigBundle}} -from bedrock_agentcore.runtime.context import BedrockAgentCoreContext -{{/if}} -{{#if hasBrowser}} -from strands_tools.browser import AgentCoreBrowser -{{/if}} -{{#if hasCodeInterpreter}} -from strands_tools.code_interpreter import AgentCoreCodeInterpreter -{{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model -{{#if hasGateway}} -from mcp_client.client import get_all_gateway_mcp_clients -{{/if}} {{#if remoteMcpTools}} from mcp_client.client import get_all_remote_mcp_clients {{/if}} -{{#unless (or hasGateway remoteMcpTools)}} +{{#unless remoteMcpTools}} {{#unless isExportHarness}} from mcp_client.client import get_streamable_http_mcp_client {{/unless}} @@ -65,23 +50,17 @@ from memory.session import get_memory_session_manager {{/if}} {{#unless hasFileOperations}} -{{#if (or needsOs browserIdentifierEnvVar codeInterpreterIdentifierEnvVar (some gitSkills "credentialArn"))}} +{{#if (or needsOs (some gitSkills "credentialArn"))}} import os {{/if}} {{/unless}} -{{#if hasPayment}} -from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT -{{/if}} app = BedrockAgentCoreApp() log = app.logger -{{#if (or hasGateway remoteMcpTools)}} +{{#if remoteMcpTools}} # Define MCP clients for all configured MCP servers (gateways and/or remote MCP) mcp_clients = [] -{{#if hasGateway}} -mcp_clients += get_all_gateway_mcp_clients() -{{/if}} {{#if remoteMcpTools}} mcp_clients += get_all_remote_mcp_clients() {{/if}} @@ -106,9 +85,6 @@ """ {{/if}} -{{#if hasConfigBundle}} -DEFAULT_TOOL_DESC = "Return the sum of two numbers" -{{/if}} # Define a collection of tools used by the model tools = [] @@ -138,11 +114,7 @@ def _handler(tool: ToolUse, **kwargs: Any) -> ToolResult: {{#unless isExportHarness}} # Define a simple function tool -{{#if hasConfigBundle}} -@tool(description=DEFAULT_TOOL_DESC) -{{else}} @tool -{{/if}} def add_numbers(a: int, b: int) -> int: """Return the sum of two numbers""" return a+b @@ -150,22 +122,6 @@ def add_numbers(a: int, b: int) -> int: {{/unless}} {{/if}} -{{#if hasBrowser}} -{{#if browserIdentifierEnvVar}} -_browser_id = os.getenv("{{browserIdentifierEnvVar}}") -tools.append(AgentCoreBrowser(**({"identifier": _browser_id} if _browser_id else {})).browser) -{{else}} -tools.append(AgentCoreBrowser().browser) -{{/if}} -{{/if}} -{{#if hasCodeInterpreter}} -{{#if codeInterpreterIdentifierEnvVar}} -_code_interpreter_id = os.getenv("{{codeInterpreterIdentifierEnvVar}}") -tools.append(AgentCoreCodeInterpreter(**({"identifier": _code_interpreter_id} if _code_interpreter_id else {})).code_interpreter) -{{else}} -tools.append(AgentCoreCodeInterpreter().code_interpreter) -{{/if}} -{{/if}} {{#if hasShell}} @tool def shell(command: str, timeout: int = 300) -> dict: @@ -323,7 +279,7 @@ def list_files(path: str) -> str: tools.extend([file_read, file_write, list_files]) {{/unless}}{{/if}} -{{#if (or hasGateway remoteMcpTools)}} +{{#if remoteMcpTools}} # Add MCP clients to tools for mcp_client in mcp_clients: if mcp_client: @@ -337,39 +293,6 @@ def list_files(path: str) -> str: {{/unless}} {{/if}} -{{#if hasConfigBundle}} - -class ConfigBundleHook(HookProvider): - """Injects config bundle values (system prompt, tool descriptions) before each invocation. - - BedrockAgentCoreContext.get_config_bundle() fetches the component configuration - for the current runtime ARN from the config bundle service. The SDK caches the - result and refreshes on bundle version changes. - """ - - def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: - registry.add_callback(BeforeInvocationEvent, self._inject_system_prompt) - registry.add_callback(BeforeToolCallEvent, self._override_tool_desc) - - def _inject_system_prompt(self, event: BeforeInvocationEvent) -> None: - config = BedrockAgentCoreContext.get_config_bundle() - prompt = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT) - - if prompt != event.agent.system_prompt: - event.agent.system_prompt = prompt - - def _override_tool_desc(self, event: BeforeToolCallEvent) -> None: - config = BedrockAgentCoreContext.get_config_bundle() - tool_descs = config.get("toolDescriptions", {}) - - tool_name = event.tool_use["name"] - override = tool_descs.get(tool_name) - if override and event.selected_tool: - spec = event.selected_tool.tool_spec - if spec and "description" in spec: - spec["description"] = override - -{{/if}} def _make_conversation_manager(): {{#if truncationStrategy}} @@ -391,7 +314,6 @@ def _make_conversation_manager(): {{/if}} {{#if hasMemory}} -{{#unless hasPayment}} def agent_factory(): cache = {} def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}): @@ -412,17 +334,12 @@ def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugi plugins=skill_plugins or None, {{/if}} hooks=[ - {{#if hasConfigBundle}} - ConfigBundleHook(), - {{/if}} ], ) return cache[key] return get_or_create_agent get_or_create_agent = agent_factory() -{{/unless}} {{else}} -{{#unless hasPayment}} # Reuses one Agent per session_id so each session keeps its own in-process # conversation history (best-effort; resets on cold start). The cache is bounded # to 128 sessions with LRU eviction (least-recently-used is dropped and its @@ -445,15 +362,11 @@ def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{ plugins=skill_plugins or None, {{/if}} hooks=[ - {{#if hasConfigBundle}} - ConfigBundleHook(), - {{/if}} ], ) return cache[session_id] return get_or_create_agent get_or_create_agent = agent_factory() -{{/unless}} {{/if}} @@ -533,15 +446,8 @@ def _is_inline_function_call(event: dict) -> bool: async def invoke(payload, context): log.info("Invoking Agent.....") -{{#if hasPayment}} - user_id = payload.get("user_id") or getattr(context, "user_id", "default-user") - instrument_id = payload.get("payment_instrument_id") - session_id = payload.get("payment_session_id") - payments_plugin = create_payments_plugin(user_id, instrument_id, session_id) - plugins = [payments_plugin] if payments_plugin else [] -{{/if}} {{#if hasSkillsFetcher}} - skill_paths = [{{#each pathSkills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] + skill_paths = [] {{#if s3Skills}} s3_skill_sources = [{{#each s3Skills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] skill_paths.extend(await asyncio.to_thread(resolve_s3_skills, s3_skill_sources, None)) @@ -563,22 +469,6 @@ async def invoke(payload, context): {{/if}} {{#if hasMemory}} -{{#if hasPayment}} - mem_session_id = getattr(context, 'session_id', 'default-session') - {{#if actorId}} - mem_user_id = "{{actorId}}" - {{else}} - mem_user_id = getattr(context, 'user_id', 'default-user') - {{/if}} - agent = Agent( - model=load_model(), - session_manager=get_memory_session_manager(mem_session_id, mem_user_id), - system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, - tools=tools, - plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} - hooks=[ConfigBundleHook()],{{/if}} - ) -{{else}} session_id = getattr(context, 'session_id', 'default-session') {{#if actorId}} user_id = "{{actorId}}" @@ -586,20 +476,9 @@ async def invoke(payload, context): user_id = getattr(context, 'user_id', 'default-user') {{/if}} agent = get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) -{{/if}} -{{else}} -{{#if hasPayment}} - agent = Agent( - model=load_model(), - system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, - tools=tools, - plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} - hooks=[ConfigBundleHook()],{{/if}} - ) {{else}} session_id = getattr(context, 'session_id', 'default-session') agent = get_or_create_agent(session_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) -{{/if}} {{/if}} prompt = _extract_prompt(payload) diff --git a/src/assets/templates/export-harness-python/mcp_client/client.py b/src/assets/templates/export-harness-python/mcp_client/client.py index 9cf57422d..ec98d6762 100644 --- a/src/assets/templates/export-harness-python/mcp_client/client.py +++ b/src/assets/templates/export-harness-python/mcp_client/client.py @@ -5,62 +5,6 @@ logger = logging.getLogger(__name__) -{{#if hasGateway}} -{{#if (includes gatewayAuthTypes "AWS_IAM")}} -from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client -{{/if}} -{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} -from bedrock_agentcore.identity import requires_access_token -{{/if}} - -{{#each gatewayProviders}} -{{#if (eq authType "CUSTOM_JWT")}} -@requires_access_token( - provider_name="{{credentialProviderName}}", - scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], - auth_flow="{{#if authFlow}}{{authFlow}}{{else}}M2M{{/if}}", -{{#if customParameters}} - custom_parameters={{safeJson customParameters}}, -{{/if}} -) -def _get_bearer_token_{{snakeCase name}}(*, access_token: str): - """Obtain OAuth access token via AgentCore Identity for {{name}}.""" - return access_token - -{{/if}} -{{/each}} -{{#each gatewayProviders}} -def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: - """Returns an MCP Client connected to the {{name}} gateway.""" - {{#if hardcodedUrl}} - url = {{safeJson hardcodedUrl}} - {{else}} - url = os.environ.get("{{envVarName}}") - if not url: - logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") - return None - {{/if}} - {{#if (eq authType "AWS_IAM")}} - return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}") - {{else if (eq authType "CUSTOM_JWT")}} - token = _get_bearer_token_{{snakeCase name}}() - headers = {"Authorization": f"Bearer {token}"} if token else {} - return MCPClient(lambda: streamablehttp_client(url, headers=headers), prefix="{{snakeCase name}}") - {{else}} - return MCPClient(lambda: streamablehttp_client(url), prefix="{{snakeCase name}}") - {{/if}} - -{{/each}} -def get_all_gateway_mcp_clients() -> list[MCPClient]: - """Returns MCP clients for all configured gateways.""" - clients = [] - {{#each gatewayProviders}} - client = get_{{snakeCase name}}_mcp_client() - if client: - clients.append(client) - {{/each}} - return clients -{{/if}} {{#if remoteMcpTools}} {{#if (some remoteMcpTools "headerCredentials")}} from bedrock_agentcore.identity.auth import requires_api_key @@ -97,7 +41,7 @@ def get_all_remote_mcp_clients() -> list[MCPClient]: clients = [{{#each remoteMcpTools}}get_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] return [c for c in clients if c is not None] {{/if}} -{{#unless (or hasGateway remoteMcpTools)}} +{{#unless remoteMcpTools}} {{#if isVpc}} # VPC mode: external MCP endpoints are not reachable without a NAT gateway. # Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. diff --git a/src/assets/templates/export-harness-python/pyproject.toml b/src/assets/templates/export-harness-python/pyproject.toml index 612830ed1..29262d715 100644 --- a/src/assets/templates/export-harness-python/pyproject.toml +++ b/src/assets/templates/export-harness-python/pyproject.toml @@ -15,11 +15,7 @@ dependencies = [ "mcp >= 1.23.0, < 2.0.0", {{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0", {{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} ~= 1.54.0", - {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools ~= 0.1.0", - {{/if}}{{#if hasBrowser}}"nest-asyncio ~= 1.5.0", - "playwright ~= 1.42.0", - {{/if}}{{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws ~= 1.1.0", - {{/if}}{{/if}} + ] [tool.hatch.build.targets.wheel] diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index df8135e0f..a431dde6b 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -332,9 +332,11 @@ describe("mapHarnessToExportPlan tools", () => { }), }); - expect(result.context.hasBrowser).toBe(false); - expect(result.context.hasCodeInterpreter).toBe(false); - expect(result.context.hasGateway).toBe(false); + // The render context carries nothing for these tools at all, so the template has no + // branch to render them from — the notes below are the whole output. + expect(result.context.hasBrowser).toBeUndefined(); + expect(result.context.hasCodeInterpreter).toBeUndefined(); + expect(result.context.hasGateway).toBeUndefined(); expect(result.context.remoteMcpTools).toBeUndefined(); expect(categories(result)).toEqual([ GATEWAY_TOOL_NOTE_CATEGORY, diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index 3c3221b99..fd882b04a 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -169,8 +169,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport isExportHarness: true, entrypoint: "main", enableOtel: true, - hasConfigBundle: false, - hasPayment: false, isVpc: spec.networkMode === "VPC", protocol: "HTTP", // Model @@ -190,21 +188,15 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport actorId: memory.actorId, // Gateways are never exported as code (see resolveTools); the template still // needs the keys so its conditionals resolve. - hasGateway: false, - gatewayProviders: [], - gatewayAuthTypes: [], // Tools. Empty collections become undefined: the template's custom `or`/ // `some` helpers use JS truthiness, where [] is truthy, unlike `{{#if}}`. inlineFunctionTools: undefinedIfEmpty(tools.inlineFunctionTools), remoteMcpTools: undefinedIfEmpty(tools.remoteMcpTools), hasShell: tools.hasShell, hasFileOperations: tools.hasFileOperations, - hasBrowser: false, - hasCodeInterpreter: false, // Skills hasSkillsFetcher: skills.hasSkillsFetcher, hasFetchedSkills: skills.hasFetchedSkills, - pathSkills: skills.pathSkills, s3Skills: undefinedIfEmpty(skills.s3Skills), gitSkills: undefinedIfEmpty(skills.gitSkills), // Execution limits (numbers are schema-validated >= 1, so plain #if works)