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
11 changes: 7 additions & 4 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe("FsProjectManager.create", () => {
]);
expect(project.name).toBe("example");
expect(project.rootPath).toContain("example");
expect(project.runtimes).toHaveLength(1);
expect(project.spec.runtimes).toHaveLength(1);
});

test("a failed step propagates and leaves the scaffolded files in place", async () => {
Expand Down Expand Up @@ -280,7 +280,10 @@ describe("FsProjectManager.build", () => {
commands.length = 0;

// CDK is the only backend today; the cast stands in for a future one.
const foreign = { ...project, managedBy: "Terraform" as Project["managedBy"] };
const foreign = {
...project,
spec: { ...project.spec, managedBy: "Terraform" as Project["spec"]["managedBy"] },
};
await expect(drain(subject.build(foreign))).rejects.toThrow(/unsupported backend: Terraform/);
expect(commands).toEqual([]);
});
Expand Down Expand Up @@ -312,8 +315,8 @@ describe("FsProjectManager.resolve", () => {

expect(resolved?.name).toBe("example");
expect(resolved?.rootPath).toBe(join(root, "example"));
expect(resolved?.managedBy).toBe("CDK");
expect(resolved?.runtimes).toHaveLength(1);
expect(resolved?.spec.managedBy).toBe("CDK");
expect(resolved?.spec.runtimes).toHaveLength(1);
});

test("returns undefined when no project encloses the path", async () => {
Expand Down
118 changes: 105 additions & 13 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { copyFile, rm } from "node:fs/promises";
import { join, relative } from "node:path";
import type {
AddResourceInput,
CreateProjectInput,
ResolveProjectInput,
Project,
ProjectManager,
ProjectEvent,
ProjectResource,
ProjectResourceConfig,
} from "../../handlers/project/types";
import type { Logger } from "../../logging";
import {
Expand All @@ -18,7 +19,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
import {
Expand All @@ -27,6 +28,8 @@ import {
NotImplementedError,
ProjectStateError,
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import type z from "zod";

type ProjectManagerConfig = {
logger: Logger;
Expand Down Expand Up @@ -64,8 +67,7 @@ export class FsProjectManager implements ProjectManager {
return {
name: spec.name,
rootPath,
managedBy: spec.managedBy,
runtimes: spec.runtimes,
spec,
};
} catch (error) {
// A malformed agentcore.json is a user-correctable problem, not a crash.
Expand Down Expand Up @@ -130,26 +132,104 @@ export class FsProjectManager implements ProjectManager {
return project;
}

// eslint-disable-next-line require-yield
public async *addResource<TResource extends ProjectResource>(
_project: Project,
_resourceType: TResource,
_resourceConfig: ProjectResourceConfig<TResource>,
public async *addResource(
project: Project,
input: AddResourceInput,
): AsyncGenerator<ProjectEvent, Project> {
throw new NotImplementedError("FsProjectManager.addResource is not yet implemented");
const { resourceType, resourceConfig } = input;
const agentCoreSpecPath = join(project.rootPath, "agentcore", "agentcore.json");
const projectSpecKey = toProjectSpecKey(resourceType);

yield { message: `Reading project spec file at '${agentCoreSpecPath}'` };
const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema);

const existingResources = existingProjectSpec[projectSpecKey];
if (existingResources.find((r) => r.name === resourceConfig.name))
throw new InputValidationError(
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

const newResources = [...existingResources];
const scaffoldedPaths: string[] = [];

switch (resourceType) {
case "harness": {
yield { message: `Scaffolding harness in project` };
const harnessPath = await this.scaffoldHarness(project.rootPath, input.resourceConfig);
scaffoldedPaths.push(harnessPath);
newResources.push({
name: input.resourceConfig.name,
path: relative(project.rootPath, harnessPath),
});
break;
}
case "runtime": {
throw new NotImplementedError(
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
// TODO: add limited special casing for runtime and default for other resources that proxy directly to spec changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we either implement the runtime branch here or leave runtime out of AddResourceInput for now? At the moment a runtime call falls through this switch, emits the Updating project spec message, writes the unchanged runtime list, and returns successfully. That silent success will be hard for callers to diagnose.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine right? assuming we are implementing add runtime right after this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to be the next PR, but understand the current behavior is confusing. I'll have it throw early.

}

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
try {
const newProjectSpec = await this.json.write(agentCoreSpecPath, {
...existingProjectSpec,
[projectSpecKey]: newResources,
});

return {
...project,
spec: newProjectSpec,
};
} catch (err) {
this.logger.warn(
`Failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
);
await Promise.all(
scaffoldedPaths.map((p) => rm(p, { recursive: true, force: true }).catch(() => {})),
);
throw err;
}
}

private async scaffoldHarness(
projectRoot: string,
harnessSpec: z.input<typeof HarnessSpecSchema>,
): Promise<string> {
const outputPath = join(projectRoot, "app", harnessSpec.name);

const harness = await createHarnessTreeFromSpec({
...harnessSpec,
dockerfile: harnessSpec.dockerfile ? "Dockerfile" : undefined,
});

if (harnessSpec.dockerfile) {
if (!existsSync(harnessSpec.dockerfile))
throw new InputValidationError(`dockerfile not found: '${harnessSpec.dockerfile}'`);
}

await harness.write(outputPath);

if (harnessSpec.dockerfile) {
await copyFile(harnessSpec.dockerfile, join(outputPath, "Dockerfile"));
}
return outputPath;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
// 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
// editing the CDK path.
switch (project.managedBy) {
switch (project.spec.managedBy) {
case "CDK":
yield* this.buildWithCdk(project);
break;
default: {
// Exhaustiveness: a new ManagedBy member fails to compile until it is handled.
const unsupported: never = project.managedBy;
const unsupported: never = project.spec.managedBy;
throw new ProjectStateError(
`project '${project.name}' declares an unsupported backend: ${String(unsupported)}`,
);
Expand Down Expand Up @@ -183,3 +263,15 @@ export class FsProjectManager implements ProjectManager {
return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) });
}
}

/** Map {@link ProjectResource} to keys in the project spec.
* Note: we let TS infer the return type to avoid pulling in keys that do not correspond to resources (ex. name, managedBy, etc.)
*/
function toProjectSpecKey(resourceType: ProjectResource) {
switch (resourceType) {
case "harness":
return "harnesses";
case "runtime":
return "runtimes";
}
}
30 changes: 30 additions & 0 deletions src/core/project/templates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ZodError, z } from "zod";
import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types";
import { HarnessSpecSchema } from "../../projectSchemas/harness";
import { FsTreeNode } from "./fsTree";
import type { AssetSource } from "./source";
import { InputValidationError } from "../../errors/errors";

type TemplateSpec = {
runtimes?: unknown[];
Expand Down Expand Up @@ -90,3 +93,30 @@ export async function createProjectTreeFromTemplate(
FsTreeNode.createDirectory("app", [await FsTreeNode.fromAssetSource(src, assetDir, appDir)]),
]);
}

const DEFAULT_HARNESS_SYSTEM_PROMPT = "You are a helpful assistant";

export async function createHarnessTreeFromSpec(
spec: z.input<typeof HarnessSpecSchema>,
): Promise<FsTreeNode> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { systemPrompt, ...rest } = spec;
// strip system prompt such that markdown file is source of truth.
const parsed = parseHarnessSpec(rest);
return FsTreeNode.createDirectory(".", [
FsTreeNode.createFile("harness.json", async () => json(parsed)),
FsTreeNode.createFile(
"system-prompt.md",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we making the changes on the cdk side as well? resolveSystemPrompt() on there uses the inline systemPrompt first and onlyl reads system-prompt.md when that field is absent. so there are these 2 input sources. did we want that behavior to still exist?

what we are losing here is that there is no way a user will be warned if they make changes in their local md file here if they don't update the value from harness.json.

@Hweinstock Hweinstock Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, good callout, was not aware of that behavior. I feel like having two sources of system prompts might be unnecessary and confusing if we scaffold a system-prompt.md for them so I think stripping the system prompt field from the json to maintain backwards compatibility, and treat the file as source of truth might be the simplest path forward.

async () => spec.systemPrompt ?? DEFAULT_HARNESS_SYSTEM_PROMPT,
),
]);
}

function parseHarnessSpec(spec: z.input<typeof HarnessSpecSchema>) {
try {
return HarnessSpecSchema.parse(spec);
} catch (err) {
if (err instanceof ZodError) throw new InputValidationError(z.prettifyError(err));
throw err;
}
}
2 changes: 1 addition & 1 deletion src/core/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type CoreFetch = (
// full ClientConfig so callers can request any client customization (region,
// endpoint, ...).
export interface AwsClients {
control(config: ClientConfig): BedrockAgentCoreControlClient
control(config: ClientConfig): BedrockAgentCoreControlClient;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps a rebase issue, but this is failing ci on main https://github.com/aws/agentcore-cli/actions/runs/31832012548/job/94869692163.

data(config: ClientConfig): BedrockAgentCoreClient;
iam(config: ClientConfig): IAMClient;
// logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation
Expand Down
4 changes: 3 additions & 1 deletion src/handlers/eval/ondemand/ondemand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ const TRACE: SessionTrace = {
const RESULT: EvaluateResult = {
sessionsRequested: 1,
sessionsEvaluated: 1,
results: [{ evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number]],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above.

results: [
{ evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number],
],
};

async function run(args: string[], configure?: (core: TestCoreClient) => void) {
Expand Down
46 changes: 39 additions & 7 deletions src/handlers/project/add/harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import type {
AuthorizerConfiguration as SdkAuthorizerConfiguration,
HarnessEnvironmentArtifact,
HarnessEnvironmentProviderRequest,
HarnessGatewayOutboundAuth as SdkHarnessGatewayOutboundAuth,
HarnessMemoryConfiguration as SdkMemoryConfiguration,
HarnessModelConfiguration,
HarnessSkill as SdkHarnessSkill,
HarnessTool as SdkHarnessTool,
HarnessTruncationConfiguration as SdkTruncationConfiguration,
} from "@aws-sdk/client-bedrock-agentcore-control";
import type {
HarnessGatewayOutboundAuth,
HarnessMemoryRef,
HarnessModel,
HarnessSkill,
Expand Down Expand Up @@ -82,6 +84,11 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) =>
"path to local dockerfile to use as the container image for the harness",
z.string().optional(),
),
flag(
"vpc-id",
"VPC ID for Dockerfile builds in VPC mode (required when combining --dockerfile with VPC networking)",
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
Expand Down Expand Up @@ -136,7 +143,9 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) =>
timeoutSeconds: flags["timeout-seconds"],
tags: parseJsonFlag<Record<string, string>>("tags", flags["tags"]),
networkMode: env?.networkMode,
networkConfig: env?.networkConfig,
networkConfig: env?.networkConfig
? { ...env.networkConfig, vpcId: flags["vpc-id"] }
: undefined,
lifecycleConfig: env?.lifecycleConfig,
sessionStoragePath: env?.sessionStoragePath,
efsAccessPoints: env?.efsAccessPoints,
Expand All @@ -146,11 +155,10 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) =>
};

const project = ctx.require(ProjectKey);
for await (const event of config.projectManager.addResource(
project,
"harness",
harnessConfig,
)) {
for await (const event of config.projectManager.addResource(project, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is currently no way to add a Dockerfile-backed harness in VPC mode. The environment conversion carries subnets and security groups, but the harness schema also requires networkConfig.vpcId for Dockerfile builds, and this handler has no --vpc-id input. The command always fails validation for that combination. Could we add an explicit VPC ID option and thread it into the resource config?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow great edge case find, was not aware vpc id was required with VPC + docker. Let me add the explicit flag for this and make it clear.

resourceType: "harness",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One part of the requested tool configuration gets lost before this call. For an agentcore_gateway tool, toTool copies gatewayArn but drops outboundAuth. For example, asking for { none: {} } produces a harness config with no outbound auth, which changes the behavior back to the default AWS IAM mode. Could we preserve the awsIam, none, and oauth variants?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think @notgitika spotted the same in #1998 (comment). Was going to address as follow-up, but let me bring in here.

resourceConfig: harnessConfig,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Private Git skill auth has a field mismatch here. The SDK input gives us a credentialArn, but toSkill writes that value into credentialName. During synth, CDK treats credentialName as a project credential key, so it cannot resolve the ARN-shaped value and deployment fails. Could we either accept a project credential name for this project command, or carry the ARN separately instead of renaming it?

@Hweinstock Hweinstock Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding it as a new field makes sense (with them being mutually exclusive). That allows us to support credentials from outside the project with minimal extra effort. We can comeback to supporting credentialName since its already in the schema.

})) {
config.io.stderr.write(`${event.message}\n`);
}

Expand Down Expand Up @@ -233,6 +241,9 @@ function toTool(tool: SdkHarnessTool): HarnessTool {
config: {
agentCoreGateway: {
gatewayArn: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"),
outboundAuth: c.agentCoreGateway.outboundAuth
? toOutboundAuth(c.agentCoreGateway.outboundAuth)
: undefined,
},
},
};
Expand Down Expand Up @@ -263,6 +274,27 @@ function toTool(tool: SdkHarnessTool): HarnessTool {
return { type: tool.type, name: tool.name };
}

/** Converts an SDK HarnessGatewayOutboundAuth tagged union into the project-schema shape. */
function toOutboundAuth(auth: SdkHarnessGatewayOutboundAuth): HarnessGatewayOutboundAuth {
if ("awsIam" in auth && auth.awsIam) return { awsIam: {} };
if ("none" in auth && auth.none) return { none: {} };
if ("oauth" in auth && auth.oauth) {
return {
oauth: {
providerArn: requireField(auth.oauth.providerArn, "outboundAuth.oauth.providerArn"),
scopes: requireField(auth.oauth.scopes, "outboundAuth.oauth.scopes"),
// SDK does not expose this type directly.
grantType: auth.oauth.grantType as Extract<
HarnessGatewayOutboundAuth,
{ oauth: unknown }
>["oauth"]["grantType"],
customParameters: auth.oauth.customParameters,
},
};
}
throw new InputValidationError("unrecognized outboundAuth variant");
}

/** Converts an SDK HarnessSkill tagged union into the project-schema shape. */
function toSkill(skill: SdkHarnessSkill): HarnessSkill {
if ("path" in skill && skill.path) {
Expand All @@ -277,7 +309,7 @@ function toSkill(skill: SdkHarnessSkill): HarnessSkill {
path: skill.git.path,
auth: skill.git.auth
? {
credentialName: requireField(
credentialArn: requireField(
skill.git.auth.credentialArn,
"skill.git.auth.credentialArn",
),
Expand Down
Loading
Loading