diff --git a/cdk/src/constructs/blueprint.ts b/cdk/src/constructs/blueprint.ts index dbb2c8c3..acfa44c2 100644 --- a/cdk/src/constructs/blueprint.ts +++ b/cdk/src/constructs/blueprint.ts @@ -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; @@ -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. */ @@ -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. */ @@ -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 ?? [])]; @@ -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')); @@ -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 }; } @@ -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'); 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'); @@ -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'; @@ -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) }; @@ -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 diff --git a/cdk/test/constructs/blueprint.test.ts b/cdk/test/constructs/blueprint.test.ts index 482e691c..96e79bef 100644 --- a/cdk/test/constructs/blueprint.test.ts +++ b/cdk/test/constructs/blueprint.test.ts @@ -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): { stack: Stack; template: Template } { const app = new App(); @@ -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.' }, @@ -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'); diff --git a/docs/design/REPO_ONBOARDING.md b/docs/design/REPO_ONBOARDING.md index 912f90c5..3b6f0122 100644 --- a/docs/design/REPO_ONBOARDING.md +++ b/docs/design/REPO_ONBOARDING.md @@ -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 | diff --git a/docs/guides/DEVELOPER_GUIDE.md b/docs/guides/DEVELOPER_GUIDE.md index e5814f23..e0c3a4c5 100644 --- a/docs/guides/DEVELOPER_GUIDE.md +++ b/docs/guides/DEVELOPER_GUIDE.md @@ -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 ` (`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: diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 7b37407e..0ef78fb4 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -223,13 +223,52 @@ Blueprints can configure per-repository settings that override platform defaults | `runtime_arn` | AgentCore runtime ARN override | Platform default | | `model_id` | Bedrock inference-profile ID (`us.`-prefixed) | `us.anthropic.claude-opus-4-8` | | `max_turns` | Default turn limit for tasks | 100 | -| `max_budget_usd` | Default cost budget in USD per task | None (unlimited) | +| `max_budget_usd` | Default cost budget in USD per task, `0.01`–`100` (Blueprint `agent.maxBudgetUsd`) | None (unlimited) | | `system_prompt_overrides` | Additional system prompt instructions | None | | `github_token_secret_arn` | Per-repo GitHub token (Secrets Manager ARN) | Platform default | | `poll_interval_ms` | Poll interval for awaiting completion (5000–300000) | 30000 | When you specify `--max-turns` (CLI) or `max_turns` (API) on a task, your value takes precedence over the Blueprint default. If neither is specified, the platform default (100) is used. The same override pattern applies to `--max-budget` / `max_budget_usd`, except there is no platform default - if neither the task nor the Blueprint specifies a budget, no cost limit is applied. +### Where can I set `max_budget_usd`? + +Every place a cost budget can come from, and nowhere else: + +| Surface | How | Scope | Notes | +|---|---|---|---| +| Per task, CLI | `bgagent submit --max-budget ` | One task | Range `0.01`–`100`; rejected client-side before the request is sent | +| Per task, REST | `max_budget_usd` in the `POST /v1/tasks` body | One task | Same `0.01`–`100` range, validated server-side | +| Per repo, Blueprint | `agent.maxBudgetUsd` on the repo's `Blueprint` construct | Every task on that repo | Persisted to `RepoTable.max_budget_usd`; same `0.01`–`100` range, enforced at CDK synth so an out-of-range value cannot deploy | +| Local batch runs | `MAX_BUDGET_USD` shell env | One local run | **Local `entrypoint.py` batch mode only.** The deployed AgentCore **server** mode ignores this variable — it reads the budget from the `/invocations` request body, so setting it on the runtime has no effect | +| Platform-wide default | — | — | **None exists.** Unset means unlimited (see below) | + +The two that apply to a deployed task resolve in this order: **per-task value wins, then the repo's Blueprint default, then no budget at all.** A mid-task Blueprint edit does not move a running task's budget. + +Administrators set the Blueprint default in the CDK stack: + +```typescript +new Blueprint(this, 'MyRepo', { + repo: 'my-org/my-repo', + repoTable, + agent: { maxBudgetUsd: 5.0 }, // every task on this repo caps at $5 unless overridden +}); +``` + +Run `bgagent repo show ` to see which value is in effect; the `max_budget_usd` line reads `(per-blueprint override)` when the repo pins one and `(platform default) unlimited` when it does not. + +### Unlimited by default is deliberate + +There is intentionally no platform-wide budget ceiling. A hard global cap would kill long-running tasks mid-change — the failure mode is a half-finished branch and no PR, which is worse than a task that costs more than expected. The intended controls are the per-repo Blueprint default above (opt in where you want a ceiling), the per-task flag, and `max_turns`. + +The documented escape hatch for cost is **choosing a lighter-token model** rather than relying on a cap: + +- **Per repo:** Blueprint `agent.modelId` — no code change and no agent redeploy +- **Per task:** `model_id` in the task payload + +The model you pick must be in the platform's Bedrock IAM grant list, or the task fails at turn 0 with `AccessDenied` — the grant is the gate, so a lighter model is only reachable if it has been granted. For how the model layers resolve, the grant list, and the measured cost comparison, see [Model configuration](./DEVELOPER_GUIDE.md#model-configuration). + +Note that the reported `cost_usd` is a client-side estimate, not authoritative billing — see [Cost attribution](./COST_ATTRIBUTION.md). + ## Workflows Every task runs a **workflow** — a named, versioned recipe that decides whether to clone a repo, which tools the agent may use, and how the result is delivered. You select one with `workflow_ref` (REST/webhook) or `--workflow` (CLI); the `--pr`/`--review-pr` flags select the coding PR workflows for you. If you specify nothing, the platform resolves a default (your repo's Blueprint default, or the conservative `default/agent-v1`). Workflows replace the old `task_type` field — see [Workflows](../design/WORKFLOWS.md) for the full design and how to author your own. diff --git a/docs/src/content/docs/architecture/Repo-onboarding.md b/docs/src/content/docs/architecture/Repo-onboarding.md index 61b33220..f48523fc 100644 --- a/docs/src/content/docs/architecture/Repo-onboarding.md +++ b/docs/src/content/docs/architecture/Repo-onboarding.md @@ -126,7 +126,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](/sample-autonomous-cloud-coding-agents/developer-guide/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](/sample-autonomous-cloud-coding-agents/customizing/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 | diff --git a/docs/src/content/docs/customizing/Per-repo-overrides.md b/docs/src/content/docs/customizing/Per-repo-overrides.md index 360e10ff..3eca5867 100644 --- a/docs/src/content/docs/customizing/Per-repo-overrides.md +++ b/docs/src/content/docs/customizing/Per-repo-overrides.md @@ -10,9 +10,48 @@ Blueprints can configure per-repository settings that override platform defaults | `runtime_arn` | AgentCore runtime ARN override | Platform default | | `model_id` | Bedrock inference-profile ID (`us.`-prefixed) | `us.anthropic.claude-opus-4-8` | | `max_turns` | Default turn limit for tasks | 100 | -| `max_budget_usd` | Default cost budget in USD per task | None (unlimited) | +| `max_budget_usd` | Default cost budget in USD per task, `0.01`–`100` (Blueprint `agent.maxBudgetUsd`) | None (unlimited) | | `system_prompt_overrides` | Additional system prompt instructions | None | | `github_token_secret_arn` | Per-repo GitHub token (Secrets Manager ARN) | Platform default | | `poll_interval_ms` | Poll interval for awaiting completion (5000–300000) | 30000 | -When you specify `--max-turns` (CLI) or `max_turns` (API) on a task, your value takes precedence over the Blueprint default. If neither is specified, the platform default (100) is used. The same override pattern applies to `--max-budget` / `max_budget_usd`, except there is no platform default - if neither the task nor the Blueprint specifies a budget, no cost limit is applied. \ No newline at end of file +When you specify `--max-turns` (CLI) or `max_turns` (API) on a task, your value takes precedence over the Blueprint default. If neither is specified, the platform default (100) is used. The same override pattern applies to `--max-budget` / `max_budget_usd`, except there is no platform default - if neither the task nor the Blueprint specifies a budget, no cost limit is applied. + +### Where can I set `max_budget_usd`? + +Every place a cost budget can come from, and nowhere else: + +| Surface | How | Scope | Notes | +|---|---|---|---| +| Per task, CLI | `bgagent submit --max-budget ` | One task | Range `0.01`–`100`; rejected client-side before the request is sent | +| Per task, REST | `max_budget_usd` in the `POST /v1/tasks` body | One task | Same `0.01`–`100` range, validated server-side | +| Per repo, Blueprint | `agent.maxBudgetUsd` on the repo's `Blueprint` construct | Every task on that repo | Persisted to `RepoTable.max_budget_usd`; same `0.01`–`100` range, enforced at CDK synth so an out-of-range value cannot deploy | +| Local batch runs | `MAX_BUDGET_USD` shell env | One local run | **Local `entrypoint.py` batch mode only.** The deployed AgentCore **server** mode ignores this variable — it reads the budget from the `/invocations` request body, so setting it on the runtime has no effect | +| Platform-wide default | — | — | **None exists.** Unset means unlimited (see below) | + +The two that apply to a deployed task resolve in this order: **per-task value wins, then the repo's Blueprint default, then no budget at all.** A mid-task Blueprint edit does not move a running task's budget. + +Administrators set the Blueprint default in the CDK stack: + +```typescript +new Blueprint(this, 'MyRepo', { + repo: 'my-org/my-repo', + repoTable, + agent: { maxBudgetUsd: 5.0 }, // every task on this repo caps at $5 unless overridden +}); +``` + +Run `bgagent repo show ` to see which value is in effect; the `max_budget_usd` line reads `(per-blueprint override)` when the repo pins one and `(platform default) unlimited` when it does not. + +### Unlimited by default is deliberate + +There is intentionally no platform-wide budget ceiling. A hard global cap would kill long-running tasks mid-change — the failure mode is a half-finished branch and no PR, which is worse than a task that costs more than expected. The intended controls are the per-repo Blueprint default above (opt in where you want a ceiling), the per-task flag, and `max_turns`. + +The documented escape hatch for cost is **choosing a lighter-token model** rather than relying on a cap: + +- **Per repo:** Blueprint `agent.modelId` — no code change and no agent redeploy +- **Per task:** `model_id` in the task payload + +The model you pick must be in the platform's Bedrock IAM grant list, or the task fails at turn 0 with `AccessDenied` — the grant is the gate, so a lighter model is only reachable if it has been granted. For how the model layers resolve, the grant list, and the measured cost comparison, see [Model configuration](/sample-autonomous-cloud-coding-agents/developer-guide/model-configuration). + +Note that the reported `cost_usd` is a client-side estimate, not authoritative billing — see [Cost attribution](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution). \ No newline at end of file diff --git a/docs/src/content/docs/developer-guide/Model-configuration.md b/docs/src/content/docs/developer-guide/Model-configuration.md index cf03195a..bef6469d 100644 --- a/docs/src/content/docs/developer-guide/Model-configuration.md +++ b/docs/src/content/docs/developer-guide/Model-configuration.md @@ -76,7 +76,7 @@ Token ratio 1.169; cost ratio 1.169 — identical. **The per-token rate is uncha | Per task, CLI | `bgagent submit --max-budget ` (`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](/sample-autonomous-cloud-coding-agents/customizing/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: