From fdafb8a3971f05c2e2d7438666a77c351162490b Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 8 Apr 2026 12:30:37 +0100 Subject: [PATCH] fix: deprecate max-turns and move timeout-minutes to YAML job property - max-turns was specific to Claude Code and is not supported by Copilot CLI. It is now ignored at compile time with a deprecation warning. Front matter parsing is preserved for backwards compatibility. - timeout-minutes no longer emits --max-timeout CLI arg. Instead it generates timeoutInMinutes on the PerformAgenticTask job in both standalone and 1ES templates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 27 ++++------ prompts/create-ado-agentic-workflow.md | 1 - src/compile/common.rs | 68 ++++++++++++++++++-------- src/compile/onees.rs | 11 +++-- src/compile/standalone.rs | 12 +++-- src/compile/types.rs | 5 +- templates/1es-base.yml | 1 + templates/base.yml | 1 + 8 files changed, 76 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5fc0b15..eb0b4c18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) @@ -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 ``` @@ -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 ` 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 ` in the generated pipeline's copilot params. +When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: ` on the agentic job. ### Tools Configuration @@ -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 ` - AI model from `engine` front matter field (default: claude-opus-4.5) -- `--max-turns ` - Maximum agentic turns from `engine.max-turns` (omitted when not set) -- `--max-timeout ` - 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 ` - Explicitly allows specific tools (github, safeoutputs, write, shell commands like cat, date, echo, grep, head, ls, pwd, sort, tail, uniq, wc, yq) @@ -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: ` 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. diff --git a/prompts/create-ado-agentic-workflow.md b/prompts/create-ado-agentic-workflow.md index e1328c60..00a97c08 100644 --- a/prompts/create-ado-agentic-workflow.md +++ b/prompts/create-ado-agentic-workflow.md @@ -50,7 +50,6 @@ Object form with extra options: ```yaml engine: model: claude-sonnet-4.5 - max-turns: 50 timeout-minutes: 30 ``` diff --git a/src/compile/common.rs b/src/compile/common.rs index 51432e7c..ae3248ad 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -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()); @@ -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(); @@ -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] @@ -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] @@ -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 ──────────────────────────────────────────────────── diff --git a/src/compile/onees.rs b/src/compile/onees.rs index 93de9d5a..4a14058d 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -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}; @@ -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"); @@ -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), diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 2fde3739..e1a5210d 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -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}; @@ -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( @@ -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), diff --git a/src/compile/types.rs b/src/compile/types.rs index 89ecca86..1b438398 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -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)] @@ -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 { match self { EngineConfig::Simple(_) => None, @@ -178,7 +177,7 @@ pub struct EngineOptions { /// AI model to use (defaults to claude-opus-4.5) #[serde(default)] pub model: Option, - /// 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, /// Workflow timeout in minutes diff --git a/templates/1es-base.yml b/templates/1es-base.yml index 024ff0cc..da1957d4 100644 --- a/templates/1es-base.yml +++ b/templates/1es-base.yml @@ -43,6 +43,7 @@ extends: - job: PerformAgenticTask displayName: "{{ agent_name }} (Agent)" {{ agentic_depends_on }} + {{ job_timeout }} templateContext: type: agencyJob arguments: diff --git a/templates/base.yml b/templates/base.yml index 5d39b135..cc1eb014 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -17,6 +17,7 @@ jobs: - job: PerformAgenticTask displayName: "{{ agent_name }} (Agent Automations)" {{ agentic_depends_on }} + {{ job_timeout }} pool: name: {{ pool }} steps: