Skip to content

Commit fbfb385

Browse files
jamesadevineCopilot
andcommitted
feat: append agent stats to 6 safe output write actions
Append a collapsible markdown stats block to safe outputs that produce human-readable content. Each tool reads include_stats from its typed config struct (deserialized via ctx.get_tool_config), matching the existing config pattern used for all other tool options. Safe outputs with stats: - create-pull-request (PR description) - create-work-item (work item description) - comment-on-work-item (comment body) - add-pr-comment (PR comment body) - create-wiki-page (wiki page content) - update-wiki-page (wiki page content) Per-tool opt-out via front matter: safe-outputs: create-pull-request: include-stats: false Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 41e5248 commit fbfb385

7 files changed

Lines changed: 105 additions & 9 deletions

File tree

src/agent_stats.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,25 @@ impl AgentStats {
147147
}
148148
}
149149

150-
/// Compute duration from OTel span startTime/endTime.
150+
/// Append agent stats markdown to a body string if stats are available
151+
/// and stats are not opted out.
152+
///
153+
/// Used by safe output executors after they read their typed config
154+
/// (which contains the `include_stats` field).
155+
pub fn append_stats_to_body(
156+
body: &str,
157+
ctx: &crate::safeoutputs::ExecutionContext,
158+
include_stats: bool,
159+
) -> String {
160+
if !include_stats {
161+
return body.to_string();
162+
}
163+
164+
match &ctx.agent_stats {
165+
Some(stats) => format!("{}{}", body, stats.to_markdown()),
166+
None => body.to_string(),
167+
}
168+
}
151169
///
152170
/// Times are `[seconds, nanoseconds]` arrays.
153171
fn compute_duration(span: &Value) -> f64 {

src/safeoutputs/add_pr_comment.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ pub struct AddPrCommentConfig {
150150
/// If empty, all valid statuses are allowed.
151151
#[serde(default, rename = "allowed-statuses")]
152152
pub allowed_statuses: Vec<String>,
153+
/// Whether to include agent execution stats in the output (default: true).
154+
#[serde(default = "default_include_stats", rename = "include-stats")]
155+
pub include_stats: bool,
153156
}
154157

155158
impl Default for AddPrCommentConfig {
@@ -158,6 +161,7 @@ impl Default for AddPrCommentConfig {
158161
comment_prefix: None,
159162
allowed_repositories: Vec::new(),
160163
allowed_statuses: Vec::new(),
164+
include_stats: true,
161165
}
162166
}
163167
}
@@ -286,6 +290,11 @@ impl Executor for AddPrCommentResult {
286290
Some(prefix) => format!("{}{}", prefix, self.content),
287291
None => self.content.clone(),
288292
};
293+
let comment_body = crate::agent_stats::append_stats_to_body(
294+
&comment_body,
295+
ctx,
296+
config.include_stats,
297+
);
289298

290299
// Build the API URL
291300
let url = format!(
@@ -578,6 +587,7 @@ allowed-statuses:
578587
comment_prefix: None,
579588
allowed_repositories: Vec::new(),
580589
allowed_statuses: vec!["Active".to_string(), "Closed".to_string()],
590+
include_stats: true,
581591
};
582592
// Test the exact comparison logic extracted from execute_impl
583593
let status = "active";
@@ -598,6 +608,7 @@ allowed-statuses:
598608
comment_prefix: None,
599609
allowed_repositories: Vec::new(),
600610
allowed_statuses: vec!["active".to_string()],
611+
include_stats: true,
601612
};
602613
let status = "Active";
603614
let matched = config
@@ -610,3 +621,7 @@ allowed-statuses:
610621
);
611622
}
612623
}
624+
625+
fn default_include_stats() -> bool {
626+
true
627+
}

src/safeoutputs/comment_on_work_item.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ pub struct CommentOnWorkItemConfig {
100100
/// Target scope — which work items can be commented on.
101101
/// `None` means no target was configured; execution must reject this.
102102
pub target: Option<CommentTarget>,
103+
104+
/// Whether to include agent execution stats in the comment (default: true).
105+
#[serde(default = "default_include_stats", rename = "include-stats")]
106+
pub include_stats: bool,
103107
}
104108

105109
/// Fetch a work item's area path from the ADO API
@@ -258,8 +262,13 @@ impl Executor for CommentOnWorkItemResult {
258262
);
259263
debug!("API URL: {}", url);
260264

265+
let body_with_stats = crate::agent_stats::append_stats_to_body(
266+
&self.body,
267+
ctx,
268+
config.include_stats,
269+
);
261270
let comment_body = serde_json::json!({
262-
"text": self.body,
271+
"text": body_with_stats,
263272
});
264273

265274
info!("Sending comment to work item #{}", self.work_item_id);
@@ -472,3 +481,7 @@ target: "*"
472481
assert!(config.target.is_some());
473482
}
474483
}
484+
485+
fn default_include_stats() -> bool {
486+
true
487+
}

src/safeoutputs/create_pr.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,10 @@ pub struct CreatePrConfig {
456456
/// so operators can manually create the PR. No work item is created automatically.
457457
#[serde(default = "default_true", rename = "fallback-record-branch")]
458458
pub fallback_record_branch: bool,
459+
460+
/// Whether to include agent execution stats in the PR description (default: true).
461+
#[serde(default = "default_true", rename = "include-stats")]
462+
pub include_stats: bool,
459463
}
460464

461465
fn default_target_branch() -> String {
@@ -500,6 +504,7 @@ impl Default for CreatePrConfig {
500504
labels: Vec::new(),
501505
work_items: Vec::new(),
502506
fallback_record_branch: true,
507+
include_stats: true,
503508
}
504509
}
505510
}
@@ -1255,8 +1260,13 @@ impl Executor for CreatePrResult {
12551260
}
12561261
debug!("Changes pushed successfully");
12571262

1258-
// Append provenance footer to description
1263+
// Append provenance footer and agent stats to description
12591264
let description_with_footer = format!("{}{}", self.description, generate_pr_footer());
1265+
let description_with_stats = crate::agent_stats::append_stats_to_body(
1266+
&description_with_footer,
1267+
ctx,
1268+
config.include_stats,
1269+
);
12601270

12611271
// Create the pull request via REST API
12621272
info!("Creating pull request");
@@ -1270,7 +1280,7 @@ impl Executor for CreatePrResult {
12701280
"sourceRefName": source_ref,
12711281
"targetRefName": target_ref,
12721282
"title": effective_title,
1273-
"description": description_with_footer,
1283+
"description": description_with_stats,
12741284
"isDraft": config.draft,
12751285
});
12761286

src/safeoutputs/create_wiki_page.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,11 @@ pub struct CreateWikiPageConfig {
132132
/// Default commit comment used when the agent does not supply one.
133133
#[serde(default)]
134134
pub comment: Option<String>,
135-
}
136135

137-
// ============================================================================
136+
/// Whether to include agent execution stats in the output (default: true).
137+
#[serde(default = "default_include_stats", rename = "include-stats")]
138+
pub include_stats: bool,
139+
}
138140
// Path helpers
139141
// ============================================================================
140142

@@ -321,7 +323,13 @@ impl Executor for CreateWikiPageResult {
321323
.header("Content-Type", "application/json")
322324
.header("If-Match", "")
323325
.basic_auth("", Some(token))
324-
.json(&serde_json::json!({ "content": self.content }))
326+
.json(&serde_json::json!({
327+
"content": crate::agent_stats::append_stats_to_body(
328+
&self.content,
329+
ctx,
330+
config.include_stats,
331+
)
332+
}))
325333
.send()
326334
.await
327335
.context("Failed to create wiki page")?;
@@ -896,3 +904,7 @@ wiki-name: "MyProject.wiki"
896904
assert_eq!(encoded, "MyProject.wiki");
897905
}
898906
}
907+
908+
fn default_include_stats() -> bool {
909+
true
910+
}

src/safeoutputs/create_work_item.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ pub struct CreateWorkItemConfig {
9898
#[serde(default, rename = "artifact-link")]
9999
#[sanitize_config(nested)]
100100
pub artifact_link: ArtifactLinkConfig,
101+
102+
/// Whether to include agent execution stats in the output (default: true).
103+
#[serde(default = "default_include_stats", rename = "include-stats")]
104+
pub include_stats: bool,
101105
}
102106

103107
/// Configuration for artifact links (repository linking for GitHub Copilot)
@@ -141,6 +145,7 @@ impl Default for CreateWorkItemConfig {
141145
tags: Vec::new(),
142146
custom_fields: std::collections::HashMap::new(),
143147
artifact_link: ArtifactLinkConfig::default(),
148+
include_stats: true,
144149
}
145150
}
146151
}
@@ -269,9 +274,14 @@ impl Executor for CreateWorkItemResult {
269274
debug!("API URL: {}", url);
270275

271276
// Build the patch document for work item creation
277+
let description_with_stats = crate::agent_stats::append_stats_to_body(
278+
&self.description,
279+
ctx,
280+
config.include_stats,
281+
);
272282
let mut patch_doc = vec![
273283
field_op("System.Title", &self.title),
274-
field_op("System.Description", &self.description),
284+
field_op("System.Description", &description_with_stats),
275285
// Tell Azure DevOps the description is markdown
276286
serde_json::json!({
277287
"op": "add",
@@ -524,3 +534,7 @@ tags:
524534
assert_eq!(config.tags, vec!["my-tag"]);
525535
}
526536
}
537+
538+
fn default_include_stats() -> bool {
539+
true
540+
}

src/safeoutputs/update_wiki_page.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ pub struct UpdateWikiPageConfig {
128128
/// Default commit comment used when the agent does not supply one.
129129
#[serde(default)]
130130
pub comment: Option<String>,
131+
132+
/// Whether to include agent execution stats in the output (default: true).
133+
#[serde(default = "default_include_stats", rename = "include-stats")]
134+
pub include_stats: bool,
131135
}
132136

133137
// ============================================================================
@@ -316,7 +320,13 @@ impl Executor for UpdateWikiPageResult {
316320
.query(&put_query)
317321
.header("Content-Type", "application/json")
318322
.basic_auth("", Some(token))
319-
.json(&serde_json::json!({ "content": self.content }));
323+
.json(&serde_json::json!({
324+
"content": crate::agent_stats::append_stats_to_body(
325+
&self.content,
326+
ctx,
327+
config.include_stats,
328+
)
329+
}));
320330

321331
// Provide the ETag for optimistic concurrency when updating an existing page.
322332
if let Some(etag) = &etag {
@@ -857,3 +867,7 @@ wiki-name: "MyProject.wiki"
857867
assert_eq!(encoded, "MyProject.wiki");
858868
}
859869
}
870+
871+
fn default_include_stats() -> bool {
872+
true
873+
}

0 commit comments

Comments
 (0)