Skip to content

Commit 0116de3

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 0116de3

46 files changed

Lines changed: 2764 additions & 646 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: 41 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,30 @@ 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. The bundled
55+
`dist/import/data/threat-analysis.md` file is copied at build time from
56+
`src/data/threat-analysis.md`, keeping the threat prompt on a single
57+
source of truth with no drift. Because `import.js` uses only the Node
58+
standard library, the ncc bundle is small (~1.5 KB) and carries no SDK
59+
dependency.
60+
3661
## End-to-end data flow
3762

3863
```
@@ -147,14 +172,21 @@ scripts/ado-script/
147172
│ │ ├── env-facts.ts # Pipeline-variable readers + ENV_BY_FACT + BRANCH_FACTS + ref-prefix stripping
148173
│ │ ├── policy.ts # PolicyTracker state machine
149174
│ │ └── 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
175+
│ ├── gate/ # gate.js entry point + per-concern modules
176+
│ │ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit
177+
│ │ ├── bypass.ts # build-reason auto-pass
178+
│ │ ├── facts.ts # fact acquisition (env + REST)
179+
│ │ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening
180+
│ │ └── selfcancel.ts # best-effort build cancellation
181+
│ └── import/ # import.js entry point + runtime prompt resolver
182+
│ ├── index.ts # main(): expand runtime-import markers in place
183+
│ └── __tests__/ # marker, path-resolution, and single-pass coverage
156184
├── test/ # End-to-end smoke tests
157-
└── dist/gate/index.js # ncc bundle output (gitignored)
185+
├── dist/gate/index.js # ncc bundle output (gitignored)
186+
└── dist/import/
187+
├── index.js # ncc bundle output (gitignored)
188+
└── data/
189+
└── threat-analysis.md # bundled threat prompt source copied from src/data/
158190
```
159191

160192
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: 12 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,11 +493,17 @@ 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
493501
- Malicious patches (suspicious web calls, backdoors, encoded strings, suspicious dependencies)
494502

503+
## {{ threat_prompt_resolver_steps }}
504+
505+
Optional YAML step(s) inserted immediately after the threat-analysis prompt heredoc. In runtime-import mode this runs the bundled import resolver against `/tmp/awf-tools/threat-analysis-prompt.md`; in inline mode it is replaced with an empty string.
506+
495507
## {{ agent_description }}
496508

497509
Should be replaced with the description field from the front matter. This is used in display contexts and the threat analysis prompt template.

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 && node scripts/copy-threat-prompt.mjs",
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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env node
2+
// Copies src/data/threat-analysis.md (the canonical Rust-side source of
3+
// truth for the Stage-2 threat-analysis prompt) into the import bundle's
4+
// distributed data directory so the runtime resolver step can read it.
5+
//
6+
// The compiler emits {{#runtime-import …/dist/import/data/threat-analysis.md}}
7+
// pointing at the bundled copy. Run this AFTER ncc has produced dist/import/
8+
// so the data/ subdirectory lands next to the bundled index.js.
9+
10+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
11+
import { dirname, resolve } from "node:path";
12+
import { fileURLToPath } from "node:url";
13+
14+
const here = dirname(fileURLToPath(import.meta.url));
15+
const workspaceRoot = resolve(here, "..");
16+
const repoRoot = resolve(workspaceRoot, "..", "..");
17+
18+
const src = resolve(repoRoot, "src", "data", "threat-analysis.md");
19+
const dst = resolve(
20+
workspaceRoot,
21+
"dist",
22+
"import",
23+
"data",
24+
"threat-analysis.md",
25+
);
26+
27+
if (!existsSync(src)) {
28+
console.error(`copy-threat-prompt: source not found: ${src}`);
29+
process.exit(1);
30+
}
31+
32+
mkdirSync(dirname(dst), { recursive: true });
33+
copyFileSync(src, dst);
34+
console.log(`copy-threat-prompt: ${src} -> ${dst}`);
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+
}

0 commit comments

Comments
 (0)