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