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