1use 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
41pub const CANCEL_GRACE: Duration = Duration::from_secs(5);
44
45const CLOSE_GRACE: Duration = Duration::from_secs(2);
47
48const MODE_GRACE: Duration = Duration::from_secs(10);
51
52const SPOKEN: u64 = 1;
54
55const STDERR_KEPT: usize = 5;
57
58#[derive(Debug, Clone)]
60pub struct Attempt {
61 pub agent: Agent,
63 pub argv: Vec<String>,
65 pub cwd: PathBuf,
67 pub prompt: String,
69 pub model: Option<String>,
71 pub timeout: Duration,
73 pub runner: crate::sandbox::Runner,
75 pub progress: Option<(String, Arc<dyn Progress>)>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct SignIn {
82 pub id: String,
84 pub name: String,
86 pub description: Option<String>,
88 pub command: Option<String>,
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct Finished {
95 pub agent_name: Option<String>,
97 pub message: String,
99 pub stop_reason: Option<String>,
101 pub error: Option<String>,
103 pub timed_out: bool,
105 pub sign_in: Option<Vec<SignIn>>,
107 pub mode: Option<Statement>,
109 pub notices: Vec<String>,
111 pub usage: Option<Value>,
113 pub stderr: Vec<String>,
115 pub plan: Vec<Value>,
117 pub left: Option<String>,
120 pub prompted: bool,
122}
123
124#[derive(Debug, Default)]
126struct Words {
127 pending: String,
129 thinking: String,
131 last: String,
133 calls: HashMap<String, ToolRequest>,
135 finished: std::collections::HashSet<String>,
137 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 stop: Arc<Notify>,
152 watch: Mutex<Option<Watch>>,
154 left: Mutex<Option<String>>,
157}
158
159struct Watch {
161 agent: String,
162 profile: &'static modes::ModeProfile,
163 advertised: Option<Advertised>,
164}
165
166impl Shared {
167 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 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 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 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 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
374fn 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
393pub(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
432pub(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
443pub(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#[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 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 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 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 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 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
812fn 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
820async 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 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 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 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 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 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 let ended = loop {
966 let until = shared.deadline + shared.governor.asking();
967 let left = until.saturating_duration_since(Instant::now());
968 tokio::select! {
969 r = &mut turn => break Some(r),
970 () = stop.notified() => break None,
971 () = tokio::time::sleep(left) => {
972 if Instant::now() >= shared.deadline + shared.governor.asking() {
973 finished.timed_out = true;
974 break None;
975 }
976 }
977 }
978 };
979 let response = match ended {
980 Some(r) => r,
981 None => {
982 shared.governor.stop();
984 let _ = connection.send_notification(CancelNotification::new(session_id.clone()));
985 match tokio::time::timeout(CANCEL_GRACE, &mut turn).await {
986 Ok(r) => r,
987 Err(_) => {
988 finished.stop_reason = Some("cancelled".to_owned());
989 finished.message = last_words(shared);
990 return finished;
991 }
992 }
993 }
994 };
995 match response {
996 Ok(r) => {
997 finished.stop_reason = Some(stop_reason_name(r.stop_reason).to_owned());
998 if let Some(u) = &r.usage {
999 finished.usage = Some(json!({
1000 "input_tokens": u.input_tokens,
1001 "output_tokens": u.output_tokens,
1002 "cache_read_tokens": u.cached_read_tokens.unwrap_or(0),
1003 "cache_write_tokens": u.cached_write_tokens.unwrap_or(0),
1004 }));
1005 }
1006 }
1007 Err(e) => {
1008 if e.code == agent_client_protocol::ErrorCode::AuthRequired {
1009 finished.sign_in = Some(sign_in_methods(&auth_methods, &attempt.argv));
1010 }
1011 finished.error = Some(format!("{shown} failed the turn: {}", e.message));
1012 }
1013 }
1014 finished.message = last_words(shared);
1015 if can_close {
1016 let _ = tokio::time::timeout(
1017 CLOSE_GRACE,
1018 connection
1019 .send_request(CloseSessionRequest::new(session_id))
1020 .block_task(),
1021 )
1022 .await;
1023 }
1024 finished
1025}
1026
1027fn last_words(shared: &Shared) -> String {
1029 shared
1030 .words
1031 .lock()
1032 .map(|w| crate::secrets::mask(w.last.trim()))
1033 .unwrap_or_default()
1034}
1035
1036async fn request_mode(
1039 connection: &ConnectionTo<AgentRole>,
1040 session_id: &agent_client_protocol::schema::v1::SessionId,
1041 advertised: &Advertised,
1042 value: &str,
1043) -> (Outcome, Option<Value>) {
1044 match &advertised.via {
1045 modes::Via::Config(id) => {
1046 let asked = connection
1047 .send_request(SetSessionConfigOptionRequest::new(
1048 session_id.clone(),
1049 id.clone(),
1050 value,
1051 ))
1052 .block_task();
1053 match tokio::time::timeout(MODE_GRACE, asked).await {
1054 Err(_) => (Outcome::NoAnswer, None),
1055 Ok(Err(e)) => (Outcome::Refused(e.message), None),
1056 Ok(Ok(r)) => {
1057 let all = serde_json::to_value(&r.config_options).unwrap_or_default();
1058 (
1059 Outcome::Confirmed(
1060 advertised
1061 .current_in(&all)
1062 .unwrap_or_else(|| value.to_owned()),
1063 ),
1064 Some(all),
1065 )
1066 }
1067 }
1068 }
1069 modes::Via::Modes => {
1070 let asked = connection
1071 .send_request(SetSessionModeRequest::new(
1072 session_id.clone(),
1073 value.to_owned(),
1074 ))
1075 .block_task();
1076 match tokio::time::timeout(MODE_GRACE, asked).await {
1077 Err(_) => (Outcome::NoAnswer, None),
1078 Ok(Err(e)) => (Outcome::Refused(e.message), None),
1079 Ok(Ok(_)) => (Outcome::Confirmed(value.to_owned()), None),
1080 }
1081 }
1082 }
1083}
1084
1085async fn choose_model(
1087 connection: &ConnectionTo<AgentRole>,
1088 session_id: &agent_client_protocol::schema::v1::SessionId,
1089 options: &Value,
1090 model: &str,
1091 agent: &str,
1092) -> (String, Option<Value>) {
1093 let option = options
1094 .as_array()
1095 .into_iter()
1096 .flatten()
1097 .find(|o| o["category"] == "model" && o["type"] == "select");
1098 let Some(option) = option else {
1099 return (
1100 format!("model `{model}`: {agent} offers no choice of model over ACP; it uses its own"),
1101 None,
1102 );
1103 };
1104 let id = option["id"].as_str().unwrap_or("model").to_owned();
1105 let values: Vec<String> = option["options"]
1106 .as_array()
1107 .into_iter()
1108 .flatten()
1109 .flat_map(|o| match o["options"].as_array() {
1110 Some(group) => group.clone(),
1111 None => vec![o.clone()],
1112 })
1113 .filter_map(|o| o["value"].as_str().map(str::to_owned))
1114 .collect();
1115 let current = option["currentValue"].as_str().unwrap_or("its default");
1116 if !values.iter().any(|v| v == model) {
1117 let mut offered = values.clone();
1118 offered.dedup();
1119 return (
1120 format!(
1121 "model `{model}` is not offered by {agent} (it offers {}); it uses {current}",
1122 offered.join(", ")
1123 ),
1124 None,
1125 );
1126 }
1127 if current == model {
1128 return (format!("model: {model}"), None);
1129 }
1130 let asked = connection
1131 .send_request(SetSessionConfigOptionRequest::new(
1132 session_id.clone(),
1133 id,
1134 model,
1135 ))
1136 .block_task();
1137 match tokio::time::timeout(MODE_GRACE, asked).await {
1138 Ok(Ok(r)) => (
1139 format!("model: {model}"),
1140 serde_json::to_value(&r.config_options).ok(),
1141 ),
1142 Ok(Err(e)) => (
1143 format!(
1144 "model `{model}`: {agent} refused it ({}); it uses {current}",
1145 e.message
1146 ),
1147 None,
1148 ),
1149 Err(_) => (
1150 format!("model `{model}`: {agent} did not answer; it uses {current}"),
1151 None,
1152 ),
1153 }
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159
1160 #[test]
1161 fn sign_in_methods_name_the_agents_own_login() {
1162 let copilot = json!([{"id": "copilot-login", "name": "Log in with Copilot CLI",
1163 "description": "Run `copilot login` in the terminal",
1164 "_meta": {"terminal-auth": {"command": "/opt/homebrew/Caskroom/copilot-cli/1.0.105/copilot", "args": ["login"]}}}]);
1165 let m = sign_in_methods(&copilot, &["copilot".to_owned(), "--acp".to_owned()]);
1166 assert_eq!(m[0].command.as_deref(), Some("copilot login"));
1167 assert_eq!(
1168 m[0].description.as_deref(),
1169 Some("Run `copilot login` in the terminal")
1170 );
1171 let terminal =
1172 json!([{"id": "t", "name": "Sign in", "type": "terminal", "args": ["--login"]}]);
1173 let m = sign_in_methods(&terminal, &["/opt/agent".to_owned()]);
1174 assert_eq!(m[0].command.as_deref(), Some("/opt/agent --login"));
1175 let opencode = json!([{"id": "opencode-login", "name": "Login with opencode", "description": "Run `opencode auth login` in the terminal"}]);
1176 assert_eq!(sign_in_methods(&opencode, &[])[0].command, None);
1177 }
1178
1179 #[test]
1180 fn the_client_declares_files_and_terminals() {
1181 let v = serde_json::to_value(declared()).unwrap_or_default();
1182 assert_eq!(
1183 v["fs"],
1184 json!({"readTextFile": true, "writeTextFile": true})
1185 );
1186 assert_eq!(v["terminal"], json!(true));
1187 }
1188}