diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c23f4d960..dbbce6106 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; @@ -123,6 +130,15 @@ export class FsProjectManager implements ProjectManager { return project; } + // eslint-disable-next-line require-yield + public async *addResource( + _project: Project, + _resourceType: TResource, + _resourceConfig: ProjectResourceConfig, + ): AsyncGenerator { + throw new NotImplementedError("FsProjectManager.addResource is not yet implemented"); + } + public async *build(project: Project): AsyncGenerator { // agentcore.json records which backend owns the project's artifacts. CDK is the // only one today; a terraform or no-IaC backend adds an arm here rather than diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts new file mode 100644 index 000000000..9e98690d1 --- /dev/null +++ b/src/handlers/project/add/harness/index.ts @@ -0,0 +1,449 @@ +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 current 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, + dockerfile: flags["dockerfile"], + }; + + const project = ctx.require(ProjectKey); + 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}'\n`); + }, + }); + +/** 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: requireField(c.remoteMcp.url, "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: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"), + }, + }, + }; + } + if ("inlineFunction" in c && c.inlineFunction) { + return { + type: tool.type, + name: tool.name, + config: { + inlineFunction: { + description: requireField(c.inlineFunction.description, "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: requireField(skill.s3.uri, "skill.s3.uri") }; + } + if ("git" in skill && skill.git) { + return { + gitUrl: requireField(skill.git.url, "skill.git.url"), + path: skill.git.path, + auth: skill.git.auth + ? { + credentialName: requireField( + skill.git.auth.credentialArn, + "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: requireField(net.networkModeConfig.subnets, "networkConfiguration.subnets"), + securityGroups: requireField( + net.networkModeConfig.securityGroups, + "networkConfiguration.securityGroups", + ), + } + : undefined, + lifecycleConfig: rt.lifecycleConfiguration + ? { + idleRuntimeSessionTimeout: rt.lifecycleConfiguration.idleRuntimeSessionTimeout, + maxLifetime: rt.lifecycleConfiguration.maxLifetime, + } + : undefined, + sessionStoragePath: + sessionStorage && "sessionStorage" in sessionStorage + ? requireField( + ("sessionStorage" in sessionStorage ? sessionStorage.sessionStorage : undefined) + ?.mountPath, + "sessionStorage.mountPath", + ) + : undefined, + efsAccessPoints: + efsAccessPoints.length > 0 + ? efsAccessPoints.map((f) => { + 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 = requireField( + "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint : undefined, + "s3FilesAccessPoint", + ); + return { + accessPointArn: requireField(s3.accessPointArn, "s3FilesAccessPoint.accessPointArn"), + mountPath: requireField(s3.mountPath, "s3FilesAccessPoint.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: 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/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 5b3e1a99b..91d0350ad 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -2,13 +2,13 @@ import { Router } from "../../router"; import { withProject } from "../../middleware"; 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; @@ -21,7 +21,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 922d8ca2f..d9dce3bba 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"])("project %s", (command) => { +describe.each(["remove", "dev", "deploy", "status"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); @@ -47,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(); @@ -98,21 +108,261 @@ describe("project create", () => { }); }); -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"]); +describe("project add harness", () => { + 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 inProject(); + // TODO: update to verify that the project updates. + await expect(run(["add", "harness", ...flags])).rejects.toThrow("not yet implemented"); + }); - const projectRoot = join(directory, "MyAgent"); + 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":{}}'], + ], + [ + "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 inProject(); + await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); + +describe("project build", () => { + 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([ @@ -126,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"]); @@ -142,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 cc9066e25..7e8c25cb5 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,5 +1,7 @@ +import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ManagedBy } from "../../projectSchemas/project"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import type z from "zod"; /** Available project templates for scaffolding new AgentCore projects. */ export const PROJECT_TEMPLATES = { @@ -9,6 +11,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; @@ -52,4 +64,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. */ + addResource( + project: Project, + resourceType: TResource, + resourceConfig: ProjectResourceConfig, + ): AsyncGenerator; }