Skip to content
Merged
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
72 changes: 72 additions & 0 deletions cdk/src/constructs/blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ const DOMAIN_PATTERN = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9
const APPROVAL_GATE_CAP_MIN = sharedConstants.approval_gate_cap.min;
const APPROVAL_GATE_CAP_MAX = sharedConstants.approval_gate_cap.max;

/**
* Bounds on a per-repo cost budget (#748). Same JSON the task-submit path
* validates ``max_budget_usd`` against (``handlers/shared/types.ts`` →
* ``MAX_BUDGET_USD_MIN``/``MAX_BUDGET_USD_MAX``, used by
* ``cli/src/commands/submit.ts``). Reading the shared constants rather than
* re-declaring literals is what keeps the per-repo default and the per-task
* override from disagreeing about what is in range.
*/
const MAX_BUDGET_USD_MIN = sharedConstants.max_budget_usd.min;
const MAX_BUDGET_USD_MAX = sharedConstants.max_budget_usd.max;

/** Timeout for the RepoConfig custom resource (minutes). */
const REPO_CONFIG_CR_TIMEOUT_MINUTES = 5;

Expand Down Expand Up @@ -90,6 +101,19 @@ export interface BlueprintProps {
*/
readonly maxTurns?: number;

/**
* Default cost budget in USD for tasks against this repo (#748).
*
* A per-task ``max_budget_usd`` (REST) / ``--max-budget`` (CLI) wins over
* this value; when neither is set NO budget applies (there is no platform
* default — unset means unlimited, deliberately).
*
* Must be in ``[0.01, 100]`` — the same range the task-submit path
* enforces, so a per-repo default cannot be a value a per-task override
* would have rejected. Out-of-range values fail at synth.
*/
readonly maxBudgetUsd?: number;

/**
* Additional system prompt instructions appended to the platform default.
*/
Expand Down Expand Up @@ -220,6 +244,13 @@ export class Blueprint extends Construct {
*/
public readonly approvalGateCap?: number;

/**
* Per-repo cost budget in USD from the agent.maxBudgetUsd prop (#748),
* exposed for inspection. Undefined when the blueprint did not configure
* one — there is no platform default, so unset means unlimited.
*/
public readonly maxBudgetUsd?: number;

/**
* Registry ``registry://`` refs for MCP servers (#246), exposed for inspection.
*/
Expand All @@ -237,6 +268,7 @@ export class Blueprint extends Construct {
this.egressAllowlist = [...(props.networking?.egressAllowlist ?? [])];
this.cedarPolicies = [...(props.security?.cedarPolicies ?? [])];
this.approvalGateCap = props.security?.approvalGateCap;
this.maxBudgetUsd = props.agent?.maxBudgetUsd;
this.mcpServerRefs = [...(props.assets?.mcpServers ?? [])];
this.cedarPolicyModuleRefs = [...(props.assets?.cedarPolicyModules ?? [])];
this.skillRefs = [...(props.assets?.skills ?? [])];
Expand All @@ -258,6 +290,7 @@ export class Blueprint extends Construct {
this.node.addValidation(new RepoFormatValidation(props.repo));
this.node.addValidation(new DomainFormatValidation(this.egressAllowlist));
this.node.addValidation(new ApprovalGateCapValidation(this.approvalGateCap));
this.node.addValidation(new MaxBudgetUsdValidation(this.maxBudgetUsd));
this.node.addValidation(new RegistryRefValidation('assets.mcpServers', this.mcpServerRefs, 'mcp_server'));
this.node.addValidation(new RegistryRefValidation('assets.cedarPolicyModules', this.cedarPolicyModuleRefs, 'cedar_policy_module'));
this.node.addValidation(new RegistryRefValidation('assets.skills', this.skillRefs, 'skill'));
Expand All @@ -284,6 +317,9 @@ export class Blueprint extends Construct {
if (props.agent?.maxTurns !== undefined) {
item.max_turns = { N: String(props.agent.maxTurns) };
}
if (this.maxBudgetUsd !== undefined) {
item.max_budget_usd = { N: String(this.maxBudgetUsd) };
}
if (props.agent?.systemPromptOverrides) {
item.system_prompt_overrides = { S: props.agent.systemPromptOverrides };
}
Expand Down Expand Up @@ -384,6 +420,7 @@ export class Blueprint extends Construct {
if (props.compute?.runtimeArn) fields.push(', #runtime_arn = :runtime_arn');
if (props.agent?.modelId) fields.push(', #model_id = :model_id');
if (props.agent?.maxTurns !== undefined) fields.push(', #max_turns = :max_turns');
if (this.maxBudgetUsd !== undefined) fields.push(', #max_budget_usd = :max_budget_usd');
Comment thread
scottschreckengaust marked this conversation as resolved.
if (props.agent?.systemPromptOverrides) fields.push(', #system_prompt_overrides = :system_prompt_overrides');
if (props.credentials?.githubTokenSecretArn) fields.push(', #github_token_secret_arn = :github_token_secret_arn');
if (props.pipeline?.pollIntervalMs !== undefined) fields.push(', #poll_interval_ms = :poll_interval_ms');
Expand All @@ -406,6 +443,7 @@ export class Blueprint extends Construct {
if (props.compute?.runtimeArn) names['#runtime_arn'] = 'runtime_arn';
if (props.agent?.modelId) names['#model_id'] = 'model_id';
if (props.agent?.maxTurns !== undefined) names['#max_turns'] = 'max_turns';
if (this.maxBudgetUsd !== undefined) names['#max_budget_usd'] = 'max_budget_usd';
if (props.agent?.systemPromptOverrides) names['#system_prompt_overrides'] = 'system_prompt_overrides';
if (props.credentials?.githubTokenSecretArn) names['#github_token_secret_arn'] = 'github_token_secret_arn';
if (props.pipeline?.pollIntervalMs !== undefined) names['#poll_interval_ms'] = 'poll_interval_ms';
Expand All @@ -426,6 +464,7 @@ export class Blueprint extends Construct {
if (props.compute?.runtimeArn) values[':runtime_arn'] = { S: props.compute.runtimeArn };
if (props.agent?.modelId) values[':model_id'] = { S: props.agent.modelId };
if (props.agent?.maxTurns !== undefined) values[':max_turns'] = { N: String(props.agent.maxTurns) };
if (this.maxBudgetUsd !== undefined) values[':max_budget_usd'] = { N: String(this.maxBudgetUsd) };
if (props.agent?.systemPromptOverrides) values[':system_prompt_overrides'] = { S: props.agent.systemPromptOverrides };
if (props.credentials?.githubTokenSecretArn) values[':github_token_secret_arn'] = { S: props.credentials.githubTokenSecretArn };
if (props.pipeline?.pollIntervalMs !== undefined) values[':poll_interval_ms'] = { N: String(props.pipeline.pollIntervalMs) };
Expand Down Expand Up @@ -522,6 +561,39 @@ class ApprovalGateCapValidation implements IValidation {
}
}

/**
* #748 — validates the per-repo cost budget is a finite number inside
* ``[MAX_BUDGET_USD_MIN, MAX_BUDGET_USD_MAX]``. Bounds come from
* ``contracts/constants.json``, the SAME source the task-submit path validates
* a per-task ``max_budget_usd`` against — so a blueprint cannot persist a
* per-repo default that a per-task override of the same value would reject.
* Out-of-range fails at synth rather than deploying a budget the agent would
* then act on. ``undefined`` is allowed: there is no platform default, and
* unset deliberately means unlimited.
*
* Fractional values ARE valid (unlike ``approvalGateCap``) — a budget is
* dollars-and-cents, and the minimum is one cent.
*/
class MaxBudgetUsdValidation implements IValidation {
constructor(private readonly budget: number | undefined) {}

public validate(): string[] {
if (this.budget === undefined) {
return [];
}
if (!Number.isFinite(this.budget)) {
return [`Invalid agent.maxBudgetUsd: ${this.budget}. Must be a finite number.`];
}
if (this.budget < MAX_BUDGET_USD_MIN || this.budget > MAX_BUDGET_USD_MAX) {
return [
`Invalid agent.maxBudgetUsd: ${this.budget}. ` +
`Must be between ${MAX_BUDGET_USD_MIN} and ${MAX_BUDGET_USD_MAX}.`,
];
}
return [];
}
}

/**
* Registry (#246) — validates each ``registry://`` asset ref against the strict
* grammar at synth, so a floating or malformed pin cannot deploy and then fail
Expand Down
171 changes: 171 additions & 0 deletions cdk/test/constructs/blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { App, Stack } from 'aws-cdk-lib';
import { Annotations, Template, Match } from 'aws-cdk-lib/assertions';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Blueprint, type BlueprintProps } from '../../src/constructs/blueprint';
import { MAX_BUDGET_USD_MAX, MAX_BUDGET_USD_MIN } from '../../src/handlers/shared/types';

function createStack(props?: Partial<BlueprintProps>): { stack: Stack; template: Template } {
const app = new App();
Expand Down Expand Up @@ -142,6 +143,92 @@ describe('Blueprint construct', () => {
expect(serialized).toContain('"max_turns":{"N":"50"}');
});

// --- #748: agent.maxBudgetUsd (mirrors agent.maxTurns) ------------------

test('maps agent max budget prop', () => {
const { template } = createStack({
agent: { maxBudgetUsd: 25 },
});
const parts = getCreateJoinParts(template);
const serialized = parts.join('');
expect(serialized).toContain('"max_budget_usd":{"N":"25"}');
});

test('maps a fractional agent max budget prop without rounding', () => {
const { template } = createStack({
agent: { maxBudgetUsd: 2.5 },
});
const parts = getCreateJoinParts(template);
const serialized = parts.join('');
expect(serialized).toContain('"max_budget_usd":{"N":"2.5"}');
});

test('omits max_budget_usd when agent is absent', () => {
const { template } = createStack();
const parts = getCreateJoinParts(template);
const serialized = parts.join('');
expect(serialized).not.toContain('max_budget_usd');
});

test('omits max_budget_usd when maxBudgetUsd is undefined', () => {
const { template } = createStack({
agent: { maxTurns: 50 },
});
const parts = getCreateJoinParts(template);
const serialized = parts.join('');
expect(serialized).not.toContain('max_budget_usd');
});

test('exposes maxBudgetUsd as a public property when configured', () => {
const app = new App();
const stack = new Stack(app, 'TestStack');
const repoTable = new dynamodb.Table(stack, 'RepoTable', {
partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING },
});

const blueprint = new Blueprint(stack, 'Blueprint', {
repo: 'org/my-repo',
repoTable,
agent: { maxBudgetUsd: 12.5 },
});

expect(blueprint.maxBudgetUsd).toBe(12.5);
});

test('maxBudgetUsd public property is undefined when absent', () => {
const app = new App();
const stack = new Stack(app, 'TestStack');
const repoTable = new dynamodb.Table(stack, 'RepoTable', {
partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING },
});

const blueprint = new Blueprint(stack, 'Blueprint', {
repo: 'org/my-repo',
repoTable,
});

expect(blueprint.maxBudgetUsd).toBeUndefined();
});

test('onUpdate includes max_budget_usd in UpdateExpression', () => {
const { template } = createStack({
agent: { maxBudgetUsd: 7.25 },
});
const parts = getUpdateJoinParts(template);
const serialized = parts.join('');
expect(serialized).toContain('#max_budget_usd');
expect(serialized).toContain('"N":"7.25"');
});

test('onUpdate omits max_budget_usd when not configured', () => {
const { template } = createStack({
agent: { maxTurns: 50 },
});
const parts = getUpdateJoinParts(template);
const serialized = parts.join('');
expect(serialized).not.toContain('max_budget_usd');
});

test('maps system prompt overrides prop', () => {
const { template } = createStack({
agent: { systemPromptOverrides: 'Always use TypeScript.' },
Expand Down Expand Up @@ -524,6 +611,90 @@ describe('Blueprint construct', () => {
expect(() => Template.fromStack(stack)).toThrow(/Invalid security.approvalGateCap: 3.14.*integer/);
});

// --- #748: agent.maxBudgetUsd bounds must match the CLI's 0.01–100 ------

test('rejects maxBudgetUsd below minimum at synth', () => {
const app = new App();
const stack = new Stack(app, 'TestStack');
const repoTable = new dynamodb.Table(stack, 'RepoTable', {
partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING },
});

new Blueprint(stack, 'Blueprint', {
repo: 'org/my-repo',
repoTable,
agent: { maxBudgetUsd: 0 },
});

expect(() => Template.fromStack(stack)).toThrow(/Invalid agent.maxBudgetUsd: 0.*between 0.01 and 100/);
});

test('rejects maxBudgetUsd above maximum at synth', () => {
const app = new App();
const stack = new Stack(app, 'TestStack');
const repoTable = new dynamodb.Table(stack, 'RepoTable', {
partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING },
});

new Blueprint(stack, 'Blueprint', {
repo: 'org/my-repo',
repoTable,
agent: { maxBudgetUsd: 100.01 },
});

expect(() => Template.fromStack(stack)).toThrow(/Invalid agent.maxBudgetUsd: 100.01.*between 0.01 and 100/);
});

test('rejects a non-finite maxBudgetUsd at synth', () => {
const app = new App();
const stack = new Stack(app, 'TestStack');
const repoTable = new dynamodb.Table(stack, 'RepoTable', {
partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING },
});

new Blueprint(stack, 'Blueprint', {
repo: 'org/my-repo',
repoTable,
agent: { maxBudgetUsd: Number.NaN },
});

expect(() => Template.fromStack(stack)).toThrow(/Invalid agent.maxBudgetUsd: NaN.*finite number/);
});

test('accepts maxBudgetUsd boundary values matching the CLI range', () => {
const appMin = new App();
const stackMin = new Stack(appMin, 'TestStackMin');
const tableMin = new dynamodb.Table(stackMin, 'RepoTable', {
partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING },
});
new Blueprint(stackMin, 'Blueprint', {
repo: 'org/min',
repoTable: tableMin,
agent: { maxBudgetUsd: MAX_BUDGET_USD_MIN },
});
expect(() => Template.fromStack(stackMin)).not.toThrow();

const appMax = new App();
const stackMax = new Stack(appMax, 'TestStackMax');
const tableMax = new dynamodb.Table(stackMax, 'RepoTable', {
partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING },
});
new Blueprint(stackMax, 'Blueprint', {
repo: 'org/max',
repoTable: tableMax,
agent: { maxBudgetUsd: MAX_BUDGET_USD_MAX },
});
expect(() => Template.fromStack(stackMax)).not.toThrow();
});

test('blueprint bounds are the SAME constants the task-submit path validates against', () => {
// #748 — the whole point of the prop is that a per-repo default and a
// per-task override cannot disagree about what is in range. Both must read
// ``contracts/constants.json``, not two hand-copied literals.
expect(MAX_BUDGET_USD_MIN).toBe(0.01);
expect(MAX_BUDGET_USD_MAX).toBe(100);
});

test('accepts boundary values (min and max)', () => {
const appMin = new App();
const stackMin = new Stack(appMin, 'TestStackMin');
Expand Down
2 changes: 1 addition & 1 deletion docs/design/REPO_ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ From lowest to highest priority:
| `runtime_arn` | Stack-level env var | CDK stack props |
| `model_id` | `us.anthropic.claude-opus-4-8` | Python literal in `agent/src/config.py` (no CDK prop or env knob today) — see [Model configuration](../guides/DEVELOPER_GUIDE.md#model-configuration) |
| `max_turns` | 100 | Platform constant |
| `max_budget_usd` | None (unlimited) | - |
| `max_budget_usd` | None (unlimited) | No platform default by design — a global ceiling would kill long tasks mid-change. Set a per-repo default with Blueprint `agent.maxBudgetUsd` (`0.01`–`100`, validated at synth) or per task with `--max-budget` / `max_budget_usd`. See [Per-repo overrides](../guides/USER_GUIDE.md#per-repo-overrides) for the complete list of surfaces a budget can come from |
| `memory_token_budget` | 2000 | Platform constant |
| `github_token_secret_arn` | Stack-level secret | CDK stack props |
| `poll_interval_ms` | 30000 | Orchestrator constant |
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ Token ratio 1.169; cost ratio 1.169 — identical. **The per-token rate is uncha
| Per task, CLI | `bgagent submit --max-budget <dollars>` (`cli/src/commands/submit.ts:69`), range 0.01–100 | Works |
| Per task, REST | `max_budget_usd` in the `POST /v1/tasks` body | Works |
| Local batch only | `MAX_BUDGET_USD` shell env, when running `entrypoint.py` directly | Works locally; **ignored** by the deployed AgentCore **server** mode, which reads the budget from the `/invocations` JSON body |
| Per repo, Blueprint | `agent.maxBudgetUsd` | **Not implemented** — `cdk/src/constructs/blueprint.ts` has no such prop (it implements `maxTurns`). Tracked in [#748](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/748), which owns that documentation. |
| Per repo, Blueprint | `agent.maxBudgetUsd` on the repo's `Blueprint` construct | Works — persisted to `RepoTable.max_budget_usd`; same `0.01`–`100` range as the CLI, validated at CDK synth so an out-of-range value cannot deploy. See [Per-repo overrides](./USER_GUIDE.md#per-repo-overrides). |
| Platform default | — | None by design: **unset means unlimited** |

**Unlimited-by-default is deliberate — pair it with the escape hatch.** Because no platform budget ceiling applies, the documented mitigation for cost is choosing a lighter-token model rather than relying on a cap:
Expand Down
Loading
Loading