Skip to main content

task_graph_mcp/prompts/
mod.rs

1//! Transition prompts system.
2//!
3//! Loads and delivers prompts when tasks transition between states/phases.
4//! Prompts are defined in `workflows.yaml` with the following structure:
5//!
6//! - State prompts: `states.<state>.prompts.enter` / `states.<state>.prompts.exit`
7//! - Phase prompts: `phases.<phase>.prompts.enter` / `phases.<phase>.prompts.exit`
8//! - Combo prompts: `combos.<state>+<phase>.enter` / `combos.<state>+<phase>.exit`
9//!
10//! Trigger naming convention:
11//! - `enter~{status}` - entering a status (any phase)
12//! - `exit~{status}` - exiting a status (any phase)
13//! - `enter%{phase}` - entering a phase (any status)
14//! - `exit%{phase}` - exiting a phase (any status)
15//! - `enter~{status}%{phase}` - entering specific status+phase combo
16//! - `exit~{status}%{phase}` - exiting specific status+phase combo
17//!
18//! Template variables are expanded in prompts:
19//! - `{{valid_exits}}` - valid states to transition to from current state
20//! - `{{current_phase}}` - current phase if set
21//! - `{{valid_phases}}` - list of valid phases that can be set
22//! - `{{current_status}}` - current status name
23
24use crate::config::workflows::WorkflowsConfig;
25use crate::config::{PhasesConfig, StatesConfig};
26
27/// Context for expanding template variables in prompts.
28///
29/// Provides both workflow context (status, phase, valid transitions) and
30/// situational context (task metadata, agent identity) for rich prompt
31/// template expansion.
32#[derive(Debug, Clone)]
33pub struct PromptContext<'a> {
34    /// Current status of the task
35    pub status: &'a str,
36    /// Current phase of the task (if any)
37    pub phase: Option<&'a str>,
38    /// States configuration for looking up valid transitions
39    pub states_config: &'a StatesConfig,
40    /// Phases configuration for listing valid phases
41    pub phases_config: &'a PhasesConfig,
42    /// Task ID (if available)
43    pub task_id: Option<&'a str>,
44    /// Task title (if available)
45    pub task_title: Option<&'a str>,
46    /// Task priority (if available)
47    pub task_priority: Option<i32>,
48    /// Task tags (if available)
49    pub task_tags: Option<&'a [String]>,
50    /// Agent/worker ID (if available)
51    pub agent_id: Option<&'a str>,
52    /// Agent's matched role name (if available)
53    pub agent_role: Option<&'a str>,
54    /// Agent's tags (if available)
55    pub agent_tags: Option<&'a [String]>,
56}
57
58impl<'a> PromptContext<'a> {
59    /// Create a new prompt context with workflow information only.
60    ///
61    /// For backwards compatibility -- callers that don't have task/agent
62    /// info can use this constructor. Use `with_task()` and `with_agent()`
63    /// to add situational context.
64    pub fn new(
65        status: &'a str,
66        phase: Option<&'a str>,
67        states_config: &'a StatesConfig,
68        phases_config: &'a PhasesConfig,
69    ) -> Self {
70        Self {
71            status,
72            phase,
73            states_config,
74            phases_config,
75            task_id: None,
76            task_title: None,
77            task_priority: None,
78            task_tags: None,
79            agent_id: None,
80            agent_role: None,
81            agent_tags: None,
82        }
83    }
84
85    /// Add task context to the prompt context.
86    pub fn with_task(
87        mut self,
88        id: &'a str,
89        title: &'a str,
90        priority: i32,
91        tags: &'a [String],
92    ) -> Self {
93        self.task_id = Some(id);
94        self.task_title = Some(title);
95        self.task_priority = Some(priority);
96        self.task_tags = Some(tags);
97        self
98    }
99
100    /// Add agent context to the prompt context.
101    pub fn with_agent(
102        mut self,
103        agent_id: &'a str,
104        role: Option<&'a str>,
105        tags: &'a [String],
106    ) -> Self {
107        self.agent_id = Some(agent_id);
108        self.agent_role = role;
109        self.agent_tags = Some(tags);
110        self
111    }
112}
113
114/// Load a prompt by trigger name from WorkflowsConfig.
115///
116/// Returns None if no prompt exists for this trigger.
117pub fn load_prompt(trigger: &str, workflows: &WorkflowsConfig) -> Option<String> {
118    workflows.get_prompt(trigger).map(|s| s.to_string())
119}
120
121/// Expand template variables in a prompt string.
122///
123/// Supported variables:
124///
125/// **Workflow context:**
126/// - `{{valid_exits}}` - markdown list of valid exit states
127/// - `{{current_phase}}` - current phase or "(none)" if not set
128/// - `{{valid_phases}}` - comma-separated list of valid phases
129/// - `{{current_status}}` - current status name
130///
131/// **Task context** (available when task info is provided):
132/// - `{{task_id}}` - task identifier
133/// - `{{task_title}}` - task title
134/// - `{{task_priority}}` - task priority (0-10)
135/// - `{{task_tags}}` - comma-separated task tags
136///
137/// **Agent context** (available when agent info is provided):
138/// - `{{agent_id}}` - agent/worker identifier
139/// - `{{agent_role}}` - matched role name or "(none)"
140/// - `{{agent_tags}}` - comma-separated agent tags
141pub fn expand_prompt(content: &str, ctx: &PromptContext) -> String {
142    let mut result = content.to_string();
143
144    // === Workflow context ===
145
146    // Expand {{current_status}}
147    result = result.replace("{{current_status}}", ctx.status);
148
149    // Expand {{valid_exits}}
150    if result.contains("{{valid_exits}}") {
151        let exits = ctx.states_config.get_exits(ctx.status);
152        let exits_md = if exits.is_empty() {
153            "- _(no transitions available - terminal state)_".to_string()
154        } else {
155            exits
156                .iter()
157                .map(|s| format!("- `{}`", s))
158                .collect::<Vec<_>>()
159                .join("\n")
160        };
161        result = result.replace("{{valid_exits}}", &exits_md);
162    }
163
164    // Expand {{current_phase}}
165    if result.contains("{{current_phase}}") {
166        let phase_str = ctx
167            .phase
168            .map(|p| format!("`{}`", p))
169            .unwrap_or_else(|| "_(none)_".to_string());
170        result = result.replace("{{current_phase}}", &phase_str);
171    }
172
173    // Expand {{valid_phases}}
174    if result.contains("{{valid_phases}}") {
175        let mut phases: Vec<&str> = ctx.phases_config.phase_names();
176        phases.sort();
177        let phases_str = phases.join(", ");
178        result = result.replace("{{valid_phases}}", &phases_str);
179    }
180
181    // === Task context ===
182
183    if result.contains("{{task_id}}") {
184        let val = ctx.task_id.unwrap_or("_unknown_");
185        result = result.replace("{{task_id}}", val);
186    }
187
188    if result.contains("{{task_title}}") {
189        let val = ctx.task_title.unwrap_or("_untitled_");
190        result = result.replace("{{task_title}}", val);
191    }
192
193    if result.contains("{{task_priority}}") {
194        let val = ctx
195            .task_priority
196            .map(|p| p.to_string())
197            .unwrap_or_else(|| "_unset_".to_string());
198        result = result.replace("{{task_priority}}", &val);
199    }
200
201    if result.contains("{{task_tags}}") {
202        let val = ctx
203            .task_tags
204            .map(|tags| {
205                if tags.is_empty() {
206                    "_(none)_".to_string()
207                } else {
208                    tags.join(", ")
209                }
210            })
211            .unwrap_or_else(|| "_(none)_".to_string());
212        result = result.replace("{{task_tags}}", &val);
213    }
214
215    // === Agent context ===
216
217    if result.contains("{{agent_id}}") {
218        let val = ctx.agent_id.unwrap_or("_unknown_");
219        result = result.replace("{{agent_id}}", val);
220    }
221
222    if result.contains("{{agent_role}}") {
223        let val = ctx
224            .agent_role
225            .map(|r| format!("`{}`", r))
226            .unwrap_or_else(|| "_(none)_".to_string());
227        result = result.replace("{{agent_role}}", &val);
228    }
229
230    if result.contains("{{agent_tags}}") {
231        let val = ctx
232            .agent_tags
233            .map(|tags| {
234                if tags.is_empty() {
235                    "_(none)_".to_string()
236                } else {
237                    tags.join(", ")
238                }
239            })
240            .unwrap_or_else(|| "_(none)_".to_string());
241        result = result.replace("{{agent_tags}}", &val);
242    }
243
244    result
245}
246
247/// Get the list of triggers that should fire for a state transition.
248///
249/// Order: exits (specific → general), then enters (general → specific)
250pub fn get_transition_triggers(
251    old_status: &str,
252    old_phase: Option<&str>,
253    new_status: &str,
254    new_phase: Option<&str>,
255) -> Vec<String> {
256    let mut triggers = Vec::new();
257
258    let status_changed = old_status != new_status;
259    let phase_changed = old_phase != new_phase;
260
261    // === EXITS (specific → general) ===
262
263    // Exit combo (if either changed and had a phase)
264    if (status_changed || phase_changed)
265        && old_phase.is_some()
266        && let Some(op) = old_phase
267    {
268        triggers.push(format!("exit~{}%{}", old_status, op));
269    }
270
271    // Exit phase (if phase changed)
272    if phase_changed && let Some(op) = old_phase {
273        triggers.push(format!("exit%{}", op));
274    }
275
276    // Exit status (if status changed)
277    if status_changed {
278        triggers.push(format!("exit~{}", old_status));
279    }
280
281    // === ENTERS (general → specific) ===
282
283    // Enter status (if status changed)
284    if status_changed {
285        triggers.push(format!("enter~{}", new_status));
286    }
287
288    // Enter phase (if phase changed)
289    if phase_changed && let Some(np) = new_phase {
290        triggers.push(format!("enter%{}", np));
291    }
292
293    // Enter combo (if either changed and has a phase)
294    if (status_changed || phase_changed)
295        && new_phase.is_some()
296        && let Some(np) = new_phase
297    {
298        triggers.push(format!("enter~{}%{}", new_status, np));
299    }
300
301    triggers
302}
303
304/// Get all prompts that should be delivered for a state transition.
305///
306/// Returns a vector of prompt strings (caller concatenates as needed).
307/// This version does NOT expand template variables - use `get_transition_prompts_with_context` for that.
308pub fn get_transition_prompts(
309    old_status: &str,
310    old_phase: Option<&str>,
311    new_status: &str,
312    new_phase: Option<&str>,
313    workflows: &WorkflowsConfig,
314) -> Vec<String> {
315    get_transition_triggers(old_status, old_phase, new_status, new_phase)
316        .iter()
317        .filter_map(|trigger| load_prompt(trigger, workflows))
318        .collect()
319}
320
321/// Get all prompts that should be delivered for a state transition, with template expansion.
322///
323/// Returns a vector of prompt strings with template variables expanded.
324pub fn get_transition_prompts_with_context(
325    old_status: &str,
326    old_phase: Option<&str>,
327    new_status: &str,
328    new_phase: Option<&str>,
329    workflows: &WorkflowsConfig,
330    ctx: &PromptContext,
331) -> Vec<String> {
332    get_transition_triggers(old_status, old_phase, new_status, new_phase)
333        .iter()
334        .filter_map(|trigger| load_prompt(trigger, workflows))
335        .map(|content| expand_prompt(&content, ctx))
336        .collect()
337}
338
339/// List all available prompt triggers from the workflows config.
340pub fn list_available_prompts(workflows: &WorkflowsConfig) -> Vec<String> {
341    workflows.list_prompt_triggers()
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_triggers_status_change_only() {
350        let triggers = get_transition_triggers("pending", None, "working", None);
351        assert_eq!(triggers, vec!["exit~pending", "enter~working"]);
352    }
353
354    #[test]
355    fn test_triggers_phase_change_only() {
356        let triggers =
357            get_transition_triggers("working", Some("diagnose"), "working", Some("review"));
358        assert_eq!(
359            triggers,
360            vec![
361                "exit~working%diagnose",
362                "exit%diagnose",
363                "enter%review",
364                "enter~working%review"
365            ]
366        );
367    }
368
369    #[test]
370    fn test_triggers_both_change() {
371        let triggers =
372            get_transition_triggers("working", Some("diagnose"), "finished", Some("review"));
373        assert_eq!(
374            triggers,
375            vec![
376                "exit~working%diagnose",
377                "exit%diagnose",
378                "exit~working",
379                "enter~finished",
380                "enter%review",
381                "enter~finished%review"
382            ]
383        );
384    }
385
386    #[test]
387    fn test_triggers_enter_phase_from_none() {
388        let triggers = get_transition_triggers("working", None, "working", Some("diagnose"));
389        assert_eq!(triggers, vec!["enter%diagnose", "enter~working%diagnose"]);
390    }
391
392    #[test]
393    fn test_triggers_exit_phase_to_none() {
394        let triggers = get_transition_triggers("working", Some("diagnose"), "working", None);
395        assert_eq!(triggers, vec!["exit~working%diagnose", "exit%diagnose"]);
396    }
397
398    #[test]
399    fn test_no_triggers_when_unchanged() {
400        let triggers =
401            get_transition_triggers("working", Some("diagnose"), "working", Some("diagnose"));
402        assert!(triggers.is_empty());
403    }
404
405    #[test]
406    fn test_expand_prompt_valid_exits() {
407        let states_config = StatesConfig::default();
408        let phases_config = PhasesConfig::default();
409        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
410
411        let template = "From {{current_status}} you can go to:\n{{valid_exits}}";
412        let result = expand_prompt(template, &ctx);
413
414        assert!(result.contains("From working you can go to:"));
415        assert!(result.contains("`completed`"));
416        assert!(result.contains("`failed`"));
417        assert!(result.contains("`pending`"));
418    }
419
420    #[test]
421    fn test_expand_prompt_current_phase() {
422        let states_config = StatesConfig::default();
423        let phases_config = PhasesConfig::default();
424
425        // With a phase
426        let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config);
427        let template = "Phase: {{current_phase}}";
428        let result = expand_prompt(template, &ctx);
429        assert_eq!(result, "Phase: `implement`");
430
431        // Without a phase
432        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
433        let result = expand_prompt(template, &ctx);
434        assert_eq!(result, "Phase: _(none)_");
435    }
436
437    #[test]
438    fn test_expand_prompt_valid_phases() {
439        let states_config = StatesConfig::default();
440        let phases_config = PhasesConfig::default();
441        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
442
443        let template = "Phases: {{valid_phases}}";
444        let result = expand_prompt(template, &ctx);
445
446        // Should contain various default phases
447        assert!(result.contains("implement"));
448        assert!(result.contains("test"));
449        assert!(result.contains("review"));
450    }
451
452    #[test]
453    fn test_expand_prompt_terminal_state() {
454        let states_config = StatesConfig::default();
455        let phases_config = PhasesConfig::default();
456        let ctx = PromptContext::new("cancelled", None, &states_config, &phases_config);
457
458        let template = "Exits: {{valid_exits}}";
459        let result = expand_prompt(template, &ctx);
460
461        // Cancelled is a terminal state (no exits)
462        assert!(result.contains("no transitions available"));
463    }
464
465    #[test]
466    fn test_load_prompt_from_workflows() {
467        let workflows = WorkflowsConfig::default();
468
469        // Should find enter~working
470        let prompt = load_prompt("enter~working", &workflows);
471        assert!(prompt.is_some());
472        assert!(prompt.unwrap().contains("actively working"));
473
474        // Should find enter%implement
475        let prompt = load_prompt("enter%implement", &workflows);
476        assert!(prompt.is_some());
477        assert!(prompt.unwrap().contains("Implementation"));
478    }
479
480    #[test]
481    fn test_get_transition_prompts() {
482        let workflows = WorkflowsConfig::default();
483
484        let prompts = get_transition_prompts("pending", None, "working", None, &workflows);
485
486        // Should have at least the enter~working prompt
487        assert!(!prompts.is_empty());
488        assert!(prompts.iter().any(|p| p.contains("actively working")));
489    }
490
491    #[test]
492    fn test_list_available_prompts() {
493        let workflows = WorkflowsConfig::default();
494        let prompts = list_available_prompts(&workflows);
495
496        assert!(prompts.contains(&"enter~working".to_string()));
497        assert!(prompts.contains(&"exit~working".to_string()));
498        assert!(prompts.contains(&"enter%implement".to_string()));
499    }
500
501    // === Tests for context-sensitive template variables ===
502
503    #[test]
504    fn test_expand_prompt_task_context() {
505        let states_config = StatesConfig::default();
506        let phases_config = PhasesConfig::default();
507        let tags = vec!["backend".to_string(), "api".to_string()];
508        let ctx = PromptContext::new("working", Some("implement"), &states_config, &phases_config)
509            .with_task("fix-auth-bug", "Fix authentication bypass", 8, &tags);
510
511        let template = "Working on {{task_id}}: {{task_title}} (priority {{task_priority}}, tags: {{task_tags}})";
512        let result = expand_prompt(template, &ctx);
513
514        assert_eq!(
515            result,
516            "Working on fix-auth-bug: Fix authentication bypass (priority 8, tags: backend, api)"
517        );
518    }
519
520    #[test]
521    fn test_expand_prompt_task_context_empty_tags() {
522        let states_config = StatesConfig::default();
523        let phases_config = PhasesConfig::default();
524        let tags: Vec<String> = vec![];
525        let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_task(
526            "my-task",
527            "Some task",
528            5,
529            &tags,
530        );
531
532        let template = "Tags: {{task_tags}}";
533        let result = expand_prompt(template, &ctx);
534
535        assert_eq!(result, "Tags: _(none)_");
536    }
537
538    #[test]
539    fn test_expand_prompt_task_context_missing() {
540        let states_config = StatesConfig::default();
541        let phases_config = PhasesConfig::default();
542        // No with_task() call - should use fallbacks
543        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
544
545        let template = "Task: {{task_id}} / {{task_title}} / {{task_priority}} / {{task_tags}}";
546        let result = expand_prompt(template, &ctx);
547
548        assert_eq!(result, "Task: _unknown_ / _untitled_ / _unset_ / _(none)_");
549    }
550
551    #[test]
552    fn test_expand_prompt_agent_context() {
553        let states_config = StatesConfig::default();
554        let phases_config = PhasesConfig::default();
555        let agent_tags = vec!["worker".to_string(), "implement".to_string()];
556        let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_agent(
557            "worker-21",
558            Some("worker"),
559            &agent_tags,
560        );
561
562        let template = "Agent {{agent_id}} (role: {{agent_role}}, tags: {{agent_tags}})";
563        let result = expand_prompt(template, &ctx);
564
565        assert_eq!(
566            result,
567            "Agent worker-21 (role: `worker`, tags: worker, implement)"
568        );
569    }
570
571    #[test]
572    fn test_expand_prompt_agent_context_no_role() {
573        let states_config = StatesConfig::default();
574        let phases_config = PhasesConfig::default();
575        let agent_tags = vec!["generic".to_string()];
576        let ctx = PromptContext::new("working", None, &states_config, &phases_config).with_agent(
577            "worker-5",
578            None,
579            &agent_tags,
580        );
581
582        let template = "Role: {{agent_role}}";
583        let result = expand_prompt(template, &ctx);
584
585        assert_eq!(result, "Role: _(none)_");
586    }
587
588    #[test]
589    fn test_expand_prompt_agent_context_missing() {
590        let states_config = StatesConfig::default();
591        let phases_config = PhasesConfig::default();
592        // No with_agent() call
593        let ctx = PromptContext::new("working", None, &states_config, &phases_config);
594
595        let template = "{{agent_id}} / {{agent_role}} / {{agent_tags}}";
596        let result = expand_prompt(template, &ctx);
597
598        assert_eq!(result, "_unknown_ / _(none)_ / _(none)_");
599    }
600
601    #[test]
602    fn test_expand_prompt_combined_context() {
603        let states_config = StatesConfig::default();
604        let phases_config = PhasesConfig::default();
605        let task_tags = vec!["design".to_string()];
606        let agent_tags = vec!["worker".to_string(), "design".to_string()];
607        let ctx = PromptContext::new("working", Some("design"), &states_config, &phases_config)
608            .with_task(
609                "prompt-guidance",
610                "Context-sensitive prompts",
611                7,
612                &task_tags,
613            )
614            .with_agent("worker-21", Some("worker"), &agent_tags);
615
616        let template = "{{agent_id}} is working on {{task_id}} in phase {{current_phase}} with status {{current_status}}";
617        let result = expand_prompt(template, &ctx);
618
619        assert_eq!(
620            result,
621            "worker-21 is working on prompt-guidance in phase `design` with status working"
622        );
623    }
624
625    #[test]
626    fn test_prompt_context_builder_pattern() {
627        let states_config = StatesConfig::default();
628        let phases_config = PhasesConfig::default();
629        let task_tags = vec![];
630        let agent_tags = vec!["worker".to_string()];
631
632        // Verify builder pattern works correctly
633        let ctx = PromptContext::new("pending", None, &states_config, &phases_config)
634            .with_task("t1", "Title", 5, &task_tags)
635            .with_agent("w1", Some("worker"), &agent_tags);
636
637        assert_eq!(ctx.task_id, Some("t1"));
638        assert_eq!(ctx.task_title, Some("Title"));
639        assert_eq!(ctx.task_priority, Some(5));
640        assert_eq!(ctx.agent_id, Some("w1"));
641        assert_eq!(ctx.agent_role, Some("worker"));
642    }
643}