Skip to main content

skilllite_agent/
chat.rs

1//! CLI chat entry-points: single-shot and interactive REPL.
2//!
3//! Extracted from `main.rs` so that `main` only does argument dispatch.
4
5use anyhow::{Context, Result};
6use std::path::Path;
7
8use super::chat_session::ChatSession;
9use super::skills;
10use super::types::*;
11
12/// Run a single task (one turn) and return AgentResult.
13/// Used by P2P swarm when routing decides to execute locally.
14///
15/// When `skill_dirs` is `Some`, loads skills from those directories (e.g. swarm's `--skills-dir`).
16/// When `None`, auto-discovers from `workspace/.skills` and `workspace/skills`.
17pub async fn run_single_task(
18    workspace: &str,
19    session_key: &str,
20    description: &str,
21    skill_dirs: Option<&[String]>,
22) -> Result<AgentResult> {
23    let mut config = AgentConfig::from_env();
24    config.workspace = workspace.to_string();
25    config.enable_task_planning = false; // Single task, no planning
26    config.enable_memory = true;
27
28    if config.api_key.is_empty() {
29        anyhow::bail!("API key required for swarm task execution. Set OPENAI_API_KEY.");
30    }
31
32    skilllite_core::config::ensure_default_output_dir();
33
34    let skill_dirs = skill_dirs.map(|s| s.to_vec()).unwrap_or_else(|| {
35        skilllite_core::skill::discovery::discover_skill_dirs_for_loading(
36            Path::new(workspace),
37            Some(&[".skills", "skills"]),
38        )
39    });
40    let loaded_skills = skills::load_skills(&skill_dirs);
41
42    let mut session = ChatSession::new(config, session_key, loaded_skills);
43    let mut sink = SilentEventSink;
44    session.run_turn(description, &mut sink).await
45}
46
47/// Clear session (OpenClaw-style): summarize to memory, archive transcript, reset counts.
48/// Called by `skilllite clear-session` and Assistant. Loads .env from workspace.
49pub fn run_clear_session(session_key: &str, workspace: &str) -> Result<()> {
50    let workspace_path = Path::new(workspace).canonicalize().unwrap_or_else(|_| {
51        std::env::current_dir()
52            .unwrap_or_else(|_| std::path::PathBuf::from("."))
53            .join(workspace)
54    });
55    if std::env::set_current_dir(&workspace_path).is_err() {
56        // Non-fatal: .env may not exist or API key may be in env already
57    }
58
59    let mut config = AgentConfig::from_env();
60    config.workspace = workspace_path.to_string_lossy().to_string();
61
62    if config.api_key.is_empty() {
63        tracing::warn!(
64            "No OPENAI_API_KEY; summarization skipped. Session will still be archived and counts reset."
65        );
66    }
67
68    skilllite_core::config::ensure_default_output_dir();
69
70    let loaded_skills = skills::load_skills(&[]);
71    let session_key_owned = session_key.to_string();
72
73    let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
74    rt.block_on(async {
75        let mut session = ChatSession::new_for_clear(config, &session_key_owned, loaded_skills);
76        session.clear_full().await
77    })?;
78
79    Ok(())
80}
81
82/// Top-level entry-point called from `main()` for the `chat` subcommand.
83/// Caller should build `config` from env + CLI overrides (e.g. `AgentConfig::from_env()` then set api_base, skill_dirs, etc.).
84pub fn run_chat(
85    config: AgentConfig,
86    session_key: String,
87    single_message: Option<String>,
88) -> Result<()> {
89    skilllite_core::config::ensure_default_output_dir();
90
91    if config.api_key.is_empty() {
92        anyhow::bail!("API key required. Set OPENAI_API_KEY env var or use --api-key flag.");
93    }
94
95    // Auto-discover skill directories if none specified
96    let (effective_skill_dirs, was_auto_discovered) = if config.skill_dirs.is_empty() {
97        let auto_dirs = skilllite_core::skill::discovery::discover_skill_dirs_for_loading(
98            Path::new(&config.workspace),
99            Some(&[".skills", "skills"]),
100        );
101        let has_skills = !auto_dirs.is_empty();
102        (auto_dirs, has_skills)
103    } else {
104        (config.skill_dirs.clone(), false)
105    };
106
107    // Load skills & print banner
108    let loaded_skills = skills::load_skills(&effective_skill_dirs);
109    if !loaded_skills.is_empty() {
110        eprintln!("┌─ Skills ─────────────────────────────────────────────────");
111        if was_auto_discovered {
112            eprintln!("│  🔍 Auto-discovered {} skill(s)", loaded_skills.len());
113        }
114        let names: Vec<&str> = loaded_skills.iter().map(|s| s.name.as_str()).collect();
115        let list = if names.len() <= 6 {
116            names.join(", ")
117        } else {
118            format!("{} … +{} more", names[..5].join(", "), names.len() - 5)
119        };
120        eprintln!("│  📦 {}", list);
121        eprintln!("└───────────────────────────────────────────────────────────");
122    }
123
124    let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
125
126    let verbose = config.verbose;
127    if let Some(msg) = single_message {
128        rt.block_on(async {
129            let mut session = ChatSession::new(config, &session_key, loaded_skills);
130            let mut sink = TerminalEventSink::new(verbose);
131            let result = session.run_turn(&msg, &mut sink).await?;
132            println!("\n{}", result.response);
133            Ok(())
134        })
135    } else {
136        rt.block_on(async {
137            run_interactive_chat(config, &session_key, loaded_skills, verbose).await
138        })
139    }
140}
141
142/// Run agent in unattended mode: one-time goal, continuous execution until done/timeout.
143/// Replan (update_task_plan) does not wait for user — agent continues immediately.
144/// Confirmations (run_command, L3 skill scan) are auto-approved.
145/// A13: When resume=true, load checkpoint and continue from last state.
146///
147/// Caller should build `config` with run-mode defaults (e.g. enable_task_planning=true,
148/// max_consecutive_failures set, soul_path, skill_dirs, etc.).
149pub fn run_agent_run(config: AgentConfig, goal: String, resume: bool) -> Result<()> {
150    if config.api_key.is_empty() {
151        anyhow::bail!("API key required. Set OPENAI_API_KEY env var or use --api-key flag.");
152    }
153
154    skilllite_core::config::ensure_default_output_dir();
155
156    // A13: Resume from checkpoint
157    let (effective_goal, effective_workspace, history_override) = if resume {
158        let chat_root = skilllite_executor::chat_root();
159        match super::run_checkpoint::load_checkpoint(&chat_root)? {
160            Some(cp) => {
161                let resume_msg = super::run_checkpoint::build_resume_message(&cp);
162                // Use checkpoint messages as history; skip first (system) since agent_loop adds its own
163                let history: Vec<ChatMessage> = cp.messages.into_iter().skip(1).collect();
164                eprintln!("📂 从断点续跑 (run_id: {})", cp.run_id);
165                (resume_msg, cp.workspace, Some(history))
166            }
167            None => {
168                anyhow::bail!("无可用断点。请先运行 `skilllite run --goal \"...\"` 以创建断点。");
169            }
170        }
171    } else {
172        (goal, config.workspace.clone(), None)
173    };
174
175    let mut config = config;
176    config.workspace = effective_workspace;
177
178    // Optional first-run guidance: if no SOUL in chain and stdin is TTY, offer to create minimal template
179    let _ = super::soul::Soul::offer_bootstrap_soul_if_missing(
180        &config.workspace,
181        config.soul_path.as_deref(),
182    );
183
184    let (effective_skill_dirs, was_auto_discovered) = if config.skill_dirs.is_empty() {
185        let auto_dirs = skilllite_core::skill::discovery::discover_skill_dirs_for_loading(
186            Path::new(&config.workspace),
187            Some(&[".skills", "skills"]),
188        );
189        let has_skills = !auto_dirs.is_empty();
190        (auto_dirs, has_skills)
191    } else {
192        (config.skill_dirs.clone(), false)
193    };
194
195    let loaded_skills = skills::load_skills(&effective_skill_dirs);
196    if !loaded_skills.is_empty() {
197        eprintln!("┌─ Run mode ───────────────────────────────────────────────");
198        if was_auto_discovered {
199            eprintln!("│  🔍 Auto-discovered {} skill(s)", loaded_skills.len());
200        }
201        let names: Vec<&str> = loaded_skills.iter().map(|s| s.name.as_str()).collect();
202        let list = if names.len() <= 6 {
203            names.join(", ")
204        } else {
205            format!("{} … +{} more", names[..5].join(", "), names.len() - 5)
206        };
207        eprintln!("│  📦 {}", list);
208        eprintln!(
209            "│  🎯 Goal: {}",
210            effective_goal.lines().next().unwrap_or(&effective_goal)
211        );
212        eprintln!("└───────────────────────────────────────────────────────────\n");
213    }
214
215    let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
216
217    let verbose = config.verbose;
218    rt.block_on(async {
219        let mut session = ChatSession::new(config, "run", loaded_skills);
220        let mut sink = RunModeEventSink::new(verbose);
221        let result = if let Some(history) = history_override {
222            session
223                .run_turn_with_history(&effective_goal, &mut sink, history)
224                .await
225        } else {
226            session.run_turn(&effective_goal, &mut sink).await
227        };
228        let _ = result?;
229        // Response already streamed via sink during run_turn — no extra println
230        Ok(())
231    })
232}
233
234/// Format agent/API errors for user-friendly display in chat UI.
235fn format_chat_error(e: &anyhow::Error) -> String {
236    let s = e.to_string();
237    if let Some(json_start) = s.find('{') {
238        let json_part = &s[json_start..];
239        if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_part) {
240            if let Some(msg) = v
241                .get("error")
242                .and_then(|e| e.get("message"))
243                .and_then(|m| m.as_str())
244            {
245                let status = s
246                    .strip_prefix("LLM API error (")
247                    .and_then(|rest| rest.split(')').next())
248                    .unwrap_or("API");
249                return format!("{} 错误: {}", status, msg);
250            }
251        }
252    }
253    if s.len() > 200 {
254        format!("{}…", &s[..200])
255    } else {
256        s
257    }
258}
259
260async fn run_interactive_chat(
261    config: AgentConfig,
262    session_key: &str,
263    skills: Vec<skills::LoadedSkill>,
264    verbose: bool,
265) -> Result<()> {
266    eprintln!("┌────────────────────────────────────────────────────────────");
267    eprintln!("│  🤖 SkillBox Chat  ·  model: {}", config.model);
268    eprintln!("│  /exit 退出  ·  /clear 清空  ·  /compact 压缩历史");
269    eprintln!("└────────────────────────────────────────────────────────────\n");
270
271    let mut session = ChatSession::new(config, session_key, skills);
272    let mut sink = TerminalEventSink::new(verbose);
273
274    let mut rl = rustyline::DefaultEditor::new()
275        .map_err(|e| anyhow::anyhow!("Failed to create line editor: {}", e))?;
276
277    loop {
278        let readline = rl.readline("You> ");
279        match readline {
280            Ok(line) => {
281                let input = line.trim();
282                if input.is_empty() {
283                    continue;
284                }
285
286                let _ = rl.add_history_entry(input);
287
288                match input {
289                    "/exit" | "/quit" | "/q" => {
290                        eprintln!("👋 Bye!");
291                        break;
292                    }
293                    "/clear" => {
294                        session.clear().await?;
295                        eprintln!("🗑️  Session cleared.");
296                        continue;
297                    }
298                    "/compact" => {
299                        eprintln!("📦 Compacting history...");
300                        match session.force_compact().await {
301                            Ok(true) => eprintln!("✅ History compacted."),
302                            Ok(false) => eprintln!("ℹ️  Not enough messages to compact."),
303                            Err(e) => eprintln!("❌ Compaction failed: {}", format_chat_error(&e)),
304                        }
305                        continue;
306                    }
307                    _ => {}
308                }
309
310                eprintln!();
311                match session.run_turn(input, &mut sink).await {
312                    Ok(_) => {
313                        eprintln!();
314                    }
315                    Err(e) => {
316                        let msg = format_chat_error(&e);
317                        eprintln!("❌ {}", msg);
318                        eprintln!();
319                    }
320                }
321            }
322            Err(rustyline::error::ReadlineError::Interrupted) => {
323                eprintln!("\n^C");
324                eprintln!("👋 Bye!");
325                break;
326            }
327            Err(rustyline::error::ReadlineError::Eof) => {
328                eprintln!("👋 Bye!");
329                break;
330            }
331            Err(e) => {
332                eprintln!("Error: {}", e);
333                break;
334            }
335        }
336    }
337
338    Ok(())
339}