Skip to main content

everruns_core/
progress_reporting.rs

1// Deterministic progress-reporting helpers for external handoff channels.
2//
3// Design Decision: The agent-facing tool is generic (`report_progress`) while
4// delivery remains channel-specific. Handoff sessions opt in via session tags
5// (`channel:reply_mode:report_progress_only` generically, or
6// `slack:reply_mode:report_progress_only` for legacy Slack), which lets ReasonAtom expose
7// the tool and prompt without leaking platform-specific behavior into the wider
8// runtime.
9//
10// Design Decision: Tags use a generic `channel:reply_mode:*` prefix that all
11// platforms share, plus legacy `slack:reply_mode:*` aliases for backward compat.
12// `session_uses_report_progress()` checks both prefixes.
13
14use crate::RuntimeAgent;
15use crate::app::SlackReplyMode;
16use crate::channel::ChannelReplyMode;
17use crate::tool_types::ToolDefinition;
18use crate::tools::{Tool, ToolExecutionResult};
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub const REPORT_PROGRESS_TOOL_NAME: &str = "report_progress";
24
25// Generic channel-agnostic tag constants
26pub const CHANNEL_REPLY_MODE_TAG_PREFIX: &str = "channel:reply_mode:";
27pub const CHANNEL_REPORT_PROGRESS_ONLY_TAG: &str = "channel:reply_mode:report_progress_only";
28
29// Legacy Slack-specific tag constants (kept for backward compat)
30pub const SLACK_REPLY_MODE_TAG_PREFIX: &str = "slack:reply_mode:";
31pub const SLACK_REPORT_PROGRESS_ONLY_TAG: &str = "slack:reply_mode:report_progress_only";
32const REPORT_PROGRESS_PROMPT_MARKER: &str = "# External Progress Reporting";
33const REPORT_PROGRESS_SYSTEM_PROMPT: &str = r#"# External Progress Reporting
34
35This session is attached to an external handoff thread.
36
37The external user does not see normal assistant messages. They only see updates sent through `report_progress`.
38
39Rules:
40- Use `report_progress` for meaningful user-facing updates only.
41- Use status `progress` for material milestones, `blocked` when waiting or stuck, and `completed` before the turn ends.
42- Keep summaries concise, deterministic, and focused on outcomes.
43- Do not mirror low-level tool chatter into `report_progress`."#;
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum ProgressReportStatus {
48    Progress,
49    Blocked,
50    Completed,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct ProgressReportPayload {
55    pub status: ProgressReportStatus,
56    pub summary: String,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub details: Vec<String>,
59}
60
61impl ProgressReportPayload {
62    fn validate(self) -> Result<Self, String> {
63        let summary = self.summary.trim();
64        if summary.is_empty() {
65            return Err("Missing required field: 'summary'".to_string());
66        }
67
68        let mut details = Vec::with_capacity(self.details.len());
69        for (idx, detail) in self.details.iter().enumerate() {
70            let trimmed = detail.trim();
71            if trimmed.is_empty() {
72                return Err(format!("details[{}] must not be empty", idx));
73            }
74            details.push(trimmed.to_string());
75        }
76
77        Ok(Self {
78            status: self.status,
79            summary: summary.to_string(),
80            details,
81        })
82    }
83}
84
85/// Check if a session uses report_progress mode.
86/// Checks both the generic `channel:reply_mode:*` and legacy `slack:reply_mode:*` tags.
87pub fn session_uses_report_progress(tags: &[String]) -> bool {
88    tags.iter()
89        .any(|tag| tag == CHANNEL_REPORT_PROGRESS_ONLY_TAG || tag == SLACK_REPORT_PROGRESS_ONLY_TAG)
90}
91
92/// Sync channel-agnostic reply mode tags on a session.
93/// Sets the generic `channel:reply_mode:*` tag used by all platforms.
94pub fn sync_channel_reply_mode_tags(tags: &mut Vec<String>, reply_mode: ChannelReplyMode) {
95    tags.retain(|tag| !tag.starts_with(CHANNEL_REPLY_MODE_TAG_PREFIX));
96    if reply_mode == ChannelReplyMode::ReportProgressOnly {
97        tags.push(CHANNEL_REPORT_PROGRESS_ONLY_TAG.to_string());
98    }
99}
100
101/// Sync Slack-specific reply mode tags (legacy — delegates to channel-agnostic version
102/// and also sets the `slack:reply_mode:*` tag for backward compat with existing sessions).
103pub fn sync_slack_reply_mode_tags(tags: &mut Vec<String>, reply_mode: SlackReplyMode) {
104    tags.retain(|tag| !tag.starts_with(SLACK_REPLY_MODE_TAG_PREFIX));
105    if reply_mode == SlackReplyMode::ReportProgressOnly {
106        tags.push(SLACK_REPORT_PROGRESS_ONLY_TAG.to_string());
107    }
108    // Also set the generic tag so new code paths work
109    sync_channel_reply_mode_tags(tags, reply_mode.into());
110}
111
112pub fn report_progress_tool_definition() -> ToolDefinition {
113    ReportProgressTool.to_definition()
114}
115
116pub fn apply_report_progress_mode(mut runtime_agent: RuntimeAgent) -> RuntimeAgent {
117    if !runtime_agent
118        .tools
119        .iter()
120        .any(|tool| tool.name() == REPORT_PROGRESS_TOOL_NAME)
121    {
122        runtime_agent.tools.push(report_progress_tool_definition());
123    }
124
125    if !runtime_agent
126        .system_prompt
127        .contains(REPORT_PROGRESS_PROMPT_MARKER)
128    {
129        runtime_agent.system_prompt = if runtime_agent.system_prompt.is_empty() {
130            REPORT_PROGRESS_SYSTEM_PROMPT.to_string()
131        } else {
132            format!(
133                "{}\n\n{}",
134                REPORT_PROGRESS_SYSTEM_PROMPT, runtime_agent.system_prompt
135            )
136        };
137    }
138
139    runtime_agent
140}
141
142/// Format a progress report as plain text (platform-agnostic default).
143/// Platform adapters can override via ChannelDeliveryAdapter::format_progress_report().
144pub fn format_progress_report(report: &ProgressReportPayload) -> String {
145    let heading = match report.status {
146        ProgressReportStatus::Progress => "Update",
147        ProgressReportStatus::Blocked => "Blocked",
148        ProgressReportStatus::Completed => "Done",
149    };
150
151    let mut lines = vec![format!("{}: {}", heading, report.summary)];
152    for detail in &report.details {
153        lines.push(format!("- {}", detail));
154    }
155    lines.join("\n")
156}
157
158/// Format a progress report for Slack (uses same format today, kept for compat).
159pub fn format_progress_report_for_slack(report: &ProgressReportPayload) -> String {
160    let heading = match report.status {
161        ProgressReportStatus::Progress => "Update",
162        ProgressReportStatus::Blocked => "Blocked",
163        ProgressReportStatus::Completed => "Done",
164    };
165
166    let mut lines = vec![format!("{}: {}", heading, report.summary)];
167    for detail in &report.details {
168        lines.push(format!("- {}", detail));
169    }
170    lines.join("\n")
171}
172
173pub struct ReportProgressTool;
174
175#[async_trait]
176impl Tool for ReportProgressTool {
177    fn name(&self) -> &str {
178        REPORT_PROGRESS_TOOL_NAME
179    }
180
181    fn display_name(&self) -> Option<&str> {
182        Some("Report Progress")
183    }
184
185    fn description(&self) -> &str {
186        "Send a deterministic, user-facing progress update for an external handoff thread. Use status 'completed' before ending the turn."
187    }
188
189    fn parameters_schema(&self) -> Value {
190        serde_json::json!({
191            "type": "object",
192            "properties": {
193                "status": {
194                    "type": "string",
195                    "enum": ["progress", "blocked", "completed"],
196                    "description": "Kind of progress update being reported."
197                },
198                "summary": {
199                    "type": "string",
200                    "minLength": 1,
201                    "description": "Short user-facing summary of the current milestone or outcome."
202                },
203                "details": {
204                    "type": "array",
205                    "description": "Optional short bullet points with concrete outcomes or blockers.",
206                    "items": {
207                        "type": "string",
208                        "minLength": 1
209                    }
210                }
211            },
212            "required": ["status", "summary"],
213            "additionalProperties": false
214        })
215    }
216
217    async fn execute(&self, arguments: Value) -> ToolExecutionResult {
218        let payload = match serde_json::from_value::<ProgressReportPayload>(arguments) {
219            Ok(payload) => payload,
220            Err(error) => {
221                return ToolExecutionResult::tool_error(format!(
222                    "Invalid report_progress arguments: {}",
223                    error
224                ));
225            }
226        };
227
228        match payload.validate() {
229            Ok(validated) => ToolExecutionResult::success(serde_json::to_value(validated).unwrap()),
230            Err(error) => ToolExecutionResult::tool_error(error),
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[tokio::test]
240    async fn test_report_progress_tool_success() {
241        let tool = ReportProgressTool;
242        let result = tool
243            .execute(serde_json::json!({
244                "status": "completed",
245                "summary": "Fixed the failing Slack session routing",
246                "details": ["added reply-mode tags", "wired deterministic progress delivery"]
247            }))
248            .await;
249
250        match result {
251            ToolExecutionResult::Success(value) => {
252                let payload: ProgressReportPayload = serde_json::from_value(value).unwrap();
253                assert_eq!(payload.status, ProgressReportStatus::Completed);
254                assert_eq!(payload.summary, "Fixed the failing Slack session routing");
255                assert_eq!(payload.details.len(), 2);
256            }
257            other => panic!("Expected success, got {:?}", other),
258        }
259    }
260
261    #[tokio::test]
262    async fn test_report_progress_tool_rejects_empty_summary() {
263        let tool = ReportProgressTool;
264        let result = tool
265            .execute(serde_json::json!({
266                "status": "progress",
267                "summary": "   "
268            }))
269            .await;
270
271        match result {
272            ToolExecutionResult::ToolError(message) => {
273                assert_eq!(message, "Missing required field: 'summary'");
274            }
275            other => panic!("Expected tool error, got {:?}", other),
276        }
277    }
278
279    #[test]
280    fn test_sync_slack_reply_mode_tags() {
281        let mut tags = vec![
282            "slack:app:app_123".to_string(),
283            "slack:thread:123.456".to_string(),
284            "slack:reply_mode:all_messages".to_string(),
285        ];
286
287        sync_slack_reply_mode_tags(&mut tags, SlackReplyMode::ReportProgressOnly);
288
289        // Should have both legacy slack: and generic channel: tags
290        assert!(tags.iter().any(|tag| tag == SLACK_REPORT_PROGRESS_ONLY_TAG));
291        assert!(
292            tags.iter()
293                .any(|tag| tag == CHANNEL_REPORT_PROGRESS_ONLY_TAG)
294        );
295        assert_eq!(
296            tags.iter()
297                .filter(|tag| tag.starts_with(SLACK_REPLY_MODE_TAG_PREFIX))
298                .count(),
299            1
300        );
301    }
302
303    #[test]
304    fn test_sync_channel_reply_mode_tags() {
305        let mut tags = vec!["other:tag".to_string()];
306
307        sync_channel_reply_mode_tags(&mut tags, ChannelReplyMode::ReportProgressOnly);
308        assert!(
309            tags.iter()
310                .any(|tag| tag == CHANNEL_REPORT_PROGRESS_ONLY_TAG)
311        );
312
313        sync_channel_reply_mode_tags(&mut tags, ChannelReplyMode::AllMessages);
314        assert!(
315            !tags
316                .iter()
317                .any(|tag| tag.starts_with(CHANNEL_REPLY_MODE_TAG_PREFIX))
318        );
319    }
320
321    #[test]
322    fn test_session_uses_report_progress_generic_tag() {
323        let tags = vec![CHANNEL_REPORT_PROGRESS_ONLY_TAG.to_string()];
324        assert!(session_uses_report_progress(&tags));
325    }
326
327    #[test]
328    fn test_session_uses_report_progress_legacy_tag() {
329        let tags = vec![SLACK_REPORT_PROGRESS_ONLY_TAG.to_string()];
330        assert!(session_uses_report_progress(&tags));
331    }
332
333    #[test]
334    fn test_apply_report_progress_mode_adds_tool_and_prompt_once() {
335        let runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.4");
336
337        let runtime_agent = apply_report_progress_mode(runtime_agent);
338        let runtime_agent = apply_report_progress_mode(runtime_agent);
339
340        assert_eq!(
341            runtime_agent
342                .tools
343                .iter()
344                .filter(|tool| tool.name() == REPORT_PROGRESS_TOOL_NAME)
345                .count(),
346            1
347        );
348        assert_eq!(
349            runtime_agent
350                .system_prompt
351                .matches(REPORT_PROGRESS_PROMPT_MARKER)
352                .count(),
353            1
354        );
355    }
356
357    #[test]
358    fn test_format_progress_report_generic() {
359        let text = format_progress_report(&ProgressReportPayload {
360            status: ProgressReportStatus::Progress,
361            summary: "Deploying to staging".to_string(),
362            details: vec!["built image".to_string(), "pushing to registry".to_string()],
363        });
364        assert_eq!(
365            text,
366            "Update: Deploying to staging\n- built image\n- pushing to registry"
367        );
368
369        let text_no_details = format_progress_report(&ProgressReportPayload {
370            status: ProgressReportStatus::Completed,
371            summary: "All done".to_string(),
372            details: vec![],
373        });
374        assert_eq!(text_no_details, "Done: All done");
375    }
376
377    #[test]
378    fn test_format_progress_report_for_slack() {
379        let text = format_progress_report_for_slack(&ProgressReportPayload {
380            status: ProgressReportStatus::Blocked,
381            summary: "Waiting on credentials".to_string(),
382            details: vec!["need Slack bot token".to_string()],
383        });
384
385        assert_eq!(
386            text,
387            "Blocked: Waiting on credentials\n- need Slack bot token"
388        );
389    }
390}