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