Skip to content

Commit 8304c60

Browse files
jamesadevineCopilot
andcommitted
feat(compile): runtime prompt loading via {{#runtime-import}} markers
Adopts gh-aw's {{#runtime-import path}} marker model and the inlined-imports front-matter toggle. Agent prompt bodies (and the Stage-2 threat-analysis prompt) are now loaded at pipeline runtime by default; body edits no longer require ado-aw compile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c7d49a6 commit 8304c60

45 files changed

Lines changed: 2711 additions & 643 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ado-script.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ jobs:
6969
working-directory: scripts/ado-script
7070
run: npm run typecheck
7171

72-
- name: Build bundle (gate.js)
72+
- name: Build bundles
7373
working-directory: scripts/ado-script
7474
run: npm run build
7575

76-
- name: Smoke-test bundle
76+
- name: Smoke-test bundles
7777
working-directory: scripts/ado-script
78-
run: npx vitest run -c vitest.config.smoke.ts
78+
run: npm run test:smoke
7979

8080
- name: E2E gate test
8181
run: cargo test --test gate_e2e -- --ignored --nocapture

AGENTS.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Every compiled pipeline runs as three sequential jobs:
5454
│ │ ├── onees.rs # 1ES Pipeline Template compiler
5555
│ │ ├── job.rs # Job-level ADO template compiler (target: job)
5656
│ │ ├── stage.rs # Stage-level ADO template compiler (target: stage)
57+
│ │ ├── script_assets.rs # Shared ado-script.zip download/verify/extract steps
5758
│ │ ├── gitattributes.rs # .gitattributes management for compiled pipelines
5859
│ │ ├── filter_ir.rs # Filter expression IR: Fact/Predicate types, lowering, validation, codegen
5960
│ │ ├── pr_filters.rs # PR trigger filter generation (native ADO + gate steps)
@@ -62,6 +63,7 @@ Every compiled pipeline runs as three sequential jobs:
6263
│ │ │ ├── github.rs # Always-on GitHub MCP extension
6364
│ │ │ ├── safe_outputs.rs # Always-on SafeOutputs MCP extension
6465
│ │ │ ├── trigger_filters.rs # Trigger filter extension (gate evaluator delivery)
66+
│ │ │ ├── runtime_prompt.rs # Runtime prompt resolver/inlining extension
6567
│ │ │ └── tests.rs # Extension integration tests
6668
│ │ ├── codemods/ # Front-matter codemods (one file per transformation)
6769
│ │ │ ├── mod.rs # Codemod struct, CODEMODS registry, runner
@@ -156,7 +158,9 @@ Every compiled pipeline runs as three sequential jobs:
156158
│ ├── update-ado-agentic-workflow.md # Guide for modifying an existing agentic pipeline
157159
│ └── debug-ado-agentic-workflow.md # Guide for troubleshooting a failing agentic pipeline
158160
├── scripts/ # Supporting scripts shipped as release artifacts
159-
│ ├── ado-script/ # TypeScript workspace for bundled gate.js (and future bundles)
161+
│ ├── ado-script/ # TypeScript workspace for bundled gate.js, import.js, and future bundles
162+
│ │ └── src/
163+
│ │ └── import/ # Runtime prompt resolver bundle
160164
│ └── gate.js # Bundled gate evaluator (built from scripts/ado-script/, see docs/ado-script.md)
161165
├── tests/ # Integration tests and fixtures
162166
├── docs/ # Per-concept reference documentation (see index below)
@@ -169,7 +173,7 @@ Every compiled pipeline runs as three sequential jobs:
169173
- **Language**: Rust (2024 edition) - Note: Rust 2024 edition exists and is the edition used by this project
170174
- **CLI Framework**: clap v4 with derive macros
171175
- **Error Handling**: anyhow for ergonomic error propagation
172-
- **Bundled scripts**: TypeScript + ncc (`scripts/ado-script/`) — compiled gate evaluator and future internal helpers; see [`docs/ado-script.md`](docs/ado-script.md).
176+
- **Bundled scripts**: TypeScript + ncc (`scripts/ado-script/`) — compiled gate evaluator, runtime import resolver, and future internal helpers; see [`docs/ado-script.md`](docs/ado-script.md).
173177
- **Async Runtime**: tokio with full features
174178
- **YAML Parsing**: serde_yaml
175179
- **MCP Server**: rmcp with server and transport-io features
@@ -193,6 +197,8 @@ index to jump to the right page.
193197

194198
- [`docs/front-matter.md`](docs/front-matter.md) — full agent file format
195199
(markdown body + YAML front matter grammar) with every supported field.
200+
- [`docs/runtime-imports.md`](docs/runtime-imports.md) — runtime prompt import
201+
markers, path resolution, and `inlined-imports:` behavior.
196202
- [`docs/schedule-syntax.md`](docs/schedule-syntax.md) — fuzzy schedule time
197203
syntax (`daily around 14:00`, `weekly on monday`, timezones, scattering).
198204
- [`docs/engine.md`](docs/engine.md)`engine:` configuration (model,
@@ -238,7 +244,7 @@ index to jump to the right page.
238244
adding codemods.
239245
- [`docs/ado-script.md`](docs/ado-script.md)`ado-script` workspace
240246
(`scripts/ado-script/`): the bundled TypeScript runtime helpers (today:
241-
`gate.js`), schemars-driven type codegen, and the A2 design decision.
247+
`gate.js` and `import.js`), schemars-driven type codegen, and the A2 design decision.
242248
- [`docs/local-development.md`](docs/local-development.md) — local development
243249
setup notes.
244250

docs/ado-script.md

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
`ado-script` is the umbrella name for the TypeScript workspace at
44
[`scripts/ado-script/`](../scripts/ado-script/). It produces small,
55
ncc-bundled Node programs that the **compiler injects into every emitted
6-
pipeline** as runtime helpers. The first (and currently only) bundle is
7-
`gate.js`, the trigger-filter gate evaluator.
6+
pipeline** as runtime helpers. Today it produces `gate.js`, the
7+
trigger-filter gate evaluator, and `import.js`, the runtime prompt
8+
resolver described in [`runtime-imports.md`](runtime-imports.md).
89

910
> **Internal-only.** `ado-script` is not a user-facing front-matter
1011
> feature. Authors never write an `ado-script:` block in their agent
@@ -33,6 +34,33 @@ discriminated union. There is no `eval`, no `Function`, no `vm` — a
3334
compromised compiler cannot use the spec to run arbitrary code on the
3435
pipeline runner.
3536

37+
## What `import.js` does
38+
39+
`import.js` is a single-shot Node program. It reads the prompt file path
40+
from `argv[2]` and resolves `{{#runtime-import path}}` markers in place.
41+
The compiler runs it as a post-prepare-prompt step when
42+
[`inlined-imports: false`](front-matter.md#inlined-imports). See
43+
[`runtime-imports.md`](runtime-imports.md) for the author-facing marker
44+
syntax.
45+
46+
### Env-var contract
47+
48+
| Env var | Required | Purpose |
49+
|---|---|---|
50+
| `ADO_AW_IMPORT_BASE` | No | Base directory for relative imports. Defaults to `dirname(argv[2])`. |
51+
52+
The bundle lives at `dist/import/index.js` and ships in the same
53+
`ado-script.zip` release asset as `gate.js`, so pipelines download it
54+
through the same Setup-job asset flow. `import.js` uses only the Node
55+
standard library, so the ncc bundle is small (~1.5 KB) and carries no
56+
SDK dependency.
57+
58+
The Stage-2 threat-analysis prompt is **not** runtime-imported.
59+
`src/data/threat-analysis.md` is `include_str!`'d into the `ado-aw`
60+
binary and inlined into the emitted YAML at compile time, matching
61+
gh-aw's pattern (their `threat_detection.md` ships with the setup
62+
action and is read directly from disk — no marker, no resolver).
63+
3664
## End-to-end data flow
3765

3866
```
@@ -147,14 +175,19 @@ scripts/ado-script/
147175
│ │ ├── env-facts.ts # Pipeline-variable readers + ENV_BY_FACT + BRANCH_FACTS + ref-prefix stripping
148176
│ │ ├── policy.ts # PolicyTracker state machine
149177
│ │ └── vso-logger.ts # ##vso[…] emitters with property/message escaping; complete() is idempotent
150-
│ └── gate/ # gate.js entry point + per-concern modules
151-
│ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit
152-
│ ├── bypass.ts # build-reason auto-pass
153-
│ ├── facts.ts # fact acquisition (env + REST)
154-
│ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening
155-
│ └── selfcancel.ts # best-effort build cancellation
178+
│ ├── gate/ # gate.js entry point + per-concern modules
179+
│ │ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit
180+
│ │ ├── bypass.ts # build-reason auto-pass
181+
│ │ ├── facts.ts # fact acquisition (env + REST)
182+
│ │ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening
183+
│ │ └── selfcancel.ts # best-effort build cancellation
184+
│ └── import/ # import.js entry point + runtime prompt resolver
185+
│ ├── index.ts # main(): expand runtime-import markers in place
186+
│ └── __tests__/ # marker, path-resolution, and single-pass coverage
156187
├── test/ # End-to-end smoke tests
157-
└── dist/gate/index.js # ncc bundle output (gitignored)
188+
└── dist/ # ncc bundle output (gitignored)
189+
├── gate/index.js
190+
└── import/index.js
158191
```
159192

160193
The release workflow (`.github/workflows/release.yml`) runs

docs/front-matter.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,27 @@ becomes a `repos:` entry, with `checkout: false` added for entries
234234
that weren't listed under `checkout:`. Mixing the legacy fields with
235235
an existing `repos:` block is rejected; pick one shape.
236236

237+
## Inlined Imports
238+
239+
The `inlined-imports:` field controls when `{{#runtime-import ...}}`
240+
markers in the markdown body are resolved. It defaults to `false`.
241+
See [`runtime-imports.md`](runtime-imports.md) for the full marker
242+
syntax, path resolution rules, and runtime behavior.
243+
244+
When `inlined-imports: false`, the compiler leaves runtime-import
245+
markers to be resolved on the pipeline runner. This is the default
246+
behavior, and it means prompt-body edits do not require recompiling the
247+
generated YAML.
248+
249+
When `inlined-imports: true`, the compiler resolves all runtime-import
250+
markers at compile time, including the implicit top-level marker that
251+
normally reloads the body itself. The emitted YAML contains the fully
252+
expanded prompt body, so the pipeline file is self-contained.
253+
254+
The trade-off is that the generated YAML is larger, and prompt-body
255+
edits require `ado-aw compile` plus committing the updated pipeline
256+
file.
257+
237258
## Filter Validation
238259

239260
The compiler validates filter configurations at compile time and will emit

docs/runtime-imports.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Runtime Imports
2+
3+
_Part of the [ado-aw documentation](../AGENTS.md)._
4+
5+
Runtime imports let agent prompts pull in snippet files with gh-aw-compatible
6+
`{{#runtime-import ...}}` markers. They are available in the markdown body and
7+
are controlled by the [`inlined-imports:` field](front-matter.md#inlined-imports).
8+
The runtime bundle that expands them is documented in [`ado-script.md`](ado-script.md).
9+
10+
## Marker syntax
11+
12+
Use `{{#runtime-import path}}` for a required import. If the target file is
13+
missing, resolution fails.
14+
15+
```markdown
16+
## Repository policy
17+
{{#runtime-import docs/policy.md}}
18+
```
19+
20+
Use `{{#runtime-import? path}}` for an optional import. If the target file is
21+
missing, the marker is replaced with an empty string.
22+
23+
```markdown
24+
## Local notes
25+
{{#runtime-import? docs/local-notes.md}}
26+
```
27+
28+
## Where markers can appear
29+
30+
Authors can place runtime-import markers anywhere in the agent markdown body.
31+
When `inlined-imports: false` (the default), the compiler also injects an
32+
implicit top-level runtime-import marker that reloads the body itself at
33+
pipeline runtime instead of embedding it into the generated YAML. When
34+
`inlined-imports: true`, that implicit body marker is resolved at compile time
35+
along with any author-written markers.
36+
37+
## Path resolution
38+
39+
- **Absolute paths** are used as-is.
40+
- **Relative paths at runtime** are resolved against the `ADO_AW_IMPORT_BASE`
41+
environment variable. For user-facing imports, the compiler sets this to
42+
`{{ trigger_repo_directory }}`.
43+
- **Relative paths at compile time** (`inlined-imports: true`) are resolved
44+
against the source `.md` file's directory.
45+
46+
## Single-pass behavior
47+
48+
Runtime imports are expanded in a single pass. Imported snippets are inserted
49+
verbatim, and any nested `{{#runtime-import ...}}` or
50+
`{{#runtime-import? ...}}` markers inside those snippets are **not** expanded.
51+
This matches gh-aw's runtime-import behavior.
52+
53+
## Resolver ordering
54+
55+
The runtime-import resolver runs first. Any extension supplements that are
56+
appended later with `cat >>` — including SafeOutputs guidance, GitHub MCP
57+
guidance, runtime guidance, and cache-memory guidance — are added after import
58+
resolution and are left untouched.
59+
60+
## Failure modes
61+
62+
| Marker kind | Missing file behavior |
63+
|---|---|
64+
| `{{#runtime-import path}}` | Resolver exits with status 1 and the pipeline fails. |
65+
| `{{#runtime-import? path}}` | Marker is silently replaced with an empty string. |
66+
67+
When `inlined-imports: true`, the same required/optional rules are applied at
68+
compile time instead of on the pipeline runner.
69+
70+
## Implementation notes
71+
72+
- **Runtime**: `dist/import/index.js` is ncc-bundled into `ado-script.zip`,
73+
downloaded by the Setup job, and run as a post-prepare-prompt step.
74+
- **Compile time**: `resolve_imports_inline()` in
75+
`src/compile/extensions/runtime_prompt.rs` performs the inline expansion when
76+
`inlined-imports: true`.

docs/template-markers.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,12 @@ resources:
385385

386386
Should be replaced with the markdown body (agent instructions) extracted from the source markdown file, excluding the YAML front matter. This content provides the agent with its task description and guidelines.
387387

388+
When `inlined-imports: false` (the default), the compiler emits a top-level `{{#runtime-import ...}}` marker here so the prompt body is reloaded from the source markdown at pipeline runtime. When `inlined-imports: true`, any `{{#runtime-import ...}}` markers in the markdown body are resolved at compile time and the emitted YAML contains the expanded content directly.
389+
390+
## {{ agent_prompt_resolver_steps }}
391+
392+
Optional YAML step(s) inserted immediately after the agent prompt heredoc. In runtime-import mode this expands `{{#runtime-import ...}}` markers in `/tmp/awf-tools/agent-prompt.md` using the bundled resolver from `ado-script.zip`; in inline mode it is replaced with an empty string.
393+
388394
## {{ mcpg_config }}
389395

390396
Should be replaced with the MCP Gateway (MCPG) configuration JSON generated from the `mcp-servers:` front matter. This configuration defines the MCPG server entries and gateway settings.
@@ -487,6 +493,8 @@ Tool names are validated at compile time:
487493

488494
Should be replaced with the embedded threat detection analysis prompt from `src/data/threat-analysis.md`. This prompt template includes markers for `{{ source_path }}`, `{{ agent_name }}`, `{{ agent_description }}`, and `{{ working_directory }}` which are replaced during compilation.
489495

496+
When `inlined-imports: false`, the compiler emits a top-level `{{#runtime-import ...}}` marker pointing at the bundled threat-analysis prompt that ships in `ado-script.zip`; when `inlined-imports: true`, the expanded prompt body is embedded directly into the YAML.
497+
490498
The threat analysis prompt instructs the security analysis agent to check for:
491499
- Prompt injection attempts
492500
- Secret leaks

scripts/ado-script/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
"node": ">=20.0.0"
88
},
99
"scripts": {
10-
"build": "npm run codegen && npm run build:gate",
10+
"build": "npm run codegen && npm run build:gate && npm run build:import",
1111
"build:gate": "ncc build src/gate/index.ts -o dist/gate -m -t",
12+
"build:import": "ncc build src/import/index.ts -o dist/import -m -t",
1213
"build:check": "ls -lh dist/gate/index.js && wc -c dist/gate/index.js",
13-
"codegen": "mkdir -p schema && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\"",
14+
"codegen": "node -e \"require('node:fs').mkdirSync('schema', { recursive: true })\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\"",
1415
"test": "vitest run",
15-
"test:smoke": "npm run build && vitest run -c vitest.config.smoke.ts",
16+
"test:smoke": "npm run build:gate && npm run build:import && vitest run -c vitest.config.smoke.ts",
1617
"lint": "echo TODO",
1718
"typecheck": "tsc --noEmit"
1819
},
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { spawnSync } from "node:child_process";
2+
import { randomUUID } from "node:crypto";
3+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import { dirname, resolve } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
import { ModuleKind, ScriptTarget, transpileModule } from "typescript";
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const sourceEntryPath = resolve(__dirname, "../index.ts");
10+
const scratchRoot = resolve(__dirname, ".scratch");
11+
12+
export type RunResult = {
13+
stdout: string;
14+
stderr: string;
15+
status: number | null;
16+
};
17+
18+
function sanitizeLabel(label: string): string {
19+
return label.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "case";
20+
}
21+
22+
export function withScratchDir(label: string, run: (dir: string) => void): void {
23+
const dir = resolve(scratchRoot, `${sanitizeLabel(label)}-${randomUUID()}`);
24+
mkdirSync(dir, { recursive: true });
25+
26+
try {
27+
run(dir);
28+
} finally {
29+
rmSync(dir, { recursive: true, force: true });
30+
}
31+
}
32+
33+
export function writeFixture(baseDir: string, relativePath: string, contents: string): string {
34+
const filePath = resolve(baseDir, relativePath);
35+
mkdirSync(dirname(filePath), { recursive: true });
36+
writeFileSync(filePath, contents, "utf8");
37+
return filePath;
38+
}
39+
40+
export function readText(filePath: string): string {
41+
return readFileSync(filePath, "utf8");
42+
}
43+
44+
export function runImportSource(target: string, env: NodeJS.ProcessEnv = {}): RunResult {
45+
const runnerPath = resolve(dirname(target), "__runtime-import-runner.mjs");
46+
const compiled = transpileModule(readFileSync(sourceEntryPath, "utf8"), {
47+
compilerOptions: {
48+
module: ModuleKind.ES2022,
49+
target: ScriptTarget.ES2022,
50+
},
51+
}).outputText;
52+
53+
writeFileSync(runnerPath, compiled, "utf8");
54+
55+
const result = spawnSync(process.execPath, [runnerPath, target], {
56+
env: { ...process.env, ...env },
57+
encoding: "utf8",
58+
});
59+
60+
return {
61+
stdout: result.stdout ?? "",
62+
stderr: result.stderr ?? "",
63+
status: result.status,
64+
};
65+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import { readText, runImportSource, withScratchDir, writeFixture } from "./helpers.js";
3+
4+
describe("runtime-import idempotency", () => {
5+
it("is a no-op on the second run after markers are removed", () => {
6+
withScratchDir("idempotency", (dir) => {
7+
const target = writeFixture(
8+
dir,
9+
"prompt.md",
10+
"before\n{{#runtime-import ./snippet.md}}\nafter\n",
11+
);
12+
writeFixture(dir, "snippet.md", "stable\n");
13+
14+
const first = runImportSource(target);
15+
const afterFirst = readText(target);
16+
const second = runImportSource(target);
17+
18+
expect(first.status).toBe(0);
19+
expect(second.status).toBe(0);
20+
expect(afterFirst).toBe("before\nstable\n\nafter\n");
21+
expect(readText(target)).toBe(afterFirst);
22+
});
23+
});
24+
});

0 commit comments

Comments
 (0)