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}