Skip to main content

speccoder_runtime/acp/
session.rs

1//! One attempt as an ACP session: the agent started in the worktree,
2//! `initialize`, `session/new`, its permission mode settled, one
3//! `session/prompt`, and every request it makes in between answered by
4//! the [`Governor`] — until the turn ends, the attempt's time is up
5//! (`session/cancel`, then the process group is killed), or a call waits for
6//! a person nobody can ask.
7//!
8//! Adapted from devplane's `acp::session` (same owner, MIT OR Apache-2.0):
9//! the set-up, the mode request, the cancel and the sign-in reading; the
10//! watched sessions, questions and resume are left out — an attempt is one
11//! fresh session.
12
13use std::collections::{HashMap, VecDeque};
14use std::path::PathBuf;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use agent_client_protocol::schema::ProtocolVersion;
19use agent_client_protocol::schema::v1::{
20    AuthCapabilities, CancelNotification, ClientCapabilities, CloseSessionRequest, ContentBlock,
21    CreateTerminalRequest, CreateTerminalResponse, FileSystemCapabilities, InitializeRequest,
22    KillTerminalRequest, KillTerminalResponse, NewSessionRequest, PromptRequest,
23    ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
24    RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
25    SelectedPermissionOutcome, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
26    SetSessionModeRequest, StopReason, TerminalExitStatus, TerminalOutputRequest,
27    TerminalOutputResponse, TextContent, WaitForTerminalExitRequest, WaitForTerminalExitResponse,
28    WriteTextFileRequest, WriteTextFileResponse,
29};
30use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent as AgentRole, ConnectionTo};
31use serde_json::{Value, json};
32use tokio::sync::Notify;
33
34use super::agent::Agent;
35use super::calls::{ToolReport, ToolRequest};
36use super::govern::{Governor, PermissionOption};
37use super::modes::{self, Advertised, ModePlan, Outcome, Statement};
38use super::terminal::Terminals;
39use crate::progress::{Event, Progress};
40
41/// How long a cancelled turn has to acknowledge before the process group is
42/// killed anyway.
43pub const CANCEL_GRACE: Duration = Duration::from_secs(5);
44
45/// How long an agent that offers `session/close` has to answer one.
46const CLOSE_GRACE: Duration = Duration::from_secs(2);
47
48/// How long the agent has to answer the mode (or model) request before the
49/// prompt goes anyway, reported as *did not answer*.
50const MODE_GRACE: Duration = Duration::from_secs(10);
51
52/// The one protocol version spoken.
53const SPOKEN: u64 = 1;
54
55/// Lines of the agent's stderr kept, for a failure's reason.
56const STDERR_KEPT: usize = 5;
57
58/// What an attempt is started with.
59#[derive(Debug, Clone)]
60pub struct Attempt {
61    /// The agent.
62    pub agent: Agent,
63    /// Its command line, as it is started.
64    pub argv: Vec<String>,
65    /// The worktree.
66    pub cwd: PathBuf,
67    /// The task prompt.
68    pub prompt: String,
69    /// `[executor] model`.
70    pub model: Option<String>,
71    /// How long the attempt may take (a person's time deciding excluded).
72    pub timeout: Duration,
73    /// The runner terminal commands go through.
74    pub runner: crate::sandbox::Runner,
75    /// Where the agent's words and calls go (task id, events).
76    pub progress: Option<(String, Arc<dyn Progress>)>,
77}
78
79/// A way the agent says it can be signed in.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct SignIn {
82    /// Its id.
83    pub id: String,
84    /// The agent's name for it.
85    pub name: String,
86    /// The agent's words about it.
87    pub description: Option<String>,
88    /// The command a person runs in a terminal, where the agent names one.
89    pub command: Option<String>,
90}
91
92/// How the attempt ended.
93#[derive(Debug, Clone, Default)]
94pub struct Finished {
95    /// The agent's name, as it gave it.
96    pub agent_name: Option<String>,
97    /// The agent's final message (its words after its last call).
98    pub message: String,
99    /// `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, `cancelled`.
100    pub stop_reason: Option<String>,
101    /// Why it ended early.
102    pub error: Option<String>,
103    /// The time was up.
104    pub timed_out: bool,
105    /// The agent needs signing in; its own ways.
106    pub sign_in: Option<Vec<SignIn>>,
107    /// What its permission mode is, and why.
108    pub mode: Option<Statement>,
109    /// Lines worth saying on the run (the model, the mode).
110    pub notices: Vec<String>,
111    /// Tokens, as the agent reported them.
112    pub usage: Option<Value>,
113    /// The last lines the agent wrote to stderr.
114    pub stderr: Vec<String>,
115    /// The agent's plan as it last sent it: `{content, status}` each.
116    pub plan: Vec<Value>,
117    /// The agent moved to a mode that decides without asking: its turn
118    /// was cancelled, and why.
119    pub left: Option<String>,
120    /// The prompt was sent: whatever stopped it, the attempt happened.
121    pub prompted: bool,
122}
123
124/// The agent's words, kept for the narration and the final message.
125#[derive(Debug, Default)]
126struct Words {
127    /// Since the last call: shown as one `said`.
128    pending: String,
129    /// Reasoning since the last call.
130    thinking: String,
131    /// Everything said since the last call (the final message at the end).
132    last: String,
133    /// The calls, by id.
134    calls: HashMap<String, ToolRequest>,
135    /// Calls already recorded as finished.
136    finished: std::collections::HashSet<String>,
137    /// Model turns so far (a call ends one).
138    turn: u32,
139    usage_usd: Option<f64>,
140    plan: Vec<Value>,
141}
142
143struct Shared {
144    governor: Arc<Governor>,
145    words: Mutex<Words>,
146    terminals: Terminals,
147    runner: crate::sandbox::Runner,
148    progress: Option<(String, Arc<dyn Progress>)>,
149    deadline: Instant,
150    /// Raised to end the turn (a call waits, the agent left its mode).
151    stop: Arc<Notify>,
152    /// The mode as set up, to tell a move away from it.
153    watch: Mutex<Option<Watch>>,
154    /// Why the turn was ended: the agent moved to a mode that decides
155    /// without asking.
156    left: Mutex<Option<String>>,
157}
158
159/// What a session's mode is read against once it is set up.
160struct Watch {
161    agent: String,
162    profile: &'static modes::ModeProfile,
163    advertised: Option<Advertised>,
164}
165
166impl Shared {
167    /// A move of the agent's mode (`value`) or of its config options: one
168    /// that decides without asking ends the turn — what it would do next
169    /// would never reach speccoder.
170    fn moved(&self, value: Option<String>, options: Option<&Value>) {
171        let Ok(watch) = self.watch.lock() else { return };
172        let Some(w) = watch.as_ref() else { return };
173        let mut why = None;
174        if let Some(v) = &value {
175            let (reading, decides) = w.profile.reading(v);
176            // A value no recording knows is not trusted to ask.
177            let decides = decides.or_else(|| {
178                (reading == modes::Reading::NotKnown
179                    && !w.profile.values.iter().any(|(x, _)| x == v))
180                .then(|| "a mode no recording of this version knows".to_owned())
181            });
182            if reading == modes::Reading::DecidesWithoutYou || decides.is_some() {
183                let name = w
184                    .advertised
185                    .as_ref()
186                    .map_or_else(|| v.clone(), |a| a.name_of(v));
187                why = Some(format!(
188                    "{} moved to {name}, which decides without asking ({})",
189                    w.agent,
190                    decides.unwrap_or_default()
191                ));
192            }
193        }
194        if let (Some((id, asks)), Some(o)) = (w.profile.guard, options)
195            && let Some(now) = modes::current_of(o, id)
196            && now != asks
197        {
198            why = Some(format!(
199                "{} turned {id} to {now}: it decides without asking",
200                w.agent
201            ));
202        }
203        drop(watch);
204        if let Some(why) = why {
205            if let Ok(mut l) = self.left.lock() {
206                l.get_or_insert(why.clone());
207            }
208            self.governor.stop();
209            self.stop.notify_one();
210            self.event(Event::Notice {
211                text: format!("{why}: its turn was cancelled"),
212            });
213        }
214    }
215}
216
217impl Shared {
218    fn event(&self, e: Event) {
219        if let Some((_, p)) = &self.progress {
220            p.event(&e);
221        }
222    }
223
224    /// The words since the last call, as one `said` (and the reasoning).
225    fn flush(&self) {
226        let Ok(mut w) = self.words.lock() else { return };
227        let task = self
228            .progress
229            .as_ref()
230            .map(|(t, _)| t.clone())
231            .unwrap_or_default();
232        let turn = w.turn + 1;
233        let thinking = std::mem::take(&mut w.thinking);
234        if !thinking.trim().is_empty() {
235            self.event(Event::Reasoning {
236                task: task.clone(),
237                turn,
238                text: crate::approvals::present::escape(&crate::secrets::mask(thinking.trim()))
239                    .chars()
240                    .take(4000)
241                    .collect(),
242            });
243        }
244        let said = std::mem::take(&mut w.pending);
245        if !said.trim().is_empty() {
246            self.event(Event::Said {
247                task,
248                turn,
249                text: crate::approvals::present::escape(&crate::secrets::mask(said.trim()))
250                    .chars()
251                    .take(2000)
252                    .collect(),
253            });
254        }
255    }
256
257    /// A report of a call: merged, and recorded once it finished.
258    fn call(&self, report: &ToolReport) {
259        self.flush();
260        let finished = {
261            let Ok(mut w) = self.words.lock() else { return };
262            w.last.clear();
263            let c = w
264                .calls
265                .entry(report.id.clone())
266                .or_insert_with(|| ToolRequest {
267                    id: report.id.clone(),
268                    ..ToolRequest::default()
269                });
270            c.merge(report);
271            let c = c.clone();
272            let done = matches!(c.status.as_deref(), Some("completed" | "failed"));
273            if done && w.finished.insert(c.id.clone()) {
274                w.turn += 1;
275                Some(c)
276            } else {
277                None
278            }
279        };
280        if let Some(c) = finished {
281            self.governor.reported(&c);
282        }
283    }
284
285    fn update(&self, update: SessionUpdate) {
286        match update {
287            SessionUpdate::AgentMessageChunk(c) => {
288                if let ContentBlock::Text(t) = &c.content {
289                    self.governor.said(&t.text);
290                    if let Ok(mut w) = self.words.lock() {
291                        w.pending.push_str(&t.text);
292                        w.last.push_str(&t.text);
293                    }
294                }
295            }
296            SessionUpdate::AgentThoughtChunk(c) => {
297                if let (ContentBlock::Text(t), Ok(mut w)) = (&c.content, self.words.lock()) {
298                    w.thinking.push_str(&t.text);
299                }
300            }
301            SessionUpdate::ToolCall(t) => {
302                // A first report's status and kind have schema defaults that
303                // are not written on the wire: read from the typed call.
304                let mut report = ToolReport::from_wire(
305                    t.tool_call_id.to_string(),
306                    &serde_json::to_value(&t).unwrap_or_default(),
307                );
308                report.status = wire(&t.status);
309                report.kind = wire(&t.kind);
310                self.call(&report);
311            }
312            SessionUpdate::ToolCallUpdate(t) => {
313                let report = ToolReport::from_wire(
314                    t.tool_call_id.to_string(),
315                    &serde_json::to_value(&t.fields).unwrap_or_default(),
316                );
317                self.call(&report);
318            }
319            SessionUpdate::Plan(p) => {
320                let entries: Vec<Value> = p
321                    .entries
322                    .iter()
323                    .map(|e| json!({ "content": crate::secrets::mask(&e.content), "status": wire(&e.status).unwrap_or_else(|| "pending".to_owned()) }))
324                    .collect();
325                if let Ok(mut w) = self.words.lock() {
326                    w.plan.clone_from(&entries);
327                }
328                self.event(Event::AgentPlan {
329                    task: self
330                        .progress
331                        .as_ref()
332                        .map(|(t, _)| t.clone())
333                        .unwrap_or_default(),
334                    entries: entries
335                        .iter()
336                        .map(|e| crate::progress::PlanEntry {
337                            content: crate::approvals::present::escape(
338                                e["content"].as_str().unwrap_or_default(),
339                            )
340                            .chars()
341                            .take(200)
342                            .collect(),
343                            status: e["status"].as_str().unwrap_or("pending").to_owned(),
344                        })
345                        .collect(),
346                });
347            }
348            SessionUpdate::UsageUpdate(u) => {
349                if let (Some(c), Ok(mut w)) = (u.cost.as_ref(), self.words.lock())
350                    && c.currency.eq_ignore_ascii_case("usd")
351                {
352                    w.usage_usd = Some(c.amount);
353                }
354            }
355            SessionUpdate::CurrentModeUpdate(m) => {
356                self.moved(Some(m.current_mode_id.to_string()), None);
357            }
358            SessionUpdate::ConfigOptionUpdate(u) => {
359                let options = serde_json::to_value(&u.config_options).unwrap_or_default();
360                let value = self.watch.lock().ok().and_then(|w| {
361                    let w = w.as_ref()?;
362                    match w.advertised.as_ref().map(|a| &a.via) {
363                        Some(modes::Via::Config(id)) => modes::current_of(&options, id),
364                        _ => None,
365                    }
366                });
367                self.moved(value, Some(&options));
368            }
369            _ => {}
370        }
371    }
372}
373
374/// The wire name of a protocol enum, asked of serde.
375fn wire<T: serde::Serialize>(value: &T) -> Option<String> {
376    match serde_json::to_value(value).ok()? {
377        Value::String(s) => Some(s),
378        _ => None,
379    }
380}
381
382fn stop_reason_name(r: StopReason) -> &'static str {
383    match r {
384        StopReason::EndTurn => "end_turn",
385        StopReason::MaxTokens => "max_tokens",
386        StopReason::MaxTurnRequests => "max_turn_requests",
387        StopReason::Refusal => "refusal",
388        StopReason::Cancelled => "cancelled",
389        _ => "unknown",
390    }
391}
392
393/// The sign-in methods the agent advertised, in its own words.
394pub(super) fn sign_in_methods(methods: &Value, argv: &[String]) -> Vec<SignIn> {
395    methods
396        .as_array()
397        .into_iter()
398        .flatten()
399        .map(|m| {
400            let s = |k: &str| m[k].as_str().map(str::to_owned);
401            let legacy = &m["_meta"]["terminal-auth"];
402            let words = |command: &str, args: &Value| {
403                let mut line = vec![command.to_owned()];
404                line.extend(
405                    args.as_array()
406                        .into_iter()
407                        .flatten()
408                        .filter_map(|a| a.as_str().map(str::to_owned)),
409                );
410                line.join(" ")
411            };
412            let command = if m["type"] == "terminal" {
413                Some(words(&argv.join(" "), &m["args"]))
414            } else {
415                legacy["command"].as_str().map(|c| {
416                    let program = std::path::Path::new(c)
417                        .file_name()
418                        .map_or_else(|| c.to_owned(), |n| n.to_string_lossy().into_owned());
419                    words(&program, &legacy["args"])
420                })
421            };
422            SignIn {
423                id: s("id").unwrap_or_default(),
424                name: s("name").unwrap_or_default(),
425                description: s("description"),
426                command,
427            }
428        })
429        .collect()
430}
431
432/// What the client declares: the file system and terminals, served
433/// through speccoder; terminal sign-in, so an agent names its login.
434pub(super) fn declared() -> ClientCapabilities {
435    ClientCapabilities::new()
436        .fs(FileSystemCapabilities::new()
437            .read_text_file(true)
438            .write_text_file(true))
439        .terminal(true)
440        .auth(AuthCapabilities::new().terminal(true))
441}
442
443/// The launch configuration, run from `dir`: the protocol crate starts the
444/// agent in this process's directory, and an adapter resolves its own
445/// configuration and `npx` packages from where it starts — so the command
446/// is started through `sh`, which changes directory and `exec`s it.
447pub(super) fn in_directory(config: AcpAgentConfig, dir: &std::path::Path) -> AcpAgentConfig {
448    if cfg!(not(unix)) {
449        return config;
450    }
451    AcpAgentConfig::new("/bin/sh")
452        .args(["-c", "cd \"$0\" && exec \"$@\""])
453        .arg(dir.to_string_lossy().into_owned())
454        .arg(config.command().to_string_lossy().into_owned())
455        .args(config.arguments().iter().cloned())
456        .envs(config.environment().clone())
457}
458
459/// Runs the attempt to its end.
460#[must_use]
461pub fn run(attempt: &Attempt, governor: Arc<Governor>) -> Finished {
462    let rt = match tokio::runtime::Builder::new_current_thread()
463        .enable_all()
464        .build()
465    {
466        Ok(rt) => rt,
467        Err(e) => {
468            return Finished {
469                error: Some(format!("the runtime could not start: {e}")),
470                ..Finished::default()
471            };
472        }
473    };
474    let finished = rt.block_on(drive(attempt, governor));
475    // A person still being asked (the agent withdrew the request, or the
476    // turn ended) holds a blocking task: the attempt does not wait for it.
477    rt.shutdown_timeout(Duration::from_millis(200));
478    finished
479}
480
481async fn drive(attempt: &Attempt, governor: Arc<Governor>) -> Finished {
482    let Some((program, args)) = attempt.argv.split_first() else {
483        return Finished {
484            error: Some("the agent's command is empty".to_owned()),
485            ..Finished::default()
486        };
487    };
488    let stderr: Arc<Mutex<VecDeque<String>>> = Arc::default();
489    let mut config = AcpAgentConfig::new(program).args(args.iter().cloned());
490    // An agent whose modes do not govern permissions is told to ask where
491    // it starts — unless the person chose its mode.
492    if attempt.agent.mode.is_none()
493        && let Some((k, v)) = modes::launch_profile(&attempt.agent.id).and_then(|p| p.launch_env)
494    {
495        config = config.env(k, modes::launch_value(v, std::env::var(k).ok().as_deref()));
496    }
497    let lines = Arc::clone(&stderr);
498    let agent = AcpAgent::new(in_directory(config, &attempt.cwd)).with_debug(move |line, dir| {
499        if dir == agent_client_protocol::LineDirection::Stderr
500            && !line.trim().is_empty()
501            && let Ok(mut l) = lines.lock()
502        {
503            l.push_back(
504                crate::secrets::mask(line.trim())
505                    .chars()
506                    .take(500)
507                    .collect(),
508            );
509            while l.len() > STDERR_KEPT {
510                l.pop_front();
511            }
512        }
513    });
514    let shared = Arc::new(Shared {
515        governor: Arc::clone(&governor),
516        words: Mutex::new(Words::default()),
517        terminals: Terminals::default(),
518        runner: attempt.runner.clone(),
519        progress: attempt.progress.clone(),
520        deadline: Instant::now() + attempt.timeout,
521        stop: Arc::new(Notify::new()),
522        watch: Mutex::new(None),
523        left: Mutex::new(None),
524    });
525    let stop = Arc::clone(&shared.stop);
526    let result: Arc<Mutex<Finished>> = Arc::default();
527
528    let (s1, s2, s3, s4, s5, s6, s7, s8) = (
529        Arc::clone(&shared),
530        Arc::clone(&shared),
531        Arc::clone(&shared),
532        Arc::clone(&shared),
533        Arc::clone(&shared),
534        Arc::clone(&shared),
535        Arc::clone(&shared),
536        Arc::clone(&shared),
537    );
538    let stop_for_perm = Arc::clone(&stop);
539    let stop_for_fs = Arc::clone(&stop);
540    let stop_for_term = Arc::clone(&stop);
541    let result_for_run = Arc::clone(&result);
542    let connected = agent_client_protocol::Client
543        .builder()
544        .name("speccoder")
545        .on_receive_notification(
546            async move |n: SessionNotification, _cx| {
547                s1.update(n.update);
548                Ok(())
549            },
550            agent_client_protocol::on_receive_notification!(),
551        )
552        .on_receive_request(
553            async move |request: RequestPermissionRequest, responder, cx| {
554                let shared = Arc::clone(&s2);
555                let stop = Arc::clone(&stop_for_perm);
556                cx.spawn(async move {
557                    let report = ToolReport::from_wire(
558                        request.tool_call.tool_call_id.to_string(),
559                        &serde_json::to_value(&request.tool_call.fields).unwrap_or_default(),
560                    );
561                    shared.flush();
562                    let call = {
563                        let Ok(mut w) = shared.words.lock() else {
564                            return responder.respond(RequestPermissionResponse::new(
565                                RequestPermissionOutcome::Cancelled,
566                            ));
567                        };
568                        let c = w
569                            .calls
570                            .entry(report.id.clone())
571                            .or_insert_with(|| ToolRequest {
572                                id: report.id.clone(),
573                                ..ToolRequest::default()
574                            });
575                        c.merge(&report);
576                        let c = c.clone();
577                        w.last.clear();
578                        c
579                    };
580                    let options: Vec<PermissionOption> = request
581                        .options
582                        .iter()
583                        .map(|o| PermissionOption {
584                            id: o.option_id.to_string(),
585                            kind: wire(&o.kind).unwrap_or_default(),
586                        })
587                        .collect();
588                    let governor = Arc::clone(&shared.governor);
589                    let reject = options
590                        .iter()
591                        .find(|o| o.kind.starts_with("reject"))
592                        .map(|o| o.id.clone());
593                    let asked = call.clone();
594                    // A decision that fails (a panic) is a refusal.
595                    let answer = match tokio::task::spawn_blocking(move || {
596                        governor.permission(&call, &options)
597                    })
598                    .await
599                    {
600                        Ok(a) => a,
601                        Err(_) => {
602                            shared.governor.abandon(&asked);
603                            reject
604                        }
605                    };
606                    if shared.governor.stopped() {
607                        stop.notify_one();
608                    }
609                    responder.respond(RequestPermissionResponse::new(match answer {
610                        Some(id) => {
611                            RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id))
612                        }
613                        None => RequestPermissionOutcome::Cancelled,
614                    }))
615                })
616            },
617            agent_client_protocol::on_receive_request!(),
618        )
619        .on_receive_request(
620            async move |request: ReadTextFileRequest, responder, cx| {
621                let shared = Arc::clone(&s3);
622                cx.spawn(async move {
623                    match shared
624                        .governor
625                        .read_text_file(&request.path, request.line, request.limit)
626                        .await
627                    {
628                        Ok(content) => responder.respond(ReadTextFileResponse::new(content)),
629                        Err(e) => responder.respond_with_error(refused(&e)),
630                    }
631                })
632            },
633            agent_client_protocol::on_receive_request!(),
634        )
635        .on_receive_request(
636            async move |request: WriteTextFileRequest, responder, cx| {
637                let shared = Arc::clone(&s4);
638                let stop = Arc::clone(&stop_for_fs);
639                cx.spawn(async move {
640                    let governor = Arc::clone(&shared.governor);
641                    let (path, content) = (request.path.clone(), request.content.clone());
642                    // A write may wait for a person: off the connection.
643                    let written = tokio::task::spawn_blocking(move || {
644                        tokio::runtime::Handle::current()
645                            .block_on(governor.write_text_file(&path, &content))
646                    })
647                    .await
648                    .unwrap_or_else(|e| Err(e.to_string()));
649                    if shared.governor.stopped() {
650                        stop.notify_one();
651                    }
652                    match written {
653                        Ok(()) => responder.respond(WriteTextFileResponse::new()),
654                        Err(e) => responder.respond_with_error(refused(&e)),
655                    }
656                })
657            },
658            agent_client_protocol::on_receive_request!(),
659        )
660        .on_receive_request(
661            async move |request: CreateTerminalRequest, responder, cx| {
662                let shared = Arc::clone(&s5);
663                let stop = Arc::clone(&stop_for_term);
664                cx.spawn(async move {
665                    let mut argv = if request.args.is_empty() {
666                        super::govern::argv_of_line(&request.command)
667                    } else {
668                        vec![request.command.clone()]
669                    };
670                    argv.extend(request.args.iter().cloned());
671                    let cwd = request.cwd.clone();
672                    let limit = request
673                        .output_byte_limit
674                        .and_then(|n| usize::try_from(n).ok());
675                    let worker = Arc::clone(&shared);
676                    let created = tokio::task::spawn_blocking(move || {
677                        let (argv, dir, env) = worker.governor.command(argv, cwd.as_deref())?;
678                        let left = worker.deadline.saturating_duration_since(Instant::now());
679                        let most = Duration::from_secs(crate::tools::MAX_COMMAND_SECS);
680                        worker.terminals.create(
681                            &worker.runner,
682                            &argv,
683                            dir,
684                            env,
685                            limit,
686                            left.min(most),
687                        )
688                    })
689                    .await
690                    .unwrap_or_else(|e| Err(e.to_string()));
691                    if shared.governor.stopped() {
692                        stop.notify_one();
693                    }
694                    match created {
695                        Ok(id) => responder.respond(CreateTerminalResponse::new(id)),
696                        Err(e) => responder.respond_with_error(refused(&e)),
697                    }
698                })
699            },
700            agent_client_protocol::on_receive_request!(),
701        )
702        .on_receive_request(
703            async move |request: TerminalOutputRequest, responder, _cx| match s6
704                .terminals
705                .output(&request.terminal_id.to_string())
706            {
707                Ok((output, truncated, exit)) => responder.respond(
708                    TerminalOutputResponse::new(output, truncated)
709                        .exit_status(exit.map(exit_status)),
710                ),
711                Err(e) => responder.respond_with_error(refused(&e)),
712            },
713            agent_client_protocol::on_receive_request!(),
714        )
715        .on_receive_request(
716            async move |request: WaitForTerminalExitRequest, responder, cx| {
717                let shared = Arc::clone(&s7);
718                cx.spawn(async move {
719                    let id = request.terminal_id.to_string();
720                    let worker = Arc::clone(&shared);
721                    let exit = tokio::task::spawn_blocking(move || worker.terminals.wait(&id))
722                        .await
723                        .unwrap_or_else(|e| Err(e.to_string()));
724                    match exit {
725                        Ok(e) => {
726                            responder.respond(WaitForTerminalExitResponse::new(exit_status(e)))
727                        }
728                        Err(e) => responder.respond_with_error(refused(&e)),
729                    }
730                })
731            },
732            agent_client_protocol::on_receive_request!(),
733        )
734        .on_receive_request(
735            async move |request: KillTerminalRequest, responder, _cx| match s8
736                .terminals
737                .kill(&request.terminal_id.to_string())
738            {
739                Ok(()) => responder.respond(KillTerminalResponse::new()),
740                Err(e) => responder.respond_with_error(refused(&e)),
741            },
742            agent_client_protocol::on_receive_request!(),
743        )
744        .on_receive_request(
745            {
746                let shared = Arc::clone(&shared);
747                async move |request: ReleaseTerminalRequest, responder, _cx| match shared
748                    .terminals
749                    .release(&request.terminal_id.to_string())
750                {
751                    Ok(()) => responder.respond(ReleaseTerminalResponse::new()),
752                    Err(e) => responder.respond_with_error(refused(&e)),
753                }
754            },
755            agent_client_protocol::on_receive_request!(),
756        )
757        .connect_with(agent, {
758            let shared = Arc::clone(&shared);
759            let stop = Arc::clone(&stop);
760            let attempt = attempt.clone();
761            move |connection: ConnectionTo<AgentRole>| async move {
762                let finished = session(&connection, &attempt, &shared, &stop).await;
763                if let Ok(mut r) = result_for_run.lock() {
764                    *r = finished;
765                }
766                Ok(())
767            }
768        })
769        .await;
770
771    shared.terminals.kill_all();
772    shared.flush();
773    let mut finished = result.lock().map(|r| r.clone()).unwrap_or_default();
774    finished.stderr = stderr
775        .lock()
776        .map(|l| l.iter().cloned().collect())
777        .unwrap_or_default();
778    // Everything the agent or its connection said, masked.
779    finished.error = finished.error.as_deref().map(crate::secrets::mask);
780    finished.agent_name = finished.agent_name.as_deref().map(crate::secrets::mask);
781    for n in &mut finished.notices {
782        *n = crate::secrets::mask(n);
783    }
784    if let Err(e) = connected
785        && finished.error.is_none()
786        && finished.stop_reason.is_none()
787    {
788        finished.error = Some(match finished.stderr.last() {
789            Some(line) => format!("{e}: {line}"),
790            None => e.to_string(),
791        });
792    }
793    if let Ok(w) = shared.words.lock() {
794        finished.plan.clone_from(&w.plan);
795    }
796    if let Ok(l) = shared.left.lock() {
797        finished.left.clone_from(&l);
798    }
799    if let Ok(w) = shared.words.lock()
800        && let Some(usd) = w.usage_usd
801    {
802        let usage = finished.usage.get_or_insert_with(|| json!({}));
803        usage["cost_usd"] = json!(usd);
804    }
805    finished
806}
807
808fn exit_status(e: super::terminal::Exit) -> TerminalExitStatus {
809    TerminalExitStatus::new().exit_code(e.code).signal(e.signal)
810}
811
812/// A refusal the agent reads, in speccoder's words.
813fn refused(message: &str) -> agent_client_protocol::Error {
814    agent_client_protocol::Error::new(
815        agent_client_protocol::schema::v1::ErrorCode::InvalidRequest.into(),
816        message.to_owned(),
817    )
818}
819
820/// The session itself, on the connection.
821async fn session(
822    connection: &ConnectionTo<AgentRole>,
823    attempt: &Attempt,
824    shared: &Arc<Shared>,
825    stop: &Arc<Notify>,
826) -> Finished {
827    let mut finished = Finished::default();
828    let init = match connection
829        .send_request(InitializeRequest::new(ProtocolVersion::V1).client_capabilities(declared()))
830        .block_task()
831        .await
832    {
833        Ok(i) => i,
834        Err(e) => {
835            finished.error = Some(format!("the agent did not initialize: {}", e.message));
836            return finished;
837        }
838    };
839    let answered = serde_json::to_value(init.protocol_version)
840        .ok()
841        .and_then(|v| v.as_u64());
842    if answered != Some(SPOKEN) {
843        finished.error = Some(format!(
844            "the agent speaks ACP protocol version {}; speccoder speaks version {SPOKEN}",
845            answered.map_or_else(|| "unknown".to_owned(), |v| v.to_string())
846        ));
847        return finished;
848    }
849    finished.agent_name = init.agent_info.as_ref().map(|i| i.name.clone());
850    let auth_methods = serde_json::to_value(&init.auth_methods).unwrap_or_default();
851    let shown = finished
852        .agent_name
853        .clone()
854        .unwrap_or_else(|| attempt.agent.name.clone());
855
856    let new = match connection
857        .send_request(NewSessionRequest::new(attempt.cwd.clone()))
858        .block_task()
859        .await
860    {
861        Ok(r) => r,
862        Err(e) => {
863            // An agent that needs signing in says so, or fails while it
864            // advertises ways to sign in: its own ways are named.
865            let needs = e.code == agent_client_protocol::ErrorCode::AuthRequired;
866            if needs {
867                finished.sign_in = Some(sign_in_methods(&auth_methods, &attempt.argv));
868            }
869            finished.error = Some(if needs {
870                format!(
871                    "{shown} needs signing in before a session can start: {}",
872                    e.message
873                )
874            } else {
875                format!("{shown} could not start a session: {}", e.message)
876            });
877            return finished;
878        }
879    };
880    let setup = serde_json::to_value(&new).unwrap_or_default();
881    let session_id = new.session_id.clone();
882    let can_close = init.agent_capabilities.session_capabilities.close.is_some();
883
884    // The permission mode, settled before the first prompt.
885    let profile = init.agent_info.as_ref().and_then(|i| {
886        modes::profile_for(
887            &i.name,
888            &i.version,
889            &setup["configOptions"],
890            &setup["modes"],
891        )
892    });
893    let advertised = Advertised::read(&setup["configOptions"], &setup["modes"], profile);
894    let preference = attempt.agent.mode.as_deref();
895    let plan = ModePlan::of(profile, advertised.as_ref(), preference);
896    // Watched from here on: a move while the mode is being set counts too.
897    if let (Some(p), Ok(mut w)) = (profile, shared.watch.lock()) {
898        *w = Some(Watch {
899            agent: shown.clone(),
900            profile: p,
901            advertised: advertised.clone(),
902        });
903    }
904    let mut options = setup["configOptions"].clone();
905    let outcome = match (&plan.request, &advertised) {
906        (Some(value), Some(ad)) => {
907            let (o, now) = request_mode(connection, &session_id, ad, value).await;
908            if let Some(now) = now {
909                options = now;
910            }
911            Some(o)
912        }
913        _ => None,
914    };
915    let statement = modes::statement(
916        &shown,
917        profile,
918        advertised.as_ref(),
919        &plan,
920        outcome.as_ref(),
921        preference,
922        &options,
923    );
924    finished
925        .notices
926        .push(format!("permission mode: {}", statement.sentence()));
927    shared.event(Event::AgentStarted {
928        task: shared
929            .progress
930            .as_ref()
931            .map(|(t, _)| t.clone())
932            .unwrap_or_default(),
933        agent: crate::secrets::mask(&shown),
934        mode: crate::secrets::mask(&statement.sentence()),
935        decides_alone: statement.asks() == Some(false),
936        known: statement.asks().is_some(),
937    });
938    finished.mode = Some(statement);
939
940    // The model, where the agent offers a choice of it.
941    if let Some(model) = &attempt.model {
942        let (said, now) = choose_model(connection, &session_id, &options, model, &shown).await;
943        finished.notices.push(crate::secrets::mask(&said));
944        // The answer lists every option as it is now: the mode too.
945        if let Some(now) = now {
946            let value = shared.watch.lock().ok().and_then(|w| {
947                match w.as_ref()?.advertised.as_ref().map(|a| &a.via) {
948                    Some(modes::Via::Config(id)) => modes::current_of(&now, id),
949                    _ => None,
950                }
951            });
952            shared.moved(value, Some(&now));
953        }
954    }
955
956    let turn = connection
957        .send_request(PromptRequest::new(
958            session_id.clone(),
959            vec![ContentBlock::Text(TextContent::new(attempt.prompt.clone()))],
960        ))
961        .block_task();
962    tokio::pin!(turn);
963    finished.prompted = true;
964    // The attempt's clock stands while a person decides.
965    let ended = loop {
966        let until = shared.deadline + shared.governor.asking();
967        let left = until.saturating_duration_since(Instant::now());
968        // A stop is signalled before the agent is answered, so it is ready
969        // whenever the turn's end is: checked first, it always wins.
970        tokio::select! {
971            biased;
972            () = stop.notified() => break None,
973            r = &mut turn => break Some(r),
974            () = tokio::time::sleep(left) => {
975                if Instant::now() >= shared.deadline + shared.governor.asking() {
976                    finished.timed_out = true;
977                    break None;
978                }
979            }
980        }
981    };
982    let response = match ended {
983        Some(r) => r,
984        None => {
985            // Cancelled: speccoder's time is up, or a call waits for a person.
986            shared.governor.stop();
987            let _ = connection.send_notification(CancelNotification::new(session_id.clone()));
988            match tokio::time::timeout(CANCEL_GRACE, &mut turn).await {
989                Ok(r) => r,
990                Err(_) => {
991                    finished.stop_reason = Some("cancelled".to_owned());
992                    finished.message = last_words(shared);
993                    return finished;
994                }
995            }
996        }
997    };
998    match response {
999        Ok(r) => {
1000            finished.stop_reason = Some(stop_reason_name(r.stop_reason).to_owned());
1001            if let Some(u) = &r.usage {
1002                finished.usage = Some(json!({
1003                    "input_tokens": u.input_tokens,
1004                    "output_tokens": u.output_tokens,
1005                    "cache_read_tokens": u.cached_read_tokens.unwrap_or(0),
1006                    "cache_write_tokens": u.cached_write_tokens.unwrap_or(0),
1007                }));
1008            }
1009        }
1010        Err(e) => {
1011            if e.code == agent_client_protocol::ErrorCode::AuthRequired {
1012                finished.sign_in = Some(sign_in_methods(&auth_methods, &attempt.argv));
1013            }
1014            finished.error = Some(format!("{shown} failed the turn: {}", e.message));
1015        }
1016    }
1017    finished.message = last_words(shared);
1018    if can_close {
1019        let _ = tokio::time::timeout(
1020            CLOSE_GRACE,
1021            connection
1022                .send_request(CloseSessionRequest::new(session_id))
1023                .block_task(),
1024        )
1025        .await;
1026    }
1027    finished
1028}
1029
1030/// The agent's words after its last call.
1031fn last_words(shared: &Shared) -> String {
1032    shared
1033        .words
1034        .lock()
1035        .map(|w| crate::secrets::mask(w.last.trim()))
1036        .unwrap_or_default()
1037}
1038
1039/// Sends the one mode request a set-up may make; the agent's full option
1040/// list afterwards, where it sends one.
1041async fn request_mode(
1042    connection: &ConnectionTo<AgentRole>,
1043    session_id: &agent_client_protocol::schema::v1::SessionId,
1044    advertised: &Advertised,
1045    value: &str,
1046) -> (Outcome, Option<Value>) {
1047    match &advertised.via {
1048        modes::Via::Config(id) => {
1049            let asked = connection
1050                .send_request(SetSessionConfigOptionRequest::new(
1051                    session_id.clone(),
1052                    id.clone(),
1053                    value,
1054                ))
1055                .block_task();
1056            match tokio::time::timeout(MODE_GRACE, asked).await {
1057                Err(_) => (Outcome::NoAnswer, None),
1058                Ok(Err(e)) => (Outcome::Refused(e.message), None),
1059                Ok(Ok(r)) => {
1060                    let all = serde_json::to_value(&r.config_options).unwrap_or_default();
1061                    (
1062                        Outcome::Confirmed(
1063                            advertised
1064                                .current_in(&all)
1065                                .unwrap_or_else(|| value.to_owned()),
1066                        ),
1067                        Some(all),
1068                    )
1069                }
1070            }
1071        }
1072        modes::Via::Modes => {
1073            let asked = connection
1074                .send_request(SetSessionModeRequest::new(
1075                    session_id.clone(),
1076                    value.to_owned(),
1077                ))
1078                .block_task();
1079            match tokio::time::timeout(MODE_GRACE, asked).await {
1080                Err(_) => (Outcome::NoAnswer, None),
1081                Ok(Err(e)) => (Outcome::Refused(e.message), None),
1082                Ok(Ok(_)) => (Outcome::Confirmed(value.to_owned()), None),
1083            }
1084        }
1085    }
1086}
1087
1088/// Selects `model` through the agent's model option; what came of it.
1089async fn choose_model(
1090    connection: &ConnectionTo<AgentRole>,
1091    session_id: &agent_client_protocol::schema::v1::SessionId,
1092    options: &Value,
1093    model: &str,
1094    agent: &str,
1095) -> (String, Option<Value>) {
1096    let option = options
1097        .as_array()
1098        .into_iter()
1099        .flatten()
1100        .find(|o| o["category"] == "model" && o["type"] == "select");
1101    let Some(option) = option else {
1102        return (
1103            format!("model `{model}`: {agent} offers no choice of model over ACP; it uses its own"),
1104            None,
1105        );
1106    };
1107    let id = option["id"].as_str().unwrap_or("model").to_owned();
1108    let values: Vec<String> = option["options"]
1109        .as_array()
1110        .into_iter()
1111        .flatten()
1112        .flat_map(|o| match o["options"].as_array() {
1113            Some(group) => group.clone(),
1114            None => vec![o.clone()],
1115        })
1116        .filter_map(|o| o["value"].as_str().map(str::to_owned))
1117        .collect();
1118    let current = option["currentValue"].as_str().unwrap_or("its default");
1119    if !values.iter().any(|v| v == model) {
1120        let mut offered = values.clone();
1121        offered.dedup();
1122        return (
1123            format!(
1124                "model `{model}` is not offered by {agent} (it offers {}); it uses {current}",
1125                offered.join(", ")
1126            ),
1127            None,
1128        );
1129    }
1130    if current == model {
1131        return (format!("model: {model}"), None);
1132    }
1133    let asked = connection
1134        .send_request(SetSessionConfigOptionRequest::new(
1135            session_id.clone(),
1136            id,
1137            model,
1138        ))
1139        .block_task();
1140    match tokio::time::timeout(MODE_GRACE, asked).await {
1141        Ok(Ok(r)) => (
1142            format!("model: {model}"),
1143            serde_json::to_value(&r.config_options).ok(),
1144        ),
1145        Ok(Err(e)) => (
1146            format!(
1147                "model `{model}`: {agent} refused it ({}); it uses {current}",
1148                e.message
1149            ),
1150            None,
1151        ),
1152        Err(_) => (
1153            format!("model `{model}`: {agent} did not answer; it uses {current}"),
1154            None,
1155        ),
1156    }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162
1163    #[test]
1164    fn sign_in_methods_name_the_agents_own_login() {
1165        let copilot = json!([{"id": "copilot-login", "name": "Log in with Copilot CLI",
1166            "description": "Run `copilot login` in the terminal",
1167            "_meta": {"terminal-auth": {"command": "/opt/homebrew/Caskroom/copilot-cli/1.0.105/copilot", "args": ["login"]}}}]);
1168        let m = sign_in_methods(&copilot, &["copilot".to_owned(), "--acp".to_owned()]);
1169        assert_eq!(m[0].command.as_deref(), Some("copilot login"));
1170        assert_eq!(
1171            m[0].description.as_deref(),
1172            Some("Run `copilot login` in the terminal")
1173        );
1174        let terminal =
1175            json!([{"id": "t", "name": "Sign in", "type": "terminal", "args": ["--login"]}]);
1176        let m = sign_in_methods(&terminal, &["/opt/agent".to_owned()]);
1177        assert_eq!(m[0].command.as_deref(), Some("/opt/agent --login"));
1178        let opencode = json!([{"id": "opencode-login", "name": "Login with opencode", "description": "Run `opencode auth login` in the terminal"}]);
1179        assert_eq!(sign_in_methods(&opencode, &[])[0].command, None);
1180    }
1181
1182    #[test]
1183    fn the_client_declares_files_and_terminals() {
1184        let v = serde_json::to_value(declared()).unwrap_or_default();
1185        assert_eq!(
1186            v["fs"],
1187            json!({"readTextFile": true, "writeTextFile": true})
1188        );
1189        assert_eq!(v["terminal"], json!(true));
1190    }
1191}