Skip to content
Closed
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
30 changes: 15 additions & 15 deletions .github/workflows/cli-version-checker.lock.yml

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

11 changes: 11 additions & 0 deletions pkg/constants/version_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ const AWFCliProxyMinVersion Version = "v0.25.17"
// --allow-host-ports or the run will fail at startup with an unknown flag error.
const AWFAllowHostPortsMinVersion Version = "v0.25.24"

// AWFMaxRunsMinVersion is the minimum AWF version that supports the
// container.maxRuns config field, which limits the number of times the agent
// command may be re-launched within a single AWF container execution.
// This is the AWF-level alternative to engine-specific continuation flags
// (e.g. Copilot's --max-autopilot-continues). See:
// specs/awf-container-max-runs-spec.md for the full specification.
//
// ⚠️ This version is a placeholder — update it when gh-aw-firewall ships
// support for container.maxRuns and re-run: make build && make recompile && make recompile
const AWFMaxRunsMinVersion Version = "v0.26.0"

// CopilotNoAskUserMinVersion is the minimum Copilot CLI version that supports the --no-ask-user
// flag, which enables fully autonomous agentic runs by suppressing interactive prompts.
// Workflows using an older Copilot CLI version must not emit --no-ask-user or the run will fail.
Expand Down
18 changes: 16 additions & 2 deletions pkg/workflow/agent_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import (
"strings"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/goccy/go-yaml"
)

Expand Down Expand Up @@ -134,8 +135,13 @@ func (c *Compiler) validateMaxTurnsSupport(frontmatter map[string]any, engine Co
return nil
}

// validateMaxContinuationsSupport validates that max-continuations is only used with engines that support this feature
func (c *Compiler) validateMaxContinuationsSupport(frontmatter map[string]any, engine CodingAgentEngine) error {
// validateMaxContinuationsSupport validates that max-continuations is only used with engines
// that support this feature, or with AWF versions that implement it engine-agnostically.
//
// When AWF >= AWFMaxRunsMinVersion, the multi-run orchestration is handled by AWF itself
// via the container.maxRuns config field, so any engine may use engine.max-continuations.
// For older AWF versions, the constraint falls back to the engine-specific capability check.
func (c *Compiler) validateMaxContinuationsSupport(frontmatter map[string]any, engine CodingAgentEngine, networkPermissions *NetworkPermissions) error {
// Check if max-continuations is specified in the engine config
_, engineConfig := c.ExtractEngineConfig(frontmatter)

Expand All @@ -146,6 +152,14 @@ func (c *Compiler) validateMaxContinuationsSupport(frontmatter map[string]any, e

agentValidationLog.Printf("Validating max-continuations support: engine=%s, maxContinuations=%d", engine.GetID(), engineConfig.MaxContinuations)

// When AWF supports container.maxRuns, any engine may use engine.max-continuations.
// The multi-run orchestration is delegated to AWF rather than the engine CLI.
firewallConfig := getFirewallConfig(&WorkflowData{NetworkPermissions: networkPermissions})
if awfSupportsMaxRuns(firewallConfig) {
agentValidationLog.Printf("AWF supports maxRuns (>= %s); engine-specific capability check skipped", constants.AWFMaxRunsMinVersion)
return nil
}

// max-continuations is specified, check if the engine supports it
if !engine.GetCapabilities().MaxContinuations {
agentValidationLog.Printf("Engine %s does not support max-continuations feature", engine.GetID())
Expand Down
31 changes: 28 additions & 3 deletions pkg/workflow/awf_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ type AWFContainerConfig struct {
// Format: "<tag>" or "<tag>,squid=sha256:...,agent=sha256:..."
// Maps to: --image-tag <value>
ImageTag string `json:"imageTag,omitempty"`

// MaxRuns is the maximum number of times the agent command may be re-launched
// within a single AWF container execution. Values greater than 1 enable multi-run
// mode where AWF orchestrates successive agent runs up to this limit. This is the
// AWF-level alternative to engine-specific continuation flags such as Copilot's
// --max-autopilot-continues.
//
// Requires AWF >= v0.26.0. Populated from engine.max-continuations in frontmatter.
// When the effective AWF version is older than AWFMaxRunsMinVersion this field is
// omitted and the engine-specific flag is used as a fallback instead.
MaxRuns int `json:"maxRuns,omitempty"`
}

// buildAWFConfigSchemaURL returns the release-pinned JSON schema URL for the AWF config file.
Expand Down Expand Up @@ -288,13 +299,27 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {

// ── Container section ─────────────────────────────────────────────────────
awfImageTag := buildAWFImageTagWithDigests(getAWFImageTag(firewallConfig), config.WorkflowData)
containerConfig := &AWFContainerConfig{}
if awfImageTag != "" {
awfConfig.Container = &AWFContainerConfig{
ImageTag: awfImageTag,
}
containerConfig.ImageTag = awfImageTag
awfConfigLog.Printf("Container section: image_tag=%s", awfImageTag)
}

// Populate maxRuns from engine.max-continuations when AWF supports it.
// When AWF is older than AWFMaxRunsMinVersion the field is left at zero (omitted from JSON)
// and the engine-specific flag is used as a fallback in the engine execution step.
if awfSupportsMaxRuns(firewallConfig) &&
config.WorkflowData != nil &&
config.WorkflowData.EngineConfig != nil &&
config.WorkflowData.EngineConfig.MaxContinuations > 1 {
containerConfig.MaxRuns = config.WorkflowData.EngineConfig.MaxContinuations
awfConfigLog.Printf("Container section: max_runs=%d (from engine.max-continuations)", containerConfig.MaxRuns)
}

if containerConfig.ImageTag != "" || containerConfig.MaxRuns > 0 {
awfConfig.Container = containerConfig
}

jsonStr, err := jsonutil.MarshalCompactNoHTMLEscape(awfConfig)
if err != nil {
return "", fmt.Errorf("failed to marshal AWF config to JSON: %w", err)
Expand Down
82 changes: 82 additions & 0 deletions pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,3 +541,85 @@ func TestBuildAWFCommand_ConfigFileWithPathSetup(t *testing.T) {
assert.Less(t, pathSetupIdx, configWriteIdx, "path setup must precede config file write")
assert.Less(t, configWriteIdx, awfIdx, "config file write must precede AWF invocation")
}

// TestBuildAWFConfigJSON_MaxRuns verifies that container.maxRuns is populated from
// engine.max-continuations when the AWF version supports it (>= AWFMaxRunsMinVersion),
// and is omitted when the AWF version is older.
func TestBuildAWFConfigJSON_MaxRuns(t *testing.T) {
t.Run("maxRuns is omitted when AWF version does not support it", func(t *testing.T) {
// Pin an AWF version older than AWFMaxRunsMinVersion so the field is not emitted.
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MaxContinuations: 3},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true, Version: "v0.25.0"},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

assert.NotContains(t, jsonStr, `"maxRuns"`, "maxRuns must NOT be emitted for old AWF versions")
})

t.Run("maxRuns is emitted when AWF version supports it", func(t *testing.T) {
// Pin an AWF version at or above AWFMaxRunsMinVersion so the field IS emitted.
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MaxContinuations: 5},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFMaxRunsMinVersion)},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

assert.Contains(t, jsonStr, `"maxRuns":5`, "maxRuns must be emitted when AWF supports it")
})

t.Run("maxRuns is omitted when max-continuations is 0 or 1", func(t *testing.T) {
// max-continuations <= 1 means single-run (no multi-run mode) — never emit maxRuns.
for _, maxCont := range []int{0, 1} {
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MaxContinuations: maxCont},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFMaxRunsMinVersion)},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

assert.NotContains(t, jsonStr, `"maxRuns"`, "maxRuns must not be emitted for max-continuations=%d", maxCont)
}
})

t.Run("maxRuns is emitted with latest AWF version", func(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
AllowedDomains: "github.com",
WorkflowData: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MaxContinuations: 7},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true, Version: "latest"},
},
},
}

jsonStr, err := BuildAWFConfigJSON(config)
require.NoError(t, err)

assert.Contains(t, jsonStr, `"maxRuns":7`, "maxRuns must be emitted for 'latest' AWF version")
})
}
32 changes: 32 additions & 0 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -684,3 +684,35 @@ func awfSupportsAllowHostPorts(firewallConfig *FirewallConfig) bool {
minVersion := string(constants.AWFAllowHostPortsMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}

// awfSupportsMaxRuns returns true when the effective AWF version supports the
// container.maxRuns config field, which controls how many times AWF re-launches
// the agent command within a single container execution.
//
// The container.maxRuns field was introduced in AWF v0.26.0. When the effective
// AWF version is older than AWFMaxRunsMinVersion this field must not be set in the
// generated AWF config; the engine-specific continuation flag (e.g.
// --max-autopilot-continues for Copilot) is used as a fallback instead.
//
// Special cases:
// - No version override (firewallConfig is nil or has no Version): use DefaultFirewallVersion
// and compare against AWFMaxRunsMinVersion.
// - "latest": always returns true (latest is always a new release).
// - Any semver string ≥ AWFMaxRunsMinVersion: returns true.
// - Any semver string < AWFMaxRunsMinVersion: returns false.
// - Non-semver string (e.g. a branch name): returns false (conservative).
func awfSupportsMaxRuns(firewallConfig *FirewallConfig) bool {
var versionStr string
if firewallConfig != nil && firewallConfig.Version != "" {
versionStr = firewallConfig.Version
} else {
versionStr = string(constants.DefaultFirewallVersion)
}

if strings.EqualFold(versionStr, "latest") {
return true
}

minVersion := string(constants.AWFMaxRunsMinVersion)
return semverutil.Compare(versionStr, minVersion) >= 0
}
Loading