Skip to content

Commit 9887c97

Browse files
fix: deprecate max-turns and move timeout-minutes to YAML job property (#138)
- 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>
1 parent 8155d24 commit 9887c97

8 files changed

Lines changed: 76 additions & 50 deletions

File tree

AGENTS.md

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ target: standalone # Optional: "standalone" (default) or "1es". See Target Platf
104104
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.
105105
# engine: # Alternative object format (with additional options)
106106
# model: claude-opus-4.5
107-
# max-turns: 50
108107
# timeout-minutes: 30
109108
schedule: daily around 14:00 # Fuzzy schedule syntax - see Schedule Syntax section below
110109
# schedule: # Alternative object format (with branch filtering)
@@ -296,7 +295,6 @@ engine: claude-opus-4.5
296295
# Object format with additional options
297296
engine:
298297
model: claude-opus-4.5
299-
max-turns: 50
300298
timeout-minutes: 30
301299
```
302300

@@ -305,28 +303,19 @@ engine:
305303
| Field | Type | Default | Description |
306304
|-------|------|---------|-------------|
307305
| `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. |
308-
| `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. |
309-
| `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. |
306+
| `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. |
310307

311-
#### `max-turns`
312-
313-
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:
314-
315-
- **Cost control** — limiting expensive model invocations.
316-
- **Safety** — preventing infinite loops where the agent repeatedly calls tools without converging on a result.
317-
- **Predictability** — ensuring the pipeline completes within a reasonable time frame.
318-
319-
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.
308+
> **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.
320309

321310
#### `timeout-minutes`
322311

323-
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:
312+
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:
324313

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

329-
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.
318+
When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: <value>` on the agentic job.
330319

331320
### Tools Configuration
332321

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

483472
Additional params provided to agency CLI. The compiler generates:
484473
- `--model <model>` - AI model from `engine` front matter field (default: claude-opus-4.5)
485-
- `--max-turns <n>` - Maximum agentic turns from `engine.max-turns` (omitted when not set)
486-
- `--max-timeout <n>` - Workflow timeout in minutes from `engine.timeout-minutes` (omitted when not set)
487474
- `--disable-builtin-mcps` - Disables all built-in MCPs initially
488475
- `--no-ask-user` - Prevents interactive prompts
489476
- `--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)
@@ -544,6 +531,12 @@ Generates a `dependsOn: SetupJob` clause for `PerformAgenticTask` if a setup job
544531

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

534+
## {{ job_timeout }}
535+
536+
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.
537+
538+
If `timeout-minutes` is not configured, this is replaced with an empty string.
539+
547540
## {{ working_directory }}
548541

549542
Should be replaced with the appropriate working directory based on the effective workspace setting.

prompts/create-ado-agentic-workflow.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ Object form with extra options:
5050
```yaml
5151
engine:
5252
model: claude-sonnet-4.5
53-
max-turns: 50
5453
timeout-minutes: 30
5554
```
5655

src/compile/common.rs

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -310,27 +310,22 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
310310
let mut params = Vec::new();
311311

312312
params.push(format!("--model {}", front_matter.engine.model()));
313-
if let Some(max_turns) = front_matter.engine.max_turns() {
314-
if max_turns == 0 {
315-
eprintln!(
316-
"Warning: Agent '{}' has max-turns: 0, which means zero turns allowed. \
317-
The agent will not be able to perform any tool calls. \
318-
Consider setting max-turns to at least 1.",
319-
front_matter.name
320-
);
321-
}
322-
params.push(format!("--max-turns {}", max_turns));
313+
if front_matter.engine.max_turns().is_some() {
314+
eprintln!(
315+
"Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \
316+
and will be ignored. Consider removing it from the engine configuration.",
317+
front_matter.name
318+
);
323319
}
324320
if let Some(timeout_minutes) = front_matter.engine.timeout_minutes() {
325321
if timeout_minutes == 0 {
326322
eprintln!(
327323
"Warning: Agent '{}' has timeout-minutes: 0, which means no time is allowed. \
328-
The agent session will time out immediately. \
324+
The agent job will time out immediately. \
329325
Consider setting timeout-minutes to at least 1.",
330326
front_matter.name
331327
);
332328
}
333-
params.push(format!("--max-timeout {}", timeout_minutes));
334329
}
335330
params.push("--disable-builtin-mcps".to_string());
336331
params.push("--no-ask-user".to_string());
@@ -400,6 +395,15 @@ pub fn generate_working_directory(effective_workspace: &str) -> String {
400395
}
401396
}
402397

398+
/// Generate `timeoutInMinutes` job property from `engine.timeout-minutes`.
399+
/// Returns an empty string when timeout is not configured.
400+
pub fn generate_job_timeout(front_matter: &FrontMatter) -> String {
401+
match front_matter.engine.timeout_minutes() {
402+
Some(minutes) => format!("timeoutInMinutes: {}", minutes),
403+
None => String::new(),
404+
}
405+
}
406+
403407
/// Format a single step's YAML string with proper indentation
404408
pub fn format_step_yaml(step_yaml: &str) -> String {
405409
let trimmed = step_yaml.trim();
@@ -917,13 +921,13 @@ mod tests {
917921
}
918922

919923
#[test]
920-
fn test_copilot_params_max_turns() {
924+
fn test_copilot_params_max_turns_ignored() {
921925
let (fm, _) = parse_markdown(
922926
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 50\n---\n",
923927
)
924928
.unwrap();
925929
let params = generate_copilot_params(&fm);
926-
assert!(params.contains("--max-turns 50"));
930+
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
927931
}
928932

929933
#[test]
@@ -934,13 +938,13 @@ mod tests {
934938
}
935939

936940
#[test]
937-
fn test_copilot_params_max_timeout() {
941+
fn test_copilot_params_no_max_timeout() {
938942
let (fm, _) = parse_markdown(
939943
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n",
940944
)
941945
.unwrap();
942946
let params = generate_copilot_params(&fm);
943-
assert!(params.contains("--max-timeout 30"));
947+
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
944948
}
945949

946950
#[test]
@@ -951,23 +955,47 @@ mod tests {
951955
}
952956

953957
#[test]
954-
fn test_copilot_params_max_turns_zero_still_emitted() {
958+
fn test_copilot_params_max_turns_zero_not_emitted() {
955959
let (fm, _) = parse_markdown(
956960
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 0\n---\n",
957961
)
958962
.unwrap();
959963
let params = generate_copilot_params(&fm);
960-
assert!(params.contains("--max-turns 0"));
964+
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
961965
}
962966

963967
#[test]
964-
fn test_copilot_params_max_timeout_zero_still_emitted() {
968+
fn test_copilot_params_max_timeout_zero_not_emitted() {
965969
let (fm, _) = parse_markdown(
966970
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n",
967971
)
968972
.unwrap();
969973
let params = generate_copilot_params(&fm);
970-
assert!(params.contains("--max-timeout 0"));
974+
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
975+
}
976+
977+
#[test]
978+
fn test_job_timeout_with_value() {
979+
let (fm, _) = parse_markdown(
980+
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n",
981+
)
982+
.unwrap();
983+
assert_eq!(generate_job_timeout(&fm), "timeoutInMinutes: 30");
984+
}
985+
986+
#[test]
987+
fn test_job_timeout_without_value() {
988+
let fm = minimal_front_matter();
989+
assert_eq!(generate_job_timeout(&fm), "");
990+
}
991+
992+
#[test]
993+
fn test_job_timeout_zero() {
994+
let (fm, _) = parse_markdown(
995+
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n",
996+
)
997+
.unwrap();
998+
assert_eq!(generate_job_timeout(&fm), "timeoutInMinutes: 0");
971999
}
9721000

9731001
// ─── sanitize_filename ────────────────────────────────────────────────────

src/compile/onees.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ use super::common::{
2020
self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params,
2121
generate_acquire_ado_token, generate_checkout_self, generate_checkout_steps,
2222
generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env,
23-
generate_header_comment, generate_pipeline_path, generate_pipeline_resources,
24-
generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path,
25-
generate_working_directory, replace_with_indent, validate_comment_target,
26-
validate_update_work_item_target, validate_write_permissions,
23+
generate_header_comment, generate_job_timeout, generate_pipeline_path,
24+
generate_pipeline_resources, generate_pr_trigger, generate_repositories,
25+
generate_schedule, generate_source_path, generate_working_directory,
26+
replace_with_indent, validate_comment_target, validate_update_work_item_target,
27+
validate_write_permissions,
2728
};
2829
use super::types::{FrontMatter, McpConfig};
2930

@@ -104,6 +105,7 @@ displayName: "Finalize""#,
104105
} else {
105106
String::new()
106107
};
108+
let job_timeout = generate_job_timeout(front_matter);
107109

108110
// Load threat analysis prompt template
109111
let threat_analysis_prompt = include_str!("../../templates/threat-analysis.md");
@@ -163,6 +165,7 @@ displayName: "Finalize""#,
163165
("{{ log_level }}", ""),
164166
("{{ mcp_configuration }}", &mcp_configuration),
165167
("{{ agentic_depends_on }}", &agentic_depends_on),
168+
("{{ job_timeout }}", &job_timeout),
166169
("{{ setup_job }}", &setup_job),
167170
("{{ teardown_job }}", &teardown_job),
168171
("{{ source_path }}", &source_path),

src/compile/standalone.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ use super::common::{
1717
self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params,
1818
generate_acquire_ado_token, generate_cancel_previous_builds, generate_checkout_self,
1919
generate_checkout_steps, generate_ci_trigger, generate_copilot_ado_env,
20-
generate_executor_ado_env, generate_header_comment, generate_pipeline_path,
21-
generate_pipeline_resources, generate_pr_trigger, generate_repositories,
22-
generate_schedule, generate_source_path, generate_working_directory,
23-
replace_with_indent, sanitize_filename, validate_write_permissions,
24-
validate_comment_target, validate_update_work_item_target,
20+
generate_executor_ado_env, generate_header_comment, generate_job_timeout,
21+
generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger,
22+
generate_repositories, generate_schedule, generate_source_path,
23+
generate_working_directory, replace_with_indent, sanitize_filename,
24+
validate_write_permissions, validate_comment_target, validate_update_work_item_target,
2525
};
2626
use super::types::{FrontMatter, McpConfig};
2727
use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts};
@@ -106,6 +106,7 @@ impl Compiler for StandaloneCompiler {
106106
let prepare_steps = generate_prepare_steps(&front_matter.steps, has_memory);
107107
let finalize_steps = generate_finalize_steps(&front_matter.post_steps);
108108
let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup);
109+
let job_timeout = generate_job_timeout(front_matter);
109110

110111
// Generate service connection token acquisition steps and env vars
111112
let acquire_read_token = generate_acquire_ado_token(
@@ -152,6 +153,7 @@ impl Compiler for StandaloneCompiler {
152153
("{{ prepare_steps }}", &prepare_steps),
153154
("{{ finalize_steps }}", &finalize_steps),
154155
("{{ agentic_depends_on }}", &agentic_depends_on),
156+
("{{ job_timeout }}", &job_timeout),
155157
("{{ repositories }}", &repositories),
156158
("{{ schedule }}", &schedule),
157159
("{{ pipeline_resources }}", &pipeline_resources),

src/compile/types.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ pub struct ScheduleOptions {
129129
/// # Object format (with additional options)
130130
/// engine:
131131
/// model: claude-opus-4.5
132-
/// max-turns: 50
133132
/// timeout-minutes: 30
134133
/// ```
135134
#[derive(Debug, Deserialize, Clone)]
@@ -156,7 +155,7 @@ impl EngineConfig {
156155
}
157156
}
158157

159-
/// Get the max turns setting
158+
/// Get the max turns setting (deprecated — ignored at compile time)
160159
pub fn max_turns(&self) -> Option<u32> {
161160
match self {
162161
EngineConfig::Simple(_) => None,
@@ -178,7 +177,7 @@ pub struct EngineOptions {
178177
/// AI model to use (defaults to claude-opus-4.5)
179178
#[serde(default)]
180179
pub model: Option<String>,
181-
/// Maximum number of chat iterations per run
180+
/// Maximum number of chat iterations per run (deprecated — not supported by Copilot CLI)
182181
#[serde(default, rename = "max-turns")]
183182
pub max_turns: Option<u32>,
184183
/// Workflow timeout in minutes

templates/1es-base.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ extends:
4343
- job: PerformAgenticTask
4444
displayName: "{{ agent_name }} (Agent)"
4545
{{ agentic_depends_on }}
46+
{{ job_timeout }}
4647
templateContext:
4748
type: agencyJob
4849
arguments:

templates/base.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ jobs:
1717
- job: PerformAgenticTask
1818
displayName: "{{ agent_name }} (Agent Automations)"
1919
{{ agentic_depends_on }}
20+
{{ job_timeout }}
2021
pool:
2122
name: {{ pool }}
2223
steps:

0 commit comments

Comments
 (0)