Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions .github/aw/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ The YAML frontmatter supports these fields:
- Bot must be active (installed) on repository to trigger workflow
- **`strict:`** - Enable enhanced validation for production workflows (boolean, defaults to `true`)
- Must be `true`
- **`max-runs:`** - Maximum number of LLM invocations allowed per workflow run (integer or numeric string, minimum: 1)
- Top-level field mapped to `awf.maxRuns` / `apiProxy.maxRuns`
- Supported by all engines
- **`user-rate-limit:`** - Rate limiting configuration to prevent users from triggering the workflow too frequently (object)
- **`max-runs-per-window:`** - Maximum runs allowed per user per time window (required, integer 1-10)
- **`window:`** - Time window in minutes (integer 1-180, default: 60)
Expand Down
7 changes: 6 additions & 1 deletion docs/public/editor/autocomplete-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,11 @@
"desc": "Explicit ET budget control for firewall cost enforcement.",
"leaf": true
},
"max-runs": {
"type": "integer|string",
"desc": "AWF invocation cap (`apiProxy.maxRuns`) applied consistently across all engines.",
"leaf": true
},
"mcp-servers": {
"type": "object",
"desc": "MCP server definitions"
Expand Down Expand Up @@ -3435,4 +3440,4 @@
"runtimes",
"jobs"
]
}
}
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Not all features are available across all engines. The table below summarizes pe

| Feature | Copilot | Claude | Codex | Gemini | Crush | OpenCode |
|---------|:-------:|:------:|:-----:|:------:|:-----:|:--------:|
| `max-runs` (AWF invocation cap) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `max-turns` | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `max-continuations` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `tools.web-fetch` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Expand All @@ -43,6 +44,7 @@ Not all features are available across all engines. The table below summarizes pe
| Tools allowlist | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |

**Notes:**
- `max-runs` is a top-level frontmatter field that maps to `awf.maxRuns` / `apiProxy.maxRuns` and is supported by all engines.
- `max-turns` limits the number of AI chat iterations per run (Claude only).
- `max-continuations` enables autopilot mode with multiple consecutive runs (Copilot only).
- `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/).
Expand Down
86 changes: 86 additions & 0 deletions pkg/cli/codemod_engine_max_runs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cli

import (
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var engineMaxRunsCodemodLog = logger.New("cli:codemod_engine_max_runs")

// getEngineMaxRunsToTopLevelCodemod migrates deprecated engine.max-runs to
// top-level max-runs.
func getEngineMaxRunsToTopLevelCodemod() Codemod {
return Codemod{
ID: "engine-max-runs-to-top-level",
Name: "Move engine.max-runs to top-level max-runs",
Description: "Moves deprecated 'engine.max-runs' to top-level 'max-runs' so AWF enforces invocation caps consistently across all engines.",
IntroducedIn: "0.17.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
engineValue, hasEngine := frontmatter["engine"]
if !hasEngine {
return content, false, nil
}
engineMap, ok := engineValue.(map[string]any)
if !ok {
return content, false, nil
}
if _, hasMaxRuns := engineMap["max-runs"]; !hasMaxRuns {
return content, false, nil
}

_, hasTopLevelMaxRuns := frontmatter["max-runs"]

return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
maxRunsSuffix := ""
inEngineBlock := false
engineIndent := ""
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if isTopLevelKey(line) && strings.HasPrefix(trimmed, "engine:") {
inEngineBlock = true
engineIndent = getIndentation(line)
continue
}
if inEngineBlock && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "#") && len(getIndentation(line)) <= len(engineIndent) {
inEngineBlock = false
}
if inEngineBlock && strings.HasPrefix(trimmed, "max-runs:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
maxRunsSuffix = parts[1]
}
break
}
}

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.

[/improve-codebase-architecture] The first loop (lines 38–55) scans lines to extract maxRunsSuffix, but the value is already available from the engineMap passed in as frontmatter — no need for a second traversal:

// Instead of the scan loop, derive the suffix directly:
var maxRunsSuffix string
if v := engineMap["max-runs"]; v != nil {
    maxRunsSuffix = fmt.Sprintf(" %v", v)
}

This removes ~18 lines of stateful scanning and eliminates the subtle edge case where a comment line inside the engine block could confuse the inEngineBlock state machine.


result, removed := removeFieldFromBlock(lines, "max-runs", "engine")
if !removed {
return lines, false
}
Comment on lines +69 to +72

if hasTopLevelMaxRuns {
engineMaxRunsCodemodLog.Print("Removed deprecated engine.max-runs (top-level max-runs already present)")
return result, true
}

insertAt := 0
for i, line := range result {
if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), "engine:") {
insertAt = i
break
}
}

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.

[/tdd] If engine: is not found in result (shouldn't happen in practice, but the loop doesn't guard against it), insertAt stays 0 and max-runs is silently prepended to the very start of the frontmatter. This is hard to detect without a dedicated test.

Consider adding an early return:

insertAt := -1
for i, line := range result {
    if isTopLevelKey(line) && strings.HasPrefix(strings.TrimSpace(line), "engine:") {
        insertAt = i
        break
    }
}
if insertAt < 0 {
    // engine: block disappeared after removal — should never happen
    return lines, false
}

A test with an edge-case input where engine: has no sub-keys left after removal would cover this path.


maxRunsLine := "max-runs:" + maxRunsSuffix
withTopLevel := make([]string, 0, len(result)+1)
withTopLevel = append(withTopLevel, result[:insertAt]...)
withTopLevel = append(withTopLevel, maxRunsLine)
withTopLevel = append(withTopLevel, result[insertAt:]...)

engineMaxRunsCodemodLog.Print("Migrated engine.max-runs to top-level max-runs")
return withTopLevel, true
})
},
}
}
94 changes: 94 additions & 0 deletions pkg/cli/codemod_engine_max_runs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//go:build !integration

package cli

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEngineMaxRunsToTopLevelCodemod_Metadata(t *testing.T) {
codemod := getEngineMaxRunsToTopLevelCodemod()

assert.Equal(t, "engine-max-runs-to-top-level", codemod.ID)
assert.Equal(t, "Move engine.max-runs to top-level max-runs", codemod.Name)
assert.NotEmpty(t, codemod.Description)
assert.Equal(t, "0.17.0", codemod.IntroducedIn)
require.NotNil(t, codemod.Apply)
}

func TestEngineMaxRunsToTopLevelCodemod_NoOp(t *testing.T) {
codemod := getEngineMaxRunsToTopLevelCodemod()

content := `---
on: push
engine:
id: copilot
---
`
frontmatter := map[string]any{
"on": "push",
"engine": map[string]any{
"id": "copilot",
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
}

func TestEngineMaxRunsToTopLevelCodemod_MigratesField(t *testing.T) {
codemod := getEngineMaxRunsToTopLevelCodemod()

content := `---
on: push
engine:
id: copilot
max-runs: 42
---

# Body`
frontmatter := map[string]any{
"on": "push",
"engine": map[string]any{
"id": "copilot",
"max-runs": 42,
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.True(t, applied)
assert.Contains(t, result, "\nmax-runs: 42\nengine:")
assert.NotContains(t, result, "\n max-runs:")
}

func TestEngineMaxRunsToTopLevelCodemod_RespectsExistingTopLevel(t *testing.T) {
codemod := getEngineMaxRunsToTopLevelCodemod()

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.

[/tdd] assert.Contains on the transformed string checks substring presence but doesn't enforce exact structure — if whitespace or another line snuck between max-runs: and engine:, the assertion would fail with a confusing message. Using assert.Equal on the full result makes the spec explicit:

expected := `---
max-runs: 42
engine:
  id: copilot
---

# Body`
assert.Equal(t, expected, result)

This also doubles as a regression guard against inadvertent whitespace changes.

content := `---
max-runs: 10
engine:
id: copilot
max-runs: 42
---
`
frontmatter := map[string]any{
"max-runs": 10,
"engine": map[string]any{
"id": "copilot",
"max-runs": 42,
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.True(t, applied)
assert.Contains(t, result, "max-runs: 10")
assert.NotContains(t, result, "max-runs: 42")
assert.NotContains(t, result, "\n max-runs:")
}
1 change: 1 addition & 0 deletions pkg/cli/fix_codemods.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func GetAllCodemods() []Codemod {
getRolesToOnRolesCodemod(), // Move top-level roles to on.roles
getBotsToOnBotsCodemod(), // Move top-level bots to on.bots
getEngineStepsToTopLevelCodemod(), // Move engine.steps to top-level steps
getEngineMaxRunsToTopLevelCodemod(), // Move engine.max-runs to top-level max-runs
getStepsRunSecretsToEnvCodemod(), // Move inline secrets in step run fields to step env bindings
getEngineEnvSecretsCodemod(), // Remove unsafe secret-bearing engine.env entries
getAssignToAgentDefaultAgentCodemod(), // Rename deprecated default-agent to name in assign-to-agent
Expand Down
2 changes: 2 additions & 0 deletions pkg/cli/fix_codemods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) {
"mcp-network-to-top-level-migration",
"safe-inputs-to-mcp-scripts",
"rate-limit-to-user-rate-limit",
"engine-max-runs-to-top-level",
"steps-run-secrets-to-env",
"engine-env-secrets-to-engine-config",
"serena-tools-to-shared-import",
Expand Down Expand Up @@ -145,6 +146,7 @@ func expectedCodemodOrder() []string {
"roles-to-on-roles",
"bots-to-on-bots",
"engine-steps-to-top-level",
"engine-max-runs-to-top-level",
"steps-run-secrets-to-env",
"engine-env-secrets-to-engine-config",
"assign-to-agent-default-agent-to-name",
Expand Down
15 changes: 15 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3695,6 +3695,21 @@
],
"description": "Explicit ET budget control for firewall cost enforcement. Defaults to 10000000 when omitted."
},
"max-runs": {
"oneOf": [
{
"type": "integer",
"minimum": 1,
"description": "Maximum number of LLM invocations allowed per run."
},
{
"type": "string",
"pattern": "^[0-9]+$",
"description": "Maximum number of LLM invocations allowed per run as a numeric string."
}
],
"description": "AWF invocation cap (`apiProxy.maxRuns`) applied consistently across all engines."
},
"mcp-servers": {
"type": "object",
"description": "MCP server definitions",
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ type AWFAPIProxyConfig struct {
// Maps to: --enable-api-proxy
Enabled bool `json:"enabled"`

// MaxRuns is the maximum number of LLM invocations allowed for a run.
MaxRuns int `json:"maxRuns,omitempty"`

// MaxEffectiveTokens is the explicit ET budget enforced by the API proxy.
MaxEffectiveTokens int64 `json:"maxEffectiveTokens,omitempty"`

Expand Down Expand Up @@ -248,12 +251,15 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {

// ── API proxy section ─────────────────────────────────────────────────────
maxEffectiveTokens := constants.DefaultMaxEffectiveTokens
maxRuns := 0
if config.WorkflowData != nil && config.WorkflowData.EngineConfig != nil {
maxEffectiveTokens = config.WorkflowData.EngineConfig.GetMaxEffectiveTokens()
maxRuns = config.WorkflowData.EngineConfig.GetMaxRuns()
}

apiProxy := &AWFAPIProxyConfig{
Enabled: true,
MaxRuns: maxRuns,
MaxEffectiveTokens: maxEffectiveTokens,
}

Expand Down
41 changes: 40 additions & 1 deletion pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,45 @@ func TestBuildAWFConfigJSON(t *testing.T) {
assert.Contains(t, jsonStr, `"maxEffectiveTokens":424242`, "apiProxy should emit configured maxEffectiveTokens")
})

t.Run("configured max-runs is emitted in apiProxy config", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
MaxRuns: 37,
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)
assert.Contains(t, jsonStr, `"maxRuns":37`, "apiProxy should emit configured maxRuns")
})

t.Run("max-runs omitted when not configured", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)
assert.NotContains(t, jsonStr, `"maxRuns"`, "apiProxy should omit maxRuns when unset")
})

t.Run("engine token-weights multipliers are emitted in apiProxy modelMultipliers", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
Expand All @@ -127,7 +166,7 @@ func TestBuildAWFConfigJSON(t *testing.T) {
ID: "copilot",
TokenWeights: &types.TokenWeights{
Multipliers: map[string]float64{
"gpt-5": 1.2,
"gpt-5": 1.2,
"gpt-5-mini": 0.8,
},
},
Expand Down
Loading
Loading