Skip to main content

agent_works/prompt/
mod.rs

1//! Composable prompt fragments for building system prompts.
2//!
3//! Each [`PromptFragment`] owns one concern (role definition, tool descriptions,
4//! environment info, etc.) and renders itself independently. Fragments are
5//! sorted by [`priority`](PromptFragment::priority) and concatenated by
6//! [`compose_fragments`] to produce the final system prompt.
7//!
8//! # Architecture
9//!
10//! - **agent-works** (this module): trait definition + generic fragments
11//!   (`EnvironmentFragment`, `DynamicToolsFragment`).
12//! - **phi-agent**: application-specific fragments (`CoreInstructionsFragment`,
13//!   `FocusFragment`, etc.) and the `build_system_prompt()` assembly entry point.
14//! - **Consumers**: inject custom fragments via `build_system_prompt_with_fragments()`.
15
16mod fragment;
17
18pub use fragment::{FragmentContext, PromptFragment, compose_fragments};
19
20// ── Generic fragments provided by agent-works ──────────────────────────────
21
22/// Injects runtime environment information: OS, working directory, git branch.
23///
24/// Priority: 50 (middle — after core instructions, before tool descriptions).
25#[derive(Clone)]
26pub struct EnvironmentFragment {
27    pub os: String,
28    pub cwd: String,
29    pub git_branch: Option<String>,
30}
31
32impl EnvironmentFragment {
33    /// Auto-detect environment from the current process.
34    pub fn detect() -> Self {
35        Self {
36            os: std::env::consts::OS.to_string(),
37            cwd: std::env::current_dir()
38                .map(|p| p.display().to_string())
39                .unwrap_or_else(|_| "unknown".to_string()),
40            git_branch: detect_git_branch(),
41        }
42    }
43}
44
45impl PromptFragment for EnvironmentFragment {
46    fn name(&self) -> &str {
47        "environment"
48    }
49
50    fn priority(&self) -> i32 {
51        50
52    }
53
54    fn render(&self, _ctx: &FragmentContext) -> Option<String> {
55        let mut lines = vec![
56            format!("[Environment]\n- OS: {}", self.os),
57            format!("- Working directory: {}", self.cwd),
58        ];
59        if let Some(ref branch) = self.git_branch {
60            lines.push(format!("- Git branch: {}", branch));
61        }
62        Some(lines.join("\n"))
63    }
64}
65
66/// Dynamically generate tool descriptions from registered tools.
67///
68/// Iterates `ctx.tool_definitions` and produces a `[Tools]` section listing
69/// each tool's name and description. Useful for consumers that want tool
70/// documentation in the system prompt without hardcoding it.
71///
72/// Priority: 70 (after workflow/safety, before memory).
73#[derive(Clone)]
74pub struct DynamicToolsFragment;
75
76impl PromptFragment for DynamicToolsFragment {
77    fn name(&self) -> &str {
78        "dynamic_tools"
79    }
80
81    fn priority(&self) -> i32 {
82        70
83    }
84
85    fn render(&self, ctx: &FragmentContext) -> Option<String> {
86        if ctx.tool_definitions.is_empty() {
87            return None;
88        }
89        let mut lines = vec!["[Available Tools]".to_string()];
90        for def in ctx.tool_definitions {
91            let name = def
92                .get("function")
93                .and_then(|f| f.get("name"))
94                .and_then(|n| n.as_str())
95                .unwrap_or("unknown");
96            let desc = def
97                .get("function")
98                .and_then(|f| f.get("description"))
99                .and_then(|d| d.as_str())
100                .unwrap_or("");
101            if desc.is_empty() {
102                lines.push(format!("- `{}`", name));
103            } else {
104                lines.push(format!("- `{}` — {}", name, desc));
105            }
106        }
107        Some(lines.join("\n"))
108    }
109}
110
111/// Helper: detect current git branch via `git rev-parse`.
112fn detect_git_branch() -> Option<String> {
113    std::process::Command::new("git")
114        .args(["rev-parse", "--abbrev-ref", "HEAD"])
115        .output()
116        .ok()
117        .filter(|o| o.status.success())
118        .and_then(|o| String::from_utf8(o.stdout).ok())
119        .map(|s| s.trim().to_string())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_environment_fragment_render() {
128        let frag = EnvironmentFragment {
129            os: "macos".into(),
130            cwd: "/Users/test".into(),
131            git_branch: Some("main".into()),
132        };
133        let ctx = FragmentContext {
134            tool_definitions: &[],
135            session_id: "s1",
136        };
137        let output = frag.render(&ctx).unwrap();
138        assert!(output.contains("OS: macos"));
139        assert!(output.contains("/Users/test"));
140        assert!(output.contains("Git branch: main"));
141    }
142
143    #[test]
144    fn test_environment_fragment_no_git() {
145        let frag = EnvironmentFragment {
146            os: "linux".into(),
147            cwd: "/tmp".into(),
148            git_branch: None,
149        };
150        let ctx = FragmentContext {
151            tool_definitions: &[],
152            session_id: "s1",
153        };
154        let output = frag.render(&ctx).unwrap();
155        assert!(output.contains("OS: linux"));
156        assert!(!output.contains("Git branch"));
157    }
158
159    #[test]
160    fn test_dynamic_tools_fragment_with_tools() {
161        let tool_def = serde_json::json!({
162            "type": "function",
163            "function": {
164                "name": "read_file",
165                "description": "Read a file from disk",
166                "parameters": {}
167            }
168        });
169        let frag = DynamicToolsFragment;
170        let ctx = FragmentContext {
171            tool_definitions: &[tool_def],
172            session_id: "test",
173        };
174        let output = frag.render(&ctx).unwrap();
175        assert!(output.contains("read_file"));
176        assert!(output.contains("Read a file from disk"));
177    }
178
179    #[test]
180    fn test_dynamic_tools_fragment_empty() {
181        let frag = DynamicToolsFragment;
182        let ctx = FragmentContext {
183            tool_definitions: &[],
184            session_id: "test",
185        };
186        assert!(frag.render(&ctx).is_none());
187    }
188
189    #[test]
190    fn test_compose_orders_by_priority() {
191        let env = EnvironmentFragment {
192            os: "linux".into(),
193            cwd: "/tmp".into(),
194            git_branch: None,
195        };
196        let tool_def = serde_json::json!({
197            "type": "function",
198            "function": { "name": "test_tool", "description": "A test", "parameters": {} }
199        });
200        let fragments: Vec<Box<dyn PromptFragment>> = vec![
201            Box::new(DynamicToolsFragment), // priority 70
202            Box::new(env),                  // priority 50
203        ];
204        let ctx = FragmentContext {
205            tool_definitions: &[tool_def],
206            session_id: "test",
207        };
208        let result = compose_fragments(&fragments, &ctx);
209        let env_pos = result.find("[Environment]").unwrap();
210        let tools_pos = result.find("[Available Tools]").unwrap();
211        assert!(
212            env_pos < tools_pos,
213            "Environment (50) should come before Tools (70)"
214        );
215    }
216}