Skip to content

Commit a480a28

Browse files
authored
Route always-on SafeOutputs through Stage 3 executors (#310)
1 parent 1dac259 commit a480a28

5 files changed

Lines changed: 137 additions & 37 deletions

File tree

src/execute.rs

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@ use std::collections::HashMap;
1111
use std::path::Path;
1212

1313
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
14-
use crate::sanitize::SanitizeContent;
1514
use crate::safeoutputs::{
1615
AddBuildTagResult, AddPrCommentResult, CreateBranchResult, CreateGitTagResult,
1716
CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, CommentOnWorkItemResult,
18-
ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, QueueBuildResult,
19-
ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult,
20-
ToolResult, UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult,
21-
UploadAttachmentResult,
17+
ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, MissingDataResult,
18+
MissingToolResult, NoopResult, QueueBuildResult, ReplyToPrCommentResult,
19+
ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult, ToolResult,
20+
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadAttachmentResult,
2221
};
2322

2423
// Re-export memory types for use by main.rs
@@ -256,10 +255,13 @@ pub async fn execute_safe_output(
256255

257256
debug!("Dispatching tool: {}", tool_name);
258257

259-
// Dispatch based on tool name. All standard tools go through `dispatch_tool` which
260-
// handles deserialization and sanitized execution uniformly. Special cases (informational
261-
// outputs and report-incomplete) are handled inline.
258+
// Dispatch based on tool name. All registered tools go through `dispatch_tool`,
259+
// which handles deserialization and sanitized execution uniformly.
262260
let result = if let Some(dispatched_result) = dispatch_executor_tools!(tool_name, entry, ctx, {
261+
"noop" => NoopResult,
262+
"missing-tool" => MissingToolResult,
263+
"missing-data" => MissingDataResult,
264+
"report-incomplete" => ReportIncompleteResult,
263265
"create-work-item" => CreateWorkItemResult,
264266
"comment-on-work-item" => CommentOnWorkItemResult,
265267
"update-work-item" => UpdateWorkItemResult,
@@ -280,25 +282,8 @@ pub async fn execute_safe_output(
280282
})? {
281283
dispatched_result
282284
} else {
283-
match tool_name {
284-
// Informational outputs — no side effects, always succeed
285-
"noop" | "missing-tool" | "missing-data" => {
286-
debug!("Skipping informational entry: {}", tool_name);
287-
ExecutionResult::success(format!("Skipped informational output: {}", tool_name))
288-
}
289-
// report-incomplete does not implement Executor; Stage 3 surfaces its reason as a failure
290-
"report-incomplete" => {
291-
let mut output: ReportIncompleteResult = serde_json::from_value(entry.clone())
292-
.map_err(|e| anyhow::anyhow!("Failed to parse report-incomplete: {}", e))?;
293-
output.sanitize_content_fields();
294-
debug!("report-incomplete: {}", output.reason);
295-
ExecutionResult::failure(format!("Agent reported task incomplete: {}", output.reason))
296-
}
297-
other => {
298-
error!("Unknown tool type: {}", other);
299-
bail!("Unknown tool type: {}. No executor registered.", other)
300-
}
301-
}
285+
error!("Unknown tool type: {}", tool_name);
286+
bail!("Unknown tool type: {}. No executor registered.", tool_name)
302287
};
303288

304289
Ok((tool_name.to_string(), result))
@@ -470,7 +455,7 @@ mod tests {
470455
let (tool_name, result) = result.unwrap();
471456
assert_eq!(tool_name, "noop");
472457
assert!(result.success);
473-
assert!(result.message.contains("Skipped"));
458+
assert!(result.message.contains("No operation"));
474459
}
475460

476461
#[tokio::test]
@@ -1050,9 +1035,9 @@ mod tests {
10501035
assert_eq!(results.len(), 2);
10511036
// create-work-item goes through Executor trait → dry-run intercepted
10521037
assert!(results[0].message.contains("[DRY-RUN]"));
1053-
// noop is handled inline, not through Executor → runs normally
1038+
// noop now also goes through Executor trait → dry-run intercepted
10541039
assert!(results[1].success);
1055-
assert!(results[1].message.contains("noop"));
1040+
assert!(results[1].message.contains("[DRY-RUN]"));
10561041
}
10571042

10581043
#[tokio::test]
@@ -1124,8 +1109,8 @@ mod tests {
11241109

11251110
#[tokio::test]
11261111
async fn test_dry_run_report_incomplete_still_fails() {
1127-
// report-incomplete is dispatched inline (not through Executor trait),
1128-
// so it still returns ExecutionResult::failure even in dry-run mode.
1112+
// report-incomplete uses an Executor override that still returns
1113+
// ExecutionResult::failure even in dry-run mode.
11291114
// This is correct: the agent declared it couldn't complete the task.
11301115
let entry = serde_json::json!({
11311116
"name": "report-incomplete",

src/safeoutputs/missing_data.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
use schemars::JsonSchema;
44
use serde::Deserialize;
55

6+
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
67
use crate::tool_result;
7-
use crate::safeoutputs::Validate;
8+
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
89

910
/// Parameters for reporting missing data
1011
#[derive(Deserialize, JsonSchema)]
@@ -34,6 +35,32 @@ tool_result! {
3435
}
3536
}
3637

38+
impl SanitizeContent for MissingDataResult {
39+
fn sanitize_content_fields(&mut self) {
40+
self.data_type = sanitize_text(&self.data_type);
41+
self.reason = sanitize_text(&self.reason);
42+
self.context = self.context.as_deref().map(sanitize_text);
43+
}
44+
}
45+
46+
#[async_trait::async_trait]
47+
impl Executor for MissingDataResult {
48+
fn dry_run_summary(&self) -> String {
49+
format!("report missing data '{}'", self.data_type)
50+
}
51+
52+
async fn execute_impl(&self, _: &ExecutionContext) -> anyhow::Result<ExecutionResult> {
53+
let mut message = format!(
54+
"Missing data reported: {} ({})",
55+
self.data_type, self.reason
56+
);
57+
if let Some(context) = &self.context {
58+
message.push_str(&format!(" [{context}]"));
59+
}
60+
Ok(ExecutionResult::success(message))
61+
}
62+
}
63+
3764
#[cfg(test)]
3865
mod tests {
3966
use super::*;

src/safeoutputs/missing_tool.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
use schemars::JsonSchema;
44
use serde::Deserialize;
55

6+
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
67
use crate::tool_result;
7-
use crate::safeoutputs::Validate;
8+
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
89

910
/// Parameters for reporting a missing tool
1011
#[derive(Deserialize, JsonSchema)]
@@ -29,6 +30,28 @@ tool_result! {
2930
}
3031
}
3132

33+
impl SanitizeContent for MissingToolResult {
34+
fn sanitize_content_fields(&mut self) {
35+
self.tool_name = sanitize_text(&self.tool_name);
36+
self.context = self.context.as_deref().map(sanitize_text);
37+
}
38+
}
39+
40+
#[async_trait::async_trait]
41+
impl Executor for MissingToolResult {
42+
fn dry_run_summary(&self) -> String {
43+
format!("report missing tool '{}'", self.tool_name)
44+
}
45+
46+
async fn execute_impl(&self, _: &ExecutionContext) -> anyhow::Result<ExecutionResult> {
47+
let message = match &self.context {
48+
Some(context) => format!("Missing tool reported: {} ({context})", self.tool_name),
49+
None => format!("Missing tool reported: {}", self.tool_name),
50+
};
51+
Ok(ExecutionResult::success(message))
52+
}
53+
}
54+
3255
#[cfg(test)]
3356
mod tests {
3457
use super::*;

src/safeoutputs/noop.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use schemars::JsonSchema;
22
use serde::Deserialize;
33

4+
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
45
use crate::tool_result;
5-
use crate::safeoutputs::Validate;
6+
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
67

78
/// Parameters for describing a no operation. Use this if there is no work to do.
89
#[derive(Deserialize, JsonSchema)]
@@ -24,6 +25,27 @@ tool_result! {
2425
}
2526
}
2627

28+
impl SanitizeContent for NoopResult {
29+
fn sanitize_content_fields(&mut self) {
30+
self.context = self.context.as_deref().map(sanitize_text);
31+
}
32+
}
33+
34+
#[async_trait::async_trait]
35+
impl Executor for NoopResult {
36+
fn dry_run_summary(&self) -> String {
37+
"noop".to_string()
38+
}
39+
40+
async fn execute_impl(&self, _: &ExecutionContext) -> anyhow::Result<ExecutionResult> {
41+
let message = match &self.context {
42+
Some(context) => format!("No operation needed: {context}"),
43+
None => "No operation needed".to_string(),
44+
};
45+
Ok(ExecutionResult::success(message))
46+
}
47+
}
48+
2749
#[cfg(test)]
2850
mod tests {
2951
use super::*;

src/safeoutputs/report_incomplete.rs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use serde::Deserialize;
55

66
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
77
use crate::tool_result;
8-
use crate::safeoutputs::Validate;
8+
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
99
use anyhow::ensure;
1010

1111
/// Parameters for reporting that a task could not be completed
@@ -49,10 +49,35 @@ impl SanitizeContent for ReportIncompleteResult {
4949
}
5050
}
5151

52+
#[async_trait::async_trait]
53+
impl Executor for ReportIncompleteResult {
54+
fn dry_run_summary(&self) -> String {
55+
"report task incomplete".to_string()
56+
}
57+
58+
// Intentionally bypass default dry-run behavior: this tool signals that the
59+
// task itself failed, so Stage 3 should preserve failure semantics in dry-run.
60+
async fn execute_sanitized(
61+
&mut self,
62+
ctx: &ExecutionContext,
63+
) -> anyhow::Result<ExecutionResult> {
64+
self.sanitize_content_fields();
65+
self.execute_impl(ctx).await
66+
}
67+
68+
async fn execute_impl(&self, _: &ExecutionContext) -> anyhow::Result<ExecutionResult> {
69+
let mut message = format!("Agent reported task incomplete: {}", self.reason);
70+
if let Some(context) = &self.context {
71+
message.push_str(&format!(" [{context}]"));
72+
}
73+
Ok(ExecutionResult::failure(message))
74+
}
75+
}
76+
5277
#[cfg(test)]
5378
mod tests {
5479
use super::*;
55-
use crate::safeoutputs::ToolResult;
80+
use crate::safeoutputs::{Executor, ToolResult};
5681

5782
#[test]
5883
fn test_result_has_correct_name() {
@@ -102,4 +127,22 @@ mod tests {
102127
assert!(json.contains(r#""name":"report-incomplete""#));
103128
assert!(json.contains(r#""reason":"API timed out after 30s""#));
104129
}
130+
131+
#[tokio::test]
132+
async fn test_execute_impl_includes_context_when_present() {
133+
let mut result: ReportIncompleteResult = ReportIncompleteParams {
134+
reason: "API timed out after 30s".to_string(),
135+
context: Some("tried 3 retries".to_string()),
136+
}
137+
.try_into()
138+
.unwrap();
139+
140+
let exec = result
141+
.execute_sanitized(&crate::safeoutputs::ExecutionContext::default())
142+
.await
143+
.unwrap();
144+
assert!(!exec.success);
145+
assert!(exec.message.contains("API timed out after 30s"));
146+
assert!(exec.message.contains("[tried 3 retries]"));
147+
}
105148
}

0 commit comments

Comments
 (0)