feat(compile): runtime prompt injection via prompt.js bundle#395
feat(compile): runtime prompt injection via prompt.js bundle#395jamesadevine wants to merge 1 commit intofeat/ado-script-typescript-gatefrom
Conversation
Default behaviour now renders the agent prompt at pipeline runtime via a
new `prompt.js` ado-script bundle, instead of embedding the body in the
compiled YAML. Body-only edits to the agent .md no longer require
recompiling the pipeline.
Set `inlined-imports: true` in front matter to opt out and keep the
legacy heredoc-embedded behaviour.
The runtime contract is a new `PromptSpec` IR (mirrors `GateSpec`):
schemars-derived JSON Schema, `json-schema-to-typescript` codegen into
`types-prompt.gen.ts`, base64-encoded into a single
`ADO_AW_PROMPT_SPEC` env var on the prompt.js step.
`prompt.js` reads the source from the workspace, strips front matter,
appends extension supplements, and substitutes `${{ parameters.NAME }}`
and `$(VAR)` patterns at runtime via env vars. Secret variables stay
secure-by-default (not auto-exposed).
Existing compiled YAMLs will fail `ado-aw check` after upgrade until
recompiled. Use `inlined-imports: true` as the one-line escape hatch.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: Looks good with two issues worth addressing — a subtle string-trimming bug in the inlined branch and a double-substitution vector in the TypeScript substitution pipeline. Findings🐛 Bugs / Logic Issues
🔒 Security Concerns
|
Summary
Replaces compile-time prompt embedding with runtime prompt injection via a new
prompt.jsado-script bundle. The default behaviour changes: the agent's markdown body is no longer baked into the compiled pipeline YAML — it's read from the workspace at runtime byprompt.js, which also strips front matter, appends extension supplements, and substitutes pipeline parameters / variables.UX win: body-only edits to the agent
.mdno longer require recompiling the pipeline. Front-matter changes still recompile (correct: front-matter is configuration, body is prompt).Set
inlined-imports: truein front matter (mirrors gh-aw's field name exactly) to opt out and keep the legacy heredoc-embedded behaviour. Use this for self-contained YAML, restricted networks, or debugging.Design
The runtime contract is a new
PromptSpecIR mirroring the provenGateSpecpattern:src/compile/prompt_ir.rs—PromptSpec,PromptSupplement,PROMPT_SPEC_VERSION = 1, schemars-derived JSON Schema.export-prompt-schema(mirrorsexport-gate-schema).npm run codegennow produces bothtypes.gen.ts(gate) andtypes-prompt.gen.ts(prompt). CI drift check covers both.PromptSpecis JSON-serialized + base64-encoded into a singleADO_AW_PROMPT_SPECenv var on the prompt.js step. Each declared parameter gets a separateADO_AW_PARAM_<NAME>: ${{ parameters.<NAME> }}env entry — values stay outside the spec so ADO secret-redaction and late binding work correctly.Substitution patterns recognised by
prompt.js${{ parameters.NAME }}ADO_AW_PARAM_<NAME upper, hyphen→underscore>$(VAR)/$(VAR.SUB)<name upper, dot→underscore>(ADO native)$[ ... ]\$(...)$(...)left literal.Pipeline ordering
When
inlined-imports: false(default), the new{{ prepare_agent_prompt }}template marker emits a three-step bundle in the Agent job:NodeTool@0to install Node 20.x.curldownload ofscripts.zipand unzip to/tmp/ado-aw-scripts/(factored into a shared helper used by bothgate.jsSetup-job step andprompt.jsAgent-job step).bash: node /tmp/ado-aw-scripts/prompt.jswith the spec env + parameter env mappings.When
inlined-imports: true, the same marker emits the legacy heredoc step embedding the body verbatim, plus per-extensioncat >>supplement steps via the existingwrap_prompt_append.Scope
.md.PromptSpec.supplements.${{ parameters.* }}and$(VAR)patterns.{{#import path }}runtime-import macros — out of scope; the spec is additive-friendly so this is not a one-way door.Files changed
Rust (compile-time)
src/compile/prompt_ir.rs(new) —PromptSpec,PromptSupplement,PROMPT_SPEC_VERSION, schema generator.src/compile/mod.rs—pub(crate) mod prompt_ir;.src/compile/types.rs— addsinlined_imports: bool(#[serde(rename = "inlined-imports", default)]) toFrontMatter.src/compile/common.rs— new helperscollect_prompt_supplements,generate_prepare_agent_prompt;generate_prepare_stepstakesinlined_imports: booland skipswrap_prompt_appendin the runtime branch;compile_sharedbuilds and emits{{ prepare_agent_prompt }}.src/compile/extensions/mod.rs— extractsscripts_download_step()andnode_tool_step(name)helpers shared by both bundles.src/compile/extensions/trigger_filters.rs— uses the shared helpers.src/main.rs— adds hiddenCommands::ExportPromptSchemasubcommand.src/data/base.ymlandsrc/data/1es-base.yml— replace the static heredoc step with{{ prepare_agent_prompt }}.TypeScript (runtime bundle)
scripts/ado-script/src/prompt/index.ts(new) — entry point; decodesADO_AW_PROMPT_SPEC, validates version, reads source, strips front matter, substitutes, appends supplements, writes output.scripts/ado-script/src/prompt/frontmatter.ts(new) — purestripFrontMattermirroringextract_front_matterin Rust.scripts/ado-script/src/prompt/substitute.ts(new) — substitution engine (${{ parameters.* }},$(VAR),\$(...),$[...]warning).scripts/ado-script/src/prompt/__tests__/{frontmatter,substitute}.test.ts(new) — 19 unit tests.scripts/ado-script/src/shared/types-prompt.gen.ts(new, AUTO-GENERATED) — committed; CI verifies it stays in lockstep with the Rust IR.scripts/ado-script/test/prompt-smoke.test.ts(new) — 3 end-to-end smoke tests against the compiled bundle.scripts/ado-script/package.json— extendscodegento also produce the prompt schema; addsbuild:prompt; updatesbuildandbuild:checkto cover both bundles.CI / infra
.github/workflows/ado-script.yml— drift check now covers bothtypes.gen.tsandtypes-prompt.gen.ts..github/workflows/release.yml— comment updated; no zip-command change needed (the existing glob picks upscripts/prompt.jsautomatically)..gitignore— addsscripts/prompt.js.Docs
docs/ado-script.md— addsprompt.jsbundle section, updated architecture diagram, codegen pipeline now produces both schemas.docs/front-matter.md— documentsinlined-imports: true.docs/template-markers.md— replaces{{ agent_content }}with{{ prepare_agent_prompt }}and documents the substitution contract.docs/extending.md— notes thatprompt_supplement()content goes through the substitution pipeline at runtime.AGENTS.md— addsprompt.jsandprompt_ir.rsto the source tree overview.Test plan
cargo build✓cargo test— 1117 + 78 + others pass; 8 new unit tests incompile::commoncovering both branches, both call paths throughgenerate_prepare_steps, parameter env emission, supplement ordering, and the source-path validation guard. 3 new tests incompile::typesfor theinlined-importsfield. 4 new tests incompile::prompt_irfor the schema and serialization.cargo clippy --all-targets --all-features— no new lints (baseline pre-existed; verified by stash-pop comparison).cd scripts/ado-script && npm test— 191 tests pass (172 baseline + 19 new for prompt.js: 8 frontmatter + 11 substitute).cd scripts/ado-script && npm run typecheck— clean.cd scripts/ado-script && npm run test:smoke— 5 tests pass (gate.js smoke + 3 new prompt.js end-to-end tests covering happy path, missing source, and unknown version).examples/sample-agent.mdand verified the YAML emitsnode /tmp/ado-aw-scripts/prompt.js+ADO_AW_PROMPT_SPECenv, and that the prompt body is absent from the YAML. Decoded the base64 spec and confirmedsource_pathresolves to$(Build.SourcesDirectory)/examples/sample-agent.md(i.e.,{{ trigger_repo_directory }}is substituted before encoding so prompt.js sees a fully resolved path).inlined-imports: true, verified the YAML contains the body verbatim inside anAGENT_PROMPT_EOFheredoc and does not invoke prompt.js.Migration
Default behaviour changes. Existing compiled
.lock.ymlfiles will failado-aw checkafter this lands until the consumer recompiles. The integrity check property strengthens: with the body no longer embedded, prose-only edits no longer drift from the compiled YAML at all.inlined-imports: trueis the one-line escape hatch for users who can't recompile immediately, can't reach github.com from the Agent pool, or want a fully self-contained pipeline file.