Skip to content
Draft
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
38 changes: 37 additions & 1 deletion docs/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Creates an Azure DevOps work item.
- `work-item-type` - Work item type (default: "Task")
- `area-path` - Area path for the work item
- `iteration-path` - Iteration path for the work item
- `assignee` - User to assign (email or display name)
- `assignee` - User to assign (email or display name). When omitted, falls back to the email of the last person who committed changes to the agent source markdown file (discovered via `git log` at Stage 3).
- `tags` - Static list of tags always applied to the work item (regardless of agent input)
- `allowed-tags` - Allowlist of tags the agent is permitted to use via the `tags` parameter. If empty, any agent-provided tags are accepted. Supports `*` wildcards anywhere in the pattern (e.g., `"agent-*"` matches `"agent-created"`; `"copilot:repo=org/project/*@main"` matches any repo name).
- `custom-fields` - Map of custom field reference names to values (e.g., `Custom.MyField: "value"`)
Expand Down Expand Up @@ -199,9 +199,27 @@ The `repository` value must be `"self"`, an alias from the `checkout:` list in t
### noop
Reports that no action was needed. Use this to provide visibility when analysis is complete but no changes or outputs are required.

The executor always files an Azure DevOps work item or appends a comment to an existing one. Override the defaults in front matter to customise the title, type, or area path. If ADO credentials are not available the tool succeeds with a warning.

**Agent parameters:**
- `context` - Optional context about why no action was taken

**Configuration options (front matter):**
```yaml
safe-outputs:
noop:
work-item: # Work item config — always active with these defaults
title: "[ado-aw] Agent reported no operation" # Default title (used to find existing items too)
work-item-type: Task # Work item type (default: "Task")
area-path: "MyProject\\MyTeam" # Optional — area path
iteration-path: "MyProject\\Sprint 1" # Optional — iteration path
tags: # Optional — tags to apply
- agent-noop
include-stats: true # Append agent stats to description/comment (default: true)
```

The executor searches for a non-closed work item with the same `title` in the project. If one is found, a comment is appended; otherwise a new work item is created.

### missing-data
Reports that data or information needed to complete the task is not available.

Expand All @@ -213,10 +231,28 @@ Reports that data or information needed to complete the task is not available.
### missing-tool
Reports that a tool or capability needed to complete the task is not available.

The executor always files an Azure DevOps work item or appends a comment to an existing one. Override the defaults in front matter to customise the title, type, or area path. If ADO credentials are not available the tool succeeds with a warning.

**Agent parameters:**
- `tool_name` - Name of the tool that was expected but not found
- `context` - Optional context about why the tool was needed

**Configuration options (front matter):**
```yaml
safe-outputs:
missing-tool:
work-item: # Work item config — always active with these defaults
title: "[ado-aw] Agent encountered missing tool" # Default title (used to find existing items too)
work-item-type: Task # Work item type (default: "Task")
area-path: "MyProject\\MyTeam" # Optional — area path
iteration-path: "MyProject\\Sprint 1" # Optional — iteration path
tags: # Optional — tags to apply
- agent-missing-tool
include-stats: true # Append agent stats to description/comment (default: true)
```

The executor searches for a non-closed work item with the same `title` in the project. If one is found, a comment is appended; otherwise a new work item is created.

### report-incomplete
Reports that a task could not be completed.

Expand Down
5 changes: 4 additions & 1 deletion src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,9 @@ mod tests {
assert!(result.is_ok());
let (tool_name, result) = result.unwrap();
assert_eq!(tool_name, "noop");
// noop always attempts to file a work item; without ADO credentials it
// returns a warning (success=true) rather than failing hard.
assert!(result.success);
assert!(result.message.contains("No operation"));
}

#[tokio::test]
Expand All @@ -591,6 +592,8 @@ mod tests {
assert!(result.is_ok());
let (tool_name, result) = result.unwrap();
assert_eq!(tool_name, "missing-tool");
// missing-tool always attempts to file a work item; without ADO credentials
// it returns a warning (success=true) rather than failing hard.
assert!(result.success);
}

Expand Down
48 changes: 47 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ async fn run_execute(
let tools = front_matter.tools.clone();

// Build execution context from front matter, CLI args, and environment
let ctx = build_execution_context(
let mut ctx = build_execution_context(
front_matter,
&safe_output_dir,
ado_org_url,
Expand All @@ -273,6 +273,13 @@ async fn run_execute(
)
.await;

// Discover the last committer of the agent source file for use as a
// fallback assignee in create-work-item.
ctx.agent_last_committer = discover_last_committer(&source).await;
if let Some(ref email) = ctx.agent_last_committer {
log::info!("Agent source last committer: {}", email);
}

let results = execute::execute_safe_outputs(&safe_output_dir, &ctx).await?;

// Process agent memory if cache-memory tool is enabled
Expand Down Expand Up @@ -349,6 +356,45 @@ async fn build_execution_context(
ctx
}

/// Look up the email of the person who last committed changes to `path`.
///
/// Runs `git log -1 --format='%ae' -- <path>` in the file's parent directory.
/// Returns `None` (with a debug log) when the lookup fails — e.g. shallow
/// clone with no relevant history, or git is unavailable.
async fn discover_last_committer(path: &Path) -> Option<String> {
let dir = path.parent().unwrap_or(Path::new("."));
let output = tokio::process::Command::new("git")
.args(["log", "-1", "--format=%ae", "--"])
.arg(path.file_name()?)
.current_dir(dir)
.output()
.await;

match output {
Ok(o) if o.status.success() => {
let email = String::from_utf8_lossy(&o.stdout).trim().to_string();
if email.is_empty() {
log::debug!("git log returned no committer for {}", path.display());
None
} else {
Some(email)
}
}
Ok(o) => {
log::debug!(
"git log failed for {}: {}",
path.display(),
String::from_utf8_lossy(&o.stderr).trim()
);
None
}
Err(e) => {
log::debug!("Failed to run git log for {}: {}", path.display(), e);
None
}
}
}

async fn process_cache_memory(
tools: Option<&compile::types::ToolsConfig>,
safe_output_dir: &PathBuf,
Expand Down
5 changes: 3 additions & 2 deletions src/safeoutputs/create_work_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ impl Executor for CreateWorkItemResult {
debug!("Work item type: {}", config.work_item_type);
debug!("Area path: {:?}", config.area_path);
debug!("Iteration path: {:?}", config.iteration_path);
debug!("Assignee: {:?}", config.assignee);
debug!("Assignee (config): {:?}", config.assignee);
debug!("Assignee (last committer fallback): {:?}", ctx.agent_last_committer);

// Validate agent-provided tags against allowed-tags (if configured)
if !self.tags.is_empty() && !config.allowed_tags.is_empty() {
Expand Down Expand Up @@ -357,7 +358,7 @@ impl Executor for CreateWorkItemResult {
if let Some(iteration_path) = &config.iteration_path {
patch_doc.push(field_op("System.IterationPath", iteration_path));
}
if let Some(assignee) = &config.assignee {
if let Some(assignee) = config.assignee.as_ref().or(ctx.agent_last_committer.as_ref()) {
patch_doc.push(field_op("System.AssignedTo", assignee));
}
// Merge static config tags with validated agent-provided tags (dedup, case-insensitive)
Expand Down
136 changes: 131 additions & 5 deletions src/safeoutputs/missing_tool.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
//! Missing tool reporting schemas

use schemars::JsonSchema;
use serde::Deserialize;
use serde::{Deserialize, Serialize};

use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
use crate::sanitize::{SanitizeConfig, SanitizeContent, sanitize as sanitize_text};
use crate::tool_result;
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate, WorkItemReportConfig, file_or_append_work_item};

/// Parameters for reporting a missing tool
#[derive(Deserialize, JsonSchema)]
Expand Down Expand Up @@ -37,18 +37,76 @@ impl SanitizeContent for MissingToolResult {
}
}

fn missing_tool_default_work_item_title() -> String {
"[ado-aw] Agent encountered missing tool".to_string()
}

fn missing_tool_default_work_item() -> WorkItemReportConfig {
WorkItemReportConfig {
title: Some(missing_tool_default_work_item_title()),
work_item_type: "Task".to_string(),
area_path: None,
iteration_path: None,
tags: Vec::new(),
include_stats: true,
}
}

/// Configuration for the missing-tool tool (specified in front matter).
///
/// The executor always files a new Azure DevOps work item or appends a comment to an
/// existing one with the same title. Override the defaults to customise the work item.
///
/// If ADO credentials are not available (e.g. the pipeline has no write service
/// connection), the executor succeeds with a warning rather than failing hard.
///
/// Example front matter:
/// ```yaml
/// safe-outputs:
/// missing-tool:
/// work-item:
/// title: "[ado-aw] Agent encountered missing tool"
/// work-item-type: Bug
/// area-path: "MyProject\\MyTeam"
/// tags:
/// - agent-missing-tool
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissingToolConfig {
/// Work item to file (or append to) when a tool is reported missing.
/// Defaults to a Task titled "[ado-aw] Agent encountered missing tool".
#[serde(default = "missing_tool_default_work_item", rename = "work-item")]
pub work_item: WorkItemReportConfig,
}

impl Default for MissingToolConfig {
fn default() -> Self {
Self {
work_item: missing_tool_default_work_item(),
}
}
}

impl SanitizeConfig for MissingToolConfig {
fn sanitize_config_fields(&mut self) {
self.work_item.sanitize_config_fields();
}
}

#[async_trait::async_trait]
impl Executor for MissingToolResult {
fn dry_run_summary(&self) -> String {
format!("report missing tool '{}'", self.tool_name)
}

async fn execute_impl(&self, _: &ExecutionContext) -> anyhow::Result<ExecutionResult> {
async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result<ExecutionResult> {
let message = match &self.context {
Some(context) => format!("Missing tool reported: {} ({context})", self.tool_name),
None => format!("Missing tool reported: {}", self.tool_name),
};
Ok(ExecutionResult::success(message))

let config: MissingToolConfig = ctx.get_tool_config("missing-tool");
file_or_append_work_item(&config.work_item, &missing_tool_default_work_item_title(), &message, ctx).await
}
}

Expand Down Expand Up @@ -102,4 +160,72 @@ mod tests {
let result: Result<MissingToolParams, _> = serde_json::from_str(json);
assert!(result.is_err());
}

#[test]
fn test_config_default_has_sensible_work_item() {
let config = MissingToolConfig::default();
assert_eq!(config.work_item.title.as_deref(), Some("[ado-aw] Agent encountered missing tool"));
assert_eq!(config.work_item.work_item_type, "Task");
assert!(config.work_item.area_path.is_none());
assert!(config.work_item.iteration_path.is_none());
assert!(config.work_item.tags.is_empty());
assert!(config.work_item.include_stats);
}

#[test]
fn test_config_deserializes_with_work_item_overrides() {
let yaml = r#"
work-item:
title: "Custom missing tool title"
work-item-type: Bug
area-path: "MyProject\\MyTeam"
tags:
- agent-missing-tool
"#;
let config: MissingToolConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.work_item.title.as_deref(), Some("Custom missing tool title"));
assert_eq!(config.work_item.work_item_type, "Bug");
assert_eq!(config.work_item.area_path.as_deref(), Some("MyProject\\MyTeam"));
assert_eq!(config.work_item.tags, vec!["agent-missing-tool"]);
}

#[test]
fn test_config_deserializes_empty_uses_defaults() {
let yaml = r#"{}"#;
let config: MissingToolConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.work_item.title.as_deref(), Some("[ado-aw] Agent encountered missing tool"));
assert_eq!(config.work_item.work_item_type, "Task");
}

#[test]
fn test_config_partial_work_item_preserves_overrides() {
let yaml = r#"
work-item:
work-item-type: Bug
tags:
- agent-missing-tool
"#;
let config: MissingToolConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.work_item.title.is_none(), "title should be None when omitted");
assert_eq!(config.work_item.work_item_type, "Bug");
assert_eq!(config.work_item.tags, vec!["agent-missing-tool"]);
}

#[tokio::test]
async fn test_execute_impl_without_ado_credentials_returns_warning() {
let result: MissingToolResult = MissingToolParams {
tool_name: "bash".to_string(),
context: Some("needed for script execution".to_string()),
}
.try_into()
.unwrap();

// Default ExecutionContext has no ADO credentials — should warn, not fail
let exec = result
.execute_impl(&crate::safeoutputs::ExecutionContext::default())
.await
.unwrap();
assert!(exec.success);
assert!(exec.is_warning());
}
}
Loading
Loading