Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR-41674: Deduplicate Engine Env Assembly and Codemod Migration Helpers

**Date**: 2026-06-26
**Status**: Draft
**Deciders**: Unknown (automated refactor by copilot-swe-agent)

---

### Context

The three agentic engine implementations (Claude, Codex, and Copilot) each contained near-identical ~20-line inline blocks responsible for setting tool-timeout env vars (`GH_AW_STARTUP_TIMEOUT`, `GH_AW_TOOL_TIMEOUT`), max-turns env resolution (`GH_AW_MAX_TURNS`), engine/agent env merges, and MCP-scripts secret passthrough. Similarly, two codemods for migrating deprecated `engine.max-runs` and `engine.max-turns` to top-level YAML keys shared ~80 lines of near-identical frontmatter transformation logic. Any change to env-assembly semantics — such as adding a new runtime env var — required the same edit in three separate engine files. The `FormatStepWithCommandAndEnv` function also used a bespoke ad-hoc sort instead of the existing `sliceutil.SortedKeys` utility.

### Decision

We will extract the repeated env-assembly logic into four shared helper functions in `pkg/workflow/engine_helpers.go` (`applyOptionalEngineToolTimeouts`, `applyEngineMaxTurnsEnv`, `applyEngineAndAgentEnv`, `applyMCPScriptsSecretEnv`) and introduce a single `migrateEngineFieldToTopLevel` helper in `pkg/cli/codemod_engine_to_top_level_helpers.go` that both codemods delegate to. We will also replace the ad-hoc key-sort in `FormatStepWithCommandAndEnv` with `sliceutil.SortedKeys`. This centralises shared behavior without changing runtime semantics or user-visible output.

### Alternatives Considered

#### Alternative 1: Keep Inline Per-Engine Logic (Status Quo)

Each engine file retains its own copy of the env-assembly blocks. The approach is maximally self-contained: reading any single engine file gives the full picture of what env vars it sets. This was rejected because the duplication made it error-prone to keep the three engines in sync when semantics change, as demonstrated by the three identical blocks already being the dominant maintenance burden called out in the PR description.

#### Alternative 2: Interface-Based Polymorphism

Define an `EngineEnvApplier` interface with an `ApplyCommonEnv(env map[string]string, wd *WorkflowData)` method, then have each engine embed a common implementation. This is a valid Go pattern for shared behavior across types, but it adds interface indirection for what is essentially stateless, side-effecting helper logic with no need for substitutability. Package-level functions are simpler and sufficient here.

### Consequences

#### Positive
- Single source of truth for each env-assembly concern; future additions (e.g., a new `GH_AW_*` env var) require exactly one code change.
- Net reduction of ~160 lines of duplicated code across the three engine files and two codemod files.
- `FormatStepWithCommandAndEnv` uses the shared `sliceutil.SortedKeys` utility consistently with other callers in the codebase.
- `migrateEngineFieldToTopLevel` is parameterised and reusable for any future `engine.*` → top-level migration codemods.

#### Negative
- New engine implementations must know to call the shared helpers; this implicit contract is not enforced by the type system.
- The generic `migrateEngineFieldToTopLevel` signature (9 parameters) is harder to read in isolation than each of the original self-contained codemods, increasing cognitive overhead for understanding a single migration path.

#### Neutral
- The refactor is behavior-preserving: no change to env-var names, values, or precedence order; existing tests continue to exercise the same code paths via the new helper call sites.
- The `docs/adr/` directory convention is being adopted concurrently with this PR, so this ADR is the first to use the PR-number-as-ADR-number naming scheme.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
94 changes: 12 additions & 82 deletions pkg/cli/codemod_engine_max_runs.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package cli

import (
"strings"

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

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

Expand All @@ -17,83 +13,17 @@ func getEngineMaxRunsToTopLevelCodemod() Codemod {
Description: "Moves deprecated 'engine.max-runs' to top-level 'max-turns' 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"]
_, hasTopLevelMaxTurns := frontmatter["max-turns"]

return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !isTopLevelKey(line) || !strings.HasPrefix(trimmed, "engine:") {
continue
}
inlineValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "engine:"))
if strings.HasPrefix(inlineValue, "{") && strings.Contains(inlineValue, "max-runs:") {
engineMaxRunsCodemodLog.Print("Skipping engine.max-runs migration for inline-map engine syntax; migrate to top-level max-turns manually")
return lines, false
}
}

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
}
}

result, removed := removeFieldFromBlock(lines, "max-runs", "engine")
if !removed {
return lines, false
}

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

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

maxRunsLine := "max-turns:" + 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-turns")
return withTopLevel, true
})
return migrateEngineFieldToTopLevel(
content,
frontmatter,
"max-runs",
"max-turns",
[]string{"max-runs", "max-turns"},
engineMaxRunsCodemodLog,
"Skipping engine.max-runs migration for inline-map engine syntax; migrate to top-level max-turns manually",
"Removed deprecated engine.max-runs (top-level max-runs/max-turns already present)",
"Migrated engine.max-runs to top-level max-turns",
)
},
}
}
93 changes: 12 additions & 81 deletions pkg/cli/codemod_engine_max_turns.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package cli

import (
"strings"

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

var engineMaxTurnsCodemodLog = logger.New("cli:codemod_engine_max_turns")

Expand All @@ -17,82 +13,17 @@ func getEngineMaxTurnsToTopLevelCodemod() Codemod {
Description: "Moves deprecated 'engine.max-turns' to top-level 'max-turns' so AWF enforces turn caps consistently across all agentic engines.",
IntroducedIn: "0.68.4",
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 _, hasMaxTurns := engineMap["max-turns"]; !hasMaxTurns {
return content, false, nil
}

_, hasTopLevelMaxTurns := frontmatter["max-turns"]

return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !isTopLevelKey(line) || !strings.HasPrefix(trimmed, "engine:") {
continue
}
inlineValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "engine:"))
if strings.HasPrefix(inlineValue, "{") && strings.Contains(inlineValue, "max-turns:") {
engineMaxTurnsCodemodLog.Print("Skipping engine.max-turns migration for inline-map engine syntax; migrate to top-level max-turns manually")
return lines, false
}
}

maxTurnsSuffix := ""
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-turns:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
maxTurnsSuffix = parts[1]
}
break
}
}

result, removed := removeFieldFromBlock(lines, "max-turns", "engine")
if !removed {
return lines, false
}

if hasTopLevelMaxTurns {
engineMaxTurnsCodemodLog.Print("Removed deprecated engine.max-turns (top-level max-turns already present)")
return result, true
}

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

maxTurnsLine := "max-turns:" + maxTurnsSuffix
withTopLevel := make([]string, 0, len(result)+1)
withTopLevel = append(withTopLevel, result[:insertAt]...)
withTopLevel = append(withTopLevel, maxTurnsLine)
withTopLevel = append(withTopLevel, result[insertAt:]...)

engineMaxTurnsCodemodLog.Print("Migrated engine.max-turns to top-level max-turns")
return withTopLevel, true
})
return migrateEngineFieldToTopLevel(
content,
frontmatter,
"max-turns",
"max-turns",
[]string{"max-turns"},
engineMaxTurnsCodemodLog,
"Skipping engine.max-turns migration for inline-map engine syntax; migrate to top-level max-turns manually",
"Removed deprecated engine.max-turns (top-level max-turns already present)",
"Migrated engine.max-turns to top-level max-turns",
)
},
}
}
109 changes: 109 additions & 0 deletions pkg/cli/codemod_engine_to_top_level_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cli

import (
"strings"

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

func migrateEngineFieldToTopLevel(

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] Nine positional parameters — three of which are string message literals at the end — makes the call site fragile: it's easy to swap skipInlineMessage, removedMessage, and migratedMessage without the compiler noticing.

💡 Suggestion: use a named-struct options type

Extract the three message strings into a migrationMessages struct so each call site reads as self-documenting named fields:

type migrationMessages struct {
	SkipInline string
	Removed    string
	Migrated   string
}

func migrateEngineFieldToTopLevel(
	content string,
	frontmatter map[string]any,
	engineField string,
	targetTopLevelField string,
	preserveTopLevelFields []string,
	log *logger.Logger,
	msgs migrationMessages,
) (string, bool, error)

This is especially valuable because both codemods share this helper — a silently swapped message string would produce incorrect log output while the migration logic itself still passes tests.

content string,
frontmatter map[string]any,
engineField string,
targetTopLevelField string,
preserveTopLevelFields []string,
log *logger.Logger,
skipInlineMessage string,
removedMessage string,
migratedMessage string,
) (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 _, hasEngineField := engineMap[engineField]; !hasEngineField {
return content, false, nil
}

hasPreservedTopLevelField := false
for _, field := range preserveTopLevelFields {
if _, exists := frontmatter[field]; exists {
hasPreservedTopLevelField = true
break
}
}

return applyFrontmatterLineTransform(content, func(lines []string) ([]string, bool) {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !isTopLevelKey(line) || !strings.HasPrefix(trimmed, "engine:") {
continue
}
inlineValue := strings.TrimSpace(strings.TrimPrefix(trimmed, "engine:"))
if strings.HasPrefix(inlineValue, "{") && strings.Contains(inlineValue, engineField+":") {
if log != nil {
log.Print(skipInlineMessage)
}
return lines, false
}
Comment thread
Copilot marked this conversation as resolved.
}

fieldSuffix := ""
inEngineBlock := false
engineIndent := ""
engineFieldPrefix := engineField + ":"
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, engineFieldPrefix) {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
fieldSuffix = parts[1]
}
break
}
}

result, removed := removeFieldFromBlock(lines, engineField, "engine")
if !removed {
return lines, false
}

if hasPreservedTopLevelField {
if log != nil {
log.Print(removedMessage)
}
return result, true
}
Comment thread
Copilot marked this conversation as resolved.

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

topLevelLine := targetTopLevelField + ":" + fieldSuffix
withTopLevel := make([]string, 0, len(result)+1)
withTopLevel = append(withTopLevel, result[:insertAt]...)
withTopLevel = append(withTopLevel, topLevelLine)
withTopLevel = append(withTopLevel, result[insertAt:]...)

if log != nil {
log.Print(migratedMessage)
}
return withTopLevel, true
})
}
Loading
Loading