Skip to main content

mermaid_cli/providers/
ctx.rs

1//! Per-call context passed to providers and tool executors.
2//!
3//! The two structs below are the single point where per-turn
4//! cancellation + progress reporting + session identity meet a
5//! specific provider call. Everything a model or tool adapter needs
6//! to participate in structured concurrency is here.
7//!
8//! - `StreamContext` is handed to a `ModelProvider::chat()`. It
9//!   carries the cancellation token for the turn and a bounded mpsc
10//!   sink for streaming events. The adapter `select!`s on
11//!   `token.cancelled()` inside its read loop and awaits
12//!   `sink.send(event)` — if the main loop is drowning, the `await`
13//!   applies natural backpressure and the provider's TCP buffer fills
14//!   instead of the channel growing unbounded.
15//!
16//! - `ExecContext` is handed to a `ToolExecutor::execute()`. Same
17//!   token (so Ctrl+C cancels tools too) plus a progress sink and
18//!   identifiers so the reducer can match results to the call that
19//!   produced them.
20
21use std::path::PathBuf;
22use std::sync::Arc;
23
24use tokio::sync::mpsc;
25use tokio_util::sync::CancellationToken;
26
27use crate::domain::{Msg, ToolCallId, TurnId};
28use crate::models::tool_call::ToolCall as ModelToolCall;
29use crate::models::{ChatMessage, FinishReason, ProviderContinuation, ReasoningChunk, TokenUsage};
30use crate::runtime::SafetyMode;
31
32use super::approval::ApprovalBroker;
33use super::auto_classifier::AutoClassifier;
34use super::questions::QuestionBroker;
35
36/// What a `ModelProvider::chat()` receives.
37#[derive(Debug)]
38pub struct StreamContext {
39    pub token: CancellationToken,
40    pub sink: mpsc::Sender<StreamEvent>,
41    pub turn: TurnId,
42}
43
44impl StreamContext {
45    pub fn new(token: CancellationToken, sink: mpsc::Sender<StreamEvent>, turn: TurnId) -> Self {
46        Self { token, sink, turn }
47    }
48}
49
50/// One event emitted during a streaming model call. Adapters MUST
51/// emit exactly one `Done` at the end of a successful stream. `Text`
52/// and `Reasoning` may interleave. `ToolCall` events typically arrive
53/// near the end but the contract is "before `Done`".
54#[derive(Debug, Clone)]
55pub enum StreamEvent {
56    Text(String),
57    Reasoning(ReasoningChunk),
58    ToolCall(ModelToolCall),
59    /// Out-of-band, user-visible plumbing notice (e.g. "Starting the local
60    /// Ollama server…"). Not response content — the effect layer routes it
61    /// to a transient/system line, never into the assistant message.
62    Status(String),
63    /// Stream complete. Carries final token usage (None if unknown),
64    /// any provider continuation state, and why generation stopped
65    /// (so the reducer can flag truncation / a content block).
66    Done {
67        usage: Option<TokenUsage>,
68        provider_continuation: Option<ProviderContinuation>,
69        stop_reason: Option<FinishReason>,
70    },
71}
72
73/// Final response returned by `ModelProvider::chat()` after the
74/// stream drains. Carries what the reducer can't derive from the
75/// stream events themselves: token usage and opaque provider continuation.
76#[derive(Debug, Clone)]
77pub struct FinalResponse {
78    pub usage: Option<TokenUsage>,
79    pub provider_continuation: Option<ProviderContinuation>,
80    pub tool_calls: Vec<ModelToolCall>,
81    pub stop_reason: Option<FinishReason>,
82}
83
84/// What a `ToolExecutor::execute()` receives.
85pub struct ExecContext {
86    pub token: CancellationToken,
87    /// Ctrl+B "background this" signal, parallel to `token`. Tools that can
88    /// detach a running child (execute_command, agent) select on it; the live
89    /// path sets it from the turn scope, tests leave it never-fired.
90    pub background: CancellationToken,
91    /// Turn-independent channel back to the main reducer loop. Detached work
92    /// (a backgrounded subagent) reports through this after the owning turn
93    /// is gone — the per-turn `progress` channel dies with the turn. `None`
94    /// in tests and contexts that never detach.
95    pub notify: Option<mpsc::Sender<Msg>>,
96    pub progress: mpsc::Sender<ProgressEvent>,
97    pub call_id: ToolCallId,
98    pub turn: TurnId,
99    pub workdir: PathBuf,
100    /// Parent session's `app::Config`. Needed by `SubagentTool` so the
101    /// child reducer uses the same Ollama host, reasoning prefs, MCP
102    /// servers, etc. Other tools don't consult it — keeping it as a
103    /// typed field (rather than a global) means the dependency is
104    /// explicit in the signature.
105    pub config: Arc<crate::app::Config>,
106    /// Parent session's active model id (e.g. `"anthropic/claude-opus-4-7"`).
107    /// Subagents inherit this so they hit the same provider.
108    pub model_id: String,
109    /// Durable daemon task that owns this tool call, when execution was
110    /// launched through the runtime task queue.
111    pub task_id: Option<String>,
112    /// Conversation id of the interactive session dispatching this call —
113    /// stamped by the reducer onto `Cmd::ExecuteTool` so checkpoints can be
114    /// anchored to a conversation position. `None` on headless/daemon paths.
115    pub session_id: Option<String>,
116    /// Conversation length (`messages().len()`) at dispatch; pairs with
117    /// `session_id` for checkpoint anchoring (see `CheckpointOrigin`).
118    pub message_index: Option<i64>,
119    /// Per-session scratch directory, when the session has one materialized
120    /// (`Msg::ScratchpadReady`). Stamped by the reducer onto
121    /// `Cmd::ExecuteTool`; like `background`/`notify` it is field-set after
122    /// construction on the live path — `None` in tests and before the
123    /// directory is confirmed on disk.
124    pub scratchpad: Option<PathBuf>,
125    /// Effective live safety mode for this call (from the session, not the
126    /// static config; floored to `ReadOnly` while a plan is being drafted).
127    /// The policy gate builds its `PolicyEngine` from this.
128    pub safety_mode: SafetyMode,
129    /// `Some(path)` while the session is in plan mode: the one path the
130    /// policy gate exempts from the read-only floor, and the flag the plan
131    /// carve-outs (memory writes, known-safe builds) and the task tools key
132    /// on. Defaults to `None` in `new` — the live dispatch path sets it,
133    /// like `background`/`notify`.
134    pub plan_file: Option<std::path::PathBuf>,
135    /// LIVE per-category plan permission levels, threaded from the reducer
136    /// (the frozen startup `config` would go stale under `/plan config`
137    /// edits). Only consulted while `plan_file` is `Some`; defaults in `new`.
138    pub plan_permissions: crate::app::PlanPermissions,
139    /// Context-window fill at dispatch, when known (`exit_plan_mode` shows
140    /// it on the clear-context approval option). Defaults to `None` in `new`.
141    pub context_percent: Option<u8>,
142    /// The user's stated intent for the turn (latest user message), passed to
143    /// the Auto-mode classifier so it can judge whether an action is aligned.
144    pub intent: Option<String>,
145    /// LLM classifier for `SafetyMode::Auto`. `Some` only when the effective
146    /// mode is `Auto` and a provider is bound; the gate awaits it to resolve a
147    /// `PolicyDecision::Classify`. `None` ⇒ the gate fails safe (escalate).
148    pub classifier: Option<Arc<dyn AutoClassifier>>,
149    /// Inline-approval back-channel (interactive runs only). `Some` lets the
150    /// gate prompt the user and park until they answer; `None` (headless) falls
151    /// back to the out-of-band DB-approval flow.
152    pub approval: Option<ApprovalBroker>,
153    /// Inline-question back-channel for `ask_user_question` (interactive runs
154    /// only). `Some` lets the tool park until the user answers; `None`
155    /// (headless) makes the tool proceed with best judgment instead of blocking.
156    pub questions: Option<QuestionBroker>,
157    /// The checklist broker for the task tools (single writer for all task
158    /// state). Present on every live path — interactive, headless, and
159    /// subagent runners each own one; `None` only in bare test contexts,
160    /// where the tools degrade to a graceful no-op.
161    pub tasks: Option<crate::providers::tasks::TaskBroker>,
162}
163
164impl std::fmt::Debug for ExecContext {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        // `classifier` is a trait object (no `Debug`); render its presence.
167        f.debug_struct("ExecContext")
168            .field("call_id", &self.call_id)
169            .field("turn", &self.turn)
170            .field("workdir", &self.workdir)
171            .field("model_id", &self.model_id)
172            .field("task_id", &self.task_id)
173            .field("session_id", &self.session_id)
174            .field("message_index", &self.message_index)
175            .field("scratchpad", &self.scratchpad)
176            .field("safety_mode", &self.safety_mode)
177            .field("intent", &self.intent)
178            .field(
179                "classifier",
180                &self.classifier.as_ref().map(|_| "<dyn AutoClassifier>"),
181            )
182            .field(
183                "approval",
184                &self.approval.as_ref().map(|_| "<ApprovalBroker>"),
185            )
186            .field(
187                "questions",
188                &self.questions.as_ref().map(|_| "<QuestionBroker>"),
189            )
190            .field("tasks", &self.tasks.as_ref().map(|_| "<TaskBroker>"))
191            .finish_non_exhaustive()
192    }
193}
194
195impl ExecContext {
196    #[allow(clippy::too_many_arguments)]
197    pub fn new(
198        token: CancellationToken,
199        progress: mpsc::Sender<ProgressEvent>,
200        call_id: ToolCallId,
201        turn: TurnId,
202        workdir: PathBuf,
203        config: Arc<crate::app::Config>,
204        model_id: String,
205        task_id: Option<String>,
206        session_id: Option<String>,
207        message_index: Option<i64>,
208        safety_mode: SafetyMode,
209        intent: Option<String>,
210        classifier: Option<Arc<dyn AutoClassifier>>,
211        approval: Option<ApprovalBroker>,
212        questions: Option<QuestionBroker>,
213        tasks: Option<crate::providers::tasks::TaskBroker>,
214    ) -> Self {
215        Self {
216            token,
217            // Defaults to a fresh, never-fired token ("no background
218            // requested"); the live execute path overwrites it with the turn
219            // scope's background token (and sets `notify`).
220            background: CancellationToken::new(),
221            notify: None,
222            plan_file: None,
223            plan_permissions: crate::app::PlanPermissions::default(),
224            context_percent: None,
225            // Field-set by the live execute path alongside `background`/
226            // `notify`; tests and bare contexts leave it unset.
227            scratchpad: None,
228            progress,
229            call_id,
230            turn,
231            workdir,
232            config,
233            model_id,
234            task_id,
235            session_id,
236            message_index,
237            safety_mode,
238            intent,
239            classifier,
240            approval,
241            questions,
242            tasks,
243        }
244    }
245
246    /// Checkpoint provenance for this call — every checkpoint-creating tool
247    /// passes this so file snapshots anchor to the conversation position
248    /// that produced them (rewind/fork surfaces them by anchor).
249    pub fn checkpoint_origin(&self) -> crate::runtime::CheckpointOrigin {
250        crate::runtime::CheckpointOrigin {
251            task_id: self.task_id.clone(),
252            session_id: self.session_id.clone(),
253            message_index: self.message_index,
254        }
255    }
256}
257
258/// Tool-side progress event. The reducer already knows `ToolStarted`
259/// and `ToolFinished`; this carries everything in between (streaming
260/// subprocess output, long-running download status, multimodal
261/// artifacts like inline screenshots, and nested activity from
262/// subagents).
263#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
264pub enum ProgressEvent {
265    /// Partial stdout/stderr chunk.
266    Output(String),
267    /// Arbitrary status string for display.
268    Status(String),
269    /// Byte-count progress for long downloads/transfers. `total` is
270    /// None when the producer doesn't know the final size.
271    Bytes { done: u64, total: Option<u64> },
272    /// Binary artifact produced mid-execution (screenshot preview,
273    /// generated file, etc.). MIME string determines routing in the
274    /// reducer — `image/*` attaches inline to the active assistant
275    /// message; anything else lands on the status line as a label.
276    Artifact {
277        mime: String,
278        #[serde(with = "crate::utils::serde_base64")]
279        data: Vec<u8>,
280        caption: Option<String>,
281    },
282    /// A child subagent just started or finished a tool call. Carries
283    /// the CHILD's call identity + tool name + phase so the parent UI
284    /// can surface it without needing to recurse into the child's
285    /// event vocabulary.
286    SubagentToolCall {
287        child_call_id: ToolCallId,
288        tool_name: String,
289        phase: SubagentPhase,
290    },
291    /// Coarse phase label for a child subagent ("starting…",
292    /// "thinking", "replying"). Emitted only on phase CHANGE — never
293    /// per stream chunk — so the parent status stays calm.
294    SubagentActivity(String),
295    /// Cumulative output-token estimate for a child subagent's current
296    /// drive. Throttled at the source (≥500ms apart); powers the live
297    /// per-agent token counters without per-chunk churn.
298    SubagentTokens(usize),
299}
300
301/// Phase a subagent tool-call is in, from the parent's perspective.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
303pub enum SubagentPhase {
304    Started,
305    Finished,
306    Errored,
307}
308
309/// Narrow shim from the reducer's `ChatRequest` to the adapter-facing
310/// messages. Providers often want to mutate the last assistant
311/// message (e.g. Anthropic cache_control injection); this helper
312/// clones the slice as owned so the provider can do that without
313/// fighting the borrow checker.
314pub fn clone_messages(msgs: &[ChatMessage]) -> Vec<ChatMessage> {
315    msgs.to_vec()
316}
317
318/// Builder that lets tests construct a pair of `StreamContext` +
319/// receiver without needing a runtime. Used by provider unit tests
320/// and by integration harnesses in C9.
321pub fn test_stream_context(turn: TurnId) -> (StreamContext, mpsc::Receiver<StreamEvent>) {
322    let token = CancellationToken::new();
323    let (tx, rx) = mpsc::channel(64);
324    (StreamContext::new(token, tx, turn), rx)
325}
326
327/// Builder counterpart for `ExecContext`. Uses a `Config` pinned to
328/// `SafetyMode::FullAccess` (the production default is now `Ask`) so tool
329/// unit tests exercise the tool's own behavior rather than the approval
330/// gate. Tests that specifically exercise policy gating should construct
331/// `ExecContext::new` directly with their chosen safety mode.
332pub fn test_exec_context(
333    turn: TurnId,
334    call_id: ToolCallId,
335    workdir: PathBuf,
336) -> (ExecContext, mpsc::Receiver<ProgressEvent>) {
337    let mut config = crate::app::Config::default();
338    config.safety.mode = crate::runtime::SafetyMode::FullAccess;
339    test_exec_context_with_config(turn, call_id, workdir, config)
340}
341
342/// [`test_exec_context`] with an explicit `Config` (e.g. `exec.pty = false`
343/// to pin the pipe spawn path).
344pub fn test_exec_context_with_config(
345    turn: TurnId,
346    call_id: ToolCallId,
347    workdir: PathBuf,
348    config: crate::app::Config,
349) -> (ExecContext, mpsc::Receiver<ProgressEvent>) {
350    let token = CancellationToken::new();
351    let (tx, rx) = mpsc::channel(64);
352    let config = Arc::new(config);
353    (
354        ExecContext::new(
355            token,
356            tx,
357            call_id,
358            turn,
359            workdir,
360            config,
361            String::new(),
362            None,
363            None,
364            None,
365            crate::runtime::SafetyMode::FullAccess,
366            None,
367            None,
368            None,
369            None,
370            None,
371        ),
372        rx,
373    )
374}
375
376/// Convenience: build a Send+Sync sharable sink for tests.
377pub fn arc_sink(tx: mpsc::Sender<StreamEvent>) -> Arc<mpsc::Sender<StreamEvent>> {
378    Arc::new(tx)
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use std::path::PathBuf;
385
386    #[tokio::test]
387    async fn stream_context_carries_token_and_turn() {
388        let (ctx, _rx) = test_stream_context(TurnId(5));
389        assert_eq!(ctx.turn, TurnId(5));
390        assert!(!ctx.token.is_cancelled());
391    }
392
393    #[tokio::test]
394    async fn exec_context_propagates_cancel_signal() {
395        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
396        let token = ctx.token.clone();
397        tokio::spawn(async move {
398            token.cancel();
399        });
400        // Wait until cancelled.
401        ctx.token.cancelled().await;
402        assert!(ctx.token.is_cancelled());
403    }
404
405    #[tokio::test]
406    async fn progress_event_round_trips_through_channel() {
407        let (ctx, mut rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
408        ctx.progress
409            .send(ProgressEvent::Status("halfway".to_string()))
410            .await
411            .expect("send");
412        match rx.recv().await.expect("recv") {
413            ProgressEvent::Status(s) => assert_eq!(s, "halfway"),
414            _ => panic!("wrong variant"),
415        }
416    }
417}