1use 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
23pub struct AgentHandle {
26 pub commands: mpsc::UnboundedSender<AgentCommand>,
31 agent_events: Mutex<Option<mpsc::UnboundedReceiver<crate::client::AgentEvent>>>,
35 bridge: Arc<ForgeSdkBridge>,
38}
39
40impl AgentHandle {
41 pub fn take_events(&self) -> Option<mpsc::UnboundedReceiver<crate::client::AgentEvent>> {
44 self.agent_events.lock().take()
45 }
46
47 pub fn config_dir(&self) -> PathBuf {
51 self.bridge.config_dir()
52 }
53
54 pub fn claude_pid(&self) -> Option<u32> {
58 self.bridge.claude_pid()
59 }
60
61 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 pub fn display_name(&self) -> Option<String> {
90 self.bridge.display_name()
91 }
92
93 #[cfg(any(test, feature = "testing"))]
99 pub fn env(&self) -> std::collections::HashMap<String, String> {
100 self.bridge.env()
101 }
102
103 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 fn send(&self, cmd: AgentCommand) -> Result<(), AgentError> {
131 self.commands.send(cmd).map_err(|_| AgentError::DispatcherShutDown)
132 }
133
134 pub fn new_session(
137 &self,
138 session_id: Option<String>,
139 cwd: String,
140 launch_settings: crate::client::SessionLaunchSettings,
141 ) -> Result<(), AgentError> {
142 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 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#[derive(Debug, thiserror::Error)]
264pub enum AgentError {
265 #[error("agent dispatcher shut down")]
268 DispatcherShutDown,
269 #[error("failed to encode launch settings: {0}")]
272 EncodeFailed(#[from] serde_json::Error),
273 #[error("no active session for {command}")]
276 NoSession { command: &'static str },
277}
278
279pub struct Agent;
281
282impl Agent {
283 #[cfg(any(test, feature = "testing"))]
294 pub fn testing_stub() -> (AgentHandle, mpsc::UnboundedReceiver<AgentCommand>) {
295 let bridge = ForgeSdkBridge::default();
296 let _ = bridge.take_events();
299
300 let (commands_tx, commands_rx) = mpsc::unbounded_channel::<AgentCommand>();
301 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 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 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
371fn 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
414pub(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 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 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 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}