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::{ToolCallId, TurnId};
28use crate::models::tool_call::ToolCall as ModelToolCall;
29use crate::models::{ChatMessage, FinishReason, ReasoningChunk, TokenUsage};
30use crate::runtime::SafetyMode;
31
32use super::approval::ApprovalBroker;
33use super::auto_classifier::AutoClassifier;
34
35/// What a `ModelProvider::chat()` receives.
36#[derive(Debug)]
37pub struct StreamContext {
38    pub token: CancellationToken,
39    pub sink: mpsc::Sender<StreamEvent>,
40    pub turn: TurnId,
41}
42
43impl StreamContext {
44    pub fn new(token: CancellationToken, sink: mpsc::Sender<StreamEvent>, turn: TurnId) -> Self {
45        Self { token, sink, turn }
46    }
47}
48
49/// One event emitted during a streaming model call. Adapters MUST
50/// emit exactly one `Done` at the end of a successful stream. `Text`
51/// and `Reasoning` may interleave. `ToolCall` events typically arrive
52/// near the end but the contract is "before `Done`".
53#[derive(Debug, Clone)]
54pub enum StreamEvent {
55    Text(String),
56    Reasoning(ReasoningChunk),
57    ToolCall(ModelToolCall),
58    /// Optional — some providers emit a signature mid-stream
59    /// (Anthropic). Adapters that only have it at the end can attach
60    /// it to `Done` instead.
61    ThinkingSignature(String),
62    /// Stream complete. Carries final token usage (None if unknown),
63    /// any terminal thinking signature, and why generation stopped
64    /// (so the reducer can flag truncation / a content block).
65    Done {
66        usage: Option<TokenUsage>,
67        thinking_signature: Option<String>,
68        stop_reason: Option<FinishReason>,
69    },
70}
71
72/// Final response returned by `ModelProvider::chat()` after the
73/// stream drains. Carries what the reducer can't derive from the
74/// stream events themselves — token usage and the opaque thinking
75/// signature needed for Anthropic extended-thinking continuation.
76#[derive(Debug, Clone)]
77pub struct FinalResponse {
78    pub usage: Option<TokenUsage>,
79    pub thinking_signature: Option<String>,
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) select on it; the live path
89    /// sets it from the turn scope, tests leave it never-fired.
90    pub background: CancellationToken,
91    pub progress: mpsc::Sender<ProgressEvent>,
92    pub call_id: ToolCallId,
93    pub turn: TurnId,
94    pub workdir: PathBuf,
95    /// Parent session's `app::Config`. Needed by `SubagentTool` so the
96    /// child reducer uses the same Ollama host, reasoning prefs, MCP
97    /// servers, etc. Other tools don't consult it — keeping it as a
98    /// typed field (rather than a global) means the dependency is
99    /// explicit in the signature.
100    pub config: Arc<crate::app::Config>,
101    /// Parent session's active model id (e.g. `"anthropic/claude-opus-4-7"`).
102    /// Subagents inherit this so they hit the same provider.
103    pub model_id: String,
104    /// Durable daemon task that owns this tool call, when execution was
105    /// launched through the runtime task queue.
106    pub task_id: Option<String>,
107    /// Effective live safety mode for this call (from the session, not the
108    /// static config). The policy gate builds its `PolicyEngine` from this.
109    pub safety_mode: SafetyMode,
110    /// The user's stated intent for the turn (latest user message), passed to
111    /// the Auto-mode classifier so it can judge whether an action is aligned.
112    pub intent: Option<String>,
113    /// LLM classifier for `SafetyMode::Auto`. `Some` only when the effective
114    /// mode is `Auto` and a provider is bound; the gate awaits it to resolve a
115    /// `PolicyDecision::Classify`. `None` ⇒ the gate fails safe (escalate).
116    pub classifier: Option<Arc<dyn AutoClassifier>>,
117    /// Inline-approval back-channel (interactive runs only). `Some` lets the
118    /// gate prompt the user and park until they answer; `None` (headless) falls
119    /// back to the out-of-band DB-approval flow.
120    pub approval: Option<ApprovalBroker>,
121}
122
123impl std::fmt::Debug for ExecContext {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        // `classifier` is a trait object (no `Debug`); render its presence.
126        f.debug_struct("ExecContext")
127            .field("call_id", &self.call_id)
128            .field("turn", &self.turn)
129            .field("workdir", &self.workdir)
130            .field("model_id", &self.model_id)
131            .field("task_id", &self.task_id)
132            .field("safety_mode", &self.safety_mode)
133            .field("intent", &self.intent)
134            .field(
135                "classifier",
136                &self.classifier.as_ref().map(|_| "<dyn AutoClassifier>"),
137            )
138            .field(
139                "approval",
140                &self.approval.as_ref().map(|_| "<ApprovalBroker>"),
141            )
142            .finish_non_exhaustive()
143    }
144}
145
146impl ExecContext {
147    #[allow(clippy::too_many_arguments)]
148    pub fn new(
149        token: CancellationToken,
150        progress: mpsc::Sender<ProgressEvent>,
151        call_id: ToolCallId,
152        turn: TurnId,
153        workdir: PathBuf,
154        config: Arc<crate::app::Config>,
155        model_id: String,
156        task_id: Option<String>,
157        safety_mode: SafetyMode,
158        intent: Option<String>,
159        classifier: Option<Arc<dyn AutoClassifier>>,
160        approval: Option<ApprovalBroker>,
161    ) -> Self {
162        Self {
163            token,
164            // Defaults to a fresh, never-fired token ("no background
165            // requested"); the live execute path overwrites it with the turn
166            // scope's background token.
167            background: CancellationToken::new(),
168            progress,
169            call_id,
170            turn,
171            workdir,
172            config,
173            model_id,
174            task_id,
175            safety_mode,
176            intent,
177            classifier,
178            approval,
179        }
180    }
181}
182
183/// Tool-side progress event. The reducer already knows `ToolStarted`
184/// and `ToolFinished`; this carries everything in between (streaming
185/// subprocess output, long-running download status, multimodal
186/// artifacts like inline screenshots, and nested activity from
187/// subagents).
188#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
189pub enum ProgressEvent {
190    /// Partial stdout/stderr chunk.
191    Output(String),
192    /// Arbitrary status string for display.
193    Status(String),
194    /// Byte-count progress for long downloads/transfers. `total` is
195    /// None when the producer doesn't know the final size.
196    Bytes { done: u64, total: Option<u64> },
197    /// Binary artifact produced mid-execution (screenshot preview,
198    /// generated file, etc.). MIME string determines routing in the
199    /// reducer — `image/*` attaches inline to the active assistant
200    /// message; anything else lands on the status line as a label.
201    Artifact {
202        mime: String,
203        #[serde(with = "crate::utils::serde_base64")]
204        data: Vec<u8>,
205        caption: Option<String>,
206    },
207    /// A child subagent just started or finished a tool call. Carries
208    /// the CHILD's call identity + tool name + phase so the parent UI
209    /// can surface it without needing to recurse into the child's
210    /// event vocabulary.
211    SubagentToolCall {
212        child_call_id: ToolCallId,
213        tool_name: String,
214        phase: SubagentPhase,
215    },
216    /// A chunk of assistant text produced by a child subagent. Mostly
217    /// UI flavor — lets the parent status line show what the sub is
218    /// "saying" in real time.
219    SubagentText(String),
220}
221
222/// Phase a subagent tool-call is in, from the parent's perspective.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
224pub enum SubagentPhase {
225    Started,
226    Finished,
227    Errored,
228}
229
230/// Narrow shim from the reducer's `ChatRequest` to the adapter-facing
231/// messages. Providers often want to mutate the last assistant
232/// message (e.g. Anthropic cache_control injection); this helper
233/// clones the slice as owned so the provider can do that without
234/// fighting the borrow checker.
235pub fn clone_messages(msgs: &[ChatMessage]) -> Vec<ChatMessage> {
236    msgs.to_vec()
237}
238
239/// Builder that lets tests construct a pair of `StreamContext` +
240/// receiver without needing a runtime. Used by provider unit tests
241/// and by integration harnesses in C9.
242pub fn test_stream_context(turn: TurnId) -> (StreamContext, mpsc::Receiver<StreamEvent>) {
243    let token = CancellationToken::new();
244    let (tx, rx) = mpsc::channel(64);
245    (StreamContext::new(token, tx, turn), rx)
246}
247
248/// Builder counterpart for `ExecContext`. Uses a `Config` pinned to
249/// `SafetyMode::FullAccess` (the production default is now `Ask`) so tool
250/// unit tests exercise the tool's own behavior rather than the approval
251/// gate. Tests that specifically exercise policy gating should construct
252/// `ExecContext::new` directly with their chosen safety mode.
253pub fn test_exec_context(
254    turn: TurnId,
255    call_id: ToolCallId,
256    workdir: PathBuf,
257) -> (ExecContext, mpsc::Receiver<ProgressEvent>) {
258    let token = CancellationToken::new();
259    let (tx, rx) = mpsc::channel(64);
260    let mut config = crate::app::Config::default();
261    config.safety.mode = crate::runtime::SafetyMode::FullAccess;
262    let config = Arc::new(config);
263    (
264        ExecContext::new(
265            token,
266            tx,
267            call_id,
268            turn,
269            workdir,
270            config,
271            String::new(),
272            None,
273            crate::runtime::SafetyMode::FullAccess,
274            None,
275            None,
276            None,
277        ),
278        rx,
279    )
280}
281
282/// Convenience: build a Send+Sync sharable sink for tests.
283pub fn arc_sink(tx: mpsc::Sender<StreamEvent>) -> Arc<mpsc::Sender<StreamEvent>> {
284    Arc::new(tx)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::path::PathBuf;
291
292    #[tokio::test]
293    async fn stream_context_carries_token_and_turn() {
294        let (ctx, _rx) = test_stream_context(TurnId(5));
295        assert_eq!(ctx.turn, TurnId(5));
296        assert!(!ctx.token.is_cancelled());
297    }
298
299    #[tokio::test]
300    async fn exec_context_propagates_cancel_signal() {
301        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
302        let token = ctx.token.clone();
303        tokio::spawn(async move {
304            token.cancel();
305        });
306        // Wait until cancelled.
307        ctx.token.cancelled().await;
308        assert!(ctx.token.is_cancelled());
309    }
310
311    #[tokio::test]
312    async fn progress_event_round_trips_through_channel() {
313        let (ctx, mut rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
314        ctx.progress
315            .send(ProgressEvent::Status("halfway".to_string()))
316            .await
317            .expect("send");
318        match rx.recv().await.expect("recv") {
319            ProgressEvent::Status(s) => assert_eq!(s, "halfway"),
320            _ => panic!("wrong variant"),
321        }
322    }
323}