Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -283,6 +302,22 @@ export class FsProjectManager implements ProjectManager {
};
}

private assertUniqueGatewayTargetName(project: Project["spec"], name: string): void {
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>,
Expand Down Expand Up @@ -341,5 +376,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
case "online-eval":
case "online-insight":
return "onlineEvalConfigs";
case "gateway":
case "gateway-target":
return "agentCoreGateways";
}
}
168 changes: 168 additions & 0 deletions src/handlers/project/add/gateway-connector/index.test.ts
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);
});
});
127 changes: 127 additions & 0 deletions src/handlers/project/add/gateway-connector/index.ts
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",
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) {
throw new InputValidationError(
"--connector bedrock-knowledge-bases requires --knowledge-base",
);
}
return {
name,
targetType: "connector",
connectorId,
configurations: [{ name: "Retrieve", parameterValues: { knowledgeBaseId: knowledgeBase } }],
};
}
}
Loading
Loading