Skip to main content

forge_agent/
agent.rs

1//! Channel-based [`Agent`] handle - the public consumer surface.
2//!
3//! `Agent::spawn` constructs a `ForgeSdkBridge` under the hood and
4//! spawns one background task: the **command dispatcher**, which
5//! drains `mpsc::UnboundedReceiver<AgentCommand>` and calls the matching
6//! inherent method on the bridge. The bridge's `AgentEvent`
7//! receiver is handed back to consumers via `take_events()`.
8//!
9//! Direct-return accessors (config_dir, settings_documents, etc.)
10//! live on [`AgentHandle`] as method passthroughs to the bridge.
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use forge_primitives::{AgentCommand, PermissionMode};
17use parking_lot::Mutex;
18use tokio::sync::mpsc;
19use tracing::Instrument;
20
21use crate::forge_sdk_bridge::ForgeSdkBridge;
22
23/// Handle returned by [`Agent::spawn`]. Owns the channels + a thin
24/// passthrough to the bridge's direct-return accessors.
25pub struct AgentHandle {
26    /// UI → agent commands. Used internally by the inherent
27    /// command-shorthand methods (`prompt_text`, `cancel`, etc.). Public
28    /// so callers can `send(AgentCommand::...)` directly when richer flows
29    /// are needed.
30    pub commands: mpsc::UnboundedSender<AgentCommand>,
31    /// Bridge's raw `AgentEvent` receiver. Single-take via
32    /// [`AgentHandle::take_events`] - the workspace's session task
33    /// consumes this and translates it into `SessionUpdate`s.
34    agent_events: Mutex<Option<mpsc::UnboundedReceiver<crate::client::AgentEvent>>>,
35    /// Bridge handle used for direct-return accessors (config_dir,
36    /// settings_documents, …) plus internal command dispatch.
37    bridge: Arc<ForgeSdkBridge>,
38}
39
40impl AgentHandle {
41    /// Take ownership of the bridge's `AgentEvent` receiver. Returns
42    /// `Some` exactly once.
43    pub fn take_events(&self) -> Option<mpsc::UnboundedReceiver<crate::client::AgentEvent>> {
44        self.agent_events.lock().take()
45    }
46
47    // Direct-accessor passthroughs to the bridge. Each method
48    // simply forwards arguments - no transform.
49
50    pub fn config_dir(&self) -> PathBuf {
51        self.bridge.config_dir()
52    }
53
54    /// OS PID of the bound `claude` child, when one is currently
55    /// attached. Surfaces to forge-workspace for the Inspector
56    /// pane's PROCESSES OS-walk.
57    pub fn claude_pid(&self) -> Option<u32> {
58        self.bridge.claude_pid()
59    }
60
61    /// Take the session's process group down without the async drain.
62    ///
63    /// For a session that never got a task to run its exit path: dropping
64    /// the handle alone SIGKILLs the direct child and leaves the tools the
65    /// CLI already started behind.
66    pub fn kill_process_group(&self) {
67        if let Some(client) = self.bridge.clear_client() {
68            client.kill_process_group();
69        }
70    }
71
72    pub fn project_memory_path(&self, cwd: &Path) -> PathBuf {
73        self.bridge.project_memory_path(cwd)
74    }
75
76    pub fn settings_documents(
77        &self,
78        cwd: Option<&Path>,
79    ) -> crate::userdata::settings::SettingsDocuments {
80        self.bridge.settings_documents(cwd)
81    }
82
83    /// Returns a clone of the bridge's bound forge-account
84    /// `display_name` (when forge-workspace picked one). Used by
85    /// the connect-flow to emit a `ForgeAccountIdentityReady`
86    /// event right after spawn - eliminates the welcome-message
87    /// flicker that would otherwise wait for the slow status
88    /// snapshot to arrive from the CLI.
89    pub fn display_name(&self) -> Option<String> {
90        self.bridge.display_name()
91    }
92
93    /// The bridge's resolved forge.toml env. Test-only, and one hop
94    /// short of what the child receives - the subprocess also gets
95    /// `CLAUDE_CONFIG_DIR` and the SDK version. It exists so a test
96    /// can observe the map a spawn carries rather than what the
97    /// resolution helper returned.
98    #[cfg(any(test, feature = "testing"))]
99    pub fn env(&self) -> std::collections::HashMap<String, String> {
100        self.bridge.env()
101    }
102
103    /// Disconnect the bound `claude` subprocess: take the bridge's
104    /// client slot and run the SDK's graceful shutdown (signal the
105    /// reader task, drain, close the child). Both layers are
106    /// idempotent via `Option::take`, so repeated calls on clones are
107    /// no-ops. Called from `SessionTask`'s exit path so release /
108    /// despawn / teardown paths actually kill the subprocess instead
109    /// of leaving it alive until forge exits.
110    pub async fn disconnect(&self) {
111        if let Some(client) = self.bridge.clear_client()
112            && let Err(err) = client.disconnect().await
113        {
114            tracing::warn!(
115                target: crate::logging::targets::BRIDGE_LIFECYCLE,
116                error = %err,
117                "client disconnect failed during session teardown",
118            );
119        }
120    }
121
122    // ---- Fire-and-forget AgentCommand shorthands ----
123    //
124    // Each method builds the matching `AgentCommand` variant and pushes it
125    // onto `commands`. Returns `Err` only if the dispatcher task has
126    // shut down (channel closed) or `launch_settings` serialisation
127    // fails. Errors from the underlying forge_sdk::Client surface
128    // asynchronously via the events stream.
129
130    fn send(&self, cmd: AgentCommand) -> Result<(), AgentError> {
131        self.commands.send(cmd).map_err(|_| AgentError::DispatcherShutDown)
132    }
133
134    /// Start a session under `session_id`, or under an id the CLI picks
135    /// when that is `None`.
136    pub fn new_session(
137        &self,
138        session_id: Option<String>,
139        cwd: String,
140        launch_settings: crate::client::SessionLaunchSettings,
141    ) -> Result<(), AgentError> {
142        // Propagate serialise failure instead of launching with `Value::Null`:
143        // the bridge REFUSES a value it cannot decode rather than
144        // deserialising it to default settings, and the refusal is logged and
145        // emitted as a `ConnectionFailed`, so a lossy payload stops the spawn
146        // with a visible failure instead of dropping the configured model or
147        // the blocked-tools denial silently.
148        let launch_settings = serde_json::to_value(launch_settings)?;
149        self.send(AgentCommand::NewSession { session_id, cwd, launch_settings })
150    }
151
152    pub fn resume_session(
153        &self,
154        session_id: String,
155        cwd: String,
156        launch_settings: crate::client::SessionLaunchSettings,
157    ) -> Result<(), AgentError> {
158        let launch_settings = serde_json::to_value(launch_settings)?;
159        self.send(AgentCommand::ResumeSession {
160            session_id: session_id.into(),
161            cwd,
162            launch_settings,
163        })
164    }
165
166    /// Resume the recorded `session_id`; if resume fails (stale
167    /// catalog entry, cross-account scan, deleted `.jsonl`), retry as
168    /// a fresh session in `cwd`. The user only sees a
169    /// `ConnectionFailed` event when both attempts fail.
170    pub fn resume_or_new_session(
171        &self,
172        session_id: String,
173        cwd: String,
174        launch_settings: crate::client::SessionLaunchSettings,
175    ) -> Result<(), AgentError> {
176        let launch_settings = serde_json::to_value(launch_settings)?;
177        self.send(AgentCommand::ResumeOrNewSession {
178            session_id: session_id.into(),
179            cwd,
180            launch_settings,
181        })
182    }
183
184    pub fn prompt_text(
185        &self,
186        session_id: String,
187        text: String,
188        uuid: String,
189    ) -> Result<(), AgentError> {
190        self.send(AgentCommand::Prompt { session_id: session_id.into(), text, uuid })
191    }
192
193    pub fn prompt_with_images(
194        &self,
195        session_id: String,
196        text: String,
197        images: Vec<forge_primitives::ImageAttachment>,
198        uuid: String,
199    ) -> Result<(), AgentError> {
200        self.send(AgentCommand::PromptWithImages {
201            session_id: session_id.into(),
202            text,
203            images,
204            uuid,
205        })
206    }
207
208    pub fn cancel(&self, session_id: String) -> Result<(), AgentError> {
209        self.send(AgentCommand::Cancel { session_id: session_id.into() })
210    }
211
212    pub fn cancel_queued(&self, session_id: String, uuid: String) -> Result<(), AgentError> {
213        self.send(AgentCommand::CancelQueuedPrompt { session_id: session_id.into(), uuid })
214    }
215
216    pub fn set_mode(&self, session_id: String, mode: PermissionMode) -> Result<(), AgentError> {
217        self.send(AgentCommand::SetMode { session_id: session_id.into(), mode })
218    }
219
220    pub fn set_model(&self, session_id: String, model: String) -> Result<(), AgentError> {
221        self.send(AgentCommand::SetModel { session_id: session_id.into(), model })
222    }
223
224    pub fn get_context_usage(&self, session_id: String) -> Result<(), AgentError> {
225        self.send(AgentCommand::GetContextUsage { session_id: session_id.into() })
226    }
227
228    pub fn get_mcp_snapshot(&self, session_id: String) -> Result<(), AgentError> {
229        self.send(AgentCommand::GetMcpSnapshot { session_id: session_id.into() })
230    }
231
232    pub fn permission_response(
233        &self,
234        session_id: String,
235        tool_call_id: String,
236        outcome: forge_primitives::PermissionOutcome,
237    ) -> Result<(), AgentError> {
238        self.send(AgentCommand::PermissionResponse {
239            session_id: session_id.into(),
240            tool_call_id: tool_call_id.into(),
241            outcome,
242        })
243    }
244
245    pub fn question_response(
246        &self,
247        session_id: String,
248        tool_call_id: String,
249        outcome: forge_primitives::QuestionOutcome,
250    ) -> Result<(), AgentError> {
251        self.send(AgentCommand::QuestionResponse {
252            session_id: session_id.into(),
253            tool_call_id: tool_call_id.into(),
254            outcome,
255        })
256    }
257}
258
259/// Errors surfaced from `AgentHandle`'s fire-and-forget command
260/// methods. The underlying `claude` subprocess work flows back
261/// asynchronously via the events stream - these are dispatch-time
262/// failures only.
263#[derive(Debug, thiserror::Error)]
264pub enum AgentError {
265    /// The command dispatcher task has shut down; the channel is
266    /// closed and no further commands will be processed.
267    #[error("agent dispatcher shut down")]
268    DispatcherShutDown,
269    /// Serialising `SessionLaunchSettings` to JSON failed before the
270    /// command could be dispatched.
271    #[error("failed to encode launch settings: {0}")]
272    EncodeFailed(#[from] serde_json::Error),
273    /// The command needs a session id but the bridge hasn't emitted
274    /// its first `Connected` yet, so it was dropped.
275    #[error("no active session for {command}")]
276    NoSession { command: &'static str },
277}
278
279/// Agent factory - wraps a private `ForgeSdkBridge` behind a channel API.
280pub struct Agent;
281
282impl Agent {
283    /// Construct a test stub: `AgentHandle` backed by a fresh
284    /// ForgeSdkBridge that's never actually driven (no `new_session`
285    /// call). Returns the handle plus a `Receiver<AgentCommand>` that
286    /// drains every command the test exercises.
287    ///
288    /// Safe to call outside a Tokio runtime - no tasks are spawned.
289    /// The bridge's events stream is dropped on the floor; tests that
290    /// don't drive sessions never see events anyway. The bridge is
291    /// bound to a synthetic `/tmp/forge-testing-stub` config_dir;
292    /// since no session is driven, no I/O hits this path.
293    #[cfg(any(test, feature = "testing"))]
294    pub fn testing_stub() -> (AgentHandle, mpsc::UnboundedReceiver<AgentCommand>) {
295        let bridge = ForgeSdkBridge::default();
296        // Drop the bridge's events receiver immediately - tests don't
297        // run a real session so nothing is producing.
298        let _ = bridge.take_events();
299
300        let (commands_tx, commands_rx) = mpsc::unbounded_channel::<AgentCommand>();
301        // Hand a fresh empty channel as the agent_events receiver so
302        // the AgentHandle shape matches production. The `_dead_tx`
303        // drops at end of scope; `take_events()` returns the
304        // receiver and its first `recv()` then returns `None`.
305        let (_dead_tx, dead_rx) = mpsc::unbounded_channel::<crate::client::AgentEvent>();
306
307        let handle = AgentHandle {
308            commands: commands_tx,
309            agent_events: Mutex::new(Some(dead_rx)),
310            bridge: Arc::new(bridge),
311        };
312        (handle, commands_rx)
313    }
314
315    /// Spawn a new agent runtime bound to `config_dir` with an
316    /// optional forge-account `display_name`. Both are stored on the
317    /// bridge as typed fields. `config_dir` is consulted by every
318    /// in-process accessor (oauth, settings, catalog scans) and
319    /// exported to the spawned `claude` subprocess as
320    /// `CLAUDE_CONFIG_DIR`. `display_name`, when set, is read back
321    /// through the handle's `display_name()` so a view
322    /// renders which forge-account the bridge is bound to. `env`
323    /// carries the session's resolved forge.toml env - `[env]`, then
324    /// `[accounts.env]`, then the spawning project's `[[orgs.projects]]`
325    /// `env` - stamped onto the spawned subprocess
326    /// alongside `CLAUDE_CONFIG_DIR`. Returns a
327    /// handle holding the command sender + events receiver + direct-
328    /// accessor passthroughs.
329    pub fn spawn(
330        config_dir: PathBuf,
331        display_name: Option<String>,
332        extra_mcp_servers: Vec<(String, forge_sdk::mcp::McpServer)>,
333        env: HashMap<String, String>,
334    ) -> AgentHandle {
335        let bridge = ForgeSdkBridge::new(config_dir, display_name, extra_mcp_servers, env);
336        let agent_event_rx = bridge.take_events().unwrap_or_else(|| mpsc::unbounded_channel().1);
337
338        let (commands_tx, commands_rx) = mpsc::unbounded_channel::<AgentCommand>();
339
340        // AgentCommand dispatcher task.
341        let dispatch_bridge = Arc::new(bridge);
342        let span = tracing::info_span!(
343            "agent_dispatch",
344            config_dir = %dispatch_bridge.config_dir().display(),
345        );
346        tokio::spawn(dispatch_commands(commands_rx, Arc::clone(&dispatch_bridge)).instrument(span));
347
348        AgentHandle {
349            commands: commands_tx,
350            agent_events: Mutex::new(Some(agent_event_rx)),
351            bridge: dispatch_bridge,
352        }
353    }
354}
355
356async fn dispatch_commands(
357    mut commands_rx: mpsc::UnboundedReceiver<AgentCommand>,
358    bridge: Arc<ForgeSdkBridge>,
359) {
360    while let Some(cmd) = commands_rx.recv().await {
361        if let Err(err) = dispatch(cmd, &bridge) {
362            tracing::warn!(
363                target: crate::logging::targets::BRIDGE_LIFECYCLE,
364                error = %err,
365                "agent command dispatch failed",
366            );
367        }
368    }
369}
370
371/// Dispatch one `AgentCommand` to the matching `ForgeSdkBridge` method.
372/// Internal - kept on `anyhow` because the bridge surface itself
373/// returns `anyhow::Result` and the dispatcher task only logs the
374/// error before continuing. The public AgentHandle surface above
375/// uses the typed `AgentError`.
376fn dispatch(cmd: AgentCommand, bridge: &ForgeSdkBridge) -> anyhow::Result<()> {
377    use forge_primitives::AgentCommand as C;
378
379    match cmd {
380        C::NewSession { session_id, cwd, launch_settings } => {
381            bridge.new_session(session_id, cwd, launch_settings)
382        }
383        C::ResumeSession { session_id, cwd, launch_settings } => {
384            bridge.resume_session(session_id.into_string(), cwd, launch_settings)
385        }
386        C::ResumeOrNewSession { session_id, cwd, launch_settings } => {
387            bridge.resume_or_new_session(session_id.into_string(), cwd, launch_settings)
388        }
389        C::Prompt { session_id, text, uuid } => {
390            bridge.prompt_text(session_id.into_string(), text, uuid)
391        }
392        C::PromptWithImages { session_id, text, images, uuid } => {
393            bridge.prompt_with_images(session_id.into_string(), text, images, uuid)
394        }
395        C::Cancel { session_id } => bridge.cancel(session_id.into_string()),
396        C::CancelQueuedPrompt { session_id, uuid } => {
397            bridge.cancel_queued(session_id.into_string(), uuid)
398        }
399        C::SetMode { session_id, mode } => bridge.set_mode(session_id.into_string(), mode),
400        C::SetModel { session_id, model } => bridge.set_model(session_id.into_string(), model),
401        C::GetContextUsage { session_id } => bridge.get_context_usage(session_id.into_string()),
402        C::GetMcpSnapshot { session_id } => bridge.get_mcp_snapshot(session_id.into_string()),
403        C::PermissionResponse { session_id, tool_call_id, outcome } => bridge.permission_response(
404            session_id.into_string(),
405            tool_call_id.into_string(),
406            outcome,
407        ),
408        C::QuestionResponse { session_id, tool_call_id, outcome } => {
409            bridge.question_response(session_id.into_string(), tool_call_id.into_string(), outcome)
410        }
411    }
412}
413
414/// Decode launch settings off the dispatcher channel. Symmetric to the
415/// encode-side `?`-propagation in `AgentHandle::new_session`.
416///
417/// **A failure refuses the spawn** (Ved, 2026-10-15). The settings carry the
418/// blocked-tools denial (`disallowedTools`) among the rest, so a spawn on
419/// defaults would start a session WIDER than the one that was configured -
420/// silently unblocking tools. The decode runs in the BRIDGE, where a refusal
421/// goes on the same event every other spawn failure does (`ConnectionFailed`),
422/// so the seat records the failure and rolls its row instead of sitting at
423/// Spawning with nothing to say.
424pub(crate) fn decode_launch_settings(
425    value: serde_json::Value,
426) -> anyhow::Result<crate::client::SessionLaunchSettings> {
427    serde_json::from_value(value).map_err(|e| {
428        anyhow::anyhow!(
429            "the launch settings could not be decoded, so the spawn is refused rather than \
430             started with none of them (the blocked-tools denial rides them): {e}"
431        )
432    })
433}
434
435#[cfg(test)]
436#[allow(clippy::unwrap_used, clippy::expect_used)]
437mod decode_launch_settings_tests {
438    use super::decode_launch_settings;
439
440    #[test]
441    fn decodes_the_stamped_settings_intact() {
442        let value = serde_json::json!({
443            "settings": { "model": "haiku" },
444            "agent_progress_summaries": true,
445            "charter": "worker charter",
446            "extra_args": [["disallowedTools", "Bash"]],
447        });
448        let decoded = decode_launch_settings(value).expect("the stamped settings decode");
449        assert_eq!(
450            decoded.settings.as_ref().and_then(|s| s.get("model")).and_then(|v| v.as_str()),
451            Some("haiku"),
452            "the stamped settings survive the decode"
453        );
454        assert_eq!(decoded.agent_progress_summaries, Some(true));
455        assert_eq!(decoded.charter.as_deref(), Some("worker charter"));
456        assert_eq!(
457            decoded.extra_args,
458            vec![("disallowedTools".to_owned(), Some("Bash".to_owned()))],
459            "the blocked-tools denial's carrier survives the decode"
460        );
461    }
462
463    #[test]
464    fn the_dispatch_refuses_rather_than_defaulting() {
465        // **The property #1183 is about, pinned at the SEAM.** A defaulting
466        // arm (`decode_launch_settings(..).unwrap_or_default()`) compiles and
467        // passes every other test while every spawn launches on defaults with
468        // the denial dropped - so the refusal is asserted where it is used,
469        // not only inside the decoder (Ved refused the spawn, 2026-10-15).
470        let command = forge_primitives::AgentCommand::NewSession {
471            session_id: Some("a-test-seat".to_owned()),
472            cwd: "/tmp".to_owned(),
473            launch_settings: serde_json::json!(42),
474        };
475        let bridge = crate::forge_sdk_bridge::ForgeSdkBridge::default();
476        let mut events = bridge.take_events().expect("a fresh bridge yields its events receiver");
477        let error = super::dispatch(command, &bridge)
478            .expect_err("an undecodable payload refuses the dispatch rather than defaulting");
479        assert!(
480            error.to_string().contains("launch settings could not be decoded"),
481            "and the refusal is the decoder's own, not whatever the bridge would say: {error}"
482        );
483        // **And it goes where every spawn failure goes.** The event is what
484        // the seat's task records the failure by and rolls the row on, so a
485        // refused spawn is visible and retryable, not a seat stuck Spawning.
486        match events.try_recv() {
487            Ok(crate::AgentEvent::ConnectionFailed { message, .. }) => assert!(
488                message.contains("refused"),
489                "the event says the spawn was refused: {message}"
490            ),
491            other => panic!("expected a ConnectionFailed for the refused spawn, got {other:?}"),
492        }
493    }
494
495    #[test]
496    fn refuses_the_spawn_on_an_undecodable_value() {
497        // The settings carry the blocked-tools denial, so a decode failure
498        // must not start a session on defaults: it refuses, naming the
499        // denial's carrier (Ved, 2026-10-15).
500        let error = decode_launch_settings(serde_json::json!(42))
501            .expect_err("an undecodable value refuses rather than defaults");
502        assert!(
503            error.to_string().contains("blocked-tools denial"),
504            "the refusal names what the settings carry: {error}"
505        );
506    }
507}