Skip to main content

speccoder_runtime/acp/
ready.rs

1//! How ready each agent is to make attempts: installed, signed in, asking
2//! before it acts — each with its one action (an install command of the
3//! person's own, the agent's login in a terminal of theirs, or the reason).
4//!
5//! An installed agent is probed: started, `initialize`d and given one
6//! session in a scratch directory, never a prompt — so it costs nothing
7//! and asks nothing. An `npx` preset is not started: that would download
8//! it; its ticks say what is known without it.
9
10use std::path::Path;
11use std::time::Duration;
12
13use agent_client_protocol::schema::ProtocolVersion;
14use agent_client_protocol::schema::v1::{
15    CloseSessionRequest, InitializeRequest, NewSessionRequest,
16};
17use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent as AgentRole, ConnectionTo};
18use speccoder_core::{AgentCard, AgentTick, TickState};
19
20use super::agent::{Agent, PRESETS, Preset};
21use super::modes::{self, Advertised, ModePlan, Why};
22
23/// How long a probe may take.
24pub const PROBE: Duration = Duration::from_secs(20);
25
26/// What a probe found.
27#[derive(Debug, Clone, Default)]
28pub struct Probed {
29    /// The agent's name and version, as it gave them.
30    pub info: Option<(String, String)>,
31    /// It needs signing in: its ways.
32    pub sign_in: Option<Vec<super::session::SignIn>>,
33    /// Why the probe failed otherwise.
34    pub error: Option<String>,
35    /// What speccoder would do about its mode, and what it would read.
36    pub mode: Option<(
37        ModePlan,
38        Option<&'static modes::ModeProfile>,
39        Option<Advertised>,
40    )>,
41}
42
43/// Starts `argv` in a scratch directory, opens one session and closes it.
44#[must_use]
45pub fn probe(agent: &Agent, argv: &[String]) -> Probed {
46    let Ok(dir) = tempfile::tempdir() else {
47        return Probed {
48            error: Some("no scratch directory".to_owned()),
49            ..Probed::default()
50        };
51    };
52    let Ok(rt) = tokio::runtime::Builder::new_current_thread()
53        .enable_all()
54        .build()
55    else {
56        return Probed {
57            error: Some("the runtime could not start".to_owned()),
58            ..Probed::default()
59        };
60    };
61    let Some((program, args)) = argv.split_first() else {
62        return Probed::default();
63    };
64    let mut config = AcpAgentConfig::new(program).args(args.iter().cloned());
65    if agent.mode.is_none()
66        && let Some((k, v)) = modes::launch_profile(&agent.id).and_then(|p| p.launch_env)
67    {
68        config = config.env(k, modes::launch_value(v, std::env::var(k).ok().as_deref()));
69    }
70    let cwd = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
71    let started = AcpAgent::new(super::session::in_directory(config, &cwd));
72    let out = std::sync::Arc::new(std::sync::Mutex::new(Probed::default()));
73    let result = std::sync::Arc::clone(&out);
74    let preference = agent.mode.clone();
75    let argv = argv.to_vec();
76    let ran = rt.block_on(async move {
77        tokio::time::timeout(
78            PROBE,
79            agent_client_protocol::Client
80                .builder()
81                .name("speccoder")
82                .connect_with(started, move |c: ConnectionTo<AgentRole>| async move {
83                    let p = open(&c, &cwd, preference.as_deref(), &argv).await;
84                    if let Ok(mut r) = result.lock() {
85                        *r = p;
86                    }
87                    Ok(())
88                }),
89        )
90        .await
91    });
92    let mut p = out.lock().map(|p| p.clone()).unwrap_or_default();
93    match ran {
94        Err(_) if p.info.is_none() => {
95            p.error = Some(format!("it did not answer within {} s", PROBE.as_secs()));
96        }
97        Ok(Err(e)) if p.info.is_none() && p.error.is_none() => p.error = Some(e.to_string()),
98        _ => {}
99    }
100    p
101}
102
103async fn open(
104    c: &ConnectionTo<AgentRole>,
105    cwd: &Path,
106    preference: Option<&str>,
107    argv: &[String],
108) -> Probed {
109    let mut p = Probed::default();
110    let init = match c
111        .send_request(
112            InitializeRequest::new(ProtocolVersion::V1)
113                .client_capabilities(super::session::declared()),
114        )
115        .block_task()
116        .await
117    {
118        Ok(i) => i,
119        Err(e) => {
120            p.error = Some(format!("it did not initialize: {}", e.message));
121            return p;
122        }
123    };
124    p.info = init
125        .agent_info
126        .as_ref()
127        .map(|i| (i.name.clone(), i.version.clone()));
128    let methods = serde_json::to_value(&init.auth_methods).unwrap_or_default();
129    let new = match c
130        .send_request(NewSessionRequest::new(cwd.to_path_buf()))
131        .block_task()
132        .await
133    {
134        Ok(n) => n,
135        Err(e) => {
136            if e.code == agent_client_protocol::ErrorCode::AuthRequired {
137                p.sign_in = Some(super::session::sign_in_methods(&methods, argv));
138            }
139            p.error = Some(e.message);
140            return p;
141        }
142    };
143    let setup = serde_json::to_value(&new).unwrap_or_default();
144    let profile = p
145        .info
146        .as_ref()
147        .and_then(|(n, v)| modes::profile_for(n, v, &setup["configOptions"], &setup["modes"]));
148    let advertised = Advertised::read(&setup["configOptions"], &setup["modes"], profile);
149    p.mode = Some((
150        ModePlan::of(profile, advertised.as_ref(), preference),
151        profile,
152        advertised,
153    ));
154    if init.agent_capabilities.session_capabilities.close.is_some() {
155        let _ = tokio::time::timeout(
156            Duration::from_secs(2),
157            c.send_request(CloseSessionRequest::new(new.session_id))
158                .block_task(),
159        )
160        .await;
161    }
162    p
163}
164
165/// The install command for a preset, where there is one this machine can
166/// run.
167fn install(p: &Preset, on_path: &dyn Fn(&str) -> bool) -> AgentTick {
168    let mac = cfg!(target_os = "macos") && on_path("brew");
169    let s = |a: &[&str]| vec![a.iter().map(|w| (*w).to_owned()).collect::<Vec<String>>()];
170    let missing = AgentTick::new(TickState::No, format!("`{}` is not on PATH", p.argv[0]));
171    match p.id {
172        "copilot" if mac => missing.with_command(s(&["brew", "install", "--cask", "copilot-cli"])),
173        "copilot" if on_path("npm") => {
174            missing.with_command(s(&["npm", "install", "-g", "@github/copilot"]))
175        }
176        "opencode" if mac => missing.with_command(s(&["brew", "install", "opencode"])),
177        "opencode" if on_path("npm") => {
178            missing.with_command(s(&["npm", "install", "-g", "opencode-ai"]))
179        }
180        _ if p.argv[0] == "npx" && mac => {
181            AgentTick::new(TickState::No, "it runs through `npx`, which needs Node.js")
182                .with_command(s(&["brew", "install", "node"]))
183        }
184        _ if p.argv[0] == "npx" => {
185            AgentTick::new(TickState::No, "it runs through `npx`, which needs Node.js")
186                .with_words("install Node.js (https://nodejs.org), then come back")
187        }
188        _ => missing.with_words(format!(
189            "install {} and put `{}` on PATH",
190            p.name, p.argv[0]
191        )),
192    }
193}
194
195/// One preset's card; `probe` starts an installed one.
196#[must_use]
197pub fn card(
198    p: &Preset,
199    chosen: Option<&Agent>,
200    on_path: &dyn Fn(&str) -> bool,
201    probe_it: bool,
202) -> AgentCard {
203    let agent = match chosen.filter(|a| a.id == p.id) {
204        Some(a) => a.clone(),
205        None => Agent::of(p, None),
206    };
207    let argv = agent.command(|x| on_path(x));
208    let npx = argv.first().is_some_and(|a| a == "npx");
209    let pin = Agent::npm_pin(&argv).map(|(n, v)| format!("{n}@{v}"));
210    let runs = argv.first().is_some_and(|a| on_path(a));
211    let installed = if runs {
212        match &pin {
213            Some(pin) => AgentTick::new(
214                TickState::Yes,
215                format!("through npx: {pin}, downloaded when it first runs"),
216            ),
217            None => AgentTick::new(TickState::Yes, format!("`{}` on PATH", argv.join(" "))),
218        }
219    } else {
220        install(p, on_path)
221    };
222    let login =
223        || -> Vec<Vec<String>> { vec![p.login.split_whitespace().map(str::to_owned).collect()] };
224    let mut signed_in = AgentTick::new(TickState::Unknown, "checked when it first runs");
225    let mut asks_first = recorded(p, pin.as_deref());
226    if runs && !npx && probe_it {
227        let probed = probe(&agent, &argv);
228        let version = probed.info.as_ref().map(|(_, v)| v.clone());
229        signed_in = match (&probed.sign_in, &probed.error) {
230            (Some(ways), _) => {
231                let mut t = AgentTick::new(
232                    TickState::No,
233                    ways.iter()
234                        .find_map(|w| w.description.clone())
235                        .unwrap_or_else(|| format!("{} needs signing in", p.name)),
236                );
237                let commands: Vec<Vec<String>> = ways
238                    .iter()
239                    .filter_map(|w| w.command.as_deref())
240                    .map(|c| c.split_whitespace().map(str::to_owned).collect())
241                    .collect();
242                for c in if commands.is_empty() {
243                    login()
244                } else {
245                    commands
246                } {
247                    t = t.with_command(vec![c]);
248                }
249                t
250            }
251            (None, Some(e)) => {
252                AgentTick::new(TickState::Unknown, format!("it could not be asked: {e}"))
253            }
254            (None, None) => AgentTick::new(
255                TickState::Yes,
256                format!(
257                    "it opened a session{}",
258                    version
259                        .as_deref()
260                        .map(|v| format!(" ({v})"))
261                        .unwrap_or_default()
262                ),
263            ),
264        };
265        if let Some((plan, profile, advertised)) = &probed.mode {
266            asks_first = asks(p, plan, *profile, advertised.as_ref());
267        }
268    } else if runs && !npx {
269        signed_in = AgentTick::new(TickState::Unknown, "not checked (doctor --agents asks it)");
270    } else if npx {
271        signed_in = AgentTick::new(
272            TickState::Unknown,
273            "checked when it first runs: npx downloads it then",
274        );
275    }
276    AgentCard {
277        id: p.id.to_owned(),
278        name: p.name.to_owned(),
279        chosen: chosen.is_some_and(|a| a.id == p.id),
280        installed,
281        signed_in,
282        asks_first,
283    }
284}
285
286/// The asks-first tick from a live set-up answer.
287fn asks(
288    p: &Preset,
289    plan: &ModePlan,
290    profile: Option<&modes::ModeProfile>,
291    advertised: Option<&Advertised>,
292) -> AgentTick {
293    let name = |v: &str| advertised.map_or_else(|| v.to_owned(), |a| a.name_of(v));
294    if profile.is_some_and(|p| !p.recorded)
295        && matches!(plan.why, Why::AskedForAskFirst | Why::AsksAlready)
296    {
297        return AgentTick::new(
298            TickState::Unknown,
299            "its ask-first mode is known from its source only, not recorded: not known whether it asks",
300        );
301    }
302    match (plan.why, &plan.request) {
303        (Why::AskedForAskFirst, Some(v)) => AgentTick::new(
304            TickState::Yes,
305            format!("put into {} before each attempt", name(v)),
306        ),
307        (Why::AsksAlready, _) if profile.is_some_and(|p| p.launch_env.is_some()) => AgentTick::new(
308            TickState::Yes,
309            "started with every permission rule set to ask",
310        ),
311        (Why::AsksAlready, _) => AgentTick::new(
312            TickState::Yes,
313            format!(
314                "it asks first: {}",
315                advertised.map_or_else(String::new, |a| a.name_of(&a.current))
316            ),
317        ),
318        (Why::YouChose, _) => {
319            AgentTick::new(TickState::Unknown, "you chose its mode ([executor] mode)")
320        }
321        (Why::YouSetIt, _) => AgentTick::new(
322            TickState::No,
323            format!(
324                "its own settings put it in {}, which speccoder keeps: it decides some calls alone",
325                advertised.map_or_else(|| "another mode".to_owned(), |a| a.name_of(&a.current))
326            ),
327        )
328        .with_words(format!(
329            "set {} back to its ask-first mode in its own settings",
330            p.name
331        )),
332        (Why::Unprofiled, _) => AgentTick::new(
333            TickState::Unknown,
334            "no recording of this version says which mode asks first: it may decide alone",
335        ),
336        _ => AgentTick::new(
337            TickState::No,
338            "it offers no mode known to ask first: it may decide alone",
339        ),
340    }
341}
342
343/// The asks-first tick from the recordings alone.
344fn recorded(p: &Preset, pin: Option<&str>) -> AgentTick {
345    let Some(profile) = modes::launch_profile(p.id) else {
346        return AgentTick::new(
347            TickState::Unknown,
348            "no recording says which mode asks first",
349        );
350    };
351    if !profile.recorded {
352        return AgentTick::new(
353            TickState::Unknown,
354            "its ask-first mode is known from its source only, not recorded",
355        );
356    }
357    let version = pin.and_then(|x| x.rsplit('@').next());
358    match version {
359        Some(v) if v != profile.agent_info.1 => AgentTick::new(
360            TickState::Unknown,
361            format!(
362                "recorded for {}; {v} is checked against it when it runs",
363                profile.agent_info.1
364            ),
365        ),
366        _ => AgentTick::new(
367            TickState::Yes,
368            format!(
369                "recorded for {} {}",
370                profile.agent_info.0, profile.agent_info.1
371            ),
372        ),
373    }
374}
375
376/// Every preset's card; installed ones probed (in parallel) when `probe`.
377#[must_use]
378pub fn cards(chosen: Option<&Agent>, probe_them: bool) -> Vec<AgentCard> {
379    let on_path = |x: &str| super::agent::on_path(x);
380    std::thread::scope(|s| {
381        let handles: Vec<_> = PRESETS
382            .iter()
383            .map(|p| s.spawn(move || card(p, chosen, &on_path, probe_them)))
384            .collect();
385        handles.into_iter().filter_map(|h| h.join().ok()).collect()
386    })
387}
388
389/// The next agent to try after `current`: the first ready-looking preset
390/// after it (installed; not known to need a sign-in), in preset order.
391#[must_use]
392pub fn next_after(
393    current: Option<&str>,
394    on_path: &dyn Fn(&str) -> bool,
395) -> Option<&'static Preset> {
396    let start = current
397        .and_then(|c| PRESETS.iter().position(|p| p.id == c))
398        .map_or(0, |i| i + 1);
399    PRESETS
400        .iter()
401        .cycle()
402        .skip(start)
403        .take(PRESETS.len())
404        .filter(|p| Some(p.id) != current)
405        .find(|p| {
406            Agent::of(p, None)
407                .command(on_path)
408                .first()
409                .is_some_and(|a| on_path(a))
410        })
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn preset(id: &str) -> &'static Preset {
418        super::super::agent::preset(id).unwrap_or(&PRESETS[0])
419    }
420
421    #[test]
422    fn a_missing_agent_has_its_install_command_and_an_npx_one_is_not_started() {
423        let none = |_: &str| false;
424        let c = card(preset("opencode"), None, &none, true);
425        assert_eq!(c.installed.state, TickState::No);
426        assert!(!c.installed.fix.is_empty(), "{:?}", c.installed);
427        // With npm (and no Homebrew): its install, run as the person's own
428        // command, outside the project — and said so.
429        let npm = |x: &str| x == "npm";
430        let c = card(preset("opencode"), None, &npm, true);
431        let shown = serde_json::to_value(&c.installed).unwrap_or_default();
432        let step = &shown["fix_steps"][0];
433        assert_eq!(
434            step["argvs"],
435            serde_json::json!([["npm", "install", "-g", "opencode-ai"]]),
436            "{shown}"
437        );
438        assert_eq!(step["how"], "command", "{shown}");
439        assert!(
440            step["outside"]
441                .as_str()
442                .unwrap_or_default()
443                .starts_with("installs "),
444            "{shown}"
445        );
446        let npx = |x: &str| x == "npx";
447        let c = card(preset("claude"), None, &npx, true);
448        assert_eq!(c.installed.state, TickState::Yes);
449        assert!(
450            c.installed
451                .detail
452                .contains("@agentclientprotocol/claude-agent-acp@0.98.0")
453        );
454        assert_eq!(c.signed_in.state, TickState::Unknown);
455        // Recorded for another version than pinned: not known until it runs.
456        assert_eq!(c.asks_first.state, TickState::Unknown);
457        let c = card(preset("copilot"), None, &npx, false);
458        assert_eq!(c.asks_first.state, TickState::Yes, "{:?}", c.asks_first);
459    }
460
461    #[test]
462    fn the_next_agent_is_the_next_installed_one() {
463        let have = |x: &str| x == "opencode" || x == "copilot";
464        assert_eq!(
465            next_after(Some("opencode"), &have).map(|p| p.id),
466            Some("copilot")
467        );
468        let npx = |x: &str| x == "opencode" || x == "npx";
469        assert_eq!(
470            next_after(Some("opencode"), &npx).map(|p| p.id),
471            Some("claude")
472        );
473        let only = |x: &str| x == "opencode";
474        assert_eq!(next_after(Some("opencode"), &only), None);
475        assert_eq!(next_after(None, &only).map(|p| p.id), Some("opencode"));
476    }
477}