Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ target: standalone # Optional: "standalone" (default) or "1es". See Target Platf
engine: claude-opus-4.5 # AI engine to use. Defaults to claude-opus-4.5. Other options include claude-sonnet-4.5, gpt-5.2-codex, gemini-3-pro-preview, etc.
# engine: # Alternative object format (with additional options)
# model: claude-opus-4.5
# max-turns: 50
# timeout-minutes: 30
schedule: daily around 14:00 # Fuzzy schedule syntax - see Schedule Syntax section below
# schedule: # Alternative object format (with branch filtering)
Expand Down Expand Up @@ -296,7 +295,6 @@ engine: claude-opus-4.5
# Object format with additional options
engine:
model: claude-opus-4.5
max-turns: 50
timeout-minutes: 30
```

Expand All @@ -305,28 +303,19 @@ engine:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string | `claude-opus-4.5` | AI model to use. Options include `claude-sonnet-4.5`, `gpt-5.2-codex`, `gemini-3-pro-preview`, etc. |
| `max-turns` | integer | *(none)* | Maximum number of agentic turns (tool-use iterations) the model is allowed per run. Maps to the `--max-turns` Copilot CLI argument. Use this to cap compute and prevent runaway loops. |
| `timeout-minutes` | integer | *(none)* | Maximum time in minutes the agent workflow is allowed to run. Maps to the `--max-timeout` Copilot CLI argument. Use this to cap long-running agent sessions. |
| `timeout-minutes` | integer | *(none)* | Maximum time in minutes the agent job is allowed to run. Sets `timeoutInMinutes` on the `PerformAgenticTask` job in the generated pipeline. |

#### `max-turns`

Each "turn" is one iteration of the model calling a tool and receiving its output. Setting `max-turns` places an upper bound on how many such iterations the agent can perform in a single pipeline run. This is useful for:

- **Cost control** — limiting expensive model invocations.
- **Safety** — preventing infinite loops where the agent repeatedly calls tools without converging on a result.
- **Predictability** — ensuring the pipeline completes within a reasonable time frame.

When omitted, the Copilot CLI uses its built-in default. When set, the compiler emits `--max-turns <value>` in the generated pipeline's copilot params.
> **Deprecated:** `max-turns` is still accepted in front matter for backwards compatibility but is ignored at compile time (a warning is emitted). It was specific to Claude Code and is not supported by Copilot CLI.

#### `timeout-minutes`

The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire agent session. It maps to the `--max-timeout` Copilot CLI argument. This is useful for:
The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire agent job. It maps to the Azure DevOps `timeoutInMinutes` job property on `PerformAgenticTask`. This is useful for:

- **Budget enforcement** — hard-capping the total runtime of an agent to control compute costs.
- **Pipeline hygiene** — preventing agents from occupying a runner indefinitely if they stall or enter long retry loops.
- **SLA compliance** — ensuring scheduled agents complete within a known window.

When omitted, the Copilot CLI uses its built-in default. When set, the compiler emits `--max-timeout <value>` in the generated pipeline's copilot params.
When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: <value>` on the agentic job.

### Tools Configuration

Expand Down Expand Up @@ -482,8 +471,6 @@ Should be replaced with the human-readable name from the front matter (e.g., "Da

Additional params provided to agency CLI. The compiler generates:
- `--model <model>` - AI model from `engine` front matter field (default: claude-opus-4.5)
- `--max-turns <n>` - Maximum agentic turns from `engine.max-turns` (omitted when not set)
- `--max-timeout <n>` - Workflow timeout in minutes from `engine.timeout-minutes` (omitted when not set)
- `--disable-builtin-mcps` - Disables all built-in MCPs initially
- `--no-ask-user` - Prevents interactive prompts
- `--allow-tool <tool>` - Explicitly allows specific tools (github, safeoutputs, write, shell commands like cat, date, echo, grep, head, ls, pwd, sort, tail, uniq, wc, yq)
Expand Down Expand Up @@ -544,6 +531,12 @@ Generates a `dependsOn: SetupJob` clause for `PerformAgenticTask` if a setup job

If no setup job is configured, this is replaced with an empty string.

## {{ job_timeout }}

Generates a `timeoutInMinutes: <value>` job property for `PerformAgenticTask` when `engine.timeout-minutes` is configured. This sets the Azure DevOps job-level timeout for the agentic task.

If `timeout-minutes` is not configured, this is replaced with an empty string.

## {{ working_directory }}

Should be replaced with the appropriate working directory based on the effective workspace setting.
Expand Down
1 change: 0 additions & 1 deletion prompts/create-ado-agentic-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ Object form with extra options:
```yaml
engine:
model: claude-sonnet-4.5
max-turns: 50
timeout-minutes: 30
```

Expand Down
68 changes: 48 additions & 20 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,27 +310,22 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
let mut params = Vec::new();

params.push(format!("--model {}", front_matter.engine.model()));
if let Some(max_turns) = front_matter.engine.max_turns() {
if max_turns == 0 {
eprintln!(
"Warning: Agent '{}' has max-turns: 0, which means zero turns allowed. \
The agent will not be able to perform any tool calls. \
Consider setting max-turns to at least 1.",
front_matter.name
);
}
params.push(format!("--max-turns {}", max_turns));
if front_matter.engine.max_turns().is_some() {
eprintln!(
"Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \
and will be ignored. Consider removing it from the engine configuration.",
front_matter.name
);
}
if let Some(timeout_minutes) = front_matter.engine.timeout_minutes() {
if timeout_minutes == 0 {
eprintln!(
"Warning: Agent '{}' has timeout-minutes: 0, which means no time is allowed. \
The agent session will time out immediately. \
The agent job will time out immediately. \
Consider setting timeout-minutes to at least 1.",
front_matter.name
);
}
params.push(format!("--max-timeout {}", timeout_minutes));
}
params.push("--disable-builtin-mcps".to_string());
params.push("--no-ask-user".to_string());
Expand Down Expand Up @@ -400,6 +395,15 @@ pub fn generate_working_directory(effective_workspace: &str) -> String {
}
}

/// Generate `timeoutInMinutes` job property from `engine.timeout-minutes`.
/// Returns an empty string when timeout is not configured.
pub fn generate_job_timeout(front_matter: &FrontMatter) -> String {
match front_matter.engine.timeout_minutes() {
Some(minutes) => format!("timeoutInMinutes: {}", minutes),
None => String::new(),
}
}

/// Format a single step's YAML string with proper indentation
pub fn format_step_yaml(step_yaml: &str) -> String {
let trimmed = step_yaml.trim();
Expand Down Expand Up @@ -917,13 +921,13 @@ mod tests {
}

#[test]
fn test_copilot_params_max_turns() {
fn test_copilot_params_max_turns_ignored() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 50\n---\n",
)
.unwrap();
let params = generate_copilot_params(&fm);
assert!(params.contains("--max-turns 50"));
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
}

#[test]
Expand All @@ -934,13 +938,13 @@ mod tests {
}

#[test]
fn test_copilot_params_max_timeout() {
fn test_copilot_params_no_max_timeout() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n",
)
.unwrap();
let params = generate_copilot_params(&fm);
assert!(params.contains("--max-timeout 30"));
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
}

#[test]
Expand All @@ -951,23 +955,47 @@ mod tests {
}

#[test]
fn test_copilot_params_max_turns_zero_still_emitted() {
fn test_copilot_params_max_turns_zero_not_emitted() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 0\n---\n",
)
.unwrap();
let params = generate_copilot_params(&fm);
assert!(params.contains("--max-turns 0"));
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
}

#[test]
fn test_copilot_params_max_timeout_zero_still_emitted() {
fn test_copilot_params_max_timeout_zero_not_emitted() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n",
)
.unwrap();
let params = generate_copilot_params(&fm);
assert!(params.contains("--max-timeout 0"));
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
}

#[test]
fn test_job_timeout_with_value() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n",
)
.unwrap();
assert_eq!(generate_job_timeout(&fm), "timeoutInMinutes: 30");
}

#[test]
fn test_job_timeout_without_value() {
let fm = minimal_front_matter();
assert_eq!(generate_job_timeout(&fm), "");
}

#[test]
fn test_job_timeout_zero() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n",
)
.unwrap();
assert_eq!(generate_job_timeout(&fm), "timeoutInMinutes: 0");
}

// ─── sanitize_filename ────────────────────────────────────────────────────
Expand Down
11 changes: 7 additions & 4 deletions src/compile/onees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ use super::common::{
self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params,
generate_acquire_ado_token, generate_checkout_self, generate_checkout_steps,
generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env,
generate_header_comment, generate_pipeline_path, generate_pipeline_resources,
generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path,
generate_working_directory, replace_with_indent, validate_comment_target,
validate_update_work_item_target, validate_write_permissions,
generate_header_comment, generate_job_timeout, generate_pipeline_path,
generate_pipeline_resources, generate_pr_trigger, generate_repositories,
generate_schedule, generate_source_path, generate_working_directory,
replace_with_indent, validate_comment_target, validate_update_work_item_target,
validate_write_permissions,
};
use super::types::{FrontMatter, McpConfig};

Expand Down Expand Up @@ -104,6 +105,7 @@ displayName: "Finalize""#,
} else {
String::new()
};
let job_timeout = generate_job_timeout(front_matter);

// Load threat analysis prompt template
let threat_analysis_prompt = include_str!("../../templates/threat-analysis.md");
Expand Down Expand Up @@ -163,6 +165,7 @@ displayName: "Finalize""#,
("{{ log_level }}", ""),
("{{ mcp_configuration }}", &mcp_configuration),
("{{ agentic_depends_on }}", &agentic_depends_on),
("{{ job_timeout }}", &job_timeout),
("{{ setup_job }}", &setup_job),
("{{ teardown_job }}", &teardown_job),
("{{ source_path }}", &source_path),
Expand Down
12 changes: 7 additions & 5 deletions src/compile/standalone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ use super::common::{
self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params,
generate_acquire_ado_token, generate_cancel_previous_builds, generate_checkout_self,
generate_checkout_steps, generate_ci_trigger, generate_copilot_ado_env,
generate_executor_ado_env, generate_header_comment, generate_pipeline_path,
generate_pipeline_resources, generate_pr_trigger, generate_repositories,
generate_schedule, generate_source_path, generate_working_directory,
replace_with_indent, sanitize_filename, validate_write_permissions,
validate_comment_target, validate_update_work_item_target,
generate_executor_ado_env, generate_header_comment, generate_job_timeout,
generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger,
generate_repositories, generate_schedule, generate_source_path,
generate_working_directory, replace_with_indent, sanitize_filename,
validate_write_permissions, validate_comment_target, validate_update_work_item_target,
};
use super::types::{FrontMatter, McpConfig};
use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts};
Expand Down Expand Up @@ -106,6 +106,7 @@ impl Compiler for StandaloneCompiler {
let prepare_steps = generate_prepare_steps(&front_matter.steps, has_memory);
let finalize_steps = generate_finalize_steps(&front_matter.post_steps);
let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup);
let job_timeout = generate_job_timeout(front_matter);

// Generate service connection token acquisition steps and env vars
let acquire_read_token = generate_acquire_ado_token(
Expand Down Expand Up @@ -152,6 +153,7 @@ impl Compiler for StandaloneCompiler {
("{{ prepare_steps }}", &prepare_steps),
("{{ finalize_steps }}", &finalize_steps),
("{{ agentic_depends_on }}", &agentic_depends_on),
("{{ job_timeout }}", &job_timeout),
("{{ repositories }}", &repositories),
("{{ schedule }}", &schedule),
("{{ pipeline_resources }}", &pipeline_resources),
Expand Down
5 changes: 2 additions & 3 deletions src/compile/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ pub struct ScheduleOptions {
/// # Object format (with additional options)
/// engine:
/// model: claude-opus-4.5
/// max-turns: 50
/// timeout-minutes: 30
/// ```
#[derive(Debug, Deserialize, Clone)]
Expand All @@ -156,7 +155,7 @@ impl EngineConfig {
}
}

/// Get the max turns setting
/// Get the max turns setting (deprecated — ignored at compile time)
pub fn max_turns(&self) -> Option<u32> {
match self {
EngineConfig::Simple(_) => None,
Expand All @@ -178,7 +177,7 @@ pub struct EngineOptions {
/// AI model to use (defaults to claude-opus-4.5)
#[serde(default)]
pub model: Option<String>,
/// Maximum number of chat iterations per run
/// Maximum number of chat iterations per run (deprecated — not supported by Copilot CLI)
#[serde(default, rename = "max-turns")]
pub max_turns: Option<u32>,
/// Workflow timeout in minutes
Expand Down
1 change: 1 addition & 0 deletions templates/1es-base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ extends:
- job: PerformAgenticTask
displayName: "{{ agent_name }} (Agent)"
{{ agentic_depends_on }}
{{ job_timeout }}
templateContext:
type: agencyJob
arguments:
Expand Down
1 change: 1 addition & 0 deletions templates/base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
- job: PerformAgenticTask
displayName: "{{ agent_name }} (Agent Automations)"
{{ agentic_depends_on }}
{{ job_timeout }}
pool:
name: {{ pool }}
steps:
Expand Down
Loading