-
Notifications
You must be signed in to change notification settings - Fork 83
feat(project): add gateway resources #2028
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: refactor
Are you sure you want to change the base?
Changes from all commits
db6a077
820945b
51adfd5
c9423e0
a5df428
7753af0
854514e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -147,18 +147,21 @@ export class FsProjectManager implements ProjectManager { | |
| const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); | ||
|
|
||
| const existingResources = existingProjectSpec[projectSpecKey]; | ||
| if (existingResources.find((r) => r.name === resourceConfig.name)) | ||
| if (resourceType === "gateway-target") { | ||
| this.assertUniqueGatewayTargetName(existingProjectSpec, resourceConfig.name); | ||
| } else if (existingResources.find((resource) => resource.name === resourceConfig.name)) { | ||
| throw new InputValidationError( | ||
| `a ${resourceType} with name '${resourceConfig.name}' already exists`, | ||
| ); | ||
| } | ||
|
|
||
| // Widened: arms push their own shapes; the whole-spec safeParse below validates. | ||
| const newResources: unknown[] = [...existingResources]; | ||
| const scaffoldedPaths: string[] = []; | ||
| // Non-file work that a failed spec write must also reverse. | ||
| let envFile: EnvLocalFile | undefined; | ||
|
|
||
| switch (resourceType) { | ||
| switch (input.resourceType) { | ||
| case "harness": { | ||
| yield { message: `Scaffolding harness in project` }; | ||
| const outputPath = join(project.rootPath, "app", resourceConfig.name); | ||
|
|
@@ -195,9 +198,25 @@ export class FsProjectManager implements ProjectManager { | |
| case "config-bundle": | ||
| case "online-eval": | ||
| case "online-insight": | ||
| case "gateway": | ||
| newResources.push(resourceConfig); | ||
| break; | ||
|
|
||
| case "gateway-target": { | ||
| const gatewayIndex = existingProjectSpec.agentCoreGateways.findIndex( | ||
| (gateway) => gateway.name === input.gatewayName, | ||
| ); | ||
| if (gatewayIndex < 0) { | ||
| throw new InputValidationError( | ||
| `gateway '${input.gatewayName}' does not exist in agentCoreGateways[]`, | ||
| ); | ||
| } | ||
| const gateway = existingProjectSpec.agentCoreGateways[gatewayIndex]!; | ||
| newResources[gatewayIndex] = { | ||
| ...gateway, | ||
| targets: [...gateway.targets, resourceConfig], | ||
| }; | ||
| break; | ||
| } | ||
| default: { | ||
| const unhandled: never = input; | ||
| throw new NotImplementedError(`unsupported project resource: ${String(unhandled)}`); | ||
|
|
@@ -283,6 +302,22 @@ export class FsProjectManager implements ProjectManager { | |
| }; | ||
| } | ||
|
|
||
| private assertUniqueGatewayTargetName(project: Project["spec"], name: string): void { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are all gateway target names required to be unique across every gateway, or must they only be unique inside a single gateway? |
||
| const gateway = project.agentCoreGateways.find((candidate) => | ||
| candidate.targets.some((target) => target.name === name), | ||
| ); | ||
| if (gateway) { | ||
| throw new InputValidationError( | ||
| `a gateway target with name '${name}' already exists in gateway '${gateway.name}'`, | ||
| ); | ||
| } | ||
| if (project.unassignedTargets?.some((target) => target.name === name)) { | ||
| throw new InputValidationError( | ||
| `an unassigned gateway target with name '${name}' already exists`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| private async scaffoldHarness( | ||
| outputPath: string, | ||
| harnessSpec: z.input<typeof HarnessSpecSchema>, | ||
|
|
@@ -341,5 +376,8 @@ function toProjectSpecKey(resourceType: ProjectResource) { | |
| case "online-eval": | ||
| case "online-insight": | ||
| return "onlineEvalConfigs"; | ||
| case "gateway": | ||
| case "gateway-target": | ||
| return "agentCoreGateways"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import { afterEach, describe, expect, test } from "bun:test"; | ||
| import { writeFile } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import { createGatewayProjectTestHarness } from "../gateway-test-support"; | ||
|
|
||
| const COMPLETE_CONNECTOR = JSON.stringify({ | ||
| name: "configured", | ||
| targetType: "connector", | ||
| connectorId: "web-search", | ||
| }); | ||
|
|
||
| const { addGateway, cleanup, inProject, projectSpec, run } = | ||
| createGatewayProjectTestHarness("gateway-connector"); | ||
|
|
||
| afterEach(cleanup); | ||
|
|
||
| describe("project add gateway-connector", () => { | ||
| test("adds Web Search and external Knowledge Base connectors", async () => { | ||
| const projectRoot = await inProject(); | ||
| await addGateway(); | ||
|
|
||
| await run([ | ||
| "add", | ||
| "gateway-connector", | ||
| "--gateway", | ||
| "tools", | ||
| "--name", | ||
| "web", | ||
| "--connector", | ||
| "web-search", | ||
| ]); | ||
| await run([ | ||
| "add", | ||
| "gateway-connector", | ||
| "--gateway", | ||
| "tools", | ||
| "--name", | ||
| "knowledge", | ||
| "--connector", | ||
| "bedrock-knowledge-bases", | ||
| "--knowledge-base", | ||
| "ABCDEFGHIJ", | ||
| ]); | ||
|
|
||
| expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([ | ||
| { | ||
| name: "web", | ||
| targetType: "connector", | ||
| connectorId: "web-search", | ||
| configurations: [{ name: "WebSearch", parameterValues: { maxResults: 10 } }], | ||
| }, | ||
| { | ||
| name: "knowledge", | ||
| targetType: "connector", | ||
| connectorId: "bedrock-knowledge-bases", | ||
| configurations: [{ name: "Retrieve", parameterValues: { knowledgeBaseId: "ABCDEFGHIJ" } }], | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| test("reads a complete connector Target from a file", async () => { | ||
| const projectRoot = await inProject(); | ||
| await addGateway(); | ||
| const target = { | ||
| name: "configured", | ||
| targetType: "connector", | ||
| connectorId: "web-search", | ||
| configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], | ||
| }; | ||
| const path = join(projectRoot, "connector.json"); | ||
| await writeFile(path, JSON.stringify(target)); | ||
|
|
||
| await run([ | ||
| "add", | ||
| "gateway-connector", | ||
| "--gateway", | ||
| "tools", | ||
| "--connector-configuration", | ||
| `file://${path}`, | ||
| ]); | ||
|
|
||
| expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); | ||
| }); | ||
|
|
||
| test("rejects a non-connector project Target", async () => { | ||
| await inProject(); | ||
| await addGateway(); | ||
|
|
||
| await expect( | ||
| run([ | ||
| "add", | ||
| "gateway-connector", | ||
| "--gateway", | ||
| "tools", | ||
| "--connector-configuration", | ||
| '{"name":"server","targetType":"mcpServer","endpoint":"https://mcp.example.com"}', | ||
| ]), | ||
| ).rejects.toThrow('targetType: "connector"'); | ||
| }); | ||
|
|
||
| test.each([ | ||
| [ | ||
| "missing parent Gateway", | ||
| ["--name", "web", "--connector", "web-search"], | ||
| "required option '--gateway", | ||
| ], | ||
| ["no connector mode", ["--gateway", "tools", "--name", "web"], "specify exactly one"], | ||
| [ | ||
| "both connector modes", | ||
| [ | ||
| "--gateway", | ||
| "tools", | ||
| "--name", | ||
| "web", | ||
| "--connector", | ||
| "web-search", | ||
| "--connector-configuration", | ||
| COMPLETE_CONNECTOR, | ||
| ], | ||
| "specify exactly one", | ||
| ], | ||
| [ | ||
| "name with complete connector JSON", | ||
| ["--gateway", "tools", "--name", "web", "--connector-configuration", COMPLETE_CONNECTOR], | ||
| "--name is part of --connector-configuration", | ||
| ], | ||
| [ | ||
| "knowledge base with complete connector JSON", | ||
| [ | ||
| "--gateway", | ||
| "tools", | ||
| "--connector-configuration", | ||
| COMPLETE_CONNECTOR, | ||
| "--knowledge-base", | ||
| "ABCDEFGHIJ", | ||
| ], | ||
| "--knowledge-base cannot be combined", | ||
| ], | ||
| [ | ||
| "shortcut without name", | ||
| ["--gateway", "tools", "--connector", "web-search"], | ||
| "required option '--name", | ||
| ], | ||
| [ | ||
| "knowledge base with Web Search", | ||
| [ | ||
| "--gateway", | ||
| "tools", | ||
| "--name", | ||
| "web", | ||
| "--connector", | ||
| "web-search", | ||
| "--knowledge-base", | ||
| "ABCDEFGHIJ", | ||
| ], | ||
| "--knowledge-base requires --connector bedrock-knowledge-bases", | ||
| ], | ||
| [ | ||
| "Knowledge Base connector without Knowledge Base", | ||
| ["--gateway", "tools", "--name", "knowledge", "--connector", "bedrock-knowledge-bases"], | ||
| "requires --knowledge-base", | ||
| ], | ||
| ])("rejects %s", async (_label, flags, message) => { | ||
| await inProject(); | ||
| await addGateway(); | ||
| await expect(run(["add", "gateway-connector", ...flags])).rejects.toThrow(message); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import z from "zod"; | ||
| import { InputValidationError } from "../../../../errors"; | ||
| import { SourceResolver } from "../../../../io"; | ||
| import { | ||
| AgentCoreGatewayTargetSchema, | ||
| type AgentCoreGatewayTarget, | ||
| type ConnectorId, | ||
| } from "../../../../projectSchemas/gateway"; | ||
| import { createHandler, flag, ProjectKey } from "../../../../router"; | ||
| import { parseJsonFlagWithSchema } from "../../../utils"; | ||
| import type { AddProjectResourceConfig } from "../types"; | ||
|
|
||
| export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfig) => | ||
| createHandler({ | ||
| name: "gateway-connector", | ||
| description: "adds a connector-backed Target to a project Gateway", | ||
| flags: [ | ||
| flag("gateway", "name of the parent Gateway in this project", z.string().optional()), | ||
| flag("name", "the Target name for a connector shortcut", z.string().optional()), | ||
| flag( | ||
| "connector", | ||
| "curated connector", | ||
| z.enum(["web-search", "bedrock-knowledge-bases"]).optional(), | ||
| ), | ||
| flag( | ||
| "connector-configuration", | ||
| "complete connector agentCoreGateways[].targets[] object (JSON; inline, file://<path>, or - for stdin)", | ||
| z.string().optional(), | ||
| ), | ||
| flag( | ||
| "knowledge-base", | ||
| "project Knowledge Base name or external ten-character Knowledge Base ID", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add that this could only be used with |
||
| z.string().optional(), | ||
| ), | ||
| ], | ||
| handle: async (ctx, flags) => { | ||
| if (!flags.gateway) { | ||
| throw new InputValidationError("required option '--gateway <gateway>' not specified"); | ||
| } | ||
| if ((flags.connector === undefined) === (flags["connector-configuration"] === undefined)) { | ||
| throw new InputValidationError( | ||
| "specify exactly one of '--connector' or '--connector-configuration'", | ||
| ); | ||
| } | ||
|
|
||
| const usesConfiguration = flags["connector-configuration"] !== undefined; | ||
| if (usesConfiguration && flags.name !== undefined) { | ||
| throw new InputValidationError( | ||
| "--name is part of --connector-configuration and cannot be supplied separately", | ||
| ); | ||
| } | ||
| if (usesConfiguration && flags["knowledge-base"] !== undefined) { | ||
| throw new InputValidationError( | ||
| "--knowledge-base cannot be combined with --connector-configuration", | ||
| ); | ||
| } | ||
| if (!usesConfiguration && !flags.name) { | ||
| throw new InputValidationError("required option '--name <name>' not specified"); | ||
| } | ||
| if (flags["knowledge-base"] !== undefined && flags.connector !== "bedrock-knowledge-bases") { | ||
| throw new InputValidationError( | ||
| "--knowledge-base requires --connector bedrock-knowledge-bases", | ||
| ); | ||
| } | ||
|
|
||
| const project = ctx.require(ProjectKey); | ||
| let target: AgentCoreGatewayTarget; | ||
| if (usesConfiguration) { | ||
| const source = new SourceResolver({ stdin: config.io.stdin }); | ||
| target = parseJsonFlagWithSchema( | ||
| "connector-configuration", | ||
| await source.resolveText("connector-configuration", flags["connector-configuration"]), | ||
| AgentCoreGatewayTargetSchema, | ||
| )!; | ||
| if (target.targetType !== "connector") { | ||
| throw new InputValidationError( | ||
| '--connector-configuration must have targetType: "connector"', | ||
| ); | ||
| } | ||
| } else { | ||
| target = connectorTargetFromShortcut( | ||
| flags.name!, | ||
| flags.connector!, | ||
| flags["knowledge-base"], | ||
| ); | ||
| } | ||
|
|
||
| for await (const event of config.projectManager.addResource(project, { | ||
| resourceType: "gateway-target", | ||
| gatewayName: flags.gateway, | ||
| resourceConfig: target, | ||
| })) { | ||
| config.io.stderr.write(`${event.message}\n`); | ||
| } | ||
| config.io.stderr.write( | ||
| `added Connector Target '${target.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, | ||
| ); | ||
| }, | ||
| }); | ||
|
|
||
| function connectorTargetFromShortcut( | ||
| name: string, | ||
| connectorId: ConnectorId, | ||
| knowledgeBase?: string, | ||
| ): AgentCoreGatewayTarget { | ||
| switch (connectorId) { | ||
| case "web-search": | ||
| return { | ||
| name, | ||
| targetType: "connector", | ||
| connectorId, | ||
| configurations: [{ name: "WebSearch", parameterValues: { maxResults: 10 } }], | ||
| }; | ||
| case "bedrock-knowledge-bases": | ||
| if (!knowledgeBase) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't we do this validation at line 60? |
||
| throw new InputValidationError( | ||
| "--connector bedrock-knowledge-bases requires --knowledge-base", | ||
| ); | ||
| } | ||
| return { | ||
| name, | ||
| targetType: "connector", | ||
| connectorId, | ||
| configurations: [{ name: "Retrieve", parameterValues: { knowledgeBaseId: knowledgeBase } }], | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: does not exist in project. Please check agentCoreGateway in agentcore.json