Skip to content

Compound || expressions in prompt markdown body never substitute at runtime (compiler/runtime env-var naming mismatch) #33074

@mason-tim

Description

@mason-tim

Summary

GitHub Actions compound expressions using || inside an agentic workflow's prompt markdown body never evaluate at runtime. The expression is emitted as a hash-based env var by the compiler, but the runtime evaluator only looks up env vars under deterministic per-sub-expression names — so each side resolves to undefined, and the literal ${{ … }} text reaches the agent's prompt.

The canonical slash-command pattern documented in githubnext/agentics/workflows/repo-assist.md is affected. Most agents silently mask this by self-recovering via the GitHub MCP comment fetch, so the bug isn't usually visible — but for longer / multi-line trigger comments the agent drifts into scheduled-mode behaviour instead of acting on the user's slash-command instructions.

Reproducer

Any agentic workflow whose prompt body contains a compound || expression. The reference upstream workflow at githubnext/agentics/workflows/repo-assist.md line 204:

Take heed of **instructions**: "${{ steps.sanitized.outputs.text || inputs.command }}"

Trigger it via /repo-assist <some text> on an issue comment.

Observed

The activation job's "Print prompt" step shows the prompt sent to the agent contains the literal text:

Take heed of **instructions**: "${{ inputs.command }}"

Note the steps.sanitized.outputs.text || portion is gone (or never made it into the rewrite) and the surviving ${{ inputs.command }} is unsubstituted.

The activation job logs confirm the sanitiser DID capture the comment correctly:

text: /repo-assist <user's instruction>
GH_AW_EXPR_<hash>: /repo-assist <user's instruction>

So the data is available in an env var — it just never reaches the prompt.

Expected

The compound expression should resolve to the sanitised slash-command body (or inputs.command for manual dispatch), and the agent's prompt should read e.g.:

Take heed of **instructions**: "/repo-assist <user's instruction>"

Root Cause Analysis

Two code paths in gh-aw disagree on env var naming for compound expressions:

Compile-timepkg/workflow/expression_extraction.go, generateEnvVarName():

if simpleIdentifierRegex.MatchString(content) {
    // Simple expression -> deterministic name e.g. GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT
    prettyName := strings.ToUpper(strings.ReplaceAll(content, ".", "_"))
    return "GH_AW_" + prettyName
}
// Compound expression (anything containing `||`, function calls, etc.) -> hash
hash := sha256.Sum256([]byte(content))
return "GH_AW_EXPR_" + strings.ToUpper(hashStr[:8])

So steps.sanitized.outputs.text || inputs.command is treated as "complex" and assigned GH_AW_EXPR_<8-char-hash>. The generated lock yml emits exactly that env var, and only that env var.

Runtimeactions/setup/js/runtime_import.cjs, evaluateExpression() (around line 415):

if (trimmed.startsWith("needs.") || trimmed.startsWith("steps.") || trimmed.startsWith("inputs.")) {
  const envVarName = "GH_AW_" + trimmed.toUpperCase().replace(/\./g, "_");
  const envValue = process.env[envVarName];
  if (envValue !== undefined && envValue !== null) {
    return envValue;
  }
}

The runtime evaluator handles || by recursing on each side, then for each terminal sub-expression looks up its deterministic env var name. It never tries the hash form. So:

  • steps.sanitized.outputs.text → looks up GH_AW_STEPS_SANITIZED_OUTPUTS_TEXTnot set → returns the original ${{ … }} form
  • inputs.command → looks up GH_AW_INPUTS_COMMANDnot set → returns the original ${{ … }} form
  • The || operator sees both as unevaluated → gives up

The compiler emits GH_AW_EXPR_<hash> (which the runtime never looks for), and never emits GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT or GH_AW_INPUTS_COMMAND (which the runtime does look for).

Proposed Fix

In pkg/workflow/expression_extraction.go, when an extracted expression is compound (currently routed through the hash naming branch), recursively walk the expression's operands. For every terminal sub-expression that matches the deterministic-name shape (steps.*, needs.*, inputs.*, github.*), also emit an env var under the deterministic name. The hash env var can stay (for any other consumer that depends on it), but the deterministic names are what the runtime evaluator actually uses.

In pseudocode:

func (e *ExpressionExtractor) emitEnvVars(content string) {
    // Existing: emit the hash env var for the full expression
    hashName := e.generateEnvVarName(content)
    e.mappings[hashName] = &ExpressionMapping{Original: ..., EnvVar: hashName}

    // New: for compound expressions, also emit deterministic env vars
    // for each terminal sub-expression that the runtime evaluator
    // recognises (steps.*, needs.*, inputs.*).
    if !simpleIdentifierRegex.MatchString(content) {
        for _, sub := range extractTerminalIdentifiers(content) {
            if isRuntimeDeterministicShape(sub) {
                detName := "GH_AW_" + strings.ToUpper(strings.ReplaceAll(sub, ".", "_"))
                if _, exists := e.mappings[detName]; !exists {
                    e.mappings[detName] = &ExpressionMapping{Original: "${{ " + sub + " }}", EnvVar: detName}
                }
            }
        }
    }
}

extractTerminalIdentifiers would be a small tokeniser that splits on ||, &&, function-call delimiters, etc., and returns each <prefix>.<chain> terminal.

With this change:

  • The lock yml's prompt-creation step env block would set both GH_AW_EXPR_<hash> AND GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT AND GH_AW_INPUTS_COMMAND
  • The runtime evaluator finds the deterministic names and resolves each side of || correctly
  • Compound expressions in markdown bodies behave as users expect

Test Coverage to Add

pkg/workflow/expression_extraction_test.go:

  • Compile a workflow whose prompt body contains ${{ A || B }} where A and B are deterministic-shape (e.g. steps.sanitized.outputs.text || inputs.command). Assert that the generated lock yml's prompt-creation step env block contains env vars GH_AW_STEPS_SANITIZED_OUTPUTS_TEXT and GH_AW_INPUTS_COMMAND (in addition to any hash env var).

actions/setup/js/runtime_import.test.cjs (or equivalent):

  • Given prompt content ${{ A || B }}, env var for A set, env var for B unset → resolves to A's value.
  • Given env for A unset, env for B set → resolves to B's value.
  • Given both set → resolves to A's value (left-biased, matches GitHub Actions semantics).
  • Given neither set → returns the literal ${{ … }} form.

User-Side Workaround

Until this is fixed, workflow authors who need the equivalent of ${{ A || B }} in their prompt body can use two separate single-sub-expression references on adjacent lines. Each single-sub-expression generates a deterministic env var name that the runtime evaluator finds, so each side substitutes correctly:

Slash command instructions: "${{ steps.sanitized.outputs.text }}"
Manual dispatch instructions: "${{ inputs.command }}"

If either of the above is non-empty (not ""), you have been triggered
with explicit instructions — follow them exclusively. Slash takes
precedence if both happen to be present.

This pattern works today and is verified end-to-end (both slash-command and manual-dispatch paths resolve the corresponding env var into the prompt; the agent acts on whichever is non-empty).

Impact

The reference slash-command workflow in githubnext/agentics ships in this state. Any user adopting it gets a subtly broken Command Mode: short, simple slash invocations sometimes work (agent recovers via MCP comment fetch), but longer or multi-line invocations drift into scheduled-mode behaviour. The failure is silent — there's no error in the logs; the agent simply ignores command-mode and runs whatever the scheduled prompt says.

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions