Skip to content

Commit 1d47a9e

Browse files
authored
Refactor engine.max-runs to top-level max-runs with AWF enforcement (#31418)
1 parent 2f3a255 commit 1d47a9e

13 files changed

Lines changed: 458 additions & 4 deletions

.github/aw/syntax.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ The YAML frontmatter supports these fields:
168168
- Bot must be active (installed) on repository to trigger workflow
169169
- **`strict:`** - Enable enhanced validation for production workflows (boolean, defaults to `true`)
170170
- Must be `true`
171+
- **`max-runs:`** - Maximum number of LLM invocations allowed per workflow run (integer or numeric string, minimum: 1)
172+
- Top-level field mapped to `apiProxy.maxRuns`
173+
- Supported by all engines
171174
- **`user-rate-limit:`** - Rate limiting configuration to prevent users from triggering the workflow too frequently (object)
172175
- **`max-runs-per-window:`** - Maximum runs allowed per user per time window (required, integer 1-10)
173176
- **`window:`** - Time window in minutes (integer 1-180, default: 60)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
name: ADR 31418 — Move engine.max-runs to top-level max-runs with AWF enforcement
3+
description: Canonicalize run-cap configuration as top-level `max-runs` and route enforcement through AWF (`apiProxy.maxRuns`) for all engines.
4+
type: project
5+
---
6+
7+
# ADR-31418: Move `engine.max-runs` to Top-Level `max-runs` with AWF Enforcement
8+
9+
**Date**: 2026-05-11
10+
**Status**: Draft
11+
**Deciders**: Unknown
12+
13+
---
14+
15+
## Part 1 — Narrative (Human-Friendly)
16+
17+
### Context
18+
19+
Run-cap configuration was previously expressed as the nested frontmatter field `engine.max-runs`, and enforcement was handled inconsistently across engines (Copilot/Claude/Codex/Gemini/Crush/OpenCode). The new AWF (Agent Workflow Framework) API proxy already centralizes other invocation-time policies (for example, `apiProxy.maxEffectiveTokens` and `apiProxy.modelMultipliers`), so it is the natural enforcement point for invocation caps as well. Without a single canonical place to declare the cap and a single enforcement point, behavior drifts per engine and the field is effectively unsupported on engines that have not implemented it. The repository already ships a codemod framework that can rewrite deprecated frontmatter automatically, which makes a deprecation path low-friction for existing workflows.
20+
21+
### Decision
22+
23+
We will canonicalize the run-cap configuration as a **top-level** frontmatter field `max-runs` and **route enforcement through AWF** by emitting it as `apiProxy.maxRuns` in the AWF config. The value is parsed once at the top level (accepting either an integer or a numeric string), stored on `EngineConfig.MaxRuns`, and forwarded to AWF; `engine.max-runs` is deprecated and migrated automatically via a new codemod (`engine-max-runs-to-top-level`). The field is omitted from `apiProxy` when unset so that no implicit cap is written.
24+
25+
### Alternatives Considered
26+
27+
#### Alternative 1: Keep `engine.max-runs` and implement per-engine enforcement
28+
29+
Continue to expose the cap as a nested `engine.max-runs` field and have each engine wrapper enforce it. Rejected because this is the status quo that motivated the change: enforcement diverges between engines, several engines do not implement the cap at all, and there is no single place to reason about invocation limits.
30+
31+
#### Alternative 2: Dual-support both `engine.max-runs` and top-level `max-runs`
32+
33+
Accept both forms indefinitely and have the compiler merge them. Rejected because long-term dual support adds documentation surface, parsing precedence rules, and ongoing user confusion. The existing codemod infrastructure (see `pkg/cli/fix_codemods.go`) lets us migrate in place automatically, so the cost of deprecation is small.
34+
35+
#### Alternative 3: Introduce a new name (e.g., `awf.maxRuns` or `invocation-cap`) at the top level
36+
37+
Pick a different top-level name to signal the AWF-routed semantics. Rejected because users already think of the value as `max-runs`; renaming would force a larger documentation churn and a more disruptive codemod without changing behavior. The internal AWF field is already named `maxRuns`, so the mapping is transparent.
38+
39+
### Consequences
40+
41+
#### Positive
42+
- Run-cap enforcement is uniform across all engines because it is applied by the AWF API proxy, eliminating per-engine drift.
43+
- Workflow authors have a single, discoverable top-level field (`max-runs`) instead of a nested engine option.
44+
- Existing workflows are migrated automatically by the `engine-max-runs-to-top-level` codemod, so the deprecation is low-friction.
45+
- The schema, autocomplete metadata, and engine-feature table now advertise the cap as supported by every engine, which matches the new reality.
46+
47+
#### Negative
48+
- `engine.max-runs` is now deprecated; users on older workflow files who do not run `fix` will see the nested field silently removed by the codemod next time it runs, which is a behavior change.
49+
- A new top-level frontmatter key expands the public configuration surface and must be kept in sync across schema, autocomplete, docs, parser, AWF config emitter, and codemods (six artifacts updated in this PR).
50+
- Enforcement now depends on AWF being in the request path; engines that bypass AWF would silently ignore `max-runs`. The PR assumes AWF is always present for runs that need the cap.
51+
52+
#### Neutral
53+
- The omission semantics are preserved: when `max-runs` is unset, no `apiProxy.maxRuns` key is emitted (verified by `TestBuildAWFConfigJSON/max-runs omitted when not configured`).
54+
- The codemod preserves an already-present top-level value when both nested and top-level forms exist, choosing the top-level value (verified by `TestEngineMaxRunsToTopLevelCodemod_RespectsExistingTopLevel`).
55+
- Both integer and numeric-string inputs are accepted, matching the existing convention used for `max-effective-tokens`.
56+
57+
---
58+
59+
## Part 2 — Normative Specification (RFC 2119)
60+
61+
> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
62+
63+
### Frontmatter Contract
64+
65+
1. The canonical run-cap field **MUST** be the top-level frontmatter key `max-runs`.
66+
2. The value of `max-runs` **MUST** be either a positive integer or a numeric string that parses to a positive integer (minimum `1`).
67+
3. The compiler **MUST** reject or ignore values of `max-runs` that are not positive integers; non-positive or non-numeric values **MUST NOT** be propagated to `EngineConfig.MaxRuns`.
68+
4. The compiler **MUST NOT** require `max-runs` to be set; it is **OPTIONAL** frontmatter.
69+
70+
### Engine Configuration Wiring
71+
72+
1. `EngineConfig` **MUST** carry the parsed run-cap value on the `MaxRuns` field.
73+
2. `EngineConfig.MaxRuns` **MUST** be sourced from the top-level frontmatter `max-runs`, not from any nested `engine.max-runs` field.
74+
3. `EngineConfig.GetMaxRuns()` **MUST** return `0` when `MaxRuns` is unset or non-positive.
75+
4. The compiler **SHOULD** populate `MaxRuns` for every engine extraction path (string form, inline form, and named-engine form) so that the field is available regardless of how the engine is declared.
76+
77+
### AWF API Proxy Emission
78+
79+
1. When `EngineConfig.GetMaxRuns()` returns a positive integer, `BuildAWFConfigJSON` **MUST** emit `apiProxy.maxRuns` with that value.
80+
2. When `EngineConfig.GetMaxRuns()` returns `0`, `BuildAWFConfigJSON` **MUST NOT** emit the `maxRuns` key in the `apiProxy` section.
81+
3. The emitted JSON key **MUST** be exactly `maxRuns` (camelCase), matching the existing `apiProxy` field naming convention.
82+
83+
### Deprecation and Migration
84+
85+
1. The nested field `engine.max-runs` **MUST** be treated as deprecated.
86+
2. The codemod `engine-max-runs-to-top-level` **MUST** be registered in `GetAllCodemods()` and **MUST** run after `engine-steps-to-top-level` and before `steps-run-secrets-to-env` to preserve the established codemod ordering.
87+
3. When applied, the codemod **MUST** remove `engine.max-runs` from the nested `engine` block.
88+
4. When a top-level `max-runs` is absent, the codemod **MUST** insert a top-level `max-runs` line carrying the migrated value.
89+
5. When a top-level `max-runs` already exists, the codemod **MUST** preserve the existing top-level value and **MUST NOT** overwrite it with the nested value.
90+
6. The codemod **MUST** be a no-op when neither `engine.max-runs` nor a top-level `max-runs` is present.
91+
92+
### Documentation and Tooling
93+
94+
1. The workflow JSON schema (`pkg/parser/schemas/main_workflow_schema.json`) **MUST** declare `max-runs` as a top-level property with `oneOf` integer-or-numeric-string typing and a minimum of `1`.
95+
2. The autocomplete metadata (`docs/public/editor/autocomplete-data.json`) **MUST** advertise `max-runs` as a top-level leaf field.
96+
3. The engine feature table in `docs/src/content/docs/reference/engines.md` **MUST** mark `max-runs` as supported (``) for every engine listed.
97+
4. New or updated documentation **SHOULD** describe `max-runs` as mapping to `awf.maxRuns` / `apiProxy.maxRuns`.
98+
99+
### Conformance
100+
101+
An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. In particular: emitting `apiProxy.maxRuns` when `max-runs` is unset, sourcing `MaxRuns` from `engine.max-runs` at runtime, or skipping codemod migration when both forms are present all constitute non-conformance.
102+
103+
---
104+
105+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25650796734) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

docs/public/editor/autocomplete-data.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,11 @@
11761176
"desc": "Explicit ET budget control for firewall cost enforcement.",
11771177
"leaf": true
11781178
},
1179+
"max-runs": {
1180+
"type": "integer|string",
1181+
"desc": "AWF invocation cap (`apiProxy.maxRuns`) applied consistently across all engines.",
1182+
"leaf": true
1183+
},
11791184
"mcp-servers": {
11801185
"type": "object",
11811186
"desc": "MCP server definitions"
@@ -3435,4 +3440,4 @@
34353440
"runtimes",
34363441
"jobs"
34373442
]
3438-
}
3443+
}

docs/src/content/docs/reference/engines.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Not all features are available across all engines. The table below summarizes pe
3232

3333
| Feature | Copilot | Claude | Codex | Gemini | Crush | OpenCode |
3434
|---------|:-------:|:------:|:-----:|:------:|:-----:|:--------:|
35+
| `max-runs` (AWF invocation cap) |||||||
3536
| `max-turns` |||||||
3637
| `max-continuations` |||||||
3738
| `tools.web-fetch` |||||||
@@ -43,6 +44,7 @@ Not all features are available across all engines. The table below summarizes pe
4344
| Tools allowlist |||||||
4445

4546
**Notes:**
47+
- `max-runs` is a top-level frontmatter field that maps to `apiProxy.maxRuns` and is supported by all engines.
4648
- `max-turns` limits the number of AI chat iterations per run (Claude only).
4749
- `max-continuations` enables autopilot mode with multiple consecutive runs (Copilot only).
4850
- `web-search` for Codex is disabled by default; add `tools: web-search:` to enable it. Other engines use a third-party MCP server — see [Using Web Search](/gh-aw/guides/web-search/).

pkg/cli/codemod_engine_max_runs.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
6+
"github.com/github/gh-aw/pkg/logger"
7+
)
8+
9+
var engineMaxRunsCodemodLog = logger.New("cli:codemod_engine_max_runs")
10+
11+
// getEngineMaxRunsToTopLevelCodemod migrates deprecated engine.max-runs to
12+
// top-level max-runs.
13+
func getEngineMaxRunsToTopLevelCodemod() Codemod {
14+
return Codemod{
15+
ID: "engine-max-runs-to-top-level",
16+
Name: "Move engine.max-runs to top-level max-runs",
17+
Description: "Moves deprecated 'engine.max-runs' to top-level 'max-runs' so AWF enforces invocation caps consistently across all engines.",
18+
IntroducedIn: "0.17.0",
19+
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
20+
engineValue, hasEngine := frontmatter["engine"]
21+
if !hasEngine {
22+
return content, false, nil
23+
}
24+
engineMap, ok := engineValue.(map[string]any)
25+
if !ok {
26+
return content, false, nil
27+
}
28+
if _, hasMaxRuns := engineMap["max-runs"]; !hasMaxRuns {
29+
return content, false, nil
30+
}
31+
32+
_, hasTopLevelMaxRuns := frontmatter["max-runs"]
33+
34+
return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
35+
for _, line := range lines {
36+
trimmed := strings.TrimSpace(line)
37+
if !isTopLevelKey(line) || !strings.HasPrefix(trimmed, "engine:") {
38+
continue
39+
}
40+
inlineValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "engine:"))
41+
if strings.HasPrefix(inlineValue, "{") && strings.Contains(inlineValue, "max-runs:") {
42+
engineMaxRunsCodemodLog.Print("Skipping engine.max-runs migration for inline-map engine syntax; migrate to top-level max-runs manually")
43+
return lines, false
44+
}
45+
}
46+
47+
maxRunsSuffix := ""
48+
inEngineBlock := false
49+
engineIndent := ""
50+
for _, line := range lines {
51+
trimmed := strings.TrimSpace(line)
52+
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "engine:") {
53+
inEngineBlock = true
54+
engineIndent = getIndentation(line)
55+
continue
56+
}
57+
if inEngineBlock && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "#") && len(getIndentation(line)) <= len(engineIndent) {
58+
inEngineBlock = false
59+
}
60+
if inEngineBlock && strings.HasPrefix(trimmed, "max-runs:") {
61+
parts := strings.SplitN(line, ":", 2)
62+
if len(parts) == 2 {
63+
maxRunsSuffix = parts[1]
64+
}
65+
break
66+
}
67+
}
68+
69+
result, removed := removeFieldFromBlock(lines, "max-runs", "engine")
70+
if !removed {
71+
return lines, false
72+
}
73+
74+
if hasTopLevelMaxRuns {
75+
engineMaxRunsCodemodLog.Print("Removed deprecated engine.max-runs (top-level max-runs already present)")
76+
return result, true
77+
}
78+
79+
insertAt := 0
80+
for i, line := range result {
81+
if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), "engine:") {
82+
insertAt = i
83+
break
84+
}
85+
}
86+
87+
maxRunsLine := "max-runs:" + maxRunsSuffix
88+
withTopLevel := make([]string, 0, len(result)+1)
89+
withTopLevel = append(withTopLevel, result[:insertAt]...)
90+
withTopLevel = append(withTopLevel, maxRunsLine)
91+
withTopLevel = append(withTopLevel, result[insertAt:]...)
92+
93+
engineMaxRunsCodemodLog.Print("Migrated engine.max-runs to top-level max-runs")
94+
return withTopLevel, true
95+
})
96+
},
97+
}
98+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//go:build !integration
2+
3+
package cli
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestEngineMaxRunsToTopLevelCodemod_Metadata(t *testing.T) {
13+
codemod := getEngineMaxRunsToTopLevelCodemod()
14+
15+
assert.Equal(t, "engine-max-runs-to-top-level", codemod.ID)
16+
assert.Equal(t, "Move engine.max-runs to top-level max-runs", codemod.Name)
17+
assert.NotEmpty(t, codemod.Description)
18+
assert.Equal(t, "0.17.0", codemod.IntroducedIn)
19+
require.NotNil(t, codemod.Apply)
20+
}
21+
22+
func TestEngineMaxRunsToTopLevelCodemod_NoOp(t *testing.T) {
23+
codemod := getEngineMaxRunsToTopLevelCodemod()
24+
25+
content := `---
26+
on: push
27+
engine:
28+
id: copilot
29+
---
30+
`
31+
frontmatter := map[string]any{
32+
"on": "push",
33+
"engine": map[string]any{
34+
"id": "copilot",
35+
},
36+
}
37+
38+
result, applied, err := codemod.Apply(content, frontmatter)
39+
require.NoError(t, err)
40+
assert.False(t, applied)
41+
assert.Equal(t, content, result)
42+
}
43+
44+
func TestEngineMaxRunsToTopLevelCodemod_MigratesField(t *testing.T) {
45+
codemod := getEngineMaxRunsToTopLevelCodemod()
46+
47+
content := `---
48+
on: push
49+
engine:
50+
id: copilot
51+
max-runs: 42
52+
---
53+
54+
# Body`
55+
frontmatter := map[string]any{
56+
"on": "push",
57+
"engine": map[string]any{
58+
"id": "copilot",
59+
"max-runs": 42,
60+
},
61+
}
62+
63+
result, applied, err := codemod.Apply(content, frontmatter)
64+
require.NoError(t, err)
65+
assert.True(t, applied)
66+
assert.Contains(t, result, "\nmax-runs: 42\nengine:")
67+
assert.NotContains(t, result, "\n max-runs:")
68+
}
69+
70+
func TestEngineMaxRunsToTopLevelCodemod_RespectsExistingTopLevel(t *testing.T) {
71+
codemod := getEngineMaxRunsToTopLevelCodemod()
72+
73+
content := `---
74+
max-runs: 10
75+
engine:
76+
id: copilot
77+
max-runs: 42
78+
---
79+
`
80+
frontmatter := map[string]any{
81+
"max-runs": 10,
82+
"engine": map[string]any{
83+
"id": "copilot",
84+
"max-runs": 42,
85+
},
86+
}
87+
88+
result, applied, err := codemod.Apply(content, frontmatter)
89+
require.NoError(t, err)
90+
assert.True(t, applied)
91+
assert.Contains(t, result, "max-runs: 10")
92+
assert.NotContains(t, result, "max-runs: 42")
93+
assert.NotContains(t, result, "\n max-runs:")
94+
}
95+
96+
func TestEngineMaxRunsToTopLevelCodemod_InlineEngineMapNoOp(t *testing.T) {
97+
codemod := getEngineMaxRunsToTopLevelCodemod()
98+
99+
content := `---
100+
on: push
101+
engine: { id: copilot, max-runs: 42 }
102+
---
103+
`
104+
frontmatter := map[string]any{
105+
"on": "push",
106+
"engine": map[string]any{
107+
"id": "copilot",
108+
"max-runs": 42,
109+
},
110+
}
111+
112+
result, applied, err := codemod.Apply(content, frontmatter)
113+
require.NoError(t, err)
114+
assert.False(t, applied)
115+
assert.Equal(t, content, result)
116+
}

pkg/cli/fix_codemods.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ func GetAllCodemods() []Codemod {
4343
getRolesToOnRolesCodemod(), // Move top-level roles to on.roles
4444
getBotsToOnBotsCodemod(), // Move top-level bots to on.bots
4545
getEngineStepsToTopLevelCodemod(), // Move engine.steps to top-level steps
46+
getEngineMaxRunsToTopLevelCodemod(), // Move engine.max-runs to top-level max-runs
4647
getStepsRunSecretsToEnvCodemod(), // Move inline secrets in step run fields to step env bindings
4748
getEngineEnvSecretsCodemod(), // Remove unsafe secret-bearing engine.env entries
4849
getAssignToAgentDefaultAgentCodemod(), // Rename deprecated default-agent to name in assign-to-agent

pkg/cli/fix_codemods_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) {
8282
"mcp-network-to-top-level-migration",
8383
"safe-inputs-to-mcp-scripts",
8484
"rate-limit-to-user-rate-limit",
85+
"engine-max-runs-to-top-level",
8586
"steps-run-secrets-to-env",
8687
"engine-env-secrets-to-engine-config",
8788
"serena-tools-to-shared-import",
@@ -145,6 +146,7 @@ func expectedCodemodOrder() []string {
145146
"roles-to-on-roles",
146147
"bots-to-on-bots",
147148
"engine-steps-to-top-level",
149+
"engine-max-runs-to-top-level",
148150
"steps-run-secrets-to-env",
149151
"engine-env-secrets-to-engine-config",
150152
"assign-to-agent-default-agent-to-name",

0 commit comments

Comments
 (0)