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,781 @@
|
|
|
1
|
+
// Modified by dsh-ops (https://github.com/T-Auto/dsh-ops): took over the Guarded output-limit constants that lived in the deleted control/provider.rs module, with their values unchanged.
|
|
2
|
+
//! Exact o200k_base token accounting and text response assembly.
|
|
3
|
+
|
|
4
|
+
use crate::operation::{WorkCheckpoint, WorkStop};
|
|
5
|
+
use crate::{ToolContent, ToolResponse};
|
|
6
|
+
use std::cell::RefCell;
|
|
7
|
+
use std::fmt;
|
|
8
|
+
use std::sync::Arc;
|
|
9
|
+
|
|
10
|
+
/// Default output budget with 15% headroom below the Codex host's approximate 10k-token limit.
|
|
11
|
+
pub const DEFAULT_TOKEN_BUDGET: usize = 8_500;
|
|
12
|
+
/// Host-side output ceiling the Codex Guarded output policy applied.
|
|
13
|
+
///
|
|
14
|
+
/// The value is unchanged from the deleted `control::provider` module; nothing selects the
|
|
15
|
+
/// Guarded mode any more now that Codex provider detection is gone.
|
|
16
|
+
pub const GUARDED_HOST_LIMIT: i64 = 10_000;
|
|
17
|
+
/// FastCtx response budget kept below the Guarded host ceiling.
|
|
18
|
+
pub const GUARDED_FASTCTX_BUDGET: usize = 9_000;
|
|
19
|
+
/// Environment variable for the global text budget.
|
|
20
|
+
pub const GLOBAL_TOKEN_BUDGET_ENV: &str = "FASTCTX_TOKEN_BUDGET";
|
|
21
|
+
/// Environment variable for the read-specific budget.
|
|
22
|
+
pub const READ_TOKEN_BUDGET_ENV: &str = "FASTCTX_READ_TOKEN_BUDGET";
|
|
23
|
+
/// Environment variable for the grep-specific budget.
|
|
24
|
+
pub const GREP_TOKEN_BUDGET_ENV: &str = "FASTCTX_GREP_TOKEN_BUDGET";
|
|
25
|
+
/// Environment variable for the glob-specific budget.
|
|
26
|
+
pub const GLOB_TOKEN_BUDGET_ENV: &str = "FASTCTX_GLOB_TOKEN_BUDGET";
|
|
27
|
+
/// Per-tool budget for foreground shell output.
|
|
28
|
+
pub const RUN_TOKEN_BUDGET_ENV: &str = "FASTCTX_RUN_TOKEN_BUDGET";
|
|
29
|
+
/// Per-tool budget for background job output polling.
|
|
30
|
+
pub const JOB_OUTPUT_TOKEN_BUDGET_ENV: &str = "FASTCTX_JOB_OUTPUT_TOKEN_BUDGET";
|
|
31
|
+
|
|
32
|
+
/// Effective budget for one tool and the variable that supplied it, used for precise errors.
|
|
33
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
34
|
+
pub struct TokenBudget {
|
|
35
|
+
/// Effective token ceiling.
|
|
36
|
+
pub value: usize,
|
|
37
|
+
/// Environment variable supplying the value; inherited budgets point to the global variable.
|
|
38
|
+
pub variable: &'static str,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
42
|
+
enum ReservationLevel {
|
|
43
|
+
Full,
|
|
44
|
+
Summary,
|
|
45
|
+
None,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[derive(Clone, Debug)]
|
|
49
|
+
struct ResponseReservationState {
|
|
50
|
+
variable: &'static str,
|
|
51
|
+
configured_budget: usize,
|
|
52
|
+
full_line: String,
|
|
53
|
+
full_tokens: usize,
|
|
54
|
+
summary_line: String,
|
|
55
|
+
summary_tokens: usize,
|
|
56
|
+
level: ReservationLevel,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
thread_local! {
|
|
60
|
+
static RESPONSE_RESERVATION: RefCell<Option<ResponseReservationState>> = const { RefCell::new(None) };
|
|
61
|
+
static RESPONSE_BUDGET_CEILING: RefCell<Option<usize>> = const { RefCell::new(None) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// One thread-local aggregate-burst ceiling installed around a single formatter.
|
|
65
|
+
pub(crate) struct ResponseBudgetCeiling {
|
|
66
|
+
previous: Option<usize>,
|
|
67
|
+
active: bool,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl ResponseBudgetCeiling {
|
|
71
|
+
pub(crate) fn install(budget: usize) -> Self {
|
|
72
|
+
let previous = RESPONSE_BUDGET_CEILING.with(|slot| slot.borrow_mut().replace(budget));
|
|
73
|
+
Self {
|
|
74
|
+
previous,
|
|
75
|
+
active: true,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
impl Drop for ResponseBudgetCeiling {
|
|
81
|
+
fn drop(&mut self) {
|
|
82
|
+
if self.active {
|
|
83
|
+
RESPONSE_BUDGET_CEILING.with(|slot| *slot.borrow_mut() = self.previous.take());
|
|
84
|
+
self.active = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// One thread-local response-budget reservation installed around a single tool formatter.
|
|
90
|
+
pub(crate) struct ResponseReservation {
|
|
91
|
+
previous: Option<ResponseReservationState>,
|
|
92
|
+
active: bool,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// The status line selected after the formatter has had a chance to request a fallback.
|
|
96
|
+
pub(crate) struct ResponseReservationOutcome {
|
|
97
|
+
pub(crate) configured_budget: usize,
|
|
98
|
+
pub(crate) line: Option<String>,
|
|
99
|
+
pub(crate) summary_line: String,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
impl ResponseReservation {
|
|
103
|
+
/// Reserves the exact rendered status-line cost before the tool reads its budget.
|
|
104
|
+
pub(crate) fn install(
|
|
105
|
+
variable: &'static str,
|
|
106
|
+
full_line: String,
|
|
107
|
+
summary_line: String,
|
|
108
|
+
) -> Option<Self> {
|
|
109
|
+
let configured_budget =
|
|
110
|
+
apply_response_ceiling(configured_tool_token_budget(variable).ok()?.value);
|
|
111
|
+
Some(Self::install_with_budget(
|
|
112
|
+
variable,
|
|
113
|
+
configured_budget,
|
|
114
|
+
full_line,
|
|
115
|
+
summary_line,
|
|
116
|
+
))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fn install_with_budget(
|
|
120
|
+
variable: &'static str,
|
|
121
|
+
configured_budget: usize,
|
|
122
|
+
full_line: String,
|
|
123
|
+
summary_line: String,
|
|
124
|
+
) -> Self {
|
|
125
|
+
// A status line is inserted with at most one extra blank-line separator. Counting
|
|
126
|
+
// the pieces independently is conservative because joining pieces can merge tokens
|
|
127
|
+
// across the boundary but cannot require more than encoding both pieces separately.
|
|
128
|
+
let separator_tokens = estimate_tokens("\n\n");
|
|
129
|
+
let full_tokens = estimate_tokens(&full_line).saturating_add(separator_tokens);
|
|
130
|
+
let summary_tokens = estimate_tokens(&summary_line).saturating_add(separator_tokens);
|
|
131
|
+
// A successful text response always has user content, so an exact-fit status would
|
|
132
|
+
// consume the whole budget and must yield to the next tier.
|
|
133
|
+
let level = if full_tokens < configured_budget {
|
|
134
|
+
ReservationLevel::Full
|
|
135
|
+
} else if summary_tokens < configured_budget {
|
|
136
|
+
ReservationLevel::Summary
|
|
137
|
+
} else {
|
|
138
|
+
ReservationLevel::None
|
|
139
|
+
};
|
|
140
|
+
let state = ResponseReservationState {
|
|
141
|
+
variable,
|
|
142
|
+
configured_budget,
|
|
143
|
+
full_line,
|
|
144
|
+
full_tokens,
|
|
145
|
+
summary_line,
|
|
146
|
+
summary_tokens,
|
|
147
|
+
level,
|
|
148
|
+
};
|
|
149
|
+
let previous = RESPONSE_RESERVATION.with(|slot| slot.borrow_mut().replace(state));
|
|
150
|
+
Self {
|
|
151
|
+
previous,
|
|
152
|
+
active: true,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[cfg(test)]
|
|
157
|
+
pub(crate) fn install_for_test(
|
|
158
|
+
variable: &'static str,
|
|
159
|
+
configured_budget: usize,
|
|
160
|
+
full_line: String,
|
|
161
|
+
summary_line: String,
|
|
162
|
+
) -> Self {
|
|
163
|
+
Self::install_with_budget(variable, configured_budget, full_line, summary_line)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Relaxes full status to the summary and then to no status.
|
|
167
|
+
pub(crate) fn downgrade(&self) -> bool {
|
|
168
|
+
downgrade_response_reservation(None)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Restores any outer reservation and returns the line selected for this response.
|
|
172
|
+
pub(crate) fn finish(mut self) -> ResponseReservationOutcome {
|
|
173
|
+
let state = RESPONSE_RESERVATION.with(|slot| slot.borrow_mut().take());
|
|
174
|
+
RESPONSE_RESERVATION.with(|slot| *slot.borrow_mut() = self.previous.take());
|
|
175
|
+
self.active = false;
|
|
176
|
+
let state = state.expect("an installed response reservation must remain active");
|
|
177
|
+
let summary_line = state.summary_line.clone();
|
|
178
|
+
let line = match state.level {
|
|
179
|
+
ReservationLevel::Full => Some(state.full_line),
|
|
180
|
+
ReservationLevel::Summary => Some(state.summary_line),
|
|
181
|
+
ReservationLevel::None => None,
|
|
182
|
+
};
|
|
183
|
+
ResponseReservationOutcome {
|
|
184
|
+
configured_budget: state.configured_budget,
|
|
185
|
+
line,
|
|
186
|
+
summary_line,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
impl Drop for ResponseReservation {
|
|
192
|
+
fn drop(&mut self) {
|
|
193
|
+
if self.active {
|
|
194
|
+
RESPONSE_RESERVATION.with(|slot| {
|
|
195
|
+
*slot.borrow_mut() = self.previous.take();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// Reads the global text budget, rejecting invalid configuration instead of silently falling back.
|
|
202
|
+
pub fn token_budget() -> Result<usize, String> {
|
|
203
|
+
let configured = apply_response_ceiling(configured_token_budget()?);
|
|
204
|
+
Ok(apply_response_reservation(
|
|
205
|
+
GLOBAL_TOKEN_BUDGET_ENV,
|
|
206
|
+
configured,
|
|
207
|
+
))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn configured_token_budget() -> Result<usize, String> {
|
|
211
|
+
match crate::session::var(GLOBAL_TOKEN_BUDGET_ENV) {
|
|
212
|
+
Ok(value) => parse_token_budget(GLOBAL_TOKEN_BUDGET_ENV, &value),
|
|
213
|
+
Err(std::env::VarError::NotPresent) => Ok(DEFAULT_TOKEN_BUDGET),
|
|
214
|
+
Err(std::env::VarError::NotUnicode(_)) => Err(
|
|
215
|
+
"Invalid FASTCTX_TOKEN_BUDGET value: expected a UTF-8 positive integer.".to_string(),
|
|
216
|
+
),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Reads a tool budget; omission inherits the global value and explicit values may not exceed it.
|
|
221
|
+
pub fn tool_token_budget(variable: &'static str) -> Result<TokenBudget, String> {
|
|
222
|
+
let mut budget = configured_tool_token_budget(variable)?;
|
|
223
|
+
budget.value = apply_response_ceiling(budget.value);
|
|
224
|
+
budget.value = apply_response_reservation(variable, budget.value);
|
|
225
|
+
Ok(budget)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn configured_tool_token_budget(variable: &'static str) -> Result<TokenBudget, String> {
|
|
229
|
+
let global = configured_token_budget()?;
|
|
230
|
+
match crate::session::var(variable) {
|
|
231
|
+
Ok(value) => {
|
|
232
|
+
let value = parse_token_budget(variable, &value)?;
|
|
233
|
+
if value > global {
|
|
234
|
+
return Err(format!(
|
|
235
|
+
"{variable}={value} exceeds FASTCTX_TOKEN_BUDGET={global}. Increase the global budget or lower the per-tool budget."
|
|
236
|
+
));
|
|
237
|
+
}
|
|
238
|
+
Ok(TokenBudget { value, variable })
|
|
239
|
+
}
|
|
240
|
+
Err(std::env::VarError::NotPresent) => Ok(TokenBudget {
|
|
241
|
+
value: global,
|
|
242
|
+
variable: "FASTCTX_TOKEN_BUDGET",
|
|
243
|
+
}),
|
|
244
|
+
Err(std::env::VarError::NotUnicode(_)) => Err(format!(
|
|
245
|
+
"Invalid {variable} value: expected a UTF-8 positive integer."
|
|
246
|
+
)),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Returns a safe ceiling for formatting a budget-configuration error before
|
|
251
|
+
/// the requested tool budget itself can be trusted.
|
|
252
|
+
pub(crate) fn error_budget_hint(variable: &'static str) -> usize {
|
|
253
|
+
let Ok(global) = configured_token_budget() else {
|
|
254
|
+
return apply_response_ceiling(DEFAULT_TOKEN_BUDGET);
|
|
255
|
+
};
|
|
256
|
+
let budget = match crate::session::var(variable) {
|
|
257
|
+
Ok(value) => parse_token_budget(variable, &value)
|
|
258
|
+
.ok()
|
|
259
|
+
.filter(|value| *value <= global)
|
|
260
|
+
.unwrap_or(global),
|
|
261
|
+
Err(_) => global,
|
|
262
|
+
};
|
|
263
|
+
apply_response_ceiling(budget)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
fn apply_response_ceiling(configured: usize) -> usize {
|
|
267
|
+
RESPONSE_BUDGET_CEILING.with(|slot| {
|
|
268
|
+
slot.borrow()
|
|
269
|
+
.map_or(configured, |ceiling| configured.min(ceiling))
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fn apply_response_reservation(variable: &'static str, configured: usize) -> usize {
|
|
274
|
+
RESPONSE_RESERVATION.with(|slot| {
|
|
275
|
+
let slot = slot.borrow();
|
|
276
|
+
let Some(state) = slot.as_ref().filter(|state| state.variable == variable) else {
|
|
277
|
+
return configured;
|
|
278
|
+
};
|
|
279
|
+
let reserved = match state.level {
|
|
280
|
+
ReservationLevel::Full => state.full_tokens,
|
|
281
|
+
ReservationLevel::Summary => state.summary_tokens,
|
|
282
|
+
ReservationLevel::None => 0,
|
|
283
|
+
};
|
|
284
|
+
state.configured_budget.saturating_sub(reserved)
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fn downgrade_response_reservation(variable: Option<&'static str>) -> bool {
|
|
289
|
+
RESPONSE_RESERVATION.with(|slot| {
|
|
290
|
+
let mut slot = slot.borrow_mut();
|
|
291
|
+
let Some(state) = slot
|
|
292
|
+
.as_mut()
|
|
293
|
+
.filter(|state| variable.is_none_or(|variable| state.variable == variable))
|
|
294
|
+
else {
|
|
295
|
+
return false;
|
|
296
|
+
};
|
|
297
|
+
state.level = match state.level {
|
|
298
|
+
// Keep at least one token available for the user-facing response body.
|
|
299
|
+
ReservationLevel::Full if state.summary_tokens < state.configured_budget => {
|
|
300
|
+
ReservationLevel::Summary
|
|
301
|
+
}
|
|
302
|
+
ReservationLevel::Full | ReservationLevel::Summary => ReservationLevel::None,
|
|
303
|
+
ReservationLevel::None => return false,
|
|
304
|
+
};
|
|
305
|
+
true
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// Relaxes the background status by one tier and returns the newly available tool budget.
|
|
310
|
+
pub(crate) fn relax_tool_token_budget(
|
|
311
|
+
variable: &'static str,
|
|
312
|
+
) -> Result<Option<TokenBudget>, String> {
|
|
313
|
+
if !downgrade_response_reservation(Some(variable)) {
|
|
314
|
+
return Ok(None);
|
|
315
|
+
}
|
|
316
|
+
tool_token_budget(variable).map(Some)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// Makes room for an irreducible response note before an operation with side effects begins.
|
|
320
|
+
pub(crate) fn tool_token_budget_for_required(
|
|
321
|
+
variable: &'static str,
|
|
322
|
+
required_tokens: usize,
|
|
323
|
+
) -> Result<TokenBudget, String> {
|
|
324
|
+
loop {
|
|
325
|
+
let budget = tool_token_budget(variable)?;
|
|
326
|
+
if budget.value >= required_tokens || !downgrade_response_reservation(Some(variable)) {
|
|
327
|
+
return Ok(budget);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
fn parse_token_budget(variable: &str, value: &str) -> Result<usize, String> {
|
|
333
|
+
value
|
|
334
|
+
.parse::<usize>()
|
|
335
|
+
.ok()
|
|
336
|
+
.filter(|value| *value > 0)
|
|
337
|
+
.ok_or_else(|| {
|
|
338
|
+
format!("Invalid {variable} value \"{value}\": expected a positive integer.")
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/// Counts text with the same o200k_base tokenizer used by the Codex host.
|
|
343
|
+
pub fn estimate_tokens(text: &str) -> usize {
|
|
344
|
+
bpe_openai::o200k_base().count(text)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// Diagnostic family used to select the contractually ordered tiny-budget fallbacks.
|
|
348
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
349
|
+
pub(crate) enum ErrorClass {
|
|
350
|
+
Budget,
|
|
351
|
+
Cancelled,
|
|
352
|
+
Other,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/// Fits every grep/glob error response to its effective o200k budget without
|
|
356
|
+
/// changing ordinary diagnostics that already fit.
|
|
357
|
+
#[derive(Clone, Copy, Debug)]
|
|
358
|
+
pub(crate) struct ErrorBudgetAdapter<'a> {
|
|
359
|
+
budget: usize,
|
|
360
|
+
variable: &'a str,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
impl<'a> ErrorBudgetAdapter<'a> {
|
|
364
|
+
pub(crate) fn new(budget: usize, variable: &'a str) -> Self {
|
|
365
|
+
Self { budget, variable }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/// Builds one text error using the exact fallback order from the search contract.
|
|
369
|
+
pub(crate) fn error(self, class: ErrorClass, message: impl Into<String>) -> ToolResponse {
|
|
370
|
+
let message = message.into();
|
|
371
|
+
if estimate_tokens(&message) <= self.budget {
|
|
372
|
+
return ToolResponse::error(message);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let fallbacks = match class {
|
|
376
|
+
ErrorClass::Budget => vec![
|
|
377
|
+
format!("Increase {}.", self.variable),
|
|
378
|
+
"Budget too small.".to_string(),
|
|
379
|
+
"Budget.".to_string(),
|
|
380
|
+
],
|
|
381
|
+
ErrorClass::Cancelled => {
|
|
382
|
+
vec!["Request cancelled.".to_string(), "Cancelled.".to_string()]
|
|
383
|
+
}
|
|
384
|
+
ErrorClass::Other => vec!["Error; increase budget.".to_string(), "Error.".to_string()],
|
|
385
|
+
};
|
|
386
|
+
for fallback in fallbacks {
|
|
387
|
+
if estimate_tokens(&fallback) <= self.budget {
|
|
388
|
+
return ToolResponse::error(fallback);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
ToolResponse::error(String::new())
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/// Applies the adapter to an existing text error and leaves successes untouched.
|
|
395
|
+
pub(crate) fn adapt(self, response: ToolResponse) -> ToolResponse {
|
|
396
|
+
if !response.is_error {
|
|
397
|
+
return response;
|
|
398
|
+
}
|
|
399
|
+
let [ToolContent::Text(message)] = response.content.as_slice() else {
|
|
400
|
+
return response;
|
|
401
|
+
};
|
|
402
|
+
let class = classify_error(message);
|
|
403
|
+
self.error(class, message.clone())
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn classify_error(message: &str) -> ErrorClass {
|
|
408
|
+
if message == "Request cancelled." || message == "Cancelled." {
|
|
409
|
+
ErrorClass::Cancelled
|
|
410
|
+
} else {
|
|
411
|
+
let lower = message.to_ascii_lowercase();
|
|
412
|
+
if message.contains("TOKEN_BUDGET")
|
|
413
|
+
|| lower.contains("budget too small")
|
|
414
|
+
|| lower.contains("too small to return")
|
|
415
|
+
{
|
|
416
|
+
ErrorClass::Budget
|
|
417
|
+
} else {
|
|
418
|
+
ErrorClass::Other
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/// Exact incremental state at one rendered-prefix boundary.
|
|
424
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
425
|
+
pub(crate) struct TokenCheckpoint {
|
|
426
|
+
committed_tokens: usize,
|
|
427
|
+
unresolved_tail: Arc<str>,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/// Failures produced while maintaining exact tokenizer checkpoints.
|
|
431
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
432
|
+
pub(crate) enum TokenCountError {
|
|
433
|
+
/// Request cancellation or speculative epoch retirement.
|
|
434
|
+
Stopped(WorkStop),
|
|
435
|
+
/// The platform token counter could not represent the exact result.
|
|
436
|
+
Overflow,
|
|
437
|
+
/// The locked o200k tokenizer unexpectedly enabled normalization.
|
|
438
|
+
UnsupportedNormalization,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
impl fmt::Display for TokenCountError {
|
|
442
|
+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
443
|
+
match self {
|
|
444
|
+
Self::Stopped(WorkStop::RequestCancelled) => formatter.write_str("Request cancelled."),
|
|
445
|
+
Self::Stopped(WorkStop::EpochRetired) => {
|
|
446
|
+
formatter.write_str("The render generation was retired.")
|
|
447
|
+
}
|
|
448
|
+
Self::Overflow => {
|
|
449
|
+
formatter.write_str("The exact token count overflowed this platform.")
|
|
450
|
+
}
|
|
451
|
+
Self::UnsupportedNormalization => formatter.write_str(
|
|
452
|
+
"The locked o200k tokenizer unexpectedly changed its normalization contract.",
|
|
453
|
+
),
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/// Exact o200k counter that permanently commits closed pre-token pieces and
|
|
459
|
+
/// retains only the final piece that a later append can still change.
|
|
460
|
+
#[derive(Clone, Debug, Default)]
|
|
461
|
+
pub(crate) struct ExactPrefixCounter {
|
|
462
|
+
committed_tokens: usize,
|
|
463
|
+
unresolved_tail: String,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
impl ExactPrefixCounter {
|
|
467
|
+
/// Restores a counter at an immutable prefix checkpoint.
|
|
468
|
+
pub(crate) fn from_checkpoint(checkpoint: &TokenCheckpoint) -> Self {
|
|
469
|
+
Self {
|
|
470
|
+
committed_tokens: checkpoint.committed_tokens,
|
|
471
|
+
unresolved_tail: checkpoint.unresolved_tail.to_string(),
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/// Appends one exact output fragment.
|
|
476
|
+
pub(crate) fn append(
|
|
477
|
+
&mut self,
|
|
478
|
+
fragment: &str,
|
|
479
|
+
operation: Option<&dyn WorkCheckpoint>,
|
|
480
|
+
) -> Result<(), TokenCountError> {
|
|
481
|
+
check_token_work(operation)?;
|
|
482
|
+
if fragment.is_empty() {
|
|
483
|
+
return Ok(());
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let mut combined = String::with_capacity(self.unresolved_tail.len() + fragment.len());
|
|
487
|
+
combined.push_str(&self.unresolved_tail);
|
|
488
|
+
combined.push_str(fragment);
|
|
489
|
+
let tokenizer = bpe_openai::o200k_base();
|
|
490
|
+
let normalized = tokenizer.normalize(combined.as_str());
|
|
491
|
+
if normalized.as_str() != combined {
|
|
492
|
+
return Err(TokenCountError::UnsupportedNormalization);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let mut pending = None;
|
|
496
|
+
for piece in tokenizer.split(normalized.as_str()) {
|
|
497
|
+
check_token_work(operation)?;
|
|
498
|
+
if let Some(closed) = pending.replace(piece) {
|
|
499
|
+
self.committed_tokens = self
|
|
500
|
+
.committed_tokens
|
|
501
|
+
.checked_add(tokenizer.bpe.count(closed.as_bytes()))
|
|
502
|
+
.ok_or(TokenCountError::Overflow)?;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
self.unresolved_tail.clear();
|
|
506
|
+
if let Some(tail) = pending {
|
|
507
|
+
self.unresolved_tail.push_str(tail);
|
|
508
|
+
}
|
|
509
|
+
check_token_work(operation)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/// Saves the exact state after the current rendered prefix.
|
|
513
|
+
pub(crate) fn checkpoint(&self) -> TokenCheckpoint {
|
|
514
|
+
TokenCheckpoint {
|
|
515
|
+
committed_tokens: self.committed_tokens,
|
|
516
|
+
unresolved_tail: Arc::from(self.unresolved_tail.as_str()),
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/// Counts one candidate suffix without assembling or re-tokenizing its prefix.
|
|
521
|
+
pub(crate) fn count_with_suffix(
|
|
522
|
+
&mut self,
|
|
523
|
+
checkpoint: &TokenCheckpoint,
|
|
524
|
+
suffix: &str,
|
|
525
|
+
operation: Option<&dyn WorkCheckpoint>,
|
|
526
|
+
) -> Result<usize, TokenCountError> {
|
|
527
|
+
let mut tail = String::with_capacity(checkpoint.unresolved_tail.len() + suffix.len());
|
|
528
|
+
tail.push_str(&checkpoint.unresolved_tail);
|
|
529
|
+
tail.push_str(suffix);
|
|
530
|
+
checkpoint
|
|
531
|
+
.committed_tokens
|
|
532
|
+
.checked_add(count_exact_pieces(&tail, operation)?)
|
|
533
|
+
.ok_or(TokenCountError::Overflow)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// Independently verifies the fully assembled response exactly once.
|
|
537
|
+
pub(crate) fn verify_full(
|
|
538
|
+
&mut self,
|
|
539
|
+
text: &str,
|
|
540
|
+
operation: Option<&dyn WorkCheckpoint>,
|
|
541
|
+
) -> Result<usize, TokenCountError> {
|
|
542
|
+
check_token_work(operation)?;
|
|
543
|
+
let count = estimate_tokens(text);
|
|
544
|
+
check_token_work(operation)?;
|
|
545
|
+
Ok(count)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
fn count_exact_pieces(
|
|
550
|
+
text: &str,
|
|
551
|
+
operation: Option<&dyn WorkCheckpoint>,
|
|
552
|
+
) -> Result<usize, TokenCountError> {
|
|
553
|
+
check_token_work(operation)?;
|
|
554
|
+
let tokenizer = bpe_openai::o200k_base();
|
|
555
|
+
let normalized = tokenizer.normalize(text);
|
|
556
|
+
if normalized.as_str() != text {
|
|
557
|
+
return Err(TokenCountError::UnsupportedNormalization);
|
|
558
|
+
}
|
|
559
|
+
let mut total = 0_usize;
|
|
560
|
+
for piece in tokenizer.split(normalized.as_str()) {
|
|
561
|
+
check_token_work(operation)?;
|
|
562
|
+
total = total
|
|
563
|
+
.checked_add(tokenizer.bpe.count(piece.as_bytes()))
|
|
564
|
+
.ok_or(TokenCountError::Overflow)?;
|
|
565
|
+
}
|
|
566
|
+
check_token_work(operation)?;
|
|
567
|
+
Ok(total)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
fn check_token_work(operation: Option<&dyn WorkCheckpoint>) -> Result<(), TokenCountError> {
|
|
571
|
+
match operation.map(WorkCheckpoint::check_work) {
|
|
572
|
+
Some(Err(stop)) => Err(TokenCountError::Stopped(stop)),
|
|
573
|
+
Some(Ok(())) | None => Ok(()),
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/// Incremental line-boundary counter used while building paged responses.
|
|
578
|
+
///
|
|
579
|
+
/// Each logical line is encoded independently, including the separator before
|
|
580
|
+
/// it. This is deliberately conservative relative to encoding the final text
|
|
581
|
+
/// in one pass because BPE merges cannot cross the chosen line boundary.
|
|
582
|
+
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
583
|
+
pub struct LineTokenCounter {
|
|
584
|
+
tokens: usize,
|
|
585
|
+
has_line: bool,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
impl LineTokenCounter {
|
|
589
|
+
/// Appends one output line and returns the accumulated token count.
|
|
590
|
+
pub fn push(&mut self, line: &str) -> usize {
|
|
591
|
+
if self.has_line {
|
|
592
|
+
self.tokens = self.tokens.saturating_add(estimate_tokens("\n"));
|
|
593
|
+
}
|
|
594
|
+
self.tokens = self.tokens.saturating_add(estimate_tokens(line));
|
|
595
|
+
self.has_line = true;
|
|
596
|
+
self.tokens
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/// Returns the accumulated conservative count.
|
|
600
|
+
pub fn tokens(&self) -> usize {
|
|
601
|
+
self.tokens
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/// Assembles body lines and notes with LF separators without appending a hidden final newline.
|
|
606
|
+
pub fn assemble_text(lines: &[String], notes: &[String]) -> String {
|
|
607
|
+
let mut text = lines.join("\n");
|
|
608
|
+
if !notes.is_empty() {
|
|
609
|
+
if !text.is_empty() {
|
|
610
|
+
text.push_str("\n\n");
|
|
611
|
+
}
|
|
612
|
+
text.push_str(¬es.join("\n"));
|
|
613
|
+
}
|
|
614
|
+
text
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
#[cfg(test)]
|
|
618
|
+
mod tests {
|
|
619
|
+
use super::{
|
|
620
|
+
ErrorBudgetAdapter, ErrorClass, ExactPrefixCounter, LineTokenCounter, assemble_text,
|
|
621
|
+
estimate_tokens, parse_token_budget,
|
|
622
|
+
};
|
|
623
|
+
use crate::{ToolContent, ToolResponse};
|
|
624
|
+
|
|
625
|
+
#[test]
|
|
626
|
+
fn tokenizer_matches_known_o200k_base_vectors() {
|
|
627
|
+
assert_eq!(estimate_tokens("hello world"), 2);
|
|
628
|
+
assert_eq!(estimate_tokens("界字"), 2);
|
|
629
|
+
assert_eq!(estimate_tokens("{\"a\":1}"), 5);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
#[test]
|
|
633
|
+
fn incremental_counter_is_conservative_at_line_boundaries() {
|
|
634
|
+
let lines = ["alpha", "界字", "{\"a\":1}"];
|
|
635
|
+
let mut counter = LineTokenCounter::default();
|
|
636
|
+
for line in lines {
|
|
637
|
+
counter.push(line);
|
|
638
|
+
}
|
|
639
|
+
assert!(counter.tokens() >= estimate_tokens(&lines.join("\n")));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#[test]
|
|
643
|
+
fn exact_prefix_checkpoints_match_full_o200k_for_every_chunking() {
|
|
644
|
+
let text = "Alpha界 123\n\n punctuation///\r\n最后 e\u{301} tail";
|
|
645
|
+
let boundaries = std::iter::once(0)
|
|
646
|
+
.chain(text.char_indices().map(|(index, _)| index).skip(1))
|
|
647
|
+
.chain(std::iter::once(text.len()))
|
|
648
|
+
.collect::<Vec<_>>();
|
|
649
|
+
|
|
650
|
+
for split in boundaries {
|
|
651
|
+
let mut counter = ExactPrefixCounter::default();
|
|
652
|
+
counter.append(&text[..split], None).unwrap();
|
|
653
|
+
let checkpoint = counter.checkpoint();
|
|
654
|
+
let exact = counter
|
|
655
|
+
.count_with_suffix(&checkpoint, &text[split..], None)
|
|
656
|
+
.unwrap();
|
|
657
|
+
assert_eq!(exact, estimate_tokens(text), "split={split}");
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
#[test]
|
|
662
|
+
fn checkpoint_restore_preserves_exact_prefix_and_suffix_counts() {
|
|
663
|
+
let mut counter = ExactPrefixCounter::default();
|
|
664
|
+
counter.append("one long", None).unwrap();
|
|
665
|
+
let checkpoint = counter.checkpoint();
|
|
666
|
+
counter.append(" prefix that is discarded", None).unwrap();
|
|
667
|
+
|
|
668
|
+
let mut restored = ExactPrefixCounter::from_checkpoint(&checkpoint);
|
|
669
|
+
restored.append(" replacement", None).unwrap();
|
|
670
|
+
let restored_checkpoint = restored.checkpoint();
|
|
671
|
+
let suffix = "\n\n(Complete.)";
|
|
672
|
+
assert_eq!(
|
|
673
|
+
restored
|
|
674
|
+
.count_with_suffix(&restored_checkpoint, suffix, None)
|
|
675
|
+
.unwrap(),
|
|
676
|
+
estimate_tokens("one long replacement\n\n(Complete.)")
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
#[test]
|
|
681
|
+
fn exact_prefix_handles_o200k_whitespace_lookahead_across_appends() {
|
|
682
|
+
let fragments = ["a ", " ", "b", "\r", "\n", " ", "tail"];
|
|
683
|
+
let mut expected = String::new();
|
|
684
|
+
let mut counter = ExactPrefixCounter::default();
|
|
685
|
+
for fragment in fragments {
|
|
686
|
+
expected.push_str(fragment);
|
|
687
|
+
counter.append(fragment, None).unwrap();
|
|
688
|
+
let checkpoint = counter.checkpoint();
|
|
689
|
+
assert_eq!(
|
|
690
|
+
counter.count_with_suffix(&checkpoint, "", None).unwrap(),
|
|
691
|
+
estimate_tokens(&expected)
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
let checkpoint = counter.checkpoint();
|
|
695
|
+
assert_eq!(
|
|
696
|
+
counter.count_with_suffix(&checkpoint, "", None).unwrap(),
|
|
697
|
+
estimate_tokens(&expected)
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
#[test]
|
|
702
|
+
fn every_error_family_is_exactly_bounded_for_every_tiny_budget() {
|
|
703
|
+
let independent = tiktoken_rs::o200k_base_singleton();
|
|
704
|
+
let cases = [
|
|
705
|
+
(
|
|
706
|
+
ErrorClass::Budget,
|
|
707
|
+
"FASTCTX_GREP_TOKEN_BUDGET=1 is too small to return the required grep continuation note. Increase it and retry.",
|
|
708
|
+
),
|
|
709
|
+
(ErrorClass::Cancelled, "Request cancelled."),
|
|
710
|
+
(
|
|
711
|
+
ErrorClass::Other,
|
|
712
|
+
"Path does not exist: /a/diagnostic/that/is/intentionally/long.",
|
|
713
|
+
),
|
|
714
|
+
(
|
|
715
|
+
ErrorClass::Other,
|
|
716
|
+
"Permission denied while accessing /a/protected/search/root.",
|
|
717
|
+
),
|
|
718
|
+
(
|
|
719
|
+
ErrorClass::Other,
|
|
720
|
+
"Cannot determine the encoding of /a/legacy/file.txt; candidates: windows-1252, gbk, shift_jis.",
|
|
721
|
+
),
|
|
722
|
+
];
|
|
723
|
+
for budget in 1..=32 {
|
|
724
|
+
for (class, message) in cases {
|
|
725
|
+
let response = ErrorBudgetAdapter::new(budget, "FASTCTX_GREP_TOKEN_BUDGET")
|
|
726
|
+
.error(class, message);
|
|
727
|
+
assert!(response.is_error);
|
|
728
|
+
let [ToolContent::Text(text)] = response.content.as_slice() else {
|
|
729
|
+
panic!("expected one text error");
|
|
730
|
+
};
|
|
731
|
+
assert!(
|
|
732
|
+
independent.encode_ordinary(text).len() <= budget,
|
|
733
|
+
"independent oracle exceeded budget={budget}, text={text:?}"
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
#[test]
|
|
740
|
+
fn adapter_preserves_fitting_errors_and_infers_cancellation() {
|
|
741
|
+
let original = "Path does not exist: /tmp/missing.";
|
|
742
|
+
assert_eq!(
|
|
743
|
+
ErrorBudgetAdapter::new(8_500, "FASTCTX_TOKEN_BUDGET")
|
|
744
|
+
.adapt(ToolResponse::error(original)),
|
|
745
|
+
ToolResponse::error(original)
|
|
746
|
+
);
|
|
747
|
+
let response = ErrorBudgetAdapter::new(1, "FASTCTX_TOKEN_BUDGET")
|
|
748
|
+
.adapt(ToolResponse::error("Request cancelled."));
|
|
749
|
+
let [ToolContent::Text(text)] = response.content.as_slice() else {
|
|
750
|
+
panic!("expected one text error");
|
|
751
|
+
};
|
|
752
|
+
assert!(estimate_tokens(text) <= 1);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
#[test]
|
|
756
|
+
fn assembly_has_one_blank_line_before_adjacent_notes_and_no_final_newline() {
|
|
757
|
+
assert_eq!(
|
|
758
|
+
assemble_text(
|
|
759
|
+
&["body".to_string()],
|
|
760
|
+
&["(first)".to_string(), "(second)".to_string()]
|
|
761
|
+
),
|
|
762
|
+
"body\n\n(first)\n(second)"
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#[test]
|
|
767
|
+
fn invalid_budget_values_fail_with_the_exact_actionable_message() {
|
|
768
|
+
assert_eq!(
|
|
769
|
+
parse_token_budget("FASTCTX_TOKEN_BUDGET", "0").unwrap_err(),
|
|
770
|
+
"Invalid FASTCTX_TOKEN_BUDGET value \"0\": expected a positive integer."
|
|
771
|
+
);
|
|
772
|
+
assert_eq!(
|
|
773
|
+
parse_token_budget("FASTCTX_TOKEN_BUDGET", "many").unwrap_err(),
|
|
774
|
+
"Invalid FASTCTX_TOKEN_BUDGET value \"many\": expected a positive integer."
|
|
775
|
+
);
|
|
776
|
+
assert_eq!(
|
|
777
|
+
parse_token_budget("FASTCTX_READ_TOKEN_BUDGET", "0").unwrap_err(),
|
|
778
|
+
"Invalid FASTCTX_READ_TOKEN_BUDGET value \"0\": expected a positive integer."
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|