Part of the ado-aw documentation.
ado-aw compiles agent markdown into Azure DevOps YAML through the typed pipeline IR in src/compile/ir/. New features should add typed declarations and IR nodes, not YAML string fragments.
When extending the compiler:
- New CLI commands: add variants to the
Commandsenum insrc/main.rs, implement dispatch, and add parsing/behavior tests. - New compile targets: build a typed
PipelineIR in a target wrapper module undersrc/compile/(use existingstandalone_ir.rs,onees_ir.rs,job_ir.rs, andstage_ir.rsas references). The canonical 5-job shape itself lives insrc/compile/agentic_pipeline.rsand is reused by every target — wrappers only set the per-targetPipelineShapeand lift the sharedBuiltPipelineContextinto the right envelope. - New front matter fields: add fields to
FrontMatteror nested config types insrc/compile/types.rs. Breaking changes require a codemod undersrc/compile/codemods/; seedocs/codemods.md. - New compiler extensions: implement the
CompilerExtensionname/phase/declarationstrio and return typedDeclarations. - New safe-output tools: add to
src/safeoutputs/, implement the safe-output data model and executor, and register it in MCP and Stage 3 execution wiring. - New first-class tools: create
src/tools/<name>/withmod.rsandextension.rs(CompilerExtensionimpl). Addexecute.rsif the tool has Stage 3 runtime logic. ExtendToolsConfigintypes.rsand collection incollect_extensions(). - New runtimes: create
src/runtimes/<name>/withmod.rs(config types/helpers) andextension.rs(CompilerExtensionimpl). ExtendRuntimesConfigintypes.rsand collection incollect_extensions(). - Validation: add compile-time validation for front matter, safe outputs, permissions, and any IR invariants your feature introduces.
The codebase follows a colocation principle:
- Tools (
tools:front matter) live insrc/tools/<name>/— one directory per tool, containing compile-time (extension.rs) and optional runtime (execute.rs) code. - Runtimes (
runtimes:front matter) live insrc/runtimes/<name>/— config and helpers inmod.rs, compiler integration inextension.rs. - Infrastructure extensions live in
src/compile/extensions/. These are always-on compiler plumbing, not user-facing tools. - Safe outputs (
safe-outputs:front matter) live insrc/safeoutputs/. They follow the Stage 1 NDJSON proposal → Detection → Stage 3 execution lifecycle and are notCompilerExtensionimplementations.
src/compile/extensions/mod.rs owns the CompilerExtension trait, the Extension enum, Declarations, and collect_extensions(). It re-exports runtime/tool extension types from their colocated modules so target compilers can import extension machinery from one place.
Runtimes, first-class tools, and always-on compiler infrastructure declare compile-time contributions through CompilerExtension:
pub trait CompilerExtension {
fn name(&self) -> &str;
fn phase(&self) -> ExtensionPhase;
fn declarations(&self, ctx: &CompileContext) -> Result<Declarations>;
}name() is for diagnostics. phase() controls ordering. declarations() returns a typed aggregate of everything the extension contributes.
Extensions are sorted by ExtensionPhase before the compiler merges declarations:
System— compiler-internal infrastructure that later phases depend on (for exampleAdoScriptExtension).Runtime— language/toolchain installation (LeanExtension,PythonExtension,NodeExtension,DotnetExtension).Tool— first-party tools (AzureDevOpsExtension,CacheMemoryExtension,AzureCliExtension).
System extensions run first, runtimes run before tools, and definition order is preserved within each phase.
collect_extensions() always includes:
AdoAwMarkerExtension— embeds ado-aw metadata in compiled YAML.GitHubExtension— GitHub MCP plumbing.SafeOutputsExtension— SafeOutputs MCP plumbing.AdoScriptExtension— gate evaluator, runtime-import resolver, and synthetic PR helpers.ExecContextExtension—aw-context/precompute contributors.AzureCliExtension— Azure CLI mounts, allowlist entries, and PATH setup.
User-configured runtimes and tools are appended after those always-on extensions, then sorted by phase.
Declarations contains typed IR steps plus non-step signals:
pub struct Declarations {
pub agent_prepare_steps: Vec<Step>,
pub setup_steps: Vec<Step>,
pub agent_finalize_steps: Vec<Step>,
pub detection_prepare_steps: Vec<Step>,
pub safe_outputs_steps: Vec<Step>,
pub network_hosts: Vec<String>,
pub bash_commands: Vec<String>,
pub prompt_supplement: Option<String>,
pub mcpg_servers: Vec<(String, McpgServerConfig)>,
pub copilot_allow_tools: Vec<String>,
pub pipeline_env: Vec<PipelineEnvMapping>,
pub awf_mounts: Vec<AwfMount>,
pub awf_path_prepends: Vec<String>,
pub agent_env_vars: Vec<(String, String)>,
pub warnings: Vec<String>,
}Return Declarations::default() and fill only the fields your feature owns. Do not add target-specific special cases when the same information can be declared here.
Compiler-owned steps should be Step variants from src/compile/ir/step.rs.
use crate::compile::ir::env::EnvValue;
use crate::compile::ir::ids::StepId;
use crate::compile::ir::output::OutputDecl;
use crate::compile::ir::step::{BashStep, Step};
let step = Step::Bash(
BashStep::new("Prepare tool", "echo preparing")
.with_id(StepId::new("prepareTool")?)
.with_env("BUILD_REASON", EnvValue::ado_macro("Build.Reason")?)
.with_output(OutputDecl::new("TOOL_READY")),
);BashStep::script is the raw bash body. Do not include - bash: | or YAML indentation; the lowerer and serializer own YAML formatting.
use crate::compile::ir::step::Step;
use crate::compile::ir::tasks::publish_test_results::{PublishTestResults, TestResultsFormat};
let step = Step::Task(
PublishTestResults::new(TestResultsFormat::JUnit, "**/TEST-*.xml")
.test_run_title("Unit Tests")
.into_step(),
);Use TaskStep for Azure DevOps built-in tasks. When the task is compiler-generated, add or reuse a typed builder struct in src/compile/ir/tasks/ (one file per task) so required inputs are positional, optional inputs are typed setters, and call sites do not hand-construct TaskStep::new(...) with raw task/input strings. Each builder exposes new(<required>), typed chained setters, and into_step() -> TaskStep; constrained values are typed enums and bool inputs take bool. Command/mode-dispatch tasks (e.g. Docker@2) use a command enum with per-variant data — model new ones on src/compile/ir/tasks/docker.rs.
use crate::compile::ir::step::{DownloadStep, PublishStep, Step};
let download = Step::Download(DownloadStep {
source: "current".into(),
artifact: "agent_outputs_$(Build.BuildId)".into(),
condition: None,
});
let publish = Step::Publish(PublishStep {
path: "$(Agent.TempDirectory)/agent_outputs".into(),
artifact: "agent_outputs_$(Build.BuildId)".into(),
condition: Some(Condition::Always),
});Step::Publish lowers differently for 1ES: the 1ES shape collects publishes into templateContext.outputs and removes the inline publish step.
Step::RawYaml is an escape hatch for user-authored setup/teardown YAML that the IR does not model. Prefer typed steps for generated compiler behavior, especially when a step needs env values, conditions, outputs, or graph-derived dependencies.
A producer declares outputs on BashStep:
let producer = BashStep::new("Resolve PR", script)
.with_id(StepId::new("synthPr")?)
.with_output(OutputDecl::new("AW_SYNTHETIC_PR_ID"));A consumer references an output through OutputRef:
let pr_id = OutputRef::new(StepId::new("synthPr")?, "AW_SYNTHETIC_PR_ID");
let step = BashStep::new("Use PR", "echo using PR")
.with_env("PR_ID", EnvValue::step_output(pr_id));The graph and lowering passes choose the correct Azure DevOps syntax for same-job, cross-job, or cross-stage consumers. Do not hand-code $(step.var), dependencies.*, or stageDependencies.* unless you are adding a new lowering rule.
The graph pass also derives dependsOn edges from these refs, validates that producers and output names exist, detects cycles, and marks producer declarations that need isOutput=true.
Use Condition and Expr from src/compile/ir/condition.rs:
use crate::compile::ir::condition::{Condition, Expr};
let only_pr = Condition::Eq(
Expr::Variable("Build.Reason".into()),
Expr::Literal("PullRequest".into()),
);
let condition = Condition::and([
Condition::Succeeded,
only_pr,
]);Available forms include Succeeded, Always, Failed, SucceededOrFailed, And, Or, Not, Eq, Ne, and Custom. Prefer the AST. Use Condition::Custom only for ADO expressions the AST cannot yet model; codegen rejects embedded newlines and pipeline-command markers before emitting custom strings.
Expr::StepOutput(OutputRef) participates in the same graph and output-ref lowering path as EnvValue::StepOutput.
A compile target should build a complete typed Pipeline and then use the shared IR emit path. Follow the existing target wrapper modules — they delegate the heavy lifting to src/compile/agentic_pipeline.rs::build_pipeline_context and only handle the per-target envelope:
src/compile/standalone_ir.rssrc/compile/onees_ir.rssrc/compile/job_ir.rssrc/compile/stage_ir.rs
Recommended workflow:
- Parse and validate front matter in
src/compile/types.rs. - Build
CompileContextand callcollect_extensions(). - Merge extension
Declarationsin phase order. - Construct typed
Jobs,Stages, andSteps. - Choose
PipelineBody::JobsorPipelineBody::Stages. - Choose the appropriate
PipelineShapeor add a new shape if the output wrapper is structurally new. - Let
ir::emitlower throughserde_yaml::Valueand serialize. - Add fixture tests for the target's emitted YAML.
Do not create new template files or marker replacement systems for new targets.
Safe-output tools live in src/safeoutputs/. Use them when the agent should propose a write action that Detection can inspect and Stage 3 can apply with a write-capable token.
Typical steps:
- Add
src/safeoutputs/<tool>.rswith the tool input type, sanitization/validation,ToolResult, andExecutorimplementation. - Register the module in
src/safeoutputs/mod.rs. - Expose the MCP tool in
src/mcp.rs. - Wire Stage 3 execution in
src/execute.rsif the executor dispatch table needs an update. - Add front-matter configuration if the tool is configurable under
safe-outputs:. - Add tests for validation, NDJSON parsing, MCP handling, and executor behavior.
Type path/identifier
Paramsfields with validated newtypes. If your tool's input holds a file path, git ref, commit SHA, artifact name, or similar identifier, use a newtype fromsrc/secure.rs(RelativeSafePath,StrictRelativePath,PathSegment,GitRefName,BranchName,CommitSha,ArtifactName,Identifier,HostName,Version) instead of a rawString. These wrap the canonical primitives insrc/validate.rsand run them at deserialization time, so the path-traversal / injection / format checks are applied automatically and cannot be silently omitted. Reserve the manualvalidate()method for cross-field and semantic rules (e.g. positive IDs, length minimums).
Safe-output tools are not CompilerExtensions. If a safe output also needs compile-time MCP configuration, add that through the always-on SafeOutputsExtension declarations.
Runtimes live under src/runtimes/<name>/.
- Add config types and helpers in
mod.rs. - Implement
CompilerExtensioninextension.rs. - Return installation steps as typed
Step::TaskorStep::BashinDeclarations::agent_prepare_steps. - Return network hosts, bash commands, prompt supplements, env vars, mounts, and warnings through
Declarationsas needed. - Extend
RuntimesConfiginsrc/compile/types.rs. - Re-export and collect the extension in
src/compile/extensions/mod.rs. - Add tests for front-matter parsing and generated pipeline IR/YAML.
First-class tools live under src/tools/<name>/.
- Add config and helper code in
mod.rs. - Implement
CompilerExtensioninextension.rs. - Return typed setup, prepare, finalize, detection, or SafeOutputs steps through
Declarations. - Return MCPG servers, allowed Copilot tools, pipeline env mappings, AWF mounts/PATH entries, network hosts, and prompt supplements through the corresponding declaration fields.
- Add
execute.rsif the tool also runs in Stage 3. - Extend
ToolsConfiginsrc/compile/types.rsandcollect_extensions(). - Add tests for config parsing, declarations, and emitted pipeline behavior.
Trigger filter expressions still use the separate filter IR. It lowers PrFilters / PipelineFilters into typed checks, validates conflicts, and emits bash consumed by AdoScriptExtension declarations. The generated gate steps are now returned as typed IR steps instead of being spliced into YAML templates.
To add a new filter type:
- Add a
Factvariant if the filter needs a new data source. - Add a
Predicatevariant if it needs a new test shape. - Extend lowering from
PrFiltersorPipelineFiltersinfilter_ir.rs. - Add validation rules for impossible or redundant combinations.
- Add lowering, validation, and codegen tests.
tests/bash_lint_tests.rs compiles representative fixtures and runs shellcheck against every literal bash: body in generated YAML. When adding or modifying bash:
- Run
cargo test --test bash_lint_testsifshellcheckis available locally. - Fix findings such as unquoted variables,
cdwithout failure handling, masked exit codes, and tilde-in-double-quotes. - If a finding is intentional, add a
# shellcheck disable=SCxxxxcomment immediately above the line in the bash body.
Do not add blanket set -eo pipefail to every step just to satisfy lint. Use targeted fail-fast behavior only when the step requires it.