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
40 changes: 39 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,28 @@ 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
enabled: true # Set to false to disable work-item filing entirely
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 +232,29 @@ 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
enabled: true # Set to false to disable work-item filing entirely
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
17 changes: 16 additions & 1 deletion src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,15 @@ 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"));
assert!(result.is_warning(), "noop without credentials should be a warning");
assert!(
result.message.contains("not set"),
"noop warning should mention missing config, got: {}",
result.message
);
}

#[tokio::test]
Expand All @@ -591,7 +598,15 @@ 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);
assert!(result.is_warning(), "missing-tool without credentials should be a warning");
assert!(
result.message.contains("not set"),
"missing-tool warning should mention missing config, got: {}",
result.message
);
}

#[tokio::test]
Expand Down
57 changes: 56 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 author of the agent source file for use as a
// fallback assignee in create-work-item.
ctx.agent_last_author = discover_last_author(&source).await;
if let Some(ref email) = ctx.agent_last_author {
log::info!("Agent source last author: {}", 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,54 @@ async fn build_execution_context(
ctx
}

/// Look up the email of the person who last authored 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.
///
/// Note: we pass the bare filename (not a full path) so git resolves it
/// relative to `cwd`. This means renames in history are not followed
/// (`--follow` has its own edge-cases with merge commits and is not worth
/// the complexity here).
async fn discover_last_author(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 {
// Sanitize the email: git committer values can contain
// arbitrary text (e.g. ADO pipeline log commands like
// ##vso[task.setvariable ...]). Apply the same config-level
// sanitization used for operator-supplied fields.
Some(crate::sanitize::sanitize_config(&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
1 change: 1 addition & 0 deletions src/safeoutputs/create_pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,7 @@ new file mode 100755
uploaded_pipeline_artifact_keys: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
)),
agent_last_author: None,
};
let outcome = result.execute_impl(&ctx).await.unwrap();
assert!(!outcome.success);
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 author fallback): {:?}", ctx.agent_last_author);

// 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_author.as_ref()) {
patch_doc.push(field_op("System.AssignedTo", assignee));
}
// Merge static config tags with validated agent-provided tags (dedup, case-insensitive)
Expand Down
Loading
Loading