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.
- package/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
//! Per-response background-job status rendering and budget-safe insertion.
|
|
2
|
+
|
|
3
|
+
use crate::budget::{ResponseReservation, ResponseReservationOutcome, estimate_tokens};
|
|
4
|
+
use crate::{ToolContent, ToolResponse};
|
|
5
|
+
use std::time::{Duration, SystemTime};
|
|
6
|
+
|
|
7
|
+
/// Lifecycle displayed for one job known to the current MCP session.
|
|
8
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
9
|
+
pub(crate) enum BackgroundState {
|
|
10
|
+
Running,
|
|
11
|
+
Exited(i32),
|
|
12
|
+
Killed,
|
|
13
|
+
Interrupted,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// One status entry with the persisted start time used for sorting and elapsed rendering.
|
|
17
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
18
|
+
pub(crate) struct BackgroundEntry {
|
|
19
|
+
pub(crate) job_id: String,
|
|
20
|
+
pub(crate) started_at: SystemTime,
|
|
21
|
+
pub(crate) started_sort_key: u64,
|
|
22
|
+
pub(crate) state: BackgroundState,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Fully rendered full and summary forms for one response instant.
|
|
26
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
27
|
+
pub(crate) struct BackgroundStatus {
|
|
28
|
+
full_line: String,
|
|
29
|
+
summary_line: String,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl BackgroundStatus {
|
|
33
|
+
pub(crate) fn render(mut entries: Vec<BackgroundEntry>, now: SystemTime) -> Option<Self> {
|
|
34
|
+
if entries.is_empty() {
|
|
35
|
+
return None;
|
|
36
|
+
}
|
|
37
|
+
entries.sort_by(|left, right| {
|
|
38
|
+
left.started_sort_key
|
|
39
|
+
.cmp(&right.started_sort_key)
|
|
40
|
+
.then_with(|| left.job_id.as_bytes().cmp(right.job_id.as_bytes()))
|
|
41
|
+
});
|
|
42
|
+
let running_count = entries
|
|
43
|
+
.iter()
|
|
44
|
+
.filter(|entry| entry.state == BackgroundState::Running)
|
|
45
|
+
.count();
|
|
46
|
+
let rendered = entries
|
|
47
|
+
.into_iter()
|
|
48
|
+
.map(|entry| match entry.state {
|
|
49
|
+
BackgroundState::Running => format!(
|
|
50
|
+
"{} running {}",
|
|
51
|
+
entry.job_id,
|
|
52
|
+
format_elapsed(now.duration_since(entry.started_at).unwrap_or_default())
|
|
53
|
+
),
|
|
54
|
+
BackgroundState::Exited(code) => format!("{} exited {code}", entry.job_id),
|
|
55
|
+
BackgroundState::Killed => format!("{} killed", entry.job_id),
|
|
56
|
+
BackgroundState::Interrupted => format!("{} interrupted", entry.job_id),
|
|
57
|
+
})
|
|
58
|
+
.collect::<Vec<_>>()
|
|
59
|
+
.join(", ");
|
|
60
|
+
let running_noun = if running_count == 1 { "job" } else { "jobs" };
|
|
61
|
+
Some(Self {
|
|
62
|
+
full_line: format!("(Background: {rendered}.)"),
|
|
63
|
+
summary_line: format!("(Background: {running_count} {running_noun} running.)"),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn format_elapsed(elapsed: Duration) -> String {
|
|
69
|
+
let seconds = elapsed.as_secs();
|
|
70
|
+
if seconds < 60 {
|
|
71
|
+
format!("{seconds}s")
|
|
72
|
+
} else if seconds < 3_600 {
|
|
73
|
+
format!("{}m{}s", seconds / 60, seconds % 60)
|
|
74
|
+
} else {
|
|
75
|
+
format!("{}h{}m", seconds / 3_600, (seconds % 3_600) / 60)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Reservation plus final response insertion for one tool call.
|
|
80
|
+
pub(crate) struct BackgroundDecorator {
|
|
81
|
+
reservation: Option<ResponseReservation>,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
impl BackgroundDecorator {
|
|
85
|
+
pub(crate) fn new(status: Option<BackgroundStatus>, budget_variable: &'static str) -> Self {
|
|
86
|
+
let reservation = status.and_then(|status| {
|
|
87
|
+
ResponseReservation::install(budget_variable, status.full_line, status.summary_line)
|
|
88
|
+
});
|
|
89
|
+
Self { reservation }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#[cfg(test)]
|
|
93
|
+
fn with_budget(status: BackgroundStatus, budget_variable: &'static str, budget: usize) -> Self {
|
|
94
|
+
Self {
|
|
95
|
+
reservation: Some(ResponseReservation::install_for_test(
|
|
96
|
+
budget_variable,
|
|
97
|
+
budget,
|
|
98
|
+
status.full_line,
|
|
99
|
+
status.summary_line,
|
|
100
|
+
)),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Returns true when a retry-safe formatter should run again with the next status tier.
|
|
105
|
+
pub(crate) fn retry_after_budget_starvation(&self, response: &ToolResponse) -> bool {
|
|
106
|
+
self.reservation
|
|
107
|
+
.as_ref()
|
|
108
|
+
.is_some_and(|reservation| is_budget_starvation(response) && reservation.downgrade())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pub(crate) fn finish(self, response: ToolResponse) -> ToolResponse {
|
|
112
|
+
let Some(reservation) = self.reservation else {
|
|
113
|
+
return response;
|
|
114
|
+
};
|
|
115
|
+
let outcome = reservation.finish();
|
|
116
|
+
decorate_response(response, outcome)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn is_budget_starvation(response: &ToolResponse) -> bool {
|
|
121
|
+
response.is_error
|
|
122
|
+
&& response.content.iter().any(|content| {
|
|
123
|
+
matches!(content, ToolContent::Text(text) if text.to_ascii_lowercase().contains("too small to return"))
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fn decorate_response(response: ToolResponse, outcome: ResponseReservationOutcome) -> ToolResponse {
|
|
128
|
+
if response.is_error {
|
|
129
|
+
return response;
|
|
130
|
+
}
|
|
131
|
+
let Some(mut line) = outcome.line else {
|
|
132
|
+
return response;
|
|
133
|
+
};
|
|
134
|
+
let Some(index) = response
|
|
135
|
+
.content
|
|
136
|
+
.iter()
|
|
137
|
+
.rposition(|content| matches!(content, ToolContent::Text(_)))
|
|
138
|
+
else {
|
|
139
|
+
return response;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
loop {
|
|
143
|
+
let mut candidate = response.clone();
|
|
144
|
+
let ToolContent::Text(text) = &mut candidate.content[index] else {
|
|
145
|
+
unreachable!("the selected content block is text")
|
|
146
|
+
};
|
|
147
|
+
insert_status_line(text, &line);
|
|
148
|
+
let tokens = candidate
|
|
149
|
+
.content
|
|
150
|
+
.iter()
|
|
151
|
+
.filter_map(|content| match content {
|
|
152
|
+
ToolContent::Text(text) => Some(estimate_tokens(text)),
|
|
153
|
+
ToolContent::Image { .. } => None,
|
|
154
|
+
})
|
|
155
|
+
.fold(0_usize, usize::saturating_add);
|
|
156
|
+
if tokens <= outcome.configured_budget {
|
|
157
|
+
return candidate;
|
|
158
|
+
}
|
|
159
|
+
// The reservation counts separators conservatively, so this is only a final guard
|
|
160
|
+
// against multi-block totals or tokenizer boundary effects. User content wins.
|
|
161
|
+
if line != outcome.summary_line {
|
|
162
|
+
line.clone_from(&outcome.summary_line);
|
|
163
|
+
} else {
|
|
164
|
+
return response;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn insert_status_line(text: &mut String, status: &str) {
|
|
170
|
+
let terminal_start = text.rfind('\n').map_or(0, |index| index.saturating_add(1));
|
|
171
|
+
let last = &text[terminal_start..];
|
|
172
|
+
if last.starts_with("(Complete:")
|
|
173
|
+
|| last.starts_with("(Partial:")
|
|
174
|
+
|| last.starts_with("(Killed:")
|
|
175
|
+
{
|
|
176
|
+
if terminal_start == 0 {
|
|
177
|
+
*text = format!("{status}\n{text}");
|
|
178
|
+
} else {
|
|
179
|
+
text.insert_str(terminal_start, &format!("{status}\n"));
|
|
180
|
+
}
|
|
181
|
+
} else if text.is_empty() {
|
|
182
|
+
text.push_str(status);
|
|
183
|
+
} else {
|
|
184
|
+
text.push_str("\n\n");
|
|
185
|
+
text.push_str(status);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[cfg(test)]
|
|
190
|
+
mod tests {
|
|
191
|
+
use super::{BackgroundEntry, BackgroundState, BackgroundStatus, insert_status_line};
|
|
192
|
+
use crate::background_status::BackgroundDecorator;
|
|
193
|
+
use crate::budget::{GLOBAL_TOKEN_BUDGET_ENV, estimate_tokens, token_budget};
|
|
194
|
+
use crate::{ImageDetail, ToolContent, ToolResponse};
|
|
195
|
+
use std::time::{Duration, SystemTime};
|
|
196
|
+
|
|
197
|
+
fn entry(id: &str, age: u64, state: BackgroundState, now: SystemTime) -> BackgroundEntry {
|
|
198
|
+
BackgroundEntry {
|
|
199
|
+
job_id: id.to_string(),
|
|
200
|
+
started_at: now - Duration::from_secs(age),
|
|
201
|
+
started_sort_key: u64::MAX - age,
|
|
202
|
+
state,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[test]
|
|
207
|
+
fn elapsed_boundaries_and_lifecycle_forms_are_byte_exact() {
|
|
208
|
+
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
|
|
209
|
+
let cases = [
|
|
210
|
+
(59, "j running 59s"),
|
|
211
|
+
(60, "j running 1m0s"),
|
|
212
|
+
(3_599, "j running 59m59s"),
|
|
213
|
+
(3_600, "j running 1h0m"),
|
|
214
|
+
];
|
|
215
|
+
for (age, expected) in cases {
|
|
216
|
+
let status =
|
|
217
|
+
BackgroundStatus::render(vec![entry("j", age, BackgroundState::Running, now)], now)
|
|
218
|
+
.unwrap();
|
|
219
|
+
assert_eq!(status.full_line, format!("(Background: {expected}.)"));
|
|
220
|
+
assert_eq!(status.summary_line, "(Background: 1 job running.)");
|
|
221
|
+
}
|
|
222
|
+
let status = BackgroundStatus::render(
|
|
223
|
+
vec![
|
|
224
|
+
entry("exit", 5, BackgroundState::Exited(42), now),
|
|
225
|
+
entry("lost", 4, BackgroundState::Interrupted, now),
|
|
226
|
+
],
|
|
227
|
+
now,
|
|
228
|
+
)
|
|
229
|
+
.unwrap();
|
|
230
|
+
assert_eq!(
|
|
231
|
+
status.full_line,
|
|
232
|
+
"(Background: exit exited 42, lost interrupted.)"
|
|
233
|
+
);
|
|
234
|
+
assert_eq!(status.summary_line, "(Background: 0 jobs running.)");
|
|
235
|
+
assert!(!status.full_line.contains("line"));
|
|
236
|
+
assert!(!status.full_line.contains("byte"));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn status_sits_immediately_before_the_terminal_line() {
|
|
241
|
+
let mut text = "body\n\n(Note: detail.)\n(Complete: done.)".to_string();
|
|
242
|
+
insert_status_line(&mut text, "(Background: j running 1s.)");
|
|
243
|
+
assert_eq!(
|
|
244
|
+
text,
|
|
245
|
+
"body\n\n(Note: detail.)\n(Background: j running 1s.)\n(Complete: done.)"
|
|
246
|
+
);
|
|
247
|
+
assert_eq!(text.lines().last(), Some("(Complete: done.)"));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn synthetic_status() -> BackgroundStatus {
|
|
251
|
+
BackgroundStatus {
|
|
252
|
+
full_line: "(Background: j-alpha running 12s, j-beta exited 7.)".to_string(),
|
|
253
|
+
summary_line: "(Background: 1 job running.)".to_string(),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn budget_error(value: usize) -> ToolResponse {
|
|
258
|
+
ToolResponse::error(format!(
|
|
259
|
+
"FASTCTX_TOKEN_BUDGET={value} is too small to return the required status note. Increase it and retry."
|
|
260
|
+
))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fn render_user_lines(budget: usize) -> ToolResponse {
|
|
264
|
+
let terminal = "(Complete: done.)";
|
|
265
|
+
let mut lines = Vec::new();
|
|
266
|
+
for index in 0..1_000 {
|
|
267
|
+
let mut candidate = lines.clone();
|
|
268
|
+
candidate.push(format!("user-{index:04}-payload"));
|
|
269
|
+
let text = format!("{}\n\n{terminal}", candidate.join("\n"));
|
|
270
|
+
if estimate_tokens(&text) > budget {
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
lines = candidate;
|
|
274
|
+
}
|
|
275
|
+
ToolResponse::text(format!("{}\n\n{terminal}", lines.join("\n")))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[test]
|
|
279
|
+
fn full_status_is_prepaid_and_reduces_the_body_before_formatting() {
|
|
280
|
+
let configured = 96;
|
|
281
|
+
let without_status = render_user_lines(configured);
|
|
282
|
+
let decorator = BackgroundDecorator::with_budget(
|
|
283
|
+
synthetic_status(),
|
|
284
|
+
GLOBAL_TOKEN_BUDGET_ENV,
|
|
285
|
+
configured,
|
|
286
|
+
);
|
|
287
|
+
let effective = token_budget().unwrap();
|
|
288
|
+
assert!(effective < configured);
|
|
289
|
+
let with_status = decorator.finish(render_user_lines(effective));
|
|
290
|
+
let ToolContent::Text(without_text) = &without_status.content[0] else {
|
|
291
|
+
panic!("expected text")
|
|
292
|
+
};
|
|
293
|
+
let ToolContent::Text(with_text) = &with_status.content[0] else {
|
|
294
|
+
panic!("expected text")
|
|
295
|
+
};
|
|
296
|
+
assert!(with_text.contains("j-alpha running 12s"));
|
|
297
|
+
assert!(with_text.matches("user-").count() < without_text.matches("user-").count());
|
|
298
|
+
assert!(estimate_tokens(with_text) <= configured);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[test]
|
|
302
|
+
fn reservation_degrades_full_to_summary_then_omits_before_reformatting() {
|
|
303
|
+
let status = synthetic_status();
|
|
304
|
+
let full_cost = estimate_tokens(&status.full_line) + estimate_tokens("\n\n");
|
|
305
|
+
let summary_cost = estimate_tokens(&status.summary_line) + estimate_tokens("\n\n");
|
|
306
|
+
assert!(full_cost > summary_cost);
|
|
307
|
+
|
|
308
|
+
let required = 8;
|
|
309
|
+
let summary_budget = full_cost + required - 1;
|
|
310
|
+
assert!(summary_budget >= summary_cost + required);
|
|
311
|
+
let decorator = BackgroundDecorator::with_budget(
|
|
312
|
+
status.clone(),
|
|
313
|
+
GLOBAL_TOKEN_BUDGET_ENV,
|
|
314
|
+
summary_budget,
|
|
315
|
+
);
|
|
316
|
+
let mut observed = Vec::new();
|
|
317
|
+
let response = loop {
|
|
318
|
+
let available = token_budget().unwrap();
|
|
319
|
+
observed.push(available);
|
|
320
|
+
let response = if available < required {
|
|
321
|
+
budget_error(available)
|
|
322
|
+
} else {
|
|
323
|
+
ToolResponse::text("user result\n\n(Complete: done.)")
|
|
324
|
+
};
|
|
325
|
+
if decorator.retry_after_budget_starvation(&response) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
break decorator.finish(response);
|
|
329
|
+
};
|
|
330
|
+
let ToolContent::Text(text) = &response.content[0] else {
|
|
331
|
+
panic!("expected text")
|
|
332
|
+
};
|
|
333
|
+
assert_eq!(observed.len(), 2);
|
|
334
|
+
assert!(observed[0] < observed[1]);
|
|
335
|
+
assert!(text.contains("(Background: 1 job running.)"));
|
|
336
|
+
assert!(!text.contains("j-alpha"));
|
|
337
|
+
assert!(estimate_tokens(text) <= summary_budget);
|
|
338
|
+
|
|
339
|
+
let omit_required = summary_budget - summary_cost + 1;
|
|
340
|
+
let decorator =
|
|
341
|
+
BackgroundDecorator::with_budget(status, GLOBAL_TOKEN_BUDGET_ENV, summary_budget);
|
|
342
|
+
let response = loop {
|
|
343
|
+
let available = token_budget().unwrap();
|
|
344
|
+
let response = if available < omit_required {
|
|
345
|
+
budget_error(available)
|
|
346
|
+
} else {
|
|
347
|
+
ToolResponse::text("user result\n\n(Complete: done.)")
|
|
348
|
+
};
|
|
349
|
+
if decorator.retry_after_budget_starvation(&response) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
break decorator.finish(response);
|
|
353
|
+
};
|
|
354
|
+
let ToolContent::Text(text) = &response.content[0] else {
|
|
355
|
+
panic!("expected text")
|
|
356
|
+
};
|
|
357
|
+
assert_eq!(text, "user result\n\n(Complete: done.)");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#[test]
|
|
361
|
+
fn an_exact_fit_status_cost_yields_to_user_content() {
|
|
362
|
+
let status = synthetic_status();
|
|
363
|
+
let full_cost = estimate_tokens(&status.full_line) + estimate_tokens("\n\n");
|
|
364
|
+
let summary_cost = estimate_tokens(&status.summary_line) + estimate_tokens("\n\n");
|
|
365
|
+
|
|
366
|
+
let decorator =
|
|
367
|
+
BackgroundDecorator::with_budget(status.clone(), GLOBAL_TOKEN_BUDGET_ENV, full_cost);
|
|
368
|
+
let summary_response = decorator.finish(ToolResponse::text("user result"));
|
|
369
|
+
let ToolContent::Text(summary_text) = &summary_response.content[0] else {
|
|
370
|
+
panic!("expected text")
|
|
371
|
+
};
|
|
372
|
+
assert!(summary_text.starts_with("user result"));
|
|
373
|
+
assert!(summary_text.contains("(Background: 1 job running.)"));
|
|
374
|
+
assert!(!summary_text.contains("j-alpha"));
|
|
375
|
+
assert!(estimate_tokens(summary_text) <= full_cost);
|
|
376
|
+
|
|
377
|
+
let decorator =
|
|
378
|
+
BackgroundDecorator::with_budget(status, GLOBAL_TOKEN_BUDGET_ENV, summary_cost);
|
|
379
|
+
let omitted_response = decorator.finish(ToolResponse::text("user result"));
|
|
380
|
+
assert_eq!(omitted_response, ToolResponse::text("user result"));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn error_and_image_only_responses_never_receive_background_text() {
|
|
385
|
+
let status = synthetic_status();
|
|
386
|
+
let error =
|
|
387
|
+
BackgroundDecorator::with_budget(status.clone(), GLOBAL_TOKEN_BUDGET_ENV, 8_500)
|
|
388
|
+
.finish(ToolResponse::error("failure"));
|
|
389
|
+
assert_eq!(error, ToolResponse::error("failure"));
|
|
390
|
+
|
|
391
|
+
let image = ToolResponse {
|
|
392
|
+
content: vec![ToolContent::Image {
|
|
393
|
+
data: "AA==".to_string(),
|
|
394
|
+
mime_type: "image/png".to_string(),
|
|
395
|
+
detail: Some(ImageDetail::High),
|
|
396
|
+
}],
|
|
397
|
+
is_error: false,
|
|
398
|
+
};
|
|
399
|
+
let decorated = BackgroundDecorator::with_budget(status, GLOBAL_TOKEN_BUDGET_ENV, 8_500)
|
|
400
|
+
.finish(image.clone());
|
|
401
|
+
assert_eq!(decorated, image);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//! Magic-byte recognition for common binary formats shared by read and grep.
|
|
2
|
+
|
|
3
|
+
/// Returns the contract file type used in binary errors, declining to guess when no signature matches.
|
|
4
|
+
pub(crate) fn detect_binary_type(bytes: &[u8]) -> Option<&'static str> {
|
|
5
|
+
if bytes.starts_with(b"PK\x03\x04") {
|
|
6
|
+
return Some("a ZIP archive");
|
|
7
|
+
}
|
|
8
|
+
if bytes.starts_with(b"\x1F\x8B") {
|
|
9
|
+
return Some("gzip data");
|
|
10
|
+
}
|
|
11
|
+
if bytes.starts_with(b"\x37\x7A\xBC\xAF\x27\x1C") {
|
|
12
|
+
return Some("7-Zip data");
|
|
13
|
+
}
|
|
14
|
+
if bytes.starts_with(b"\x28\xB5\x2F\xFD") {
|
|
15
|
+
return Some("Zstandard data");
|
|
16
|
+
}
|
|
17
|
+
if bytes.starts_with(b"\x7FELF") {
|
|
18
|
+
return Some("an ELF executable");
|
|
19
|
+
}
|
|
20
|
+
if bytes.starts_with(b"MZ") {
|
|
21
|
+
return Some("a Windows executable");
|
|
22
|
+
}
|
|
23
|
+
if [
|
|
24
|
+
b"\xFE\xED\xFA\xCE".as_slice(),
|
|
25
|
+
b"\xFE\xED\xFA\xCF".as_slice(),
|
|
26
|
+
b"\xCE\xFA\xED\xFE".as_slice(),
|
|
27
|
+
b"\xCF\xFA\xED\xFE".as_slice(),
|
|
28
|
+
]
|
|
29
|
+
.iter()
|
|
30
|
+
.any(|magic| bytes.starts_with(magic))
|
|
31
|
+
{
|
|
32
|
+
return Some("a Mach-O executable");
|
|
33
|
+
}
|
|
34
|
+
if bytes.starts_with(b"SQLite format 3\0") {
|
|
35
|
+
return Some("a SQLite database");
|
|
36
|
+
}
|
|
37
|
+
if bytes.starts_with(b"\0asm") {
|
|
38
|
+
return Some("a WebAssembly module");
|
|
39
|
+
}
|
|
40
|
+
if bytes.get(257..262) == Some(b"ustar") {
|
|
41
|
+
return Some("a tar archive");
|
|
42
|
+
}
|
|
43
|
+
None
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[cfg(test)]
|
|
47
|
+
mod tests {
|
|
48
|
+
use super::detect_binary_type;
|
|
49
|
+
|
|
50
|
+
#[test]
|
|
51
|
+
fn recognizes_each_contract_signature_without_extension_help() {
|
|
52
|
+
let cases: &[(&[u8], &str)] = &[
|
|
53
|
+
(b"PK\x03\x04", "a ZIP archive"),
|
|
54
|
+
(b"\x1F\x8B", "gzip data"),
|
|
55
|
+
(b"\x37\x7A\xBC\xAF\x27\x1C", "7-Zip data"),
|
|
56
|
+
(b"\x28\xB5\x2F\xFD", "Zstandard data"),
|
|
57
|
+
(b"\x7FELF", "an ELF executable"),
|
|
58
|
+
(b"MZ", "a Windows executable"),
|
|
59
|
+
(b"\xFE\xED\xFA\xCE", "a Mach-O executable"),
|
|
60
|
+
(b"\xFE\xED\xFA\xCF", "a Mach-O executable"),
|
|
61
|
+
(b"\xCE\xFA\xED\xFE", "a Mach-O executable"),
|
|
62
|
+
(b"\xCF\xFA\xED\xFE", "a Mach-O executable"),
|
|
63
|
+
(b"SQLite format 3\0", "a SQLite database"),
|
|
64
|
+
(b"\0asm", "a WebAssembly module"),
|
|
65
|
+
];
|
|
66
|
+
for (bytes, expected) in cases {
|
|
67
|
+
assert_eq!(detect_binary_type(bytes), Some(*expected));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let mut tar = vec![0_u8; 262];
|
|
71
|
+
tar[257..262].copy_from_slice(b"ustar");
|
|
72
|
+
assert_eq!(detect_binary_type(&tar), Some("a tar archive"));
|
|
73
|
+
assert_eq!(detect_binary_type(b"ordinary text"), None);
|
|
74
|
+
}
|
|
75
|
+
}
|