From 7dc2d574377553f27fecd7eded75df4c61ea59e2 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 11 Aug 2026 23:23:53 +0000 Subject: [PATCH 1/3] feat(project): implement add harness scaffolding --- src/core/project/manager.tsx | 18 +- src/handlers/project/add/harness/index.ts | 399 ++++++++++++++++++++++ src/handlers/project/add/index.ts | 20 +- src/handlers/project/add/types.ts | 7 + src/handlers/project/index.ts | 4 +- src/handlers/project/project.test.ts | 243 ++++++++++++- src/handlers/project/types.ts | 19 ++ src/middleware/index.tsx | 1 + 8 files changed, 697 insertions(+), 14 deletions(-) create mode 100644 src/handlers/project/add/harness/index.ts create mode 100644 src/handlers/project/add/types.ts diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index eeaea3ca0..1e601728f 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -6,6 +6,8 @@ import type { Project, ProjectManager, ProjectEvent, + ProjectResource, + ProjectResourceConfig, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { @@ -19,7 +21,12 @@ import { defaultSource, type AssetSource } from "./source"; import { createProjectTreeFromTemplate, TEMPLATES } from "./templates"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; -import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; +import { + DeserializationError, + InputValidationError, + NotImplementedError, + ProjectStateError, +} from "../../errors/errors"; type ProjectManagerConfig = { logger: Logger; @@ -118,6 +125,15 @@ export class FsProjectManager implements ProjectManager { return project; } + // eslint-disable-next-line require-yield + public async *add( + _project: Project, + _resourceType: TResource, + _resourceConfig: ProjectResourceConfig, + ): AsyncGenerator { + throw new NotImplementedError("FsProjectManager.add is not yet implemented"); + } + // Runs a command with its output streamed to the file logger. private run(command: string[], cwd: string): Promise { return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) }); diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts new file mode 100644 index 000000000..c7a4d2a95 --- /dev/null +++ b/src/handlers/project/add/harness/index.ts @@ -0,0 +1,399 @@ +import z from "zod"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; +import { parseJsonFlag } from "../../../utils"; +import { InputValidationError } from "../../../../errors"; +import type { + AuthorizerConfiguration as SdkAuthorizerConfiguration, + HarnessEnvironmentArtifact, + HarnessEnvironmentProviderRequest, + HarnessMemoryConfiguration as SdkMemoryConfiguration, + HarnessModelConfiguration, + HarnessSkill as SdkHarnessSkill, + HarnessTool as SdkHarnessTool, + HarnessTruncationConfiguration as SdkTruncationConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { + HarnessMemoryRef, + HarnessModel, + HarnessSkill, + HarnessTool, + HarnessTruncationConfig, + ManagedMemoryStrategy, +} from "../../../../projectSchemas/harness"; +import type { AuthorizerConfig } from "../../../../projectSchemas/auth"; + +export const createAddHarnessHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "harness", + description: "adds a harness to the active project", + flags: [ + flag("name", "the name of the harness", z.string().optional()), + flag( + "execution-role-arn", + "IAM role the harness assumes; a default role is created when omitted", + z.string().optional(), + ), + flag("system-prompt", "the agent's system prompt", z.string().optional()), + flag("model", "model configuration (JSON HarnessModelConfiguration)", z.string().optional()), + flag("tools", "tools available to the agent (JSON HarnessTool[])", z.string().optional()), + flag("skills", "skills available to the agent (JSON HarnessSkill[])", z.string().optional()), + flag( + "allowed-tools", + "tool allowlist patterns (e.g. * or @serverName/toolName)", + z.array(z.string()).optional(), + ), + flag( + "memory", + "memory configuration (JSON HarnessMemoryConfiguration)", + z.string().optional(), + ), + flag( + "truncation", + "context truncation configuration (JSON HarnessTruncationConfiguration)", + z.string().optional(), + ), + flag( + "environment", + "compute environment configuration (JSON HarnessEnvironmentProviderRequest)", + z.string().optional(), + ), + flag( + "environment-variables", + "environment variables (JSON object of key/value strings)", + z.string().optional(), + ), + flag( + "environment-artifact", + "environment artifact configuration (ex. container image) (JSON HarnessEnvironmentArtifact)", + z.string().optional(), + ), + flag( + "authorizer-configuration", + "inbound authorizer configuration (JSON AuthorizerConfiguration)", + z.string().optional(), + ), + flag("max-iterations", "max agent loop iterations per invocation", z.number().optional()), + flag("max-tokens", "max total output tokens per invocation", z.number().optional()), + flag("timeout-seconds", "max duration in seconds per invocation", z.number().optional()), + flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), + flag( + "dockerfile", + "path to local dockerfile to use as the container image for the harness", + z.string().optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.name) + throw new InputValidationError("required option '--name ' not specified"); + + const inputModelConfig = parseJsonFlag("model", flags["model"]); + const inputTools = parseJsonFlag("tools", flags["tools"]); + const inputSkills = parseJsonFlag("skills", flags["skills"]); + const inputMemory = parseJsonFlag("memory", flags["memory"]); + const inputTruncation = parseJsonFlag( + "truncation", + flags["truncation"], + ); + const inputAuthConfig = parseJsonFlag( + "authorizer-configuration", + flags["authorizer-configuration"], + ); + const inputEnvironment = parseJsonFlag( + "environment", + flags["environment"], + ); + const inputArtifact = parseJsonFlag( + "environment-artifact", + flags["environment-artifact"], + ); + const env = inputEnvironment ? toEnvironment(inputEnvironment) : undefined; + const artifact = inputArtifact ? toEnvironmentArtifact(inputArtifact) : undefined; + + if (inputArtifact?.containerConfiguration?.containerUri && flags.dockerfile) + throw new InputValidationError(`containerUri and dockerfile are mutually exclusive`); + + const harnessConfig = { + name: flags.name, + model: inputModelConfig + ? toModelConfig(inputModelConfig) + : { provider: "bedrock" as const, modelId: "global.anthropic.claude-sonnet-4-6" }, + systemPrompt: flags["system-prompt"], + executionRoleArn: flags["execution-role-arn"], + tools: inputTools?.map(toTool), + skills: inputSkills?.map(toSkill), + allowedTools: flags["allowed-tools"], + memory: inputMemory ? toMemory(inputMemory) : undefined, + truncation: inputTruncation ? toTruncation(inputTruncation) : undefined, + environmentVariables: parseJsonFlag>( + "environment-variables", + flags["environment-variables"], + ), + authorizerType: inputAuthConfig ? ("CUSTOM_JWT" as const) : undefined, + authorizerConfiguration: inputAuthConfig ? toAuthorizerConfig(inputAuthConfig) : undefined, + maxIterations: flags["max-iterations"], + maxTokens: flags["max-tokens"], + timeoutSeconds: flags["timeout-seconds"], + tags: parseJsonFlag>("tags", flags["tags"]), + networkMode: env?.networkMode, + networkConfig: env?.networkConfig, + lifecycleConfig: env?.lifecycleConfig, + sessionStoragePath: env?.sessionStoragePath, + efsAccessPoints: env?.efsAccessPoints, + s3AccessPoints: env?.s3AccessPoints, + containerUri: artifact?.containerUri, + }; + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.add(project, "harness", harnessConfig)) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'`); + }, + }); + +/** Converts the SDK's tagged-union model config into the flat project-schema shape. */ +function toModelConfig(modelConfig: HarnessModelConfiguration): HarnessModel { + function commonFields(c: { + modelId?: string; + maxTokens?: number; + temperature?: number; + topP?: number; + additionalParams?: unknown; + }) { + if (!c.modelId) throw new InputValidationError("modelId is required in model configuration"); + return { + modelId: c.modelId, + maxTokens: c.maxTokens, + temperature: c.temperature, + topP: c.topP, + additionalParams: c.additionalParams as Record | undefined, + }; + } + + if ("bedrockModelConfig" in modelConfig && modelConfig.bedrockModelConfig) { + const c = modelConfig.bedrockModelConfig; + return { provider: "bedrock", ...commonFields(c), apiFormat: c.apiFormat }; + } + if ("openAiModelConfig" in modelConfig && modelConfig.openAiModelConfig) { + const c = modelConfig.openAiModelConfig; + return { + provider: "open_ai", + ...commonFields(c), + apiKeyArn: c.apiKeyArn, + apiFormat: c.apiFormat, + }; + } + if ("geminiModelConfig" in modelConfig && modelConfig.geminiModelConfig) { + const c = modelConfig.geminiModelConfig; + return { provider: "gemini", ...commonFields(c), apiKeyArn: c.apiKeyArn, topK: c.topK }; + } + if ("liteLlmModelConfig" in modelConfig && modelConfig.liteLlmModelConfig) { + const c = modelConfig.liteLlmModelConfig; + return { provider: "lite_llm", ...commonFields(c), apiKeyArn: c.apiKeyArn, apiBase: c.apiBase }; + } + throw new InputValidationError("Unrecognized model configuration variant"); +} + +/** Converts an SDK HarnessTool into the flat project-schema shape. */ +function toTool(tool: SdkHarnessTool): HarnessTool { + if (!tool.type) throw new InputValidationError("tool type is required"); + if (!tool.name) throw new InputValidationError(`tool name is required (type: ${tool.type})`); + if (!tool.config) return { type: tool.type, name: tool.name }; + const c = tool.config; + if ("remoteMcp" in c && c.remoteMcp) { + return { + type: tool.type, + name: tool.name, + config: { remoteMcp: { url: c.remoteMcp.url!, headers: c.remoteMcp.headers } }, + }; + } + if ("agentCoreBrowser" in c && c.agentCoreBrowser) { + return { + type: tool.type, + name: tool.name, + config: { agentCoreBrowser: { browserArn: c.agentCoreBrowser.browserArn } }, + }; + } + if ("agentCoreGateway" in c && c.agentCoreGateway) { + return { + type: tool.type, + name: tool.name, + config: { agentCoreGateway: { gatewayArn: c.agentCoreGateway.gatewayArn! } }, + }; + } + if ("inlineFunction" in c && c.inlineFunction) { + return { + type: tool.type, + name: tool.name, + config: { + inlineFunction: { + description: c.inlineFunction.description!, + inputSchema: c.inlineFunction.inputSchema as Record, + }, + }, + }; + } + if ("agentCoreCodeInterpreter" in c && c.agentCoreCodeInterpreter) { + return { + type: tool.type, + name: tool.name, + config: { + agentCoreCodeInterpreter: { + codeInterpreterArn: c.agentCoreCodeInterpreter.codeInterpreterArn, + }, + }, + }; + } + return { type: tool.type, name: tool.name }; +} + +/** Converts an SDK HarnessSkill tagged union into the project-schema shape. */ +function toSkill(skill: SdkHarnessSkill): HarnessSkill { + if ("path" in skill && skill.path) { + return { path: skill.path }; + } + if ("s3" in skill && skill.s3) { + return { s3Uri: skill.s3.uri! }; + } + if ("git" in skill && skill.git) { + return { + gitUrl: skill.git.url!, + path: skill.git.path, + auth: skill.git.auth + ? { credentialName: skill.git.auth.credentialArn!, username: skill.git.auth.username } + : undefined, + }; + } + if ("awsSkills" in skill && skill.awsSkills) { + return { awsSkills: { paths: skill.awsSkills.paths } }; + } + throw new InputValidationError("Unrecognized skill variant"); +} + +/** Converts an SDK HarnessMemoryConfiguration tagged union into the project-schema shape. */ +function toMemory(memory: SdkMemoryConfiguration): HarnessMemoryRef { + if ("managedMemoryConfiguration" in memory && memory.managedMemoryConfiguration) { + const c = memory.managedMemoryConfiguration; + return { + mode: "managed", + strategies: c.strategies as ManagedMemoryStrategy[] | undefined, + eventExpiryDuration: c.eventExpiryDuration, + encryptionKeyArn: c.encryptionKeyArn, + }; + } + if ("agentCoreMemoryConfiguration" in memory && memory.agentCoreMemoryConfiguration) { + const c = memory.agentCoreMemoryConfiguration; + return { + mode: "existing", + arn: c.arn, + actorId: c.actorId, + messagesCount: c.messagesCount, + }; + } + if ("disabled" in memory && memory.disabled) { + return { mode: "disabled" }; + } + throw new InputValidationError("Unrecognized memory configuration variant"); +} + +/** Converts an SDK HarnessTruncationConfiguration into the project-schema shape. */ +function toTruncation(truncation: SdkTruncationConfiguration): HarnessTruncationConfig { + if (!truncation.strategy) throw new InputValidationError("truncation strategy is required"); + const config = truncation.config; + if (!config) return { strategy: truncation.strategy }; + if ("slidingWindow" in config && config.slidingWindow) { + return { + strategy: truncation.strategy, + config: { slidingWindow: { messagesCount: config.slidingWindow.messagesCount } }, + }; + } + if ("summarization" in config && config.summarization) { + return { + strategy: truncation.strategy, + config: { + summarization: { + summaryRatio: config.summarization.summaryRatio, + preserveRecentMessages: config.summarization.preserveRecentMessages, + summarizationSystemPrompt: config.summarization.summarizationSystemPrompt, + }, + }, + }; + } + return { strategy: truncation.strategy }; +} + +/** Converts an SDK AuthorizerConfiguration tagged union into the project-schema shape. */ +function toAuthorizerConfig(auth: SdkAuthorizerConfiguration): AuthorizerConfig { + if ("customJWTAuthorizer" in auth && auth.customJWTAuthorizer) { + const c = auth.customJWTAuthorizer; + if (!c.discoveryUrl) + throw new InputValidationError("discoveryUrl is required in authorizer configuration"); + return { + customJwtAuthorizer: { + discoveryUrl: c.discoveryUrl, + allowedAudience: c.allowedAudience, + allowedClients: c.allowedClients, + allowedScopes: c.allowedScopes, + }, + }; + } + throw new InputValidationError("Unrecognized authorizer configuration variant"); +} + +/** Decomposes the SDK's environment tagged union into flat HarnessSpec fields. */ +function toEnvironment(env: HarnessEnvironmentProviderRequest) { + if (!("agentCoreRuntimeEnvironment" in env) || !env.agentCoreRuntimeEnvironment) { + throw new InputValidationError("Unrecognized environment configuration variant"); + } + const rt = env.agentCoreRuntimeEnvironment; + const net = rt.networkConfiguration; + const fss = rt.filesystemConfigurations ?? []; + + const sessionStorage = fss.find((f) => "sessionStorage" in f && f.sessionStorage); + const efsAccessPoints = fss.filter((f) => "efsAccessPoint" in f && f.efsAccessPoint); + const s3AccessPoints = fss.filter((f) => "s3FilesAccessPoint" in f && f.s3FilesAccessPoint); + + return { + networkMode: net?.networkMode as "PUBLIC" | "VPC" | undefined, + networkConfig: net?.networkModeConfig + ? { + subnets: net.networkModeConfig.subnets!, + securityGroups: net.networkModeConfig.securityGroups!, + } + : undefined, + lifecycleConfig: rt.lifecycleConfiguration + ? { + idleRuntimeSessionTimeout: rt.lifecycleConfiguration.idleRuntimeSessionTimeout, + maxLifetime: rt.lifecycleConfiguration.maxLifetime, + } + : undefined, + sessionStoragePath: + sessionStorage && "sessionStorage" in sessionStorage + ? sessionStorage.sessionStorage!.mountPath! + : undefined, + efsAccessPoints: + efsAccessPoints.length > 0 + ? efsAccessPoints.map((f) => { + const efs = "efsAccessPoint" in f ? f.efsAccessPoint! : undefined; + return { accessPointArn: efs!.accessPointArn!, mountPath: efs!.mountPath! }; + }) + : undefined, + s3AccessPoints: + s3AccessPoints.length > 0 + ? s3AccessPoints.map((f) => { + const s3 = "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint! : undefined; + return { accessPointArn: s3!.accessPointArn!, mountPath: s3!.mountPath! }; + }) + : undefined, + }; +} + +/** Decomposes the SDK's environment artifact tagged union into flat HarnessSpec fields. */ +function toEnvironmentArtifact(artifact: HarnessEnvironmentArtifact) { + if ("containerConfiguration" in artifact && artifact.containerConfiguration) { + return { containerUri: artifact.containerConfiguration.containerUri! }; + } + throw new InputValidationError("Unrecognized environment artifact variant"); +} diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index add436cc5..8545ccba3 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -1,11 +1,11 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import { withProject } from "../../../middleware/"; +import { Router } from "../../../router"; +import { createAddHarnessHandler } from "./harness"; +import type { AddProjectResourceConfig } from "./types"; -export const createAddProjectHandler = () => - createHandler({ - name: "add", - description: "add a resource to the project", - handle: async () => { - throw new NotImplementedError("agentcore project add is not implemented yet"); - }, - }); +export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { + const projectAdd = new Router("add", "add project resources"); + projectAdd.use(withProject({ projectManager: config.projectManager, cwd: process.cwd() })); + projectAdd.handler(createAddHarnessHandler(config)); + return projectAdd; +} diff --git a/src/handlers/project/add/types.ts b/src/handlers/project/add/types.ts new file mode 100644 index 000000000..26943b932 --- /dev/null +++ b/src/handlers/project/add/types.ts @@ -0,0 +1,7 @@ +import type { AppIO } from "../../../io"; +import type { ProjectManager } from "../types"; + +export type AddProjectResourceConfig = { + projectManager: ProjectManager; + io: AppIO; +}; diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 6c681a32d..c36a84b60 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,13 +1,13 @@ import { Router } from "../../router"; import type { AppIO } from "../../io"; import { createCreateProjectHandler } from "./create"; -import { createAddProjectHandler } from "./add"; import { createRemoveProjectHandler } from "./remove"; import { createDevProjectHandler } from "./dev"; import { createDeployProjectHandler } from "./deploy"; import { createStatusProjectHandler } from "./status"; import { createBuildProjectHandler } from "./build"; import type { ProjectManager } from "./types"; +import { createAddProjectResourceHandler } from "./add"; type ProjectHandlerConfig = { projectManager: ProjectManager; @@ -20,7 +20,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { project.handler( createCreateProjectHandler({ projectManager: config.projectManager, io: config.io }), ); - project.handler(createAddProjectHandler()); + project.handler(createAddProjectResourceHandler(config)); project.handler(createRemoveProjectHandler()); project.handler(createDevProjectHandler()); project.handler(createDeployProjectHandler()); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 8ca6f537a..88d3f3936 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -9,6 +9,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; +import { InputValidationError } from "../../errors"; async function run(args: string[]) { const io = testIO(); @@ -22,7 +23,7 @@ async function run(args: string[]) { return { io, core }; } -describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s", (command) => { +describe.each(["remove", "dev", "deploy", "status", "build"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); @@ -97,3 +98,243 @@ describe("project create", () => { await expect(run(["create", "--name", "MyAgent", "--template", "nonsense"])).rejects.toThrow(); }); }); + +describe("project add harness", () => { + async function scaffoldProject() { + const directory = await inTempDirectory(); + await run(["create", "--name", "TestProject", "--skip-install", "--skip-git"]); + process.chdir(join(directory, "TestProject")); + } + + test.each([ + ["minimal — name only", ["--name", "my-agent"]], + [ + "model — bedrock", + [ + "--name", + "x", + "--model", + '{"bedrockModelConfig":{"modelId":"us.anthropic.claude-sonnet-4-5-20250929-v1:0"}}', + ], + ], + [ + "model — openai", + [ + "--name", + "x", + "--model", + '{"openAiModelConfig":{"modelId":"gpt-4","apiKeyArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:api-key/k"}}', + ], + ], + [ + "model — gemini", + [ + "--name", + "x", + "--model", + '{"geminiModelConfig":{"modelId":"gemini-pro","apiKeyArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:api-key/k"}}', + ], + ], + [ + "model — litellm", + ["--name", "x", "--model", '{"liteLlmModelConfig":{"modelId":"anthropic/claude-3"}}'], + ], + [ + "tools — remote_mcp", + [ + "--name", + "x", + "--tools", + '[{"type":"remote_mcp","name":"mcp1","config":{"remoteMcp":{"url":"https://mcp.example.com"}}}]', + ], + ], + [ + "tools — agentcore_gateway", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_gateway","name":"gw1","config":{"agentCoreGateway":{"gatewayArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g"}}}]', + ], + ], + [ + "tools — agentcore_browser", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_browser","name":"br1","config":{"agentCoreBrowser":{}}}]', + ], + ], + [ + "tools — inline_function", + [ + "--name", + "x", + "--tools", + '[{"type":"inline_function","name":"fn1","config":{"inlineFunction":{"description":"test","inputSchema":{"type":"object"}}}}]', + ], + ], + [ + "tools — agentcore_code_interpreter", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_code_interpreter","name":"ci1","config":{"agentCoreCodeInterpreter":{}}}]', + ], + ], + [ + "tools — no config", + ["--name", "x", "--tools", '[{"type":"agentcore_browser","name":"br1"}]'], + ], + [ + "tools — unrecognized config variant (passes through without config)", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_browser","name":"br1","config":{"someFutureConfig":{}}}]', + ], + ], + ["skills — path", ["--name", "x", "--skills", '[{"path":"./my-skill"}]']], + ["skills — s3", ["--name", "x", "--skills", '[{"s3":{"uri":"s3://bucket/skill/"}}]']], + [ + "skills — git", + [ + "--name", + "x", + "--skills", + '[{"git":{"url":"https://github.com/org/repo","path":"skills/","auth":{"credentialArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:credential/c","username":"oauth2"}}}]', + ], + ], + [ + "skills — awsSkills", + ["--name", "x", "--skills", '[{"awsSkills":{"paths":["core-skills/*"]}}]'], + ], + [ + "memory — managed", + [ + "--name", + "x", + "--memory", + '{"managedMemoryConfiguration":{"strategies":["SEMANTIC"],"eventExpiryDuration":30}}', + ], + ], + [ + "memory — existing", + [ + "--name", + "x", + "--memory", + '{"agentCoreMemoryConfiguration":{"arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m"}}', + ], + ], + ["memory — disabled", ["--name", "x", "--memory", '{"disabled":{}}']], + [ + "truncation — sliding_window", + [ + "--name", + "x", + "--truncation", + '{"strategy":"sliding_window","config":{"slidingWindow":{"messagesCount":40}}}', + ], + ], + [ + "truncation — summarization", + [ + "--name", + "x", + "--truncation", + '{"strategy":"summarization","config":{"summarization":{"summaryRatio":0.5,"preserveRecentMessages":5}}}', + ], + ], + ["truncation — none", ["--name", "x", "--truncation", '{"strategy":"none"}']], + [ + "truncation — unrecognized config variant (passes through strategy only)", + ["--name", "x", "--truncation", '{"strategy":"none","config":{"someFutureStrategy":{}}}'], + ], + [ + "authorizer — customJWT", + [ + "--name", + "x", + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["my-app"]}}', + ], + ], + [ + "environment — VPC + lifecycle", + [ + "--name", + "x", + "--environment", + '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-abc"]}},"lifecycleConfiguration":{"idleRuntimeSessionTimeout":900,"maxLifetime":28800}}}', + ], + ], + [ + "environment — with filesystem mounts", + [ + "--name", + "x", + "--environment", + '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-abc"]}},"filesystemConfigurations":[{"sessionStorage":{"mountPath":"/mnt/data"}},{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}},{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-abc","mountPath":"/mnt/s3"}}]}}', + ], + ], + [ + "environment-artifact — containerUri", + [ + "--name", + "x", + "--environment-artifact", + '{"containerConfiguration":{"containerUri":"123456789012.dkr.ecr.us-east-1.amazonaws.com/my-agent:latest"}}', + ], + ], + ["environment-variables", ["--name", "x", "--environment-variables", '{"LOG_LEVEL":"debug"}']], + ["tags", ["--name", "x", "--tags", '{"team":"ml"}']], + ["allowed-tools", ["--name", "x", "--allowed-tools", "*", "@builtin"]], + [ + "max-iterations, max-tokens, timeout-seconds", + ["--name", "x", "--max-iterations", "10", "--max-tokens", "4096", "--timeout-seconds", "60"], + ], + ])("%s", async (_label, flags) => { + await scaffoldProject(); + // TODO: update to verify that the project updates. + await expect(run(["add", "harness", ...flags])).rejects.toThrow("not yet implemented"); + }); + + test.each([ + ["missing --name", ["--model", '{"bedrockModelConfig":{"modelId":"x"}}']], + ["model without modelId", ["--name", "x", "--model", '{"bedrockModelConfig":{}}']], + ["unrecognized model variant", ["--name", "x", "--model", '{"unknownConfig":{"modelId":"x"}}']], + ["tool without type", ["--name", "x", "--tools", '[{"name":"t1"}]']], + ["tool without name", ["--name", "x", "--tools", '[{"type":"remote_mcp"}]']], + ["unrecognized skill variant", ["--name", "x", "--skills", '[{"unknown":true}]']], + ["unrecognized memory variant", ["--name", "x", "--memory", '{"unknownMemory":{}}']], + [ + "missing truncation strategy", + ["--name", "x", "--truncation", '{"config":{"slidingWindow":{"messagesCount":10}}}'], + ], + [ + "unrecognized authorizer variant", + ["--name", "x", "--authorizer-configuration", '{"unknownAuth":{}}'], + ], + [ + "missing discoveryUrl in authorizer", + [ + "--name", + "x", + "--authorizer-configuration", + '{"customJWTAuthorizer":{"allowedAudience":["a"]}}', + ], + ], + ["unrecognized environment variant", ["--name", "x", "--environment", '{"unknownEnv":{}}']], + [ + "unrecognized environment-artifact variant", + ["--name", "x", "--environment-artifact", '{"unknownArtifact":{}}'], + ], + ])("%s", async (_label, flags) => { + await scaffoldProject(); + await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 4b0478998..5b8050074 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,4 +1,6 @@ +import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import type z from "zod"; /** Available project templates for scaffolding new AgentCore projects. */ export const PROJECT_TEMPLATES = { @@ -8,6 +10,16 @@ export const PROJECT_TEMPLATES = { export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; +/** Resources that may be added to an agentcore project **/ +export const PROJECT_RESOURCE_TYPES = { + harness: { schema: HarnessSpecSchema }, +}; + +export type ProjectResource = keyof typeof PROJECT_RESOURCE_TYPES; +export type ProjectResourceConfig = z.input< + (typeof PROJECT_RESOURCE_TYPES)[TResource]["schema"] +>; + export type CreateProjectInput = { /** The name of the project; also the directory it is scaffolded into. */ name: string; @@ -46,4 +58,11 @@ export interface ProjectManager { /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; + + /** Add a resource to an existing AgentCore project. */ + add( + project: Project, + resourceType: TResource, + resourceConfig: ProjectResourceConfig, + ): AsyncGenerator; } diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index e77355898..4838ad322 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -3,3 +3,4 @@ export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; +export { withProject } from "./withProject"; From bb093caa0d47c1b8db17840354ba3ce4639a1c1e Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 13 Aug 2026 19:06:26 +0000 Subject: [PATCH 2/3] refactor(test): clean up tests --- src/handlers/project/add/harness/index.ts | 2 +- src/handlers/project/project.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index af882f61a..4843ba7ab 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -26,7 +26,7 @@ import type { AuthorizerConfig } from "../../../../projectSchemas/auth"; export const createAddHarnessHandler = (config: AddProjectResourceConfig) => createHandler({ name: "harness", - description: "adds a harness to the active project", + description: "adds a harness to the current project", flags: [ flag("name", "the name of the harness", z.string().optional()), flag( diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 4211adf43..bdd1a2699 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -333,6 +333,17 @@ describe("project add harness", () => { "unrecognized environment-artifact variant", ["--name", "x", "--environment-artifact", '{"unknownArtifact":{}}'], ], + [ + "containerUri and dockerfile are mutually exclusive", + [ + "--name", + "x", + "--environment-artifact", + '{"containerConfiguration":{"containerUri":"123456789012.dkr.ecr.us-east-1.amazonaws.com/img:v1"}}', + "--dockerfile", + "Dockerfile", + ], + ], ])("%s", async (_label, flags) => { await scaffoldProject(); await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); From d9138cdd5b96ff5ec131ebbaf62077b130a1b17c Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 13 Aug 2026 19:11:59 +0000 Subject: [PATCH 3/3] refactor(project): rename add to addResource --- src/core/project/manager.tsx | 4 +- src/handlers/project/add/harness/index.ts | 81 ++++++++++++++++++----- src/handlers/project/project.test.ts | 34 +++++----- src/handlers/project/types.ts | 2 +- 4 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index e3f3b45d9..dbbce6106 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -131,12 +131,12 @@ export class FsProjectManager implements ProjectManager { } // eslint-disable-next-line require-yield - public async *add( + public async *addResource( _project: Project, _resourceType: TResource, _resourceConfig: ProjectResourceConfig, ): AsyncGenerator { - throw new NotImplementedError("FsProjectManager.add is not yet implemented"); + throw new NotImplementedError("FsProjectManager.addResource is not yet implemented"); } public async *build(project: Project): AsyncGenerator { diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index 4843ba7ab..9e98690d1 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -146,11 +146,15 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) => }; const project = ctx.require(ProjectKey); - for await (const event of config.projectManager.add(project, "harness", harnessConfig)) { + for await (const event of config.projectManager.addResource( + project, + "harness", + harnessConfig, + )) { config.io.stderr.write(`${event.message}\n`); } - config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'`); + config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'\n`); }, }); @@ -207,7 +211,12 @@ function toTool(tool: SdkHarnessTool): HarnessTool { return { type: tool.type, name: tool.name, - config: { remoteMcp: { url: c.remoteMcp.url!, headers: c.remoteMcp.headers } }, + config: { + remoteMcp: { + url: requireField(c.remoteMcp.url, "remoteMcp.url"), + headers: c.remoteMcp.headers, + }, + }, }; } if ("agentCoreBrowser" in c && c.agentCoreBrowser) { @@ -221,7 +230,11 @@ function toTool(tool: SdkHarnessTool): HarnessTool { return { type: tool.type, name: tool.name, - config: { agentCoreGateway: { gatewayArn: c.agentCoreGateway.gatewayArn! } }, + config: { + agentCoreGateway: { + gatewayArn: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"), + }, + }, }; } if ("inlineFunction" in c && c.inlineFunction) { @@ -230,7 +243,7 @@ function toTool(tool: SdkHarnessTool): HarnessTool { name: tool.name, config: { inlineFunction: { - description: c.inlineFunction.description!, + description: requireField(c.inlineFunction.description, "inlineFunction.description"), inputSchema: c.inlineFunction.inputSchema as Record, }, }, @@ -256,14 +269,20 @@ function toSkill(skill: SdkHarnessSkill): HarnessSkill { return { path: skill.path }; } if ("s3" in skill && skill.s3) { - return { s3Uri: skill.s3.uri! }; + return { s3Uri: requireField(skill.s3.uri, "skill.s3.uri") }; } if ("git" in skill && skill.git) { return { - gitUrl: skill.git.url!, + gitUrl: requireField(skill.git.url, "skill.git.url"), path: skill.git.path, auth: skill.git.auth - ? { credentialName: skill.git.auth.credentialArn!, username: skill.git.auth.username } + ? { + credentialName: requireField( + skill.git.auth.credentialArn, + "skill.git.auth.credentialArn", + ), + username: skill.git.auth.username, + } : undefined, }; } @@ -360,8 +379,11 @@ function toEnvironment(env: HarnessEnvironmentProviderRequest) { networkMode: net?.networkMode as "PUBLIC" | "VPC" | undefined, networkConfig: net?.networkModeConfig ? { - subnets: net.networkModeConfig.subnets!, - securityGroups: net.networkModeConfig.securityGroups!, + subnets: requireField(net.networkModeConfig.subnets, "networkConfiguration.subnets"), + securityGroups: requireField( + net.networkModeConfig.securityGroups, + "networkConfiguration.securityGroups", + ), } : undefined, lifecycleConfig: rt.lifecycleConfiguration @@ -372,20 +394,36 @@ function toEnvironment(env: HarnessEnvironmentProviderRequest) { : undefined, sessionStoragePath: sessionStorage && "sessionStorage" in sessionStorage - ? sessionStorage.sessionStorage!.mountPath! + ? requireField( + ("sessionStorage" in sessionStorage ? sessionStorage.sessionStorage : undefined) + ?.mountPath, + "sessionStorage.mountPath", + ) : undefined, efsAccessPoints: efsAccessPoints.length > 0 ? efsAccessPoints.map((f) => { - const efs = "efsAccessPoint" in f ? f.efsAccessPoint! : undefined; - return { accessPointArn: efs!.accessPointArn!, mountPath: efs!.mountPath! }; + const efs = requireField( + "efsAccessPoint" in f ? f.efsAccessPoint : undefined, + "efsAccessPoint", + ); + return { + accessPointArn: requireField(efs.accessPointArn, "efsAccessPoint.accessPointArn"), + mountPath: requireField(efs.mountPath, "efsAccessPoint.mountPath"), + }; }) : undefined, s3AccessPoints: s3AccessPoints.length > 0 ? s3AccessPoints.map((f) => { - const s3 = "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint! : undefined; - return { accessPointArn: s3!.accessPointArn!, mountPath: s3!.mountPath! }; + const s3 = requireField( + "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint : undefined, + "s3FilesAccessPoint", + ); + return { + accessPointArn: requireField(s3.accessPointArn, "s3FilesAccessPoint.accessPointArn"), + mountPath: requireField(s3.mountPath, "s3FilesAccessPoint.mountPath"), + }; }) : undefined, }; @@ -394,7 +432,18 @@ function toEnvironment(env: HarnessEnvironmentProviderRequest) { /** Decomposes the SDK's environment artifact tagged union into flat HarnessSpec fields. */ function toEnvironmentArtifact(artifact: HarnessEnvironmentArtifact) { if ("containerConfiguration" in artifact && artifact.containerConfiguration) { - return { containerUri: artifact.containerConfiguration.containerUri! }; + return { + containerUri: requireField( + artifact.containerConfiguration.containerUri, + "containerConfiguration.containerUri", + ), + }; } throw new InputValidationError("Unrecognized environment artifact variant"); } + +/** Validates a required field is present, throwing with context instead of crashing opaquely. */ +function requireField(value: T | undefined | null, field: string): T { + if (value == null) throw new InputValidationError(`${field} is required`); + return value; +} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index bdd1a2699..d9dce3bba 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -48,6 +48,15 @@ afterEach(async () => { ); }); +/** Scaffolds a project and cds into it so withProject resolves it. */ +async function inProject(name = "TestProject"): Promise { + const directory = await inTempDirectory(); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; +} + describe("project create", () => { test("scaffolds the project into a fresh directory named for the project", async () => { const directory = await inTempDirectory(); @@ -100,12 +109,6 @@ describe("project create", () => { }); describe("project add harness", () => { - async function scaffoldProject() { - const directory = await inTempDirectory(); - await run(["create", "--name", "TestProject", "--skip-install", "--skip-git"]); - process.chdir(join(directory, "TestProject")); - } - test.each([ ["minimal — name only", ["--name", "my-agent"]], [ @@ -298,7 +301,7 @@ describe("project add harness", () => { ["--name", "x", "--max-iterations", "10", "--max-tokens", "4096", "--timeout-seconds", "60"], ], ])("%s", async (_label, flags) => { - await scaffoldProject(); + await inProject(); // TODO: update to verify that the project updates. await expect(run(["add", "harness", ...flags])).rejects.toThrow("not yet implemented"); }); @@ -345,26 +348,21 @@ describe("project add harness", () => { ], ], ])("%s", async (_label, flags) => { - await scaffoldProject(); + await inProject(); await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); }); }); describe("project build", () => { - // Scaffolds a project, then runs from inside it so withProject resolves it. - async function inProject(): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", "MyAgent", "--skip-install", "--skip-git"]); - - const projectRoot = join(directory, "MyAgent"); + async function inBuildableProject(): Promise { + const projectRoot = await inProject("MyAgent"); // create --skip-install leaves no node_modules, which build requires. await mkdir(join(projectRoot, "agentcore", "cdk", "node_modules"), { recursive: true }); - process.chdir(projectRoot); return projectRoot; } test("synthesizes the CDK app of the enclosing project", async () => { - const projectRoot = await inProject(); + const projectRoot = await inBuildableProject(); const { io, core } = await run(["build"]); expect(core.projectCommands).toEqual([ @@ -378,7 +376,7 @@ describe("project build", () => { }); test("resolves the project from a nested directory", async () => { - const projectRoot = await inProject(); + const projectRoot = await inBuildableProject(); process.chdir(join(projectRoot, "app", "hello-world")); const { core } = await run(["build"]); @@ -394,7 +392,7 @@ describe("project build", () => { }); test("fails when the CDK dependencies have not been installed", async () => { - const projectRoot = await inProject(); + const projectRoot = await inBuildableProject(); await rm(join(projectRoot, "agentcore", "cdk", "node_modules"), { recursive: true }); await expect(run(["build"])).rejects.toThrow(/npm install/); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 3a2f266d6..7e8c25cb5 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -66,7 +66,7 @@ export interface ProjectManager { resolve(input: ResolveProjectInput): Promise; /** Add a resource to an existing AgentCore project. */ - add( + addResource( project: Project, resourceType: TResource, resourceConfig: ProjectResourceConfig,