Skip to main content

sparrow/onboarding/
setup_agent.rs

1//! Conversational onboarding via a minimal Setup Agent (§16).
2//!
3//! Reads natural-language intent from the user, sends it to a Brain with a
4//! structured-output system prompt, parses the resulting JSON into Config
5//! updates, validates against the registry, prompts for missing secrets,
6//! and writes config.toml.
7
8use std::io::{self, IsTerminal, Write};
9use std::sync::Arc;
10
11/// `true` when stdin is connected to a real terminal, so it's safe to prompt
12/// for a password / interactive line. CI / piped invocations get `false`.
13fn is_stdin_tty() -> bool {
14    io::stdin().is_terminal()
15}
16
17use crate::auth::{AuthStore, Credential};
18use crate::config::{Config, ConfigStore, FsConfigStore, ProviderConfig};
19use crate::event::AutonomyLevel;
20use crate::memory::Memory;
21use crate::provider::{Brain, BrainEvent, BrainRequest, ContentBlock, Msg, PromptCacheConfig};
22
23use futures::StreamExt;
24
25/// Run the conversational setup. Falls back to a minimal interactive flow if no
26/// Brain is reachable.
27pub async fn run_setup_agent(
28    config: &Config,
29    store: &FsConfigStore,
30    memory: Arc<dyn Memory>,
31    build_brains: impl Fn(
32        &Config,
33        &Arc<dyn Memory>,
34        bool,
35    ) -> std::collections::HashMap<String, Vec<Arc<dyn Brain>>>,
36) -> anyhow::Result<()> {
37    print_welcome(config);
38
39    // 1. Locate a Brain — env-keyed provider, or Ollama, or fall back.
40    let providers = build_brains(config, &memory, false);
41    let any_brain: Option<Arc<dyn Brain>> = providers
42        .values()
43        .flat_map(|chain| chain.iter())
44        .next()
45        .cloned();
46
47    let Some(brain) = any_brain else {
48        eprintln!(
49            "⚠ Aucun modèle disponible pour piloter le Setup Agent.\n  → fallback en mode interactif simple.\n"
50        );
51        return fallback_interactive(config, store).await;
52    };
53
54    println!("Setup Agent piloté par : {}\n", brain.id());
55    println!("Décris ce que tu veux en langage naturel.");
56    println!("Exemple :");
57    println!("  \"Use my NVIDIA and Anthropic keys, keep daily cost under €5,");
58    println!(
59        "   default to trusted autonomy, reach me on Telegram, code in a hardened sandbox.\"\n"
60    );
61    print!("> ");
62    io::stdout().flush().ok();
63
64    let mut intent = String::new();
65    io::stdin().read_line(&mut intent)?;
66    let intent = intent.trim().to_string();
67    if intent.is_empty() {
68        eprintln!("Aucun intent fourni, setup annulé.");
69        return Ok(());
70    }
71
72    // 2. Ask the LLM for structured JSON
73    let json_value = ask_llm_for_setup(&brain, &intent, None).await?;
74
75    // 3. Handle optional clarifying question (max 1 round per §16)
76    let mut value = json_value;
77    if let Some(question) = value
78        .get("clarifying_question")
79        .and_then(|v| v.as_str())
80        .filter(|s| !s.is_empty())
81        .map(str::to_string)
82    {
83        println!("\n{}\n", question);
84        print!("> ");
85        io::stdout().flush().ok();
86        let mut clarif = String::new();
87        io::stdin().read_line(&mut clarif)?;
88        let clarif = clarif.trim().to_string();
89        value = ask_llm_for_setup(&brain, &intent, Some(&clarif)).await?;
90    }
91
92    // 4. Validate + merge into Config
93    let updated = apply_setup_json(config, &value)?;
94
95    // 5. Prompt for missing secrets
96    let missing: Vec<String> = value
97        .get("missing_keys")
98        .and_then(|v| v.as_array())
99        .map(|arr| {
100            arr.iter()
101                .filter_map(|v| v.as_str().map(|s| s.to_string()))
102                .collect()
103        })
104        .unwrap_or_default();
105    if !missing.is_empty() {
106        let auth = crate::auth::store::ChainedAuthStore::new(updated.config_dir.clone());
107        // In non-interactive contexts (CI, piped stdin) we MUST NOT block on a
108        // hidden-password prompt: rpassword fails over to stdin and blocks
109        // forever. Detect once up-front and print actionable instructions
110        // instead.
111        let interactive = is_stdin_tty();
112        for env_var in &missing {
113            // Heuristic: env var like `ANTHROPIC_API_KEY` → provider id `anthropic`
114            let provider_id = env_var.to_lowercase().replace("_api_key", "");
115            if !interactive {
116                println!(
117                    "\nSkipping API key prompt for {} ({}) — stdin is not a TTY. \
118                     Set the env var or run `sparrow setup` in an interactive shell.",
119                    provider_id, env_var
120                );
121                continue;
122            }
123            print!("\nPaste API key for {} ({}): ", provider_id, env_var);
124            io::stdout().flush().ok();
125            let key = match rpassword::read_password() {
126                Ok(k) => k.trim().to_string(),
127                Err(e) => {
128                    eprintln!(
129                        "\nwarning: could not read API key for {}: {}. Skipping.",
130                        provider_id, e
131                    );
132                    continue;
133                }
134            };
135            if !key.is_empty() {
136                auth.set(&provider_id, Credential::api_key(key))?;
137                println!("  ✓ Credential stored for {}", provider_id);
138            }
139        }
140    }
141
142    // 6. Persist + summary
143    store.save(&updated)?;
144    print_summary(&updated, &value);
145    Ok(())
146}
147
148fn print_welcome(config: &Config) {
149    println!("══════════════════════════════════════════════════");
150    println!("  Sparrow Setup Agent (§16)");
151    println!("══════════════════════════════════════════════════");
152    println!("  Config dir : {:?}", config.config_dir);
153    println!("  State dir  : {:?}", config.state_dir);
154    println!();
155}
156
157const SETUP_SYSTEM_PROMPT: &str = r#"You are the Sparrow Setup Agent. The user describes in natural language how they want Sparrow configured. Output ONLY valid JSON conforming to this exact schema (no markdown, no commentary):
158
159{
160  "providers_to_enable": ["<provider_id>", ...],
161  "routing_policy": {"trivial": "<id>", "small": "<id>", "medium": "<id>", "hard": "<id>", "vision": "<id>"},
162  "budget_daily_usd": <number>,
163  "budget_session_usd": <number>,
164  "default_autonomy": "supervised" | "trusted" | "autonomous",
165  "default_sandbox": "local" | "local-hardened" | "docker",
166  "surfaces": {
167    "telegram": {"enabled": <bool>, "allow_users": [<numbers>]},
168    "discord":  {"enabled": <bool>, "allow_users": [<strings>]},
169    "slack":    {"enabled": <bool>, "allow_users": [<strings>]}
170  },
171  "missing_keys": ["ANTHROPIC_API_KEY", ...],
172  "clarifying_question": null | "<one short question if absolutely needed>"
173}
174
175Valid provider ids: anthropic, openai-codex, nvidia, openrouter, deepseek, gemini, xai, huggingface, nous, novita, alibaba, bedrock, kimi-coding, minimax, xiaomi, zai, gmi, arcee, stepfun, custom, azure-foundry, qwen-oauth, opencode-zen, kilocode, copilot, alibaba-coding-plan, ollama, groq, together, cerebras, mistral, fireworks, perplexity, cohere.
176
177Rules:
178- Map user mentions ("NVIDIA", "Anthropic", "Telegram", "local") to the right keys.
179- If the user mentions a cost cap in euros or dollars, set both daily_usd and session_usd (session_usd typically 1/5 of daily).
180- Only ask a clarifying_question if a critical field is genuinely ambiguous; otherwise null.
181- For each provider in providers_to_enable, add its standard env-key name to missing_keys.
182- Default to autonomy=trusted, sandbox=local-hardened if not specified.
183"#;
184
185async fn ask_llm_for_setup(
186    brain: &Arc<dyn Brain>,
187    intent: &str,
188    clarification: Option<&str>,
189) -> anyhow::Result<serde_json::Value> {
190    let mut messages = vec![Msg {
191        role: "user".into(),
192        content: vec![ContentBlock::Text {
193            text: format!("User intent:\n{}", intent),
194        }],
195    }];
196    if let Some(c) = clarification {
197        messages.push(Msg {
198            role: "user".into(),
199            content: vec![ContentBlock::Text {
200                text: format!("Clarification:\n{}", c),
201            }],
202        });
203    }
204
205    let req = BrainRequest {
206        system: Some(SETUP_SYSTEM_PROMPT.into()),
207        messages,
208        tools: vec![],
209        max_tokens: 1500,
210        temperature: 0.0,
211        stop: vec![],
212        cache: PromptCacheConfig::disabled(),
213    };
214
215    let mut stream = brain.complete(req).await?;
216    let mut buf = String::new();
217    while let Some(evt) = stream.next().await {
218        match evt {
219            BrainEvent::TextDelta(t) => buf.push_str(&t),
220            BrainEvent::Done(_) => break,
221            BrainEvent::Error(e) => anyhow::bail!("Setup Agent LLM error: {}", e),
222            _ => {}
223        }
224    }
225
226    // Strip markdown fences if present
227    let cleaned = buf
228        .trim()
229        .trim_start_matches("```json")
230        .trim_start_matches("```")
231        .trim_end_matches("```")
232        .trim();
233
234    serde_json::from_str(cleaned)
235        .map_err(|e| anyhow::anyhow!("Setup Agent returned non-JSON: {}\nRaw:\n{}", e, buf))
236}
237
238fn apply_setup_json(base: &Config, value: &serde_json::Value) -> anyhow::Result<Config> {
239    let mut next = base.clone();
240    let registry: std::collections::HashSet<String> = crate::config::providers::provider_registry()
241        .into_iter()
242        .map(|p| p.id)
243        .collect();
244
245    // Providers
246    if let Some(arr) = value.get("providers_to_enable").and_then(|v| v.as_array()) {
247        for v in arr {
248            let Some(id) = v.as_str() else { continue };
249            if !registry.contains(id) {
250                eprintln!("  ! Unknown provider id '{}' — skipped.", id);
251                continue;
252            }
253            let def = crate::config::providers::find_provider(id);
254            next.providers
255                .entry(id.to_string())
256                .or_insert_with(|| ProviderConfig {
257                    adapter: def
258                        .as_ref()
259                        .map(|d| d.adapter.clone())
260                        .unwrap_or_else(|| "openai-compatible".into()),
261                    base_url: def.as_ref().map(|d| d.base_url.clone()),
262                    models: def
263                        .as_ref()
264                        .map(|d| crate::config::providers::default_models(&d.id))
265                        .unwrap_or_default(),
266                    api_key_env: def.and_then(|d| d.api_key_env),
267                });
268        }
269    }
270
271    // Routing policy
272    if let Some(obj) = value.get("routing_policy").and_then(|v| v.as_object()) {
273        for (tier, provider) in obj {
274            if let Some(p) = provider.as_str() {
275                if registry.contains(p) {
276                    next.routing.policy.insert(tier.clone(), p.to_string());
277                }
278            }
279        }
280    }
281
282    // Budgets
283    if let Some(d) = value.get("budget_daily_usd").and_then(|v| v.as_f64()) {
284        next.budget.daily_usd = d.max(0.0);
285    }
286    if let Some(s) = value.get("budget_session_usd").and_then(|v| v.as_f64()) {
287        next.budget.session_usd = s.max(0.0);
288    }
289
290    // Autonomy
291    if let Some(s) = value.get("default_autonomy").and_then(|v| v.as_str()) {
292        next.defaults.autonomy = match s {
293            "supervised" => AutonomyLevel::Supervised,
294            "trusted" => AutonomyLevel::Trusted,
295            "autonomous" => AutonomyLevel::Autonomous,
296            _ => next.defaults.autonomy,
297        };
298    }
299
300    // Sandbox
301    if let Some(s) = value.get("default_sandbox").and_then(|v| v.as_str()) {
302        next.defaults.sandbox = s.to_string();
303    }
304
305    // Surfaces — telegram (most common)
306    if let Some(tg) = value.pointer("/surfaces/telegram") {
307        let enabled = tg.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
308        let allow_users: Vec<String> = tg
309            .get("allow_users")
310            .and_then(|v| v.as_array())
311            .map(|arr| {
312                arr.iter()
313                    .filter_map(|v| {
314                        v.as_str()
315                            .map(String::from)
316                            .or_else(|| v.as_i64().map(|n| n.to_string()))
317                    })
318                    .collect()
319            })
320            .unwrap_or_default();
321        if enabled {
322            next.surfaces.telegram = Some(crate::config::MessagingSurface {
323                enabled: true,
324                allow_users,
325                token_env: Some("TELEGRAM_BOT_TOKEN".into()),
326            });
327        }
328    }
329
330    Ok(next)
331}
332
333fn print_summary(config: &Config, value: &serde_json::Value) {
334    println!("\n══════════════════════════════════════════════════");
335    println!("  Setup terminé");
336    println!("══════════════════════════════════════════════════");
337    let provs: Vec<String> = config.providers.keys().cloned().collect();
338    println!("  Providers     : {}", provs.join(", "));
339    println!(
340        "  Budget        : ${:.2}/day, ${:.2}/session",
341        config.budget.daily_usd, config.budget.session_usd
342    );
343    println!("  Autonomy      : {:?}", config.defaults.autonomy);
344    println!("  Sandbox       : {}", config.defaults.sandbox);
345    if let Some(tg) = &config.surfaces.telegram {
346        if tg.enabled {
347            println!(
348                "  Telegram      : enabled ({} users allowed)",
349                tg.allow_users.len()
350            );
351        }
352    }
353    if let Some(arr) = value.get("missing_keys").and_then(|v| v.as_array()) {
354        if !arr.is_empty() {
355            println!(
356                "  Missing keys  : {}",
357                arr.iter()
358                    .filter_map(|v| v.as_str())
359                    .collect::<Vec<_>>()
360                    .join(", ")
361            );
362        }
363    }
364    println!("\n→ Run 'sparrow doctor' to verify.\n");
365}
366
367/// Last-resort fallback if no Brain is reachable at all.
368async fn fallback_interactive(config: &Config, store: &FsConfigStore) -> anyhow::Result<()> {
369    println!("Fallback minimal: configuring ollama (local) as default.\n");
370    let mut next = config.clone();
371    next.providers.insert(
372        "ollama".into(),
373        ProviderConfig {
374            adapter: "ollama".into(),
375            base_url: Some("http://localhost:11434/v1".into()),
376            models: vec!["qwen3.5:32b".into()],
377            api_key_env: None,
378        },
379    );
380    next.routing
381        .policy
382        .insert("trivial".into(), "ollama".into());
383    next.routing.policy.insert("small".into(), "ollama".into());
384    next.routing.policy.insert("medium".into(), "ollama".into());
385    next.routing.policy.insert("hard".into(), "ollama".into());
386    store.save(&next)?;
387    println!(
388        "Ollama configured locally. Add real provider keys with 'sparrow auth add <provider>'."
389    );
390    Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn apply_setup_json_maps_intent_to_config() {
399        let base = Config::default();
400        let json = serde_json::json!({
401            "providers_to_enable": ["anthropic", "nvidia", "not-a-real-provider"],
402            "routing_policy": {"medium": "nvidia", "hard": "anthropic"},
403            "budget_daily_usd": 5.0,
404            "budget_session_usd": 1.0,
405            "default_autonomy": "trusted",
406            "default_sandbox": "local-hardened",
407            "surfaces": {"telegram": {"enabled": true, "allow_users": [1780070685]}},
408            "missing_keys": ["ANTHROPIC_API_KEY"],
409            "clarifying_question": null
410        });
411        let cfg = apply_setup_json(&base, &json).unwrap();
412        assert!(cfg.providers.contains_key("anthropic"));
413        assert!(cfg.providers.contains_key("nvidia"));
414        assert!(
415            !cfg.providers.contains_key("not-a-real-provider"),
416            "unknown provider must be skipped"
417        );
418        assert_eq!(
419            cfg.routing.policy.get("medium").map(String::as_str),
420            Some("nvidia")
421        );
422        assert_eq!(
423            cfg.routing.policy.get("hard").map(String::as_str),
424            Some("anthropic")
425        );
426        assert_eq!(cfg.budget.daily_usd, 5.0);
427        assert_eq!(cfg.defaults.autonomy, crate::event::AutonomyLevel::Trusted);
428        assert_eq!(cfg.defaults.sandbox, "local-hardened");
429        let tg = cfg.surfaces.telegram.expect("telegram enabled");
430        assert!(tg.enabled);
431        assert_eq!(tg.allow_users, vec!["1780070685".to_string()]);
432    }
433}