diff --git a/.changeset/patch-max-continuations.md b/.changeset/patch-max-continuations.md new file mode 100644 index 0000000000..977cea2f5a --- /dev/null +++ b/.changeset/patch-max-continuations.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Allow workflows to configure a `max-continuations` limit when running Copilot engines so autopilot mode can stop after a predictable number of iterations. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b7e2b86566..82e0cfa4a9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,8 +33,8 @@ "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, "ghcr.io/devcontainers/features/copilot-cli:latest": {}, "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/git-lfs:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/node:1": { "version": "24" } diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 3f0e5ca0a2..b5d7275910 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,7 +29,7 @@ # - shared/github-queries-safe-input.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"d8f85237def24565e6e04a7afcf460aab687ec2e8da39e945f8ccd8bdefaf5ed"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"2db010ebae2dafeeecf613a4430a7b8159d150f79ab54eeddc32fce6b932ac3f"} name: "Smoke Copilot" "on": @@ -1675,7 +1675,7 @@ jobs: set -o pipefail # shellcheck disable=SC1003 sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "*.githubusercontent.com,*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,playwright.download.prss.microsoft.com,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --autopilot --max-autopilot-continues 2 --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 91c3145565..cafac347b6 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -15,7 +15,9 @@ permissions: discussions: read actions: read name: Smoke Copilot -engine: copilot +engine: + id: copilot + max-continuations: 2 imports: - shared/gh.md - shared/reporting.md diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 9ef729733d..7ea42dff52 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7413,6 +7413,10 @@ "id": "copilot", "version": "beta" }, + { + "id": "copilot", + "max-continuations": 5 + }, { "id": "claude", "concurrency": { @@ -7458,6 +7462,11 @@ ], "description": "Maximum number of chat iterations per run. Helps prevent runaway loops and control costs. Has sensible defaults and can typically be omitted. Note: Only supported by the claude engine." }, + "max-continuations": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of continuations for multi-run autopilot mode. Default is 1 (single run, no autopilot). Values greater than 1 enable --autopilot mode for the copilot engine with --max-autopilot-continues set to this value. Note: Only supported by the copilot engine." + }, "concurrency": { "oneOf": [ { diff --git a/pkg/workflow/agent_validation.go b/pkg/workflow/agent_validation.go index 960ed753b0..09af8fed5a 100644 --- a/pkg/workflow/agent_validation.go +++ b/pkg/workflow/agent_validation.go @@ -12,6 +12,7 @@ // // - validateAgentFile() - Validates custom agent file exists // - validateMaxTurnsSupport() - Validates max-turns feature support +// - validateMaxContinuationsSupport() - Validates max-continuations feature support // - validateWebSearchSupport() - Validates web-search feature support (warning) // - validateWorkflowRunBranches() - Validates workflow_run has branch restrictions // @@ -122,6 +123,24 @@ 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 { + // Check if max-continuations is specified in the engine config + _, engineConfig := c.ExtractEngineConfig(frontmatter) + + if engineConfig == nil || engineConfig.MaxContinuations == 0 { + // No max-continuations specified, no validation needed + return nil + } + + // max-continuations is specified, check if the engine supports it + if !engine.SupportsMaxContinuations() { + return fmt.Errorf("max-continuations not supported: engine '%s' does not support the max-continuations feature", engine.GetID()) + } + + return nil +} + // validateWebSearchSupport validates that web-search tool is only used with engines that support this feature func (c *Compiler) validateWebSearchSupport(tools map[string]any, engine CodingAgentEngine) { // Check if web-search tool is requested diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 9028b57508..aa53184f67 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -126,6 +126,10 @@ type CapabilityProvider interface { // When true, plugins can be installed using the engine's plugin install command SupportsPlugins() bool + // SupportsMaxContinuations returns true if this engine supports the max-continuations feature + // When true, max-continuations > 1 enables autopilot/multi-run mode for the engine + SupportsMaxContinuations() bool + // SupportsLLMGateway returns the LLM gateway port number for this engine // Returns the port number (e.g., 10000) if the engine supports an LLM gateway // Returns -1 if the engine does not support an LLM gateway @@ -207,17 +211,18 @@ type CodingAgentEngine interface { // BaseEngine provides common functionality for agentic engines type BaseEngine struct { - id string - displayName string - description string - experimental bool - supportsToolsAllowlist bool - supportsMaxTurns bool - supportsWebFetch bool - supportsWebSearch bool - supportsFirewall bool - supportsPlugins bool - supportsLLMGateway bool + id string + displayName string + description string + experimental bool + supportsToolsAllowlist bool + supportsMaxTurns bool + supportsMaxContinuations bool + supportsWebFetch bool + supportsWebSearch bool + supportsFirewall bool + supportsPlugins bool + supportsLLMGateway bool } func (e *BaseEngine) GetID() string { @@ -260,6 +265,10 @@ func (e *BaseEngine) SupportsPlugins() bool { return e.supportsPlugins } +func (e *BaseEngine) SupportsMaxContinuations() bool { + return e.supportsMaxContinuations +} + func (e *BaseEngine) SupportsLLMGateway() int { // Engines that support LLM gateway must override this method // to return their specific port number (e.g., 10000, 10001, 10002) diff --git a/pkg/workflow/compiler_orchestrator_tools.go b/pkg/workflow/compiler_orchestrator_tools.go index 08a648b3fe..9924d57edb 100644 --- a/pkg/workflow/compiler_orchestrator_tools.go +++ b/pkg/workflow/compiler_orchestrator_tools.go @@ -234,6 +234,11 @@ func (c *Compiler) processToolsAndMarkdown(result *parser.FrontmatterResult, cle return nil, err } + // Validate max-continuations support for the current engine + if err := c.validateMaxContinuationsSupport(result.Frontmatter, agenticEngine); err != nil { + return nil, err + } + // Validate web-search support for the current engine (warning only) c.validateWebSearchSupport(tools, agenticEngine) diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 8d9da7f802..8aefe1eab5 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -34,17 +34,18 @@ func NewCopilotEngine() *CopilotEngine { copilotLog.Print("Creating new Copilot engine instance") return &CopilotEngine{ BaseEngine: BaseEngine{ - id: "copilot", - displayName: "GitHub Copilot CLI", - description: "Uses GitHub Copilot CLI with MCP server support", - experimental: false, - supportsToolsAllowlist: true, - supportsMaxTurns: false, // Copilot CLI does not support max-turns feature yet - supportsWebFetch: true, // Copilot CLI has built-in web-fetch support - supportsWebSearch: false, // Copilot CLI does not have built-in web-search support - supportsFirewall: true, // Copilot supports network firewalling via AWF - supportsPlugins: true, // Copilot supports plugin installation - supportsLLMGateway: true, // Copilot supports LLM gateway on port 10003 + id: "copilot", + displayName: "GitHub Copilot CLI", + description: "Uses GitHub Copilot CLI with MCP server support", + experimental: false, + supportsToolsAllowlist: true, + supportsMaxTurns: false, // Copilot CLI does not support max-turns feature yet + supportsMaxContinuations: true, // Copilot CLI supports --autopilot with --max-autopilot-continues + supportsWebFetch: true, // Copilot CLI has built-in web-fetch support + supportsWebSearch: false, // Copilot CLI does not have built-in web-search support + supportsFirewall: true, // Copilot supports network firewalling via AWF + supportsPlugins: true, // Copilot supports plugin installation + supportsLLMGateway: true, // Copilot supports LLM gateway on port 10003 }, } } diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index 73539ac2b9..e90c75db0a 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -86,6 +86,15 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st copilotArgs = append(copilotArgs, "--agent", agentIdentifier) } + // Add --autopilot and --max-autopilot-continues when max-continuations > 1 + // Never apply autopilot flags to detection jobs; they are only meaningful for the agent run. + isDetectionJob := workflowData.SafeOutputs == nil + if !isDetectionJob && workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxContinuations > 1 { + maxCont := workflowData.EngineConfig.MaxContinuations + copilotExecLog.Printf("Enabling autopilot mode with max-autopilot-continues=%d", maxCont) + copilotArgs = append(copilotArgs, "--autopilot", "--max-autopilot-continues", strconv.Itoa(maxCont)) + } + // Add tool permission arguments based on configuration toolArgs := e.computeCopilotToolArguments(workflowData.Tools, workflowData.SafeOutputs, workflowData.SafeInputs, workflowData) if len(toolArgs) > 0 { @@ -144,7 +153,6 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st // Copilot CLI reads it natively - no --model flag in the shell command needed. needsModelFlag := !modelConfigured // Check if this is a detection job (has no SafeOutputs config) - isDetectionJob := workflowData.SafeOutputs == nil var modelEnvVar string if isDetectionJob { modelEnvVar = constants.EnvVarModelDetectionCopilot @@ -303,7 +311,6 @@ COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" env[constants.CopilotCLIModelEnvVar] = workflowData.EngineConfig.Model } else { // No model configured - use fallback GitHub variable with shell expansion - isDetectionJob := workflowData.SafeOutputs == nil if isDetectionJob { env[constants.EnvVarModelDetectionCopilot] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarModelDetectionCopilot) } else { diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index de933b9309..e7ae617214 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -13,18 +13,19 @@ var engineLog = logger.New("workflow:engine") // EngineConfig represents the parsed engine configuration type EngineConfig struct { - ID string - Version string - Model string - MaxTurns string - Concurrency string // Agent job-level concurrency configuration (YAML format) - UserAgent string - Command string // Custom executable path (when set, skip installation steps) - Env map[string]string - Config string - Args []string - Firewall *FirewallConfig // AWF firewall configuration - Agent string // Agent identifier for copilot --agent flag (copilot engine only) + ID string + Version string + Model string + MaxTurns string + MaxContinuations int // Maximum number of continuations for autopilot mode (copilot engine only; > 1 enables --autopilot) + Concurrency string // Agent job-level concurrency configuration (YAML format) + UserAgent string + Command string // Custom executable path (when set, skip installation steps) + Env map[string]string + Config string + Args []string + Firewall *FirewallConfig // AWF firewall configuration + Agent string // Agent identifier for copilot --agent flag (copilot engine only) } // NetworkPermissions represents network access permissions for workflow execution @@ -116,6 +117,19 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } + // Extract optional 'max-continuations' field + if maxCont, hasMaxCont := engineObj["max-continuations"]; hasMaxCont { + if maxContInt, ok := maxCont.(int); ok { + config.MaxContinuations = maxContInt + } else if maxContUint64, ok := maxCont.(uint64); ok { + config.MaxContinuations = int(maxContUint64) + } else if maxContStr, ok := maxCont.(string); ok { + if parsed, err := strconv.Atoi(maxContStr); err == nil { + config.MaxContinuations = parsed + } + } + } + // Extract optional 'concurrency' field (string or object format) if concurrency, hasConcurrency := engineObj["concurrency"]; hasConcurrency { if concurrencyStr, ok := concurrency.(string); ok { diff --git a/smoke-test-22406871179.txt b/smoke-test-22406871179.txt new file mode 100644 index 0000000000..f75f995800 --- /dev/null +++ b/smoke-test-22406871179.txt @@ -0,0 +1 @@ +Test file for PR push - Run 22406871179