Skip to main content

aether_cli/show_prompt/
run.rs

1use std::collections::BTreeMap;
2
3use super::PromptArgs;
4use crate::error::CliError;
5use crate::resolve::resolve_agent_spec;
6use crate::runtime::RuntimeBuilder;
7use aether_core::core::Prompt;
8use aether_project::{AetherSettings, AgentCatalog};
9use llm::ToolDefinition;
10use serde_json::Value;
11
12pub async fn run_prompt(args: PromptArgs) -> Result<(), CliError> {
13    let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
14    let config = if let Some(source) = args.settings_source.source(&cwd) {
15        AetherSettings::load(&cwd, [source])
16    } else {
17        AetherSettings::load_default(&cwd)
18    }
19    .map_err(|e| CliError::AgentError(e.to_string()))?;
20    let catalog = if config.agents.is_empty() {
21        AgentCatalog::empty(cwd.clone())
22    } else {
23        AgentCatalog::from_settings(&cwd, config).map_err(|e| CliError::AgentError(e.to_string()))?
24    };
25    let spec = resolve_agent_spec(&catalog, args.agent.as_deref())?;
26
27    let info = RuntimeBuilder::from_spec(cwd.clone(), spec)
28        .mcp_sources(args.mcp_config.sources(&cwd))
29        .build_prompt_info()
30        .await?;
31
32    let system_prompt = build_prompt(&info.spec.prompts, args.system_prompt.as_deref()).await?;
33    let tools_output = build_tools(&info.tool_definitions);
34
35    println!("{system_prompt}");
36
37    if !tools_output.is_empty() {
38        println!();
39        println!("--- Tools ({} tools) ---", info.tool_definitions.len());
40        println!();
41        println!("{tools_output}");
42    }
43
44    println!();
45    println!("{}", format_stats(system_prompt.len(), tools_output.len(), info.tool_definitions.len()));
46
47    Ok(())
48}
49
50pub async fn build_prompt(prompts: &[Prompt], custom: Option<&str>) -> Result<String, CliError> {
51    let mut prompts = prompts.to_vec();
52    if let Some(custom) = custom {
53        prompts.push(Prompt::text(custom));
54    }
55    Prompt::build_all(&prompts).await.map_err(|e| CliError::AgentError(e.to_string()))
56}
57
58pub fn build_tools(tools: &[ToolDefinition]) -> String {
59    if tools.is_empty() {
60        return String::new();
61    }
62
63    let mut grouped: BTreeMap<&str, Vec<Value>> = BTreeMap::new();
64    for tool in tools {
65        let server = tool.server.as_deref().unwrap_or("(built-in)");
66        let input_schema = serde_json::from_str::<Value>(&tool.parameters).unwrap_or(Value::Null);
67        let entry = serde_json::json!({
68            "name": tool.name,
69            "description": tool.description,
70            "input_schema": input_schema,
71        });
72        grouped.entry(server).or_default().push(entry);
73    }
74
75    let mut sections = Vec::new();
76    for (server, entries) in &grouped {
77        let json = serde_json::to_string_pretty(entries).unwrap_or_default();
78        sections.push(format!("Server: {server}\n{json}"));
79    }
80
81    sections.join("\n\n")
82}
83
84pub fn format_stats(prompt_chars: usize, tool_schema_chars: usize, tool_count: usize) -> String {
85    let est_tokens = (prompt_chars + tool_schema_chars) / 4;
86    format!(
87        "---\n\
88         Prompt chars:     {prompt_chars:>8}\n\
89         Tool schema chars:{tool_schema_chars:>8}\n\
90         Est. tokens:     ~{est_tokens:>8}\n\
91         MCP tools:        {tool_count:>8}"
92    )
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn tool(name: &str, desc: &str, params: &str, server: Option<&str>) -> ToolDefinition {
100        ToolDefinition {
101            name: name.to_string(),
102            description: desc.to_string(),
103            parameters: params.to_string(),
104            server: server.map(String::from),
105        }
106    }
107
108    #[test]
109    fn format_stats_computes_token_estimate() {
110        let output = format_stats(12000, 8500, 14);
111        assert_eq!(
112            output,
113            "---\n\
114             Prompt chars:        12000\n\
115             Tool schema chars:    8500\n\
116             Est. tokens:     ~    5125\n\
117             MCP tools:              14"
118        );
119    }
120
121    #[test]
122    fn format_stats_handles_zero() {
123        let output = format_stats(0, 0, 0);
124        assert_eq!(
125            output,
126            "---\n\
127             Prompt chars:            0\n\
128             Tool schema chars:       0\n\
129             Est. tokens:     ~       0\n\
130             MCP tools:               0"
131        );
132    }
133
134    #[test]
135    fn format_stats_handles_small_values() {
136        let output = format_stats(3, 0, 1);
137        assert_eq!(
138            output,
139            "---\n\
140             Prompt chars:            3\n\
141             Tool schema chars:       0\n\
142             Est. tokens:     ~       0\n\
143             MCP tools:               1"
144        );
145    }
146
147    #[test]
148    fn build_tools_groups_by_server() {
149        let tools = vec![
150            tool("fs_read", "Read a file", r#"{"type":"object"}"#, Some("filesystem")),
151            tool("git_log", "Show log", r#"{"type":"object"}"#, Some("git")),
152            tool("fs_write", "Write a file", r#"{"type":"object"}"#, Some("filesystem")),
153        ];
154        let output = build_tools(&tools);
155        // BTreeMap sorts: filesystem < git
156        let fs_pos = output.find("Server: filesystem").unwrap();
157        let git_pos = output.find("Server: git").unwrap();
158        assert!(fs_pos < git_pos);
159        // filesystem group has both tools
160        assert!(output.contains("fs_read"));
161        assert!(output.contains("fs_write"));
162    }
163
164    #[test]
165    fn build_tools_handles_no_server() {
166        let tools = vec![tool("builtin_tool", "A built-in", r#"{"type":"object"}"#, None)];
167        let output = build_tools(&tools);
168        assert!(output.contains("Server: (built-in)"));
169        assert!(output.contains("builtin_tool"));
170    }
171
172    #[test]
173    fn build_tools_produces_api_format() {
174        let tools = vec![tool("my_tool", "Does stuff", r#"{"type":"object","properties":{}}"#, Some("test"))];
175        let output = build_tools(&tools);
176        // Strip "Server: test\n" prefix to get the JSON
177        let json_start = output.find('[').unwrap();
178        let parsed: Vec<Value> = serde_json::from_str(&output[json_start..]).unwrap();
179        assert_eq!(parsed.len(), 1);
180        let entry = &parsed[0];
181        assert_eq!(entry["name"], "my_tool");
182        assert_eq!(entry["description"], "Does stuff");
183        assert!(entry["input_schema"].is_object());
184    }
185
186    #[test]
187    fn build_tools_empty() {
188        assert_eq!(build_tools(&[]), "");
189    }
190
191    #[test]
192    fn build_tools_malformed_params() {
193        let tools = vec![tool("bad_tool", "Broken params", "not valid json", Some("srv"))];
194        let output = build_tools(&tools);
195        assert!(output.contains("bad_tool"));
196        assert!(output.contains("null"));
197    }
198}