From 8570ab288572d1df0902b891b022e087c4eeb707 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 31 Aug 2026 21:43:01 +0000 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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", () => {