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
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,73 @@ pre-built container image or a custom Dockerfile, that is reported in
`EXPORT_NOTES.md` rather than rebuilt. Path-based skills are not supported,
since the exported agent has no container filesystem to read them from.

### Harness Project Files

`project create` (without `--template`) and `project add harness` share the same
scaffolding flow. Each harness has `app/<name>/harness.yaml` and
`app/<name>/system-prompt.md`. YAML is the harness configuration format.
The YAML contains the supplied settings and
commented optional examples. Tools are opt-in. Newly scaffolded harnesses
explicitly use `memory: { mode: managed }` unless another memory configuration
was supplied. Reading an existing file with no `memory` setting still means
disabled memory; reading never adds the scaffold default.

```yaml
name: assistant
model:
provider: bedrock
modelId: global.anthropic.claude-sonnet-4-6
systemPrompt: file://./system-prompt.md
memory:
mode: managed
```

Only `systemPrompt` and
`truncation.config.summarization.summarizationSystemPrompt` resolve local
`file://` references. The prefix is removed and the remaining filesystem path
is resolved relative to **the YAML file's directory**, not the shell's working
directory. `file://./prompt.md`, `file://../shared/prompt.md`, and absolute
filesystem paths are supported. Paths use native filesystem spelling (including
Windows drive paths), not URL host or percent-encoding rules; spaces, `#`, and
`%` in a filename stay literal. YAML quoting preserves special characters.

```yaml
systemPrompt: |
You are a concise assistant.
truncation:
strategy: summarization
config:
summarization:
summarizationSystemPrompt: "file://../shared/summary #1.md"
```

Both build/deploy and local export resolve these files to literal text before
schema validation. Explicit prompt text or a reference takes precedence over
`system-prompt.md`. That conventional file is used only when `systemPrompt` is
omitted. A bad explicit reference never falls back silently. Referenced files
must be readable, nonempty UTF-8 text of at most **1 MiB each**; whitespace-only
files are rejected. Inline text and file contents preserve their whitespace.
File contents are not interpreted as further references.
Plain strings such as `README.md`, `./instructions.md`, and HTTPS URLs are
literal prompt text, not file references.

`project add harness --system-prompt file://./selected.md` preserves that
reference in the generated YAML. Scaffolding validates reference syntax without
reading or copying the selected file; build and export resolve it from the new
harness directory. A literal `--system-prompt` is written to `system-prompt.md`.

Skills are unchanged: skill paths refer to the **runtime/container filesystem**,
not local files to package. Other fields do not support local includes.
Malformed YAML, duplicate keys, and existing schema violations fail the read.
Unknown fields at the harness root and directly inside `model` are rejected,
not silently removed or corrected. Nested configurations keep their existing
validation contracts; this is not a recursive unknown-field check. Free-form
maps such as headers, tags, environment variables, `additionalParams`, and
`inputSchema` still accept arbitrary keys.
Build, deploy, and export do not rewrite harness YAML or remove its comments.
`agentcore.json`, deployment targets, JSON CLI flags/output, and service payloads
are unchanged.

Global flags (declared at the root, available on every command):

| Flag | Purpose |
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"string-width": "^8.2.2",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"yaml": "^2.8.1",
"zod": "^4.4.3"
},
"overrides": {
Expand Down
2 changes: 1 addition & 1 deletion src/assets/cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This CDK project is managed by the AgentCore CLI. It deploys your agent infrastr
## Useful commands

- `npm run build` compile TypeScript to JavaScript
- `npm run test` run unit tests
- `npm run test` compile the app and run unit tests
- `npx cdk synth` emit the synthesized CloudFormation template
- `npx cdk deploy` deploy this stack to your default AWS account/region
- `npx cdk diff` compare deployed stack with current state
Expand Down
14 changes: 8 additions & 6 deletions src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#!/usr/bin/env node
import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack';
import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk';
import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk';
import { App, type Environment } from 'aws-cdk-lib';
import * as path from 'path';
import * as fs from 'fs';
import { HarnessConfigReader } from '../io/harnessConfig';
import { HarnessSpecSchema } from '../lib/harness-schema';

function toEnvironment(target: AwsDeploymentTarget): Environment {
return {
Expand Down Expand Up @@ -67,13 +69,13 @@ function resolveConnectorParametersByFile(
// Synthesize a HarnessConfig for each harness entry in the spec. The full validated
// spec drives the AWS::BedrockAgentCore::Harness CFN resource; the role-scoped
// fields drive the IAM role + container build.
function resolveHarnessConfigs(spec: SpecWithLatestFields, projectRoot: string): HarnessConfig[] {
async function resolveHarnessConfigs(spec: SpecWithLatestFields, projectRoot: string): Promise<HarnessConfig[]> {
const harnessConfigs: HarnessConfig[] = [];
for (const entry of spec.harnesses ?? []) {
const harnessDir = path.resolve(projectRoot, entry.path);
const harnessPath = path.resolve(harnessDir, 'harness.json');
const harnessPath = path.resolve(harnessDir, 'harness.yaml');
try {
const harnessSpec = HarnessSpecSchema.parse(JSON.parse(fs.readFileSync(harnessPath, 'utf-8')));
const harnessSpec = HarnessSpecSchema.parse(await new HarnessConfigReader().read(harnessPath));
harnessConfigs.push({
name: entry.name,
executionRoleArn: harnessSpec.executionRoleArn,
Expand All @@ -96,7 +98,7 @@ function resolveHarnessConfigs(spec: SpecWithLatestFields, projectRoot: string):
});
} catch (err) {
throw new Error(
`Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}`
`Could not read harness.yaml for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}`
);
}
}
Expand All @@ -122,7 +124,7 @@ async function main() {

const mcpSpec = resolveMcpSpec(specAny);
const connectorParametersByFile = resolveConnectorParametersByFile(specAny, projectRoot);
const harnessConfigs = resolveHarnessConfigs(specAny, projectRoot);
const harnessConfigs = await resolveHarnessConfigs(specAny, projectRoot);

// Read deployed state for credential ARNs (populated by pre-deploy identity setup).
// Under agentcore/.cli/ to match the released CLI's location.
Expand Down
98 changes: 98 additions & 0 deletions src/assets/cdk/io/harnessConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { constants } from "node:fs";
import { open, readFile, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { parseDocument } from "yaml";

const MAX_PROMPT_FILE_SIZE = 1024 * 1024;
const PROMPT_FIELDS = [
["systemPrompt"],
["truncation", "config", "summarization", "summarizationSystemPrompt"],
] as const;

/** Project-file I/O only; callers validate the resolved data with their HarnessSpecSchema. */
export class HarnessConfigReader {
async read(filePath: string): Promise<unknown> {
const configPath = resolve(filePath);
try {
const raw = await readFile(configPath, "utf8");
const document = parseDocument(raw);
if (document.errors.length) throw document.errors[0];
const data: unknown = document.toJS();
if (!isRecord(data)) return data;

// These are the only local-file fields. Skills and other runtime paths stay untouched.
promptFields: for (const keys of PROMPT_FIELDS) {
let parent = data;
for (const key of keys.slice(0, -1)) {
const child = parent[key];
if (!isRecord(child)) continue promptFields;
parent[key] = { ...child };
parent = parent[key] as Record<string, unknown>;
}
const key = keys[keys.length - 1]!;
const value = parent[key];
if (typeof value === "string" && value.startsWith("file://")) {
const source = value.slice("file://".length);
if (!source) throw new Error(`${keys.join(".")}: file:// requires a path`);
parent[key] = await this.readPrompt(
resolve(dirname(configPath), source),
keys.join("."),
);
}
}
if (data.systemPrompt === undefined) {
const fallback = join(dirname(configPath), "system-prompt.md");
try {
data.systemPrompt = await this.readPrompt(fallback, "systemPrompt");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
return data;
} catch (error) {
throw new Error(
`Could not read harness.yaml at '${configPath}': ${error instanceof Error ? error.message : error}`,
{ cause: error },
);
}
}

private async readPrompt(filePath: string, field: string): Promise<string> {
try {
const info = await stat(filePath);
if (!info.isFile()) throw new Error(`${field}: '${filePath}' is not a regular prompt file`);
// A path can change after stat; nonblocking POSIX opens avoid waiting on a replacement FIFO.
const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NONBLOCK);
const file = await open(filePath, flags);
try {
if (!(await file.stat()).isFile()) throw new Error(`${field}: '${filePath}' is not a regular prompt file`);
const bytes = Buffer.alloc(MAX_PROMPT_FILE_SIZE + 1);
let length = 0;
while (length < bytes.length) {
const { bytesRead } = await file.read(bytes, length, bytes.length - length, null);
if (!bytesRead) break;
length += bytesRead;
}
if (length > MAX_PROMPT_FILE_SIZE) {
throw new Error(`${field}: prompt file '${filePath}' exceeds the 1 MiB limit`);
}
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, length));
if (!text.trim()) throw new Error(`${field}: prompt file '${filePath}' is empty or whitespace-only`);
return text;
} finally {
await file.close();
}
} catch (error) {
throw Object.assign(
new Error(`${field}: cannot read prompt file '${filePath}': ${error instanceof Error ? error.message : error}`, {
cause: error,
}),
{ code: (error as NodeJS.ErrnoException).code },
);
}
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
10 changes: 10 additions & 0 deletions src/assets/cdk/lib/harness-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { HarnessSpecSchema as PublishedHarnessSpecSchema } from '@aws/agentcore-cdk';
import { z } from 'zod';

// Match CLI strictness and literal prompt policy while retaining the published object refinements.
export const HarnessSpecSchema: typeof PublishedHarnessSpecSchema = PublishedHarnessSpecSchema.strict().safeExtend({
model: PublishedHarnessSpecSchema.shape.model.strict(),
systemPrompt: z.string()
.refine(val => val.trim().length > 0, { message: 'systemPrompt must not be empty or whitespace-only' })
.optional(),
});
6 changes: 4 additions & 2 deletions src/assets/cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"test": "npm run build && jest",
"cdk": "npm run build && cdk",
"clean": "rm -rf dist",
"format": "prettier --write .",
Expand All @@ -25,6 +25,8 @@
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.52",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
"constructs": "~10.7.0",
"yaml": "^2.8.1",
"zod": "^4.4.3"
}
}
86 changes: 86 additions & 0 deletions src/assets/cdk/test/harness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { stringify } from 'yaml';
import { HarnessSpecSchema } from '../lib/harness-schema';

const entrypoint = resolve(__dirname, '..', 'dist/bin/cdk.js');
const roots: string[] = [];

afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

test.each([
{ systemPrompt: '' },
{ systemPrompt: ' \r\n\t' },
])('retains published refinements for %j', (overrides) => {
expect(HarnessSpecSchema.safeParse({
name: 'assistant',
model: { provider: 'bedrock', modelId: 'example' },
...overrides,
}).success).toBe(false);
});

test.each([
...['literal', 'file', 'fallback'].map(source => ({
source, prompt: ' Selected prompt: # 100%\n',
})),
...['README.md\n', './instructions.md', 'https://example.com/prompt.md\n', 'file://./not-a-recursive-include.md'].map(prompt => ({
source: 'file', prompt,
})),
{ source: 'literal', prompt: './instructions.md' },
{ source: 'fallback', prompt: 'README.md\n' },
])('generated app validates $source prompt $prompt without rewriting it', ({ source, prompt }) => {
const root = mkdtempSync(join(tmpdir(), 'harness-yaml-synth-'));
roots.push(root);
const configRoot = join(root, 'agentcore');
const cdkRoot = join(configRoot, 'cdk');
const harnessDir = join(root, 'app', 'assistant');
mkdirSync(cdkRoot, { recursive: true });
mkdirSync(harnessDir, { recursive: true });
writeFileSync(join(configRoot, 'agentcore.json'), JSON.stringify({
name: 'YamlProject',
version: 1,
managedBy: 'CDK',
harnesses: [{ name: 'assistant', path: 'app/assistant' }],
}));
writeFileSync(join(configRoot, 'aws-targets.json'), '[]');
const summary = 'Keep decisions and open questions.\n';
writeFileSync(join(harnessDir, 'chosen #100%.md'), prompt);
writeFileSync(join(harnessDir, 'system-prompt.md'), source === 'fallback' ? prompt : 'Conventional prompt loses.');
writeFileSync(join(harnessDir, 'summary.md'), summary);
const yaml = '# Customer comment stays intact.\n' + stringify({
name: 'assistant',
model: {
provider: 'bedrock',
modelId: 'global.anthropic.claude-sonnet-4-6',
},
systemPrompt: source === 'fallback' ? undefined : source === 'literal' ? prompt : 'file://./chosen #100%.md',
memory: { mode: 'disabled' },
truncation: {
strategy: 'summarization',
config: { summarization: { summaryRatio: 0.3, preserveRecentMessages: 0, summarizationSystemPrompt: 'file://./summary.md' } },
},
});
writeFileSync(join(harnessDir, 'harness.yaml'), yaml);
const outdir = join(root, 'cdk.out');
execFileSync(process.execPath, [entrypoint], {
cwd: cdkRoot,
env: { ...process.env, INIT_CWD: root, CDK_OUTDIR: outdir },
stdio: 'pipe',
timeout: 30000,
});
expect(readFileSync(join(harnessDir, 'harness.yaml'), 'utf8')).toBe(yaml);
const templateFile = readdirSync(outdir).find((name) => name.endsWith('.template.json'))!;
const template = JSON.parse(readFileSync(join(outdir, templateFile), 'utf8'));
const harness = Object.values(template.Resources).find((resource: any) => resource.Type === 'AWS::BedrockAgentCore::Harness') as any;
expect(harness.Properties.SystemPrompt).toEqual([{ Text: prompt }]);
expect(harness.Properties.Memory).toEqual({ Disabled: {} });
expect(JSON.stringify(harness.Properties)).toContain(JSON.stringify(summary).slice(1, -1));
expect(JSON.stringify(harness.Properties)).not.toContain('file://./chosen #100%.md');
expect(JSON.stringify(harness.Properties)).not.toContain('file://./summary.md');
expect(readFileSync(join(harnessDir, 'chosen #100%.md'), 'utf8')).toBe(prompt);
expect(readFileSync(join(harnessDir, 'harness.yaml'), 'utf8')).toBe(yaml);
});
2 changes: 1 addition & 1 deletion src/assets/cdk/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@
"rootDir": ".",
"outDir": "dist"
},
"include": ["bin/**/*", "lib/**/*", "test/**/*"],
"include": ["bin/**/*", "lib/**/*", "io/**/*", "test/**/*"],
"exclude": ["node_modules", "cdk.out", "dist"]
}
Loading
Loading