• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 27708733892

17 Jun 2026 05:50PM UTC coverage: 66.631% (+0.06%) from 66.574%
27708733892

Pull #758

github

web-flow
Merge 9e5296bf0 into 4778a5cd3
Pull Request #758: feat(config, cli, inquire): Configurable Ctrl-C interrupt behavior

110 of 122 new or added lines in 7 files covered. (90.16%)

2 existing lines in 2 files now uncovered.

35567 of 53379 relevant lines covered (66.63%)

403.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.68
/crates/jp_cli/src/cmd/query/tool/coordinator.rs
1
//! Tool execution coordination for the query stream pipeline.
2
//!
3
//! The [`ToolCoordinator`] manages parallel execution of multiple tool calls.
4
//!
5
//! # Execution Model
6
//!
7
//! The coordinator uses an **event-driven streaming model** where:
8
//!
9
//! 1. All tools are spawned as independent async tasks
10
//! 2. Results stream in as tools complete (not all at once)
11
//! 3. When a tool needs user input, a prompt is shown while other tools
12
//!    continue running in the background
13
//! 4. After the user answers, the tool is restarted with the accumulated
14
//!    answers
15
//! 5. This continues until all tools have completed
16
//! 6. Results are returned in the original request order
17
//!
18
//! <!-- end list -->
19
//!
20
//! ```text
21
//! ┌───────────────────────────────────────────────────────────────┐
22
//! │                        Event Channel                          │
23
//! │                                                               │
24
//! │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐       │
25
//! │  │ Tool 1   │  │ Tool 2   │  │ Tool 3   │  │ Signal   │       │
26
//! │  │ (spawn)  │  │ (spawn)  │  │ (spawn)  │  │ Stream   │       │
27
//! │  └────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬─────┘       │
28
//! │       │             │             │             │             │
29
//! │       └─────────────┴─────────────┴─────────────┘             │
30
//! │                           │                                   │
31
//! │                           ▼                                   │
32
//! │                    ┌─────────────┐                            │
33
//! │                    │ Event Loop  │◄──────┐                    │
34
//! │                    └──────┬──────┘       │                    │
35
//! │                           │              │                    │
36
//! │         ┌─────────────────┼──────────────┼───────────┐        │
37
//! │         ▼                 ▼              │           ▼        │
38
//! │  ┌────────────┐   ┌────────────┐   ┌─────┴─────┐  ┌────────┐  │
39
//! │  │ Completed  │   │ NeedsInput │   │ Prompt    │  │ Signal │  │
40
//! │  │ → collect  │   │ (User)     │   │ Answer    │  │ Handle │  │
41
//! │  └────────────┘   └─────┬──────┘   │ → restart │  └────────┘  │
42
//! │                         │          └───────────┘              │
43
//! │                         ▼                                     │
44
//! │                  ┌─────────────────┐                          │
45
//! │                  │ spawn_blocking  │                          │
46
//! │                  │ prompt_question │───► sends PromptAnswer   │
47
//! │                  └─────────────────┘                          │
48
//! └───────────────────────────────────────────────────────────────┘
49
//! ```
50
//!
51
//! # Question Handling
52
//!
53
//! When a tool returns `NeedsInput`, the coordinator checks the configuration:
54
//!
55
//! - **User target**: Prompt is shown via `spawn_blocking` (other tools keep
56
//!   running).
57
//!   When answered, the tool is restarted with the answer.
58
//! - **LLM target**: The question is formatted as a response asking the LLM to
59
//!   re-run the tool with the answer.
60
//!   The tool is marked as completed.
61
//! - **Static answer**: Pre-populated before first execution, so the tool
62
//!   should never ask for this input.
63
//!
64
//! # Non-Blocking Prompts
65
//!
66
//! Interactive prompts run on a blocking thread (`spawn_blocking`) so the async
67
//! event loop continues processing other tool results.
68
//! If multiple tools need input, prompts are queued and shown sequentially.
69
//!
70
//! # Thread Safety
71
//!
72
//! [`TurnState`] is wrapped in [`Arc<RwLock<>>`] to allow concurrent access.
73
//! Each executor reads needed state, executes, then writes back results.
74
//!
75
//! # Testing
76
//!
77
//! The coordinator uses the [`Executor`] trait for tool execution.
78

79
use std::{
80
    collections::{HashMap, VecDeque},
81
    sync::Arc,
82
    time::Duration,
83
};
84

85
use camino::{Utf8Path, Utf8PathBuf};
86
use indexmap::IndexMap;
87
use jp_config::{
88
    conversation::tool::{
89
        FormatMode, QuestionTarget, ResultMode, RunMode, ToolsConfig, style::ParametersStyle,
90
    },
91
    interrupt::ToolInterruptConfig,
92
};
93
use jp_conversation::{
94
    ConversationStream,
95
    event::{
96
        InquiryAnswerType, InquiryQuestion, InquiryRequest, InquiryResponse, InquirySource,
97
        SelectOption, ToolCallRequest, ToolCallResponse,
98
    },
99
};
100
use jp_inquire::prompt::PromptBackend;
101
use jp_llm::tool::executor::{Executor, ExecutorResult, ExecutorSource, PermissionInfo};
102
use jp_mcp::Client;
103
use jp_printer::Printer;
104
use jp_tool::{AnswerType, Question};
105
use jp_workspace::ConversationMut;
106
use serde_json::{Map, Value};
107
use tokio::sync::{broadcast, mpsc};
108
use tokio_util::sync::CancellationToken;
109
use tracing::warn;
110

111
use super::{
112
    ToolRenderer,
113
    inquiry::{self, InquiryBackend},
114
    prompter::{PermissionResult, ToolPrompter, permission_inquiry_id, tool_question_inquiry_id},
115
};
116
use crate::{
117
    cmd::query::turn::{TurnCoordinator, state::TurnState},
118
    render::tool::RenderOutcome,
119
};
120

121
#[derive(Debug)]
122
enum ExecutionEvent {
123
    ToolResult {
124
        index: usize,
125
        result: ExecutorResult,
126
    },
127

128
    PromptAnswer {
129
        index: usize,
130
        question_id: String,
131
        answer: Value,
132
        persist_level: jp_tool::PersistLevel,
133
    },
134

135
    PromptCancelled {
136
        index: usize,
137
    },
138

139
    /// Result of a structured inquiry (LLM answering a tool question).
140
    InquiryResult {
141
        index: usize,
142
        question_id: String,
143
        question_text: String,
144
        result: Result<Value, String>,
145
    },
146

147
    ResultModeProcessed {
148
        index: usize,
149
        tool_id: String,
150
        response: ToolCallResponse,
151
    },
152

153
    Signal(crate::signals::SignalTo),
154

155
    ProgressTick {
156
        elapsed: Duration,
157
    },
158
}
159

160
#[derive(Debug)]
161
pub struct ExecutionResult {
162
    /// Tool responses paired with the plan index supplied by the caller in
163
    /// `executors`.
164
    /// Indices may be sparse when the caller's plan also contains pre-resolved
165
    /// tools that bypass execution; merging those back into the original stream
166
    /// order is the caller's job.
167
    pub responses: Vec<(usize, ToolCallResponse)>,
168
    pub restart_requested: bool,
169
}
170

171
struct ExecutingTool {
172
    executor: Arc<dyn Executor>,
173
    tool_id: String,
174
    tool_name: String,
175
    accumulated_answers: IndexMap<String, Value>,
176
}
177

178
#[derive(Debug)]
179
enum PendingPrompt {
180
    Question {
181
        index: usize,
182
        question: Question,
183
    },
184
    ResultMode {
185
        index: usize,
186
        tool_id: String,
187
        tool_name: String,
188
        response: ToolCallResponse,
189
        result_mode: ResultMode,
190
    },
191
}
192

193
/// Result of [`ToolCoordinator::decide_permission`] for a single tool.
194
pub enum PermissionDecision {
195
    /// Tool can run immediately (unattended, persisted approval, non-TTY).
196
    Approved(Box<dyn Executor>),
197
    /// Tool should not run (persisted skip).
198
    Skipped(ToolCallResponse),
199
    /// Requires an interactive user prompt before deciding.
200
    NeedsPrompt {
201
        executor: Box<dyn Executor>,
202
        info: PermissionInfo,
203
    },
204
}
205

206
/// Final outcome of [`ToolCoordinator::resolve_tool_call_decision`] — the
207
/// per-tool permission pipeline.
208
///
209
/// This wraps the full decide → pre-render → prompt → apply → post-render
210
/// flow into one of three terminal states.
211
/// Callers map this into their own storage shape (see the streaming path in
212
/// `turn_loop.rs` and the batch path in
213
/// [`ToolCoordinator::run_permission_phase`]).
214
pub enum ToolCallDecision {
215
    /// Tool is approved and ready to be queued for execution.
216
    /// Includes any rendered argument content from the formatter — the caller
217
    /// is responsible for persisting it (typically into a `ToolCallRequest`
218
    /// event's metadata).
219
    Approved {
220
        executor: Box<dyn Executor>,
221
        rendered_arguments: Option<String>,
222
    },
223
    /// Tool was skipped: persisted "n", `RunMode::Skip`, or user declined at
224
    /// the prompt.
225
    /// The response is the synthesized skip message ready to be appended to the
226
    /// conversation stream.
227
    Skipped(ToolCallResponse),
228
    /// Tool failed before it could run — typically because a custom-format
229
    /// formatter command errored.
230
    /// The response tells the LLM the tool was not executed and may be retried.
231
    Failed(ToolCallResponse),
232
}
233

234
#[derive(Debug, Clone, PartialEq, Eq)]
235
pub enum ToolCallState {
236
    ReceivingArguments { name: String },
237
    Queued,
238
    AwaitingPermission,
239
    Running,
240
    AwaitingInput,
241
    AwaitingResultEdit,
242
    Completed,
243
}
244

245
impl ToolCallState {
246
    #[must_use]
247
    pub fn is_prompting(&self) -> bool {
18✔
248
        matches!(
11✔
249
            self,
18✔
250
            Self::AwaitingPermission | Self::AwaitingInput | Self::AwaitingResultEdit
251
        )
252
    }
18✔
253
}
254

255
/// Converts a `jp_tool::Question` into an `InquiryQuestion` for recording in
256
/// the conversation stream.
257
fn tool_question_to_inquiry_question(q: &Question) -> InquiryQuestion {
7✔
258
    let answer_type = match &q.answer_type {
7✔
259
        AnswerType::Boolean => InquiryAnswerType::Boolean,
6✔
260
        AnswerType::Select { options } => InquiryAnswerType::Select {
×
261
            options: options
×
262
                .iter()
×
263
                .map(|o| SelectOption::from(o.as_str()))
×
264
                .collect(),
×
265
        },
266
        AnswerType::Text => InquiryAnswerType::Text,
1✔
267
    };
268

269
    let mut iq = InquiryQuestion::new(q.text.clone(), answer_type);
7✔
270
    if let Some(default) = &q.default {
7✔
271
        iq = iq.with_default(default.clone());
×
272
    }
7✔
273

274
    iq
7✔
275
}
7✔
276

277
pub struct ToolCoordinator {
278
    executors: Vec<(usize, Box<dyn Executor>)>,
279
    tool_states: HashMap<String, ToolCallState>,
280
    tools_config: ToolsConfig,
281
    interrupt_config: ToolInterruptConfig,
282
    executor_source: Box<dyn ExecutorSource>,
283
    cancellation_token: CancellationToken,
284
    /// Rendered custom argument output accumulated during the permission phase.
285
    /// Keyed by tool call ID.
286
    /// Drained by the turn loop to write into event metadata.
287
    rendered_arguments: HashMap<String, String>,
288
}
289

290
impl ToolCoordinator {
291
    pub fn new(tools_config: ToolsConfig, executor_source: Box<dyn ExecutorSource>) -> Self {
52✔
292
        Self {
52✔
293
            executors: Vec::new(),
52✔
294
            tool_states: HashMap::new(),
52✔
295
            tools_config,
52✔
296
            interrupt_config: ToolInterruptConfig::default(),
52✔
297
            executor_source,
52✔
298
            cancellation_token: CancellationToken::new(),
52✔
299
            rendered_arguments: HashMap::new(),
52✔
300
        }
52✔
301
    }
52✔
302

303
    /// Set the Ctrl-C behavior while tools are executing.
304
    ///
305
    /// Defaults to showing the interrupt menu.
306
    #[must_use]
NEW
307
    pub fn with_interrupt(mut self, config: ToolInterruptConfig) -> Self {
×
NEW
308
        self.interrupt_config = config;
×
NEW
309
        self
×
NEW
310
    }
×
311

312
    /// Drain accumulated rendered argument content.
313
    ///
314
    /// Returns `(tool_call_id, rendered_content)` pairs collected during the
315
    /// permission phase.
316
    /// The caller writes these into event metadata.
317
    pub fn drain_rendered_arguments(&mut self) -> HashMap<String, String> {
22✔
318
        std::mem::take(&mut self.rendered_arguments)
22✔
319
    }
22✔
320

321
    pub fn is_prompting(&self) -> bool {
10✔
322
        self.tool_states.values().any(ToolCallState::is_prompting)
10✔
323
    }
10✔
324

325
    pub(crate) fn set_tool_state(&mut self, tool_id: impl Into<String>, state: ToolCallState) {
132✔
326
        self.tool_states.insert(tool_id.into(), state);
132✔
327
    }
132✔
328

329
    fn clear_tool_states(&mut self) {
×
330
        self.tool_states.clear();
×
331
    }
×
332

333
    pub fn parameter_style(&self, tool_name: &str) -> ParametersStyle {
28✔
334
        self.tools_config
28✔
335
            .get(tool_name)
28✔
336
            .map(|c| c.style().parameters.clone())
28✔
337
            .unwrap_or_default()
28✔
338
    }
28✔
339

340
    /// Return the format mode for a tool, falling back to `Ask` if the tool is
341
    /// unknown (untrusted-by-default).
342
    pub fn format_mode(&self, tool_name: &str) -> FormatMode {
3✔
343
        self.tools_config
3✔
344
            .get(tool_name)
3✔
345
            .map_or(FormatMode::Ask, |c| c.format())
3✔
346
    }
3✔
347

348
    /// Pre-render a tool call ahead of its approval prompt.
349
    ///
350
    /// Built-in parameter styles ([`ParametersStyle::Json`],
351
    /// [`ParametersStyle::FunctionCall`], [`ParametersStyle::Off`]) always
352
    /// pre-render: they are pure transformations of the arguments map and the
353
    /// user needs to see the rendered call to make an informed approval
354
    /// decision.
355
    ///
356
    /// [`ParametersStyle::Custom`] shells out to a user-configured command and
357
    /// is gated by [`FormatMode`]: it only pre-renders when the tool opts in
358
    /// via `format = "unattended"`; otherwise rendering is deferred until after
359
    /// approval.
360
    ///
361
    /// Returns:
362
    ///
363
    /// - `Ok(Some(content))` if pre-render fired successfully — caller should
364
    ///   skip the post-approval render and use this content.
365
    /// - `Ok(None)` if pre-render was suppressed (Custom style with `format =
366
    ///   "ask"`) — caller should follow the existing post-approval render
367
    ///   path.
368
    /// - `Err(error_message)` if a custom formatter command failed — caller
369
    ///   should treat this as a tool failure and skip prompting.
370
    pub(crate) async fn pre_render_for_prompt(
7✔
371
        &self,
7✔
372
        tool_name: &str,
7✔
373
        arguments: &Map<String, Value>,
7✔
374
        tool_renderer: &ToolRenderer,
7✔
375
    ) -> Result<Option<Option<String>>, String> {
7✔
376
        // `FormatMode::Ask` exists to defer side-effecting *custom*
377
        // formatters until after approval — running a user-configured
378
        // shell command before the user okays the tool would be
379
        // surprising. Built-in styles are pure and have no side effects,
380
        // so they always render before the prompt.
381
        let should_pre_render = match self.parameter_style(tool_name) {
7✔
382
            ParametersStyle::Custom(_) => {
383
                matches!(self.format_mode(tool_name), FormatMode::Unattended)
1✔
384
            }
385
            ParametersStyle::Json | ParametersStyle::FunctionCall | ParametersStyle::Off => true,
6✔
386
        };
387

388
        if !should_pre_render {
7✔
389
            return Ok(None);
1✔
390
        }
6✔
391

392
        match self
6✔
393
            .render_approved_tool(tool_name, arguments, tool_renderer)
6✔
394
            .await
6✔
395
        {
396
            RenderOutcome::Rendered { content } => Ok(Some(content)),
6✔
397
            RenderOutcome::Suppressed { error } => Err(error),
×
398
        }
399
    }
7✔
400

401
    /// Single-tool permission pipeline.
402
    ///
403
    /// Encapsulates the full decide → pre-render → prompt → apply →
404
    /// post-render flow.
405
    /// Returns a [`ToolCallDecision`] that the caller maps to its storage
406
    /// shape.
407
    ///
408
    /// This is the seam where new permission-related features should land:
409
    /// telemetry, sandboxing decisions, alternate prompting modes — anything
410
    /// that needs to apply uniformly to both the streaming path (in
411
    /// `turn_loop.rs`) and the batch/restart path
412
    /// ([`Self::run_permission_phase`]).
413
    /// Both paths funnel through here, so changes don't drift between sites.
414
    ///
415
    /// # Pipeline steps
416
    ///
417
    /// 1. [`Self::decide_permission`] resolves the executor's run mode against
418
    ///    persisted answers and TTY availability.
419
    /// 2. If the decision is `NeedsPrompt`, pre-render the call via
420
    ///    [`Self::pre_render_for_prompt`] (always for built-in parameter
421
    ///    styles; only when `format = "unattended"` for `Custom` formatters),
422
    ///    then prompt the user via [`ToolPrompter::prompt_permission`], then
423
    ///    apply the result via [`Self::apply_permission_result`].
424
    ///    If the user edited the arguments at the prompt, the pre-render is
425
    ///    discarded so step 3 re-renders with the args that will actually
426
    ///    execute.
427
    /// 3. For approved tools, render the call (skipping if pre-rendered).
428
    /// 4. Return [`ToolCallDecision::Approved`], `Skipped`, or `Failed`.
429
    pub(crate) async fn resolve_tool_call_decision(
19✔
430
        &mut self,
19✔
431
        executor: Box<dyn Executor>,
19✔
432
        prompter: &ToolPrompter,
19✔
433
        is_tty: bool,
19✔
434
        turn_state: &mut TurnState,
19✔
435
        tool_renderer: &ToolRenderer,
19✔
436
    ) -> ToolCallDecision {
19✔
437
        // Step 1: decide.
438
        let decision = self.decide_permission(executor, is_tty, turn_state);
19✔
439

440
        // Step 2: handle prompt path. After this match, `executor` is
441
        // approved and `pre_rendered` is `Some(content)` if pre-rendering
442
        // already happened, `None` if a post-render is still needed.
443
        let (executor, pre_rendered) = match decision {
19✔
444
            PermissionDecision::Approved(executor) => (executor, None),
14✔
445
            PermissionDecision::Skipped(response) => {
×
446
                return ToolCallDecision::Skipped(response);
×
447
            }
448
            PermissionDecision::NeedsPrompt { executor, info } => {
5✔
449
                self.set_tool_state(&info.tool_id, ToolCallState::AwaitingPermission);
5✔
450

451
                // Pre-render before the prompt so the user sees the
452
                // rendered call (not raw arguments) when deciding.
453
                // Built-in parameter styles always pre-render; Custom
454
                // formatters are gated on `format = "unattended"`
455
                // because they shell out to a user-controlled command.
456
                let pre = match self
5✔
457
                    .pre_render_for_prompt(&info.tool_name, executor.arguments(), tool_renderer)
5✔
458
                    .await
5✔
459
                {
460
                    Ok(maybe_content) => maybe_content,
5✔
461
                    Err(error) => {
×
462
                        return ToolCallDecision::Failed(Self::render_failed_response(
×
463
                            info.tool_id.clone(),
×
464
                            &info.tool_name,
×
465
                            &error,
×
466
                        ));
×
467
                    }
468
                };
469

470
                // Snapshot the args we just rendered so we can detect a
471
                // user edit. If `e` changes the arguments, the pre-render
472
                // reflects pre-edit values and would diverge from what
473
                // actually executes — drop it so step 3 re-renders with
474
                // the post-edit args.
475
                let pre_edit_args = executor.arguments().clone();
5✔
476

477
                let result = prompter.prompt_permission(&info);
5✔
478
                match self.apply_permission_result(result, &info, turn_state, executor) {
5✔
479
                    Ok(executor) => {
3✔
480
                        let pre = if executor.arguments() == &pre_edit_args {
3✔
481
                            pre
2✔
482
                        } else {
483
                            None
1✔
484
                        };
485
                        (executor, pre)
3✔
486
                    }
487
                    Err(response) => return ToolCallDecision::Skipped(response),
2✔
488
                }
489
            }
490
        };
491

492
        // Step 3: render. If pre-rendered, use that; otherwise render now.
493
        let rendered_arguments = if let Some(pre) = pre_rendered {
17✔
494
            pre
2✔
495
        } else {
496
            let tool_name = executor.tool_name().to_owned();
15✔
497
            let args = executor.arguments().clone();
15✔
498
            match self
15✔
499
                .render_approved_tool(&tool_name, &args, tool_renderer)
15✔
500
                .await
15✔
501
            {
502
                RenderOutcome::Rendered { content } => content,
15✔
503
                RenderOutcome::Suppressed { error } => {
×
504
                    let id = executor.tool_id().to_owned();
×
505
                    return ToolCallDecision::Failed(Self::render_failed_response(
×
506
                        id, &tool_name, &error,
×
507
                    ));
×
508
                }
509
            }
510
        };
511

512
        ToolCallDecision::Approved {
17✔
513
            executor,
17✔
514
            rendered_arguments,
17✔
515
        }
17✔
516
    }
19✔
517

518
    pub fn question_target(&self, tool_name: &str, question_id: &str) -> Option<QuestionTarget> {
10✔
519
        self.tools_config
10✔
520
            .get(tool_name)
10✔
521
            .and_then(|config| config.question_target(question_id).cloned())
10✔
522
    }
10✔
523

524
    pub fn static_answer(&self, tool_name: &str, question_id: &str) -> Option<serde_json::Value> {
11✔
525
        self.tools_config.get(tool_name).and_then(|config| {
11✔
526
            config
10✔
527
                .questions()
10✔
528
                .get(question_id)
10✔
529
                .and_then(|q| q.answer.clone())
10✔
530
        })
10✔
531
    }
11✔
532

533
    pub fn static_answers_for_tool(
18✔
534
        &self,
18✔
535
        tool_name: &str,
18✔
536
    ) -> indexmap::IndexMap<String, serde_json::Value> {
18✔
537
        let mut answers = indexmap::IndexMap::new();
18✔
538
        if let Some(config) = self.tools_config.get(tool_name) {
18✔
539
            for (question_id, question_config) in config.questions() {
17✔
540
                if let Some(answer) = &question_config.answer {
10✔
541
                    answers.insert(question_id.clone(), answer.clone());
2✔
542
                }
8✔
543
            }
544
        }
1✔
545
        answers
18✔
546
    }
18✔
547

548
    /// Returns pre-configured static answers for a tool's questions.
549
    ///
550
    /// These are answers set in the tool configuration (e.g.
551
    /// `questions.confirm.answer = true`) that bypass both user prompts and LLM
552
    /// inquiries.
553
    pub(crate) fn static_answers_for_all_questions(
16✔
554
        &self,
16✔
555
        tool_name: &str,
16✔
556
    ) -> IndexMap<String, Value> {
16✔
557
        self.static_answers_for_tool(tool_name)
16✔
558
    }
16✔
559

560
    pub fn is_hidden(&self, tool_name: &str) -> bool {
21✔
561
        self.tools_config
21✔
562
            .get(tool_name)
21✔
563
            .is_some_and(|cfg| cfg.style().hidden)
21✔
564
    }
21✔
565

566
    pub fn result_mode(&self, tool_name: &str) -> ResultMode {
18✔
567
        self.tools_config
18✔
568
            .get(tool_name)
18✔
569
            .map(|config| config.result())
18✔
570
            .unwrap_or_default()
18✔
571
    }
18✔
572

573
    #[allow(dead_code)]
574
    pub fn cancel(&self) {
2✔
575
        self.cancellation_token.cancel();
2✔
576
    }
2✔
577

578
    /// Resets internal state for a new execution cycle.
579
    ///
580
    /// Call this when the streaming phase has already prepared executors and
581
    /// decided permissions, so the executing phase starts with a fresh
582
    /// cancellation token.
583
    pub fn reset_for_execution(&mut self) {
22✔
584
        self.cancellation_token = CancellationToken::new();
22✔
585
    }
22✔
586

587
    /// Prepares executors for the given tool call requests.
588
    ///
589
    /// Tools that cannot be resolved (e.g. missing from config or definitions)
590
    /// are returned as pre-built error responses rather than failing the entire
591
    /// batch.
592
    pub fn prepare(&mut self, requests: Vec<ToolCallRequest>) -> Vec<(usize, ToolCallResponse)> {
×
593
        self.executors.clear();
×
594
        self.clear_tool_states();
×
595
        self.cancellation_token = CancellationToken::new();
×
596

597
        let mut unavailable = Vec::new();
×
598
        for (index, request) in requests.into_iter().enumerate() {
×
599
            match self.prepare_one(request) {
×
600
                Ok(executor) => self.executors.push((index, executor)),
×
601
                Err(response) => unavailable.push((index, response)),
×
602
            }
603
        }
604

605
        unavailable
×
606
    }
×
607

608
    /// Prepares a single executor for a tool call request.
609
    ///
610
    /// Returns the executor on success, or an error response if the tool cannot
611
    /// be resolved (e.g. missing from config or definitions).
612
    pub fn prepare_one(
28✔
613
        &mut self,
28✔
614
        request: ToolCallRequest,
28✔
615
    ) -> Result<Box<dyn Executor>, ToolCallResponse> {
28✔
616
        self.tool_states
28✔
617
            .insert(request.id.clone(), ToolCallState::Queued);
28✔
618

619
        if let Some(executor) = self
28✔
620
            .tools_config
28✔
621
            .get(&request.name)
28✔
622
            .and_then(|config| self.executor_source.create(request.clone(), config))
28✔
623
        {
624
            return Ok(executor);
18✔
625
        }
10✔
626

627
        warn!(tool = %request.name, "Tool not available, returning error to LLM");
10✔
628
        self.set_tool_state(&request.id, ToolCallState::Completed);
10✔
629
        Err(ToolCallResponse {
10✔
630
            id: request.id,
10✔
631
            result: Err(format!(
10✔
632
                "Tool '{}' is not available. It may have been available earlier in this \
10✔
633
                 conversation but is no longer enabled. Do not retry this tool until it it is \
10✔
634
                 available again in the list of enabled tools.",
10✔
635
                request.name,
10✔
636
            )),
10✔
637
        })
10✔
638
    }
28✔
639

640
    /// Renders the tool call header and arguments after permission approval.
641
    ///
642
    /// For non-Custom styles: prints the header with inline-formatted
643
    /// arguments.
644
    /// For Custom style: runs the custom formatter command, then prints header
645
    ///
646
    /// - custom output atomically.
647
    ///   If the custom formatter fails, nothing is printed and
648
    ///   [`RenderOutcome::Suppressed`] is returned — the caller should abort
649
    ///   execution and return an error response to the LLM.
650
    ///   For hidden tools: renders nothing but returns `Rendered` (hidden tools
651
    ///   still execute).
652
    pub(crate) async fn render_approved_tool(
21✔
653
        &self,
21✔
654
        tool_name: &str,
21✔
655
        arguments: &serde_json::Map<String, Value>,
21✔
656
        tool_renderer: &ToolRenderer,
21✔
657
    ) -> RenderOutcome {
21✔
658
        if self.is_hidden(tool_name) {
21✔
659
            return RenderOutcome::Rendered { content: None };
×
660
        }
21✔
661

662
        let style = self.parameter_style(tool_name);
21✔
663
        tool_renderer
21✔
664
            .render_approved(tool_name, arguments, &style)
21✔
665
            .await
21✔
666
    }
21✔
667

668
    /// Determines permission for a single tool without blocking on user input.
669
    ///
670
    /// Does NOT render any output.
671
    /// Rendering happens after the permission decision via
672
    /// [`render_approved_tool`].
673
    ///
674
    /// Returns one of:
675
    ///
676
    /// - `Approved` — tool can run immediately (unattended, persisted "y",
677
    ///   non-interactive)
678
    /// - `Skipped` — tool should not run (persisted "n")
679
    /// - `NeedsPrompt` — requires an interactive user prompt
680
    ///
681
    /// [`render_approved_tool`]: Self::render_approved_tool
682
    pub fn decide_permission(
19✔
683
        &mut self,
19✔
684
        executor: Box<dyn Executor>,
19✔
685
        is_tty: bool,
19✔
686
        turn_state: &TurnState,
19✔
687
    ) -> PermissionDecision {
19✔
688
        let Some(info) = executor.permission_info() else {
19✔
689
            return PermissionDecision::Approved(executor);
14✔
690
        };
691

692
        if !is_tty && matches!(info.run_mode, RunMode::Ask | RunMode::Edit) {
5✔
693
            self.set_tool_state(&info.tool_id, ToolCallState::Running);
×
694
            return PermissionDecision::Approved(executor);
×
695
        }
5✔
696

697
        // Check for a persisted permission decision from earlier in this turn.
698
        let permission_id = permission_inquiry_id(&info.tool_name);
5✔
699
        let persisted = turn_state
5✔
700
            .persisted_inquiry_responses
5✔
701
            .get(&permission_id)
5✔
702
            .and_then(|r| r.answer.as_str())
5✔
703
            .map(str::to_owned);
5✔
704

705
        if let Some(ref decision) = persisted {
5✔
706
            match decision.as_str() {
×
707
                "y" | "Y" => {
×
708
                    self.set_tool_state(&info.tool_id, ToolCallState::Running);
×
709
                    return PermissionDecision::Approved(executor);
×
710
                }
711
                "n" | "N" => {
×
712
                    self.set_tool_state(&info.tool_id, ToolCallState::Completed);
×
713
                    return PermissionDecision::Skipped(ToolCallResponse {
×
714
                        id: info.tool_id.clone(),
×
715
                        result: Ok("Tool skipped by user (remembered).".to_string()),
×
716
                    });
×
717
                }
718
                _ => {} // Unknown value, fall through to prompt
×
719
            }
720
        }
5✔
721

722
        PermissionDecision::NeedsPrompt { executor, info }
5✔
723
    }
19✔
724

725
    /// Applies the result of an interactive permission prompt.
726
    ///
727
    /// Call this after the user answers a prompt for a tool returned as
728
    /// [`PermissionDecision::NeedsPrompt`].
729
    pub fn apply_permission_result(
5✔
730
        &mut self,
5✔
731
        result: Result<PermissionResult, crate::error::Error>,
5✔
732
        info: &PermissionInfo,
5✔
733
        turn_state: &mut TurnState,
5✔
734
        mut executor: Box<dyn Executor>,
5✔
735
    ) -> Result<Box<dyn Executor>, ToolCallResponse> {
5✔
736
        let permission_id = permission_inquiry_id(&info.tool_name);
5✔
737

738
        match result {
5✔
739
            Ok(PermissionResult::Run { arguments, persist }) => {
3✔
740
                if persist {
3✔
741
                    turn_state.persisted_inquiry_responses.insert(
×
742
                        permission_id.clone(),
×
743
                        InquiryResponse::select(permission_id, "y"),
×
744
                    );
×
745
                }
3✔
746
                executor.set_arguments(arguments);
3✔
747
                self.set_tool_state(&info.tool_id, ToolCallState::Running);
3✔
748
                Ok(executor)
3✔
749
            }
750
            Ok(PermissionResult::Skip { reason, persist }) => {
2✔
751
                if persist {
2✔
752
                    turn_state.persisted_inquiry_responses.insert(
×
753
                        permission_id.clone(),
×
754
                        InquiryResponse::select(permission_id, "n"),
×
755
                    );
×
756
                }
2✔
757
                self.set_tool_state(&info.tool_id, ToolCallState::Completed);
2✔
758
                let msg = if let Some(r) = reason {
2✔
759
                    format!("Tool skipped by user: {r}")
×
760
                } else {
761
                    "Tool skipped by user.".to_string()
2✔
762
                };
763
                Err(ToolCallResponse {
2✔
764
                    id: info.tool_id.clone(),
2✔
765
                    result: Ok(msg),
2✔
766
                })
2✔
767
            }
768
            Err(e) => {
×
769
                self.set_tool_state(&info.tool_id, ToolCallState::Completed);
×
770
                Err(ToolCallResponse {
×
771
                    id: info.tool_id.clone(),
×
772
                    result: Err(format!("Permission prompt failed: {e}")),
×
773
                })
×
774
            }
775
        }
776
    }
5✔
777

778
    pub async fn run_permission_phase(
×
779
        &mut self,
×
780
        prompter: &ToolPrompter,
×
781
        is_tty: bool,
×
782
        turn_state: &mut TurnState,
×
783
        tool_renderer: &ToolRenderer,
×
784
    ) -> (
×
785
        Vec<(usize, Box<dyn Executor>)>,
×
786
        Vec<(usize, ToolCallResponse)>,
×
787
    ) {
×
788
        let mut approved_executors = Vec::new();
×
789
        let mut skipped_responses = Vec::new();
×
790

791
        for (index, executor) in std::mem::take(&mut self.executors) {
×
792
            // Funnel through the unified per-tool permission pipeline. The
793
            // streaming path in `turn_loop.rs` uses the same call so the
794
            // decide → pre-render → prompt → render policy stays in one
795
            // place.
796
            let decision = self
×
797
                .resolve_tool_call_decision(executor, prompter, is_tty, turn_state, tool_renderer)
×
798
                .await;
×
799

800
            match decision {
×
801
                ToolCallDecision::Approved {
802
                    executor,
×
803
                    rendered_arguments,
×
804
                } => {
805
                    if let Some(content) = rendered_arguments {
×
806
                        self.rendered_arguments
×
807
                            .insert(executor.tool_id().to_owned(), content);
×
808
                    }
×
809
                    approved_executors.push((index, executor));
×
810
                }
811
                ToolCallDecision::Skipped(response) | ToolCallDecision::Failed(response) => {
×
812
                    skipped_responses.push((index, response));
×
813
                }
×
814
            }
815
        }
816

817
        (approved_executors, skipped_responses)
×
818
    }
×
819

820
    #[allow(clippy::too_many_arguments)]
821
    #[allow(clippy::too_many_lines)]
822
    pub async fn execute_with_prompting(
22✔
823
        &mut self,
22✔
824
        executors: Vec<(usize, Box<dyn Executor>)>,
22✔
825
        prompter: Arc<ToolPrompter>,
22✔
826
        mut signal_rx: broadcast::Receiver<crate::signals::SignalTo>,
22✔
827
        turn_coordinator: &mut TurnCoordinator,
22✔
828
        turn_state: &mut TurnState,
22✔
829
        printer: &Printer,
22✔
830
        prompt_backend: &dyn PromptBackend,
22✔
831
        inquiry_backend: Arc<dyn InquiryBackend>,
22✔
832
        conv: &ConversationMut,
22✔
833
        mcp_client: &Client,
22✔
834
        root: &Utf8Path,
22✔
835
        tool_renderer: &ToolRenderer,
22✔
836
        is_tty: bool,
22✔
837
    ) -> ExecutionResult {
22✔
838
        if executors.is_empty() {
22✔
839
            return ExecutionResult {
10✔
840
                responses: Vec::new(),
10✔
841
                restart_requested: false,
10✔
842
            };
10✔
843
        }
12✔
844

845
        // The caller's `index` values come from the execution plan and may
846
        // be sparse (e.g. when some tools in the same plan are
847
        // pre-resolved and don't reach this function). We can't use them
848
        // as offsets into a `Vec` sized to `executors.len()`, so we
849
        // re-base to contiguous local indices for internal bookkeeping
850
        // and pair each response back with its plan index on output.
851
        let plan_indices: Vec<usize> = executors.iter().map(|(idx, _)| *idx).collect();
12✔
852
        let executors: Vec<Box<dyn Executor>> =
12✔
853
            executors.into_iter().map(|(_, exec)| exec).collect();
12✔
854

855
        let total_tools = executors.len();
12✔
856
        let cancellation_token = self.cancellation_token.clone();
12✔
857
        let (event_tx, mut event_rx) = mpsc::channel::<ExecutionEvent>(32);
12✔
858
        let mut executing_tools: HashMap<usize, ExecutingTool> = HashMap::new();
12✔
859
        let mut results: Vec<Option<ToolCallResponse>> = vec![None; total_tools];
12✔
860
        let mut pending_prompts: VecDeque<PendingPrompt> = VecDeque::new();
12✔
861
        let mut prompt_active = false;
12✔
862

863
        for (index, executor) in executors.into_iter().enumerate() {
16✔
864
            let tool_id = executor.tool_id().to_string();
16✔
865
            let tool_name = executor.tool_name().to_string();
16✔
866
            let accumulated_answers = self.static_answers_for_all_questions(&tool_name);
16✔
867

16✔
868
            let executor: Arc<dyn Executor> = Arc::from(executor);
16✔
869

16✔
870
            executing_tools.insert(index, ExecutingTool {
16✔
871
                executor: Arc::clone(&executor),
16✔
872
                tool_id: tool_id.clone(),
16✔
873
                tool_name: tool_name.clone(),
16✔
874
                accumulated_answers: accumulated_answers.clone(),
16✔
875
            });
16✔
876

16✔
877
            self.set_tool_state(&tool_id, ToolCallState::Running);
16✔
878

16✔
879
            Self::spawn_tool_execution(
16✔
880
                index,
16✔
881
                executor,
16✔
882
                accumulated_answers,
16✔
883
                mcp_client.clone(),
16✔
884
                root.to_path_buf(),
16✔
885
                cancellation_token.child_token(),
16✔
886
                event_tx.clone(),
16✔
887
            );
16✔
888
        }
16✔
889

890
        let signal_tx = event_tx.clone();
12✔
891
        tokio::spawn(async move {
12✔
892
            while let Ok(signal) = signal_rx.recv().await {
12✔
893
                if signal_tx
×
894
                    .send(ExecutionEvent::Signal(signal))
×
895
                    .await
×
896
                    .is_err()
×
897
                {
898
                    break;
×
899
                }
×
900
            }
901
        });
×
902

903
        let progress_config = tool_renderer.progress_config().clone();
12✔
904
        let mut progress_shown = false;
12✔
905

906
        let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel::<Duration>(1);
12✔
907
        let progress_token = if is_tty {
12✔
908
            let event_tx = event_tx.clone();
4✔
909
            tokio::spawn(async move {
4✔
910
                while let Some(elapsed) = progress_rx.recv().await {
4✔
911
                    if event_tx
×
912
                        .send(ExecutionEvent::ProgressTick { elapsed })
×
913
                        .await
×
914
                        .is_err()
×
915
                    {
916
                        break;
×
917
                    }
×
918
                }
919
            });
4✔
920

921
            crate::timer::spawn_tick_sender(
4✔
922
                progress_tx,
4✔
923
                progress_config.show,
4✔
924
                Duration::from_secs(u64::from(progress_config.delay_secs)),
4✔
925
                Duration::from_millis(u64::from(progress_config.interval_ms)),
4✔
926
            )
927
        } else {
928
            None
8✔
929
        };
930

931
        let mut restart_requested = false;
12✔
932
        let mut cancellation_response: Option<String> = None;
12✔
933
        let mut cancelled_indices: Vec<usize> = Vec::new();
12✔
934

935
        while let Some(event) = event_rx.recv().await {
29✔
936
            let was_prompting = prompt_active;
29✔
937

938
            match event {
29✔
939
                ExecutionEvent::ToolResult { index, result } => {
22✔
940
                    if progress_shown {
22✔
941
                        tool_renderer.clear_progress();
×
942
                        progress_shown = false;
×
943
                    }
22✔
944
                    let Some(tool) = executing_tools.get_mut(&index) else {
22✔
945
                        warn!(index, "Received ToolResult for unknown tool.");
×
946
                        continue;
×
947
                    };
948
                    let response = &mut results[index];
22✔
949
                    self.handle_tool_result(
22✔
950
                        result,
22✔
951
                        tool,
22✔
952
                        index,
22✔
953
                        response,
22✔
954
                        &mut pending_prompts,
22✔
955
                        &mut prompt_active,
22✔
956
                        prompter.clone(),
22✔
957
                        &inquiry_backend,
22✔
958
                        conv,
22✔
959
                        mcp_client,
22✔
960
                        root,
22✔
961
                        &cancellation_token,
22✔
962
                        event_tx.clone(),
22✔
963
                        turn_state,
22✔
964
                        is_tty,
22✔
965
                        tool_renderer,
22✔
966
                    );
967
                }
968
                ExecutionEvent::PromptAnswer {
969
                    index,
×
970
                    question_id,
×
971
                    answer,
×
972
                    persist_level,
×
973
                } => {
×
974
                    self.handle_prompt_answer(
×
975
                        index,
×
976
                        question_id,
×
977
                        answer,
×
978
                        persist_level,
×
979
                        &mut executing_tools,
×
980
                        &mut pending_prompts,
×
981
                        &mut prompt_active,
×
982
                        prompter.clone(),
×
983
                        mcp_client,
×
984
                        root,
×
985
                        &cancellation_token,
×
986
                        event_tx.clone(),
×
987
                        turn_state,
×
988
                    );
×
989
                }
×
990
                ExecutionEvent::InquiryResult {
991
                    index,
7✔
992
                    question_id,
7✔
993
                    question_text,
7✔
994
                    result,
7✔
995
                } => match result {
7✔
996
                    Ok(answer) => {
6✔
997
                        if let Some(tool) = executing_tools.get_mut(&index) {
6✔
998
                            let id = inquiry::tool_call_inquiry_id(&tool.tool_id, &question_id);
6✔
999
                            conv.update_events(|events| {
6✔
1000
                                events
6✔
1001
                                    .current_turn_mut()
6✔
1002
                                    .add_inquiry_response(InquiryResponse::new(id, answer.clone()))
6✔
1003
                                    .build()
6✔
1004
                                    .expect("Invalid ConversationStream state");
6✔
1005
                            });
6✔
1006

1007
                            tool.accumulated_answers.insert(question_id, answer);
6✔
1008
                            self.set_tool_state(&tool.tool_id, ToolCallState::Running);
6✔
1009
                            Self::spawn_tool_execution(
6✔
1010
                                index,
6✔
1011
                                tool.executor.clone(),
6✔
1012
                                tool.accumulated_answers.clone(),
6✔
1013
                                mcp_client.clone(),
6✔
1014
                                root.to_path_buf(),
6✔
1015
                                cancellation_token.child_token(),
6✔
1016
                                event_tx.clone(),
6✔
1017
                            );
1018
                        }
×
1019
                    }
1020
                    Err(error) => match executing_tools.get(&index) {
1✔
1021
                        None => warn!(index, %error, "Received ToolResult for unknown tool."),
×
1022
                        Some(tool) => {
1✔
1023
                            self.set_tool_state(&tool.tool_id, ToolCallState::Completed);
1✔
1024

1✔
1025
                            results[index] = Some(ToolCallResponse {
1✔
1026
                                id: tool.tool_id.clone(),
1✔
1027
                                result: Err(format!(
1✔
1028
                                    "The tool '{}' asked a follow-up question (\"{}\") that was \
1✔
1029
                                     routed to a secondary assistant for resolution, but the \
1✔
1030
                                     secondary assistant failed to provide a valid answer. Error: \
1✔
1031
                                     {}. You may retry the tool call or end the turn.",
1✔
1032
                                    tool.tool_name, question_text, error,
1✔
1033
                                )),
1✔
1034
                            });
1✔
1035
                        }
1✔
1036
                    },
1037
                },
1038
                ExecutionEvent::PromptCancelled { index } => {
×
1039
                    self.handle_prompt_cancelled(
×
1040
                        index,
×
1041
                        &mut executing_tools,
×
1042
                        &mut results,
×
1043
                        &mut pending_prompts,
×
1044
                        &mut prompt_active,
×
1045
                        prompter.clone(),
×
1046
                        event_tx.clone(),
×
1047
                    );
×
1048
                }
×
1049
                ExecutionEvent::ResultModeProcessed {
1050
                    index,
×
1051
                    tool_id,
×
1052
                    response,
×
1053
                } => {
1054
                    if progress_shown {
×
1055
                        tool_renderer.clear_progress();
×
1056
                        progress_shown = false;
×
1057
                    }
×
1058
                    prompt_active = false;
×
1059
                    let tool_name = executing_tools
×
1060
                        .get(&index)
×
1061
                        .map(|t| t.tool_name.clone())
×
1062
                        .unwrap_or_default();
×
1063
                    let is_error = response.result.is_err();
×
1064
                    let (inline_results, results_file_link) = self
×
1065
                        .tools_config
×
1066
                        .get(&tool_name)
×
1067
                        .map(|c| {
×
1068
                            (
×
1069
                                c.style().inline_results(is_error).clone(),
×
1070
                                c.style().results_file_link(is_error).clone(),
×
1071
                            )
×
1072
                        })
×
1073
                        .unwrap_or_default();
×
1074

1075
                    let is_hidden = self
×
1076
                        .tools_config
×
1077
                        .get(&tool_name)
×
1078
                        .is_some_and(|cfg| cfg.style().hidden);
×
1079
                    if !is_hidden {
×
1080
                        tool_renderer.render_result(&response, &inline_results, &results_file_link);
×
1081
                    }
×
1082

1083
                    self.set_tool_state(&tool_id, ToolCallState::Completed);
×
1084
                    results[index] = Some(response);
×
1085
                    self.process_next_prompt(
×
1086
                        &mut pending_prompts,
×
1087
                        &mut prompt_active,
×
1088
                        prompter.clone(),
×
1089
                        &executing_tools,
×
1090
                        event_tx.clone(),
×
1091
                    );
1092
                }
1093
                ExecutionEvent::Signal(signal) => {
×
1094
                    if !prompt_active {
×
1095
                        use crate::cmd::query::interrupt::signals::{
1096
                            ToolSignalResult, handle_tool_signal,
1097
                        };
1098
                        if progress_shown {
×
1099
                            tool_renderer.clear_progress();
×
1100
                            progress_shown = false;
×
1101
                        }
×
1102
                        match handle_tool_signal(
×
1103
                            signal,
×
1104
                            &cancellation_token,
×
1105
                            turn_coordinator,
×
1106
                            self.is_prompting(),
×
1107
                            printer,
×
1108
                            prompt_backend,
×
NEW
1109
                            &self.interrupt_config,
×
1110
                        ) {
×
1111
                            ToolSignalResult::Continue => {}
×
1112
                            ToolSignalResult::Restart => {
×
1113
                                restart_requested = true;
×
1114
                            }
×
1115
                            ToolSignalResult::Cancelled { response } => {
×
1116
                                cancelled_indices = results
×
1117
                                    .iter()
×
1118
                                    .enumerate()
×
1119
                                    .filter(|(_, r)| r.is_none())
×
1120
                                    .map(|(i, _)| i)
×
1121
                                    .collect();
×
1122
                                cancellation_response = Some(response);
×
1123
                            }
1124
                        }
1125
                    }
×
1126
                }
1127
                ExecutionEvent::ProgressTick { elapsed } => {
×
1128
                    if !prompt_active {
×
1129
                        tool_renderer.render_progress(elapsed);
×
1130
                        progress_shown = true;
×
1131
                    }
×
1132
                }
1133
            }
1134

1135
            if !was_prompting && prompt_active && progress_shown {
29✔
1136
                tool_renderer.clear_progress();
×
1137
                progress_shown = false;
×
1138
            }
29✔
1139
            if results.iter().all(Option::is_some) {
29✔
1140
                break;
12✔
1141
            }
17✔
1142
        }
1143

1144
        if let Some(token) = progress_token {
12✔
1145
            token.cancel();
4✔
1146
        }
8✔
1147
        if progress_shown {
12✔
1148
            tool_renderer.clear_progress();
×
1149
        }
12✔
1150

1151
        let mut responses: Vec<(usize, ToolCallResponse)> = plan_indices
12✔
1152
            .into_iter()
12✔
1153
            .zip(results.into_iter().map(|r| {
16✔
1154
                r.unwrap_or_else(|| ToolCallResponse {
16✔
1155
                    id: "unknown".to_string(),
×
1156
                    result: Err("Tool did not complete".to_string()),
×
1157
                })
×
1158
            }))
16✔
1159
            .collect();
12✔
1160

1161
        if let Some(cancel_msg) = cancellation_response {
12✔
1162
            for &i in &cancelled_indices {
×
1163
                if let Some((_, response)) = responses.get_mut(i) {
×
1164
                    response.result = Ok(format!(
×
1165
                        "Tool run cancelled by user with a custom message:\n\n{cancel_msg}"
×
1166
                    ));
×
1167
                }
×
1168
            }
1169
        }
12✔
1170

1171
        ExecutionResult {
12✔
1172
            responses,
12✔
1173
            restart_requested,
12✔
1174
        }
12✔
1175
    }
22✔
1176

1177
    /// Builds an error response for a tool whose argument rendering failed.
1178
    ///
1179
    /// The response tells the LLM the tool was not executed and it may retry.
1180
    pub(crate) fn render_failed_response(
×
1181
        tool_id: String,
×
1182
        tool_name: &str,
×
1183
        error: &str,
×
1184
    ) -> ToolCallResponse {
×
1185
        ToolCallResponse {
×
1186
            id: tool_id,
×
1187
            result: Err(format!(
×
1188
                "Tool '{tool_name}' was not executed because the argument formatter failed: \
×
1189
                 {error}",
×
1190
            )),
×
1191
        }
×
1192
    }
×
1193

1194
    fn spawn_tool_execution(
22✔
1195
        index: usize,
22✔
1196
        executor: Arc<dyn Executor>,
22✔
1197
        answers: IndexMap<String, Value>,
22✔
1198
        client: Client,
22✔
1199
        root: Utf8PathBuf,
22✔
1200
        token: CancellationToken,
22✔
1201
        tx: mpsc::Sender<ExecutionEvent>,
22✔
1202
    ) {
22✔
1203
        tokio::spawn(async move {
22✔
1204
            let result = executor.execute(&answers, &client, &root, token).await;
22✔
1205
            let _err = tx.send(ExecutionEvent::ToolResult { index, result }).await;
22✔
1206
        });
22✔
1207
    }
22✔
1208

1209
    fn spawn_inquiry(
7✔
1210
        index: usize,
7✔
1211
        inquiry_id: String,
7✔
1212
        id: String,
7✔
1213
        tool_name: String,
7✔
1214
        question: Question,
7✔
1215
        backend: Arc<dyn InquiryBackend>,
7✔
1216
        mut events: ConversationStream,
7✔
1217
        cancellation_token: CancellationToken,
7✔
1218
        event_tx: mpsc::Sender<ExecutionEvent>,
7✔
1219
    ) {
7✔
1220
        // Insert a ToolCallResponse into the cloned stream so the LLM sees the
1221
        // tool as "paused". The ID must match the original ToolCallRequest.id
1222
        // so providers can resolve the tool name when converting events to
1223
        // their wire format.
1224
        events
7✔
1225
            .current_turn_mut()
7✔
1226
            .add_tool_call_response(ToolCallResponse {
7✔
1227
                id,
7✔
1228
                result: Ok(format!("Tool paused: {}", question.text)),
7✔
1229
            })
7✔
1230
            .build()
7✔
1231
            .expect("Invalid ConversationStream state");
7✔
1232

1233
        tokio::spawn(async move {
7✔
1234
            let result = backend
7✔
1235
                .inquire(
7✔
1236
                    events,
7✔
1237
                    &inquiry_id,
7✔
1238
                    &tool_name,
7✔
1239
                    &question,
7✔
1240
                    cancellation_token,
7✔
1241
                )
7✔
1242
                .await
7✔
1243
                .map_err(|error| error.to_string());
7✔
1244

1245
            let _err = event_tx
7✔
1246
                .send(ExecutionEvent::InquiryResult {
7✔
1247
                    index,
7✔
1248
                    question_id: question.id,
7✔
1249
                    question_text: question.text,
7✔
1250
                    result,
7✔
1251
                })
7✔
1252
                .await;
7✔
1253
        });
7✔
1254
    }
7✔
1255

1256
    #[allow(clippy::too_many_arguments)]
1257
    #[allow(clippy::too_many_lines)]
1258
    fn handle_tool_result(
22✔
1259
        &mut self,
22✔
1260
        result: ExecutorResult,
22✔
1261
        tool: &mut ExecutingTool,
22✔
1262
        index: usize,
22✔
1263
        tracked_response: &mut Option<ToolCallResponse>,
22✔
1264
        pending_prompts: &mut VecDeque<PendingPrompt>,
22✔
1265
        prompt_active: &mut bool,
22✔
1266
        prompter: Arc<ToolPrompter>,
22✔
1267
        inquiry_backend: &Arc<dyn InquiryBackend>,
22✔
1268
        conv: &ConversationMut,
22✔
1269
        mcp_client: &Client,
22✔
1270
        root: &Utf8Path,
22✔
1271
        cancellation_token: &CancellationToken,
22✔
1272
        event_tx: mpsc::Sender<ExecutionEvent>,
22✔
1273
        turn_state: &mut TurnState,
22✔
1274
        is_tty: bool,
22✔
1275
        tool_renderer: &ToolRenderer,
22✔
1276
    ) {
22✔
1277
        match result {
22✔
1278
            ExecutorResult::Completed(response) => {
15✔
1279
                let is_error = response.result.is_err();
15✔
1280
                let (inline_results, results_file_link) = self
15✔
1281
                    .tools_config
15✔
1282
                    .get(&tool.tool_name)
15✔
1283
                    .map(|c| {
15✔
1284
                        (
15✔
1285
                            c.style().inline_results(is_error).clone(),
15✔
1286
                            c.style().results_file_link(is_error).clone(),
15✔
1287
                        )
15✔
1288
                    })
15✔
1289
                    .unwrap_or_default();
15✔
1290

1291
                match self.result_mode(&tool.tool_name) {
15✔
1292
                    ResultMode::Unattended => {
1293
                        let is_hidden = self
15✔
1294
                            .tools_config
15✔
1295
                            .get(&tool.tool_name)
15✔
1296
                            .is_some_and(|cfg| cfg.style().hidden);
15✔
1297
                        if !is_hidden {
15✔
1298
                            tool_renderer.render_result(
15✔
1299
                                &response,
15✔
1300
                                &inline_results,
15✔
1301
                                &results_file_link,
15✔
1302
                            );
15✔
1303
                        }
15✔
1304
                        self.set_tool_state(&tool.tool_id, ToolCallState::Completed);
15✔
1305
                        *tracked_response = Some(response);
15✔
1306
                    }
1307
                    ResultMode::Skip => {
×
1308
                        self.set_tool_state(&tool.tool_id, ToolCallState::Completed);
×
1309
                        *tracked_response = Some(ToolCallResponse {
×
1310
                            id: response.id,
×
1311
                            result: Ok("Result delivery skipped by configuration.".to_string()),
×
1312
                        });
×
1313
                    }
×
1314
                    result_mode @ (ResultMode::Ask | ResultMode::Edit) => {
×
1315
                        let can_prompt =
×
1316
                            is_tty && (result_mode == ResultMode::Ask || prompter.has_editor());
×
1317
                        if can_prompt {
×
1318
                            if *prompt_active {
×
1319
                                pending_prompts.push_back(PendingPrompt::ResultMode {
×
1320
                                    index,
×
1321
                                    tool_id: tool.tool_id.clone(),
×
1322
                                    tool_name: tool.tool_name.clone(),
×
1323
                                    response,
×
1324
                                    result_mode,
×
1325
                                });
×
1326
                            } else {
×
1327
                                *prompt_active = true;
×
1328
                                self.set_tool_state(
×
1329
                                    &tool.tool_id,
×
1330
                                    ToolCallState::AwaitingResultEdit,
×
1331
                                );
×
1332
                                Self::spawn_result_mode_prompt(
×
1333
                                    index,
×
1334
                                    tool.tool_id.clone(),
×
1335
                                    tool.tool_name.clone(),
×
1336
                                    response,
×
1337
                                    result_mode,
×
1338
                                    prompter,
×
1339
                                    event_tx,
×
1340
                                );
×
1341
                            }
×
1342
                        } else {
1343
                            let is_hidden = self
×
1344
                                .tools_config
×
1345
                                .get(&tool.tool_name)
×
1346
                                .is_some_and(|cfg| cfg.style().hidden);
×
1347
                            if !is_hidden {
×
1348
                                tool_renderer.render_result(
×
1349
                                    &response,
×
1350
                                    &inline_results,
×
1351
                                    &results_file_link,
×
1352
                                );
×
1353
                            }
×
1354
                            self.set_tool_state(&tool.tool_id, ToolCallState::Completed);
×
1355
                            *tracked_response = Some(response);
×
1356
                        }
1357
                    }
1358
                }
1359
            }
1360
            ExecutorResult::NeedsInput {
1361
                tool_id,
7✔
1362
                tool_name,
7✔
1363
                question,
7✔
1364
                accumulated_answers,
7✔
1365
            } => {
1366
                tool.accumulated_answers = accumulated_answers.clone();
7✔
1367

1368
                let question_inq_id = tool_question_inquiry_id(&tool_name, &question.id);
7✔
1369
                let persisted_answer = turn_state
7✔
1370
                    .persisted_inquiry_responses
7✔
1371
                    .get(&question_inq_id)
7✔
1372
                    .map(|r| r.answer.clone());
7✔
1373
                if let Some(answer) = persisted_answer {
7✔
1374
                    tool.accumulated_answers.insert(question.id.clone(), answer);
×
1375
                    Self::spawn_tool_execution(
×
1376
                        index,
×
1377
                        tool.executor.clone(),
×
1378
                        tool.accumulated_answers.clone(),
×
1379
                        mcp_client.clone(),
×
1380
                        root.to_path_buf(),
×
1381
                        cancellation_token.clone(),
×
1382
                        event_tx,
×
1383
                    );
1384
                    return;
×
1385
                }
7✔
1386

1387
                if let Some(answer) = self.static_answer(&tool_name, &question.id) {
7✔
1388
                    tool.accumulated_answers.insert(question.id.clone(), answer);
×
1389
                    Self::spawn_tool_execution(
×
1390
                        index,
×
1391
                        tool.executor.clone(),
×
1392
                        tool.accumulated_answers.clone(),
×
1393
                        mcp_client.clone(),
×
1394
                        root.to_path_buf(),
×
1395
                        cancellation_token.clone(),
×
1396
                        event_tx,
×
1397
                    );
1398
                    return;
×
1399
                }
7✔
1400

1401
                let target = self
7✔
1402
                    .question_target(&tool_name, &question.id)
7✔
1403
                    .unwrap_or(QuestionTarget::User);
7✔
1404

1405
                tracing::info!(
7✔
1406
                    tool_name = %tool_name,
1407
                    tool_id = %tool_id,
1408
                    question_id = %question.id,
1409
                    question_text = %question.text,
1410
                    question_type = ?question.answer_type,
1411
                    target = ?target,
1412
                    is_tty = is_tty,
1413
                    "Tool question received, routing to target",
1414
                );
1415

1416
                if is_tty && target.is_user() {
7✔
1417
                    if *prompt_active {
×
1418
                        pending_prompts.push_back(PendingPrompt::Question { index, question });
×
1419
                    } else {
×
1420
                        *prompt_active = true;
×
1421
                        self.set_tool_state(&tool_id, ToolCallState::AwaitingInput);
×
1422
                        Self::spawn_user_prompt(index, question, prompter.clone(), event_tx);
×
1423
                    }
×
1424
                } else {
1425
                    // Route through the inquiry backend: either the target is
1426
                    // explicitly `Assistant`, or the user can't be prompted
1427
                    // (non-interactive). Record the request in the persisted
1428
                    // stream, then spawn the async inquiry on a cloned
1429
                    // snapshot.
1430
                    let inquiry_id = inquiry::tool_call_inquiry_id(&tool_id, &question.id);
7✔
1431

1432
                    conv.update_events(|events| {
7✔
1433
                        events
7✔
1434
                            .current_turn_mut()
7✔
1435
                            .add_inquiry_request(InquiryRequest::new(
7✔
1436
                                inquiry_id.clone(),
7✔
1437
                                InquirySource::tool(tool_name.clone()),
7✔
1438
                                tool_question_to_inquiry_question(&question),
7✔
1439
                            ))
1440
                            .build()
7✔
1441
                            .expect("Invalid ConversationStream state");
7✔
1442
                    });
7✔
1443

1444
                    Self::spawn_inquiry(
7✔
1445
                        index,
7✔
1446
                        inquiry_id,
7✔
1447
                        tool_id.clone(),
7✔
1448
                        tool_name,
7✔
1449
                        question,
7✔
1450
                        Arc::clone(inquiry_backend),
7✔
1451
                        conv.events().clone(),
7✔
1452
                        cancellation_token.child_token(),
7✔
1453
                        event_tx.clone(),
7✔
1454
                    );
1455
                    self.set_tool_state(&tool_id, ToolCallState::AwaitingInput);
7✔
1456
                }
1457
            }
1458
        }
1459
    }
22✔
1460

1461
    #[allow(clippy::too_many_arguments)]
1462
    fn handle_prompt_answer(
×
1463
        &mut self,
×
1464
        index: usize,
×
1465
        question_id: String,
×
1466
        answer: Value,
×
1467
        persist_level: jp_tool::PersistLevel,
×
1468
        executing_tools: &mut HashMap<usize, ExecutingTool>,
×
1469
        pending_prompts: &mut VecDeque<PendingPrompt>,
×
1470
        prompt_active: &mut bool,
×
1471
        prompter: Arc<ToolPrompter>,
×
1472
        mcp_client: &Client,
×
1473
        root: &Utf8Path,
×
1474
        cancellation_token: &CancellationToken,
×
1475
        event_tx: mpsc::Sender<ExecutionEvent>,
×
1476
        turn_state: &mut TurnState,
×
1477
    ) {
×
1478
        *prompt_active = false;
×
1479
        if let Some(tool) = executing_tools.get_mut(&index) {
×
1480
            if persist_level == jp_tool::PersistLevel::Turn {
×
1481
                let inq_id = tool_question_inquiry_id(&tool.tool_name, &question_id);
×
1482
                turn_state
×
1483
                    .persisted_inquiry_responses
×
1484
                    .insert(inq_id.clone(), InquiryResponse::new(inq_id, answer.clone()));
×
1485
            }
×
1486
            tool.accumulated_answers.insert(question_id, answer);
×
1487
            self.set_tool_state(&tool.tool_id, ToolCallState::Running);
×
1488
            Self::spawn_tool_execution(
×
1489
                index,
×
1490
                tool.executor.clone(),
×
1491
                tool.accumulated_answers.clone(),
×
1492
                mcp_client.clone(),
×
1493
                root.to_path_buf(),
×
1494
                cancellation_token.clone(),
×
1495
                event_tx.clone(),
×
1496
            );
1497
        }
×
1498
        self.process_next_prompt(
×
1499
            pending_prompts,
×
1500
            prompt_active,
×
1501
            prompter,
×
1502
            executing_tools,
×
1503
            event_tx,
×
1504
        );
1505
    }
×
1506

1507
    fn handle_prompt_cancelled(
×
1508
        &mut self,
×
1509
        index: usize,
×
1510
        executing_tools: &mut HashMap<usize, ExecutingTool>,
×
1511
        results: &mut [Option<ToolCallResponse>],
×
1512
        pending_prompts: &mut VecDeque<PendingPrompt>,
×
1513
        prompt_active: &mut bool,
×
1514
        prompter: Arc<ToolPrompter>,
×
1515
        event_tx: mpsc::Sender<ExecutionEvent>,
×
1516
    ) {
×
1517
        *prompt_active = false;
×
1518
        if let Some(tool) = executing_tools.get(&index) {
×
1519
            self.set_tool_state(&tool.tool_id, ToolCallState::Completed);
×
1520
            results[index] = Some(ToolCallResponse {
×
1521
                id: tool.tool_id.clone(),
×
1522
                result: Ok("Tool input cancelled by user.".to_string()),
×
1523
            });
×
1524
        }
×
1525
        self.process_next_prompt(
×
1526
            pending_prompts,
×
1527
            prompt_active,
×
1528
            prompter,
×
1529
            executing_tools,
×
1530
            event_tx,
×
1531
        );
1532
    }
×
1533

1534
    fn spawn_user_prompt(
×
1535
        index: usize,
×
1536
        question: Question,
×
1537
        prompter: Arc<ToolPrompter>,
×
1538
        event_tx: mpsc::Sender<ExecutionEvent>,
×
1539
    ) {
×
1540
        let question_id = question.id.clone();
×
1541
        tokio::task::spawn_blocking(move || {
×
1542
            if let Ok(result) = prompter.prompt_question(&question) {
×
1543
                drop(event_tx.blocking_send(ExecutionEvent::PromptAnswer {
×
1544
                    index,
×
1545
                    question_id,
×
1546
                    answer: result.answer,
×
1547
                    persist_level: result.persist_level,
×
1548
                }));
×
1549
            } else {
×
1550
                drop(event_tx.blocking_send(ExecutionEvent::PromptCancelled { index }));
×
1551
            }
×
1552
        });
×
1553
    }
×
1554

1555
    #[allow(clippy::too_many_arguments)]
1556
    fn spawn_result_mode_prompt(
×
1557
        index: usize,
×
1558
        tool_id: String,
×
1559
        tool_name: String,
×
1560
        response: ToolCallResponse,
×
1561
        result_mode: ResultMode,
×
1562
        prompter: Arc<ToolPrompter>,
×
1563
        event_tx: mpsc::Sender<ExecutionEvent>,
×
1564
    ) {
×
1565
        tokio::task::spawn_blocking(move || {
×
1566
            let final_response = match result_mode {
×
1567
                ResultMode::Ask => match prompter.prompt_result_confirmation(&tool_name) {
×
1568
                    Ok(true) => response,
×
1569
                    Ok(false) => ToolCallResponse {
×
1570
                        id: response.id,
×
1571
                        result: Ok("Result delivery skipped by user.".to_string()),
×
1572
                    },
×
1573
                    Err(e) if e.to_string().contains("edit_requested") => {
×
1574
                        Self::handle_edit_result(&prompter, response)
×
1575
                    }
1576
                    Err(_) => ToolCallResponse {
×
1577
                        id: response.id,
×
1578
                        result: Ok("Result delivery cancelled.".to_string()),
×
1579
                    },
×
1580
                },
1581
                ResultMode::Edit => Self::handle_edit_result(&prompter, response),
×
1582
                _ => response,
×
1583
            };
1584
            drop(event_tx.blocking_send(ExecutionEvent::ResultModeProcessed {
×
1585
                index,
×
1586
                tool_id,
×
1587
                response: final_response,
×
1588
            }));
×
1589
        });
×
1590
    }
×
1591

1592
    fn handle_edit_result(prompter: &ToolPrompter, response: ToolCallResponse) -> ToolCallResponse {
×
1593
        let result_str = response.result.as_ref().map_or("", |s| s.as_str());
×
1594
        match prompter.edit_result(result_str) {
×
1595
            Ok(Some(edited)) => ToolCallResponse {
×
1596
                id: response.id,
×
1597
                result: Ok(edited),
×
1598
            },
×
1599
            Ok(None) => response,
×
1600
            Err(_) => ToolCallResponse {
×
1601
                id: response.id,
×
1602
                result: Ok("Result edit cancelled.".to_string()),
×
1603
            },
×
1604
        }
1605
    }
×
1606

1607
    fn process_next_prompt(
×
1608
        &mut self,
×
1609
        pending_prompts: &mut VecDeque<PendingPrompt>,
×
1610
        prompt_active: &mut bool,
×
1611
        prompter: Arc<ToolPrompter>,
×
1612
        executing_tools: &HashMap<usize, ExecutingTool>,
×
1613
        event_tx: mpsc::Sender<ExecutionEvent>,
×
1614
    ) {
×
1615
        let Some(next) = pending_prompts.pop_front() else {
×
1616
            return;
×
1617
        };
1618
        *prompt_active = true;
×
1619
        match next {
×
1620
            PendingPrompt::Question { index, question } => {
×
1621
                if let Some(tool) = executing_tools.get(&index) {
×
1622
                    self.set_tool_state(&tool.tool_id, ToolCallState::AwaitingInput);
×
1623
                }
×
1624
                Self::spawn_user_prompt(index, question, prompter, event_tx);
×
1625
            }
1626
            PendingPrompt::ResultMode {
1627
                index,
×
1628
                tool_id,
×
1629
                tool_name,
×
1630
                response,
×
1631
                result_mode,
×
1632
            } => {
×
1633
                self.set_tool_state(&tool_id, ToolCallState::AwaitingResultEdit);
×
1634
                Self::spawn_result_mode_prompt(
×
1635
                    index,
×
1636
                    tool_id,
×
1637
                    tool_name,
×
1638
                    response,
×
1639
                    result_mode,
×
1640
                    prompter.clone(),
×
1641
                    event_tx.clone(),
×
1642
                );
×
1643
            }
×
1644
        }
1645
    }
×
1646
}
1647

1648
#[cfg(test)]
1649
#[path = "coordinator_tests.rs"]
1650
mod tests;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc