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