dsh-ops 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,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
+ }