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 mermaid_domain::ProgressEvent;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::sync::atomic::{AtomicUsize, Ordering};
25
26use tokio::sync::mpsc;
27use tokio_util::sync::CancellationToken;
28
29use mermaid_domain::{Msg, ToolCallId, TurnId};
30use mermaid_model::models::tool_call::ToolCall as ModelToolCall;
31use mermaid_model::models::{
32    ChatMessage, FinishReason, ProviderContinuation, ReasoningChunk, TokenUsage,
33};
34use mermaid_runtime::SafetyMode;
35
36use super::approval::ApprovalBroker;
37use super::auto_classifier::AutoClassifier;
38use super::questions::QuestionBroker;
39
40/// Shared, byte-exact budget for decoded HTTP response data in one turn.
41/// Clones point at the same atomic counter, so parallel tool calls and batched
42/// queries cannot each claim the full allowance independently.
43#[derive(Clone, Debug)]
44pub struct WebByteBudget {
45    used: Arc<AtomicUsize>,
46}
47
48impl WebByteBudget {
49    pub(crate) fn shared(used: Arc<AtomicUsize>) -> Self {
50        Self { used }
51    }
52
53    #[cfg(test)]
54    pub(crate) fn isolated() -> Self {
55        Self::shared(Arc::new(AtomicUsize::new(0)))
56    }
57
58    /// Charge decoded bytes without allowing the shared total to cross the
59    /// fixed per-turn limit. An overflowing charge atomically saturates the
60    /// counter so every later response observes an exhausted budget before it
61    /// polls another body.
62    // Nightly renamed `fetch_update` to `try_update` and deprecated the old
63    // name. `try_update` is not stable, so the call cannot be migrated yet and
64    // the deprecation cannot be avoided — and since the `[lints.rust]
65    // warnings = "deny"` table landed in every manifest, a warning the nightly
66    // toolchain emits is a hard error in the test build now, not only in
67    // clippy. That is what turned this into a red nightly leg.
68    //
69    // `#[allow]` and not `#[expect]`: on stable there is no deprecation to
70    // fulfil, so an expectation would itself become the warning on the
71    // toolchain that matters most. Delete both of these once `try_update`
72    // reaches the MSRV.
73    /// # Errors
74    ///
75    /// `Err(limit)` — the fixed per-turn cap — when this charge would cross
76    /// it, or when it was already reached. The counter is saturated either
77    /// way, so once one charge fails every later one does too; the `Err`
78    /// payload is the limit, not the amount over it.
79    #[allow(deprecated, reason = "try_update is not stable yet; see above")]
80    pub fn charge(&self, bytes: usize) -> Result<usize, usize> {
81        let limit = mermaid_model::constants::MAX_WEB_TURN_BYTES;
82        let prior = self
83            .used
84            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
85                Some(used.saturating_add(bytes).min(limit))
86            })
87            .expect("web byte budget update always supplies a value");
88        let next = prior.saturating_add(bytes);
89        if prior >= limit || next > limit {
90            Err(limit)
91        } else {
92            Ok(next)
93        }
94    }
95
96    #[must_use]
97    pub fn remaining(&self) -> usize {
98        mermaid_model::constants::MAX_WEB_TURN_BYTES
99            .saturating_sub(self.used.load(Ordering::Acquire))
100    }
101}
102
103/// What a `ModelProvider::chat()` receives.
104#[derive(Debug)]
105pub struct StreamContext {
106    pub token: CancellationToken,
107    pub sink: mpsc::Sender<StreamEvent>,
108    pub turn: TurnId,
109}
110
111impl StreamContext {
112    #[must_use]
113    pub fn new(token: CancellationToken, sink: mpsc::Sender<StreamEvent>, turn: TurnId) -> Self {
114        Self { token, sink, turn }
115    }
116}
117
118/// One event emitted during a streaming model call. Adapters MUST
119/// emit exactly one `Done` at the end of a successful stream. `Text`
120/// and `Reasoning` may interleave. `ToolCall` events typically arrive
121/// near the end but the contract is "before `Done`".
122#[derive(Debug, Clone)]
123pub enum StreamEvent {
124    Text(String),
125    Reasoning(ReasoningChunk),
126    ToolCall(ModelToolCall),
127    /// Out-of-band, user-visible plumbing notice (e.g. "Starting the local
128    /// Ollama server…"). Not response content — the effect layer routes it
129    /// to a transient/system line, never into the assistant message.
130    Status(String),
131    /// Stream complete. Carries final token usage (None if unknown),
132    /// any provider continuation state, and why generation stopped
133    /// (so the reducer can flag truncation / a content block).
134    Done {
135        usage: Option<TokenUsage>,
136        provider_continuation: Option<ProviderContinuation>,
137        stop_reason: Option<FinishReason>,
138    },
139}
140
141/// Final response returned by `ModelProvider::chat()` after the
142/// stream drains. Carries what the reducer can't derive from the
143/// stream events themselves: token usage and opaque provider continuation.
144#[derive(Debug, Clone)]
145pub struct FinalResponse {
146    pub usage: Option<TokenUsage>,
147    pub provider_continuation: Option<ProviderContinuation>,
148    pub tool_calls: Vec<ModelToolCall>,
149    pub stop_reason: Option<FinishReason>,
150}
151
152/// What a `ToolExecutor::execute()` receives.
153pub struct ExecContext {
154    pub token: CancellationToken,
155    /// Ctrl+B "background this" signal, parallel to `token`. Tools that can
156    /// detach a running child (`execute_command`, agent) select on it; the live
157    /// path sets it from the turn scope, tests leave it never-fired.
158    pub background: CancellationToken,
159    /// Turn-independent channel back to the main reducer loop. Detached work
160    /// (a backgrounded subagent) reports through this after the owning turn
161    /// is gone — the per-turn `progress` channel dies with the turn. `None`
162    /// in tests and contexts that never detach.
163    pub notify: Option<mpsc::Sender<Msg>>,
164    pub progress: mpsc::Sender<ProgressEvent>,
165    pub call_id: ToolCallId,
166    pub turn: TurnId,
167    pub workdir: PathBuf,
168    /// Parent session's `domain::Config`. Needed by `SubagentTool` so the
169    /// child reducer uses the same Ollama host, reasoning prefs, MCP
170    /// servers, etc. Other tools don't consult it — keeping it as a
171    /// typed field (rather than a global) means the dependency is
172    /// explicit in the signature.
173    pub config: Arc<mermaid_domain::Config>,
174    /// Parent session's active model id (e.g. `"anthropic/claude-opus-4-7"`).
175    /// Subagents inherit this so they hit the same provider.
176    pub model_id: String,
177    /// Durable daemon task that owns this tool call, when execution was
178    /// launched through the runtime task queue.
179    pub task_id: Option<String>,
180    /// Conversation id of the interactive session dispatching this call —
181    /// stamped by the reducer onto `Cmd::ExecuteTool` so checkpoints can be
182    /// anchored to a conversation position. `None` on headless/daemon paths.
183    pub session_id: Option<String>,
184    /// Conversation length (`messages().len()`) at dispatch; pairs with
185    /// `session_id` for checkpoint anchoring (see `CheckpointOrigin`).
186    pub message_index: Option<i64>,
187    /// Per-session scratch directory, when the session has one materialized
188    /// (`Msg::ScratchpadReady`). Stamped by the reducer onto
189    /// `Cmd::ExecuteTool`; like `background`/`notify` it is field-set after
190    /// construction on the live path — `None` in tests and before the
191    /// directory is confirmed on disk.
192    pub scratchpad: Option<PathBuf>,
193    /// Effective live safety mode for this call (from the session, not the
194    /// static config; floored to `ReadOnly` while a plan is being drafted).
195    /// The policy gate builds its `PolicyEngine` from this.
196    pub safety_mode: SafetyMode,
197    /// `Some(path)` while the session is in plan mode: the one path the
198    /// policy gate exempts from the read-only floor, and the flag the plan
199    /// carve-outs (memory writes, known-safe builds) and the task tools key
200    /// on. Defaults to `None` in `new` — the live dispatch path sets it,
201    /// like `background`/`notify`.
202    pub plan_file: Option<std::path::PathBuf>,
203    /// LIVE per-category plan permission levels, threaded from the reducer
204    /// (the frozen startup `config` would go stale under `/plan config`
205    /// edits). Only consulted while `plan_file` is `Some`; defaults in `new`.
206    pub plan_permissions: mermaid_domain::PlanPermissions,
207    /// Context-window fill at dispatch, when known (`exit_plan_mode` shows
208    /// it on the clear-context approval option). Defaults to `None` in `new`.
209    pub context_percent: Option<u8>,
210    /// The user's stated intent for the turn (latest user message), passed to
211    /// the Auto-mode classifier so it can judge whether an action is aligned.
212    pub intent: Option<String>,
213    /// LLM classifier for `SafetyMode::Auto`. `Some` only when the effective
214    /// mode is `Auto` and a provider is bound; the gate awaits it to resolve a
215    /// `PolicyDecision::Classify`. `None` ⇒ the gate fails safe (escalate).
216    pub classifier: Option<Arc<dyn AutoClassifier>>,
217    /// Inline-approval back-channel (interactive runs only). `Some` lets the
218    /// gate prompt the user and park until they answer; `None` (headless) falls
219    /// back to the out-of-band DB-approval flow.
220    pub approval: Option<ApprovalBroker>,
221    /// Inline-question back-channel for `ask_user_question` (interactive runs
222    /// only). `Some` lets the tool park until the user answers; `None`
223    /// (headless) makes the tool proceed with best judgment instead of blocking.
224    pub questions: Option<QuestionBroker>,
225    /// The checklist broker for the task tools (single writer for all task
226    /// state). Present on every live path — interactive, headless, and
227    /// subagent runners each own one; `None` only in bare test contexts,
228    /// where the tools degrade to a graceful no-op.
229    pub tasks: Option<crate::providers::tasks::TaskBroker>,
230    /// Decoded web bytes accepted by every sibling tool call in this turn.
231    /// The effect runner replaces the constructor default with the owning
232    /// `TurnScope` counter so parallel calls share one aggregate budget.
233    pub web_bytes: Arc<AtomicUsize>,
234}
235
236impl std::fmt::Debug for ExecContext {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        // `classifier` is a trait object (no `Debug`); render its presence.
239        f.debug_struct("ExecContext")
240            .field("call_id", &self.call_id)
241            .field("turn", &self.turn)
242            .field("workdir", &self.workdir)
243            .field("model_id", &self.model_id)
244            .field("task_id", &self.task_id)
245            .field("session_id", &self.session_id)
246            .field("message_index", &self.message_index)
247            .field("scratchpad", &self.scratchpad)
248            .field("safety_mode", &self.safety_mode)
249            .field("intent", &self.intent)
250            .field(
251                "classifier",
252                &self.classifier.as_ref().map(|_| "<dyn AutoClassifier>"),
253            )
254            .field(
255                "approval",
256                &self.approval.as_ref().map(|_| "<ApprovalBroker>"),
257            )
258            .field(
259                "questions",
260                &self.questions.as_ref().map(|_| "<QuestionBroker>"),
261            )
262            .field("tasks", &self.tasks.as_ref().map(|_| "<TaskBroker>"))
263            .finish_non_exhaustive()
264    }
265}
266
267impl ExecContext {
268    #[expect(clippy::too_many_arguments)]
269    #[must_use]
270    pub fn new(
271        token: CancellationToken,
272        progress: mpsc::Sender<ProgressEvent>,
273        call_id: ToolCallId,
274        turn: TurnId,
275        workdir: PathBuf,
276        config: Arc<mermaid_domain::Config>,
277        model_id: String,
278        task_id: Option<String>,
279        session_id: Option<String>,
280        message_index: Option<i64>,
281        safety_mode: SafetyMode,
282        intent: Option<String>,
283        classifier: Option<Arc<dyn AutoClassifier>>,
284        approval: Option<ApprovalBroker>,
285        questions: Option<QuestionBroker>,
286        tasks: Option<crate::providers::tasks::TaskBroker>,
287    ) -> Self {
288        Self {
289            token,
290            // Defaults to a fresh, never-fired token ("no background
291            // requested"); the live execute path overwrites it with the turn
292            // scope's background token (and sets `notify`).
293            background: CancellationToken::new(),
294            notify: None,
295            plan_file: None,
296            plan_permissions: mermaid_domain::PlanPermissions::default(),
297            context_percent: None,
298            // Field-set by the live execute path alongside `background`/
299            // `notify`; tests and bare contexts leave it unset.
300            scratchpad: None,
301            progress,
302            call_id,
303            turn,
304            workdir,
305            config,
306            model_id,
307            task_id,
308            session_id,
309            message_index,
310            safety_mode,
311            intent,
312            classifier,
313            approval,
314            questions,
315            tasks,
316            web_bytes: Arc::new(AtomicUsize::new(0)),
317        }
318    }
319
320    /// Charge decoded web bytes to this turn without ever crossing the fixed
321    /// aggregate limit. Returns the new total on success.
322    ///
323    /// # Errors
324    ///
325    /// [`WebByteBudget::charge`]'s: `Err(limit)` once this turn's aggregate
326    /// web budget is spent.
327    pub fn charge_web_bytes(&self, bytes: usize) -> Result<usize, usize> {
328        self.web_budget().charge(bytes)
329    }
330
331    /// A cloneable handle for transport code to charge each decoded chunk at
332    /// the point it is accepted, including failed responses and retries.
333    #[must_use]
334    pub fn web_budget(&self) -> WebByteBudget {
335        WebByteBudget::shared(self.web_bytes.clone())
336    }
337
338    /// Checkpoint provenance for this call — every checkpoint-creating tool
339    /// passes this so file snapshots anchor to the conversation position
340    /// that produced them (rewind/fork surfaces them by anchor).
341    #[must_use]
342    pub fn checkpoint_origin(&self) -> mermaid_runtime::CheckpointOrigin {
343        mermaid_runtime::CheckpointOrigin {
344            task_id: self.task_id.clone(),
345            session_id: self.session_id.clone(),
346            message_index: self.message_index,
347        }
348    }
349}
350
351/// Narrow shim from the reducer's `ChatRequest` to the adapter-facing
352/// messages. Providers often want to mutate the last assistant
353/// message (e.g. Anthropic `cache_control` injection); this helper
354/// clones the slice as owned so the provider can do that without
355/// fighting the borrow checker.
356#[must_use]
357pub fn clone_messages(msgs: &[ChatMessage]) -> Vec<ChatMessage> {
358    msgs.to_vec()
359}
360
361/// Builder that lets tests construct a pair of `StreamContext` +
362/// receiver without needing a runtime. Used by provider unit tests
363/// and by integration harnesses in C9.
364#[must_use]
365pub fn test_stream_context(turn: TurnId) -> (StreamContext, mpsc::Receiver<StreamEvent>) {
366    let token = CancellationToken::new();
367    let (tx, rx) = mpsc::channel(64);
368    (StreamContext::new(token, tx, turn), rx)
369}
370
371/// Builder counterpart for `ExecContext`. Uses a `Config` pinned to
372/// `SafetyMode::FullAccess` (the production default is now `Ask`) so tool
373/// unit tests exercise the tool's own behavior rather than the approval
374/// gate. Tests that specifically exercise policy gating should construct
375/// `ExecContext::new` directly with their chosen safety mode.
376#[must_use]
377pub fn test_exec_context(
378    turn: TurnId,
379    call_id: ToolCallId,
380    workdir: PathBuf,
381) -> (ExecContext, mpsc::Receiver<ProgressEvent>) {
382    let mut config = mermaid_domain::Config::default();
383    config.safety.mode = mermaid_runtime::SafetyMode::FullAccess;
384    test_exec_context_with_config(turn, call_id, workdir, config)
385}
386
387/// [`test_exec_context`] with an explicit `Config` (e.g. `exec.pty = false`
388/// to pin the pipe spawn path, or a `safety.mode` other than `FullAccess`).
389/// The context's safety mode follows `config.safety.mode`, so gate tests can
390/// pick a mode without hand-rolling `ExecContext::new`.
391#[must_use]
392pub fn test_exec_context_with_config(
393    turn: TurnId,
394    call_id: ToolCallId,
395    workdir: PathBuf,
396    config: mermaid_domain::Config,
397) -> (ExecContext, mpsc::Receiver<ProgressEvent>) {
398    let token = CancellationToken::new();
399    let (tx, rx) = mpsc::channel(64);
400    let safety_mode = config.safety.mode;
401    let config = Arc::new(config);
402    (
403        ExecContext::new(
404            token,
405            tx,
406            call_id,
407            turn,
408            workdir,
409            config,
410            String::new(),
411            None,
412            None,
413            None,
414            safety_mode,
415            None,
416            None,
417            None,
418            None,
419            None,
420        ),
421        rx,
422    )
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use std::path::PathBuf;
429
430    #[tokio::test]
431    async fn stream_context_carries_token_and_turn() {
432        let (ctx, _rx) = test_stream_context(TurnId(5));
433        assert_eq!(ctx.turn, TurnId(5));
434        assert!(!ctx.token.is_cancelled());
435    }
436
437    #[tokio::test]
438    async fn exec_context_propagates_cancel_signal() {
439        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
440        let token = ctx.token.clone();
441        tokio::spawn(async move {
442            token.cancel();
443        });
444        // Wait until cancelled.
445        ctx.token.cancelled().await;
446        assert!(ctx.token.is_cancelled());
447    }
448
449    #[tokio::test]
450    async fn progress_event_round_trips_through_channel() {
451        let (ctx, mut rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
452        ctx.progress
453            .send(ProgressEvent::Status("halfway".to_string()))
454            .await
455            .expect("send");
456        match rx.recv().await.expect("recv") {
457            ProgressEvent::Status(s) => assert_eq!(s, "halfway"),
458            _ => panic!("wrong variant"),
459        }
460    }
461
462    #[test]
463    fn web_budget_is_atomic_and_never_crosses_the_turn_limit() {
464        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
465        assert_eq!(ctx.charge_web_bytes(1024), Ok(1024));
466        let remaining = mermaid_model::constants::MAX_WEB_TURN_BYTES - 1024;
467        assert_eq!(
468            ctx.charge_web_bytes(remaining),
469            Ok(mermaid_model::constants::MAX_WEB_TURN_BYTES)
470        );
471        assert_eq!(
472            ctx.charge_web_bytes(1),
473            Err(mermaid_model::constants::MAX_WEB_TURN_BYTES)
474        );
475    }
476
477    #[test]
478    fn web_budget_overflow_saturates_and_stays_exhausted() {
479        let budget = WebByteBudget::isolated();
480        let limit = mermaid_model::constants::MAX_WEB_TURN_BYTES;
481        assert_eq!(budget.charge(limit - 1), Ok(limit - 1));
482        assert_eq!(budget.charge(2), Err(limit));
483        assert_eq!(budget.remaining(), 0);
484        assert_eq!(budget.charge(0), Err(limit));
485        assert_eq!(budget.charge(usize::MAX), Err(limit));
486        assert_eq!(budget.remaining(), 0);
487    }
488}