Skip to content

refactor: extract shared CLI engine execution logic; make Pi backend resolution configurable#41921

Merged
pelikhan merged 2 commits into
mainfrom
copilot/refactor-pi-agentic-engine
Jun 27, 2026
Merged

refactor: extract shared CLI engine execution logic; make Pi backend resolution configurable#41921
pelikhan merged 2 commits into
mainfrom
copilot/refactor-pi-agentic-engine

Conversation

Copilot AI commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Crush and OpenCode had ~90 lines of nearly-identical GetExecutionSteps logic. Pi's github-copilot provider alias was inlined with a manual strings.EqualFold check, not reusable.

Changes

universal_llm_consumer_engine.go

  • resolveBackendWithAliases(model, extraAliases) — extends the standard copilot/anthropic/openai/codex lookup with caller-supplied provider name aliases, checked first. Makes the Pi github-copilot alias pattern available to any future engine without special-casing.
  • UniversalCLIEngineExecutionConfig — config struct for the shared run-subcommand execution pattern: engine constant, command name, extra CLI flags, permissions config file, step name, model env var, timestamp behavior.
  • BuildCLIEngineExecutionSteps() — method on UniversalLLMConsumerEngine implementing the common execution step: config-writing step → firewall-aware AWF command → standard env injection (prompt, workspace, MCP config, safe-output, trace context, max-turns, model env) → step formatting.

crush_engine.go / opencode_engine.go

GetExecutionSteps in each engine collapses from ~90 lines to a single BuildCLIEngineExecutionSteps call:

return e.BuildCLIEngineExecutionSteps(workflowData, logFile, UniversalCLIEngineExecutionConfig{
    EngineConstant:     constants.CrushEngine,
    DefaultCommandName: "crush",
    ExtraCLIArgs:       []string{"--verbose"},
    MCPConfigFile:      ".crush.json",
    StepName:           "Execute Crush CLI",
    ConfigStep:         e.generateCrushConfigStep(workflowData),
    ModelEnvVarName:    constants.CrushCLIModelEnvVar,
    WriteTimestamp:     true,
})

pi_engine.go

resolvePiBackend now calls resolveBackendWithAliases with {"github-copilot": UniversalLLMBackendCopilot} instead of the inline EqualFold check.

…backend aliases

- Add resolveBackendWithAliases() to universal_llm_consumer_engine.go, allowing
  engines to declare custom provider name aliases before falling back to the
  standard copilot/anthropic/openai/codex resolution.

- Add UniversalCLIEngineExecutionConfig struct and BuildCLIEngineExecutionSteps()
  method on UniversalLLMConsumerEngine to capture the common execution step
  pattern shared by Crush and OpenCode: config-file step, 'run' subcommand,
  firewall-aware domain resolution, standard AWF env vars, and step formatting.

- Simplify CrushEngine.GetExecutionSteps and OpenCodeEngine.GetExecutionSteps to
  delegate to BuildCLIEngineExecutionSteps, removing ~130 lines of duplicated code
  between the two engines.

- Update resolvePiBackend in pi_engine.go to use resolveBackendWithAliases with
  the 'github-copilot' -> copilot alias, making the alias handling explicit and
  reusable by other engines that may expose custom provider names.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
@pelikhan pelikhan marked this pull request as ready for review June 27, 2026 19:05
Copilot AI review requested due to automatic review settings June 27, 2026 19:05
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

No test files were added or modified in this PR. Test Quality Sentinel skipped. PR #41921 changes only production Go files: pkg/workflow/crush_engine.go, opencode_engine.go, pi_engine.go, universal_llm_consumer_engine.go.

@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

Copilot AI left a comment

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.

Pull request overview

This PR reduces duplication across the “universal LLM consumer” CLI engines by extracting the shared GitHub Actions execution-step construction into UniversalLLMConsumerEngine, and it makes Pi’s backend resolution extensible by introducing an alias-aware backend resolver.

Changes:

  • Added resolveBackendWithAliases() to support provider-prefix aliases (e.g. Pi’s github-copilot/...) while reusing the standard universal backend resolution.
  • Introduced UniversalCLIEngineExecutionConfig + BuildCLIEngineExecutionSteps() to centralize the shared “run subcommand + AWF wrapping + env injection” step generation.
  • Updated Crush and OpenCode engines to use the shared execution-step builder; updated Pi to use alias-based backend resolution.
Show a summary per file
File Description
pkg/workflow/universal_llm_consumer_engine.go Adds alias-aware backend resolution and the shared CLI execution step builder/config used by multiple engines.
pkg/workflow/pi_engine.go Switches Pi’s backend resolution to use the new alias-aware resolver (for github-copilot/...).
pkg/workflow/opencode_engine.go Refactors OpenCode execution step generation to delegate to BuildCLIEngineExecutionSteps().
pkg/workflow/crush_engine.go Refactors Crush execution step generation to delegate to BuildCLIEngineExecutionSteps().

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Low

@github-actions github-actions Bot mentioned this pull request Jun 27, 2026
…extraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (208 new lines in pkg/workflow/) but does not have a linked Architecture Decision Record (ADR).

Draft ADR committed: docs/adr/41921-extract-shared-cli-engine-execution-logic.md — review and complete it before merging.

This PR cannot merge until an ADR is linked in the PR body.

What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI could not infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:
    ADR: ADR-41921: Extract Shared CLI Engine Execution Logic

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

Why ADRs Matter

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 41921-extract-shared-cli-engine-execution-logic.md for PR #41921).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 53.4 AIC · ⌖ 11.8 AIC · ⊞ 8.4K ·

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /tdd, /zoom-out, and /improve-codebase-architecture — commenting with improvement suggestions; no blocking issues.

📋 Key Themes & Highlights

Key Themes

  • Missing unit tests for the new helpers: resolveBackendWithAliases is a new reusable function with edge-case logic (case-insensitive matching, TrimSpace, slash-requirement) but has no direct tests. The BuildCLIEngineExecutionSteps path is well-covered indirectly by the existing GetExecutionSteps tests in crush_engine_test.go and opencode_engine_test.go.
  • Implicit API contracts on resolveBackendWithAliases: Alias keys must be pre-lowercased and alias matching only applies to "provider/model" strings. Neither constraint is documented or enforced, creating a subtle trap for future callers.
  • Silent skip of ConfigStep: The empty-check guard makes a missing config step look intentional; a brief comment or rename would clarify intent for the next engine author.

Positive Highlights

  • ✅ Excellent PR description — the call-site diff is shown in the body, making the extraction easy to verify
  • ✅ Clean struct design: UniversalCLIEngineExecutionConfig is well-doc-commented field by field
  • ✅ Correct behavioral equivalence across Crush, OpenCode, and Pi — verified field by field against the original logic
  • resolveBackendWithAliases correctly integrates with pi_engine.go's existing pre-slash guard at line 76
  • cliArgs := append([]string{}, cfg.ExtraCLIArgs...) is a good defensive copy

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 82.1 AIC · ⌖ 10.1 AIC · ⊞ 6.6K

// lowercased provider names (the prefix before the first "/") to the corresponding
// UniversalLLMBackend. This lets engines that expose their own provider naming (e.g. Pi's
// "github-copilot") reuse the shared backend resolution logic without special-casing.
func resolveBackendWithAliases(model string, extraAliases map[string]UniversalLLMBackend) (UniversalLLMBackend, error) {

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.

[/tdd] resolveBackendWithAliases has no unit tests — add coverage for at least: (a) exact alias match, (b) case-insensitive match ("GITHUB-COPILOT/model"UniversalLLMBackendCopilot), (c) unknown provider falls through to resolveUniversalLLMBackendFromModel, and (d) empty extraAliases bypasses alias check.

💡 Suggested test skeleton
func TestResolveBackendWithAliases(t *testing.T) {
    aliases := map[string]UniversalLLMBackend{
        "github-copilot": UniversalLLMBackendCopilot,
    }
    tests := []struct {
        model   string
        want    UniversalLLMBackend
        wantErr bool
    }{
        {"github-copilot/gpt-4", UniversalLLMBackendCopilot, false},
        {"GITHUB-COPILOT/gpt-4", UniversalLLMBackendCopilot, false},
        {"copilot/gpt-4", UniversalLLMBackendCopilot, false},
        {"unknown/model", "", true},
    }
    for _, tc := range tests {
        got, err := resolveBackendWithAliases(tc.model, aliases)
        if tc.wantErr != (err != nil) {
            t.Errorf("model=%q: wantErr=%v got err=%v", tc.model, tc.wantErr, err)
        }
        if !tc.wantErr && got != tc.want {
            t.Errorf("model=%q: want %q got %q", tc.model, tc.want, got)
        }
    }
}

@copilot please address this.

if len(extraAliases) > 0 {
model = strings.TrimSpace(model)
parts := strings.SplitN(model, "/", 2)
if len(parts) == 2 {

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.

[/zoom-out] The len(parts) == 2 guard means aliases only fire when the model string contains a /. Bare provider names like "github-copilot" (no slash) silently skip alias matching and fall through to resolveUniversalLLMBackendFromModel, which returns an error.

This is safe today because pi_engine.go has a pre-check (if !strings.Contains(model, "/")) before calling this function. But the function's docstring doesn't document this constraint, making it a hidden trap for future engines that call resolveBackendWithAliases directly.

💡 Suggestion

Add a sentence to the docstring:

// Alias matching only applies to "provider/model" strings (those containing a
// slash). Bare provider names without a slash are passed directly to
// resolveUniversalLLMBackendFromModel.

@copilot please address this.

parts := strings.SplitN(model, "/", 2)
if len(parts) == 2 {
provider := strings.ToLower(strings.TrimSpace(parts[0]))
if backend, ok := extraAliases[provider]; ok {

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] The extraAliases map keys are compared against a lowercased provider string (line 188 calls strings.ToLower), but there's no enforcement that callers supply lowercased keys. A future caller passing {"GitHub-Copilot": ...} would silently fail to match.

💡 Suggestion

Normalise keys on the way in to make the function self-defending:

for k, v := range extraAliases {
    normalised[strings.ToLower(k)] = v
}

Or add a // Keys must be lowercase note to the docstring so the contract is explicit and reviewable.

@copilot please address this.

var steps []GitHubActionStep

// Prepend the config step (writes permissions JSON to workspace).
if len(cfg.ConfigStep) > 0 {

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] ConfigStep is silently skipped when empty — if a future engine adopts BuildCLIEngineExecutionSteps and forgets to populate ConfigStep, the permissions config write is quietly omitted, which could cause the agent to hang on interactive prompts in CI.

The original engines always wrote a config step; this guard makes the omission look intentional rather than accidental.

💡 Suggestion

Consider renaming to PreSteps []GitHubActionStep and documenting that an empty slice is valid and means "no pre-step". Or add a comment next to the current guard:

// ConfigStep is optional; engines that need a permissions config file
// (e.g. .crush.json) should always populate this.
if len(cfg.ConfigStep) > 0 {
    steps = append(steps, cfg.ConfigStep)
}

Either approach surfaces the intent to the next engineer adding an engine.

@copilot please address this.

@github-actions github-actions Bot left a comment

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.

Clean deduplication overall — the BuildCLIEngineExecutionSteps extraction is faithful and the resolveBackendWithAliases change to resolvePiBackend is behaviorally equivalent to the old inline EqualFold check.

### Findings summary

Medium — nil pointer in new shared entry point (universal_llm_consumer_engine.go:241)
workflowData.Name is dereferenced before any nil guard in BuildCLIEngineExecutionSteps. The old per-engine code had the same gap, but this method is now the single code path for all CLI engines so the blast radius is higher. See inline comment.

Medium — polymorphism bypass on secret name filter (universal_llm_consumer_engine.go:354)
BuildCLIEngineExecutionSteps calls GetUniversalRequiredSecretNames directly on the embedded base type, bypassing any GetRequiredSecretNames override a concrete engine might add. Harmless today (both Crush and OpenCode just delegate), but a silent correctness trap for future engines that extend the secret list. See inline comment.

Low — double space when ExtraCLIArgs is empty (universal_llm_consumer_engine.go:259)
Latent defect for future engines that pass no extra args. See inline comment.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • patchdiff.githubusercontent.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "patchdiff.githubusercontent.com"

See Network Configuration for more information.

🔎 Code quality review by PR Code Quality Reviewer · 173 AIC · ⌖ 7.07 AIC · ⊞ 5.2K

cfg UniversalCLIEngineExecutionConfig,
) []GitHubActionStep {
universalLLMConsumerLog.Printf("Generating execution steps for %s engine: workflow=%s, firewall=%v",
cfg.DefaultCommandName, workflowData.Name, isFirewallEnabled(workflowData))

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.

Nil dereference if workflowData is nil: workflowData.Name is read unconditionally before any nil guard; a nil argument will panic here.

💡 Suggested fix

Add a nil guard at the top of BuildCLIEngineExecutionSteps, consistent with how the rest of the file handles nil *WorkflowData:

if workflowData == nil {
    universalLLMConsumerLog.Printf("BuildCLIEngineExecutionSteps: nil workflowData for %s engine", cfg.DefaultCommandName)
    return nil
}

The old per-engine code had the same blind spot, but BuildCLIEngineExecutionSteps is now the single shared code path for every CLI engine that adopts it — so the blast radius is larger and a nil guard here protects all future callers.

" - name: " + cfg.StepName,
" id: agentic_execution",
}
allowedSecrets := e.GetUniversalRequiredSecretNames(workflowData)

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.

GetUniversalRequiredSecretNames bypasses polymorphism: the method is called directly on the embedded base struct, so any override of GetRequiredSecretNames in a concrete engine will not be used for the secret filter.

💡 Detail and suggested fix

In Go, BuildCLIEngineExecutionSteps has receiver *UniversalLLMConsumerEngine, so e is always the embedded base struct — not the outer *CrushEngine or *OpenCodeEngine. Calling e.GetUniversalRequiredSecretNames(workflowData) here is therefore statically bound to the base implementation.

Today this is harmless because CrushEngine.GetRequiredSecretNames and OpenCodeEngine.GetRequiredSecretNames both just delegate to GetUniversalRequiredSecretNames. But any future engine that calls BuildCLIEngineExecutionSteps and also adds extra secrets in its GetRequiredSecretNames override will silently have those extra secrets excluded from FilterEnvForSecrets, meaning they won't appear in the generated step's env: block.

The cleanest fix is to accept a secretNamesFn parameter:

func (e *UniversalLLMConsumerEngine) BuildCLIEngineExecutionSteps(
    workflowData *WorkflowData,
    logFile string,
    cfg UniversalCLIEngineExecutionConfig,
    secretNamesFn func(*WorkflowData) []string,  // caller supplies e.GetRequiredSecretNames
) []GitHubActionStep

Alternatively, expose it as a config field on UniversalCLIEngineExecutionConfig. Either way the caller controls which secret list gates the env filter.

if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" {
commandName = workflowData.EngineConfig.Command
}
engineCommand := fmt.Sprintf("%s run %s %s", commandName, shellJoinArgs(cliArgs), promptArg)

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.

Double space in engineCommand when ExtraCLIArgs is empty: shellJoinArgs(nil) returns "", so the format string produces "<binary> run \"$(cat ...)\"" with two consecutive spaces.

💡 Suggested fix

Both current callers (Crush: --verbose; OpenCode: --print-logs --log-level DEBUG) pass non-empty args, so there is no immediate breakage. But any future engine with no extra CLI args will silently get a double-space command. Shells handle it fine, but it's a latent inconsistency in generated YAML.

Fix by trimming or only including the args segment when non-empty:

var engineCommand string
if len(cliArgs) > 0 {
    engineCommand = fmt.Sprintf("%s run %s %s", commandName, shellJoinArgs(cliArgs), promptArg)
} else {
    engineCommand = fmt.Sprintf("%s run %s", commandName, promptArg)
}

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, link the finalized ADR in the PR body, address the remaining review suggestions (add tests for resolveBackendWithAliases, document its alias-contract assumptions, and clarify the ConfigStep guard), and refresh/rebase the branch if it turns out to be behind main before rerunning checks.

Generated by 👨‍🍳 PR Sous Chef · 83 AIC · ⌖ 0.986 AIC · ⊞ 17.1K ·

@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.82.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants