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,83 @@
|
|
|
1
|
+
//! Shared wording for whatever a tool could not reach, read, or search.
|
|
2
|
+
//!
|
|
3
|
+
//! Two kinds of gap reach a response and they mean different things to the
|
|
4
|
+
//! caller. A skipped *file* was found and then could not be used, so the count
|
|
5
|
+
//! is exact and the next move is usually a decoding or size decision. An
|
|
6
|
+
//! unreachable *path* was never entered, so it hides an unknown number of files
|
|
7
|
+
//! and the result set has a hole in it — "not found" stops meaning "not there".
|
|
8
|
+
//! Every tool states both through this module so one reading works everywhere.
|
|
9
|
+
|
|
10
|
+
/// One line of skip detail: the path, and why it did not contribute.
|
|
11
|
+
pub(crate) fn detail_line(path: &str, reason: &str) -> String {
|
|
12
|
+
format!("{path} — {reason}")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// What a response has to disclose about the gaps in its own coverage.
|
|
16
|
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
17
|
+
pub(crate) struct SkipTally {
|
|
18
|
+
/// Files reached but not used, e.g. undecodable or changed mid-search.
|
|
19
|
+
pub(crate) files: usize,
|
|
20
|
+
/// Paths never entered, each hiding an unknown amount of the tree.
|
|
21
|
+
pub(crate) unreachable: usize,
|
|
22
|
+
/// Detail lines available to show, across both kinds.
|
|
23
|
+
pub(crate) listed: usize,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
impl SkipTally {
|
|
27
|
+
pub(crate) fn is_empty(&self) -> bool {
|
|
28
|
+
self.files == 0 && self.unreachable == 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The clause appended inside a terminal note, without its punctuation.
|
|
32
|
+
///
|
|
33
|
+
/// `shown` is how many detail lines survived the budget; a value below
|
|
34
|
+
/// `listed` adds the hint that narrowing the request reveals the rest.
|
|
35
|
+
fn clause(&self, shown: usize) -> Option<String> {
|
|
36
|
+
if self.is_empty() {
|
|
37
|
+
return None;
|
|
38
|
+
}
|
|
39
|
+
let mut clause = String::new();
|
|
40
|
+
if self.files > 0 {
|
|
41
|
+
clause.push_str(&format!("{} skipped", counted(self.files, "file", "files")));
|
|
42
|
+
}
|
|
43
|
+
if self.unreachable > 0 {
|
|
44
|
+
if !clause.is_empty() {
|
|
45
|
+
clause.push_str(", ");
|
|
46
|
+
}
|
|
47
|
+
// "unreachable" carries the consequence on its own: a path the walk
|
|
48
|
+
// never entered is one these results cannot speak for. Spelling that
|
|
49
|
+
// out again collides with the truncation hint that may follow.
|
|
50
|
+
clause.push_str(&format!(
|
|
51
|
+
"{} unreachable",
|
|
52
|
+
counted(self.unreachable, "path", "paths")
|
|
53
|
+
));
|
|
54
|
+
}
|
|
55
|
+
if shown < self.listed {
|
|
56
|
+
clause.push_str(&format!(
|
|
57
|
+
", showing {shown} — narrow path/glob to inspect the rest"
|
|
58
|
+
));
|
|
59
|
+
}
|
|
60
|
+
Some(clause)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Folds a skip clause into a terminal note that ends in `.)`.
|
|
65
|
+
///
|
|
66
|
+
/// Returns `None` only when the note has no such ending, which is a rendering
|
|
67
|
+
/// bug rather than a state a caller can recover from.
|
|
68
|
+
pub(crate) fn terminal_with_skips(
|
|
69
|
+
terminal: &str,
|
|
70
|
+
tally: &SkipTally,
|
|
71
|
+
shown: usize,
|
|
72
|
+
) -> Option<String> {
|
|
73
|
+
let Some(clause) = tally.clause(shown) else {
|
|
74
|
+
return Some(terminal.to_string());
|
|
75
|
+
};
|
|
76
|
+
let stem = terminal.strip_suffix(".)")?;
|
|
77
|
+
Some(format!("{stem}; {clause}.)"))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn counted(count: usize, singular: &str, plural: &str) -> String {
|
|
81
|
+
let noun = if count == 1 { singular } else { plural };
|
|
82
|
+
format!("{count} {noun}")
|
|
83
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
//! Process-stdio adapters whose reader lifetime does not pin the async runtime.
|
|
2
|
+
|
|
3
|
+
use std::io::Read;
|
|
4
|
+
use std::pin::Pin;
|
|
5
|
+
use std::task::{Context, Poll};
|
|
6
|
+
use tokio::io::{AsyncRead, ReadBuf};
|
|
7
|
+
use tokio::sync::{mpsc, watch};
|
|
8
|
+
|
|
9
|
+
const STDIN_CHUNK_BYTES: usize = 8 * 1024;
|
|
10
|
+
const STDIN_QUEUE_DEPTH: usize = 16;
|
|
11
|
+
|
|
12
|
+
/// Async stdin backed by a detached operating-system reader thread.
|
|
13
|
+
///
|
|
14
|
+
/// Tokio's global stdin adapter uses the runtime blocking pool. A leaked writer can keep that
|
|
15
|
+
/// pool thread blocked forever after the MCP service is cancelled, preventing parent-death
|
|
16
|
+
/// shutdown from completing. This adapter deliberately owns an ordinary detached thread instead:
|
|
17
|
+
/// EOF still closes the transport normally, while process shutdown never waits for a blocked read.
|
|
18
|
+
pub(crate) struct DetachedStdin {
|
|
19
|
+
receiver: mpsc::Receiver<std::io::Result<Vec<u8>>>,
|
|
20
|
+
pending: Vec<u8>,
|
|
21
|
+
pending_offset: usize,
|
|
22
|
+
read_error: watch::Receiver<Option<String>>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl DetachedStdin {
|
|
26
|
+
/// Starts the sole stdin reader for the stdio MCP transport.
|
|
27
|
+
pub(crate) fn start() -> Result<Self, String> {
|
|
28
|
+
Self::spawn_reader(|sender, read_error_sender| {
|
|
29
|
+
let stdin = std::io::stdin();
|
|
30
|
+
forward_reader(stdin.lock(), sender, read_error_sender);
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn spawn_reader(
|
|
35
|
+
reader: impl FnOnce(mpsc::Sender<std::io::Result<Vec<u8>>>, watch::Sender<Option<String>>)
|
|
36
|
+
+ Send
|
|
37
|
+
+ 'static,
|
|
38
|
+
) -> Result<Self, String> {
|
|
39
|
+
let (sender, receiver) = mpsc::channel(STDIN_QUEUE_DEPTH);
|
|
40
|
+
let (read_error_sender, read_error) = watch::channel(None);
|
|
41
|
+
std::thread::Builder::new()
|
|
42
|
+
.name("fastctx-stdin".to_string())
|
|
43
|
+
.spawn(move || reader(sender, read_error_sender))
|
|
44
|
+
.map_err(|error| format!("Cannot start the MCP stdin reader: {error}"))?;
|
|
45
|
+
Ok(Self {
|
|
46
|
+
receiver,
|
|
47
|
+
pending: Vec::new(),
|
|
48
|
+
pending_offset: 0,
|
|
49
|
+
read_error,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Reports an operating-system read failure independently of rmcp's EOF-shaped transport API.
|
|
54
|
+
pub(crate) fn read_error_receiver(&self) -> watch::Receiver<Option<String>> {
|
|
55
|
+
self.read_error.clone()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn forward_reader(
|
|
60
|
+
mut input: impl Read,
|
|
61
|
+
sender: mpsc::Sender<std::io::Result<Vec<u8>>>,
|
|
62
|
+
read_error_sender: watch::Sender<Option<String>>,
|
|
63
|
+
) {
|
|
64
|
+
loop {
|
|
65
|
+
let mut bytes = vec![0; STDIN_CHUNK_BYTES];
|
|
66
|
+
match input.read(&mut bytes) {
|
|
67
|
+
Ok(0) => break,
|
|
68
|
+
Ok(read) => {
|
|
69
|
+
bytes.truncate(read);
|
|
70
|
+
if sender.blocking_send(Ok(bytes)).is_err() {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
Err(error) => {
|
|
75
|
+
read_error_sender.send_replace(Some(format!("Cannot read MCP stdin: {error}")));
|
|
76
|
+
let _ = sender.blocking_send(Err(error));
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
impl AsyncRead for DetachedStdin {
|
|
84
|
+
fn poll_read(
|
|
85
|
+
mut self: Pin<&mut Self>,
|
|
86
|
+
context: &mut Context<'_>,
|
|
87
|
+
output: &mut ReadBuf<'_>,
|
|
88
|
+
) -> Poll<std::io::Result<()>> {
|
|
89
|
+
loop {
|
|
90
|
+
if self.pending_offset < self.pending.len() {
|
|
91
|
+
let available = &self.pending[self.pending_offset..];
|
|
92
|
+
let copied = available.len().min(output.remaining());
|
|
93
|
+
output.put_slice(&available[..copied]);
|
|
94
|
+
self.pending_offset += copied;
|
|
95
|
+
if self.pending_offset == self.pending.len() {
|
|
96
|
+
self.pending.clear();
|
|
97
|
+
self.pending_offset = 0;
|
|
98
|
+
}
|
|
99
|
+
return Poll::Ready(Ok(()));
|
|
100
|
+
}
|
|
101
|
+
match self.receiver.poll_recv(context) {
|
|
102
|
+
Poll::Ready(Some(Ok(bytes))) => {
|
|
103
|
+
self.pending = bytes;
|
|
104
|
+
self.pending_offset = 0;
|
|
105
|
+
}
|
|
106
|
+
Poll::Ready(Some(Err(error))) => return Poll::Ready(Err(error)),
|
|
107
|
+
// A zero-byte read is the transport's end of input; the reader thread only closes
|
|
108
|
+
// the channel once every byte it read has been handed over.
|
|
109
|
+
Poll::Ready(None) => return Poll::Ready(Ok(())),
|
|
110
|
+
Poll::Pending => return Poll::Pending,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[cfg(test)]
|
|
117
|
+
mod tests {
|
|
118
|
+
use super::DetachedStdin;
|
|
119
|
+
use tokio::io::AsyncReadExt;
|
|
120
|
+
use tokio::sync::{mpsc, watch};
|
|
121
|
+
|
|
122
|
+
fn adapter(
|
|
123
|
+
receiver: mpsc::Receiver<std::io::Result<Vec<u8>>>,
|
|
124
|
+
read_error: watch::Receiver<Option<String>>,
|
|
125
|
+
) -> DetachedStdin {
|
|
126
|
+
DetachedStdin {
|
|
127
|
+
receiver,
|
|
128
|
+
pending: Vec::new(),
|
|
129
|
+
pending_offset: 0,
|
|
130
|
+
read_error,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[test]
|
|
135
|
+
fn stdin_adapter_is_send_for_the_rmcp_transport() {
|
|
136
|
+
fn assert_send<T: Send>() {}
|
|
137
|
+
assert_send::<DetachedStdin>();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[tokio::test]
|
|
141
|
+
async fn end_of_input_follows_all_buffered_input() {
|
|
142
|
+
let (sender, receiver) = mpsc::channel(1);
|
|
143
|
+
sender.send(Ok(b"complete frame".to_vec())).await.unwrap();
|
|
144
|
+
drop(sender);
|
|
145
|
+
let (_read_error_sender, read_error) = watch::channel(None);
|
|
146
|
+
let mut input = adapter(receiver, read_error);
|
|
147
|
+
|
|
148
|
+
let mut prefix = [0; 4];
|
|
149
|
+
input.read_exact(&mut prefix).await.unwrap();
|
|
150
|
+
assert_eq!(&prefix, b"comp");
|
|
151
|
+
|
|
152
|
+
let mut suffix = Vec::new();
|
|
153
|
+
input.read_to_end(&mut suffix).await.unwrap();
|
|
154
|
+
|
|
155
|
+
assert_eq!(suffix, b"lete frame");
|
|
156
|
+
assert_eq!(input.read(&mut [0; 4]).await.unwrap(), 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[tokio::test]
|
|
160
|
+
async fn read_error_is_not_reported_as_clean_eof() {
|
|
161
|
+
let (sender, receiver) = mpsc::channel(1);
|
|
162
|
+
sender
|
|
163
|
+
.send(Err(std::io::Error::new(
|
|
164
|
+
std::io::ErrorKind::BrokenPipe,
|
|
165
|
+
"injected stdin failure",
|
|
166
|
+
)))
|
|
167
|
+
.await
|
|
168
|
+
.unwrap();
|
|
169
|
+
drop(sender);
|
|
170
|
+
let (_read_error_sender, read_error) = watch::channel(None);
|
|
171
|
+
let mut input = adapter(receiver, read_error);
|
|
172
|
+
|
|
173
|
+
let error = input.read_to_end(&mut Vec::new()).await.unwrap_err();
|
|
174
|
+
|
|
175
|
+
assert_eq!(error.kind(), std::io::ErrorKind::BrokenPipe);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
//! Wire-format normalization for the tool input schemas FastCtx publishes.
|
|
2
|
+
//!
|
|
3
|
+
//! # Why this exists
|
|
4
|
+
//!
|
|
5
|
+
//! MCP lets a server publish any JSON Schema, and SEP-2106 widened that further.
|
|
6
|
+
//! The LLM APIs that ultimately consume a tool declaration do not: each accepts a
|
|
7
|
+
//! narrow, and differently narrow, subset. A declaration one of them rejects is not
|
|
8
|
+
//! degraded but fatal — the provider answers 400 for the whole request, so every
|
|
9
|
+
//! FastCtx tool disappears from that turn rather than just the offending parameter.
|
|
10
|
+
//!
|
|
11
|
+
//! Hosts do not absorb the difference for us. Codex forwards `$ref` and the
|
|
12
|
+
//! composition keywords untouched, and rewrites every local `$ref` to `{}` once a
|
|
13
|
+
//! tool schema crosses its compaction budget, which silently erases the accepted
|
|
14
|
+
//! values of an enum parameter. Other hosts each grew their own partial lowering.
|
|
15
|
+
//! FastCtx therefore publishes the intersection of the provider subsets instead of
|
|
16
|
+
//! the union of what MCP permits.
|
|
17
|
+
//!
|
|
18
|
+
//! # The published subset
|
|
19
|
+
//!
|
|
20
|
+
//! Every node carries a single scalar `type`. No published schema contains `$ref`,
|
|
21
|
+
//! `$defs`, `oneOf`, `anyOf`, `allOf`, `const`, `$schema`, `additionalProperties`,
|
|
22
|
+
//! `format`, or a `"null"` type. Only these keywords appear: `type`, `description`,
|
|
23
|
+
//! `properties`, `required`, `items`, `enum`, `default`, `minimum`, `maximum`,
|
|
24
|
+
//! `minItems`, `maxItems`.
|
|
25
|
+
//!
|
|
26
|
+
//! This is a published contract, not a local style choice. It is enforced by
|
|
27
|
+
//! `server_contract::published_tool_schemas_stay_inside_the_portable_subset`, which
|
|
28
|
+
//! walks every published tool instead of a named list of parameters, so neither a
|
|
29
|
+
//! new tool nor a new parameter type can reintroduce a rejected construct unnoticed.
|
|
30
|
+
//! Widening the subset means answering, for each provider, why the addition is safe.
|
|
31
|
+
//!
|
|
32
|
+
//! # Deserialization is untouched
|
|
33
|
+
//!
|
|
34
|
+
//! Normalization rewrites only what the model is told. Serde still parses the
|
|
35
|
+
//! original Rust types, so a parameter may accept more than its schema advertises.
|
|
36
|
+
//! That direction is safe: every input the schema describes is still accepted.
|
|
37
|
+
|
|
38
|
+
use serde_json::{Map, Value};
|
|
39
|
+
|
|
40
|
+
/// Keys removed from every node.
|
|
41
|
+
///
|
|
42
|
+
/// - `$schema` is metadata no consumer needs, and hosts strip it anyway.
|
|
43
|
+
/// - `additionalProperties` does not exist in the Gemini API `Schema` type, where an
|
|
44
|
+
/// unrecognized key is an `Unknown name` 400. Removing it costs no runtime
|
|
45
|
+
/// strictness: `serde(deny_unknown_fields)` still rejects unknown input at call time.
|
|
46
|
+
/// - `format` carries our numeric widths (`uint`, `uint64`, `int64`), which sit
|
|
47
|
+
/// outside every provider's accepted format set while `minimum`/`maximum` already
|
|
48
|
+
/// express the same bound.
|
|
49
|
+
const STRIPPED_KEYS: [&str; 3] = ["$schema", "additionalProperties", "format"];
|
|
50
|
+
|
|
51
|
+
/// Bounds `$ref` inlining so a future self-referential type cannot spin here.
|
|
52
|
+
/// Exceeding it leaves the `$ref` in place, which the published-shape guard reports.
|
|
53
|
+
const MAX_INLINE_DEPTH: usize = 32;
|
|
54
|
+
|
|
55
|
+
/// Rewrites one tool's input schema into the portable subset described above.
|
|
56
|
+
pub(crate) fn normalize_published_schema(schema: &Map<String, Value>) -> Map<String, Value> {
|
|
57
|
+
let definitions = schema
|
|
58
|
+
.get("$defs")
|
|
59
|
+
.and_then(Value::as_object)
|
|
60
|
+
.cloned()
|
|
61
|
+
.unwrap_or_default();
|
|
62
|
+
let mut root = schema.clone();
|
|
63
|
+
root.remove("$defs");
|
|
64
|
+
match normalize_node(Value::Object(root), &definitions, 0) {
|
|
65
|
+
Value::Object(normalized) => normalized,
|
|
66
|
+
// A tool schema is an object by construction; anything else means the router
|
|
67
|
+
// handed us something we do not publish, so pass the original through
|
|
68
|
+
// untouched rather than invent a shape.
|
|
69
|
+
_ => schema.clone(),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fn normalize_node(node: Value, definitions: &Map<String, Value>, depth: usize) -> Value {
|
|
74
|
+
let Value::Object(mut map) = node else {
|
|
75
|
+
return node;
|
|
76
|
+
};
|
|
77
|
+
for key in STRIPPED_KEYS {
|
|
78
|
+
map.remove(key);
|
|
79
|
+
}
|
|
80
|
+
if let Some(inlined) = inlined_reference(&map, definitions, depth) {
|
|
81
|
+
map = inlined;
|
|
82
|
+
}
|
|
83
|
+
collapse_nullable_union(&mut map, definitions, depth);
|
|
84
|
+
collapse_const_variants(&mut map);
|
|
85
|
+
collapse_nullable_type(&mut map);
|
|
86
|
+
|
|
87
|
+
if let Some(Value::Object(properties)) = map.remove("properties") {
|
|
88
|
+
let normalized = properties
|
|
89
|
+
.into_iter()
|
|
90
|
+
.map(|(name, value)| (name, normalize_node(value, definitions, depth + 1)))
|
|
91
|
+
.collect();
|
|
92
|
+
map.insert("properties".to_string(), Value::Object(normalized));
|
|
93
|
+
}
|
|
94
|
+
if let Some(items) = map.remove("items") {
|
|
95
|
+
map.insert(
|
|
96
|
+
"items".to_string(),
|
|
97
|
+
normalize_node(items, definitions, depth + 1),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
Value::Object(map)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Replaces a local `$ref` with the definition body it points at.
|
|
104
|
+
///
|
|
105
|
+
/// The referring node's own keys win, so a parameter's `description` outranks the
|
|
106
|
+
/// shared description on the type it names. Only `#/$defs/<name>` is resolved:
|
|
107
|
+
/// SEP-2106 requires that a remote `$ref` never be dereferenced.
|
|
108
|
+
fn inlined_reference(
|
|
109
|
+
map: &Map<String, Value>,
|
|
110
|
+
definitions: &Map<String, Value>,
|
|
111
|
+
depth: usize,
|
|
112
|
+
) -> Option<Map<String, Value>> {
|
|
113
|
+
if depth >= MAX_INLINE_DEPTH {
|
|
114
|
+
return None;
|
|
115
|
+
}
|
|
116
|
+
let name = map.get("$ref")?.as_str()?.strip_prefix("#/$defs/")?;
|
|
117
|
+
let Value::Object(mut merged) =
|
|
118
|
+
normalize_node(definitions.get(name)?.clone(), definitions, depth + 1)
|
|
119
|
+
else {
|
|
120
|
+
return None;
|
|
121
|
+
};
|
|
122
|
+
for (key, value) in map {
|
|
123
|
+
if key != "$ref" {
|
|
124
|
+
merged.insert(key.clone(), value.clone());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
Some(merged)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Collapses the `Option<T>` shape `anyOf: [T, {"type": "null"}]` down to `T`.
|
|
131
|
+
///
|
|
132
|
+
/// The `required` list already says which parameters may be omitted, so the union
|
|
133
|
+
/// spells optionality a second time in the one form no provider subset accepts. A
|
|
134
|
+
/// union with more than one non-null branch is left intact on purpose: guessing a
|
|
135
|
+
/// branch would silently narrow what the model may send, so the published-shape
|
|
136
|
+
/// guard fails instead and the choice gets made at the Rust type.
|
|
137
|
+
fn collapse_nullable_union(
|
|
138
|
+
map: &mut Map<String, Value>,
|
|
139
|
+
definitions: &Map<String, Value>,
|
|
140
|
+
depth: usize,
|
|
141
|
+
) {
|
|
142
|
+
let Some(Value::Array(branches)) = map.get("anyOf") else {
|
|
143
|
+
return;
|
|
144
|
+
};
|
|
145
|
+
let mut kept: Vec<Value> = branches
|
|
146
|
+
.iter()
|
|
147
|
+
.map(|branch| normalize_node(branch.clone(), definitions, depth + 1))
|
|
148
|
+
.filter(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
|
|
149
|
+
.collect();
|
|
150
|
+
if kept.len() != 1 {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
let Value::Object(branch) = kept.remove(0) else {
|
|
154
|
+
return;
|
|
155
|
+
};
|
|
156
|
+
map.remove("anyOf");
|
|
157
|
+
for (key, value) in branch {
|
|
158
|
+
map.entry(key).or_insert(value);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Rewrites the `oneOf` of `const` variants schemars derives for a fieldless enum
|
|
163
|
+
/// into `type: "string"` with `enum`.
|
|
164
|
+
///
|
|
165
|
+
/// No provider subset accepts `oneOf` or `const`; all of them accept a string enum.
|
|
166
|
+
/// The per-variant doc comments are dropped along with the `oneOf`, so every such
|
|
167
|
+
/// parameter states its accepted values in its own `description`.
|
|
168
|
+
fn collapse_const_variants(map: &mut Map<String, Value>) {
|
|
169
|
+
let Some(Value::Array(variants)) = map.get("oneOf") else {
|
|
170
|
+
return;
|
|
171
|
+
};
|
|
172
|
+
let mut values = Vec::with_capacity(variants.len());
|
|
173
|
+
for variant in variants {
|
|
174
|
+
match variant.get("const") {
|
|
175
|
+
Some(Value::String(value)) => values.push(Value::String(value.clone())),
|
|
176
|
+
_ => return,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if values.is_empty() {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
map.remove("oneOf");
|
|
183
|
+
map.insert("type".to_string(), Value::String("string".to_string()));
|
|
184
|
+
map.insert("enum".to_string(), Value::Array(values));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Rewrites `type: ["string", "null"]` as `type: "string"`.
|
|
188
|
+
///
|
|
189
|
+
/// Gemini's schema type is a single scalar rather than a list, so a type array is
|
|
190
|
+
/// rejected outright; `required` already carries which parameters are optional.
|
|
191
|
+
fn collapse_nullable_type(map: &mut Map<String, Value>) {
|
|
192
|
+
let Some(Value::Array(members)) = map.get("type") else {
|
|
193
|
+
return;
|
|
194
|
+
};
|
|
195
|
+
let mut kept: Vec<Value> = members
|
|
196
|
+
.iter()
|
|
197
|
+
.filter(|member| member.as_str() != Some("null"))
|
|
198
|
+
.cloned()
|
|
199
|
+
.collect();
|
|
200
|
+
if kept.len() != 1 {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
map.insert("type".to_string(), kept.remove(0));
|
|
204
|
+
}
|