diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 39bfa8c0e..9830595e9 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -71,6 +71,16 @@ Cargo manifests and lockfile; Research has an independent dependency graph. runs and how long it has worked. A subagent that runs in the background keeps its row after the turn ends, and Maple tells the task when it finishes: into the running turn, or into the next one. +- External agents: a task can hand work to the Codex CLI installed on + this computer with the `agent_start`, `agent_send`, `agent_status`, + `agent_cancel`, and `list_agent_providers` tools, once Codex is enabled + under Settings > Integrations. Codex runs in the project with its own + account, context, and sandbox settings; whatever it asks approval for + comes to you through Maple's permission card, and Allow all grants it. Its progress streams + into the tool call's row and its row above the composer has a Stop + button. Three skills, `/handoff`, `/committee`, and `/advisor`, teach the + task when and how to delegate. See + [`docs/external-agents.md`](docs/external-agents.md). - Voice: dictate a message with the microphone button, and read any message aloud. Both use Maple's speech models; the voice and speed are settings. @@ -143,6 +153,17 @@ account configuration that may roam between devices. The embedded design, migration rules, privacy boundary, and preview limits are documented in [`docs/embedded-cua.md`](docs/embedded-cua.md). +#### Codex + +Settings > Integrations also lists the Codex CLI when `codex` is on the PATH +(the login shell's PATH on macOS). The card shows the installed version and +whether Codex is signed in; Maple never runs Codex's sign-in itself. The +toggle is off by default. Enabling it gives new runs the external-agent +tools and installs the `handoff`, `committee`, and `advisor` skills into the +account's Goose skills directory; disabling removes only the files Maple +wrote. Codex needs version 0.143 or newer. See +[`docs/external-agents.md`](docs/external-agents.md). + ### Composer Vim preview Turn on **Vim mode in composer** in General settings. The composer opens in @@ -404,6 +425,7 @@ The roots follow the platform, the same way the Tauri app's | `/settings.json` | App settings. | | `/agent/accounts//config.json` | Per-account agent configuration (default root, model, custom MCP servers, project trust). May roam between machines. | | `/agent/accounts//goose/config/` | Goose permission file for the account. | +| `/agent/accounts//goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while Codex is enabled. | | `/agent/goose-runtime/` | Goose process configuration. | | `/auth.json` | Sign-in credentials (mode 0600). Device-local; never in a roaming profile. | | `/agent/accounts//integrations.json` | Per-account defaults and validated launch details for integrations detected on this device. | diff --git a/apps/maple-agent/app/assets/icons/openai-mark.svg b/apps/maple-agent/app/assets/icons/openai-mark.svg new file mode 100644 index 000000000..9d031b3ea --- /dev/null +++ b/apps/maple-agent/app/assets/icons/openai-mark.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index 006df18fb..6ea577246 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -36,6 +36,8 @@ assets!( "icons/maple-wordmark.svg", "icons/maximize-2.svg", "icons/minimize-2.svg", + // OpenAI mark (simple-icons, CC0) for the Codex integration card. + "icons/openai-mark.svg", "icons/paperclip.svg", "icons/pin.svg", "icons/plus.svg", diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 2a41dea86..cf08caeb4 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -1874,16 +1874,35 @@ impl AgentBackend { .await) } + /// Interrupt an external agent (Codex) from its row. The agent keeps + /// its thread so the task can continue it later. + pub async fn cancel_external_agent( + &self, + user_id: &str, + session_id: &str, + agent_id: &str, + ) -> Result<(), String> { + self.service + .handle_for_user(user_id) + .await? + .cancel_external_agent(session_id, agent_id) + .await + } + /// Slash commands (installed skills) for a working directory. Filesystem /// scan, so it runs on a blocking thread. pub async fn list_slash_commands( &self, + user_id: &str, working_dir: Option, ) -> Result, String> { let service = self.service.clone(); - tokio::task::spawn_blocking(move || service.list_slash_commands(working_dir.as_deref())) - .await - .map_err(|error| format!("Slash command scan failed: {error}")) + let user_id = user_id.to_string(); + tokio::task::spawn_blocking(move || { + service.list_slash_commands(Some(&user_id), working_dir.as_deref()) + }) + .await + .map_err(|error| format!("Slash command scan failed: {error}")) } /// Expand `/command args` into the skill prompt; `None` when the command diff --git a/apps/maple-agent/app/src/ui/chat/cache.rs b/apps/maple-agent/app/src/ui/chat/cache.rs index 3f1e5c598..684361555 100644 --- a/apps/maple-agent/app/src/ui/chat/cache.rs +++ b/apps/maple-agent/app/src/ui/chat/cache.rs @@ -11,11 +11,12 @@ use std::collections::HashMap; use std::sync::Arc; use gpui::{AsyncApp, SharedString, Task, WeakEntity}; -use maple_agent::agent::AgentTimelineItem; +use maple_agent::agent::{AgentTimelineItem, ExternalAgentActivity}; use super::ChatScreen; use super::transcript::{ - diff_lines_for, maple_display_text, tool_input_line, tool_output_markdown, + diff_lines_for, external_agent_activity, maple_display_text, tool_input_line, + tool_output_markdown, }; use crate::ui::markdown; @@ -259,6 +260,9 @@ pub(super) struct ItemDerived { pub(super) input_line: Option, /// +/- lines of an edit or write tool, capped at `MAX_DIFF_LINES`. pub(super) diff_lines: Arc>, + /// What an external agent (Codex) did, for the row of the tool call + /// that ran it. + pub(super) external_agent: Option>, } pub(super) const MAX_DIFF_LINES: usize = 200; @@ -273,6 +277,7 @@ impl ItemDerived { output_text, input_line: tool_input_line(item).map(SharedString::from), diff_lines: Arc::new(diff_lines_for(item)), + external_agent: external_agent_activity(item).map(Arc::new), } } } diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index eee054e68..6aeb69cc6 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -699,7 +699,7 @@ impl ChatScreen { /// The subagents working for this task, pinned above the composer. /// `None` when none are working, which is the common case. - pub(super) fn render_subagents_card(&self) -> Option
{ + pub(super) fn render_subagents_card(&self, cx: &mut Context) -> Option
{ if self.subagents.is_empty() { return None; } @@ -735,13 +735,19 @@ impl ChatScreen { .border_color(gpui::rgb(theme::border_subtle())) .overflow_hidden() .child(header) - .child( - div().flex().flex_col().gap_1().px_3().pb_2().children( - self.subagents - .iter() - .map(|subagent| render_subagent_row(subagent, now)), - ), - ), + .child(div().flex().flex_col().gap_1().px_3().pb_2().children( + self.subagents.iter().map(|subagent| { + let on_stop = subagent.external.as_ref().map(|external| { + let agent_id = external.agent_id.clone(); + let listener = + cx.listener(move |this: &mut ChatScreen, _event, _window, cx| { + this.stop_external_agent(&agent_id, cx); + }); + Box::new(listener) as super::transcript::StopHandler + }); + render_subagent_row(subagent, now, on_stop) + }), + )), ) } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 64d16e842..64441deba 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -339,6 +339,9 @@ pub struct ChatScreen { /// landed while it was in flight; a stale one would wipe a row the /// events already know about (or revive one they removed). subagent_epoch: u64, + /// Whether a running runtime has been seen, so the skills list is + /// re-read once Goose knows the account's skills directory. + runtime_running_seen: bool, /// Web tools on for the selected task (mirrors the session record). web_enabled: bool, /// Settings default applied to newly created tasks. @@ -809,11 +812,17 @@ impl ChatScreen { .iter() .zip(&self.slash_entries) .any(|(next, previous)| next.name != previous.name); + // The palette just opened: re-read the skills, so one installed + // since the last scan (a toggle in Settings) is in the list. + let palette_opened = self.slash_entries.is_empty() && !entries.is_empty(); if has_text != self.composer_has_text || entries_changed { self.composer_has_text = has_text; self.slash_entries = entries; cx.notify(); } + if palette_opened { + self.refresh_slash_commands(cx); + } } /// Test seam: pure state without composer wiring or runtime start. @@ -959,6 +968,7 @@ impl ChatScreen { plan_collapsed: false, subagents: Vec::new(), subagent_tick_pending: std::cell::Cell::new(false), + runtime_running_seen: false, subagent_epoch: 0, audio: Arc::new(crate::audio::AudioEngine::new()), audio_caps: maple_agent::agent::AudioCapabilities::default(), @@ -1941,6 +1951,7 @@ impl ChatScreen { task: subagent.task.into(), background: subagent.background, activity: subagent.activity.map(SharedString::from), + external: subagent.external, } }) .collect(); @@ -2240,16 +2251,20 @@ impl ChatScreen { /// Reload the skill slash commands for the current project root. pub fn refresh_slash_commands(&mut self, cx: &mut Context) { let backend = self.backend.clone(); + let user_id = self.user_id.clone(); let working_dir = self.project_root.clone(); let requested_root = working_dir.clone(); self.call( - async move { backend.list_slash_commands(working_dir).await }, + async move { backend.list_slash_commands(&user_id, working_dir).await }, cx, move |this, result, cx| { if this.project_root == requested_root && let Ok(commands) = result { this.slash_commands = commands; + if let Some(composer) = this.composer.clone() { + this.composer_changed(&composer, cx); + } cx.notify(); } }, @@ -2483,6 +2498,7 @@ impl ChatScreen { id: String, task: String, background: bool, + external: Option, cx: &mut Context, ) { if self.subagents.iter().any(|subagent| subagent.id == id) { @@ -2495,10 +2511,36 @@ impl ChatScreen { background, started: std::time::Instant::now(), activity: None, + external, }); self.schedule_subagent_tick(cx); } + /// Stop an external agent from its row. The row closes when the + /// runtime reports the turn's end. + fn stop_external_agent(&mut self, agent_id: &str, cx: &mut Context) { + let Some(session_id) = self.selected_session.clone() else { + return; + }; + let backend = self.backend.clone(); + let user_id = self.user_id.clone(); + let agent_id = agent_id.to_string(); + self.call( + async move { + backend + .cancel_external_agent(&user_id, &session_id, &agent_id) + .await + }, + cx, + |this, result, cx| { + if let Err(message) = result { + this.notice = Some(message.into()); + } + cx.notify(); + }, + ); + } + /// Paint once a second while a subagent works, so the elapsed time on /// its row stays true. The last subagent to end stops the timer. fn schedule_subagent_tick(&self, cx: &mut Context) { @@ -3943,6 +3985,12 @@ impl ChatScreen { fn apply_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) -> bool { match event { AgentServiceEvent::RuntimeStatus(mut status) => { + // A runtime that just started points Goose at the account's + // skills, which the first scan ran before; ask again. + if status.running && !self.runtime_running_seen { + self.runtime_running_seen = true; + self.refresh_slash_commands(cx); + } // The status snapshot is authoritative for active runs; one // that repeats the known state changes nothing. A snapshot // raced with a terminal event must not resurrect that run. @@ -4137,11 +4185,12 @@ impl ChatScreen { id, task, background, + external, } => { if !self.is_selected(session_id) { return false; } - self.start_subagent(id, task, background, cx); + self.start_subagent(id, task, background, external, cx); } AgentRunEvent::SubagentActivity { id, tool } => { if !self.is_selected(session_id) { @@ -4439,7 +4488,7 @@ impl Render for ChatScreen { .flex_col() }) .children(self.render_btw_card(cx)) - .children(self.render_subagents_card()) + .children(self.render_subagents_card(cx)) .children(self.render_plan_card(cx)) .child(self.render_composer(cx)) .children(self.render_slash_palette(cx)) diff --git a/apps/maple-agent/app/src/ui/chat/summaries.rs b/apps/maple-agent/app/src/ui/chat/summaries.rs index 95df1e957..343e34c3a 100644 --- a/apps/maple-agent/app/src/ui/chat/summaries.rs +++ b/apps/maple-agent/app/src/ui/chat/summaries.rs @@ -44,6 +44,10 @@ impl ChatScreen { Some("summaries are off in settings") } else if has_tool_input(item, "todos") { Some("todo list") + } else if super::transcript::has_external_agent_activity(item) { + // The row draws the agent's own activity; a one-line summary + // would hide what it did. + Some("external agent activity") } else if item.input.as_ref().is_none_or(serde_json::Value::is_null) { Some("no input") } else { diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 3001be693..2eca08ace 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -111,7 +111,7 @@ mod state_tests { let screen = screen(cx); screen.update(cx, |this, cx| { - assert!(this.render_subagents_card().is_none()); + assert!(this.render_subagents_card(cx).is_none()); this.handle_run_event( "s1", "r1", @@ -119,11 +119,12 @@ mod state_tests { id: "d1".to_string(), task: "Review the parser".to_string(), background: false, + external: None, }, cx, ); assert_eq!(this.subagents.len(), 1); - assert!(this.render_subagents_card().is_some()); + assert!(this.render_subagents_card(cx).is_some()); // A subagent of a task that is not on screen stays off it. this.handle_run_event( @@ -133,6 +134,7 @@ mod state_tests { id: "d2".to_string(), task: "Other task".to_string(), background: true, + external: None, }, cx, ); @@ -174,7 +176,7 @@ mod state_tests { cx, ); assert!(this.subagents.is_empty()); - assert!(this.render_subagents_card().is_none()); + assert!(this.render_subagents_card(cx).is_none()); }); } @@ -196,6 +198,7 @@ mod state_tests { id: id.to_string(), task: id.to_string(), background, + external: None, }, cx, ); @@ -210,7 +213,7 @@ mod state_tests { ); assert_eq!(this.subagents.len(), 1); assert_eq!(this.subagents[0].id, "in-background"); - assert!(this.render_subagents_card().is_some()); + assert!(this.render_subagents_card(cx).is_some()); // Opening the task later rebuilds the card from the runtime. this.set_subagents( @@ -220,6 +223,7 @@ mod state_tests { background: true, elapsed_ms: 90_000, activity: Some("Terminal: cargo build".to_string()), + external: None, }], cx, ); @@ -238,7 +242,7 @@ mod state_tests { // An empty snapshot for a task with no subagent clears the card. this.set_subagents(Vec::new(), cx); assert!(this.subagents.is_empty()); - assert!(this.render_subagents_card().is_none()); + assert!(this.render_subagents_card(cx).is_none()); }); } @@ -261,6 +265,7 @@ mod state_tests { id: "d1".to_string(), task: "Review the parser".to_string(), background: false, + external: None, }, cx, ); diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 3494bd4bf..1d1ed1e18 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -6,7 +6,10 @@ use std::collections::HashMap; use std::sync::Arc; use gpui::{Div, Entity, IntoElement, SharedString, Window, div, prelude::*, px}; -use maple_agent::agent::{AgentTimelineItem, compaction_notice_text}; +use maple_agent::agent::{ + AgentTimelineItem, EXTERNAL_AGENT_ACTIVITY_KEY, ExternalAgentActivity, ExternalAgentRef, + compaction_notice_text, +}; use super::cache::{MAX_DIFF_LINES, MarkdownKind}; use super::commands::ChatCommand; @@ -579,6 +582,35 @@ pub(super) struct ActiveSubagent { pub(super) started: std::time::Instant, /// The tool it called most recently, if any. pub(super) activity: Option, + /// Set for an external agent (Codex), which the user can stop. + pub(super) external: Option, +} + +/// A click handler for the Stop control of an external agent's row. +pub(super) type StopHandler = Box; + +/// The activity payload an external agent's tool row carries, if any. +pub(super) fn external_agent_activity(item: &AgentTimelineItem) -> Option { + if !matches!(item.item_type.as_str(), "tool" | "toolCall") { + return None; + } + let payload = item + .output + .as_ref()? + .get("structuredContent")? + .get(EXTERNAL_AGENT_ACTIVITY_KEY)?; + serde_json::from_value(payload.clone()).ok() +} + +/// True when the row belongs to an external agent turn. Cheap: a key +/// lookup, no parse. +pub(super) fn has_external_agent_activity(item: &AgentTimelineItem) -> bool { + matches!(item.item_type.as_str(), "tool" | "toolCall") + && item + .output + .as_ref() + .and_then(|output| output.get("structuredContent")) + .is_some_and(|content| content.get(EXTERNAL_AGENT_ACTIVITY_KEY).is_some()) } /// The todo list carried by a todo_write tool item, or `None` for any @@ -609,8 +641,13 @@ pub(super) fn plan_entries(item: &AgentTimelineItem) -> Option> { } /// One row of the subagent card: what the subagent was asked to do, the -/// tool it is running now, and how long it has worked. -pub(super) fn render_subagent_row(subagent: &ActiveSubagent, now: std::time::Instant) -> Div { +/// tool it is running now, and how long it has worked. An external agent's +/// row also carries a Stop control. +pub(super) fn render_subagent_row( + subagent: &ActiveSubagent, + now: std::time::Instant, + on_stop: Option, +) -> Div { let elapsed = now.saturating_duration_since(subagent.started); let mut row = div() .flex() @@ -626,6 +663,15 @@ pub(super) fn render_subagent_row(subagent: &ActiveSubagent, now: std::time::Ins .truncate() .child(subagent.task.clone()), ); + if let Some(external) = &subagent.external { + row = row.child( + div() + .flex_none() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(external.provider.clone()), + ); + } if subagent.background { row = row.child( div() @@ -635,26 +681,46 @@ pub(super) fn render_subagent_row(subagent: &ActiveSubagent, now: std::time::Ins .child("background"), ); } + let row = row + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .text_color(gpui::rgb(theme::text_secondary())) + .truncate() + .child( + subagent + .activity + .clone() + .unwrap_or_else(|| SharedString::new_static("Starting")), + ), + ) + .child( + div() + .flex_none() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(format_subagent_elapsed(elapsed)), + ); + let Some(on_stop) = on_stop else { + return row; + }; row.child( div() - .flex_1() - .min_w_0() - .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .truncate() - .child( - subagent - .activity - .clone() - .unwrap_or_else(|| SharedString::new_static("Starting")), - ), - ) - .child( - div() + .id(SharedString::from(format!("subagent-stop-{}", subagent.id))) .flex_none() + .px_2() + .py_0p5() + .rounded(theme::RADIUS_SM) + .border_1() + .border_color(gpui::rgb(theme::border_subtle())) .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(format_subagent_elapsed(elapsed)), + .text_color(gpui::rgb(theme::text_secondary())) + .cursor_pointer() + .hover(|style| style.border_color(gpui::rgb(theme::border()))) + .on_click(on_stop) + .child("Stop"), ) } @@ -933,6 +999,11 @@ fn render_tool( return div().child(card); } let derived = transcript.derived.get(item, revision); + if let Some(activity) = &derived.external_agent { + return div().child(card.child(render_external_agent_activity( + item, revision, activity, transcript, + ))); + } // A click anywhere on the card, payload included, toggles it. let mut payload = div().flex().flex_col().gap_1(); // Expanded: the call arguments and, until a summary exists, the raw @@ -999,6 +1070,111 @@ fn format_tool_input(value: &serde_json::Value) -> String { } } +/// The expanded body of an external agent's row: what it said, ran, and +/// changed, plus its todo list. The payload was parsed once into the +/// derived cache; this only lays it out. +fn render_external_agent_activity( + item: &AgentTimelineItem, + revision: u64, + activity: &ExternalAgentActivity, + transcript: &TranscriptCtx, +) -> Div { + let muted = gpui::rgb(theme::text_muted()); + let secondary = gpui::rgb(theme::text_secondary()); + let mut body = div().flex().flex_col().gap_1p5().mt_1(); + let mut meta = format!("{} · {}", activity.provider, activity.agent_id); + if let Some(error) = &activity.error { + meta.push_str(" · "); + meta.push_str(error); + } + if let Some(pending) = &activity.pending_permission { + meta.push_str(" · waiting for you to "); + meta.push_str(pending); + } + body = body.child(div().text_xs().text_color(muted).child(meta)); + if !activity.text.trim().is_empty() { + body = body.child( + div() + .w_full() + .text_sm() + .text_color(secondary) + .child(markdown::render(&transcript.markdown_cache.get( + &item.id, + MarkdownKind::ToolOutput, + revision, + &activity.text, + ))), + ); + } + if !activity.commands.is_empty() { + let mut list = div().flex().flex_col().gap_0p5(); + for command in &activity.commands { + let (label, color) = match command.status.as_str() { + "running" => ("running", theme::status_running()), + "failed" => ("failed", theme::status_error()), + _ => ("done", theme::status_success()), + }; + let exit = command + .exit_code + .map(|code| format!(" (exit {code})")) + .unwrap_or_default(); + list = list.child( + div() + .flex() + .gap_2() + .items_center() + .child(div().text_xs().text_color(gpui::rgb(color)).child(label)) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .text_color(secondary) + .font_family(crate::assets::FONT_MONO) + .truncate() + .child(format!("{}{exit}", command.command)), + ), + ); + } + body = body + .child(div().text_xs().text_color(muted).child("Commands")) + .child(list); + } + if !activity.file_changes.is_empty() { + let mut list = div().flex().flex_col().gap_0p5(); + for change in &activity.file_changes { + list = list.child( + div() + .text_xs() + .text_color(secondary) + .font_family(crate::assets::FONT_MONO) + .truncate() + .child(format!("{} {}", change.kind, change.path)), + ); + } + body = body + .child(div().text_xs().text_color(muted).child("Files")) + .child(list); + } + if !activity.todos.is_empty() { + let mut list = div().flex().flex_col().gap_0p5(); + for todo in &activity.todos { + list = list.child(render_plan_row(&PlanEntry { + content: todo.text.clone().into(), + status: if todo.completed { + PlanStatus::Completed + } else { + PlanStatus::Pending + }, + })); + } + body = body + .child(div().text_xs().text_color(muted).child("Plan")) + .child(list); + } + body +} + /// Extract readable text from a tool output for markdown rendering. pub(super) fn tool_output_markdown(item: &AgentTimelineItem) -> Option { let value = item.output.as_ref().filter(|value| !value.is_null())?; @@ -1307,6 +1483,16 @@ pub(super) fn render_waiting_indicator() -> gpui::Stateful
{ ) } +/// The card's heading names who is asking: Maple's own tools, or an +/// external agent whose request Maple relays. +pub(super) fn permission_card_heading(tool_name: &str) -> &'static str { + match tool_name { + "codex_command" => "Codex wants to run a command", + "codex_file_change" => "Codex wants to change files", + _ => "Permission required", + } +} + pub(super) fn render_permission_card( permission: &PendingPermission, responding: bool, @@ -1317,6 +1503,7 @@ pub(super) fn render_permission_card( Some(prompt) => prompt.to_string().into(), None => format!("Run tool {}?", permission.tool_name).into(), }; + let heading = permission_card_heading(&permission.tool_name); let arguments: SharedString = permission.arguments.clone().into(); let mut card = div() .my_3() @@ -1336,7 +1523,7 @@ pub(super) fn render_permission_card( div() .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(gpui::rgb(theme::text_primary())) - .child("Permission required"), + .child(heading), ) .child( div() diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index f402f0d32..d2e9cc27a 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -2163,6 +2163,12 @@ impl SettingsScreen { .size(px(28.)) .text_color(gpui::rgb(theme::text_primary())) .into_any_element() + } else if is_codex(&integration.id) { + gpui::svg() + .path("icons/openai-mark.svg") + .size(px(26.)) + .text_color(gpui::rgb(theme::text_primary())) + .into_any_element() } else { icon("plug", widgets::ROW_ICON, theme::text_secondary()).into_any_element() }), @@ -2385,6 +2391,11 @@ fn plan_card(plan: &crate::billing::PlanUsage) -> Div { } fn integration_is_visible(integration: &AgentIntegration) -> bool { + // Codex is worth a row even before it is installed: the card says how + // to get it, where a hidden row would leave the feature undiscoverable. + if is_codex(&integration.id) { + return true; + } if is_cua_driver(&integration.id) && matches!( integration.availability, @@ -2537,6 +2548,10 @@ fn is_cua_driver(id: &str) -> bool { id == "cua-driver" } +fn is_codex(id: &str) -> bool { + id == "codex" +} + /// Small on/off pill used in list rows. fn pill_button( id: String, @@ -3046,6 +3061,18 @@ mod tests { generic_missing.id = "other".to_string(); assert!(integration_is_visible(&generic_missing)); + // Codex shows even when it is not installed, so the card can say + // how to install it; it can be enabled only once it is detected. + let mut codex_missing = integration(AgentIntegrationAvailability::NotDetected, false); + codex_missing.id = "codex".to_string(); + assert!(integration_is_visible(&codex_missing)); + assert!(!integration_can_enable(&codex_missing)); + assert!(!integration_can_setup(&codex_missing)); + let mut codex_ready = integration(AgentIntegrationAvailability::Available, false); + codex_ready.id = "codex".to_string(); + assert!(integration_can_enable(&codex_ready)); + assert!(!integration_can_setup(&codex_ready)); + let mut external = integration(AgentIntegrationAvailability::Available, true); external.backend = Some(AgentIntegrationBackend::External); external.permissions = Some(macos_permissions(false, false)); diff --git a/apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md b/apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md new file mode 100644 index 000000000..55fd98d39 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md @@ -0,0 +1,27 @@ +--- +name: advisor +description: Ask an external agent (Codex) for read-only analysis or review of code, a plan, or a problem, without letting it change anything. Use when the user wants a review, an audit, or advice from another model. +metadata: + maple: external-agents + argument-hint: "" +--- + +# Ask an advisor + +An advisor reads and reasons; it never changes the project. Use it for a +code review, a design check, a bug hunt, or a second opinion on a plan. + +## Steps + +1. Call `list_agent_providers`. If no provider is usable, tell the user and + do the review yourself. +2. Write a self-contained briefing: the question, the files to read, the + constraints, and the shape of answer you want. End it with: + "This is analysis only. Do NOT edit, create, or delete any files." +3. Call `agent_start` with `provider` and the briefing. Use blocking mode + when the user is waiting on the answer; use `background: true` when you + have other work to do first. +4. When the result arrives, read it critically. Verify claims against the + code before you pass them on, and say which points you checked. +5. For follow-up questions, call `agent_send` with the same `agent_id` so + the advisor keeps its context. diff --git a/apps/maple-agent/crates/maple-agent/resources/skills/committee/SKILL.md b/apps/maple-agent/crates/maple-agent/resources/skills/committee/SKILL.md new file mode 100644 index 000000000..3680818cc --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/resources/skills/committee/SKILL.md @@ -0,0 +1,30 @@ +--- +name: committee +description: Get several independent opinions on a question or design by asking external agents (Codex) and comparing them with your own analysis. Use when the user wants a second opinion, a review from another model, or a comparison of approaches. +metadata: + maple: external-agents + argument-hint: "" +--- + +# Convene a committee + +A committee gives the user independent views on one question, then a +comparison. Each member works from the same briefing and none of them sees +the others' answers. + +## Steps + +1. Call `list_agent_providers`. If no provider is usable, do the analysis + yourself and say that no external members were available. +2. Write one briefing that states the question, the relevant files, the + constraints, and the exact output you want (for example: a ranked list of + options with trade-offs, or a verdict with reasons). End it with: + "This is analysis only. Do NOT edit, create, or delete any files." +3. Start two or three members with `agent_start`, `background: true`, and + the same briefing. Give each a different `model` or `effort` when the user + has not asked for a specific one, so their views differ. +4. Do your own analysis while they work. Do not poll `agent_status`; Maple + tells you when each member finishes. +5. When every member has reported, call `agent_status` for each, then give + the user a comparison: where the members agree, where they disagree and + why, and your recommendation. Attribute each view to its member. diff --git a/apps/maple-agent/crates/maple-agent/resources/skills/handoff/SKILL.md b/apps/maple-agent/crates/maple-agent/resources/skills/handoff/SKILL.md new file mode 100644 index 000000000..b673d1a80 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/resources/skills/handoff/SKILL.md @@ -0,0 +1,39 @@ +--- +name: handoff +description: Hand a self-contained piece of work to an external coding agent (Codex) that runs in this project with its own context. Use when the user asks to delegate, hand off, or have Codex implement something. +metadata: + maple: external-agents + argument-hint: "" +--- + +# Hand work to an external agent + +Maple can start an external coding agent (Codex today) inside this project. +The agent runs with its own context and its own account. It does not see +this conversation. It runs under its own sandbox and approval settings; +whatever it asks approval for comes to the user through Maple. + +## Steps + +1. Call `list_agent_providers` first. If no provider is usable, tell the + user what is missing (install, PATH, or `codex login`) and stop. +2. Write a self-contained briefing. The agent has zero context, so the + briefing must carry everything: + - **Task**: what to do, in one or two sentences. + - **Context**: why, and what the project is. + - **Relevant files**: paths the agent should read first. + - **Current state**: what already works, what is broken. + - **What was tried**: dead ends to skip. + - **Decisions**: choices already made that it must not revisit. + - **Acceptance criteria**: how to know it is done, including tests to run. + - **Constraints**: what it must not touch or change. +3. Call `agent_start` with `provider`, the briefing as `prompt`, and + `background: true` unless the user is waiting on the result right now. +4. Do not poll `agent_status`. Maple tells you when the agent finishes, in + this turn or the next. Continue with other work meanwhile. +5. When Maple reports the end, call `agent_status` once, read the changed + files yourself, and verify the acceptance criteria before you rely on + them. If more is needed, call `agent_send` with the same `agent_id` so + the agent keeps its context. + +Agents take time. Ten to thirty minutes is routine for a real task. diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index e7ebdf99f..e7bcb87dd 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -4,11 +4,11 @@ // that is expected. #![cfg_attr(not(feature = "acp"), allow(dead_code))] mod attachments; -#[cfg(target_os = "macos")] mod bounded_process; #[cfg(embedded_cua)] mod cua; mod developer_tools; +mod external_agents; #[cfg(target_os = "linux")] mod gnome_helper; // The computer-use half of this module is reachable only from `cua`, which @@ -36,6 +36,11 @@ use attachments::{AgentAttachmentStore, AgentImageAttachment, PreparedAgentImage #[cfg(test)] use developer_tools::EXTERNAL_MCP_TOOL_NAME; use developer_tools::MapleDeveloperClient; +pub use external_agents::{ + ACTIVITY_KEY as EXTERNAL_AGENT_ACTIVITY_KEY, ActivityCommand, ActivityFileChange, ActivityTodo, + ExternalAgentActivity, +}; +use external_agents::{ExternalAgentHost, ExternalAgentRegistry, ExternalPermissionResponder}; use futures_util::StreamExt; use goose::agents::SUBAGENT_TOOL_REQUEST_TYPE; use goose::agents::extension::Envs; @@ -157,6 +162,11 @@ const MAPLE_GOOSE_PERMISSION_CONFIG: &str = r#"user: - todo_write - request_user_input - load + - agent_start + - agent_send + - agent_status + - agent_cancel + - list_agent_providers ask_before: - delegate - read @@ -335,6 +345,26 @@ struct PendingAgentPermission { run_id: String, routing: AgentPermissionRouting, request: AgentPermissionRequest, + origin: PendingPermissionOrigin, +} + +/// Who answers a pending permission once the user decides. +#[derive(Debug, Clone)] +enum PendingPermissionOrigin { + /// Goose asked; the decision goes back through `handle_confirmation`. + Goose, + /// An external agent (Codex) asked; the decision goes to its waiter. + ExternalAgent(ExternalPermissionResponder), +} + +impl PartialEq for PendingPermissionOrigin { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Goose, Self::Goose) => true, + (Self::ExternalAgent(a), Self::ExternalAgent(b)) => a.same_as(b), + _ => false, + } + } } type PendingPermissions = Arc>>; type IssuedPermissionIds = Arc>>; @@ -355,6 +385,9 @@ struct AgentRuntime { session_tool_contexts: HashMap, permission_modes: SessionPermissionModes, web_tool_state: Arc, + /// The external agents (Codex) of this runtime's tasks. `None` only in + /// unit fixtures that never delegate. + external_agents: Option>, project_root: PathBuf, model: String, mode: String, @@ -945,9 +978,16 @@ impl MapleAgentService { } /// Slash commands available in `working_dir`: the installed skills, - /// normalized the way goose's slash-command layer does. - pub fn list_slash_commands(&self, working_dir: Option<&str>) -> Vec { - goose::slash_commands::skill_slash_command::list_commands( + /// normalized the way goose's slash-command layer does, plus the skills + /// Maple installed for `user_id`'s account. Goose finds those too once + /// the runtime has pointed it at the account, but the list is asked for + /// before that, and after a toggle in Settings. + pub fn list_slash_commands( + &self, + user_id: Option<&str>, + working_dir: Option<&str>, + ) -> Vec { + let mut commands = goose::slash_commands::skill_slash_command::list_commands( working_dir.map(std::path::Path::new), ) .into_iter() @@ -956,7 +996,18 @@ impl MapleAgentService { description: entry.description, input_hint: entry.input_hint, }) - .collect() + .collect::>(); + if let Some(user_id) = user_id { + for command in account_skill_commands(&self.host.paths, user_id) { + if !commands + .iter() + .any(|existing| existing.name == command.name) + { + commands.push(command); + } + } + } + commands } /// Expand `/command args` from the installed skills into the prompt that @@ -1022,7 +1073,8 @@ impl AgentRuntimeHandle { /// Slash commands available in `working_dir` (installed skills). pub fn slash_commands(&self, working_dir: Option<&str>) -> Vec { - self.service.list_slash_commands(working_dir) + self.service + .list_slash_commands(Some(&self.user_id), working_dir) } /// Expand `/command args` from the installed skills into the prompt that @@ -1932,6 +1984,9 @@ enum PendingPermissionRegistration { Rejected, } +// The origin is one more parameter than clippy likes; folding it into the +// request would hide who answers the permission. +#[allow(clippy::too_many_arguments)] async fn register_pending_permission( pending_permissions: &PendingPermissions, issued_permission_ids: &IssuedPermissionIds, @@ -1940,6 +1995,7 @@ async fn register_pending_permission( routing: AgentPermissionRouting, request: AgentPermissionRequest, cancel_token: &CancellationToken, + origin: PendingPermissionOrigin, ) -> PendingPermissionRegistration { if cancel_token.is_cancelled() { return PendingPermissionRegistration::Rejected; @@ -1949,6 +2005,7 @@ async fn register_pending_permission( run_id: run_id.to_string(), routing, request, + origin, }; { let mut pending = pending_permissions.lock().await; @@ -2001,7 +2058,7 @@ async fn stop_runtime_inner( requested_scope: Option<&str>, ) -> Result<(), String> { let session_lifecycle_guard = state.session_lifecycle.lock().await; - let (active_runs, session_title_tasks, web_tool_state, tool_contexts) = { + let (active_runs, session_title_tasks, web_tool_state, tool_contexts, external_agents) = { let mut runtime = state.inner.lock().await; let Some(current) = runtime.as_mut() else { return Ok(()); @@ -2016,6 +2073,7 @@ async fn stop_runtime_inner( std::mem::take(&mut current.session_title_tasks), Arc::clone(¤t.web_tool_state), std::mem::take(&mut current.session_tool_contexts), + current.external_agents.take(), ) }; @@ -2065,6 +2123,11 @@ async fn stop_runtime_inner( drop(session_lifecycle_guard); join_agent_tasks(task_handles, RUN_SHUTDOWN_TIMEOUT).await; + // Lifetime cancellation alone does not wait for the external agent + // processes to die; join them explicitly like the run tasks above. + if let Some(external_agents) = external_agents { + external_agents.shutdown_all(RUN_SHUTDOWN_TIMEOUT).await; + } state.pending_permissions.lock().await.clear(); state.live_timelines.lock().await.clear(); @@ -2121,13 +2184,50 @@ impl AgentRuntimeHandle { /// whose run has ended reads this to show the background subagents /// that work on. pub async fn session_subagents(&self, session_id: &str) -> Vec { - self.service + let mut rows = self + .service .subagents .lock() .await .get(session_id) .map(SubagentTracker::snapshot) - .unwrap_or_default() + .unwrap_or_default(); + let external_agents = { + let runtime = self.service.inner.lock().await; + runtime + .as_ref() + .filter(|current| ensure_runtime_account(current, &self.account_scope).is_ok()) + .and_then(|current| current.external_agents.clone()) + }; + if let Some(external_agents) = external_agents { + rows.extend(external_agents.snapshot(session_id).await); + } + rows + } + + /// Interrupt an external agent's current turn from its row. The agent + /// keeps its thread, so the task can continue it later. + pub async fn cancel_external_agent( + &self, + session_id: &str, + agent_id: &str, + ) -> Result<(), String> { + self.verify_generation().await?; + let external_agents = { + let runtime = self.service.inner.lock().await; + let current = runtime + .as_ref() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &self.account_scope)?; + current.external_agents.clone() + }; + let Some(external_agents) = external_agents else { + return Err("External agents are not available".to_string()); + }; + external_agents + .cancel(session_id.trim(), agent_id) + .await + .map(|_| ()) } pub async fn start( @@ -2208,6 +2308,16 @@ async fn start_runtime_for_user( // is constructed so stale Goose AlwaysAllow entries cannot bypass Maple. let goose_config_dir = goose_path_root.join("config"); reset_maple_owned_permission_file(&goose_config_dir.join("permission.yaml"))?; + // The delegation skills follow the Integrations toggle. Reconcile them + // here so an upgrade that changed their text, or a file removed by + // hand, is repaired without touching the toggle. + if let Err(error) = sync_external_agent_skills( + &state.host.paths, + user_id, + external_agents_enabled(&state.host.paths, user_id), + ) { + log::warn!("External agent skills were not reconciled: {error}"); + } // Rewriting that file drops any tool entry Maple added, so the embedded // CUA tools must be pinned into it again before the next desktop run. #[cfg(embedded_cua)] @@ -2262,6 +2372,16 @@ async fn start_runtime_for_user( .set_default_provider(Arc::new(MapleProvider::new(Arc::clone(&maple_api_session)))) .await; + let permission_modes: SessionPermissionModes = Arc::new(Mutex::new(HashMap::new())); + let lifetime = CancellationToken::new(); + let external_agents = Arc::new(ExternalAgentRegistry::new(ExternalAgentHost { + service: state.clone(), + account_scope: Arc::from(account_scope.as_str()), + session_manager: Arc::clone(&session_manager), + permission_modes: Arc::clone(&permission_modes), + project_root: project_root.clone(), + lifetime: lifetime.clone(), + })); let runtime = AgentRuntime { agent_manager, session_manager, @@ -2269,13 +2389,14 @@ async fn start_runtime_for_user( active_runs: HashMap::new(), session_title_tasks: HashMap::new(), session_tool_contexts: HashMap::new(), - permission_modes: Arc::new(Mutex::new(HashMap::new())), + permission_modes, web_tool_state: Arc::new(WebToolState::default()), + external_agents: Some(external_agents), project_root: project_root.clone(), model: model.clone(), mode: mode.clone(), account_scope, - lifetime: CancellationToken::new(), + lifetime, }; let status = runtime.desktop_status(); @@ -2357,6 +2478,24 @@ impl AgentRuntimeHandle { normalize_mcp_servers(config.mcp_servers) } + /// The PATH to look on for external agent executables. A macOS GUI + /// launch inherits a short PATH; the login shell's, once recovered for + /// the runtime, is the one the user's terminal has. + fn codex_search_path(&self) -> Option { + #[cfg(target_os = "macos")] + { + self.service + .login_shell_search_paths + .get() + .and_then(|paths| std::env::join_paths(paths).ok()) + .and_then(|joined| joined.into_string().ok()) + } + #[cfg(not(target_os = "macos"))] + { + None + } + } + /// Maple-curated integrations discovered on this device. /// /// The external manifest probe runs without holding the runtime lifecycle @@ -2364,7 +2503,7 @@ impl AgentRuntimeHandle { /// from publishing or mutating device-local state after logout. pub async fn list_integrations(&self) -> Result, String> { self.verify_generation().await?; - let detected = detect_integrations().await; + let detected = detect_integrations(self.codex_search_path().as_deref()).await; let state = &self.service; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; self.verify_generation().await?; @@ -2379,7 +2518,7 @@ impl AgentRuntimeHandle { ) -> Result, String> { require_known_integration(&request.id)?; self.verify_generation().await?; - let detected = detect_integrations().await; + let detected = detect_integrations(self.codex_search_path().as_deref()).await; let state = &self.service; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; self.verify_generation().await?; @@ -2404,7 +2543,7 @@ impl AgentRuntimeHandle { { cua::install_desktop_helper().await?; } - let detected = detect_integrations().await; + let detected = detect_integrations(self.codex_search_path().as_deref()).await; let state = &self.service; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; self.verify_generation().await?; @@ -2809,6 +2948,7 @@ impl AgentRuntimeHandle { maple_api_session, permission_modes, web_tool_state, + external_agents, runtime_lifetime, runtime_project_root, runtime_model, @@ -2825,6 +2965,7 @@ impl AgentRuntimeHandle { Arc::clone(¤t.maple_api_session), Arc::clone(¤t.permission_modes), Arc::clone(¤t.web_tool_state), + current.external_agents.clone(), // Stop and logout cancel this token, which ends a stalled MCP // startup that no run entry covers yet. current.lifetime.clone(), @@ -3005,6 +3146,7 @@ impl AgentRuntimeHandle { primary_model_supports_vision: false, tool_context: &tool_context, allow_embedded_cua: !has_external_tool_context, + external_agents: external_agents.as_ref(), }, ) .await?; @@ -3212,6 +3354,7 @@ impl AgentRuntimeHandle { maple_api_session, permission_modes, web_tool_state, + external_agents, runtime_model, ) = { let runtime = state.inner.lock().await; @@ -3235,6 +3378,7 @@ impl AgentRuntimeHandle { Arc::clone(¤t.maple_api_session), Arc::clone(¤t.permission_modes), Arc::clone(¤t.web_tool_state), + current.external_agents.clone(), current.model.clone(), ) }; @@ -3382,6 +3526,7 @@ impl AgentRuntimeHandle { primary_model_supports_vision: false, tool_context: &tool_context, allow_embedded_cua: false, + external_agents: external_agents.as_ref(), }, ) .await?; @@ -4223,7 +4368,14 @@ impl AgentRuntimeHandle { } let _session_lifecycle_guard = state.session_lifecycle.lock().await; - let (agent_manager, session_manager, permission_modes, web_tool_state, title_task) = { + let ( + agent_manager, + session_manager, + permission_modes, + web_tool_state, + title_task, + external_agents, + ) = { let mut runtime = state.inner.lock().await; match runtime.as_mut() { Some(current) => { @@ -4247,6 +4399,7 @@ impl AgentRuntimeHandle { Some(Arc::clone(¤t.permission_modes)), Some(Arc::clone(¤t.web_tool_state)), current.session_title_tasks.remove(&session_id), + current.external_agents.clone(), ) } None => ( @@ -4255,6 +4408,7 @@ impl AgentRuntimeHandle { None, None, None, + None, ), } }; @@ -4263,6 +4417,9 @@ impl AgentRuntimeHandle { title_task.token.cancel(); join_agent_tasks(vec![title_task.task_handle], RUN_SHUTDOWN_TIMEOUT).await; } + if let Some(external_agents) = external_agents { + external_agents.shutdown_session(&session_id).await; + } delete_persisted_agent_session( session_manager.as_ref(), @@ -4867,6 +5024,7 @@ impl AgentRuntimeHandle { maple_api_session, permission_modes, web_tool_state, + external_agents, runtime_lifetime, model, mode, @@ -4882,6 +5040,7 @@ impl AgentRuntimeHandle { Arc::clone(¤t.maple_api_session), Arc::clone(¤t.permission_modes), Arc::clone(¤t.web_tool_state), + current.external_agents.clone(), // Stop and logout cancel this token, which ends a stalled MCP // startup that no run entry covers yet. current.lifetime.clone(), @@ -5085,6 +5244,7 @@ impl AgentRuntimeHandle { primary_model_supports_vision: request.vision_capable, tool_context: &tool_context, allow_embedded_cua: permission_routing == AgentPermissionRouting::Desktop, + external_agents: external_agents.as_ref(), }, ) .await?; @@ -6117,7 +6277,7 @@ impl AgentRuntimeHandle { // fixed at start; one task's choice must not leak into other tasks. if goose_mode == GooseMode::Auto { - let request_ids = { + let drained = { let mut pending = state.pending_permissions.lock().await; let request_ids = pending .iter() @@ -6127,13 +6287,25 @@ impl AgentRuntimeHandle { }) .map(|((_, request_id), _)| request_id.clone()) .collect::>(); - for request_id in &request_ids { - pending.remove(&(session_id.clone(), request_id.clone())); - } request_ids + .into_iter() + .filter_map(|request_id| { + pending + .remove(&(session_id.clone(), request_id.clone())) + .map(|entry| (request_id, entry.origin)) + }) + .collect::>() }; - for request_id in request_ids { - deliver_tool_permission(&agent, request_id.clone(), Permission::AllowOnce).await; + for (request_id, origin) in drained { + match origin { + PendingPermissionOrigin::Goose => { + deliver_tool_permission(&agent, request_id.clone(), Permission::AllowOnce) + .await; + } + PendingPermissionOrigin::ExternalAgent(responder) => { + responder.resolve(AgentPermissionDecision::AllowOnce); + } + } if let Some(item) = update_live_permission_status( &state.live_timelines, &session_id, @@ -6231,6 +6403,50 @@ impl AgentRuntimeHandle { if request_id.trim().is_empty() { return Err("Agent permission response requires a request ID".to_string()); } + let key = (session_id.clone(), request_id.clone()); + let external = { + let pending = state.pending_permissions.lock().await; + pending.get(&key).and_then(|pending| match &pending.origin { + PendingPermissionOrigin::ExternalAgent(responder) => { + Some((responder.clone(), pending.routing, pending.request.clone())) + } + PendingPermissionOrigin::Goose => None, + }) + }; + if let Some((responder, routing, request)) = external { + // An external agent's request has no Goose run behind it; the + // desktop owns it, and the runtime that hosts the agent must + // still be this account's. + if !matches!(scope, AgentPermissionResponseScope::Desktop) + || routing != AgentPermissionRouting::Desktop + { + return Err("Agent permission responder does not own this request".to_string()); + } + { + let runtime = state.inner.lock().await; + let current = runtime + .as_ref() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, account_scope)?; + } + state.pending_permissions.lock().await.remove(&key); + responder.resolve(decision); + external_agents::publish_external_permission_decision( + state, + &session_id, + &request, + // The row draws `completed`, `denied`, or `cancelled`; + // nothing later rewrites an external row, so the raw + // decision string the desktop sends would leave it "waiting". + match decision { + AgentPermissionDecision::AllowOnce => "completed", + AgentPermissionDecision::DenyOnce => "denied", + AgentPermissionDecision::Cancel => "cancelled", + }, + ) + .await; + return Ok(()); + } let (agent, run_id, expected_routing, run_events, cancelled_permission_ids) = { let runtime = state.inner.lock().await; let current = runtime @@ -6275,7 +6491,6 @@ impl AgentRuntimeHandle { Arc::clone(&active_run.cancelled_permission_ids), ) }; - let key = (session_id.clone(), request_id.clone()); { let mut pending = state.pending_permissions.lock().await; let Some(request) = pending.get(&key) else { @@ -6853,6 +7068,7 @@ impl SubagentTracker { id: id.to_string(), task, background, + external: None, }) } SUBAGENT_LOAD_TOOL => { @@ -6931,6 +7147,7 @@ impl SubagentTracker { .as_millis() .min(u64::MAX as u128) as u64, activity: running.activity.clone(), + external: None, }) .collect::>(); // The map has no order of its own; the oldest subagent reads first. @@ -7436,6 +7653,7 @@ async fn run_agent_prompt(run: AgentPromptRun) -> Result { /// A cached User session may later be leased by ACP, so SessionType alone /// is not a sufficient host-process capability check. allow_embedded_cua: bool, + /// The runtime's external agents, offered to desktop tasks when the + /// integration is enabled. + external_agents: Option<&'a Arc>, } fn maple_model_config( @@ -8320,6 +8541,7 @@ async fn finish_session_agent( primary_model_supports_vision, tool_context, allow_embedded_cua, + external_agents, } = configuration; let PreparedSessionAgent { agent, @@ -8366,7 +8588,20 @@ async fn finish_session_agent( .map_err(|e| format!("Failed to create Maple developer tools: {e}"))? .with_attachment_store(attachment_store) .with_web_enabled(session_web_enabled(session)) - .with_desktop_ui_tools(session.session_type != SessionType::Acp); + .with_desktop_ui_tools(session.session_type != SessionType::Acp) + // External agents are a desktop feature: their approvals and progress + // go to the desktop, and the toggle in Integrations decides whether a + // task may delegate at all. The toggle is read at every agent build, + // so it takes effect at the next run. + .with_external_agents( + external_agents + .filter(|_| { + session.session_type != SessionType::Acp + && allow_embedded_cua + && external_agents_enabled(skills_scope.paths, skills_scope.user_id) + }) + .cloned(), + ); agent .extension_manager .add_client( @@ -10206,6 +10441,7 @@ pub(crate) mod test_support { session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), + external_agents: None, project_root: project_root.clone(), model: DEFAULT_AGENT_MODEL.to_string(), mode: DEFAULT_GOOSE_MODE.to_string(), @@ -10672,6 +10908,7 @@ mod tests { session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), + external_agents: None, project_root: project_root.clone(), model: DEFAULT_AGENT_MODEL.to_string(), mode: DEFAULT_GOOSE_MODE.to_string(), @@ -10733,6 +10970,7 @@ mod tests { run_id: run_id.to_string(), routing, request: test_permission_request(request_id), + origin: PendingPermissionOrigin::Goose, } } @@ -10835,6 +11073,7 @@ mod tests { session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), + external_agents: None, project_root: project_root.clone(), model: DEFAULT_AGENT_MODEL.to_string(), mode: DEFAULT_GOOSE_MODE.to_string(), @@ -10972,6 +11211,7 @@ mod tests { session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), + external_agents: None, project_root, model: DEFAULT_AGENT_MODEL.to_string(), mode: DEFAULT_GOOSE_MODE.to_string(), @@ -11268,6 +11508,7 @@ mod tests { session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), + external_agents: None, project_root, model: DEFAULT_AGENT_MODEL.to_string(), mode: DEFAULT_GOOSE_MODE.to_string(), @@ -11712,6 +11953,7 @@ mod tests { session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), + external_agents: None, project_root, model: DEFAULT_AGENT_MODEL.to_string(), mode: DEFAULT_GOOSE_MODE.to_string(), @@ -13805,7 +14047,7 @@ mod tests { ); assert!(matches!( started.as_slice(), - [AgentRunEvent::SubagentStarted { id, task, background: false }] + [AgentRunEvent::SubagentStarted { id, task, background: false, external: None }] if id == "delegate-1" && task == "Review the parser" )); // The same tool-request message repeated must not add a row. @@ -14130,6 +14372,16 @@ mod tests { manager.get_user_permission("load_skill"), Some(goose::config::permission::PermissionLevel::AlwaysAllow) ); + for tool in external_agents::EXTERNAL_AGENT_TOOLS { + // Starting an external agent is always allowed: its own + // commands and file changes come back through Maple's + // permission card, so gating the hand-off would prompt twice. + assert_eq!( + manager.get_user_permission(tool), + Some(goose::config::permission::PermissionLevel::AlwaysAllow), + "{tool}" + ); + } for tool in MAPLE_SUBAGENT_TOOLS { // KNOWN ISSUE: a subagent runs with every tool approved, // whatever the task's mode (see the note on @@ -18217,6 +18469,7 @@ mod tests { session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), + external_agents: None, project_root, model: DEFAULT_AGENT_MODEL.to_string(), mode: DEFAULT_GOOSE_MODE.to_string(), @@ -19191,6 +19444,7 @@ mod tests { AgentPermissionRouting::Desktop, test_permission_request("request-1"), &cancel_token, + PendingPermissionOrigin::Goose, ) .await, PendingPermissionRegistration::Rejected @@ -19243,6 +19497,7 @@ mod tests { AgentPermissionRouting::CallingSurface, original.clone(), &cancel_token, + PendingPermissionOrigin::Goose, ) .await, PendingPermissionRegistration::Registered @@ -19256,6 +19511,7 @@ mod tests { AgentPermissionRouting::CallingSurface, original, &cancel_token, + PendingPermissionOrigin::Goose, ) .await, PendingPermissionRegistration::Existing @@ -19274,6 +19530,7 @@ mod tests { AgentPermissionRouting::CallingSurface, conflicting, &cancel_token, + PendingPermissionOrigin::Goose, ) .await, PendingPermissionRegistration::Rejected @@ -19295,6 +19552,7 @@ mod tests { AgentPermissionRouting::Desktop, test_permission_request("request-1"), &cancel_token, + PendingPermissionOrigin::Goose, ) .await, PendingPermissionRegistration::Registered @@ -19319,6 +19577,7 @@ mod tests { AgentPermissionRouting::Desktop, reused, &cancel_token, + PendingPermissionOrigin::Goose, ) .await, PendingPermissionRegistration::Rejected diff --git a/apps/maple-agent/crates/maple-agent/src/agent/bounded_process.rs b/apps/maple-agent/crates/maple-agent/src/agent/bounded_process.rs index 89dcb1992..00b404756 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/bounded_process.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/bounded_process.rs @@ -5,8 +5,6 @@ //! Keeping one implementation means a fix to that cap, or to the way a partial //! read is reported, applies everywhere. -#![cfg(target_os = "macos")] - use tokio::io::AsyncReadExt; /// Read at most `max_bytes` from `stdout`, or fail if the child produced more. diff --git a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs index 6d0aebdbc..dbffdf841 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs @@ -1,4 +1,9 @@ use super::attachments::{AgentAttachmentStore, attachment_id_from_source}; +use super::external_agents::{ + AGENT_CANCEL_TOOL, AGENT_SEND_TOOL, AGENT_START_TOOL, AGENT_STATUS_TOOL, AgentRefParams, + AgentSendParams, AgentStartParams, EXTERNAL_AGENT_TOOLS, ExternalAgentCall, + ExternalAgentRegistry, LIST_AGENT_PROVIDERS_TOOL, +}; use super::web_tools::{ OPEN_URL_TOOL_NAME, OpenUrlParams, WEB_SEARCH_TOOL_NAME, WebSearchParams, WebToolState, bound_open_url_tool_error, bound_web_search_tool_error, execute_open_url, execute_web_search, @@ -156,6 +161,9 @@ pub(crate) struct MapleDeveloperClient { /// conversation and asks in plain text instead of writing to surfaces /// nobody can see. desktop_ui_tools: bool, + /// The external agents (Codex) this session may delegate to. `None` + /// leaves the `agent_*` tools out of the catalog. + external_agents: Option>, #[cfg(not(windows))] login_path_probe: ShellTool, #[cfg(not(windows))] @@ -189,6 +197,7 @@ impl MapleDeveloperClient { attachment_store: None, web_enabled: true, desktop_ui_tools: true, + external_agents: None, #[cfg(not(windows))] login_path_probe: ShellTool::new(true)?, #[cfg(not(windows))] @@ -211,6 +220,134 @@ impl MapleDeveloperClient { self } + pub(super) fn with_external_agents( + mut self, + registry: Option>, + ) -> Self { + self.external_agents = registry; + self + } + + #[cfg(windows)] + async fn login_path(&self) -> Option { + None + } + + fn external_agent_tools() -> [Tool; 5] { + [ + Tool::new( + AGENT_START_TOOL.to_string(), + format!( + "Hand a self-contained piece of work to an external coding agent (an installed harness such as Codex) that runs in the project with its own context and its own account. \ +The new agent knows nothing about this conversation: write a complete briefing with the task, relevant files, current state, what was tried, decisions made, acceptance criteria, and constraints. \ +It runs under its own sandbox and approval settings; whatever it asks approval for comes to the user through Maple, and in Allow all Maple grants it. \ +Blocking by default: the call returns the agent's result. With background=true the call returns at once and Maple tells you when the agent finishes; do not poll. \ +Call {LIST_AGENT_PROVIDERS_TOOL} first when unsure what is installed." + ), + object!({ + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Which external agent to use, from list_agent_providers (for example \"codex\")" + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained briefing for the agent" + }, + "background": { + "type": "boolean", + "description": "Return at once and let the agent work on; Maple reports when it finishes (default false)" + }, + "model": { + "type": "string", + "description": "Optional model override for the provider; omit to use its own default" + }, + "effort": { + "type": "string", + "description": "Optional reasoning effort for the provider (for example \"low\", \"medium\", \"high\"); omit to use its default" + }, + "cwd": { + "type": "string", + "description": "Optional subdirectory of the project for the agent to work in; must stay inside the project" + } + }, + "required": ["provider", "prompt"] + }), + ), + Tool::new( + AGENT_SEND_TOOL.to_string(), + format!( + "Give an external agent you started with {AGENT_START_TOOL} more instructions in the same thread, with its context intact. Use it to follow up, correct, or continue that agent's work." + ), + object!({ + "type": "object", + "properties": { + "provider": { "type": "string", "description": "The agent's provider" }, + "agent_id": { "type": "string", "description": "The agent ID that agent_start returned" }, + "prompt": { "type": "string", "description": "The next instructions for the agent" }, + "background": { + "type": "boolean", + "description": "Return at once and let the agent work on; Maple reports when it finishes (default false)" + }, + "model": { "type": "string", "description": "Optional model override" }, + "effort": { "type": "string", "description": "Optional reasoning effort" } + }, + "required": ["provider", "agent_id", "prompt"] + }), + ), + Tool::new( + AGENT_STATUS_TOOL.to_string(), + "Read the state and latest result of an external agent: its status, last message, files it changed, and commands it ran. Maple tells you when a background agent finishes, so call this when you need the result or when the user asks, not in a loop.".to_string(), + object!({ + "type": "object", + "properties": { + "provider": { "type": "string", "description": "The agent's provider" }, + "agent_id": { "type": "string", "description": "The agent ID that agent_start returned" } + }, + "required": ["provider", "agent_id"] + }), + ), + Tool::new( + AGENT_CANCEL_TOOL.to_string(), + format!( + "Interrupt what an external agent is doing now. The agent keeps its thread, so {AGENT_SEND_TOOL} can continue it later." + ), + object!({ + "type": "object", + "properties": { + "provider": { "type": "string", "description": "The agent's provider" }, + "agent_id": { "type": "string", "description": "The agent ID that agent_start returned" } + }, + "required": ["provider", "agent_id"] + }), + ), + Tool::new( + LIST_AGENT_PROVIDERS_TOOL.to_string(), + "List the external coding agents installed on this computer that a task may delegate to, with their version and sign-in state.".to_string(), + object!({ + "type": "object", + "properties": {} + }), + ), + ] + } + + async fn external_agent_call( + &self, + ctx: &ToolCallContext, + cancel_token: CancellationToken, + ) -> ExternalAgentCall { + ExternalAgentCall { + session_id: ctx.session_id.clone(), + working_dir: ctx.working_dir.clone(), + row_id: ctx.tool_call_request_id.clone(), + login_path: self.login_path().await, + tool_context: self.tool_context.snapshot(), + cancel_token, + } + } + #[cfg(not(windows))] async fn login_path(&self) -> Option { self.login_path @@ -619,6 +756,9 @@ impl McpClientTrait for MapleDeveloperClient { tools.push(web_search_tool()); tools.push(open_url_tool()); } + if self.external_agents.is_some() { + tools.extend(Self::external_agent_tools()); + } if let Some(router) = self.tool_context.transient_mcp() { if tools @@ -647,6 +787,34 @@ impl McpClientTrait for MapleDeveloperClient { cancel_token: CancellationToken, ) -> Result { let working_dir = ctx.working_dir.as_deref(); + if EXTERNAL_AGENT_TOOLS.contains(&name) { + let Some(registry) = self.external_agents.as_ref() else { + return Ok(error_result( + "External agents are not enabled for this task.", + )); + }; + let call = self.external_agent_call(ctx, cancel_token).await; + let result = match name { + AGENT_START_TOOL => match Self::parse_args::(arguments) { + Ok(params) => registry.start(call, params).await, + Err(error) => error_result(error), + }, + AGENT_SEND_TOOL => match Self::parse_args::(arguments) { + Ok(params) => registry.send(call, params).await, + Err(error) => error_result(error), + }, + AGENT_STATUS_TOOL => match Self::parse_args::(arguments) { + Ok(params) => registry.status(&call, params).await, + Err(error) => error_result(error), + }, + AGENT_CANCEL_TOOL => match Self::parse_args::(arguments) { + Ok(params) => registry.cancel_tool(&call, params).await, + Err(error) => error_result(error), + }, + _ => registry.list_providers(&call).await, + }; + return Ok(result); + } let result = match name { "read" => match Self::parse_args::(arguments) { Ok(params) => read_file(params, working_dir, cancel_token).await, @@ -824,7 +992,7 @@ fn success_result(text: impl Into) -> CallToolResult { CallToolResult::success(vec![prioritized_text(text)]) } -fn text_result(text: impl Into) -> CallToolResult { +pub(super) fn text_result(text: impl Into) -> CallToolResult { CallToolResult::success(vec![prioritized_text(text)]) } @@ -871,7 +1039,7 @@ struct BoundedShellExecution { /// `Command::kill_on_drop` only reaches the shell leader; the process-wrap /// child reaches the Unix process group or Windows job from this synchronous /// drop path as well. -struct ArmedShellChild { +pub(super) struct ArmedShellChild { child: Box, armed: bool, } @@ -881,11 +1049,11 @@ impl ArmedShellChild { Self { child, armed: true } } - fn as_mut(&mut self) -> &mut dyn ChildWrapper { + pub(super) fn as_mut(&mut self) -> &mut dyn ChildWrapper { self.child.as_mut() } - async fn kill_and_wait(&mut self) -> Option { + pub(super) async fn kill_and_wait(&mut self) -> Option { let kill_succeeded = match self.child.start_kill() { Ok(()) => true, Err(error) => { @@ -1353,13 +1521,94 @@ fn apply_flatpak_tool_context( } } -#[cfg(not(windows))] -fn executable_on_path(name: &str) -> Option { - std::env::var_os("PATH") +pub(super) fn executable_on_path(name: &str) -> Option { + std::env::var_os("PATH").and_then(|path| executable_in_paths(name, &path)) +} + +/// Like [`executable_on_path`], but against an explicit search path such +/// as the one recovered from the user's login shell. +pub(super) fn executable_in_search_path(name: &str, search_path: &str) -> Option { + executable_in_paths(name, std::ffi::OsStr::new(search_path)) +} + +fn executable_in_paths(name: &str, path: &std::ffi::OsStr) -> Option { + let candidates = executable_candidates(name); + std::env::split_paths(path).find_map(|dir| { + candidates + .iter() + .map(|candidate| dir.join(candidate)) + .find(|candidate| candidate.is_file()) + }) +} + +/// The file names one command name may resolve to. Windows resolves +/// `codex` to `codex.exe` or the npm shim `codex.cmd` through `PATHEXT`; +/// a real executable is preferred over a shim. +#[cfg(windows)] +fn executable_candidates(name: &str) -> Vec { + if Path::new(name).extension().is_some() { + return vec![name.to_string()]; + } + let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string()); + let mut extensions = pathext + .split(';') + .map(str::trim) + .filter(|extension| !extension.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>(); + extensions.sort_by_key(|extension| extension != ".exe"); + extensions .into_iter() - .flat_map(|path| std::env::split_paths(&path).collect::>()) - .map(|dir| dir.join(name)) - .find(|candidate| candidate.is_file()) + .map(|extension| format!("{name}{extension}")) + .collect() +} + +#[cfg(not(windows))] +fn executable_candidates(name: &str) -> Vec { + vec![name.to_string()] +} + +/// The command that starts a long-lived external agent process such as +/// `codex app-server`. It runs in the project, on the user's login PATH, +/// with the same environment scrubbing as the shell tool. +pub(super) fn build_external_agent_command( + executable: &Path, + args: &[&str], + working_dir: &Path, + login_path: Option<&str>, + session_id: Option<&str>, + tool_context: &AgentToolContextSnapshot, +) -> Result { + #[cfg(not(windows))] + if Path::new("/.flatpak-info").exists() { + return Err("External agents are not supported inside Flatpak yet".to_string()); + } + let mut command = tokio::process::Command::new(executable); + command.args(args).current_dir(working_dir); + if let Some(path) = login_path { + command.env("PATH", path); + } + apply_session_environment(&mut command, session_id); + apply_tool_context(&mut command, tool_context); + #[cfg(unix)] + configure_subprocess(&mut command); + Ok(command) +} + +/// Spawn under the same containment as the shell tool: a Unix process +/// group or a Windows job, so teardown reaches every descendant. +pub(super) fn spawn_contained( + command: tokio::process::Command, +) -> std::io::Result { + let mut command = CommandWrap::from(command); + #[cfg(unix)] + command.wrap(ProcessGroup::leader()); + #[cfg(windows)] + { + command.wrap(CreationFlags(CREATE_NO_WINDOW)); + command.wrap(JobObject); + } + Ok(ArmedShellChild::new(command.spawn()?)) } fn render_bounded_shell_result( @@ -2475,7 +2724,9 @@ const MAX_USER_QUESTIONS: usize = 3; /// Build the question batch from the model's entries. Entries without /// text are skipped, ids are made unique, and the batch is capped at /// `MAX_USER_QUESTIONS` so the answer map always has one slot per id. -fn parse_user_questions(entries: &[serde_json::Value]) -> Vec { +pub(super) fn parse_user_questions( + entries: &[serde_json::Value], +) -> Vec { let mut questions: Vec = Vec::new(); let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); for (index, entry) in entries.iter().enumerate() { diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/app_server.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/app_server.rs new file mode 100644 index 000000000..1888c4ad2 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/app_server.rs @@ -0,0 +1,291 @@ +//! JSON-RPC 2.0 over newline-delimited stdio, as `codex app-server` speaks it. +//! +//! One reader task owns the child's stdout. It resolves responses to the +//! requests this client sent, and hands notifications and server-initiated +//! requests to the owner through one channel. The owner answers a server +//! request with [`AppServerClient::respond`]. + +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::io::AsyncWriteExt; +use tokio::process::{ChildStdin, ChildStdout}; +use tokio::sync::{Mutex, mpsc, oneshot}; +use tokio_util::codec::{FramedRead, LinesCodec}; +use tokio_util::sync::CancellationToken; + +/// One protocol line. A file change can carry a whole diff. +const MAX_LINE_BYTES: usize = 4 * 1024 * 1024; +/// Server messages waiting for the owner. The owner never blocks on a +/// message for long: approvals run on their own tasks. +const SERVER_MESSAGE_CAPACITY: usize = 256; + +/// A message the server initiated. +#[derive(Debug)] +pub(super) enum ServerMessage { + Notification { + method: String, + params: Value, + }, + /// The client must answer with the same `id`. + Request { + id: Value, + method: String, + params: Value, + }, +} + +type PendingResponses = Arc>>>>; + +pub(super) struct AppServerClient { + writer: Mutex, + pending: PendingResponses, + next_id: AtomicU64, + /// Cancelled when the server's stdout closes; every request fails + /// after that. + closed: CancellationToken, +} + +impl AppServerClient { + /// Wrap the child's pipes. The returned receiver carries every server + /// message; the join handle is the reader task, which ends when stdout + /// closes. + pub(super) fn new( + stdin: ChildStdin, + stdout: ChildStdout, + ) -> ( + Arc, + mpsc::Receiver, + tokio::task::JoinHandle<()>, + ) { + let (sender, receiver) = mpsc::channel(SERVER_MESSAGE_CAPACITY); + let client = Arc::new(Self { + writer: Mutex::new(stdin), + pending: Arc::new(StdMutex::new(HashMap::new())), + next_id: AtomicU64::new(1), + closed: CancellationToken::new(), + }); + let reader = tokio::spawn(read_server_messages( + stdout, + Arc::clone(&client.pending), + client.closed.clone(), + sender, + )); + (client, receiver, reader) + } + + pub(super) fn closed(&self) -> &CancellationToken { + &self.closed + } + + /// Send a request and wait for its response. + pub(super) async fn request(&self, method: &str, params: Value) -> Result { + if self.closed.is_cancelled() { + return Err(format!("{method} failed: the agent process has exited")); + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, tx); + if let Err(error) = self + .write(json!({ "id": id, "method": method, "params": params })) + .await + { + self.pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); + return Err(format!("{method} failed: {error}")); + } + tokio::select! { + biased; + response = rx => match response { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) => Err(format!("{method} failed: {error}")), + Err(_) => Err(format!("{method} failed: the agent process has exited")), + }, + _ = self.closed.cancelled() => { + Err(format!("{method} failed: the agent process has exited")) + } + } + } + + pub(super) async fn notify(&self, method: &str, params: Value) -> Result<(), String> { + self.write(json!({ "method": method, "params": params })) + .await + .map_err(|error| format!("{method} failed: {error}")) + } + + /// Answer a server-initiated request. + pub(super) async fn respond(&self, id: Value, result: Value) -> Result<(), String> { + self.write(json!({ "id": id, "result": result })) + .await + .map_err(|error| format!("response failed: {error}")) + } + + pub(super) async fn respond_error(&self, id: Value, message: &str) { + let _ = self + .write(json!({ "id": id, "error": { "code": -32000, "message": message } })) + .await; + } + + async fn write(&self, message: Value) -> std::io::Result<()> { + let mut line = serde_json::to_vec(&message)?; + line.push(b'\n'); + let mut writer = self.writer.lock().await; + writer.write_all(&line).await?; + writer.flush().await + } +} + +async fn read_server_messages( + stdout: ChildStdout, + pending: PendingResponses, + closed: CancellationToken, + sender: mpsc::Sender, +) { + use futures_util::StreamExt; + + let mut lines = FramedRead::new(stdout, LinesCodec::new_with_max_length(MAX_LINE_BYTES)); + while let Some(line) = lines.next().await { + let line = match line { + Ok(line) => line, + Err(error) => { + log::warn!("External agent protocol read failed: {error}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + let message: Value = match serde_json::from_str(&line) { + Ok(message) => message, + Err(_) => { + log::debug!("External agent sent a non-JSON line"); + continue; + } + }; + match classify(message) { + Classified::Response { id, result } => { + let sender = pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); + if let Some(sender) = sender { + let _ = sender.send(result); + } + } + Classified::Server(message) => { + if sender.send(message).await.is_err() { + break; + } + } + Classified::Ignored => {} + } + } + closed.cancel(); + let stale = std::mem::take( + &mut *pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, sender) in stale { + let _ = sender.send(Err("the agent process has exited".to_string())); + } +} + +enum Classified { + Response { + id: u64, + result: Result, + }, + Server(ServerMessage), + Ignored, +} + +/// Sort one line into a response to us, a request to us, or a notification. +fn classify(message: Value) -> Classified { + let Value::Object(mut fields) = message else { + return Classified::Ignored; + }; + let method = fields + .remove("method") + .and_then(|value| value.as_str().map(str::to_string)); + let id = fields.remove("id"); + match (id, method) { + (Some(id), Some(method)) => Classified::Server(ServerMessage::Request { + id, + method, + params: fields.remove("params").unwrap_or(Value::Null), + }), + (None, Some(method)) => Classified::Server(ServerMessage::Notification { + method, + params: fields.remove("params").unwrap_or(Value::Null), + }), + (Some(id), None) => { + let Some(id) = id.as_u64() else { + return Classified::Ignored; + }; + let result = match fields.remove("error") { + Some(error) if !error.is_null() => Err(error + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| error.to_string())), + _ => Ok(fields.remove("result").unwrap_or(Value::Null)), + }; + Classified::Response { id, result } + } + (None, None) => Classified::Ignored, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_responses_requests_and_notifications() { + match classify(json!({"id": 3, "result": {"ok": true}})) { + Classified::Response { id, result } => { + assert_eq!(id, 3); + assert_eq!(result.unwrap(), json!({"ok": true})); + } + _ => panic!("expected a response"), + } + match classify(json!({"id": 4, "error": {"code": 1, "message": "nope"}})) { + Classified::Response { id, result } => { + assert_eq!(id, 4); + assert_eq!(result.unwrap_err(), "nope"); + } + _ => panic!("expected an error response"), + } + match classify( + json!({"id": 9, "method": "item/commandExecution/requestApproval", "params": {"itemId": "i"}}), + ) { + Classified::Server(ServerMessage::Request { id, method, params }) => { + assert_eq!(id, json!(9)); + assert_eq!(method, "item/commandExecution/requestApproval"); + assert_eq!(params["itemId"], "i"); + } + _ => panic!("expected a server request"), + } + match classify( + json!({"method": "turn/completed", "params": {"turn": {"status": "completed"}}}), + ) { + Classified::Server(ServerMessage::Notification { method, params }) => { + assert_eq!(method, "turn/completed"); + assert_eq!(params["turn"]["status"], "completed"); + } + _ => panic!("expected a notification"), + } + assert!(matches!( + classify(json!({"jsonrpc": "2.0"})), + Classified::Ignored + )); + assert!(matches!(classify(json!("text")), Classified::Ignored)); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/codex.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/codex.rs new file mode 100644 index 000000000..e9e347386 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/codex.rs @@ -0,0 +1,966 @@ +//! OpenAI Codex CLI as an external agent, driven through `codex app-server`. +//! +//! Codex uses the user's own sign-in and `~/.codex` configuration, +//! including its sandbox and approval settings. Maple passes it only the +//! prompt, the working directory, and one feature flag. + +use super::super::AgentQuestion; +use super::super::developer_tools::parse_user_questions; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +pub(crate) const PROVIDER_ID: &str = "codex"; +pub(crate) const PROVIDER_NAME: &str = "Codex"; +const EXECUTABLE: &str = "codex"; +/// The oldest Codex whose app-server speaks the v2 methods used here. +const MIN_VERSION: (u64, u64, u64) = (0, 143, 0); +const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(3); +const MAX_VERSION_BYTES: usize = 4 * 1024; +/// The name the handshake reports. It is the reserved non-originating +/// client name that Paseo uses, so Codex attributes the requests to the +/// user's own client rather than to a third-party product. +const CLIENT_NAME: &str = "codex_app_server_daemon"; +const CLIENT_TITLE: &str = "Codex App Server Daemon"; +const CLIENT_VERSION: &str = "0.0.0"; + +/// What Maple found out about the Codex installation on this device. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CodexDetection { + pub(crate) executable: Option, + pub(crate) version: Option, + /// `None` when Maple could not tell. + pub(crate) signed_in: Option, + /// Why the installation cannot be used, if it cannot. + pub(crate) problem: Option, +} + +/// Find `codex` on `search_path`, run `codex --version`, and read whether +/// a sign-in exists. Nothing here changes the installation. +pub(crate) async fn detect(search_path: Option<&str>) -> CodexDetection { + let Some(executable) = find_executable(search_path) else { + return CodexDetection::default(); + }; + let version = match probe_version(&executable).await { + Ok(version) => version, + Err(error) => { + return CodexDetection { + executable: Some(executable), + version: None, + signed_in: None, + problem: Some(format!("Maple could not run `codex --version`: {error}")), + }; + } + }; + let problem = match parse_version(&version) { + Some(parsed) if parsed < MIN_VERSION => Some(format!( + "Codex {version} is older than the {}.{}.{} that Maple needs. Update Codex.", + MIN_VERSION.0, MIN_VERSION.1, MIN_VERSION.2 + )), + Some(_) => None, + None => Some(format!( + "Maple could not read the Codex version from `{version}`" + )), + }; + CodexDetection { + executable: Some(executable), + version: Some(version), + signed_in: Some(auth_file_exists()), + problem, + } +} + +pub(crate) fn find_executable(search_path: Option<&str>) -> Option { + match search_path { + Some(path) => super::super::developer_tools::executable_in_search_path(EXECUTABLE, path), + None => super::super::developer_tools::executable_on_path(EXECUTABLE), + } +} + +async fn probe_version(executable: &Path) -> Result { + let mut command = tokio::process::Command::new(executable); + command + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|error| format!("could not start it: {error}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "could not capture its output".to_string())?; + let mut reader = tokio::spawn(super::super::bounded_process::read_bounded_stdout( + stdout, + MAX_VERSION_BYTES, + "the Codex version", + )); + let status = match tokio::time::timeout(VERSION_PROBE_TIMEOUT, child.wait()).await { + Ok(Ok(status)) => status, + Ok(Err(error)) => { + reader.abort(); + return Err(format!("could not wait for it: {error}")); + } + Err(_) => { + reader.abort(); + let _ = child.kill().await; + let _ = child.wait().await; + return Err("it did not exit in time".to_string()); + } + }; + let bytes = match tokio::time::timeout(VERSION_PROBE_TIMEOUT, &mut reader).await { + Ok(Ok(result)) => result?, + Ok(Err(error)) => return Err(format!("could not collect its output: {error}")), + Err(_) => { + reader.abort(); + return Err("its output did not close".to_string()); + } + }; + if !status.success() { + return Err(format!("it exited with {status}")); + } + let text = String::from_utf8_lossy(&bytes); + let version = text.trim(); + if version.is_empty() { + return Err("it printed nothing".to_string()); + } + Ok(version.to_string()) +} + +/// `codex-cli 0.153.4` and plain `0.153.4` both read as (0, 153, 4). +pub(crate) fn parse_version(text: &str) -> Option<(u64, u64, u64)> { + text.split_whitespace().rev().find_map(|token| { + let token = token.trim_start_matches('v'); + let core = token.split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) + }) +} + +/// Codex keeps its sign-in in `auth.json` under its home. Maple only reads +/// whether the file exists; it never opens it. +fn auth_file_exists() -> bool { + codex_home().is_some_and(|home| home.join("auth.json").is_file()) +} + +fn codex_home() -> Option { + if let Some(home) = std::env::var_os("CODEX_HOME").filter(|value| !value.is_empty()) { + return Some(PathBuf::from(home)); + } + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .filter(|value| !value.is_empty())?; + Some(PathBuf::from(home).join(".codex")) +} + +pub(crate) fn sign_in_hint() -> &'static str { + "Codex is not signed in. Run `codex login` in a terminal, then try again." +} + +/// The command line that starts the app-server on stdio. +pub(super) fn app_server_args() -> [&'static str; 1] { + ["app-server"] +} + +pub(super) fn initialize_params() -> Value { + json!({ + "clientInfo": { + "name": CLIENT_NAME, + "title": CLIENT_TITLE, + "version": CLIENT_VERSION, + }, + "capabilities": { + "experimentalApi": true, + }, + }) +} + +/// Configuration Maple sets for every thread and turn. Codex offers its +/// `request_user_input` tool only in plan mode unless this feature is on; +/// Maple answers those questions through its question card, so it turns +/// the feature on for the default mode too. +fn session_config() -> Value { + json!({ + "features": { + "default_mode_request_user_input": true, + }, + }) +} + +pub(super) fn thread_start_params(cwd: &Path, model: Option<&str>) -> Value { + let mut params = json!({ + "cwd": cwd.to_string_lossy(), + "config": session_config(), + }); + if let Some(model) = model { + params["model"] = json!(model); + } + params +} + +pub(super) fn thread_resume_params(thread_id: &str) -> Value { + json!({ "threadId": thread_id }) +} + +pub(super) struct TurnRequest<'a> { + pub(super) thread_id: &'a str, + pub(super) prompt: &'a str, + pub(super) cwd: &'a Path, + pub(super) model: Option<&'a str>, + pub(super) effort: Option<&'a str>, +} + +pub(super) fn turn_start_params(request: &TurnRequest<'_>) -> Value { + let mut params = json!({ + "threadId": request.thread_id, + "input": [{ "type": "text", "text": request.prompt, "text_elements": [] }], + "cwd": request.cwd.to_string_lossy(), + "config": session_config(), + }); + if let Some(model) = request.model { + params["model"] = json!(model); + } + if let Some(effort) = request.effort { + params["effort"] = json!(effort); + } + params +} + +pub(super) fn turn_interrupt_params(thread_id: &str, turn_id: &str) -> Value { + json!({ "threadId": thread_id, "turnId": turn_id }) +} + +/// The thread ID a `thread/start` or `thread/resume` response carries. +pub(super) fn thread_id_from_response(response: &Value) -> Option { + response + .get("thread") + .and_then(|thread| thread.get("id")) + .or_else(|| response.get("threadId")) + .or_else(|| response.get("id")) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// One Codex notification, reduced to what Maple shows. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum CodexEvent { + ThreadStarted { + thread_id: String, + }, + TurnStarted { + turn_id: Option, + }, + TurnCompleted { + status: String, + error: Option, + }, + AgentMessageDelta { + item_id: String, + delta: String, + }, + ItemStarted(CodexItem), + ItemCompleted(CodexItem), + Other, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) enum CodexItem { + AgentMessage { + id: String, + text: String, + }, + CommandExecution { + id: String, + command: String, + exit_code: Option, + status: Option, + }, + FileChange { + id: String, + changes: Vec, + status: Option, + }, + TodoList { + id: String, + items: Vec, + }, + /// In the default collaboration mode Codex asks the user without + /// blocking: the question rides an agent message, the turn ends, and + /// the answer comes back as the next turn's input. + AsyncQuestion { + id: String, + questions: Vec, + }, + Other { + id: String, + kind: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct AsyncQuestion { + pub(super) title: String, + pub(super) options: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct FileChangeEntry { + pub(super) path: String, + pub(super) kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct TodoEntry { + pub(super) text: String, + pub(super) completed: bool, +} + +pub(super) fn parse_notification(method: &str, params: &Value) -> CodexEvent { + match method { + "thread/started" => match params + .get("thread") + .and_then(|thread| thread.get("id")) + .and_then(Value::as_str) + { + Some(thread_id) => CodexEvent::ThreadStarted { + thread_id: thread_id.to_string(), + }, + None => CodexEvent::Other, + }, + "turn/started" => CodexEvent::TurnStarted { + turn_id: params + .get("turn") + .and_then(|turn| turn.get("id")) + .and_then(Value::as_str) + .map(str::to_string), + }, + "turn/completed" => { + let turn = params.get("turn").cloned().unwrap_or(Value::Null); + CodexEvent::TurnCompleted { + status: turn + .get("status") + .and_then(Value::as_str) + .unwrap_or("completed") + .to_string(), + error: turn + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::to_string), + } + } + "item/agentMessage/delta" => { + let item_id = params + .get("itemId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let delta = params + .get("delta") + .or_else(|| params.get("textDelta")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + CodexEvent::AgentMessageDelta { item_id, delta } + } + "item/started" => match params.get("item").map(parse_item) { + Some(item) => CodexEvent::ItemStarted(item), + None => CodexEvent::Other, + }, + "item/completed" => match params.get("item").map(parse_item) { + Some(item) => CodexEvent::ItemCompleted(item), + None => CodexEvent::Other, + }, + _ => CodexEvent::Other, + } +} + +fn parse_item(item: &Value) -> CodexItem { + let id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let kind = item.get("type").and_then(Value::as_str).unwrap_or_default(); + match normalize_item_type(kind).as_str() { + "agentmessage" => { + let questions = item + .get("questions") + .and_then(Value::as_array) + .map(|questions| { + questions + .iter() + .filter_map(parse_async_question) + .collect::>() + }) + .unwrap_or_default(); + if item.get("delivery").and_then(Value::as_str) == Some("async") + && !questions.is_empty() + { + return CodexItem::AsyncQuestion { id, questions }; + } + CodexItem::AgentMessage { + id, + text: item + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + } + } + "commandexecution" => CodexItem::CommandExecution { + id, + command: command_line(item.get("command")), + exit_code: item + .get("exitCode") + .or_else(|| item.get("exit_code")) + .and_then(Value::as_i64), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + }, + "filechange" => CodexItem::FileChange { + id, + changes: parse_file_changes(item.get("changes")), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + }, + "todolist" | "plan" => CodexItem::TodoList { + id, + items: item + .get("items") + .or_else(|| item.get("plan")) + .and_then(Value::as_array) + .map(|items| items.iter().filter_map(parse_todo).collect()) + .unwrap_or_default(), + }, + _ => CodexItem::Other { + id, + kind: kind.to_string(), + }, + } +} + +/// `CommandExecution`, `commandExecution`, and `command_execution` are one +/// type; Codex has spelled them all. +fn normalize_item_type(kind: &str) -> String { + kind.chars() + .filter(|character| *character != '_') + .flat_map(char::to_lowercase) + .collect() +} + +fn command_line(command: Option<&Value>) -> String { + match command { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" "), + _ => String::new(), + } +} + +/// Codex has sent `changes` both as a list of `{path, kind}` entries and as +/// a map from path to change; read either. +fn parse_file_changes(changes: Option<&Value>) -> Vec { + match changes { + Some(Value::Array(entries)) => entries.iter().filter_map(parse_file_change).collect(), + Some(Value::Object(by_path)) => by_path + .iter() + .filter_map(|(path, change)| { + let path = path.trim(); + if path.is_empty() { + return None; + } + Some(FileChangeEntry { + path: path.to_string(), + kind: change_kind(change.get("kind").or_else(|| change.get("type"))), + }) + }) + .collect(), + _ => Vec::new(), + } +} + +fn change_kind(kind: Option<&Value>) -> String { + match kind { + Some(Value::String(kind)) => kind.clone(), + Some(Value::Object(kind)) => kind + .get("type") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + _ => "update".to_string(), + } +} + +fn parse_file_change(change: &Value) -> Option { + let path = ["path", "file_path", "filePath"] + .into_iter() + .find_map(|key| change.get(key).and_then(Value::as_str)) + .map(str::trim) + .filter(|path| !path.is_empty())?; + let kind = change_kind(change.get("kind").or_else(|| change.get("type"))); + Some(FileChangeEntry { + path: path.to_string(), + kind, + }) +} + +fn parse_async_question(question: &Value) -> Option { + let title = question.get("title").and_then(Value::as_str)?.trim(); + if title.is_empty() { + return None; + } + Some(AsyncQuestion { + title: title.to_string(), + options: question + .get("options") + .and_then(Value::as_array) + .map(|options| { + options + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + }) +} + +/// The questions Maple shows for an async question, in the shape its +/// question card already understands. +pub(super) fn async_question_prompts(questions: &[AsyncQuestion]) -> Vec { + questions + .iter() + .enumerate() + .map(|(index, question)| AgentQuestion { + id: format!("q{index}"), + header: format!("Question {}", index + 1), + question: question.title.clone(), + options: question + .options + .iter() + .map(|label| super::super::AgentQuestionOption { + label: label.clone(), + description: String::new(), + }) + .collect(), + }) + .collect() +} + +/// The user's answers as the next turn's input, or `None` when every +/// question was dismissed. `answer` is the card's response JSON. +pub(super) fn async_answer_prompt(questions: &[AsyncQuestion], answer: &str) -> Option { + let parsed = serde_json::from_str::(answer).ok()?; + let answers = parsed.get("answers")?; + let mut lines = Vec::new(); + for (index, question) in questions.iter().enumerate() { + let values = answers + .get(format!("q{index}")) + .and_then(|entry| entry.get("answers")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + if values.is_empty() { + continue; + } + lines.push(format!("{}\n{values}", question.title)); + } + if lines.is_empty() { + return None; + } + Some(format!( + "Answers to your questions:\n\n{}", + lines.join("\n\n") + )) +} + +pub(super) fn turn_steer_params(thread_id: &str, turn_id: &str, text: &str) -> Value { + json!({ + "threadId": thread_id, + "expectedTurnId": turn_id, + "input": [{ "type": "text", "text": text, "text_elements": [] }], + }) +} + +fn parse_todo(todo: &Value) -> Option { + let text = ["text", "step", "content"] + .into_iter() + .find_map(|key| todo.get(key).and_then(Value::as_str))? + .to_string(); + let completed = todo + .get("completed") + .and_then(Value::as_bool) + .unwrap_or_else(|| { + matches!( + todo.get("status").and_then(Value::as_str), + Some("completed" | "done") + ) + }); + Some(TodoEntry { text, completed }) +} + +/// A request Codex makes of its client mid-turn. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum CodexServerRequest { + CommandApproval { + item_id: String, + command: String, + cwd: Option, + reason: Option, + }, + FileChangeApproval { + item_id: String, + reason: Option, + }, + UserInput { + questions: Vec, + }, + Unknown { + method: String, + }, +} + +pub(super) fn parse_server_request(method: &str, params: &Value) -> CodexServerRequest { + let item_id = params + .get("itemId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let reason = params + .get("reason") + .and_then(Value::as_str) + .map(str::to_string); + match method { + "item/commandExecution/requestApproval" => CodexServerRequest::CommandApproval { + item_id, + command: command_line(params.get("command")), + cwd: params + .get("cwd") + .and_then(Value::as_str) + .map(str::to_string), + reason, + }, + "item/fileChange/requestApproval" => { + CodexServerRequest::FileChangeApproval { item_id, reason } + } + "item/tool/requestUserInput" | "tool/requestUserInput" => { + let entries = params + .get("questions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + CodexServerRequest::UserInput { + questions: parse_user_questions(&entries), + } + } + _ => CodexServerRequest::Unknown { + method: method.to_string(), + }, + } +} + +/// The answer to an approval request. Maple never grants `acceptForSession`: +/// every decision is one-shot, like Maple's own permissions. +pub(super) fn approval_response(decision: super::super::AgentPermissionDecision) -> Value { + use super::super::AgentPermissionDecision; + let decision = match decision { + AgentPermissionDecision::AllowOnce => "accept", + AgentPermissionDecision::DenyOnce => "decline", + AgentPermissionDecision::Cancel => "cancel", + }; + json!({ "decision": decision }) +} + +/// The answer to a question. The question card composes Codex's own +/// response shape, so an answer that already is one passes through. +pub(super) fn user_input_response(answer: &str) -> Value { + if let Ok(value) = serde_json::from_str::(answer) + && value.get("answers").is_some() + { + return value; + } + json!({ "answers": {} }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_versions_in_the_shapes_codex_prints() { + assert_eq!(parse_version("codex-cli 0.153.4"), Some((0, 153, 4))); + assert_eq!(parse_version("0.143.0"), Some((0, 143, 0))); + assert_eq!(parse_version("v1.2.3-alpha.1"), Some((1, 2, 3))); + assert_eq!(parse_version("codex"), None); + assert!(parse_version("codex-cli 0.142.9").unwrap() < MIN_VERSION); + } + + #[test] + fn turn_start_carries_prompt_and_overrides_but_no_policy() { + let params = turn_start_params(&TurnRequest { + thread_id: "t1", + prompt: "fix it", + cwd: Path::new("/p/sub"), + model: Some("gpt-5.4"), + effort: Some("high"), + }); + assert_eq!(params["threadId"], "t1"); + assert_eq!(params["input"][0]["text"], "fix it"); + assert_eq!(params["cwd"], "/p/sub"); + // Codex's own configuration decides sandbox and approvals. + assert!(params.get("approvalPolicy").is_none()); + assert!(params.get("sandboxPolicy").is_none()); + assert_eq!(params["model"], "gpt-5.4"); + assert_eq!(params["effort"], "high"); + assert_eq!( + params["config"]["features"]["default_mode_request_user_input"], + true + ); + let bare = turn_start_params(&TurnRequest { + thread_id: "t1", + prompt: "x", + cwd: Path::new("/p"), + model: None, + effort: None, + }); + assert!(bare.get("model").is_none()); + assert!(bare.get("effort").is_none()); + } + + #[test] + fn parses_items_in_every_spelling() { + let started = parse_notification( + "item/started", + &json!({"item": {"id": "c1", "type": "CommandExecution", "command": ["cargo", "test"]}}), + ); + assert_eq!( + started, + CodexEvent::ItemStarted(CodexItem::CommandExecution { + id: "c1".into(), + command: "cargo test".into(), + exit_code: None, + status: None, + }) + ); + let completed = parse_notification( + "item/completed", + &json!({"item": {"id": "c1", "type": "command_execution", "command": "cargo test", "exit_code": 1, "status": "failed"}}), + ); + assert_eq!( + completed, + CodexEvent::ItemCompleted(CodexItem::CommandExecution { + id: "c1".into(), + command: "cargo test".into(), + exit_code: Some(1), + status: Some("failed".into()), + }) + ); + let file = parse_notification( + "item/completed", + &json!({"item": {"id": "f1", "type": "fileChange", "status": "completed", + "changes": [{"path": "src/a.rs", "kind": {"type": "add"}}, {"file_path": "b.rs"}]}}), + ); + assert_eq!( + file, + CodexEvent::ItemCompleted(CodexItem::FileChange { + id: "f1".into(), + changes: vec![ + FileChangeEntry { + path: "src/a.rs".into(), + kind: "add".into() + }, + FileChangeEntry { + path: "b.rs".into(), + kind: "update".into() + }, + ], + status: Some("completed".into()), + }) + ); + let todo = parse_notification( + "item/completed", + &json!({"item": {"id": "p1", "type": "todoList", "items": [{"text": "one", "completed": true}, {"text": "two"}]}}), + ); + assert_eq!( + todo, + CodexEvent::ItemCompleted(CodexItem::TodoList { + id: "p1".into(), + items: vec![ + TodoEntry { + text: "one".into(), + completed: true + }, + TodoEntry { + text: "two".into(), + completed: false + }, + ], + }) + ); + assert_eq!( + parse_notification( + "item/agentMessage/delta", + &json!({"itemId": "m", "delta": "hi"}) + ), + CodexEvent::AgentMessageDelta { + item_id: "m".into(), + delta: "hi".into() + } + ); + assert_eq!( + parse_notification( + "turn/completed", + &json!({"turn": {"status": "failed", "error": {"message": "boom"}}}) + ), + CodexEvent::TurnCompleted { + status: "failed".into(), + error: Some("boom".into()) + } + ); + assert_eq!( + parse_notification("thread/tokenUsage/updated", &json!({})), + CodexEvent::Other + ); + } + + #[test] + fn parses_async_questions_and_builds_the_answer_turn() { + let event = parse_notification( + "item/completed", + &json!({"item": {"id": "aq", "type": "agentMessage", "text": "Tabs or spaces?", "delivery": "async", + "questions": [{"title": "Tabs or spaces?", "options": ["Tabs", "Spaces"]}, {"title": "Why?", "options": null}]}}), + ); + let CodexEvent::ItemCompleted(CodexItem::AsyncQuestion { id, questions }) = event else { + panic!("expected an async question"); + }; + assert_eq!(id, "aq"); + assert_eq!(questions.len(), 2); + let prompts = async_question_prompts(&questions); + assert_eq!(prompts[0].id, "q0"); + assert_eq!(prompts[0].options[1].label, "Spaces"); + assert!(prompts[1].options.is_empty()); + let prompt = async_answer_prompt( + &questions, + r#"{"answers":{"q0":{"answers":["Spaces"]},"q1":{"answers":["habit"]}}}"#, + ) + .unwrap(); + assert_eq!( + prompt, + "Answers to your questions:\n\nTabs or spaces?\nSpaces\n\nWhy?\nhabit" + ); + assert!(async_answer_prompt(&questions, "").is_none()); + assert!(async_answer_prompt(&questions, r#"{"answers":{}}"#).is_none()); + // A plain message keeps being a message. + assert!(matches!( + parse_notification( + "item/completed", + &json!({"item": {"id": "m", "type": "agentMessage", "text": "hi"}}) + ), + CodexEvent::ItemCompleted(CodexItem::AgentMessage { .. }) + )); + } + + #[test] + fn parses_file_changes_keyed_by_path() { + let mapped = parse_notification( + "item/completed", + &json!({"item": {"id": "f2", "type": "fileChange", + "changes": {"src/c.rs": {"kind": {"type": "delete"}}, "d.rs": {"type": "add"}}}}), + ); + match mapped { + CodexEvent::ItemCompleted(CodexItem::FileChange { changes, .. }) => { + let mut paths = changes + .iter() + .map(|change| (change.path.as_str(), change.kind.as_str())) + .collect::>(); + paths.sort(); + assert_eq!(paths, vec![("d.rs", "add"), ("src/c.rs", "delete")]); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn parses_server_requests() { + assert_eq!( + parse_server_request( + "item/commandExecution/requestApproval", + &json!({"itemId": "i1", "command": "rm -rf build", "cwd": "/p", "reason": "cleanup"}) + ), + CodexServerRequest::CommandApproval { + item_id: "i1".into(), + command: "rm -rf build".into(), + cwd: Some("/p".into()), + reason: Some("cleanup".into()), + } + ); + assert_eq!( + parse_server_request("item/fileChange/requestApproval", &json!({"itemId": "i2"})), + CodexServerRequest::FileChangeApproval { + item_id: "i2".into(), + reason: None + } + ); + match parse_server_request( + "item/tool/requestUserInput", + &json!({"itemId": "i3", "questions": [{"id": "q1", "header": "Scope", "question": "Which?", "options": [{"label": "A", "description": "a"}]}]}), + ) { + CodexServerRequest::UserInput { questions } => { + assert_eq!(questions.len(), 1); + assert_eq!(questions[0].id, "q1"); + } + other => panic!("unexpected {other:?}"), + } + assert_eq!( + parse_server_request("mcpServer/elicitation/request", &json!({})), + CodexServerRequest::Unknown { + method: "mcpServer/elicitation/request".into() + } + ); + } + + #[test] + fn responses_use_codex_decisions() { + use super::super::super::AgentPermissionDecision; + assert_eq!( + approval_response(AgentPermissionDecision::AllowOnce)["decision"], + "accept" + ); + assert_eq!( + approval_response(AgentPermissionDecision::DenyOnce)["decision"], + "decline" + ); + assert_eq!( + approval_response(AgentPermissionDecision::Cancel)["decision"], + "cancel" + ); + assert_eq!( + user_input_response(r#"{"answers":{"q1":{"answers":["A"]}}}"#)["answers"]["q1"]["answers"] + [0], + "A" + ); + assert_eq!(user_input_response("")["answers"], json!({})); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs new file mode 100644 index 000000000..7cc83dfea --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs @@ -0,0 +1,2179 @@ +//! External coding agents a task can hand work to. +//! +//! A task delegates through `agent_start`, `agent_send`, `agent_status`, +//! `agent_cancel`, and `list_agent_providers`. Each external agent is one +//! child process owned by the Maple session that started it. Goose stays +//! the engine; the external agent runs under its own configuration, its +//! approval requests come to the user through Maple's own permission card, +//! and its progress streams into the transcript row of the tool call that +//! started the turn. +//! +//! Codex is the only provider today. The provider layer is separate so a +//! second harness can slot in without changing the tool contract. + +mod app_server; +pub(crate) mod codex; +#[cfg(test)] +mod tests; + +use super::developer_tools::{ + ArmedShellChild, build_external_agent_command, spawn_contained, text_result, +}; +use super::image_mediation::error_result; +use super::tool_context::AgentToolContextSnapshot; +use super::*; +use app_server::{AppServerClient, ServerMessage}; +use codex::{CodexEvent, CodexItem, CodexServerRequest}; +use goose::conversation::message::SystemNotificationContent; +use std::fmt::Write as _; +use std::process::Stdio; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicU64; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::oneshot; + +pub(crate) const AGENT_START_TOOL: &str = "agent_start"; +pub(crate) const AGENT_SEND_TOOL: &str = "agent_send"; +pub(crate) const AGENT_STATUS_TOOL: &str = "agent_status"; +pub(crate) const AGENT_CANCEL_TOOL: &str = "agent_cancel"; +pub(crate) const LIST_AGENT_PROVIDERS_TOOL: &str = "list_agent_providers"; +/// Every tool this module adds, in catalog order. +pub(crate) const EXTERNAL_AGENT_TOOLS: [&str; 5] = [ + AGENT_START_TOOL, + AGENT_SEND_TOOL, + AGENT_STATUS_TOOL, + AGENT_CANCEL_TOOL, + LIST_AGENT_PROVIDERS_TOOL, +]; +/// The key under which a tool result and a persisted notice carry the +/// activity payload the transcript renders. +pub const ACTIVITY_KEY: &str = "mapleExternalAgent"; +const NOTICE_ROW_KEY: &str = "rowId"; +const NOTICE_RESULT_KEY: &str = "resultText"; + +const MAX_AGENTS_PER_SESSION: usize = 4; +/// What the model reads back: the agent's last message, cut like Paseo cuts it. +const MAX_RESULT_TEXT_CHARS: usize = 4_000; +const MAX_ACTIVITY_COMMANDS: usize = 20; +const MAX_ACTIVITY_FILE_CHANGES: usize = 40; +const MAX_ACTIVITY_TODOS: usize = 20; +const MAX_ACTIVITY_COMMAND_CHARS: usize = 200; +const MAX_PROMPT_LABEL_CHARS: usize = MAX_AGENT_SESSION_TITLE_CHARS; +/// Streamed text repaints the transcript row at most this often. +const LIVE_ROW_INTERVAL: Duration = Duration::from_millis(150); +/// How long a cancelled blocking call waits for the agent to confirm the +/// interrupt before it reports back to the model. +const INTERRUPT_SETTLE_TIMEOUT: Duration = Duration::from_secs(5); +const INTERRUPT_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + +/// What one external agent has done, bounded so it fits a transcript row +/// and a persisted notice. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalAgentActivity { + pub provider: String, + pub agent_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + /// `running`, `completed`, `failed`, `cancelled`, or `idle`. + pub status: String, + /// The agent's messages in the current turn, tail-kept. + pub text: String, + #[serde(default)] + pub commands: Vec, + #[serde(default)] + pub file_changes: Vec, + #[serde(default)] + pub todos: Vec, + #[serde(default)] + pub turns: u32, + #[serde(default)] + pub elapsed_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// What the agent is waiting on the user for, while it waits. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_permission: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityCommand { + pub id: String, + pub command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// `running`, `completed`, or `failed`. + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityFileChange { + pub path: String, + pub kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityTodo { + pub text: String, + pub completed: bool, +} + +impl ExternalAgentActivity { + fn record_command_started(&mut self, id: String, command: String) { + let command = bounded_timeline_text(&command, MAX_ACTIVITY_COMMAND_CHARS); + if let Some(existing) = self.commands.iter_mut().find(|entry| entry.id == id) { + existing.command = command; + return; + } + self.commands.push(ActivityCommand { + id, + command, + exit_code: None, + status: "running".to_string(), + }); + let overflow = self.commands.len().saturating_sub(MAX_ACTIVITY_COMMANDS); + if overflow > 0 { + self.commands.drain(..overflow); + } + } + + fn record_command_finished( + &mut self, + id: &str, + command: String, + exit_code: Option, + status: Option, + ) { + let status = match (status.as_deref(), exit_code) { + (Some("failed" | "error"), _) => "failed", + (_, Some(code)) if code != 0 => "failed", + _ => "completed", + } + .to_string(); + match self.commands.iter_mut().find(|entry| entry.id == id) { + Some(existing) => { + existing.exit_code = exit_code; + existing.status = status; + } + None => { + self.record_command_started(id.to_string(), command); + if let Some(existing) = self.commands.last_mut() { + existing.exit_code = exit_code; + existing.status = status; + } + } + } + } + + fn record_file_changes(&mut self, changes: Vec) { + for change in changes { + if let Some(existing) = self + .file_changes + .iter_mut() + .find(|entry| entry.path == change.path) + { + existing.kind = change.kind; + continue; + } + self.file_changes.push(ActivityFileChange { + path: change.path, + kind: change.kind, + }); + } + let overflow = self + .file_changes + .len() + .saturating_sub(MAX_ACTIVITY_FILE_CHANGES); + if overflow > 0 { + self.file_changes.drain(..overflow); + } + } + + fn record_todos(&mut self, items: Vec) { + self.todos = items + .into_iter() + .take(MAX_ACTIVITY_TODOS) + .map(|item| ActivityTodo { + text: item.text, + completed: item.completed, + }) + .collect(); + } + + fn begin_turn(&mut self) { + self.status = "running".to_string(); + self.text.clear(); + self.error = None; + self.pending_permission = None; + self.turns = self.turns.saturating_add(1); + } +} + +/// The model-facing summary of one agent's state. +fn render_activity(activity: &ExternalAgentActivity, guidance: &str) -> String { + let mut out = String::new(); + let _ = writeln!(out, "Status: {}", activity.status); + let _ = writeln!(out, "Provider: {}", activity.provider); + let _ = writeln!(out, "Agent ID: {}", activity.agent_id); + if let Some(thread_id) = &activity.thread_id { + let _ = writeln!(out, "Thread ID: {thread_id}"); + } + if let Some(error) = &activity.error { + let _ = writeln!(out, "Error: {error}"); + } + if let Some(pending) = &activity.pending_permission { + let _ = writeln!(out, "Waiting for the user to decide: {pending}"); + } + if !activity.file_changes.is_empty() { + let paths = activity + .file_changes + .iter() + .map(|change| change.path.as_str()) + .collect::>() + .join(", "); + let _ = writeln!( + out, + "Files changed ({}): {paths}", + activity.file_changes.len() + ); + } + if !activity.commands.is_empty() { + let failed = activity + .commands + .iter() + .filter(|command| command.status == "failed") + .count(); + let _ = writeln!( + out, + "Commands run: {} ({failed} failed)", + activity.commands.len() + ); + } + out.push_str("\n\n"); + out.push_str(&bounded_timeline_text( + activity.text.trim(), + MAX_RESULT_TEXT_CHARS, + )); + out.push_str("\n\n\n"); + out.push_str(guidance); + out +} + +fn completion_guidance(activity: &ExternalAgentActivity) -> String { + match activity.status.as_str() { + "cancelled" => format!( + "The turn was interrupted. Call {AGENT_SEND_TOOL} with this agent ID to continue the same thread." + ), + "failed" => format!( + "The agent failed. Read its error, then call {AGENT_SEND_TOOL} with this agent ID to continue, or start a new agent." + ), + _ => format!( + "Read the changed files yourself before relying on them. To give this agent more instructions with its context intact, call {AGENT_SEND_TOOL} with this agent ID." + ), + } +} + +fn background_guidance() -> String { + format!( + "The agent works in the background. Maple will tell you when it finishes; continue with other work and do not poll {AGENT_STATUS_TOOL}. Use {AGENT_STATUS_TOOL} only when the user asks how it is going." + ) +} + +/// What the transcript calls a call of one of these tools. +pub(super) fn tool_title(name: &str) -> Option<&'static str> { + Some(match name { + AGENT_START_TOOL => "External agent: start", + AGENT_SEND_TOOL => "External agent: continue", + AGENT_STATUS_TOOL => "External agent: status", + AGENT_CANCEL_TOOL => "External agent: stop", + LIST_AGENT_PROVIDERS_TOOL => "External agent: providers", + _ => return None, + }) +} + +/// The row status the desktop draws for a decision; its permission rows +/// know `completed`, `denied`, and `cancelled`. +fn decision_row_status(decision: AgentPermissionDecision) -> &'static str { + match decision { + AgentPermissionDecision::AllowOnce => "completed", + AgentPermissionDecision::DenyOnce => "denied", + AgentPermissionDecision::Cancel => "cancelled", + } +} + +/// Title of a row for a turn Maple started itself. +const SYNTHETIC_TURN_TITLE: &str = "External agent: answer delivered"; + +fn external_run_id(agent_id: &str) -> String { + format!("external-{agent_id}") +} + +fn subagent_row_id(agent_id: &str) -> String { + format!("external-agent-{agent_id}") +} + +/// What Maple needs from the runtime to host external agents. +#[derive(Clone)] +pub(super) struct ExternalAgentHost { + pub(super) service: MapleAgentService, + pub(super) account_scope: Arc, + pub(super) session_manager: Arc, + pub(super) permission_modes: SessionPermissionModes, + pub(super) project_root: PathBuf, + /// The runtime's lifetime; every agent's token derives from it. + pub(super) lifetime: CancellationToken, +} + +/// One tool call's view of its caller. +pub(crate) struct ExternalAgentCall { + pub(crate) session_id: String, + pub(crate) working_dir: Option, + /// The timeline row of the tool call, which the turn's progress joins. + pub(crate) row_id: Option, + pub(crate) login_path: Option, + pub(crate) tool_context: AgentToolContextSnapshot, + pub(crate) cancel_token: CancellationToken, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentStartParams { + pub(crate) provider: String, + pub(crate) prompt: String, + #[serde(default)] + pub(crate) background: bool, + pub(crate) model: Option, + pub(crate) effort: Option, + pub(crate) cwd: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentSendParams { + pub(crate) provider: String, + pub(crate) agent_id: String, + pub(crate) prompt: String, + #[serde(default)] + pub(crate) background: bool, + pub(crate) model: Option, + pub(crate) effort: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentRefParams { + pub(crate) provider: String, + pub(crate) agent_id: String, +} + +#[derive(Default)] +struct SessionAgents { + agents: HashMap>, +} + +/// The external agents of every session of the running runtime. +pub(crate) struct ExternalAgentRegistry { + host: ExternalAgentHost, + sessions: Mutex>, + /// One-shot permission IDs, separate from Goose's so the two can + /// never collide. + issued_permission_ids: IssuedPermissionIds, + next_agent: AtomicU64, +} + +impl ExternalAgentRegistry { + pub(super) fn new(host: ExternalAgentHost) -> Self { + Self { + host, + sessions: Mutex::new(HashMap::new()), + issued_permission_ids: Arc::new(Mutex::new(HashSet::new())), + next_agent: AtomicU64::new(1), + } + } + + fn require_provider(provider: &str) -> Result<(), String> { + if provider.trim() == codex::PROVIDER_ID { + Ok(()) + } else { + Err(format!( + "Unknown agent provider '{}'. Call {LIST_AGENT_PROVIDERS_TOOL} to see what is installed.", + provider.trim() + )) + } + } + + async fn agent(&self, session_id: &str, agent_id: &str) -> Option> { + self.sessions + .lock() + .await + .get(session_id) + .and_then(|session| session.agents.get(agent_id.trim())) + .cloned() + } + + fn resolve_cwd( + &self, + call: &ExternalAgentCall, + requested: Option<&str>, + ) -> Result { + let base = call + .working_dir + .clone() + .unwrap_or_else(|| self.host.project_root.clone()); + let root = base.canonicalize().unwrap_or(base); + let Some(requested) = requested.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(root); + }; + let candidate = root.join(requested); + let candidate = candidate + .canonicalize() + .map_err(|error| format!("cwd '{requested}' is not usable: {error}"))?; + if !candidate.starts_with(&root) { + return Err(format!( + "cwd '{requested}' is outside the project root; external agents work inside the project only" + )); + } + if !candidate.is_dir() { + return Err(format!("cwd '{requested}' is not a directory")); + } + Ok(candidate) + } + + pub(crate) async fn list_providers(&self, call: &ExternalAgentCall) -> CallToolResult { + let detection = codex::detect(call.login_path.as_deref()).await; + let mut out = String::new(); + let _ = writeln!(out, "Agent providers available to this task:"); + match (&detection.executable, &detection.problem) { + (None, _) => { + let _ = writeln!( + out, + "- codex: not installed. Ask the user to install the Codex CLI and make sure `codex` is on PATH." + ); + } + (Some(_), Some(problem)) => { + let _ = writeln!(out, "- codex: unusable. {problem}"); + } + (Some(_), None) => { + let version = detection.version.as_deref().unwrap_or("unknown version"); + let sign_in = match detection.signed_in { + Some(true) => "signed in".to_string(), + Some(false) => codex::sign_in_hint().to_string(), + None => "sign-in state unknown".to_string(), + }; + let _ = writeln!( + out, + "- codex: {} {version}, {sign_in}. Runs `codex app-server` with the user's own Codex account and configuration; optional `model` and `effort` arguments override its defaults.", + codex::PROVIDER_NAME + ); + } + } + let _ = writeln!( + out, + "\nStart one with {AGENT_START_TOOL}(provider, prompt). Write a self-contained briefing: the new agent has none of this conversation's context." + ); + text_result(out) + } + + pub(crate) async fn start( + &self, + call: ExternalAgentCall, + params: AgentStartParams, + ) -> CallToolResult { + if let Err(error) = Self::require_provider(¶ms.provider) { + return error_result(error); + } + let prompt = params.prompt.trim().to_string(); + if prompt.is_empty() { + return error_result("prompt must not be empty"); + } + let cwd = match self.resolve_cwd(&call, params.cwd.as_deref()) { + Ok(cwd) => cwd, + Err(error) => return error_result(error), + }; + let agent = { + let mut sessions = self.sessions.lock().await; + let session = sessions.entry(call.session_id.clone()).or_default(); + if session.agents.len() >= MAX_AGENTS_PER_SESSION { + return error_result(format!( + "This task already has {MAX_AGENTS_PER_SESSION} external agents. Reuse one with {AGENT_SEND_TOOL} or wait for one to finish." + )); + } + let agent_id = format!( + "{}-{}", + codex::PROVIDER_ID, + self.next_agent.fetch_add(1, Ordering::Relaxed) + ); + let agent = Arc::new(ExternalAgent::new( + agent_id.clone(), + call.session_id.clone(), + first_line_label(&prompt), + cwd, + self.host.clone(), + Arc::clone(&self.issued_permission_ids), + )); + session.agents.insert(agent_id, Arc::clone(&agent)); + agent + }; + agent + .run_turn( + &call, + TurnInput { + prompt, + background: params.background, + model: params.model, + effort: params.effort, + }, + ) + .await + } + + pub(crate) async fn send( + &self, + call: ExternalAgentCall, + params: AgentSendParams, + ) -> CallToolResult { + if let Err(error) = Self::require_provider(¶ms.provider) { + return error_result(error); + } + let prompt = params.prompt.trim().to_string(); + if prompt.is_empty() { + return error_result("prompt must not be empty"); + } + let Some(agent) = self.agent(&call.session_id, ¶ms.agent_id).await else { + return error_result(unknown_agent(¶ms.agent_id)); + }; + agent + .run_turn( + &call, + TurnInput { + prompt, + background: params.background, + model: params.model, + effort: params.effort, + }, + ) + .await + } + + pub(crate) async fn status( + &self, + call: &ExternalAgentCall, + params: AgentRefParams, + ) -> CallToolResult { + if let Err(error) = Self::require_provider(¶ms.provider) { + return error_result(error); + } + let Some(agent) = self.agent(&call.session_id, ¶ms.agent_id).await else { + return error_result(unknown_agent(¶ms.agent_id)); + }; + let activity = agent.activity().await; + let guidance = if activity.status == "running" { + background_guidance() + } else { + completion_guidance(&activity) + }; + let mut result = text_result(render_activity(&activity, &guidance)); + result.structured_content = Some(json!({ ACTIVITY_KEY: activity })); + result + } + + pub(crate) async fn cancel_tool( + &self, + call: &ExternalAgentCall, + params: AgentRefParams, + ) -> CallToolResult { + if let Err(error) = Self::require_provider(¶ms.provider) { + return error_result(error); + } + match self.cancel(&call.session_id, ¶ms.agent_id).await { + Ok(activity) => { + let mut result = + text_result(render_activity(&activity, &completion_guidance(&activity))); + result.structured_content = Some(json!({ ACTIVITY_KEY: activity })); + result + } + Err(error) => error_result(error), + } + } + + /// Stop the agent's current turn and its process. The agent stays + /// resumable: the next send starts a fresh process on the same thread. + pub(crate) async fn cancel( + &self, + session_id: &str, + agent_id: &str, + ) -> Result { + let Some(agent) = self.agent(session_id, agent_id).await else { + return Err(unknown_agent(agent_id)); + }; + agent.stop_turn().await; + Ok(agent.activity().await) + } + + /// The agents of a task that are still working, for a caller that + /// opens the task after the run that started them ended. + pub(crate) async fn snapshot(&self, session_id: &str) -> Vec { + let agents = match self.sessions.lock().await.get(session_id) { + Some(session) => session.agents.values().cloned().collect::>(), + None => return Vec::new(), + }; + let mut rows = Vec::new(); + for agent in agents { + if let Some(row) = agent.subagent_row().await { + rows.push(row); + } + } + rows.sort_by_key(|row| std::cmp::Reverse(row.elapsed_ms)); + rows + } + + pub(crate) async fn shutdown_session(&self, session_id: &str) { + let agents = self + .sessions + .lock() + .await + .remove(session_id) + .map(|session| session.agents.into_values().collect::>()) + .unwrap_or_default(); + for agent in agents { + agent.shutdown().await; + } + } + + pub(crate) async fn shutdown_all(&self, graceful_timeout: Duration) { + let agents = std::mem::take(&mut *self.sessions.lock().await) + .into_values() + .flat_map(|session| session.agents.into_values()) + .collect::>(); + let shutdown = futures_util::future::join_all(agents.iter().map(|agent| agent.shutdown())); + if tokio::time::timeout(graceful_timeout, shutdown) + .await + .is_err() + { + log::warn!( + "External agents did not shut down in time; their process groups were killed" + ); + } + } +} + +fn unknown_agent(agent_id: &str) -> String { + format!( + "No external agent '{}' in this task. Start one with {AGENT_START_TOOL}.", + agent_id.trim() + ) +} + +fn first_line_label(prompt: &str) -> String { + let first_line = prompt.lines().next().unwrap_or(prompt).trim(); + bounded_timeline_text(first_line, MAX_PROMPT_LABEL_CHARS) +} + +struct TurnInput { + prompt: String, + background: bool, + model: Option, + effort: Option, +} + +/// The end of one turn, as the waiter reads it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TurnOutcome { + Completed, + Failed, + Cancelled, +} + +impl TurnOutcome { + fn status(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } +} + +struct ActiveTurn { + turn_id: Option, + row_id: String, + /// The row belongs to no tool call: Maple started this turn itself to + /// deliver the user's answers, so the row needs its own title. + synthetic: bool, + background: bool, + done: Option>, + /// Cancelled when the turn ends, so a request the agent left open + /// (an approval, a question) stops waiting on the user. + ended: CancellationToken, +} + +/// Enough of a tool call to start a turn without one. +#[derive(Clone)] +struct StoredCall { + working_dir: Option, + login_path: Option, + tool_context: AgentToolContextSnapshot, +} + +struct AgentProcess { + child: ArmedShellChild, + client: Arc, + reader: tokio::task::JoinHandle<()>, + events: tokio::task::JoinHandle<()>, +} + +struct AgentState { + process: Option, + thread_id: Option, + turn: Option, + activity: ExternalAgentActivity, + /// Streamed agent messages of the current turn, in order. + messages: Vec<(String, String)>, + /// Questions Codex asked without blocking that still wait on the user. + open_questions: usize, + /// What the last tool call gave, so an answer turn Maple starts on its + /// own can spawn the process the same way. + last_call: Option, + last_row_emit: Option, + /// The transcript row of the latest turn, for the notice that lands + /// after the turn is gone. + last_row_id: Option, +} + +struct ExternalAgent { + agent_id: String, + session_id: String, + task: String, + cwd: PathBuf, + started: Instant, + /// Ends the agent. Derived from the runtime lifetime so logout and + /// Stop end it too. + cancel: CancellationToken, + host: ExternalAgentHost, + issued_permission_ids: IssuedPermissionIds, + state: Mutex, +} + +impl ExternalAgent { + fn new( + agent_id: String, + session_id: String, + task: String, + cwd: PathBuf, + host: ExternalAgentHost, + issued_permission_ids: IssuedPermissionIds, + ) -> Self { + let cancel = host.lifetime.child_token(); + Self { + state: Mutex::new(AgentState { + process: None, + thread_id: None, + turn: None, + activity: ExternalAgentActivity { + provider: codex::PROVIDER_ID.to_string(), + agent_id: agent_id.clone(), + status: "idle".to_string(), + ..Default::default() + }, + messages: Vec::new(), + open_questions: 0, + last_call: None, + last_row_emit: None, + last_row_id: None, + }), + agent_id, + session_id, + task, + cwd, + started: Instant::now(), + cancel, + host, + issued_permission_ids, + } + } + + async fn activity(&self) -> ExternalAgentActivity { + let mut state = self.state.lock().await; + self.refresh_elapsed(&mut state); + state.activity.clone() + } + + fn refresh_elapsed(&self, state: &mut AgentState) { + state.activity.elapsed_ms = self.started.elapsed().as_millis().min(u64::MAX as u128) as u64; + } + + async fn subagent_row(&self) -> Option { + let state = self.state.lock().await; + let turn = state.turn.as_ref()?; + Some(AgentSubagent { + id: subagent_row_id(&self.agent_id), + task: self.task.clone(), + background: turn.background, + elapsed_ms: self.started.elapsed().as_millis().min(u64::MAX as u128) as u64, + activity: latest_activity_label(&state.activity), + external: Some(ExternalAgentRef { + provider: codex::PROVIDER_ID.to_string(), + agent_id: self.agent_id.clone(), + }), + }) + } + + /// Run one turn: start the process if needed, send the prompt, and + /// either wait for the end or hand the wait to a background task. + async fn run_turn( + self: &Arc, + call: &ExternalAgentCall, + input: TurnInput, + ) -> CallToolResult { + if self.cancel.is_cancelled() { + return error_result("This external agent has been shut down."); + } + if let Err(error) = self.ensure_process(call, input.model.as_deref()).await { + return error_result(error); + } + let (client, thread_id, done_rx) = { + let mut state = self.state.lock().await; + if state.turn.is_some() { + return error_result(format!( + "Agent {} is still working on its previous turn. Wait for Maple's notice, or check with {AGENT_STATUS_TOOL}.", + self.agent_id + )); + } + let Some(client) = state + .process + .as_ref() + .map(|process| Arc::clone(&process.client)) + else { + return error_result("The agent process is not running."); + }; + let Some(thread_id) = state.thread_id.clone() else { + return error_result("The agent has no thread."); + }; + let (done_tx, done_rx) = oneshot::channel(); + let synthetic = call.row_id.is_none(); + let row_id = call.row_id.clone().unwrap_or_else(|| { + format!( + "external-{}-turn-{}", + self.agent_id, + state.activity.turns + 1 + ) + }); + state.last_call = Some(StoredCall { + working_dir: call.working_dir.clone(), + login_path: call.login_path.clone(), + tool_context: call.tool_context.clone(), + }); + state.turn = Some(ActiveTurn { + turn_id: None, + row_id, + synthetic, + background: input.background, + done: Some(done_tx), + ended: CancellationToken::new(), + }); + state.messages.clear(); + state.activity.begin_turn(); + (client, thread_id, done_rx) + }; + log::info!("External agent {} turn starts", self.agent_id); + self.emit_subagent_started(input.background); + self.emit_row(true).await; + + let params = codex::turn_start_params(&codex::TurnRequest { + thread_id: &thread_id, + prompt: &input.prompt, + cwd: &self.cwd, + model: input.model.as_deref(), + effort: input.effort.as_deref(), + }); + if let Err(error) = client.request("turn/start", params).await { + self.finish_turn(TurnOutcome::Failed, Some(error.clone())) + .await; + return error_result(error); + } + + if input.background { + let agent = Arc::clone(self); + tokio::spawn(async move { + let outcome = done_rx.await.unwrap_or(TurnOutcome::Cancelled); + agent.report_background_turn_end(outcome).await; + }); + let activity = self.activity().await; + // A short turn can be over before this returns; say so rather + // than promising a notice that already went out. + let guidance = if activity.status == "running" { + background_guidance() + } else { + completion_guidance(&activity) + }; + let mut result = text_result(render_activity(&activity, &guidance)); + if activity.status != "running" { + result.structured_content = Some(json!({ ACTIVITY_KEY: activity })); + } + return result; + } + + enum Wait { + RunCancelled, + ContextRevoked, + Done, + } + let revoked = call.tool_context.revoked.clone(); + let mut done_rx = done_rx; + let wait = tokio::select! { + biased; + _ = call.cancel_token.cancelled() => Wait::RunCancelled, + _ = revoked.cancelled() => Wait::ContextRevoked, + _ = &mut done_rx => Wait::Done, + }; + match wait { + Wait::RunCancelled => { + // The Maple turn ended; the agent keeps its thread but + // stops working on this prompt, and its process goes so a + // sandboxed command cannot outlive the stop. + drop(done_rx); + self.stop_turn().await; + } + Wait::ContextRevoked => self.shutdown().await, + Wait::Done => {} + } + let activity = self.activity().await; + let mut result = text_result(render_activity(&activity, &completion_guidance(&activity))); + result.structured_content = Some(json!({ ACTIVITY_KEY: activity })); + result + } + + async fn ensure_process( + self: &Arc, + call: &ExternalAgentCall, + model: Option<&str>, + ) -> Result<(), String> { + { + let state = self.state.lock().await; + if let Some(process) = state.process.as_ref() + && !process.client.closed().is_cancelled() + { + return Ok(()); + } + } + let executable = codex::find_executable(call.login_path.as_deref()).ok_or_else(|| { + "Codex is not installed, or `codex` is not on PATH. Ask the user to install the Codex CLI.".to_string() + })?; + let mut command = build_external_agent_command( + &executable, + &codex::app_server_args(), + &self.host.project_root, + call.login_path.as_deref(), + Some(&self.session_id), + &call.tool_context, + )?; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = { + let _launch = call.tool_context.begin_process_launch(&call.cancel_token)?; + spawn_contained(command).map_err(|error| format!("Failed to start Codex: {error}"))? + }; + let stdin = child + .as_mut() + .stdin() + .take() + .ok_or_else(|| "Failed to open Codex's stdin".to_string())?; + let stdout = child + .as_mut() + .stdout() + .take() + .ok_or_else(|| "Failed to open Codex's stdout".to_string())?; + let (client, receiver, reader) = AppServerClient::new(stdin, stdout); + client + .request("initialize", codex::initialize_params()) + .await?; + client.notify("initialized", json!({})).await?; + let thread_id = { + let existing = self.state.lock().await.thread_id.clone(); + let response = match &existing { + Some(thread_id) => { + client + .request("thread/resume", codex::thread_resume_params(thread_id)) + .await? + } + None => { + client + .request("thread/start", codex::thread_start_params(&self.cwd, model)) + .await? + } + }; + match existing { + Some(thread_id) => thread_id, + None => codex::thread_id_from_response(&response) + .ok_or_else(|| "Codex did not report a thread ID".to_string())?, + } + }; + let events = tokio::spawn(Arc::clone(self).consume_server_messages(receiver)); + let mut state = self.state.lock().await; + if let Some(previous) = state.process.take() { + previous.reader.abort(); + previous.events.abort(); + } + state.thread_id = Some(thread_id.clone()); + state.activity.thread_id = Some(thread_id); + state.process = Some(AgentProcess { + child, + client, + reader, + events, + }); + Ok(()) + } + + async fn consume_server_messages(self: Arc, mut receiver: mpsc::Receiver) { + while let Some(message) = receiver.recv().await { + match message { + ServerMessage::Notification { method, params } => { + self.handle_event(codex::parse_notification(&method, ¶ms)) + .await; + } + ServerMessage::Request { id, method, params } => { + // An approval waits on the user. It must not stall the + // notifications behind it, so it runs on its own task. + let agent = Arc::clone(&self); + tokio::spawn(async move { + agent.handle_request(id, &method, params).await; + }); + } + } + } + // The process ended. A turn it owed an answer to is over. + let had_turn = self.state.lock().await.turn.is_some(); + if had_turn { + self.finish_turn( + TurnOutcome::Failed, + Some("The Codex process exited before the turn finished.".to_string()), + ) + .await; + } + } + + async fn handle_event(self: &Arc, event: CodexEvent) { + match event { + CodexEvent::ThreadStarted { thread_id } => { + let mut state = self.state.lock().await; + state.thread_id.get_or_insert(thread_id.clone()); + state.activity.thread_id.get_or_insert(thread_id); + } + CodexEvent::TurnStarted { turn_id } => { + let mut state = self.state.lock().await; + if let Some(turn) = state.turn.as_mut() { + turn.turn_id = turn_id; + } + } + CodexEvent::TurnCompleted { status, error } => { + let outcome = match status.as_str() { + "completed" => TurnOutcome::Completed, + "interrupted" | "cancelled" | "canceled" => TurnOutcome::Cancelled, + _ => TurnOutcome::Failed, + }; + self.finish_turn(outcome, error).await; + } + CodexEvent::AgentMessageDelta { item_id, delta } => { + { + let mut state = self.state.lock().await; + match state.messages.iter_mut().find(|(id, _)| *id == item_id) { + Some((_, text)) => text.push_str(&delta), + None => state.messages.push((item_id, delta)), + } + Self::refresh_text(&mut state); + } + self.emit_row(false).await; + } + CodexEvent::ItemStarted(item) => { + let label = { + let mut state = self.state.lock().await; + match item { + CodexItem::CommandExecution { id, command, .. } => { + let label = format!("Running: {command}"); + state.activity.record_command_started(id, command); + Some(label) + } + CodexItem::TodoList { items, .. } => { + state.activity.record_todos(items); + None + } + CodexItem::FileChange { changes, .. } => { + let label = changes + .first() + .map(|change| format!("Editing: {}", change.path)); + state.activity.record_file_changes(changes); + label + } + _ => None, + } + }; + if let Some(label) = label { + self.emit_subagent_activity(label); + } + self.emit_row(true).await; + } + CodexEvent::ItemCompleted(item) => { + let label = { + let mut state = self.state.lock().await; + match item { + CodexItem::AgentMessage { id, text } => { + match state + .messages + .iter_mut() + .find(|(item_id, _)| *item_id == id) + { + Some((_, existing)) if !text.is_empty() => *existing = text, + Some(_) => {} + None => state.messages.push((id, text)), + } + Self::refresh_text(&mut state); + None + } + CodexItem::CommandExecution { + id, + command, + exit_code, + status, + } => { + state + .activity + .record_command_finished(&id, command, exit_code, status); + None + } + CodexItem::FileChange { changes, .. } => { + let label = changes + .first() + .map(|change| format!("Edited: {}", change.path)); + state.activity.record_file_changes(changes); + label + } + CodexItem::TodoList { items, .. } => { + state.activity.record_todos(items); + None + } + CodexItem::AsyncQuestion { id, questions } => { + // Mark the wait now, before the turn can end and + // render its result; the answer task runs later. + state.open_questions += 1; + state.activity.pending_permission = + Some("answer a question".to_string()); + let agent = Arc::clone(self); + tokio::spawn(agent.answer_async_question(id, questions)); + None + } + CodexItem::Other { .. } => None, + } + }; + if let Some(label) = label { + self.emit_subagent_activity(label); + } + self.emit_row(true).await; + } + CodexEvent::Other => {} + } + } + + fn refresh_text(state: &mut AgentState) { + let joined = state + .messages + .iter() + .map(|(_, text)| text.as_str()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n"); + state.activity.text = tail_bounded(&joined, MAX_RESULT_TEXT_CHARS); + } + + /// A token that fires when the current turn ends; already fired when + /// no turn is running. + async fn turn_ended(&self) -> CancellationToken { + let state = self.state.lock().await; + match state.turn.as_ref() { + Some(turn) => turn.ended.clone(), + None => { + let ended = CancellationToken::new(); + ended.cancel(); + ended + } + } + } + + async fn handle_request(&self, id: Value, method: &str, params: Value) { + log::info!("External agent {} asks: {method}", self.agent_id); + let client = { + let state = self.state.lock().await; + match state.process.as_ref() { + Some(process) => Arc::clone(&process.client), + None => return, + } + }; + let response = match codex::parse_server_request(method, ¶ms) { + CodexServerRequest::CommandApproval { + item_id, + command, + cwd, + reason, + } => { + let mut arguments = serde_json::Map::new(); + arguments.insert("command".to_string(), json!(command)); + if let Some(cwd) = cwd { + arguments.insert("cwd".to_string(), json!(cwd)); + } + if let Some(reason) = &reason { + arguments.insert("reason".to_string(), json!(reason)); + } + let request = AgentPermissionRequest { + request_id: format!("{}-{item_id}", self.agent_id), + tool_name: "codex_command".to_string(), + arguments, + prompt: Some(format!("Codex wants to run: {command}")), + }; + let decision = self + .request_permission(request, format!("run `{command}`")) + .await; + codex::approval_response(decision) + } + CodexServerRequest::FileChangeApproval { item_id, reason } => { + let mut arguments = serde_json::Map::new(); + if let Some(reason) = &reason { + arguments.insert("reason".to_string(), json!(reason)); + } + let request = AgentPermissionRequest { + request_id: format!("{}-{item_id}", self.agent_id), + tool_name: "codex_file_change".to_string(), + arguments, + prompt: Some("Codex wants to change files in the project".to_string()), + }; + let decision = self + .request_permission(request, "change project files".to_string()) + .await; + codex::approval_response(decision) + } + CodexServerRequest::UserInput { questions } => { + if questions.is_empty() { + codex::user_input_response("") + } else { + // The service's own broker, not the process global: a + // rebuilt service must not strand this agent's question + // in a broker nobody answers. + let broker = self.host.service.questions.clone(); + self.set_pending_permission(Some("answer a question".to_string())) + .await; + let ended = self.turn_ended().await; + let answer = tokio::select! { + biased; + _ = self.cancel.cancelled() => String::new(), + _ = ended.cancelled() => String::new(), + answer = broker.ask(&self.session_id, questions) => answer, + }; + self.set_pending_permission(None).await; + codex::user_input_response(&answer) + } + } + CodexServerRequest::Unknown { method } => { + client + .respond_error(id, &format!("{method} is not supported by this client")) + .await; + return; + } + }; + log::info!( + "External agent {} answered {method}: {}", + self.agent_id, + response + .get("decision") + .and_then(Value::as_str) + .unwrap_or("answers") + ); + if let Err(error) = client.respond(id, response).await { + log::warn!("Failed to answer an external agent request: {error}"); + } + } + + /// Codex asked the user without blocking its turn. Show the question, + /// and hand the answer back as the next input: steered into the turn + /// if it still runs, otherwise as a turn of Maple's own. + fn answer_async_question( + self: Arc, + item_id: String, + questions: Vec, + ) -> std::pin::Pin + Send>> { + // Boxed: the answer may start a turn, whose event loop reaches this + // function again. A concrete boxed type ends the recursive future. + Box::pin(async move { + log::info!( + "External agent {} asks the user {} question(s) without blocking", + self.agent_id, + questions.len() + ); + self.set_pending_permission(Some("answer a question".to_string())) + .await; + let broker = self.host.service.questions.clone(); + let answer = tokio::select! { + biased; + _ = self.cancel.cancelled() => String::new(), + answer = broker.ask(&self.session_id, codex::async_question_prompts(&questions)) => answer, + }; + { + let mut state = self.state.lock().await; + state.open_questions = state.open_questions.saturating_sub(1); + } + self.set_pending_permission(None).await; + let Some(prompt) = codex::async_answer_prompt(&questions, &answer) else { + log::info!( + "External agent {} question {item_id} was dismissed", + self.agent_id + ); + return; + }; + let steer = { + let state = self.state.lock().await; + match ( + state.process.as_ref(), + state.turn.as_ref(), + state.thread_id.as_ref(), + ) { + (Some(process), Some(turn), Some(thread_id)) => { + turn.turn_id.as_ref().map(|turn_id| { + ( + Arc::clone(&process.client), + thread_id.clone(), + turn_id.clone(), + ) + }) + } + _ => None, + } + }; + if let Some((client, thread_id, turn_id)) = steer { + match client + .request( + "turn/steer", + codex::turn_steer_params(&thread_id, &turn_id, &prompt), + ) + .await + { + Ok(_) => { + log::info!("External agent {} took the answers mid-turn", self.agent_id); + return; + } + Err(error) => { + log::debug!("Steering the answers failed, starting a turn: {error}") + } + } + } + let Some(stored) = self.state.lock().await.last_call.clone() else { + return; + }; + let call = ExternalAgentCall { + session_id: self.session_id.clone(), + working_dir: stored.working_dir, + row_id: None, + login_path: stored.login_path, + tool_context: stored.tool_context, + cancel_token: CancellationToken::new(), + }; + let result = self + .run_turn( + &call, + TurnInput { + prompt, + background: true, + model: None, + effort: None, + }, + ) + .await; + if result.is_error.unwrap_or(false) { + log::warn!( + "External agent {} could not take the answers: {}", + self.agent_id, + result + .content + .iter() + .filter_map(|content| content.as_text().map(|text| text.text.clone())) + .collect::() + ); + } + }) + } + + async fn set_pending_permission(&self, pending: Option) { + let label = { + let mut state = self.state.lock().await; + state.activity.pending_permission = pending; + latest_activity_label(&state.activity) + }; + // The row above the composer shows only its latest label; say what + // the agent waits on, and clear it again once the user decided. + self.emit_subagent_activity(label.unwrap_or_else(|| "Working".to_string())); + self.emit_row(true).await; + } + + /// Put one approval in front of the user through Maple's permission + /// card and wait for the decision. + async fn request_permission( + &self, + request: AgentPermissionRequest, + summary: String, + ) -> AgentPermissionDecision { + { + let modes = self.host.permission_modes.lock().await; + if modes + .get(&self.session_id) + .copied() + .unwrap_or(GOOSE_PERMISSION_ROUTING_MODE) + == GooseMode::Auto + { + return AgentPermissionDecision::AllowOnce; + } + } + if self.cancel.is_cancelled() { + return AgentPermissionDecision::Cancel; + } + let (tx, rx) = oneshot::channel(); + let responder = ExternalPermissionResponder::new(tx); + let run_id = external_run_id(&self.agent_id); + let request_id = request.request_id.clone(); + let registration = register_pending_permission( + &self.host.service.pending_permissions, + &self.issued_permission_ids, + &self.session_id, + &run_id, + AgentPermissionRouting::Desktop, + request.clone(), + &self.cancel, + PendingPermissionOrigin::ExternalAgent(responder), + ) + .await; + if registration != PendingPermissionRegistration::Registered { + return AgentPermissionDecision::Cancel; + } + self.set_pending_permission(Some(summary)).await; + let item = external_permission_item(&request, unix_ms()); + self.record_live_if_desktop_run(item.clone()).await; + emit_agent_event( + &self.host.service.host.events, + AgentServiceEvent::Run { + session_id: self.session_id.clone(), + run_id, + event: AgentRunEvent::PermissionRequested { request, item }, + }, + ); + let ended = self.turn_ended().await; + let withdraw = async { + let key = (self.session_id.clone(), request_id.clone()); + let removed = self + .host + .service + .pending_permissions + .lock() + .await + .remove(&key); + if let Some(removed) = removed { + publish_external_permission_decision( + &self.host.service, + &self.session_id, + &removed.request, + decision_row_status(AgentPermissionDecision::Cancel), + ) + .await; + } + AgentPermissionDecision::Cancel + }; + let decision = tokio::select! { + biased; + _ = self.cancel.cancelled() => withdraw.await, + _ = ended.cancelled() => withdraw.await, + decision = rx => decision.unwrap_or(AgentPermissionDecision::Cancel), + }; + self.set_pending_permission(None).await; + decision + } + + /// Ask the agent to stop its current turn. The thread stays open. + async fn interrupt(&self) { + let (client, thread_id, turn_id) = { + let state = self.state.lock().await; + let Some(process) = state.process.as_ref() else { + return; + }; + let Some(turn) = state.turn.as_ref() else { + return; + }; + ( + Arc::clone(&process.client), + state.thread_id.clone(), + turn.turn_id.clone(), + ) + }; + let (Some(thread_id), Some(turn_id)) = (thread_id, turn_id) else { + // The turn has not been identified yet; the agent will report + // it and the caller's settle timeout ends the wait. + return; + }; + let request = client.request( + "turn/interrupt", + codex::turn_interrupt_params(&thread_id, &turn_id), + ); + if let Ok(Err(error)) = tokio::time::timeout(INTERRUPT_REQUEST_TIMEOUT, request).await { + log::debug!("External agent interrupt was refused: {error}"); + } + } + + /// Stop what the agent is doing now and reclaim its process. Codex + /// does not always end a sandboxed command on `turn/interrupt`, so the + /// process group goes too; the thread is on disk and the next send + /// resumes it in a fresh process. + async fn stop_turn(&self) { + let had_turn = self.state.lock().await.turn.is_some(); + if !had_turn { + return; + } + self.interrupt().await; + let settled = tokio::time::timeout(INTERRUPT_SETTLE_TIMEOUT, async { + loop { + if self.state.lock().await.turn.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .is_ok(); + let process = self.state.lock().await.process.take(); + if let Some(mut process) = process { + process.child.kill_and_wait().await; + process.reader.abort(); + process.events.abort(); + } + if !settled { + self.finish_turn(TurnOutcome::Cancelled, None).await; + } + } + + /// End the agent: interrupt, kill its process group, and close its turn. + async fn shutdown(&self) { + self.cancel.cancel(); + self.interrupt().await; + let process = self.state.lock().await.process.take(); + if let Some(mut process) = process { + process.child.kill_and_wait().await; + process.reader.abort(); + process.events.abort(); + } + let had_turn = self.state.lock().await.turn.is_some(); + if had_turn { + self.finish_turn(TurnOutcome::Cancelled, None).await; + } + } + + async fn finish_turn(&self, outcome: TurnOutcome, error: Option) { + let done = { + let mut state = self.state.lock().await; + let Some(mut turn) = state.turn.take() else { + return; + }; + state.activity.status = outcome.status().to_string(); + if state.open_questions == 0 { + state.activity.pending_permission = None; + } + if error.is_some() { + state.activity.error = error; + } + self.refresh_elapsed(&mut state); + let mut row = activity_row_item(&turn.row_id, &state.activity, unix_ms()); + if turn.synthetic { + row.title = Some(SYNTHETIC_TURN_TITLE.to_string()); + } + state.last_row_id = Some(turn.row_id.clone()); + state.last_row_emit = Some(Instant::now()); + turn.ended.cancel(); + log::info!( + "External agent {} turn ended: {}", + self.agent_id, + outcome.status() + ); + // The turn is gone from the state, so emit its last row here. + self.record_live_if_desktop_run(row.clone()).await; + emit_agent_event( + &self.host.service.host.events, + AgentServiceEvent::TimelineItem { + session_id: self.session_id.clone(), + run_id: None, + item: row, + }, + ); + turn.done.take() + }; + emit_agent_event( + &self.host.service.host.events, + AgentServiceEvent::Run { + session_id: self.session_id.clone(), + run_id: external_run_id(&self.agent_id), + event: AgentRunEvent::SubagentFinished { + id: subagent_row_id(&self.agent_id), + }, + }, + ); + if let Some(done) = done { + let _ = done.send(outcome); + } + } + + /// A background turn ended: leave the result in the transcript and + /// tell the model, into the turn that is running or the next one. + async fn report_background_turn_end(&self, outcome: TurnOutcome) { + let activity = self.activity().await; + let result_text = render_activity(&activity, &completion_guidance(&activity)); + let row_id = self.last_row_id().await; + let notice = Message::assistant() + .with_system_notification_with_data( + SystemNotificationType::InlineMessage, + format!( + "External agent {} {}", + self.agent_id, + match outcome { + TurnOutcome::Completed => "finished", + TurnOutcome::Failed => "failed", + TurnOutcome::Cancelled => "was interrupted", + } + ), + json!({ + ACTIVITY_KEY: activity, + NOTICE_ROW_KEY: row_id, + NOTICE_RESULT_KEY: result_text, + }), + ) + .with_visibility(true, false) + .with_generated_id(); + // Two notices: one carries the activity back onto the tool row, the + // other is a plain line the user cannot miss, like the one a + // background subagent leaves. + let visible = Message::assistant() + .with_system_notification( + SystemNotificationType::InlineMessage, + format!( + "External agent {} ({}) {}. The task will read its result next.", + self.agent_id, + codex::PROVIDER_NAME, + match outcome { + TurnOutcome::Completed => "finished", + TurnOutcome::Failed => "failed", + TurnOutcome::Cancelled => "was interrupted", + } + ), + ) + .with_visibility(true, false) + .with_generated_id(); + for message in [¬ice, &visible] { + if let Err(error) = self + .host + .session_manager + .add_message(&self.session_id, message) + .await + { + log::warn!("Failed to record the end of an external agent turn: {error}"); + continue; + } + for item in message_to_timeline_items(message, false) { + self.record_live_if_desktop_run(item.clone()).await; + emit_agent_event( + &self.host.service.host.events, + AgentServiceEvent::TimelineItem { + session_id: self.session_id.clone(), + run_id: None, + item, + }, + ); + } + } + + let for_model = Message::user() + .with_text(format!( + "External agent {} ({}) {}. Call {AGENT_STATUS_TOOL}(provider: \"{}\", agent_id: \"{}\") to read its result, then carry on.", + self.agent_id, + codex::PROVIDER_ID, + match outcome { + TurnOutcome::Completed => "finished", + TurnOutcome::Failed => "failed", + TurnOutcome::Cancelled => "was interrupted", + }, + codex::PROVIDER_ID, + self.agent_id, + )) + // The user did not write this; it belongs to the model's view + // of the conversation only. + .with_visibility(false, true) + .with_generated_id(); + if steer_into_desktop_run( + &self.host.service, + &self.host.account_scope, + &self.session_id, + &for_model, + ) + .await + .is_err() + && let Err(error) = self + .host + .session_manager + .add_message(&self.session_id, &for_model) + .await + { + log::warn!("Failed to tell the task about its external agent: {error}"); + } + } + + async fn last_row_id(&self) -> String { + let state = self.state.lock().await; + state + .turn + .as_ref() + .map(|turn| turn.row_id.clone()) + .unwrap_or_else(|| self.row_id_hint(&state)) + } + + fn row_id_hint(&self, state: &AgentState) -> String { + state + .last_row_id + .clone() + .unwrap_or_else(|| format!("external-{}-turn-{}", self.agent_id, state.activity.turns)) + } + + fn emit_subagent_started(&self, background: bool) { + emit_agent_event( + &self.host.service.host.events, + AgentServiceEvent::Run { + session_id: self.session_id.clone(), + run_id: external_run_id(&self.agent_id), + event: AgentRunEvent::SubagentStarted { + id: subagent_row_id(&self.agent_id), + task: self.task.clone(), + background, + external: Some(ExternalAgentRef { + provider: codex::PROVIDER_ID.to_string(), + agent_id: self.agent_id.clone(), + }), + }, + }, + ); + } + + fn emit_subagent_activity(&self, tool: String) { + emit_agent_event( + &self.host.service.host.events, + AgentServiceEvent::Run { + session_id: self.session_id.clone(), + run_id: external_run_id(&self.agent_id), + event: AgentRunEvent::SubagentActivity { + id: subagent_row_id(&self.agent_id), + tool, + }, + }, + ); + } + + /// Repaint the transcript row of the current turn with the activity so + /// far. Streamed text is throttled; structural changes are not. + async fn emit_row(&self, force: bool) { + let item = { + let mut state = self.state.lock().await; + let Some(row_id) = state.turn.as_ref().map(|turn| turn.row_id.clone()) else { + return; + }; + let now = Instant::now(); + if !force + && state + .last_row_emit + .is_some_and(|last| now.duration_since(last) < LIVE_ROW_INTERVAL) + { + return; + } + state.last_row_emit = Some(now); + state.last_row_id = Some(row_id.clone()); + self.refresh_elapsed(&mut state); + let mut item = activity_row_item(&row_id, &state.activity, unix_ms()); + if state.turn.as_ref().is_some_and(|turn| turn.synthetic) { + item.title = Some(SYNTHETIC_TURN_TITLE.to_string()); + } + item + }; + self.record_live_if_desktop_run(item.clone()).await; + emit_agent_event( + &self.host.service.host.events, + AgentServiceEvent::TimelineItem { + session_id: self.session_id.clone(), + run_id: None, + item, + }, + ); + } + + /// Keep the live overlay of a desktop run current, so a mid-run + /// reopen shows the row. With no run there is no overlay to keep. + async fn record_live_if_desktop_run(&self, item: AgentTimelineItem) { + let desktop_run_active = { + let runtime = self.host.service.inner.lock().await; + runtime.as_ref().is_some_and(|current| { + current.active_runs.values().any(|run| { + run.session_id == self.session_id + && run.permission_routing == AgentPermissionRouting::Desktop + }) + }) + }; + if desktop_run_active { + record_timeline_item( + &self.host.service.live_timelines, + &self.session_id, + AgentPermissionRouting::Desktop, + item, + ) + .await; + } + } +} + +fn latest_activity_label(activity: &ExternalAgentActivity) -> Option { + if let Some(pending) = &activity.pending_permission { + return Some(format!("Waiting for you to {pending}")); + } + if let Some(command) = activity + .commands + .iter() + .rev() + .find(|command| command.status == "running") + { + return Some(format!("Running: {}", command.command)); + } + activity + .file_changes + .last() + .map(|change| format!("Edited: {}", change.path)) + .or_else(|| { + activity + .commands + .last() + .map(|command| format!("Ran: {}", command.command)) + }) +} + +/// Keep the end of `text`, which is where an agent's conclusion lives. +fn tail_bounded(text: &str, max_chars: usize) -> String { + let count = text.chars().count(); + if count <= max_chars { + return text.to_string(); + } + let skip = count - max_chars; + format!("…{}", text.chars().skip(skip + 1).collect::()) +} + +/// The transcript row for a turn in progress, or its final state. +fn activity_row_item( + row_id: &str, + activity: &ExternalAgentActivity, + created_ms: u128, +) -> AgentTimelineItem { + let status = match activity.status.as_str() { + "running" => "running", + "failed" => "failed", + "cancelled" => "cancelled", + _ => "completed", + }; + AgentTimelineItem { + id: row_id.to_string(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: None, + text: None, + status: Some(status.to_string()), + input: None, + output: Some(json!({ + "text": render_activity(activity, ""), + "structuredContent": { ACTIVITY_KEY: activity }, + "content": [], + })), + created_ms, + merge: "replace".to_string(), + } +} + +/// Tell the desktop how an external agent's permission was decided. The +/// live overlay is updated when a run holds one; the row is emitted either +/// way, because a background agent's request has no run behind it and the +/// card would otherwise stay "pending" forever. +pub(super) async fn publish_external_permission_decision( + service: &MapleAgentService, + session_id: &str, + request: &AgentPermissionRequest, + status: &str, +) { + let item = match update_live_permission_status( + &service.live_timelines, + session_id, + AgentPermissionRouting::Desktop, + &request.request_id, + status, + ) + .await + { + Some(item) => item, + None => { + let mut item = external_permission_item(request, unix_ms()); + item.status = Some(status.to_string()); + item + } + }; + emit_agent_event( + &service.host.events, + AgentServiceEvent::TimelineItem { + session_id: session_id.to_string(), + run_id: None, + item, + }, + ); +} + +/// The permission row for an external agent's approval request, shaped +/// like Goose's so the transcript treats them alike. +pub(super) fn external_permission_item( + request: &AgentPermissionRequest, + created_ms: u128, +) -> AgentTimelineItem { + AgentTimelineItem { + id: format!("permission-{}", request.request_id), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some(match request.tool_name.as_str() { + "codex_command" => "Codex: run command".to_string(), + "codex_file_change" => "Codex: change files".to_string(), + other => other.to_string(), + }), + text: request.prompt.clone(), + status: Some("pending".to_string()), + input: Some(Value::Object(request.arguments.clone())), + output: None, + created_ms, + merge: "replace".to_string(), + } +} + +/// Project a persisted end-of-turn notice back onto the tool row it +/// belongs to, so a reopened task shows what the agent did. +pub(super) fn notice_timeline_item( + notification: &SystemNotificationContent, + created_ms: u128, +) -> Option { + let data = notification.data.as_ref()?; + let activity: ExternalAgentActivity = + serde_json::from_value(data.get(ACTIVITY_KEY)?.clone()).ok()?; + let row_id = data.get(NOTICE_ROW_KEY)?.as_str()?; + let result_text = data + .get(NOTICE_RESULT_KEY) + .and_then(Value::as_str) + .unwrap_or_default(); + let mut item = activity_row_item(row_id, &activity, created_ms); + item.output = Some(json!({ + "text": result_text, + "structuredContent": { ACTIVITY_KEY: activity }, + "content": [], + })); + Some(item) +} + +/// Answers one external-agent permission request. Shared between the +/// pending-permission table and the waiting agent; whoever resolves first +/// takes the sender. +#[derive(Clone)] +pub(super) struct ExternalPermissionResponder { + sender: Arc>>>, +} + +impl ExternalPermissionResponder { + fn new(sender: oneshot::Sender) -> Self { + Self { + sender: Arc::new(StdMutex::new(Some(sender))), + } + } + + pub(super) fn resolve(&self, decision: AgentPermissionDecision) -> bool { + let sender = self + .sender + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + sender.is_some_and(|sender| sender.send(decision).is_ok()) + } + + pub(super) fn same_as(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.sender, &other.sender) + } +} + +impl std::fmt::Debug for ExternalPermissionResponder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ExternalPermissionResponder") + } +} + +#[cfg(test)] +mod unit_tests { + use super::*; + + #[test] + fn activity_bounds_commands_files_and_todos() { + let mut activity = ExternalAgentActivity::default(); + for index in 0..(MAX_ACTIVITY_COMMANDS + 5) { + activity.record_command_started(format!("c{index}"), format!("cmd {index}")); + } + assert_eq!(activity.commands.len(), MAX_ACTIVITY_COMMANDS); + assert_eq!(activity.commands[0].id, "c5"); + activity.record_command_finished("c24", "cmd 24".into(), Some(2), None); + assert_eq!(activity.commands.last().unwrap().status, "failed"); + activity.record_command_finished("c23", "cmd 23".into(), Some(0), None); + assert_eq!( + activity.commands[MAX_ACTIVITY_COMMANDS - 2].status, + "completed" + ); + + activity.record_file_changes( + (0..(MAX_ACTIVITY_FILE_CHANGES + 3)) + .map(|index| codex::FileChangeEntry { + path: format!("f{index}"), + kind: "update".into(), + }) + .collect(), + ); + assert_eq!(activity.file_changes.len(), MAX_ACTIVITY_FILE_CHANGES); + activity.record_file_changes(vec![codex::FileChangeEntry { + path: "f42".into(), + kind: "delete".into(), + }]); + assert_eq!(activity.file_changes.len(), MAX_ACTIVITY_FILE_CHANGES); + assert_eq!(activity.file_changes.last().unwrap().kind, "delete"); + + activity.record_todos( + (0..(MAX_ACTIVITY_TODOS + 1)) + .map(|index| codex::TodoEntry { + text: format!("t{index}"), + completed: false, + }) + .collect(), + ); + assert_eq!(activity.todos.len(), MAX_ACTIVITY_TODOS); + } + + #[test] + fn rendered_result_is_paseo_shaped_and_bounded() { + let activity = ExternalAgentActivity { + provider: "codex".into(), + agent_id: "codex-1".into(), + thread_id: Some("thread".into()), + status: "completed".into(), + text: "x".repeat(MAX_RESULT_TEXT_CHARS + 10), + file_changes: vec![ActivityFileChange { + path: "a.rs".into(), + kind: "update".into(), + }], + commands: vec![ActivityCommand { + id: "c".into(), + command: "cargo test".into(), + exit_code: Some(1), + status: "failed".into(), + }], + ..Default::default() + }; + let text = render_activity(&activity, "Go on."); + assert!(text.starts_with( + "Status: completed\nProvider: codex\nAgent ID: codex-1\nThread ID: thread\n" + )); + assert!(text.contains("Files changed (1): a.rs")); + assert!(text.contains("Commands run: 1 (1 failed)")); + let response = text + .split("\n") + .nth(1) + .unwrap() + .split("\n") + .next() + .unwrap(); + // The bound keeps the text plus one ellipsis, like every other + // bounded string in the transcript. + assert_eq!(response.chars().count(), MAX_RESULT_TEXT_CHARS + 1); + assert!(response.ends_with('…')); + assert!(text.ends_with("Go on.")); + } + + #[test] + fn tail_bound_keeps_the_end() { + assert_eq!(tail_bounded("abcdef", 3), "…ef"); + assert_eq!(tail_bounded("abc", 3), "abc"); + } + + #[test] + fn notice_projects_onto_the_tool_row() { + let activity = ExternalAgentActivity { + provider: "codex".into(), + agent_id: "codex-1".into(), + status: "completed".into(), + ..Default::default() + }; + let notification = SystemNotificationContent { + notification_type: SystemNotificationType::InlineMessage, + msg: "External agent codex-1 finished".into(), + data: Some(json!({ + ACTIVITY_KEY: activity, + NOTICE_ROW_KEY: "row-9", + NOTICE_RESULT_KEY: "Status: completed", + })), + }; + let item = notice_timeline_item(¬ification, 7).unwrap(); + assert_eq!(item.id, "row-9"); + assert_eq!(item.item_type, "tool"); + assert_eq!(item.status.as_deref(), Some("completed")); + assert_eq!(item.output.as_ref().unwrap()["text"], "Status: completed"); + assert_eq!( + item.output.as_ref().unwrap()["structuredContent"][ACTIVITY_KEY]["agentId"], + "codex-1" + ); + let plain = SystemNotificationContent { + notification_type: SystemNotificationType::InlineMessage, + msg: "hi".into(), + data: None, + }; + assert!(notice_timeline_item(&plain, 7).is_none()); + } + + #[test] + fn permission_row_mirrors_goose_shape() { + let request = AgentPermissionRequest { + request_id: "codex-1-i1".into(), + tool_name: "codex_command".into(), + arguments: serde_json::Map::from_iter([("command".to_string(), json!("ls"))]), + prompt: Some("Codex wants to run: ls".into()), + }; + let item = external_permission_item(&request, 1); + assert_eq!(item.id, "permission-codex-1-i1"); + assert_eq!(item.item_type, "permission"); + assert_eq!(item.status.as_deref(), Some("pending")); + assert_eq!(item.title.as_deref(), Some("Codex: run command")); + } + + #[test] + fn responder_resolves_once() { + let (tx, rx) = oneshot::channel(); + let responder = ExternalPermissionResponder::new(tx); + assert!(responder.resolve(AgentPermissionDecision::AllowOnce)); + assert!(!responder.resolve(AgentPermissionDecision::DenyOnce)); + assert_eq!( + rx.blocking_recv().unwrap(), + AgentPermissionDecision::AllowOnce + ); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs new file mode 100644 index 000000000..ed4f1607b --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs @@ -0,0 +1,907 @@ +//! Driver tests against a fake `codex app-server`. +//! +//! The fixture is this test binary re-executed as an ignored test. A shell +//! shim named `codex` on a private PATH forwards to it, so the driver +//! resolves and spawns it exactly as it would the real CLI. Unix only until +//! a `.cmd` shim exists for Windows. + +#![cfg(unix)] + +use super::*; +use crate::agent::tool_context::default_tool_context_spec; +use crate::agent::{AgentEventSink, AgentPathLayout, MapleAgentHostResources}; +use std::io::{BufRead, Write}; +use std::os::unix::fs::PermissionsExt; + +const FIXTURE_TEST: &str = "agent::external_agents::tests::fake_codex_app_server"; +const FIXTURE_MARKER: &str = "MAPLE_FAKE_CODEX"; +const FIXTURE_ARGS: &str = "MAPLE_FAKE_CODEX_ARGS"; +const FIXTURE_MODE: &str = "MAPLE_FAKE_CODEX_MODE"; +const FIXTURE_PID_FILE: &str = "MAPLE_FAKE_CODEX_PID_FILE"; +const FIXTURE_LOG: &str = "MAPLE_FAKE_CODEX_LOG"; +const WAIT: Duration = Duration::from_secs(20); + +#[derive(Default)] +struct RecordingSink { + events: StdMutex>, +} + +impl AgentEventSink for RecordingSink { + fn emit(&self, event: &AgentServiceEvent) { + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(event.clone()); + } +} + +impl RecordingSink { + fn events(&self) -> Vec { + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +struct Harness { + _temp: tempfile::TempDir, + project: PathBuf, + shim_dir: PathBuf, + service: MapleAgentService, + sink: Arc, + host: ExternalAgentHost, + registry: Arc, + pid_file: PathBuf, + log_file: PathBuf, +} + +impl Harness { + fn new(mode: &str) -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().to_path_buf(); + let project = root.join("project"); + fs::create_dir_all(&project).unwrap(); + let history = root.join("history"); + fs::create_dir_all(&history).unwrap(); + let shim_dir = root.join("bin"); + fs::create_dir_all(&shim_dir).unwrap(); + let pid_file = root.join("codex.pid"); + let log_file = root.join("codex.log"); + let shim = format!( + "#!/bin/sh\nexport {FIXTURE_MARKER}=1\nexport {FIXTURE_MODE}='{mode}'\nexport {FIXTURE_PID_FILE}='{}'\nexport {FIXTURE_LOG}='{}'\nexport {FIXTURE_ARGS}=\"$*\"\nexec '{}' '{FIXTURE_TEST}' --exact --ignored --nocapture --test-threads=1\n", + pid_file.display(), + log_file.display(), + std::env::current_exe().unwrap().display(), + ); + let shim_path = shim_dir.join("codex"); + fs::write(&shim_path, shim).unwrap(); + fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o700)).unwrap(); + + let paths = AgentPathLayout::from_app_roots(root.join("config"), root.join("data")); + let sink = Arc::new(RecordingSink::default()); + let service = MapleAgentService::new(MapleAgentHostResources::new( + paths, + sink.clone(), + default_tool_context_spec().unwrap(), + String::new(), + )); + let host = ExternalAgentHost { + service: service.clone(), + account_scope: Arc::from("scope"), + session_manager: Arc::new(SessionManager::new(history)), + permission_modes: Arc::new(Mutex::new(HashMap::new())), + project_root: project.clone(), + lifetime: CancellationToken::new(), + }; + let registry = Arc::new(ExternalAgentRegistry::new(host.clone())); + Self { + _temp: temp, + project, + shim_dir, + service, + sink, + host, + registry, + pid_file, + log_file, + } + } + + fn call(&self, session_id: &str, row_id: &str) -> ExternalAgentCall { + ExternalAgentCall { + session_id: session_id.to_string(), + working_dir: Some(self.project.clone()), + row_id: Some(row_id.to_string()), + login_path: Some(self.shim_dir.to_string_lossy().into_owned()), + tool_context: SharedAgentToolContext::new(default_tool_context_spec().unwrap()) + .snapshot(), + cancel_token: CancellationToken::new(), + } + } + + async fn set_mode(&self, session_id: &str, mode: GooseMode) { + self.host + .permission_modes + .lock() + .await + .insert(session_id.to_string(), mode); + } + + async fn fixture_pid(&self) -> i32 { + wait_for(|| { + fs::read_to_string(&self.pid_file) + .ok() + .and_then(|pid| pid.trim().parse::().ok()) + }) + .await + } + + fn log(&self) -> String { + fs::read_to_string(&self.log_file).unwrap_or_default() + } +} + +async fn wait_for(mut probe: impl FnMut() -> Option) -> T { + let deadline = Instant::now() + WAIT; + loop { + if let Some(value) = probe() { + return value; + } + assert!(Instant::now() < deadline, "timed out waiting"); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +fn result_text(result: &CallToolResult) -> String { + result + .content + .iter() + .filter_map(|content| content.as_text().map(|text| text.text.clone())) + .collect() +} + +fn process_alive(pid: i32) -> bool { + // SAFETY: signal 0 only checks whether the process can be signalled. + unsafe { libc::kill(pid, 0) == 0 } +} + +/// The fake app-server. It answers the handshake, starts a thread, and +/// on `turn/start` plays a short turn that asks for one command approval +/// and reports what decision it got in its final message. In `slow` mode +/// it waits for `turn/interrupt` instead. +#[test] +#[ignore = "fake codex app-server run by the driver tests"] +fn fake_codex_app_server() { + if std::env::var_os(FIXTURE_MARKER).is_none() { + return; + } + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + // libtest prints "test ... " with no newline before the test + // runs; end that line so the first protocol line stands alone. + writeln!(out).unwrap(); + let args = std::env::var(FIXTURE_ARGS).unwrap_or_default(); + if args.contains("--version") { + writeln!(out, "codex-cli 0.150.0").unwrap(); + out.flush().unwrap(); + return; + } + if let Ok(pid_file) = std::env::var(FIXTURE_PID_FILE) { + fs::write(pid_file, std::process::id().to_string()).unwrap(); + } + let mode = std::env::var(FIXTURE_MODE).unwrap_or_default(); + let log = std::env::var(FIXTURE_LOG).ok(); + let stdin = std::io::stdin(); + let mut lines = stdin.lock().lines(); + let mut send = |value: Value| { + writeln!(out, "{value}").unwrap(); + out.flush().unwrap(); + }; + while let Some(Ok(line)) = lines.next() { + if let Some(log) = &log { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(); + writeln!(file, "<- {line}").unwrap(); + } + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + let id = message.get("id").cloned(); + let method = message + .get("method") + .and_then(Value::as_str) + .map(str::to_string); + match (id, method.as_deref()) { + (Some(id), Some("initialize")) => send(json!({ "id": id, "result": {} })), + (Some(id), Some("thread/start")) => { + send(json!({ "id": id, "result": { "thread": { "id": "thread-1" } } })); + send( + json!({ "method": "thread/started", "params": { "thread": { "id": "thread-1" } } }), + ); + } + (Some(id), Some("thread/resume")) => { + send( + json!({ "id": id, "result": { "thread": { "id": message["params"]["threadId"] } } }), + ); + } + (Some(id), Some("turn/start")) => { + if let Some(log) = &log { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(); + writeln!(file, "{}", message["params"]).unwrap(); + } + send(json!({ "id": id, "result": { "turn": { "id": "turn-1" } } })); + send( + json!({ "method": "turn/started", "params": { "threadId": "thread-1", "turn": { "id": "turn-1" } } }), + ); + if mode == "async-question" { + let prompt = message["params"]["input"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string(); + if prompt.starts_with("Answers to your questions") { + let last = prompt.lines().last().unwrap_or_default().to_string(); + send( + json!({ "method": "item/completed", "params": { "threadId": "thread-1", "item": { "id": "msg-a", "type": "agentMessage", "text": format!("Got: {last}") } } }), + ); + } else { + send( + json!({ "method": "item/completed", "params": { "threadId": "thread-1", "item": { "id": "aq-1", "type": "agentMessage", "text": "Tabs or spaces?", "delivery": "async", + "questions": [{ "title": "Tabs or spaces?", "options": ["Tabs", "Spaces"] }] } } }), + ); + } + send( + json!({ "method": "turn/completed", "params": { "threadId": "thread-1", "turn": { "id": "turn-1", "status": "completed" } } }), + ); + continue; + } + if mode == "question" { + send( + json!({ "id": 200, "method": "item/tool/requestUserInput", "params": { "itemId": "q-1", "threadId": "thread-1", "turnId": "turn-1", + "questions": [{ "id": "style", "header": "Style", "question": "Tabs or spaces?", "options": [{ "label": "Tabs", "description": "t" }, { "label": "Spaces", "description": "s" }] }] } }), + ); + let mut chosen = String::from("none"); + for line in lines.by_ref().map_while(Result::ok) { + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + if message.get("id").and_then(Value::as_u64) == Some(200) { + chosen = message["result"]["answers"]["style"]["answers"][0] + .as_str() + .unwrap_or("none") + .to_string(); + break; + } + } + send( + json!({ "method": "item/completed", "params": { "threadId": "thread-1", "item": { "id": "msg-q", "type": "agentMessage", "text": format!("Chosen: {chosen}") } } }), + ); + send( + json!({ "method": "turn/completed", "params": { "threadId": "thread-1", "turn": { "id": "turn-1", "status": "completed" } } }), + ); + continue; + } + if mode == "slow" { + for line in lines.by_ref().map_while(Result::ok) { + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + if message.get("method").and_then(Value::as_str) == Some("turn/interrupt") { + send(json!({ "id": message["id"], "result": {} })); + send( + json!({ "method": "turn/completed", "params": { "threadId": "thread-1", "turn": { "id": "turn-1", "status": "interrupted" } } }), + ); + break; + } + } + continue; + } + send( + json!({ "method": "item/started", "params": { "threadId": "thread-1", "item": { "id": "cmd-1", "type": "commandExecution", "command": ["cargo", "test"] } } }), + ); + send( + json!({ "id": 100, "method": "item/commandExecution/requestApproval", "params": { "itemId": "cmd-1", "threadId": "thread-1", "turnId": "turn-1", "command": "cargo test", "cwd": "." } }), + ); + let mut decision = String::from("none"); + for line in lines.by_ref().map_while(Result::ok) { + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + if message.get("id").and_then(Value::as_u64) == Some(100) { + decision = message["result"]["decision"] + .as_str() + .unwrap_or("none") + .to_string(); + break; + } + } + let exit_code = if decision == "accept" { 0 } else { 1 }; + send( + json!({ "method": "item/completed", "params": { "threadId": "thread-1", "item": { "id": "cmd-1", "type": "commandExecution", "command": ["cargo", "test"], "exitCode": exit_code } } }), + ); + send( + json!({ "method": "item/completed", "params": { "threadId": "thread-1", "item": { "id": "fc-1", "type": "fileChange", "status": "completed", "changes": [{ "path": "src/lib.rs", "kind": "update" }] } } }), + ); + send( + json!({ "method": "item/agentMessage/delta", "params": { "threadId": "thread-1", "itemId": "msg-1", "delta": "Done: " } }), + ); + send( + json!({ "method": "item/agentMessage/delta", "params": { "threadId": "thread-1", "itemId": "msg-1", "delta": decision } }), + ); + send( + json!({ "method": "item/completed", "params": { "threadId": "thread-1", "item": { "id": "msg-1", "type": "agentMessage", "text": format!("Done: {decision}") } } }), + ); + send( + json!({ "method": "turn/completed", "params": { "threadId": "thread-1", "turn": { "id": "turn-1", "status": "completed" } } }), + ); + } + (Some(id), Some("turn/interrupt")) => { + send(json!({ "id": id, "result": {} })); + } + (Some(id), Some(method)) => { + send( + json!({ "id": id, "error": { "code": -32601, "message": format!("{method} unsupported") } }), + ); + } + _ => {} + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn allow_all_turn_auto_approves_and_streams_activity() { + let harness = Harness::new("approve"); + harness.set_mode("session-1", GooseMode::Auto).await; + + let result = harness + .registry + .start( + harness.call("session-1", "row-1"), + AgentStartParams { + provider: "codex".into(), + prompt: "Fix the parser".into(), + background: false, + model: Some("gpt-5.4".into()), + effort: None, + cwd: None, + }, + ) + .await; + let text = result_text(&result); + assert!( + text.starts_with( + "Status: completed\nProvider: codex\nAgent ID: codex-1\nThread ID: thread-1\n" + ), + "{text}" + ); + assert!(text.contains("Files changed (1): src/lib.rs"), "{text}"); + assert!(text.contains("Commands run: 1 (0 failed)"), "{text}"); + assert!( + text.contains("\nDone: accept\n"), + "{text}" + ); + assert!(text.contains(AGENT_SEND_TOOL), "{text}"); + let activity = result.structured_content.as_ref().unwrap()[ACTIVITY_KEY].clone(); + assert_eq!(activity["status"], "completed"); + assert_eq!(activity["commands"][0]["exitCode"], 0); + + let log = harness.log(); + // Maple sends no policy; Codex's configuration decides. + assert!(!log.contains("approvalPolicy"), "{log}"); + assert!(!log.contains("sandboxPolicy"), "{log}"); + assert!(log.contains("\"model\":\"gpt-5.4\""), "{log}"); + assert!(log.contains("Fix the parser"), "{log}"); + + let events = harness.sink.events(); + assert!(events.iter().any(|event| matches!( + event, + AgentServiceEvent::Run { session_id, run_id, event: AgentRunEvent::SubagentStarted { id, background: false, external: Some(external), .. } } + if session_id == "session-1" && run_id == "external-codex-1" && id == "external-agent-codex-1" && external.agent_id == "codex-1" + ))); + assert!(events.iter().any(|event| matches!( + event, + AgentServiceEvent::Run { event: AgentRunEvent::SubagentFinished { id }, .. } if id == "external-agent-codex-1" + ))); + let rows = events + .iter() + .filter_map(|event| match event { + AgentServiceEvent::TimelineItem { item, .. } if item.id == "row-1" => Some(item), + _ => None, + }) + .collect::>(); + assert!( + rows.iter() + .any(|row| row.status.as_deref() == Some("running")) + ); + let last = rows.last().unwrap(); + assert_eq!(last.status.as_deref(), Some("completed")); + assert_eq!( + last.output.as_ref().unwrap()["structuredContent"][ACTIVITY_KEY]["fileChanges"][0]["path"], + "src/lib.rs" + ); + // No permission card was needed in Allow all. + assert!(!events.iter().any(|event| matches!( + event, + AgentServiceEvent::Run { + event: AgentRunEvent::PermissionRequested { .. }, + .. + } + ))); + + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn read_only_turn_puts_the_approval_in_the_permission_table() { + let harness = Harness::new("approve"); + let registry = Arc::clone(&harness.registry); + let call = harness.call("session-2", "row-2"); + let turn = tokio::spawn(async move { + registry + .start( + call, + AgentStartParams { + provider: "codex".into(), + prompt: "Run the tests".into(), + background: false, + model: None, + effort: None, + cwd: None, + }, + ) + .await + }); + + let pending = { + let service = harness.service.clone(); + wait_for(|| { + service + .pending_permissions + .try_lock() + .ok() + .and_then(|pending| { + pending + .iter() + .next() + .map(|(key, entry)| (key.clone(), entry.clone())) + }) + }) + .await + }; + let ((session_id, request_id), entry) = pending; + assert_eq!(session_id, "session-2"); + assert_eq!(request_id, "codex-1-cmd-1"); + assert_eq!(entry.run_id, "external-codex-1"); + assert_eq!(entry.routing, AgentPermissionRouting::Desktop); + assert_eq!(entry.request.tool_name, "codex_command"); + assert_eq!(entry.request.arguments["command"], "cargo test"); + let events = harness.sink.events(); + assert!(events.iter().any(|event| matches!( + event, + AgentServiceEvent::Run { run_id, event: AgentRunEvent::PermissionRequested { request, item }, .. } + if run_id == "external-codex-1" && request.request_id == "codex-1-cmd-1" && item.id == "permission-codex-1-cmd-1" + ))); + // The row shows the agent waiting on the user. + assert!(events.iter().any(|event| matches!( + event, + AgentServiceEvent::TimelineItem { item, .. } + if item.id == "row-2" && item.output.as_ref().unwrap()["structuredContent"][ACTIVITY_KEY]["pendingPermission"] == "run `cargo test`" + ))); + + // The user declines. In the app this comes through resolve_permission; + // the responder is what that path resolves. + let PendingPermissionOrigin::ExternalAgent(responder) = entry.origin else { + panic!("expected an external origin"); + }; + harness + .service + .pending_permissions + .lock() + .await + .remove(&(session_id, request_id)); + assert!(responder.resolve(AgentPermissionDecision::DenyOnce)); + + let result = turn.await.unwrap(); + let text = result_text(&result); + assert!(text.contains("Done: decline"), "{text}"); + assert!(text.contains("Commands run: 1 (1 failed)"), "{text}"); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancelling_the_run_interrupts_the_turn_and_shutdown_kills_the_process() { + let harness = Harness::new("slow"); + harness.set_mode("session-3", GooseMode::Auto).await; + let registry = Arc::clone(&harness.registry); + let call = harness.call("session-3", "row-3"); + let cancel = call.cancel_token.clone(); + let turn = tokio::spawn(async move { + registry + .start( + call, + AgentStartParams { + provider: "codex".into(), + prompt: "Take your time".into(), + background: false, + model: None, + effort: None, + cwd: None, + }, + ) + .await + }); + let pid = harness.fixture_pid().await; + // Wait until the turn is identified, so the interrupt can name it. + { + let sink = Arc::clone(&harness.sink); + wait_for(|| { + sink.events() + .iter() + .any(|event| matches!(event, AgentServiceEvent::TimelineItem { item, .. } if item.id == "row-3")) + .then_some(()) + }) + .await; + } + tokio::time::sleep(Duration::from_millis(200)).await; + cancel.cancel(); + let result = turn.await.unwrap(); + let text = result_text(&result); + assert!(text.starts_with("Status: cancelled"), "{text}"); + assert!(text.contains(AGENT_SEND_TOOL), "{text}"); + // A stop reclaims the process: Codex does not always end a sandboxed + // command on interrupt, so the process group goes with the turn. + wait_for(|| (!process_alive(pid)).then_some(())).await; + let status = harness + .registry + .status( + &harness.call("session-3", "row-3b"), + AgentRefParams { + provider: "codex".into(), + agent_id: "codex-1".into(), + }, + ) + .await; + assert!(result_text(&status).starts_with("Status: cancelled")); + + // The agent is still there: the next send starts a fresh process and + // resumes the same thread. + fs::remove_file(&harness.pid_file).unwrap(); + let follow_up = harness + .registry + .send( + harness.call("session-3", "row-3c"), + AgentSendParams { + provider: "codex".into(), + agent_id: "codex-1".into(), + prompt: "Carry on".into(), + background: true, + model: None, + effort: None, + }, + ) + .await; + assert!(result_text(&follow_up).starts_with("Status: running")); + let second_pid = harness.fixture_pid().await; + assert_ne!(second_pid, pid); + assert!(process_alive(second_pid)); + let log = harness.log(); + assert!(log.contains("\"threadId\":\"thread-1\""), "{log}"); + + // Stop from the row kills that process too. + harness + .registry + .cancel("session-3", "codex-1") + .await + .unwrap(); + wait_for(|| (!process_alive(second_pid)).then_some(())).await; + harness.registry.shutdown_all(Duration::from_secs(5)).await; + wait_for(|| (!process_alive(pid)).then_some(())).await; + assert!(harness.registry.snapshot("session-3").await.is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn background_turn_reports_its_end_into_the_transcript() { + let harness = Harness::new("approve"); + harness.set_mode("session-4", GooseMode::Auto).await; + let result = harness + .registry + .start( + harness.call("session-4", "row-4"), + AgentStartParams { + provider: "codex".into(), + prompt: "Work in the background".into(), + background: true, + model: None, + effort: None, + cwd: None, + }, + ) + .await; + let text = result_text(&result); + // The fixture answers instantly, so the turn may already be over when + // the call returns; either way the guidance matches the status. + if text.starts_with("Status: running") { + assert!(text.contains("do not poll"), "{text}"); + } else { + assert!(text.starts_with("Status: completed"), "{text}"); + assert!(text.contains(AGENT_SEND_TOOL), "{text}"); + } + + let sink = Arc::clone(&harness.sink); + wait_for(|| { + sink.events() + .iter() + .any(|event| { + matches!( + event, + AgentServiceEvent::Run { + event: AgentRunEvent::SubagentFinished { .. }, + .. + } + ) + }) + .then_some(()) + }) + .await; + let events = harness.sink.events(); + let rows = events + .iter() + .filter_map(|event| match event { + AgentServiceEvent::TimelineItem { item, .. } if item.id == "row-4" => Some(item), + _ => None, + }) + .collect::>(); + assert_eq!(rows.last().unwrap().status.as_deref(), Some("completed")); + let status = harness + .registry + .status( + &harness.call("session-4", "row-4b"), + AgentRefParams { + provider: "codex".into(), + agent_id: "codex-1".into(), + }, + ) + .await; + assert!(result_text(&status).contains("Done: accept")); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} + +#[tokio::test] +async fn registry_rejects_unknown_providers_bad_cwd_and_too_many_agents() { + let harness = Harness::new("approve"); + let call = harness.call("session-5", "row-5"); + let start = |provider: &str, cwd: Option<&str>| AgentStartParams { + provider: provider.into(), + prompt: "x".into(), + background: false, + model: None, + effort: None, + cwd: cwd.map(str::to_string), + }; + let unknown = harness + .registry + .start(harness.call("session-5", "r"), start("claude", None)) + .await; + assert_eq!(unknown.is_error, Some(true)); + assert!(result_text(&unknown).contains("Unknown agent provider")); + + fs::create_dir_all(harness.project.join("sub")).unwrap(); + let outside = harness.resolve_cwd_for_test(&call, Some("..")); + assert!(outside.unwrap_err().contains("outside the project root")); + let inside = harness.resolve_cwd_for_test(&call, Some("sub")).unwrap(); + assert!(inside.ends_with("sub")); + + let empty = harness + .registry + .start( + harness.call("session-5", "r"), + AgentStartParams { + provider: "codex".into(), + prompt: " ".into(), + background: false, + model: None, + effort: None, + cwd: None, + }, + ) + .await; + assert!(result_text(&empty).contains("prompt must not be empty")); + + { + let mut sessions = harness.registry.sessions.lock().await; + let session = sessions.entry("session-5".to_string()).or_default(); + for index in 0..MAX_AGENTS_PER_SESSION { + let agent_id = format!("codex-{index}"); + session.agents.insert( + agent_id.clone(), + Arc::new(ExternalAgent::new( + agent_id, + "session-5".into(), + "idle".into(), + harness.project.clone(), + harness.host.clone(), + Arc::clone(&harness.registry.issued_permission_ids), + )), + ); + } + } + let full = harness + .registry + .start(harness.call("session-5", "r"), start("codex", None)) + .await; + assert!(result_text(&full).contains("already has 4 external agents")); + let missing = harness + .registry + .status( + &call, + AgentRefParams { + provider: "codex".into(), + agent_id: "codex-9".into(), + }, + ) + .await; + assert!(result_text(&missing).contains("No external agent 'codex-9'")); +} + +impl Harness { + fn resolve_cwd_for_test( + &self, + call: &ExternalAgentCall, + requested: Option<&str>, + ) -> Result { + self.registry.resolve_cwd(call, requested) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_question_from_the_agent_goes_through_the_question_card() { + let harness = Harness::new("question"); + harness.set_mode("session-6", GooseMode::Auto).await; + let registry = Arc::clone(&harness.registry); + let call = harness.call("session-6", "row-6"); + let turn = tokio::spawn(async move { + registry + .start( + call, + AgentStartParams { + provider: "codex".into(), + prompt: "Ask me something".into(), + background: false, + model: None, + effort: None, + cwd: None, + }, + ) + .await + }); + let sink = Arc::clone(&harness.sink); + let (request_id, questions) = wait_for(|| { + sink.events().iter().find_map(|event| match event { + AgentServiceEvent::Question { + session_id, + request_id, + questions, + } if session_id == "session-6" => Some((request_id.clone(), questions.clone())), + _ => None, + }) + }) + .await; + assert_eq!(questions.len(), 1); + assert_eq!(questions[0].id, "style"); + assert_eq!(questions[0].options[1].label, "Spaces"); + assert!( + harness + .service + .answer_question( + &request_id, + r#"{"answers":{"style":{"answers":["Spaces"]}}}"#.to_string(), + ) + .await + ); + let result = turn.await.unwrap(); + let text = result_text(&result); + assert!(text.contains("Chosen: Spaces"), "{text}"); + let log = harness.log(); + assert!(log.contains("default_mode_request_user_input"), "{log}"); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_async_question_is_answered_in_a_turn_of_maples_own() { + let harness = Harness::new("async-question"); + harness.set_mode("session-7", GooseMode::Auto).await; + let result = harness + .registry + .start( + harness.call("session-7", "row-7"), + AgentStartParams { + provider: "codex".into(), + prompt: "Ask me something".into(), + background: false, + model: None, + effort: None, + cwd: None, + }, + ) + .await; + let text = result_text(&result); + assert!(text.starts_with("Status: completed"), "{text}"); + // The turn ended, but the question is still open. + assert!( + text.contains("Waiting for the user to decide: answer a question"), + "{text}" + ); + + let sink = Arc::clone(&harness.sink); + let request_id = wait_for(|| { + sink.events().iter().find_map(|event| match event { + AgentServiceEvent::Question { + session_id, + request_id, + questions, + } if session_id == "session-7" && questions[0].question == "Tabs or spaces?" => { + Some(request_id.clone()) + } + _ => None, + }) + }) + .await; + assert!( + harness + .service + .answer_question( + &request_id, + r#"{"answers":{"q0":{"answers":["Tabs"]}}}"#.to_string() + ) + .await + ); + // The answer starts a turn of Maple's own, with its own row. + let sink = Arc::clone(&harness.sink); + let row = wait_for(|| { + sink.events().iter().find_map(|event| match event { + AgentServiceEvent::TimelineItem { item, .. } + if item.id == "external-codex-1-turn-2" + && item.status.as_deref() == Some("completed") => + { + Some(item.clone()) + } + _ => None, + }) + }) + .await; + assert_eq!( + row.title.as_deref(), + Some("External agent: answer delivered") + ); + assert!( + row.output.as_ref().unwrap()["structuredContent"][ACTIVITY_KEY]["text"] + .as_str() + .unwrap() + .contains("Got: Tabs") + ); + let log = harness.log(); + assert!(log.contains("Answers to your questions"), "{log}"); + let status = harness + .registry + .status( + &harness.call("session-7", "row-7b"), + AgentRefParams { + provider: "codex".into(), + agent_id: "codex-1".into(), + }, + ) + .await; + let status_text = result_text(&status); + assert!( + !status_text.contains("Waiting for the user"), + "{status_text}" + ); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs index 10d29e937..754fe0ff7 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs @@ -5,6 +5,7 @@ //! Maple-hosted implementation. A task freezes that backend choice when it is //! created; account defaults never rewrite existing tasks. +use super::external_agents::codex::{self, CodexDetection}; use super::*; use std::collections::HashSet; #[cfg(target_os = "macos")] @@ -27,6 +28,31 @@ const CUA_EXTERNAL_MCP_DESCRIPTION: &str = "Control desktop applications through the locally installed Cua Driver."; #[cfg(target_os = "macos")] const CUA_DRIVER_MACOS_BINARY: &str = "/Applications/CuaDriver.app/Contents/MacOS/cua-driver"; +/// The Codex CLI as an external agent a task can hand work to. +pub(super) const CODEX_INTEGRATION_ID: &str = "codex"; +const CODEX_CARD_NAME: &str = "Codex"; +const CODEX_CARD_DESCRIPTION: &str = + "Let a task hand work to the Codex CLI installed on this computer, with its own account."; +/// Frontmatter line that marks a skill file as Maple's, so disabling the +/// integration removes only what enabling it wrote. +const EXTERNAL_AGENT_SKILL_MARKER: &str = "maple: external-agents"; +/// The skills that teach a task how to delegate. They are installed into +/// the account's Goose skills directory, which both the composer's `/` +/// list and the `load_skill` tool already scan. +const EXTERNAL_AGENT_SKILLS: [(&str, &str); 3] = [ + ( + "handoff", + include_str!("../../resources/skills/handoff/SKILL.md"), + ), + ( + "committee", + include_str!("../../resources/skills/committee/SKILL.md"), + ), + ( + "advisor", + include_str!("../../resources/skills/advisor/SKILL.md"), + ), +]; const INTEGRATIONS_FILE_NAME: &str = "integrations.json"; const INTEGRATIONS_FILE_VERSION: u32 = 2; const LEGACY_INTEGRATIONS_FILE_VERSION: u32 = 1; @@ -65,6 +91,167 @@ impl StoredIntegrationRegistry { .iter_mut() .find(|entry| entry.id == CUA_DRIVER_INTEGRATION_ID) } + + fn codex(&self) -> Option<&StoredIntegration> { + self.integrations + .iter() + .find(|entry| entry.id == CODEX_INTEGRATION_ID) + } + + fn codex_mut(&mut self) -> Option<&mut StoredIntegration> { + self.integrations + .iter_mut() + .find(|entry| entry.id == CODEX_INTEGRATION_ID) + } +} + +/// Everything discovered about the curated integrations on this device. +#[derive(Debug)] +pub(super) struct IntegrationDetections { + pub(super) cua: CuaDetection, + pub(super) codex: CodexDetection, +} + +/// The Integrations card for Codex. Availability comes from the +/// installation alone; sign-in is reported on the card but does not gate +/// enabling, because the tool itself says what to do when it is missing. +fn codex_public( + detection: &CodexDetection, + stored: Option<&StoredIntegration>, +) -> AgentIntegration { + let availability = match (&detection.executable, &detection.problem) { + (None, _) => AgentIntegrationAvailability::NotDetected, + (Some(_), Some(_)) => AgentIntegrationAvailability::SetupRequired, + (Some(_), None) => AgentIntegrationAvailability::Available, + }; + let detail = match ( + &detection.executable, + &detection.problem, + detection.signed_in, + ) { + (None, _, _) => Some( + "Install the Codex CLI and make sure `codex` is on your PATH, then reopen this page." + .to_string(), + ), + (Some(_), Some(problem), _) => Some(problem.clone()), + (Some(_), None, Some(false)) => Some(codex::sign_in_hint().to_string()), + (Some(_), None, Some(true)) => Some("Signed in.".to_string()), + (Some(_), None, None) => None, + }; + AgentIntegration { + id: CODEX_INTEGRATION_ID.to_string(), + name: CODEX_CARD_NAME.to_string(), + description: CODEX_CARD_DESCRIPTION.to_string(), + availability, + backend: stored.map(|entry| entry.backend), + version: detection.version.clone(), + standalone_version: None, + permissions: None, + setup_available: false, + enabled_for_new_tasks: stored.is_some_and(|entry| entry.enabled), + detail, + } +} + +/// Whether tasks of this account may delegate to external agents. Read +/// on a path that must keep working, so an unusable file reads as off. +pub(super) fn external_agents_enabled(paths: &AgentPathLayout, user_id: &str) -> bool { + stored_integrations_for_read(paths, user_id) + .codex() + .is_some_and(|entry| entry.enabled) +} + +/// The slash commands for the skills Maple installed in the account's +/// Goose skills directory. A minimal frontmatter read: `name`, +/// `description`, and `argument-hint`, which is all the composer shows. +pub(super) fn account_skill_commands( + paths: &AgentPathLayout, + user_id: &str, +) -> Vec { + let Ok(root) = external_agent_skills_dir(paths, user_id) else { + return Vec::new(); + }; + let Ok(entries) = fs::read_dir(&root) else { + return Vec::new(); + }; + let mut commands = entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| fs::read_to_string(entry.path().join("SKILL.md")).ok()) + .filter_map(|content| skill_command_from_frontmatter(&content)) + .collect::>(); + commands.sort_by(|a, b| a.name.cmp(&b.name)); + commands +} + +fn skill_command_from_frontmatter(content: &str) -> Option { + let body = content.trim_start().strip_prefix("---")?; + let (frontmatter, _) = body.split_once("\n---")?; + let field = |key: &str| { + frontmatter.lines().find_map(|line| { + let (found, value) = line.split_once(':')?; + (found.trim() == key).then(|| value.trim().trim_matches('"').to_string()) + }) + }; + let name = field("name")?; + if name.is_empty() || name.contains('/') { + return None; + } + Some(AgentSlashCommand { + name, + description: field("description").unwrap_or_default(), + input_hint: field("argument-hint").filter(|hint| !hint.is_empty()), + }) +} + +fn external_agent_skills_dir(paths: &AgentPathLayout, user_id: &str) -> Result { + Ok(account_config_dir_path(paths, user_id) + .map_err(|error| error.to_string())? + .join("goose") + .join("config") + .join("skills")) +} + +/// Install or remove the delegation skills so they match the toggle. Only +/// files that carry Maple's marker are ever removed. +pub(super) fn sync_external_agent_skills( + paths: &AgentPathLayout, + user_id: &str, + enabled: bool, +) -> Result<(), String> { + let root = external_agent_skills_dir(paths, user_id)?; + for (name, content) in EXTERNAL_AGENT_SKILLS { + debug_assert!(content.contains(EXTERNAL_AGENT_SKILL_MARKER)); + let dir = root.join(name); + let file = dir.join("SKILL.md"); + if enabled { + let current = fs::read_to_string(&file).ok(); + if current.as_deref() == Some(content) { + continue; + } + if current.is_some_and(|current| !current.contains(EXTERNAL_AGENT_SKILL_MARKER)) { + log::warn!( + "Leaving the user's own skill in place at {}", + file.display() + ); + continue; + } + crate::private_file::write_private_file(&file, content.as_bytes()) + .map_err(|error| format!("Failed to install the {name} skill: {error}"))?; + set_owner_only_dir_permissions(&dir); + } else { + let Ok(current) = fs::read_to_string(&file) else { + continue; + }; + if !current.contains(EXTERNAL_AGENT_SKILL_MARKER) { + continue; + } + fs::remove_file(&file) + .map_err(|error| format!("Failed to remove the {name} skill: {error}"))?; + // Only the directory Maple made; a user's extra files keep it. + let _ = fs::remove_dir(&dir); + } + } + Ok(()) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -163,14 +350,20 @@ fn embedded_cua_version() -> Option { None } -pub(super) async fn detect_integrations() -> CuaDetection { - detect_cua_driver().await +/// Discover every curated integration. `codex_search_path` is the PATH +/// to look on for the Codex CLI, when the host knows a fuller one than +/// the process environment (a macOS GUI launch). +pub(super) async fn detect_integrations(codex_search_path: Option<&str>) -> IntegrationDetections { + IntegrationDetections { + cua: detect_cua_driver().await, + codex: codex::detect(codex_search_path).await, + } } -/// The only integration Maple curates today. Every entry point that accepts an +/// The integrations Maple curates. Every entry point that accepts an /// integration id checks it here so they cannot disagree. pub(super) fn require_known_integration(id: &str) -> Result<(), String> { - if id.trim() == CUA_DRIVER_INTEGRATION_ID { + if matches!(id.trim(), CUA_DRIVER_INTEGRATION_ID | CODEX_INTEGRATION_ID) { return Ok(()); } Err(format!("Unknown integration '{}'", id.trim())) @@ -179,20 +372,71 @@ pub(super) fn require_known_integration(id: &str) -> Result<(), String> { pub(super) fn project_integrations( paths: &AgentPathLayout, user_id: &str, - detection: &CuaDetection, + detections: &IntegrationDetections, ) -> Result, String> { let stored = load_stored_integrations(paths, user_id)?; - Ok(vec![detection.public(stored.cua())]) + Ok(vec![ + detections.cua.public(stored.cua()), + codex_public(&detections.codex, stored.codex()), + ]) } pub(super) fn set_integration_default( paths: &AgentPathLayout, user_id: &str, request: &AgentSetIntegrationEnabledRequest, - detection: &CuaDetection, + detections: &IntegrationDetections, ) -> Result, String> { require_known_integration(&request.id)?; + if request.id.trim() == CODEX_INTEGRATION_ID { + set_codex_default(paths, user_id, request.enabled, &detections.codex)?; + } else { + set_cua_default(paths, user_id, request.enabled, &detections.cua)?; + } + project_integrations(paths, user_id, detections) +} + +/// Enabling needs a usable installation; disabling never fails on the +/// installation, so a removed Codex can still be switched off. +fn set_codex_default( + paths: &AgentPathLayout, + user_id: &str, + enabled: bool, + detection: &CodexDetection, +) -> Result<(), String> { + let mut stored = load_stored_integrations(paths, user_id)?; + if enabled { + if detection.executable.is_none() { + return Err( + "Install the Codex CLI and make sure `codex` is on your PATH before enabling it" + .to_string(), + ); + } + if let Some(problem) = &detection.problem { + return Err(problem.clone()); + } + match stored.codex_mut() { + Some(entry) => entry.enabled = true, + None => stored.integrations.push(StoredIntegration { + id: CODEX_INTEGRATION_ID.to_string(), + enabled: true, + backend: AgentIntegrationBackend::Embedded, + external_server: None, + }), + } + } else if let Some(entry) = stored.codex_mut() { + entry.enabled = false; + } + save_stored_integrations(paths, user_id, &stored)?; + sync_external_agent_skills(paths, user_id, enabled) +} +fn set_cua_default( + paths: &AgentPathLayout, + user_id: &str, + enabled: bool, + detection: &CuaDetection, +) -> Result<(), String> { let custom = normalize_mcp_servers( load_agent_config_inner(paths, user_id) .map_err(|error| format!("Failed to load MCP servers: {error}"))? @@ -200,7 +444,7 @@ pub(super) fn set_integration_default( )?; let mut stored = load_stored_integrations(paths, user_id)?; - if request.enabled { + if enabled { ensure_no_custom_integration_collision(&custom)?; match stored.cua_mut() { Some(entry) => { @@ -247,8 +491,7 @@ pub(super) fn set_integration_default( entry.enabled = false; } - save_stored_integrations(paths, user_id, &stored)?; - Ok(vec![detection.public(stored.cua())]) + save_stored_integrations(paths, user_id, &stored) } /// Switch the new-task default to Maple's embedded backend only after the OS @@ -257,10 +500,11 @@ pub(super) fn set_integration_default( pub(super) fn select_embedded_integration_backend( paths: &AgentPathLayout, user_id: &str, - detection: &CuaDetection, + detections: &IntegrationDetections, ) -> Result, String> { + let detection = &detections.cua; if !detection.embedded_ready() { - return project_integrations(paths, user_id, detection); + return project_integrations(paths, user_id, detections); } let custom = normalize_mcp_servers( load_agent_config_inner(paths, user_id) @@ -284,7 +528,7 @@ pub(super) fn select_embedded_integration_backend( }), } save_stored_integrations(paths, user_id, &stored)?; - project_integrations(paths, user_id, detection) + project_integrations(paths, user_id, detections) } pub(super) fn effective_mcp_servers( @@ -599,12 +843,23 @@ fn validate_stored_registry( } let mut ids = HashSet::new(); for entry in ®istry.integrations { - if entry.id != CUA_DRIVER_INTEGRATION_ID || !ids.insert(entry.id.as_str()) { + if !matches!( + entry.id.as_str(), + CUA_DRIVER_INTEGRATION_ID | CODEX_INTEGRATION_ID + ) || !ids.insert(entry.id.as_str()) + { return Err( "Device-local integration settings contain an unknown or duplicate integration" .to_string(), ); } + if entry.id == CODEX_INTEGRATION_ID { + if entry.backend != AgentIntegrationBackend::Embedded || entry.external_server.is_some() + { + return Err("Device-local Codex settings are invalid".to_string()); + } + continue; + } if entry.backend == AgentIntegrationBackend::External && entry.external_server.is_none() { return Err( "Device-local external Cua Driver settings have no server definition".to_string(), @@ -876,6 +1131,13 @@ fn join_mcp_command<'a>(parts: impl IntoIterator) -> Result IntegrationDetections { + IntegrationDetections { + cua, + codex: CodexDetection::default(), + } + } + fn stored_cua_server(root: &Path, enabled: bool) -> AgentMcpServer { let binary = root.join("cua-driver"); let command = join_mcp_command([binary.to_str().expect("UTF-8 test path"), "mcp"]) @@ -1197,7 +1459,7 @@ mod tests { id: CUA_DRIVER_INTEGRATION_ID.to_string(), enabled: true, }, - &detection, + &cua_only(detection), ) .unwrap(); assert!(enabled[0].enabled_for_new_tasks); @@ -1217,7 +1479,7 @@ mod tests { id: CUA_DRIVER_INTEGRATION_ID.to_string(), enabled: false, }, - &CuaDetection::not_detected(), + &cua_only(CuaDetection::not_detected()), ) .unwrap(); assert!(!disabled[0].enabled_for_new_tasks); @@ -1296,7 +1558,8 @@ mod tests { external_server: None, }; - let projected = select_embedded_integration_backend(&paths, user, &detection).unwrap(); + let projected = + select_embedded_integration_backend(&paths, user, &cua_only(detection)).unwrap(); assert_eq!( projected[0].backend, Some(AgentIntegrationBackend::Embedded) @@ -1321,4 +1584,153 @@ mod tests { .contains("Maple desktop app") ); } + #[test] + fn codex_toggle_persists_default_off_and_installs_skills() { + let temporary = tempfile::tempdir().unwrap(); + let paths = AgentPathLayout::from_app_roots( + temporary.path().join("config"), + temporary.path().join("data"), + ); + let user = "codex-user"; + let detections = IntegrationDetections { + cua: CuaDetection::not_detected(), + codex: CodexDetection { + executable: Some(temporary.path().join("codex")), + version: Some("codex-cli 0.150.0".to_string()), + signed_in: Some(false), + problem: None, + }, + }; + let projected = project_integrations(&paths, user, &detections).unwrap(); + let codex_card = projected + .iter() + .find(|integration| integration.id == CODEX_INTEGRATION_ID) + .unwrap(); + assert!(!codex_card.enabled_for_new_tasks); + assert_eq!( + codex_card.availability, + AgentIntegrationAvailability::Available + ); + assert_eq!(codex_card.version.as_deref(), Some("codex-cli 0.150.0")); + assert!( + codex_card + .detail + .as_deref() + .unwrap() + .contains("codex login") + ); + assert!(!external_agents_enabled(&paths, user)); + + let enabled = set_integration_default( + &paths, + user, + &AgentSetIntegrationEnabledRequest { + id: CODEX_INTEGRATION_ID.to_string(), + enabled: true, + }, + &detections, + ) + .unwrap(); + assert!( + enabled + .iter() + .find(|integration| integration.id == CODEX_INTEGRATION_ID) + .unwrap() + .enabled_for_new_tasks + ); + assert!(external_agents_enabled(&paths, user)); + let skills = external_agent_skills_dir(&paths, user).unwrap(); + for (name, content) in EXTERNAL_AGENT_SKILLS { + assert_eq!( + fs::read_to_string(skills.join(name).join("SKILL.md")).unwrap(), + content + ); + } + let commands = account_skill_commands(&paths, user); + assert_eq!( + commands.iter().map(|c| c.name.as_str()).collect::>(), + ["advisor", "committee", "handoff"] + ); + assert_eq!( + commands[2].input_hint.as_deref(), + Some("") + ); + assert!(commands[2].description.contains("Codex")); + // A user's own skill of the same name is never overwritten or removed. + let own = skills.join("advisor").join("SKILL.md"); + fs::write(&own, "---\nname: advisor\n---\nmine\n").unwrap(); + + let disabled = set_integration_default( + &paths, + user, + &AgentSetIntegrationEnabledRequest { + id: CODEX_INTEGRATION_ID.to_string(), + enabled: false, + }, + &IntegrationDetections { + cua: CuaDetection::not_detected(), + codex: CodexDetection::default(), + }, + ) + .unwrap(); + assert!( + !disabled + .iter() + .find(|integration| integration.id == CODEX_INTEGRATION_ID) + .unwrap() + .enabled_for_new_tasks + ); + assert!(!external_agents_enabled(&paths, user)); + assert!(!skills.join("handoff").join("SKILL.md").exists()); + assert!(!skills.join("committee").join("SKILL.md").exists()); + assert_eq!( + fs::read_to_string(&own).unwrap(), + "---\nname: advisor\n---\nmine\n" + ); + } + + #[test] + fn codex_cannot_be_enabled_without_a_usable_installation() { + let temporary = tempfile::tempdir().unwrap(); + let paths = AgentPathLayout::from_app_roots( + temporary.path().join("config"), + temporary.path().join("data"), + ); + let user = "codex-user"; + let request = AgentSetIntegrationEnabledRequest { + id: CODEX_INTEGRATION_ID.to_string(), + enabled: true, + }; + let missing = IntegrationDetections { + cua: CuaDetection::not_detected(), + codex: CodexDetection::default(), + }; + let error = set_integration_default(&paths, user, &request, &missing).unwrap_err(); + assert!(error.contains("Install the Codex CLI")); + let projected = project_integrations(&paths, user, &missing).unwrap(); + assert_eq!( + projected[1].availability, + AgentIntegrationAvailability::NotDetected + ); + + let old = IntegrationDetections { + cua: CuaDetection::not_detected(), + codex: CodexDetection { + executable: Some(temporary.path().join("codex")), + version: Some("0.100.0".to_string()), + signed_in: Some(true), + problem: Some( + "Codex 0.100.0 is older than the 0.143.0 that Maple needs.".to_string(), + ), + }, + }; + let error = set_integration_default(&paths, user, &request, &old).unwrap_err(); + assert!(error.contains("older")); + let projected = project_integrations(&paths, user, &old).unwrap(); + assert_eq!( + projected[1].availability, + AgentIntegrationAvailability::SetupRequired + ); + assert!(!external_agents_enabled(&paths, user)); + } } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs index 874a454a2..9a7fa46a5 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs @@ -407,6 +407,11 @@ pub(super) fn system_notification_item( notification: &SystemNotificationContent, created_ms: u128, ) -> AgentTimelineItem { + // An external agent's end-of-turn notice is the tool row it belongs + // to, not a separate notice; see `external_agents::notice_timeline_item`. + if let Some(item) = external_agents::notice_timeline_item(notification, created_ms) { + return item; + } let title = match notification.notification_type { SystemNotificationType::ThinkingMessage => "Thinking", SystemNotificationType::ProgressMessage => "Progress", @@ -517,6 +522,11 @@ pub(super) fn descriptive_tool_title( if let Some(skill) = skill_load_title(tool_name, arguments) { return Some(skill); } + // An external agent call is titled by what it does, not by its prompt; + // the row's own body shows the agent's work. + if let Some(title) = external_agents::tool_title(tool_name) { + return Some(title.to_string()); + } let arguments = serde_json::to_value(arguments).ok()?; // Most-descriptive argument per tool, in priority order. Only the shell // is described by its command; an editor call such as @@ -889,6 +899,9 @@ pub(super) fn message_role(message: &Message) -> String { } pub(super) fn format_tool_title(name: &str) -> String { + if let Some(title) = external_agents::tool_title(name) { + return title.to_string(); + } let normalized = name.replace("__", ": ").replace('_', " "); normalized .split_whitespace() diff --git a/apps/maple-agent/crates/maple-agent/src/agent/tool_context.rs b/apps/maple-agent/crates/maple-agent/src/agent/tool_context.rs index df7d254bc..dd77fc252 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/tool_context.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/tool_context.rs @@ -214,6 +214,7 @@ impl SharedAgentToolContext { } } +#[derive(Clone)] pub(crate) struct AgentToolContextSnapshot { pub(crate) values: BTreeMap, pub(crate) scrub_from_parent: BTreeSet, diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 4df9c61f9..f1b9d04ba 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -303,7 +303,7 @@ pub struct AgentSlashCommand { } /// One answer choice, mirroring codex's request_user_input option. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentQuestionOption { pub label: String, @@ -313,7 +313,7 @@ pub struct AgentQuestionOption { /// One question in a request_user_input call: one to three related /// questions ride a single call and are answered together. The client adds /// a free-form "Other" answer next to these options. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentQuestion { pub id: String, @@ -1034,6 +1034,9 @@ pub enum AgentRunEvent { /// The subagent runs in the background; the task collects its /// result later with `load`. background: bool, + /// Set when the subagent is an external agent (Codex), which the + /// user can stop from its row. + external: Option, }, /// The subagent called a tool. Only the latest one is shown. SubagentActivity { @@ -1104,6 +1107,17 @@ pub struct AgentSubagent { pub elapsed_ms: u64, /// The tool it called most recently. pub activity: Option, + /// Set when this is an external agent (Codex) rather than a Goose + /// subagent. + pub external: Option, +} + +/// Which external agent a subagent row stands for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalAgentRef { + pub provider: String, + pub agent_id: String, } /// One finished exchange of a `/btw` thread, replayed on a follow-up so diff --git a/apps/maple-agent/docs/external-agents.md b/apps/maple-agent/docs/external-agents.md new file mode 100644 index 000000000..45d881c34 --- /dev/null +++ b/apps/maple-agent/docs/external-agents.md @@ -0,0 +1,103 @@ +# External agents + +A Maple task can hand work to an external coding agent that is installed on +the same computer. Codex is the first provider. The tool contract is generic +so another harness can be added without changing what the task sees. + +## What the task sees + +Five tools appear in a desktop task when Codex is enabled under +Settings > Integrations: + +| Tool | Purpose | +| --- | --- | +| `list_agent_providers` | Which providers are installed, their version, and whether they are signed in. | +| `agent_start` | Start an agent on a self-contained briefing. Blocking by default; `background: true` returns at once. | +| `agent_send` | Give a started agent more instructions in the same thread. | +| `agent_status` | Read an agent's status, last message, changed files, and commands. | +| `agent_cancel` | Stop an agent's current turn and its process. The thread stays on disk; the next `agent_send` resumes it in a fresh process. | + +Every tool takes `provider` (`"codex"`). `agent_start` also takes optional +`model`, `effort`, and `cwd`. `cwd` must be inside the project root. + +A result has Paseo's shape: a status line, the agent ID and thread ID, the +files changed and commands run, the agent's last message inside an +`` block cut at 4,000 characters, and one line of guidance. + +Starting an agent is always allowed. The agent's own actions are what get +gated, so the hand-off does not prompt twice. + +## Skills + +Enabling the integration installs three skills into +`/agent/accounts//goose/config/skills/`: + +- `/handoff` writes a self-contained briefing and starts an agent in the + background. +- `/committee` starts several agents on one question with different models + or efforts, adds the task's own analysis, and compares. +- `/advisor` asks for read-only analysis with "do not edit files" appended. + +Disabling removes the files Maple wrote and nothing else. A user's own skill +of the same name is left alone. The files are reconciled at every runtime +start so an upgrade that changed them takes effect. + +## How Codex is driven + +Maple runs `codex app-server` as a child process, one per Codex thread, +and speaks its JSON-RPC protocol over stdio. Codex uses the user's own +sign-in and `~/.codex` configuration, including its `sandbox_mode` and +`approval_policy`. Maple sends only the prompt, the working directory, and +one feature flag, `features.default_mode_request_user_input`, so Codex can +ask the user questions outside plan mode. + +Maple's own permission mode does not change what Codex may do; Codex's +sandbox does that. The mode decides who answers when Codex asks: + +| Maple mode | Codex asks to run a command or change a file | +| --- | --- | +| Read only | A Maple permission card. Allow sends `accept`; deny sends `decline`. | +| Allow all | Maple sends `accept` without asking. | + +Stopping the agent sends `cancel`. Maple never grants `acceptForSession`. +A question from Codex, blocking in plan mode or asynchronous in the +default mode, opens Maple's question card and the answer goes back in +Codex's own shape; an asynchronous answer that arrives after the turn +ended starts a follow-up turn on the same thread. + +The child runs in the project root, on the user's login PATH, with the same +environment scrubbing as the shell tool, in its own process group or job +so teardown reaches every descendant. It is killed when the runtime stops, +on logout, and when its task is deleted. Threads are not ephemeral, so +`codex resume` works from a terminal afterwards. + +The handshake reports the reserved client name `codex_app_server_daemon`, +the same non-originating name Paseo uses. + +## Transcript + +The tool call's row streams the agent's text, its commands with exit +status, the files it changed, and its todo list. While a turn runs, the +agent also has a row above the composer with a Stop button. A background +turn that ends after the tool returned writes two notices into the session +history: one the transcript projects back onto the same row, so a reopened +task still shows what the agent did, and one plain line saying the agent +finished. Maple then tells the task that the agent finished, into the +running turn or the next one. + +Stopping an agent, from its row or with `agent_cancel`, sends +`turn/interrupt`, waits briefly for Codex to confirm, then kills the +process group. Codex does not always end a sandboxed command on interrupt, +so the kill is what guarantees nothing keeps running. + +## Limits + +- One task may run at most four external agents at once. +- Switching Maple's mode mid-turn changes who answers Codex's next + request, not the sandbox Codex already runs under. +- Flatpak builds report external agents as unsupported. +- Codex 0.143 or newer is required. +- Maple cannot sign Codex in. The Integrations card says when a sign-in is + missing; run `codex login` in a terminal. +- Windows compiles and runs the same code, with `codex.exe` preferred over + the npm `codex.cmd` shim, but the desktop smoke has not been run there.