dsh-ops 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,826 @@
1
+ // Modified by dsh-ops (https://github.com/T-Auto/dsh-ops): the guarded-burst test now reads the Guarded budget constant from crate::budget, where it moved with the deleted control/provider.rs module.
2
+ //! Shared MCP server plumbing for bounded blocking work and content conversion.
3
+
4
+ use crate::background_status::{BackgroundDecorator, BackgroundStatus};
5
+ use crate::budget::{
6
+ ErrorBudgetAdapter, ResponseBudgetCeiling, error_budget_hint, estimate_tokens,
7
+ };
8
+ use crate::context_guard::{BurstClaim, BurstTicket};
9
+ use crate::file_executor::GrepGlobExecutor;
10
+ use crate::model::{ImageDetail, ToolContent, ToolResponse};
11
+ use crate::operation::{OpError, OperationCtx, RequestWorkGuard};
12
+ #[cfg(test)]
13
+ use crate::operation::{TestStage, TestStageHook};
14
+ use crate::session::SessionContext;
15
+ use rmcp::model::RequestId;
16
+ use rmcp::model::{CallToolResult, ContentBlock, ImageContent, Meta};
17
+ use std::sync::Arc;
18
+ use tokio::sync::Semaphore;
19
+ use tokio_util::sync::CancellationToken;
20
+
21
+ // Admission reservation, not a claim about provider usage accounting. Codex does not expose
22
+ // visual-token usage to MCP; 3,000 conservatively covers one high-detail image after host resizing.
23
+ const GUARDED_IMAGE_TOKEN_RESERVATION: usize = 3_000;
24
+
25
+ /// Whether an operation is safe to repeat after status reservation starves its required note.
26
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
27
+ pub(crate) enum BudgetRetry {
28
+ Never,
29
+ Safe,
30
+ }
31
+
32
+ pub(crate) struct CancellableBlockingRequest {
33
+ session: Arc<SessionContext>,
34
+ request_id: RequestId,
35
+ request_cancel: CancellationToken,
36
+ permits: Arc<Semaphore>,
37
+ executor: Arc<GrepGlobExecutor>,
38
+ budget_variable: &'static str,
39
+ }
40
+
41
+ struct BurstFormatter {
42
+ claim: Option<BurstClaim>,
43
+ ceiling: Option<ResponseBudgetCeiling>,
44
+ budget_variable: &'static str,
45
+ }
46
+
47
+ impl BurstFormatter {
48
+ fn new(ticket: Option<BurstTicket>, budget_variable: &'static str) -> Self {
49
+ let claim = ticket.map(|ticket| ticket.claim(error_budget_hint(budget_variable)));
50
+ let ceiling = claim
51
+ .as_ref()
52
+ .map(|claim| ResponseBudgetCeiling::install(claim.allowance()));
53
+ Self {
54
+ claim,
55
+ ceiling,
56
+ budget_variable,
57
+ }
58
+ }
59
+
60
+ fn rendered(mut self, response: ToolResponse) -> PendingBurstResponse {
61
+ self.ceiling.take();
62
+ PendingBurstResponse {
63
+ response,
64
+ claim: self.claim.take(),
65
+ budget_variable: self.budget_variable,
66
+ }
67
+ }
68
+ }
69
+
70
+ struct PendingBurstResponse {
71
+ response: ToolResponse,
72
+ claim: Option<BurstClaim>,
73
+ budget_variable: &'static str,
74
+ }
75
+
76
+ impl PendingBurstResponse {
77
+ fn replace(mut self, response: ToolResponse) -> Self {
78
+ self.response = response;
79
+ self
80
+ }
81
+
82
+ fn deliver(mut self) -> ToolResponse {
83
+ let allowance = self.claim.as_ref().map(BurstClaim::allowance);
84
+ if self.claim.as_ref().is_some_and(|claim| claim.exhausted())
85
+ && is_budget_starvation(&self.response)
86
+ {
87
+ self.response = guarded_burst_stub(
88
+ self.budget_variable,
89
+ allowance.unwrap_or_default(),
90
+ &self.response,
91
+ );
92
+ }
93
+ let mut actual_tokens = response_accounted_tokens(&self.response);
94
+ if allowance.is_some_and(|allowance| actual_tokens > allowance) {
95
+ self.response = guarded_burst_stub(
96
+ self.budget_variable,
97
+ allowance.unwrap_or_default(),
98
+ &self.response,
99
+ );
100
+ actual_tokens = response_accounted_tokens(&self.response);
101
+ }
102
+ if let Some(claim) = self.claim.take() {
103
+ claim.complete(actual_tokens);
104
+ }
105
+ self.response
106
+ }
107
+ }
108
+
109
+ fn response_accounted_tokens(response: &ToolResponse) -> usize {
110
+ let text_tokens = response
111
+ .content
112
+ .iter()
113
+ .filter_map(|content| match content {
114
+ ToolContent::Text(text) => Some(estimate_tokens(text)),
115
+ ToolContent::Image { .. } => None,
116
+ })
117
+ .fold(0_usize, usize::saturating_add);
118
+ let image_tokens = response
119
+ .content
120
+ .iter()
121
+ .filter(|content| matches!(content, ToolContent::Image { .. }))
122
+ .count()
123
+ .saturating_mul(GUARDED_IMAGE_TOKEN_RESERVATION);
124
+ text_tokens.saturating_add(image_tokens)
125
+ }
126
+
127
+ fn is_budget_starvation(response: &ToolResponse) -> bool {
128
+ response.is_error
129
+ && response.content.iter().any(|content| {
130
+ matches!(content, ToolContent::Text(text) if {
131
+ let lower = text.to_ascii_lowercase();
132
+ text.contains("TOKEN_BUDGET")
133
+ || lower.contains("budget too small")
134
+ || lower.contains("too small to return")
135
+ })
136
+ })
137
+ }
138
+
139
+ fn guarded_burst_stub(
140
+ budget_variable: &'static str,
141
+ allowance: usize,
142
+ response: &ToolResponse,
143
+ ) -> ToolResponse {
144
+ let retrieval = match budget_variable {
145
+ crate::budget::READ_TOKEN_BUDGET_ENV => {
146
+ "Retry the same inspect_local_file call next turn; continue from any offset in the last Partial response."
147
+ }
148
+ crate::budget::GREP_TOKEN_BUDGET_ENV => {
149
+ "Retry grep next turn with the same request, or narrow path/pattern and continue from any reported offset."
150
+ }
151
+ crate::budget::GLOB_TOKEN_BUDGET_ENV => {
152
+ "Retry glob next turn with the same request and continue from any offset in the last Partial response."
153
+ }
154
+ crate::budget::RUN_TOKEN_BUDGET_ENV => {
155
+ "The command already ran. Do not repeat side effects blindly; run a safe inspection command next turn or redirect a deliberate rerun to a file."
156
+ }
157
+ crate::budget::JOB_OUTPUT_TOKEN_BUDGET_ENV => {
158
+ "Retry job_output next turn with the same job_id and after_seq cursor."
159
+ }
160
+ _ => {
161
+ "Retry the same tool call next turn; its original arguments remain the retrieval path."
162
+ }
163
+ };
164
+ let metadata = guarded_response_metadata(response);
165
+ let full = format!(
166
+ "Guarded burst stub\n- Result: {metadata}\n- State: this call exhausted its share of the FastCtx output pool for the turn; result content was withheld to preserve compaction room.\n- Retrieval: {retrieval}"
167
+ );
168
+ if estimate_tokens(&full) <= allowance {
169
+ return ToolResponse::text(full);
170
+ }
171
+ if let Some(compact) = compact_guarded_stub(&metadata, retrieval, allowance) {
172
+ return ToolResponse::text(compact);
173
+ }
174
+
175
+ // A pathological number of calls can consume the absolute 13,599-token ceiling after the
176
+ // normal 9,000-token pool is already empty. At that point no metadata-bearing stub can fit;
177
+ // fail explicitly within the remaining allowance instead of returning a silent empty success.
178
+ let emergency = [
179
+ "Guarded burst limit reached; retry next turn.",
180
+ "Retry next turn.",
181
+ "Limit.",
182
+ "",
183
+ ]
184
+ .into_iter()
185
+ .find(|candidate| estimate_tokens(candidate) <= allowance)
186
+ .unwrap_or_default();
187
+ ToolResponse::error(emergency)
188
+ }
189
+
190
+ fn compact_guarded_stub(metadata: &str, retrieval: &str, allowance: usize) -> Option<String> {
191
+ let mut prefix = metadata.chars().take(96).collect::<String>();
192
+ loop {
193
+ let ellipsis = if prefix.len() < metadata.len() {
194
+ "…"
195
+ } else {
196
+ ""
197
+ };
198
+ let candidate =
199
+ format!("Guarded burst withheld this result ({prefix}{ellipsis}). {retrieval}");
200
+ if !prefix.is_empty() && estimate_tokens(&candidate) <= allowance {
201
+ return Some(candidate);
202
+ }
203
+ prefix.pop()?;
204
+ }
205
+ }
206
+
207
+ fn guarded_response_metadata(response: &ToolResponse) -> String {
208
+ let image_count = response
209
+ .content
210
+ .iter()
211
+ .filter(|content| matches!(content, ToolContent::Image { .. }))
212
+ .count();
213
+ let terminal = response.content.iter().rev().find_map(|content| {
214
+ let ToolContent::Text(text) = content else {
215
+ return None;
216
+ };
217
+ text.lines()
218
+ .rev()
219
+ .find(|line| {
220
+ let line = line.trim();
221
+ line.starts_with("(Complete:")
222
+ || line.starts_with("(Partial:")
223
+ || line.starts_with("(Killed:")
224
+ || line.starts_with("Script running with cell ID")
225
+ })
226
+ .map(str::trim)
227
+ });
228
+ match (terminal, image_count) {
229
+ (Some(terminal), 0) => terminal.to_string(),
230
+ (Some(terminal), count) => format!("{terminal}; {count} image block(s)"),
231
+ (None, 0) if response.is_error => "the tool completed with an error".to_string(),
232
+ (None, 0) => "the tool completed successfully".to_string(),
233
+ (None, count) => format!("the tool completed with {count} image block(s)"),
234
+ }
235
+ }
236
+
237
+ fn finish_early_response(
238
+ session: &Arc<SessionContext>,
239
+ ticket: Option<BurstTicket>,
240
+ budget_variable: &'static str,
241
+ response: ToolResponse,
242
+ ) -> CallToolResult {
243
+ let response = session.activate(|| {
244
+ let formatter = BurstFormatter::new(ticket, budget_variable);
245
+ let adapter = ErrorBudgetAdapter::new(error_budget_hint(budget_variable), budget_variable);
246
+ formatter.rendered(adapter.adapt(response)).deliver()
247
+ });
248
+ into_mcp_result(response)
249
+ }
250
+
251
+ fn await_test_tool_barrier() {
252
+ #[cfg(debug_assertions)]
253
+ if let Ok(directory) = crate::session::var("FASTCTX_TEST_TOOL_BARRIER_DIR") {
254
+ use std::sync::atomic::{AtomicUsize, Ordering};
255
+
256
+ static NEXT_PARTICIPANT: AtomicUsize = AtomicUsize::new(0);
257
+ let directory = std::path::PathBuf::from(directory);
258
+ let participant = NEXT_PARTICIPANT.fetch_add(1, Ordering::Relaxed);
259
+ std::fs::write(directory.join(format!("participant-{participant}")), [])
260
+ .expect("the guarded-burst test barrier must accept participant markers");
261
+
262
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
263
+ loop {
264
+ let participants = std::fs::read_dir(&directory)
265
+ .expect("the guarded-burst test barrier must remain readable")
266
+ .filter_map(Result::ok)
267
+ .filter(|entry| {
268
+ entry
269
+ .file_name()
270
+ .to_string_lossy()
271
+ .starts_with("participant-")
272
+ })
273
+ .count();
274
+ if participants >= 2 {
275
+ break;
276
+ }
277
+ assert!(
278
+ std::time::Instant::now() < deadline,
279
+ "two guarded tool calls did not reach blocking work concurrently"
280
+ );
281
+ std::thread::sleep(std::time::Duration::from_millis(10));
282
+ }
283
+ }
284
+ }
285
+
286
+ impl CancellableBlockingRequest {
287
+ pub(crate) fn new(
288
+ session: Arc<SessionContext>,
289
+ request_id: RequestId,
290
+ request_cancel: CancellationToken,
291
+ permits: Arc<Semaphore>,
292
+ executor: Arc<GrepGlobExecutor>,
293
+ budget_variable: &'static str,
294
+ ) -> Self {
295
+ Self {
296
+ session,
297
+ request_id,
298
+ request_cancel,
299
+ permits,
300
+ executor,
301
+ budget_variable,
302
+ }
303
+ }
304
+ }
305
+
306
+ /// Runs synchronous tool work behind a shared semaphore and converts its response.
307
+ pub(crate) async fn run_blocking(
308
+ session: Arc<SessionContext>,
309
+ permits: Arc<Semaphore>,
310
+ budget_variable: &'static str,
311
+ status: impl FnOnce() -> Option<BackgroundStatus> + Send + 'static,
312
+ retry: BudgetRetry,
313
+ mut operation: impl FnMut() -> ToolResponse + Send + 'static,
314
+ ) -> CallToolResult {
315
+ let burst_ticket = session.begin_guarded_response();
316
+ let permit = match permits.acquire_owned().await {
317
+ Ok(permit) => permit,
318
+ Err(_) => {
319
+ return finish_early_response(
320
+ &session,
321
+ burst_ticket,
322
+ budget_variable,
323
+ ToolResponse::error(
324
+ "Internal tool failure: the blocking-operation limiter is unavailable.",
325
+ ),
326
+ );
327
+ }
328
+ };
329
+ let failure_session = Arc::clone(&session);
330
+ match tokio::task::spawn_blocking(move || {
331
+ let _permit = permit;
332
+ session.activate(|| {
333
+ await_test_tool_barrier();
334
+ let burst = BurstFormatter::new(burst_ticket, budget_variable);
335
+ let decorator = BackgroundDecorator::new(status(), budget_variable);
336
+ let response = loop {
337
+ let response = operation();
338
+ if retry == BudgetRetry::Safe && decorator.retry_after_budget_starvation(&response)
339
+ {
340
+ continue;
341
+ }
342
+ break decorator.finish(response);
343
+ };
344
+ burst.rendered(response).deliver()
345
+ })
346
+ })
347
+ .await
348
+ {
349
+ Ok(response) => into_mcp_result(response),
350
+ Err(error) => {
351
+ let ticket = failure_session.begin_guarded_response();
352
+ finish_early_response(
353
+ &failure_session,
354
+ ticket,
355
+ budget_variable,
356
+ ToolResponse::error(format!("Internal tool failure: {error}")),
357
+ )
358
+ }
359
+ }
360
+ }
361
+
362
+ /// Runs grep/glob work with cancel-aware admission and a drop-cancelled blocking sibling.
363
+ pub(crate) async fn run_blocking_cancellable(
364
+ request: CancellableBlockingRequest,
365
+ status: impl FnOnce() -> Option<BackgroundStatus> + Send + 'static,
366
+ operation: impl FnMut(OperationCtx, Arc<GrepGlobExecutor>) -> Result<ToolResponse, OpError>
367
+ + Send
368
+ + 'static,
369
+ ) -> CallToolResult {
370
+ let CancellableBlockingRequest {
371
+ session,
372
+ request_id,
373
+ request_cancel,
374
+ permits,
375
+ executor,
376
+ budget_variable,
377
+ } = request;
378
+ let (guard, operation_context) = RequestWorkGuard::new(request_id, request_cancel);
379
+ let burst_ticket = session.begin_guarded_response();
380
+ run_blocking_cancellable_with_context(
381
+ guard,
382
+ operation_context,
383
+ CancellableBlockingResources {
384
+ session,
385
+ permits,
386
+ executor,
387
+ burst_ticket,
388
+ budget_variable,
389
+ },
390
+ status,
391
+ operation,
392
+ )
393
+ .await
394
+ }
395
+
396
+ #[cfg(test)]
397
+ async fn run_blocking_cancellable_with_hook(
398
+ request_id: RequestId,
399
+ request_cancel: CancellationToken,
400
+ permits: Arc<Semaphore>,
401
+ executor: Arc<GrepGlobExecutor>,
402
+ budget_variable: &'static str,
403
+ stage_hook: TestStageHook,
404
+ operation: impl FnMut(OperationCtx, Arc<GrepGlobExecutor>) -> Result<ToolResponse, OpError>
405
+ + Send
406
+ + 'static,
407
+ ) -> CallToolResult {
408
+ let session = SessionContext::library_default();
409
+ let (guard, operation_context) =
410
+ RequestWorkGuard::new_with_hook(request_id, request_cancel, stage_hook);
411
+ let burst_ticket = session.begin_guarded_response();
412
+ run_blocking_cancellable_with_context(
413
+ guard,
414
+ operation_context,
415
+ CancellableBlockingResources {
416
+ session,
417
+ permits,
418
+ executor,
419
+ burst_ticket,
420
+ budget_variable,
421
+ },
422
+ || None,
423
+ operation,
424
+ )
425
+ .await
426
+ }
427
+
428
+ struct CancellableBlockingResources {
429
+ session: Arc<SessionContext>,
430
+ permits: Arc<Semaphore>,
431
+ executor: Arc<GrepGlobExecutor>,
432
+ burst_ticket: Option<BurstTicket>,
433
+ budget_variable: &'static str,
434
+ }
435
+
436
+ async fn run_blocking_cancellable_with_context(
437
+ mut guard: RequestWorkGuard,
438
+ operation_context: OperationCtx,
439
+ resources: CancellableBlockingResources,
440
+ status: impl FnOnce() -> Option<BackgroundStatus> + Send + 'static,
441
+ mut operation: impl FnMut(OperationCtx, Arc<GrepGlobExecutor>) -> Result<ToolResponse, OpError>
442
+ + Send
443
+ + 'static,
444
+ ) -> CallToolResult {
445
+ let CancellableBlockingResources {
446
+ session,
447
+ permits,
448
+ executor,
449
+ mut burst_ticket,
450
+ budget_variable,
451
+ } = resources;
452
+ #[cfg(test)]
453
+ operation_context.stage(TestStage::BeforeFilePermit);
454
+ let cancellation = operation_context.cancellation_token().clone();
455
+ let permit = tokio::select! {
456
+ _ = cancellation.cancelled() => {
457
+ guard.disarm();
458
+ return finish_early_response(
459
+ &session,
460
+ burst_ticket.take(),
461
+ budget_variable,
462
+ ToolResponse::error("Request cancelled."),
463
+ );
464
+ }
465
+ permit = permits.acquire_owned() => match permit {
466
+ Ok(permit) => permit,
467
+ Err(_) => {
468
+ guard.disarm();
469
+ return finish_early_response(
470
+ &session,
471
+ burst_ticket.take(),
472
+ budget_variable,
473
+ ToolResponse::error(
474
+ "Internal tool failure: the blocking-operation limiter is unavailable.",
475
+ ),
476
+ );
477
+ }
478
+ }
479
+ };
480
+ #[cfg(test)]
481
+ operation_context.stage(TestStage::AfterFilePermit);
482
+ if let Err(error) = operation_context.check() {
483
+ drop(permit);
484
+ guard.disarm();
485
+ return finish_early_response(
486
+ &session,
487
+ burst_ticket.take(),
488
+ budget_variable,
489
+ error.into_response(),
490
+ );
491
+ }
492
+
493
+ let completion_context = operation_context.clone();
494
+ let failure_session = Arc::clone(&session);
495
+ let result = tokio::task::spawn_blocking(move || {
496
+ let _permit = permit;
497
+ session.activate(|| {
498
+ await_test_tool_barrier();
499
+ let burst = BurstFormatter::new(burst_ticket, budget_variable);
500
+ let error_adapter =
501
+ ErrorBudgetAdapter::new(error_budget_hint(budget_variable), budget_variable);
502
+ let decorator = BackgroundDecorator::new(status(), budget_variable);
503
+ let response = loop {
504
+ if let Err(error) = operation_context.check() {
505
+ break error_adapter.adapt(error.into_response());
506
+ }
507
+ let response = match operation(operation_context.clone(), Arc::clone(&executor)) {
508
+ Ok(response) => response,
509
+ Err(error) => break error_adapter.adapt(error.into_response()),
510
+ };
511
+ if let Err(error) = operation_context.check() {
512
+ break error_adapter.adapt(error.into_response());
513
+ }
514
+ if decorator.retry_after_budget_starvation(&response) {
515
+ continue;
516
+ }
517
+ break decorator.finish(response);
518
+ };
519
+ burst.rendered(response)
520
+ })
521
+ })
522
+ .await;
523
+ let completion_error = completion_context.check().err();
524
+ guard.disarm();
525
+ if let Some(error) = completion_error {
526
+ return match result {
527
+ Ok(pending) => into_mcp_result(pending.replace(error.into_response()).deliver()),
528
+ Err(_) => finish_early_response(
529
+ &failure_session,
530
+ failure_session.begin_guarded_response(),
531
+ budget_variable,
532
+ error.into_response(),
533
+ ),
534
+ };
535
+ }
536
+ match result {
537
+ Ok(pending) => into_mcp_result(pending.deliver()),
538
+ Err(error) => finish_early_response(
539
+ &failure_session,
540
+ failure_session.begin_guarded_response(),
541
+ budget_variable,
542
+ ToolResponse::error(format!("Internal tool failure: {error}")),
543
+ ),
544
+ }
545
+ }
546
+
547
+ /// Converts the protocol-independent response without ever adding structured content.
548
+ pub(crate) fn into_mcp_result(response: ToolResponse) -> CallToolResult {
549
+ let content = response
550
+ .content
551
+ .into_iter()
552
+ .map(|block| match block {
553
+ ToolContent::Text(text) => ContentBlock::text(text),
554
+ ToolContent::Image {
555
+ data,
556
+ mime_type,
557
+ detail,
558
+ } => {
559
+ let image = ImageContent::new(data, mime_type);
560
+ if detail == Some(ImageDetail::High) {
561
+ let mut meta = Meta::new();
562
+ meta.0.insert(
563
+ "codex/imageDetail".to_string(),
564
+ serde_json::Value::String("high".to_string()),
565
+ );
566
+ ContentBlock::Image(image.with_meta(meta))
567
+ } else {
568
+ ContentBlock::Image(image)
569
+ }
570
+ }
571
+ })
572
+ .collect::<Vec<_>>();
573
+ if response.is_error {
574
+ CallToolResult::error(content)
575
+ } else {
576
+ CallToolResult::success(content)
577
+ }
578
+ }
579
+
580
+ #[cfg(test)]
581
+ mod tests {
582
+ use super::{
583
+ BudgetRetry, CancellableBlockingRequest, GUARDED_IMAGE_TOKEN_RESERVATION, into_mcp_result,
584
+ response_accounted_tokens, run_blocking, run_blocking_cancellable,
585
+ run_blocking_cancellable_with_hook,
586
+ };
587
+ use crate::budget::{GLOBAL_TOKEN_BUDGET_ENV, GREP_TOKEN_BUDGET_ENV};
588
+ use crate::file_executor::GrepGlobExecutor;
589
+ use crate::operation::{OpError, TestStage};
590
+ use crate::{ImageDetail, ToolContent, ToolResponse};
591
+ use rmcp::model::RequestId;
592
+ use std::sync::{Arc, mpsc};
593
+ use std::time::Duration;
594
+ use tokio::sync::Semaphore;
595
+ use tokio_util::sync::CancellationToken;
596
+
597
+ fn request_id(value: i64) -> RequestId {
598
+ RequestId::Number(value)
599
+ }
600
+
601
+ fn file_executor() -> Arc<GrepGlobExecutor> {
602
+ Arc::new(GrepGlobExecutor::new())
603
+ }
604
+
605
+ fn error_text(result: rmcp::model::CallToolResult) -> String {
606
+ assert_eq!(result.is_error, Some(true));
607
+ let value = serde_json::to_value(result).unwrap();
608
+ value["content"][0]["text"].as_str().unwrap().to_string()
609
+ }
610
+
611
+ #[test]
612
+ fn pdf_image_detail_is_preserved_in_mcp_meta_without_structured_content() {
613
+ let result = into_mcp_result(ToolResponse {
614
+ content: vec![ToolContent::Image {
615
+ data: "AA==".to_string(),
616
+ mime_type: "image/png".to_string(),
617
+ detail: Some(ImageDetail::High),
618
+ }],
619
+ is_error: false,
620
+ });
621
+ let value = serde_json::to_value(result).unwrap();
622
+ assert_eq!(value["content"][0]["_meta"]["codex/imageDetail"], "high");
623
+ assert!(value.get("structuredContent").is_none());
624
+ }
625
+
626
+ #[test]
627
+ fn guarded_accounting_combines_text_with_every_image_reservation() {
628
+ let response = ToolResponse {
629
+ content: std::iter::once(ToolContent::Text("image metadata".to_string()))
630
+ .chain((0..4).map(|_| ToolContent::Image {
631
+ data: "AA==".to_string(),
632
+ mime_type: "image/png".to_string(),
633
+ detail: Some(ImageDetail::High),
634
+ }))
635
+ .collect(),
636
+ is_error: false,
637
+ };
638
+ let accounted = response_accounted_tokens(&response);
639
+ assert!(accounted > 4 * GUARDED_IMAGE_TOKEN_RESERVATION);
640
+ assert!(accounted > crate::budget::GUARDED_FASTCTX_BUDGET);
641
+ }
642
+
643
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
644
+ async fn blocking_operations_are_bounded_before_they_reach_tokio() {
645
+ let permits = Arc::new(Semaphore::new(1));
646
+ let (first_started_tx, first_started_rx) = mpsc::channel();
647
+ let (release_first_tx, release_first_rx) = mpsc::channel();
648
+ let first_permits = Arc::clone(&permits);
649
+ let first = tokio::spawn(async move {
650
+ run_blocking(
651
+ crate::session::SessionContext::library_default(),
652
+ first_permits,
653
+ GLOBAL_TOKEN_BUDGET_ENV,
654
+ || None,
655
+ BudgetRetry::Never,
656
+ move || {
657
+ first_started_tx.send(()).unwrap();
658
+ release_first_rx.recv().unwrap();
659
+ ToolResponse::text("first")
660
+ },
661
+ )
662
+ .await
663
+ });
664
+ first_started_rx.recv().unwrap();
665
+
666
+ let (second_waiting_tx, second_waiting_rx) = mpsc::channel();
667
+ let (second_started_tx, second_started_rx) = mpsc::channel();
668
+ let second = tokio::spawn(async move {
669
+ second_waiting_tx.send(()).unwrap();
670
+ run_blocking(
671
+ crate::session::SessionContext::library_default(),
672
+ permits,
673
+ GLOBAL_TOKEN_BUDGET_ENV,
674
+ || None,
675
+ BudgetRetry::Never,
676
+ move || {
677
+ second_started_tx.send(()).unwrap();
678
+ ToolResponse::text("second")
679
+ },
680
+ )
681
+ .await
682
+ });
683
+ second_waiting_rx.recv().unwrap();
684
+ assert!(matches!(
685
+ second_started_rx.recv_timeout(Duration::from_millis(100)),
686
+ Err(mpsc::RecvTimeoutError::Timeout)
687
+ ));
688
+ release_first_tx.send(()).unwrap();
689
+ first.await.unwrap();
690
+ second.await.unwrap();
691
+ second_started_rx.recv().unwrap();
692
+ }
693
+
694
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
695
+ async fn queued_cancellation_never_starts_blocking_work_or_leaks_a_permit() {
696
+ let permits = Arc::new(Semaphore::new(1));
697
+ let held = Arc::clone(&permits).acquire_owned().await.unwrap();
698
+ let request_cancel = CancellationToken::new();
699
+ let (waiting_tx, waiting_rx) = mpsc::channel();
700
+ let (started_tx, started_rx) = mpsc::channel();
701
+ let hook = Arc::new(move |stage| {
702
+ if stage == TestStage::BeforeFilePermit {
703
+ waiting_tx.send(()).unwrap();
704
+ }
705
+ });
706
+ let task = tokio::spawn(run_blocking_cancellable_with_hook(
707
+ request_id(10),
708
+ request_cancel.clone(),
709
+ Arc::clone(&permits),
710
+ file_executor(),
711
+ GREP_TOKEN_BUDGET_ENV,
712
+ hook,
713
+ move |_, _| {
714
+ started_tx.send(()).unwrap();
715
+ Ok(ToolResponse::text("unexpected"))
716
+ },
717
+ ));
718
+ waiting_rx.recv_timeout(Duration::from_secs(1)).unwrap();
719
+ request_cancel.cancel();
720
+ let result = tokio::time::timeout(Duration::from_secs(1), task)
721
+ .await
722
+ .unwrap()
723
+ .unwrap();
724
+ assert_eq!(error_text(result), "Request cancelled.");
725
+ assert!(matches!(
726
+ started_rx.try_recv(),
727
+ Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected)
728
+ ));
729
+ assert_eq!(permits.available_permits(), 0);
730
+ drop(held);
731
+ assert_eq!(permits.available_permits(), 1);
732
+ }
733
+
734
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
735
+ async fn cancellation_after_admission_cannot_cross_into_the_coordinator() {
736
+ let permits = Arc::new(Semaphore::new(1));
737
+ let request_cancel = CancellationToken::new();
738
+ let hook_cancel = request_cancel.clone();
739
+ let hook = Arc::new(move |stage| {
740
+ if stage == TestStage::AfterFilePermit {
741
+ hook_cancel.cancel();
742
+ }
743
+ });
744
+ let (started_tx, started_rx) = mpsc::channel();
745
+ let result = run_blocking_cancellable_with_hook(
746
+ request_id(11),
747
+ request_cancel,
748
+ Arc::clone(&permits),
749
+ file_executor(),
750
+ GREP_TOKEN_BUDGET_ENV,
751
+ hook,
752
+ move |_, _| {
753
+ started_tx.send(()).unwrap();
754
+ Ok(ToolResponse::text("unexpected"))
755
+ },
756
+ )
757
+ .await;
758
+ assert_eq!(error_text(result), "Request cancelled.");
759
+ assert!(matches!(
760
+ started_rx.try_recv(),
761
+ Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected)
762
+ ));
763
+ assert_eq!(permits.available_permits(), 1);
764
+ }
765
+
766
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
767
+ async fn dropping_the_async_future_cancels_started_blocking_work() {
768
+ let permits = Arc::new(Semaphore::new(1));
769
+ let parent = CancellationToken::new();
770
+ let (started_tx, started_rx) = mpsc::channel();
771
+ let (cancelled_tx, cancelled_rx) = mpsc::channel();
772
+ let task = tokio::spawn(run_blocking_cancellable(
773
+ CancellableBlockingRequest::new(
774
+ crate::session::SessionContext::library_default(),
775
+ request_id(12),
776
+ parent.clone(),
777
+ Arc::clone(&permits),
778
+ file_executor(),
779
+ GREP_TOKEN_BUDGET_ENV,
780
+ ),
781
+ || None,
782
+ move |operation, _| {
783
+ started_tx.send(()).unwrap();
784
+ loop {
785
+ if operation.check() == Err(OpError::Cancelled) {
786
+ cancelled_tx.send(()).unwrap();
787
+ return Err(OpError::Cancelled);
788
+ }
789
+ std::thread::yield_now();
790
+ }
791
+ },
792
+ ));
793
+ started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
794
+ task.abort();
795
+ assert!(task.await.unwrap_err().is_cancelled());
796
+ cancelled_rx.recv_timeout(Duration::from_secs(1)).unwrap();
797
+ tokio::time::timeout(Duration::from_secs(1), async {
798
+ while permits.available_permits() != 1 {
799
+ tokio::task::yield_now().await;
800
+ }
801
+ })
802
+ .await
803
+ .unwrap();
804
+ assert!(!parent.is_cancelled());
805
+ }
806
+
807
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
808
+ async fn a_panicking_coordinator_returns_the_file_permit() {
809
+ let permits = Arc::new(Semaphore::new(1));
810
+ let result = run_blocking_cancellable(
811
+ CancellableBlockingRequest::new(
812
+ crate::session::SessionContext::library_default(),
813
+ request_id(13),
814
+ CancellationToken::new(),
815
+ Arc::clone(&permits),
816
+ file_executor(),
817
+ GREP_TOKEN_BUDGET_ENV,
818
+ ),
819
+ || None,
820
+ move |_, _| -> Result<ToolResponse, OpError> { panic!("injected coordinator panic") },
821
+ )
822
+ .await;
823
+ assert!(error_text(result).starts_with("Internal tool failure: task "));
824
+ assert_eq!(permits.available_permits(), 1);
825
+ }
826
+ }