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
18 changes: 8 additions & 10 deletions pkg/workflow/compiler_orchestrator_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,19 +199,17 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean
}
}

// Validate the engine setting
orchestratorEngineLog.Printf("Validating engine setting: %s", engineSetting)
if err := c.validateEngine(engineSetting); err != nil {
orchestratorEngineLog.Printf("Engine validation failed: %v", err)
return nil, err
}

// Get the agentic engine instance
agenticEngine, err := c.getAgenticEngine(engineSetting)
// Validate the engine setting and resolve the runtime adapter via the catalog.
// This performs exact catalog lookup, prefix fallback, and returns a formatted
// validation error for unknown engines — replacing the separate validateEngine
// and getAgenticEngine calls.
orchestratorEngineLog.Printf("Resolving engine setting: %s", engineSetting)
resolvedEngine, err := c.engineCatalog.Resolve(engineSetting, engineConfig)
if err != nil {
orchestratorEngineLog.Printf("Failed to get agentic engine: %v", err)
orchestratorEngineLog.Printf("Engine resolution failed: %v", err)
return nil, err
}
agenticEngine := resolvedEngine.Runtime

log.Printf("AI engine: %s (%s)", agenticEngine.GetDisplayName(), engineSetting)
Comment on lines +206 to 214
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After Resolve(), downstream logic continues to use engineSetting (the user-provided ID) for engine-specific behavior. With prefix fallback (e.g. "codex-experimental"), engineSetting won’t equal the canonical runtime ID ("codex"), and functions like enableFirewallByDefaultForCopilot/firewall.go:100-104 won’t run as intended. Consider capturing a canonicalEngineID := resolvedEngine.Runtime.GetID() (or resolvedEngine.Definition.RuntimeID) and using that for subsequent engine-ID comparisons/feature gates, while keeping engineSetting for display/logging.

Copilot uses AI. Check for mistakes.
if agenticEngine.IsExperimental() && c.verbose {
Expand Down
2 changes: 2 additions & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ type Compiler struct {
actionTag string // Override action SHA or tag for actions/setup (when set, overrides actionMode to release)
jobManager *JobManager // Manages jobs and dependencies
engineRegistry *EngineRegistry // Registry of available agentic engines
engineCatalog *EngineCatalog // Catalog of engine definitions backed by the registry
fileTracker FileTracker // Optional file tracker for tracking created files
warningCount int // Number of warnings encountered during compilation
stepOrderTracker *StepOrderTracker // Tracks step ordering for validation
Expand Down Expand Up @@ -136,6 +137,7 @@ func NewCompiler(opts ...CompilerOption) *Compiler {
actionMode: DetectActionMode(version), // Auto-detect action mode based on version
jobManager: NewJobManager(),
engineRegistry: GetGlobalEngineRegistry(),
engineCatalog: NewEngineCatalog(GetGlobalEngineRegistry()),
stepOrderTracker: NewStepOrderTracker(),
Comment on lines 138 to 141
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewCompiler initializes engineRegistry and engineCatalog by calling GetGlobalEngineRegistry() twice. It’s a singleton today, but wiring catalog off c.engineRegistry avoids accidental divergence if initialization changes (or if a future option swaps engineRegistry). Suggest: set engineCatalog based on the already-assigned engineRegistry (or initialize it after options are applied).

Copilot uses AI. Check for mistakes.
artifactManager: NewArtifactManager(),
actionPinWarnings: make(map[string]bool), // Initialize warning cache
Expand Down
185 changes: 185 additions & 0 deletions pkg/workflow/engine_definition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// This file defines the engine definition layer: declarative metadata types for AI engines,
// a catalog of registered definitions, and a resolved target that combines definition,
// config, and runtime adapter.
//
// # Architecture
//
// The engine definition layer sits on top of the existing EngineRegistry runtime layer:
//
// EngineDefinition – declarative metadata for a single engine entry
// EngineCatalog – registry of EngineDefinition entries with a Resolve() method
// ResolvedEngineTarget – result of resolving an engine ID: definition + config + runtime
//
// The existing EngineRegistry and CodingAgentEngine interfaces are unchanged; the catalog
// is an additional layer that maps logical engine IDs to runtime adapters.
//
// # Built-in Engines
//
// NewEngineCatalog registers the four built-in engines: claude, codex, copilot, gemini.
// Each EngineDefinition carries the engine's RuntimeID which maps to the corresponding
// CodingAgentEngine registered in the EngineRegistry.
//
// # Resolve()
//
// EngineCatalog.Resolve() performs:
// 1. Exact catalog ID lookup
// 2. Runtime-ID prefix fallback (for backward compat, e.g. "codex-experimental")
// 3. Formatted validation error when engine is unknown
package workflow

import (
"fmt"
"strings"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/parser"
)

// ProviderSelection identifies the AI provider for an engine (e.g. "anthropic", "openai").
type ProviderSelection struct {
Name string
}

// ModelSelection specifies the default and supported models for an engine.
type ModelSelection struct {
Default string
Supported []string
}

// AuthBinding maps a logical authentication role to a secret name.
type AuthBinding struct {
Role string
Secret string
}

// EngineDefinition holds the declarative metadata for an AI engine.
// It is separate from the runtime adapter (CodingAgentEngine) to allow the catalog
// layer to carry identity and provider information without coupling to implementation.
type EngineDefinition struct {
ID string
DisplayName string
Description string
RuntimeID string // maps to the CodingAgentEngine registered in EngineRegistry
Provider ProviderSelection
Models ModelSelection
Auth []AuthBinding
Options map[string]any
}

// EngineCatalog is a collection of EngineDefinition entries backed by an EngineRegistry
// for runtime adapter resolution.
type EngineCatalog struct {
definitions map[string]*EngineDefinition
registry *EngineRegistry
}

// ResolvedEngineTarget is the result of resolving an engine ID through the catalog.
// It combines the EngineDefinition, the caller-supplied EngineConfig, and the resolved
// CodingAgentEngine runtime adapter.
type ResolvedEngineTarget struct {
Definition *EngineDefinition
Config *EngineConfig // resolved merged config supplied by the caller
Runtime CodingAgentEngine // resolved adapter from the EngineRegistry
}

// NewEngineCatalog creates an EngineCatalog that wraps the given EngineRegistry and
// pre-registers the four built-in engine definitions (claude, codex, copilot, gemini).
func NewEngineCatalog(registry *EngineRegistry) *EngineCatalog {
catalog := &EngineCatalog{
definitions: make(map[string]*EngineDefinition),
registry: registry,
}

catalog.Register(&EngineDefinition{
ID: "claude",
DisplayName: "Claude Code",
Description: "Uses Claude Code with full MCP tool support and allow-listing",
RuntimeID: "claude",
Provider: ProviderSelection{Name: "anthropic"},
Auth: []AuthBinding{
{Role: "api-key", Secret: "ANTHROPIC_API_KEY"},
},
})

catalog.Register(&EngineDefinition{
ID: "codex",
DisplayName: "Codex",
Description: "Uses OpenAI Codex CLI with MCP server support",
RuntimeID: "codex",
Provider: ProviderSelection{Name: "openai"},
Auth: []AuthBinding{
{Role: "api-key", Secret: "CODEX_API_KEY"},
},
})

catalog.Register(&EngineDefinition{
ID: "copilot",
DisplayName: "GitHub Copilot CLI",
Description: "Uses GitHub Copilot CLI with MCP server support",
RuntimeID: "copilot",
Provider: ProviderSelection{Name: "github"},
})

catalog.Register(&EngineDefinition{
ID: "gemini",
DisplayName: "Google Gemini CLI",
Description: "Google Gemini CLI with headless mode and LLM gateway support",
RuntimeID: "gemini",
Provider: ProviderSelection{Name: "google"},
})

return catalog
}

// Register adds or replaces an EngineDefinition in the catalog.
func (c *EngineCatalog) Register(def *EngineDefinition) {
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EngineCatalog.Register will panic if called with a nil definition (def.ID dereference) and also silently accepts empty IDs. Since this is a new exported API, consider validating inputs (e.g., return an error, or at least guard and panic with a clear message) to avoid hard-to-debug nil pointer crashes or unusable catalog entries.

Suggested change
func (c *EngineCatalog) Register(def *EngineDefinition) {
func (c *EngineCatalog) Register(def *EngineDefinition) {
if def == nil {
panic("workflow: cannot register nil EngineDefinition")
}
if strings.TrimSpace(def.ID) == "" {
panic("workflow: cannot register EngineDefinition with empty ID")
}

Copilot uses AI. Check for mistakes.
c.definitions[def.ID] = def
}

// Resolve returns a ResolvedEngineTarget for the given engine ID and config.
// Resolution order:
// 1. Exact match in the catalog by ID
// 2. Prefix match in the underlying EngineRegistry (backward compat, e.g. "codex-experimental")
// 3. Returns a formatted validation error when no match is found
func (c *EngineCatalog) Resolve(id string, config *EngineConfig) (*ResolvedEngineTarget, error) {
// Exact catalog lookup
if def, ok := c.definitions[id]; ok {
runtime, err := c.registry.GetEngine(def.RuntimeID)
if err != nil {
return nil, fmt.Errorf("engine %q definition references unknown runtime %q: %w", id, def.RuntimeID, err)
}
return &ResolvedEngineTarget{Definition: def, Config: config, Runtime: runtime}, nil
}

// Fall back to runtime-ID prefix lookup for backward compat (e.g. "codex-experimental")
runtime, err := c.registry.GetEngineByPrefix(id)
if err == nil {
def := &EngineDefinition{
ID: id,
DisplayName: runtime.GetDisplayName(),
Description: runtime.GetDescription(),
RuntimeID: runtime.GetID(),
}
return &ResolvedEngineTarget{Definition: def, Config: config, Runtime: runtime}, nil
}
Comment on lines +154 to +164
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefix fallback delegates to EngineRegistry.GetEngineByPrefix(), which iterates a map and returns the first matching engine. This makes resolution nondeterministic for ambiguous inputs (e.g. "c" matches claude/codex/copilot depending on map iteration order) and can accidentally accept unintended prefixes. Consider making fallback deterministic and stricter (e.g. only allow IDs of the form "-..." and/or prefer the longest matching runtime ID after sorting).

Suggested change
// Fall back to runtime-ID prefix lookup for backward compat (e.g. "codex-experimental")
runtime, err := c.registry.GetEngineByPrefix(id)
if err == nil {
def := &EngineDefinition{
ID: id,
DisplayName: runtime.GetDisplayName(),
Description: runtime.GetDescription(),
RuntimeID: runtime.GetID(),
}
return &ResolvedEngineTarget{Definition: def, Config: config, Runtime: runtime}, nil
}
// Fall back to deterministic runtime-ID prefix lookup for backward compat (e.g. "codex-experimental").
// We only accept IDs of the form "<known-runtime-id>-..." and prefer the longest matching runtime ID.
supported := c.registry.GetSupportedEngines()
longestMatch := ""
for _, runtimeID := range supported {
if len(id) <= len(runtimeID) {
continue
}
if strings.HasPrefix(id, runtimeID) && id[len(runtimeID)] == '-' {
if len(runtimeID) > len(longestMatch) {
longestMatch = runtimeID
}
}
}
if longestMatch != "" {
runtime, err := c.registry.GetEngine(longestMatch)
if err == nil {
def := &EngineDefinition{
ID: id,
DisplayName: runtime.GetDisplayName(),
Description: runtime.GetDescription(),
RuntimeID: runtime.GetID(),
}
return &ResolvedEngineTarget{Definition: def, Config: config, Runtime: runtime}, nil
}
}

Copilot uses AI. Check for mistakes.

// Engine not found — produce a helpful validation error matching the existing format
validEngines := c.registry.GetSupportedEngines()
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolve() builds the “Valid engines are …” list from EngineRegistry.GetSupportedEngines() (runtime IDs). That omits catalog IDs for custom EngineDefinition entries (e.g. a user registers ID "my-custom-engine" mapping to runtime "copilot"), so the error message and closest-match suggestions will be misleading/incomplete. Build the valid engine list from catalog definition IDs (and optionally include runtime IDs used for legacy prefix fallback) so errors reflect what Resolve() actually accepts.

Suggested change
validEngines := c.registry.GetSupportedEngines()
// Build the list of valid engines from catalog definition IDs (what Resolve() accepts)
// and optionally include runtime IDs used for legacy prefix fallback.
validSet := make(map[string]struct{})
// Include all catalog definition IDs.
for defID := range c.definitions {
validSet[defID] = struct{}{}
}
// Include runtime IDs from the registry to reflect legacy/prefix-based resolution.
for _, runtimeID := range c.registry.GetSupportedEngines() {
validSet[runtimeID] = struct{}{}
}
// Flatten the set into a slice for suggestions and display.
validEngines := make([]string, 0, len(validSet))
for engineID := range validSet {
validEngines = append(validEngines, engineID)
}

Copilot uses AI. Check for mistakes.
suggestions := parser.FindClosestMatches(id, validEngines, 1)
enginesStr := strings.Join(validEngines, ", ")

errMsg := fmt.Sprintf("invalid engine: %s. Valid engines are: %s.\n\nExample:\nengine: copilot\n\nSee: %s",
id,
enginesStr,
constants.DocsEnginesURL)

if len(suggestions) > 0 {
errMsg = fmt.Sprintf("invalid engine: %s. Valid engines are: %s.\n\nDid you mean: %s?\n\nExample:\nengine: copilot\n\nSee: %s",
id,
enginesStr,
suggestions[0],
constants.DocsEnginesURL)
}

return nil, fmt.Errorf("%s", errMsg)
}
130 changes: 130 additions & 0 deletions pkg/workflow/engine_definition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//go:build !integration

package workflow

import (
"testing"

"github.com/github/gh-aw/pkg/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestNewEngineCatalog_BuiltIns checks that all four built-in engines are registered
// and resolve to the expected runtime adapters.
func TestNewEngineCatalog_BuiltIns(t *testing.T) {
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)

tests := []struct {
engineID string
displayName string
provider string
}{
{"claude", "Claude Code", "anthropic"},
{"codex", "Codex", "openai"},
{"copilot", "GitHub Copilot CLI", "github"},
{"gemini", "Google Gemini CLI", "google"},
}

for _, tt := range tests {
t.Run(tt.engineID, func(t *testing.T) {
resolved, err := catalog.Resolve(tt.engineID, &EngineConfig{ID: tt.engineID})
require.NoError(t, err, "expected %s to resolve without error", tt.engineID)
require.NotNil(t, resolved, "expected non-nil ResolvedEngineTarget for %s", tt.engineID)

assert.Equal(t, tt.engineID, resolved.Definition.ID, "Definition.ID should match")
assert.Equal(t, tt.displayName, resolved.Definition.DisplayName, "Definition.DisplayName should match")
assert.Equal(t, tt.provider, resolved.Definition.Provider.Name, "Definition.Provider.Name should match")
assert.Equal(t, tt.engineID, resolved.Runtime.GetID(), "Runtime.GetID() should match engine ID")
})
}
}

// TestEngineCatalog_Resolve_LegacyStringFormat verifies that resolving via plain string
// ("engine: claude") and object ID format ("engine.id: claude") produce the same runtime.
func TestEngineCatalog_Resolve_LegacyStringFormat(t *testing.T) {
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)

// Simulate "engine: claude" — EngineConfig built from string
stringConfig := &EngineConfig{ID: "claude"}
resolvedString, err := catalog.Resolve("claude", stringConfig)
require.NoError(t, err, "string-format engine should resolve without error")

// Simulate "engine:\n id: claude" — same logical ID
objectConfig := &EngineConfig{ID: "claude"}
resolvedObject, err := catalog.Resolve("claude", objectConfig)
require.NoError(t, err, "object-format engine should resolve without error")

assert.Equal(t, resolvedString.Runtime.GetID(), resolvedObject.Runtime.GetID(),
"both formats should resolve to the same runtime adapter")
assert.Equal(t, resolvedString.Definition.ID, resolvedObject.Definition.ID,
"both formats should resolve to the same definition")
}

// TestEngineCatalog_Resolve_PrefixFallback verifies backward-compat prefix matching
// (e.g. "codex-experimental" should resolve to the codex runtime).
func TestEngineCatalog_Resolve_PrefixFallback(t *testing.T) {
Comment on lines +44 to +68
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestEngineCatalog_Resolve_LegacyStringFormat doesn’t actually exercise different frontmatter formats—both branches call Resolve("claude", &EngineConfig{ID:"claude"}). Since EngineCatalog.Resolve only uses the string id argument (not how EngineConfig was constructed), this test will pass even if legacy/object parsing breaks elsewhere. Consider either removing this test or rewriting it to cover the real contract you want (e.g., parsing/extracting engine settings from frontmatter via ExtractEngineConfig, then calling Resolve with those outputs).

Suggested change
// TestEngineCatalog_Resolve_LegacyStringFormat verifies that resolving via plain string
// ("engine: claude") and object ID format ("engine.id: claude") produce the same runtime.
func TestEngineCatalog_Resolve_LegacyStringFormat(t *testing.T) {
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)
// Simulate "engine: claude" — EngineConfig built from string
stringConfig := &EngineConfig{ID: "claude"}
resolvedString, err := catalog.Resolve("claude", stringConfig)
require.NoError(t, err, "string-format engine should resolve without error")
// Simulate "engine:\n id: claude" — same logical ID
objectConfig := &EngineConfig{ID: "claude"}
resolvedObject, err := catalog.Resolve("claude", objectConfig)
require.NoError(t, err, "object-format engine should resolve without error")
assert.Equal(t, resolvedString.Runtime.GetID(), resolvedObject.Runtime.GetID(),
"both formats should resolve to the same runtime adapter")
assert.Equal(t, resolvedString.Definition.ID, resolvedObject.Definition.ID,
"both formats should resolve to the same definition")
}
// TestEngineCatalog_Resolve_PrefixFallback verifies backward-compat prefix matching
// (e.g. "codex-experimental" should resolve to the codex runtime).
func TestEngineCatalog_Resolve_PrefixFallback(t *testing.T) {
// TestEngineCatalog_Resolve_PrefixFallback verifies backward-compat prefix matching
// (e.g. "codex-experimental" should resolve to the codex runtime).
func TestEngineCatalog_Resolve_PrefixFallback(t *testing.T) {
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)
resolved, err := catalog.Resolve("codex-experimental", &EngineConfig{ID: "codex-experimental"})
require.NoError(t, err, "prefix-matched engine should resolve without error")
require.NotNil(t, resolved, "expected non-nil ResolvedEngineTarget for prefix match")
assert.Equal(t, "codex", resolved.Runtime.GetID(), "prefix match should resolve to codex runtime")
}
// TestEngineCatalog_Resolve_UnknownEngine verifies that unknown engine IDs return a
[...]

Copilot uses AI. Check for mistakes.
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)

resolved, err := catalog.Resolve("codex-experimental", &EngineConfig{ID: "codex-experimental"})
require.NoError(t, err, "prefix-matched engine should resolve without error")
require.NotNil(t, resolved, "expected non-nil ResolvedEngineTarget for prefix match")

assert.Equal(t, "codex", resolved.Runtime.GetID(), "prefix match should resolve to codex runtime")
}

// TestEngineCatalog_Resolve_UnknownEngine verifies that unknown engine IDs return a
// descriptive validation error containing the engine ID, a list of valid engines, and
// the documentation URL.
func TestEngineCatalog_Resolve_UnknownEngine(t *testing.T) {
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)

_, err := catalog.Resolve("nonexistent-engine", &EngineConfig{ID: "nonexistent-engine"})
require.Error(t, err, "unknown engine should return an error")
assert.Contains(t, err.Error(), "invalid engine",
"error should mention 'invalid engine', got: %s", err.Error())
assert.Contains(t, err.Error(), "nonexistent-engine",
"error should mention the unknown engine ID, got: %s", err.Error())
assert.Contains(t, err.Error(), string(constants.DocsEnginesURL),
"error should include the engines documentation URL, got: %s", err.Error())
assert.Contains(t, err.Error(), "engine: copilot",
"error should include an example, got: %s", err.Error())
}

// TestEngineCatalog_Resolve_ConfigPassthrough verifies that the EngineConfig passed to
// Resolve is surfaced unchanged in the ResolvedEngineTarget.
func TestEngineCatalog_Resolve_ConfigPassthrough(t *testing.T) {
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)

cfg := &EngineConfig{ID: "copilot", Model: "gpt-4o", MaxTurns: "10"}
resolved, err := catalog.Resolve("copilot", cfg)
require.NoError(t, err, "copilot with config should resolve without error")
assert.Equal(t, cfg, resolved.Config, "resolved Config should be the same pointer passed in")
}

// TestEngineCatalog_Register_Custom verifies that a custom engine definition can be
// registered and resolved via the catalog.
func TestEngineCatalog_Register_Custom(t *testing.T) {
registry := NewEngineRegistry()
// Register a test engine in the registry so the catalog can look it up
registry.Register(NewCopilotEngine()) // reuse copilot as the backing runtime

catalog := NewEngineCatalog(registry)
catalog.Register(&EngineDefinition{
ID: "my-custom-engine",
DisplayName: "My Custom Engine",
Description: "A custom engine for testing",
RuntimeID: "copilot", // backed by copilot runtime
Provider: ProviderSelection{Name: "custom"},
})

resolved, err := catalog.Resolve("my-custom-engine", &EngineConfig{ID: "my-custom-engine"})
require.NoError(t, err, "custom engine should resolve without error")
assert.Equal(t, "my-custom-engine", resolved.Definition.ID, "custom engine definition ID should match")
assert.Equal(t, "copilot", resolved.Runtime.GetID(), "custom engine should use copilot runtime")
}
Loading