From 0f374af691314fcc0f76fe219818fe0db8105cb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:46:40 +0000 Subject: [PATCH 1/2] Initial plan From daa0555cb77e63e505e3c7536859f8750fe5a73c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 05:13:21 +0000 Subject: [PATCH 2/2] feat: Phase 3 - extend schema and parser for inline and catalog-defined engine definitions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 82 +++++- pkg/workflow/compiler_orchestrator_engine.go | 9 + pkg/workflow/engine.go | 43 +++ pkg/workflow/engine_catalog_test.go | 85 +++--- pkg/workflow/engine_definition.go | 5 + pkg/workflow/engine_inline_test.go | 279 +++++++++++++++++++ pkg/workflow/engine_validation.go | 74 +++++ 7 files changed, 542 insertions(+), 35 deletions(-) create mode 100644 pkg/workflow/engine_inline_test.go diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 139fb79f757..a99851517ad 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7778,13 +7778,33 @@ "group": "gh-aw-claude", "cancel-in-progress": false } + }, + { + "runtime": { + "id": "codex", + "version": "0.105.0" + }, + "provider": { + "id": "openai", + "model": "gpt-5", + "auth": { + "secret": "OPENAI_API_KEY" + } + } + }, + { + "runtime": { + "id": "claude" + }, + "provider": { + "model": "claude-3-7-sonnet-20250219" + } } ], "oneOf": [ { "type": "string", - "enum": ["claude", "codex", "copilot", "gemini"], - "description": "Simple engine name: 'claude' (default, Claude Code), 'copilot' (GitHub Copilot CLI), 'codex' (OpenAI Codex CLI), or 'gemini' (Google Gemini CLI)" + "description": "Engine name: built-in ('claude', 'codex', 'copilot', 'gemini') or a named catalog entry" }, { "type": "object", @@ -7792,8 +7812,7 @@ "properties": { "id": { "type": "string", - "enum": ["claude", "codex", "copilot", "gemini"], - "description": "AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), 'copilot' (GitHub Copilot CLI), or 'gemini' (Google Gemini CLI)" + "description": "AI engine identifier: built-in ('claude', 'codex', 'copilot', 'gemini') or a named catalog entry" }, "version": { "type": ["string", "number"], @@ -7914,6 +7933,61 @@ }, "required": ["id"], "additionalProperties": false + }, + { + "type": "object", + "description": "Inline engine definition: specifies a runtime adapter and optional provider settings directly in the workflow frontmatter, without requiring a named catalog entry", + "properties": { + "runtime": { + "type": "object", + "description": "Runtime adapter reference for the inline engine definition", + "properties": { + "id": { + "type": "string", + "description": "Runtime adapter identifier (e.g. 'codex', 'claude', 'copilot', 'gemini')", + "examples": ["codex", "claude", "copilot", "gemini"] + }, + "version": { + "type": ["string", "number"], + "description": "Optional version of the runtime adapter (e.g. '0.105.0', 'beta')", + "examples": ["0.105.0", "beta", "latest"] + } + }, + "required": ["id"], + "additionalProperties": false + }, + "provider": { + "type": "object", + "description": "Optional provider configuration for the inline engine definition", + "properties": { + "id": { + "type": "string", + "description": "Provider identifier (e.g. 'openai', 'anthropic', 'github', 'google')", + "examples": ["openai", "anthropic", "github", "google"] + }, + "model": { + "type": "string", + "description": "Optional specific LLM model to use (e.g. 'gpt-5', 'claude-3-5-sonnet-20241022')", + "examples": ["gpt-5", "claude-3-5-sonnet-20241022", "gpt-4o"] + }, + "auth": { + "type": "object", + "description": "Authentication configuration for the provider", + "properties": { + "secret": { + "type": "string", + "description": "Name of the GitHub Actions secret that contains the API key for this provider", + "examples": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "CUSTOM_API_KEY"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": ["runtime"], + "additionalProperties": false } ] }, diff --git a/pkg/workflow/compiler_orchestrator_engine.go b/pkg/workflow/compiler_orchestrator_engine.go index d0bb4287462..b26e9aed8fc 100644 --- a/pkg/workflow/compiler_orchestrator_engine.go +++ b/pkg/workflow/compiler_orchestrator_engine.go @@ -36,6 +36,15 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean // Extract AI engine setting from frontmatter engineSetting, engineConfig := c.ExtractEngineConfig(result.Frontmatter) + // Validate and register inline engine definitions (engine.runtime sub-object). + // Must happen before catalog resolution so the inline definition is visible to Resolve(). + if engineConfig != nil && engineConfig.IsInlineDefinition { + if err := c.validateEngineInlineDefinition(engineConfig); err != nil { + return nil, err + } + c.registerInlineEngineDefinition(engineConfig) + } + // Extract network permissions from frontmatter networkPermissions := c.extractNetworkPermissions(result.Frontmatter) diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index e7ae617214b..fd8ae81ace2 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -26,6 +26,11 @@ type EngineConfig struct { Args []string Firewall *FirewallConfig // AWF firewall configuration Agent string // Agent identifier for copilot --agent flag (copilot engine only) + + // Inline definition fields (populated when engine.runtime is specified in frontmatter) + IsInlineDefinition bool // true when the engine is defined inline via engine.runtime + optional engine.provider + InlineProviderID string // engine.provider.id (e.g. "openai", "anthropic") + InlineProviderSecret string // engine.provider.auth.secret (name of the GitHub Actions secret for the provider API key) } // NetworkPermissions represents network access permissions for workflow execution @@ -87,6 +92,44 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng engineLog.Print("Found engine in object format, parsing configuration") config := &EngineConfig{} + // Detect inline definition: engine.runtime sub-object present instead of engine.id + if runtime, hasRuntime := engineObj["runtime"]; hasRuntime { + engineLog.Print("Found inline engine definition (engine.runtime sub-object)") + config.IsInlineDefinition = true + + if runtimeObj, ok := runtime.(map[string]any); ok { + if id, ok := runtimeObj["id"].(string); ok { + config.ID = id + engineLog.Printf("Inline engine runtime.id: %s", config.ID) + } + if version, hasVersion := runtimeObj["version"]; hasVersion { + config.Version = stringutil.ParseVersionValue(version) + } + } + + // Extract optional provider sub-object + if provider, hasProvider := engineObj["provider"]; hasProvider { + if providerObj, ok := provider.(map[string]any); ok { + if id, ok := providerObj["id"].(string); ok { + config.InlineProviderID = id + } + if model, ok := providerObj["model"].(string); ok { + config.Model = model + } + if auth, hasAuth := providerObj["auth"]; hasAuth { + if authObj, ok := auth.(map[string]any); ok { + if secret, ok := authObj["secret"].(string); ok { + config.InlineProviderSecret = secret + } + } + } + } + } + + engineLog.Printf("Extracted inline engine definition: runtimeID=%s, providerID=%s", config.ID, config.InlineProviderID) + return config.ID, config + } + // Extract required 'id' field if id, hasID := engineObj["id"]; hasID { if idStr, ok := id.(string); ok { diff --git a/pkg/workflow/engine_catalog_test.go b/pkg/workflow/engine_catalog_test.go index b2f6849f6a9..133a261ed55 100644 --- a/pkg/workflow/engine_catalog_test.go +++ b/pkg/workflow/engine_catalog_test.go @@ -61,9 +61,9 @@ func TestEngineCatalog_All(t *testing.T) { } } -// engineSchemaEnums parses the main workflow schema and extracts engine enum values -// from both the string variant and the object id property of engine_config. -func engineSchemaEnums(t *testing.T) []string { +// engineSchemaOneOfVariants parses the main workflow schema and returns the +// type identifiers of each variant in engine_config.oneOf for structural assertions. +func engineSchemaOneOfVariants(t *testing.T) []map[string]any { t.Helper() schemaBytes, err := os.ReadFile("../parser/schemas/main_workflow_schema.json") @@ -81,40 +81,63 @@ func engineSchemaEnums(t *testing.T) []string { oneOf, ok := engineConfig["oneOf"].([]any) require.True(t, ok, "engine_config should have oneOf") - // The first oneOf variant is the plain string enum - for _, variant := range oneOf { - v, ok := variant.(map[string]any) - if !ok { - continue - } - if v["type"] == "string" { - rawEnum, ok := v["enum"].([]any) - if !ok { - continue - } - enums := make([]string, 0, len(rawEnum)) - for _, e := range rawEnum { - if s, ok := e.(string); ok { - enums = append(enums, s) - } - } - sort.Strings(enums) - return enums + variants := make([]map[string]any, 0, len(oneOf)) + for _, v := range oneOf { + if m, ok := v.(map[string]any); ok { + variants = append(variants, m) } } - t.Fatal("could not find string enum in engine_config oneOf") - return nil + return variants } -// TestEngineCatalogMatchesSchema asserts that the schema engine enum values exactly -// match the catalog IDs. A failure here means the schema and catalog have drifted apart. -func TestEngineCatalogMatchesSchema(t *testing.T) { +// TestEngineCatalog_BuiltInsPresent verifies that the four built-in engines are always +// registered in the catalog with stable IDs. +func TestEngineCatalog_BuiltInsPresent(t *testing.T) { registry := NewEngineRegistry() catalog := NewEngineCatalog(registry) - catalogIDs := catalog.IDs() // already sorted - schemaEnums := engineSchemaEnums(t) + expected := []string{"claude", "codex", "copilot", "gemini"} + catalogIDs := catalog.IDs() + for _, id := range expected { + assert.Contains(t, catalogIDs, id, + "built-in engine %q must always be present in the catalog", id) + } +} - assert.Equal(t, catalogIDs, schemaEnums, - "schema engine enum must match catalog IDs exactly — run 'make build' after updating the schema") +// TestEngineCatalogMatchesSchema asserts that the engine_config schema has the expected +// structure: a plain-string variant (for built-ins and named catalog entries), an +// object-with-id variant, and an inline-definition variant (object-with-runtime). +// A failure here means the schema structure has changed unexpectedly. +func TestEngineCatalogMatchesSchema(t *testing.T) { + variants := engineSchemaOneOfVariants(t) + + require.Len(t, variants, 3, "engine_config oneOf should have exactly 3 variants: string, object-with-id, object-with-runtime") + + // Variant 0: plain string (no enum — allows built-ins and custom named catalog entries) + assert.Equal(t, "string", variants[0]["type"], + "first variant should be type string") + assert.Nil(t, variants[0]["enum"], + "string variant must NOT have an enum so that named catalog entries are allowed") + + // Variant 1: object with 'id' field for extended engine configuration + assert.Equal(t, "object", variants[1]["type"], + "second variant should be type object (extended config with id)") + props1, ok := variants[1]["properties"].(map[string]any) + require.True(t, ok, "second variant should have properties") + assert.Contains(t, props1, "id", + "second variant should have an 'id' property") + idProp, ok := props1["id"].(map[string]any) + require.True(t, ok, "id property should be a map") + assert.Nil(t, idProp["enum"], + "id property must NOT have an enum so that named catalog entries are allowed") + + // Variant 2: object with 'runtime' sub-object for inline definitions + assert.Equal(t, "object", variants[2]["type"], + "third variant should be type object (inline definition with runtime)") + props2, ok := variants[2]["properties"].(map[string]any) + require.True(t, ok, "third variant should have properties") + assert.Contains(t, props2, "runtime", + "third variant should have a 'runtime' property for inline engine definitions") + assert.Contains(t, props2, "provider", + "third variant should have a 'provider' property for inline engine definitions") } diff --git a/pkg/workflow/engine_definition.go b/pkg/workflow/engine_definition.go index bba72c58466..17975c6f035 100644 --- a/pkg/workflow/engine_definition.go +++ b/pkg/workflow/engine_definition.go @@ -137,6 +137,11 @@ func (c *EngineCatalog) Register(def *EngineDefinition) { c.definitions[def.ID] = def } +// Get returns the EngineDefinition for the given ID, or nil if not found. +func (c *EngineCatalog) Get(id string) *EngineDefinition { + return c.definitions[id] +} + // IDs returns a sorted list of all engine IDs in the catalog. func (c *EngineCatalog) IDs() []string { ids := make([]string, 0, len(c.definitions)) diff --git a/pkg/workflow/engine_inline_test.go b/pkg/workflow/engine_inline_test.go new file mode 100644 index 00000000000..102d08dee6f --- /dev/null +++ b/pkg/workflow/engine_inline_test.go @@ -0,0 +1,279 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/github/gh-aw/pkg/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestExtractEngineConfig_InlineDefinition verifies that an inline engine definition +// (engine.runtime + optional engine.provider) is correctly parsed from frontmatter +// into an EngineConfig with IsInlineDefinition=true. +func TestExtractEngineConfig_InlineDefinition(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedID string + expectedVersion string + expectedModel string + expectedProviderID string + expectedSecret string + expectInlineFlag bool + expectedEngineSetting string + }{ + { + name: "runtime only", + frontmatter: map[string]any{ + "engine": map[string]any{ + "runtime": map[string]any{ + "id": "codex", + }, + }, + }, + expectedID: "codex", + expectedEngineSetting: "codex", + expectInlineFlag: true, + }, + { + name: "runtime with version", + frontmatter: map[string]any{ + "engine": map[string]any{ + "runtime": map[string]any{ + "id": "codex", + "version": "0.105.0", + }, + }, + }, + expectedID: "codex", + expectedVersion: "0.105.0", + expectedEngineSetting: "codex", + expectInlineFlag: true, + }, + { + name: "runtime and provider full", + frontmatter: map[string]any{ + "engine": map[string]any{ + "runtime": map[string]any{ + "id": "codex", + }, + "provider": map[string]any{ + "id": "openai", + "model": "gpt-5", + "auth": map[string]any{ + "secret": "OPENAI_API_KEY", + }, + }, + }, + }, + expectedID: "codex", + expectedModel: "gpt-5", + expectedProviderID: "openai", + expectedSecret: "OPENAI_API_KEY", + expectedEngineSetting: "codex", + expectInlineFlag: true, + }, + { + name: "runtime with provider model only", + frontmatter: map[string]any{ + "engine": map[string]any{ + "runtime": map[string]any{ + "id": "claude", + }, + "provider": map[string]any{ + "model": "claude-3-7-sonnet-20250219", + }, + }, + }, + expectedID: "claude", + expectedModel: "claude-3-7-sonnet-20250219", + expectedEngineSetting: "claude", + expectInlineFlag: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewCompiler() + engineSetting, config := c.ExtractEngineConfig(tt.frontmatter) + + require.NotNil(t, config, "should return non-nil EngineConfig for inline definition") + assert.Equal(t, tt.expectedEngineSetting, engineSetting, "engineSetting should equal runtime.id") + assert.Equal(t, tt.expectedID, config.ID, "config.ID should equal runtime.id") + assert.Equal(t, tt.expectInlineFlag, config.IsInlineDefinition, "IsInlineDefinition flag should be set") + assert.Equal(t, tt.expectedVersion, config.Version, "Version should match runtime.version") + assert.Equal(t, tt.expectedModel, config.Model, "Model should match provider.model") + assert.Equal(t, tt.expectedProviderID, config.InlineProviderID, "InlineProviderID should match provider.id") + assert.Equal(t, tt.expectedSecret, config.InlineProviderSecret, "InlineProviderSecret should match provider.auth.secret") + }) + } +} + +// TestExtractEngineConfig_InlineDefinition_NotTriggeredByIDField verifies that the +// object variant with an 'id' field (not 'runtime') does NOT set IsInlineDefinition. +func TestExtractEngineConfig_InlineDefinition_NotTriggeredByIDField(t *testing.T) { + c := NewCompiler() + frontmatter := map[string]any{ + "engine": map[string]any{ + "id": "copilot", + }, + } + _, config := c.ExtractEngineConfig(frontmatter) + + require.NotNil(t, config, "should return non-nil EngineConfig") + assert.Equal(t, "copilot", config.ID, "ID should be set from 'id' field") + assert.False(t, config.IsInlineDefinition, "IsInlineDefinition should be false for object-with-id format") +} + +// TestExtractEngineConfig_LegacyStringFormat_Regression verifies that the legacy +// string format "engine: copilot" still parses correctly and does not set +// IsInlineDefinition. +func TestExtractEngineConfig_LegacyStringFormat_Regression(t *testing.T) { + c := NewCompiler() + + for _, engineID := range []string{"copilot", "claude", "codex", "gemini"} { + t.Run(engineID, func(t *testing.T) { + frontmatter := map[string]any{"engine": engineID} + engineSetting, config := c.ExtractEngineConfig(frontmatter) + + require.NotNil(t, config, "should return non-nil EngineConfig for string format") + assert.Equal(t, engineID, engineSetting, "engineSetting should equal the engine string") + assert.Equal(t, engineID, config.ID, "config.ID should equal the engine string") + assert.False(t, config.IsInlineDefinition, "IsInlineDefinition should be false for string format") + }) + } +} + +// TestValidateEngineInlineDefinition_MissingRuntimeID verifies that an inline +// definition with an empty runtime.id produces a clear validation error. +func TestValidateEngineInlineDefinition_MissingRuntimeID(t *testing.T) { + c := NewCompiler() + + config := &EngineConfig{ + IsInlineDefinition: true, + ID: "", // deliberately empty + } + + err := c.validateEngineInlineDefinition(config) + require.Error(t, err, "missing runtime.id should return an error") + assert.Contains(t, err.Error(), "runtime.id", "error should mention the missing field") + assert.Contains(t, err.Error(), string(constants.DocsEnginesURL), "error should include docs URL") +} + +// TestValidateEngineInlineDefinition_ValidRuntimeID verifies that a valid inline +// definition (with runtime.id present) passes validation. +func TestValidateEngineInlineDefinition_ValidRuntimeID(t *testing.T) { + c := NewCompiler() + + config := &EngineConfig{ + IsInlineDefinition: true, + ID: "codex", + } + + err := c.validateEngineInlineDefinition(config) + assert.NoError(t, err, "valid runtime.id should pass validation") +} + +// TestValidateEngineInlineDefinition_NonInlineIsNoop verifies that the validator is +// a no-op for configs that are not inline definitions. +func TestValidateEngineInlineDefinition_NonInlineIsNoop(t *testing.T) { + c := NewCompiler() + + config := &EngineConfig{ + IsInlineDefinition: false, + ID: "", + } + + err := c.validateEngineInlineDefinition(config) + assert.NoError(t, err, "non-inline config should not produce an error") +} + +// TestRegisterInlineEngineDefinition_PreservesBuiltInDisplayName verifies that +// registering an inline definition for a built-in engine ID preserves the existing +// display name and description from the catalog. +func TestRegisterInlineEngineDefinition_PreservesBuiltInDisplayName(t *testing.T) { + c := NewCompiler() + + // Retrieve the built-in display name before registration. + builtIn := c.engineCatalog.Get("codex") + require.NotNil(t, builtIn, "codex built-in should exist") + originalDisplayName := builtIn.DisplayName + + config := &EngineConfig{ + IsInlineDefinition: true, + ID: "codex", + InlineProviderID: "openai", + InlineProviderSecret: "MY_KEY", + } + c.registerInlineEngineDefinition(config) + + updated := c.engineCatalog.Get("codex") + require.NotNil(t, updated, "codex should still be in catalog after registration") + assert.Equal(t, originalDisplayName, updated.DisplayName, + "display name should be preserved from built-in definition") + assert.Equal(t, "openai", updated.Provider.Name, + "provider should be overridden by inline definition") + require.Len(t, updated.Auth, 1, + "auth binding should be set from inline definition") + assert.Equal(t, "MY_KEY", updated.Auth[0].Secret, + "auth secret should be set from inline provider auth") +} + +// TestInlineEngineDefinition_ResolvesViaCatalog verifies the full flow: inline +// definition parsed → registered in catalog → resolved to a runtime adapter. +func TestInlineEngineDefinition_ResolvesViaCatalog(t *testing.T) { + c := NewCompiler() + + frontmatter := map[string]any{ + "engine": map[string]any{ + "runtime": map[string]any{ + "id": "copilot", + "version": "beta", + }, + "provider": map[string]any{ + "id": "github", + }, + }, + } + + _, config := c.ExtractEngineConfig(frontmatter) + require.NotNil(t, config, "should extract EngineConfig from inline definition") + require.True(t, config.IsInlineDefinition, "should be flagged as inline definition") + + // Validate and register as setupEngineAndImports would do. + require.NoError(t, c.validateEngineInlineDefinition(config), "valid inline definition should pass") + c.registerInlineEngineDefinition(config) + + // Resolve via catalog. + resolved, err := c.engineCatalog.Resolve(config.ID, config) + require.NoError(t, err, "inline definition should resolve through catalog without error") + require.NotNil(t, resolved, "resolved target should not be nil") + assert.Equal(t, "copilot", resolved.Runtime.GetID(), + "inline definition should resolve to the copilot runtime adapter") + assert.Equal(t, "github", resolved.Definition.Provider.Name, + "provider override from inline definition should be applied to the resolved definition") +} + +// TestInlineEngineDefinition_UnknownRuntimeID verifies that an inline definition with +// an unknown runtime.id produces a helpful error listing known runtimes. +func TestInlineEngineDefinition_UnknownRuntimeID(t *testing.T) { + c := NewCompiler() + + config := &EngineConfig{ + IsInlineDefinition: true, + ID: "nonexistent-runtime", + } + + // validateEngineInlineDefinition should catch the unknown runtime ID with a clear error. + err := c.validateEngineInlineDefinition(config) + require.Error(t, err, "unknown runtime.id should produce a validation error") + assert.Contains(t, err.Error(), "nonexistent-runtime", + "error should mention the unknown runtime ID") + assert.Contains(t, err.Error(), "runtime.id", + "error should mention the 'runtime.id' field") + assert.Contains(t, err.Error(), string(constants.DocsEnginesURL), + "error should include the docs URL") +} diff --git a/pkg/workflow/engine_validation.go b/pkg/workflow/engine_validation.go index 005c13905dc..d3c6b44dcf6 100644 --- a/pkg/workflow/engine_validation.go +++ b/pkg/workflow/engine_validation.go @@ -44,6 +44,80 @@ import ( var engineValidationLog = newValidationLogger("engine") +// validateEngineInlineDefinition validates an inline engine definition parsed from +// engine.runtime + optional engine.provider in the workflow frontmatter. +// Returns an error if: +// - The required runtime.id field is missing +// - The runtime.id does not match a known runtime adapter +func (c *Compiler) validateEngineInlineDefinition(config *EngineConfig) error { + if !config.IsInlineDefinition { + return nil + } + + engineValidationLog.Printf("Validating inline engine definition: runtimeID=%s", config.ID) + + if config.ID == "" { + return fmt.Errorf("inline engine definition is missing required 'runtime.id' field.\n\nExample:\nengine:\n runtime:\n id: codex\n\nSee: %s", constants.DocsEnginesURL) + } + + // Validate that runtime.id maps to a known runtime adapter. + if !c.engineRegistry.IsValidEngine(config.ID) { + // Try prefix match for backward compatibility (e.g. "codex-experimental") + if matched, err := c.engineRegistry.GetEngineByPrefix(config.ID); err == nil { + engineValidationLog.Printf("Inline engine runtime.id %q matched via prefix to runtime %q", config.ID, matched.GetID()) + } else { + validEngines := c.engineRegistry.GetSupportedEngines() + suggestions := parser.FindClosestMatches(config.ID, validEngines, 1) + enginesStr := strings.Join(validEngines, ", ") + + errMsg := fmt.Sprintf("inline engine definition references unknown runtime.id: %s. Known runtime IDs are: %s.\n\nExample:\nengine:\n runtime:\n id: codex\n\nSee: %s", + config.ID, enginesStr, constants.DocsEnginesURL) + if len(suggestions) > 0 { + errMsg = fmt.Sprintf("inline engine definition references unknown runtime.id: %s. Known runtime IDs are: %s.\n\nDid you mean: %s?\n\nExample:\nengine:\n runtime:\n id: codex\n\nSee: %s", + config.ID, enginesStr, suggestions[0], constants.DocsEnginesURL) + } + return fmt.Errorf("%s", errMsg) + } + } + + return nil +} + +// registerInlineEngineDefinition registers an inline engine definition in the session +// catalog. If the runtime ID already exists in the catalog (e.g. a built-in), the +// existing display name and description are preserved while provider overrides are applied. +func (c *Compiler) registerInlineEngineDefinition(config *EngineConfig) { + def := &EngineDefinition{ + ID: config.ID, + RuntimeID: config.ID, + DisplayName: config.ID, + Description: "Inline engine definition from workflow frontmatter", + } + + // Preserve display name and description from existing built-in entry if available. + if existing := c.engineCatalog.Get(config.ID); existing != nil { + def.DisplayName = existing.DisplayName + def.Description = existing.Description + def.Models = existing.Models + // Copy existing provider/auth as defaults; inline values below fully replace them + // when present (replacement, not merge). + def.Provider = existing.Provider + def.Auth = existing.Auth + } + + // Apply inline provider overrides. + if config.InlineProviderID != "" { + def.Provider = ProviderSelection{Name: config.InlineProviderID} + } + if config.InlineProviderSecret != "" { + def.Auth = []AuthBinding{{Role: "api-key", Secret: config.InlineProviderSecret}} + } + + engineValidationLog.Printf("Registering inline engine definition in session catalog: id=%s, runtimeID=%s, providerID=%s", + def.ID, def.RuntimeID, def.Provider.Name) + c.engineCatalog.Register(def) +} + // validateEngine validates that the given engine ID is supported func (c *Compiler) validateEngine(engineID string) error { if engineID == "" {