1#![forbid(unsafe_code)]
2
3use serde::{Deserialize, Serialize};
4
5const INVALID_ARGUMENTS: &str = "invalid KtoolDocs arguments";
6const UNKNOWN_KTOOL: &str = "unknown Ktool";
7
8struct Entry {
9 name: &'static str,
10 version: &'static str,
11 docs: &'static str,
12 replacement: Option<&'static str>,
13}
14
15impl Entry {
16 const fn new(
17 name: &'static str,
18 version: &'static str,
19 docs: &'static str,
20 replacement: Option<&'static str>,
21 ) -> Self {
22 Self {
23 name,
24 version,
25 docs,
26 replacement,
27 }
28 }
29}
30
31static CATALOG: [Entry; 8] = [
32 Entry::new(
33 "KtoolDocs",
34 "1.0.0",
35 r#"Invoke as KtoolDocs {"name":"<exact Ktool name>"}. Arguments must be exactly one nonempty string field, name; matching is exact and case-sensitive. Success returns a compact JSON object with exactly name, latest_version, docs, deprecated, and replacement. Malformed arguments return invalid KtoolDocs arguments; an unrecognized exact name returns unknown Ktool. The operation is stateless metadata lookup only and does not list, search, suggest, authorize, register, or prove live availability."#,
36 None,
37 ),
38 Entry::new(
39 "CurrentTime",
40 "1.0.0",
41 r#"Invoke as CurrentTime {}. Returns current system UTC in RFC3339 with exactly three fractional digits and Z. Any nonempty or nonobject arguments return invalid CurrentTime arguments. It is stateless and needs no Kmap authorization."#,
42 None,
43 ),
44 Entry::new(
45 "KmapCreateNode",
46 "1.0.0",
47 r#"Invoke as KmapCreateNode {"title":string,"navigation_hint":string,"narrative":string,"connections":[string,...]}. Each connection must be an exact 24-character lowercase hexadecimal Kmap node ID. Using the active Kmap authorization, it creates one node under the bound profile and policy, adds the supplied Navigation connections, marks the new node loaded, and returns Node successfully created with id <id>. Malformed arguments or IDs return invalid KmapCreateNode arguments; missing authorization and Kmap dependency failures remain errors."#,
48 None,
49 ),
50 Entry::new(
51 "KmapOpenNode",
52 "1.0.0",
53 r#"Invoke as KmapOpenNode {"node_id":string,"budget":number}. node_id must be an exact 24-character lowercase hexadecimal Kmap node ID and budget must be finite and nonnegative. It opens in Full mode at temperature 1, records loaded nodes and first-pull provenance, and returns only components not already returned during the session. If no new components remain, it returns No new Kmap node components. Malformed arguments return invalid KmapOpenNode arguments; missing authorization and Kmap dependency failures remain errors."#,
54 None,
55 ),
56 Entry::new(
57 "KmapUpdateNode",
58 "1.0.0",
59 r#"Invoke as KmapUpdateNode {"node_id":string,"title":string,"navigation_hint":string,"narrative":string,"connections":[string,...]}. All IDs must be exact 24-character lowercase hexadecimal Kmap node IDs. It replaces the node text fields, additively upserts the supplied Navigation connections, marks the updated node loaded, and returns success. Naming another node does not itself load that node. Malformed arguments return invalid KmapUpdateNode arguments; missing authorization and Kmap dependency failures remain errors."#,
60 None,
61 ),
62 Entry::new(
63 "KmapPenalizeNodes",
64 "1.0.0",
65 r#"Invoke as KmapPenalizeNodes {"node_ids":[string,...]}. Every ID must be an exact 24-character lowercase hexadecimal Kmap node ID. It deduplicates the input and applies one noncritical negative measurement to each newly penalized eligible loaded node with first-pull provenance. Penalized nodes are excluded from the session’s later KmapConnectNodes operation. Unknown, unloaded, or already penalized valid IDs have no additional effect. Success returns success. Malformed arguments return invalid KmapPenalizeNodes arguments; missing authorization and Kmap dependency failures remain errors."#,
66 None,
67 ),
68 Entry::new(
69 "KmapConnectNodes",
70 "1.0.0",
71 r#"Invoke as KmapConnectNodes {}. Using the active Kmap authorization, it adds every missing directed Automated connection among loaded, unpenalized session nodes. Existing connections remain unchanged and self-connections are not added. Success returns success. Nonempty or nonobject arguments return invalid KmapConnectNodes arguments; missing authorization and Kmap dependency failures remain errors."#,
72 None,
73 ),
74 Entry::new(
75 "SendMessage",
76 "1.0.0",
77 r#"Invoke as SendMessage {"message":string}. Arguments must contain exactly one nonempty message string. It durably appends one visible Agent Message to the authenticated current conversation between the Tool Call and Tool Result, returns success, and leaves the provider turn open for continued generation. It has no recipient argument, network effect, or cross-conversation capability. Malformed arguments return invalid SendMessage arguments without appending a message; persistence failures remain errors."#,
78 None,
79 ),
80];
81
82#[derive(Deserialize)]
83#[serde(deny_unknown_fields)]
84struct Arguments {
85 name: String,
86}
87
88#[derive(Serialize)]
89struct Response<'a> {
90 name: &'a str,
91 latest_version: &'static str,
92 docs: &'a str,
93 deprecated: bool,
94 replacement: Option<&'a str>,
95}
96
97fn find_entry<'a>(catalog: &'a [Entry], name: &str) -> Option<&'a Entry> {
98 catalog.iter().find(|entry| entry.name == name)
99}
100
101fn render(entry: &Entry) -> Result<String, String> {
102 let response = Response {
103 name: entry.name,
104 latest_version: entry.version,
105 docs: entry.docs,
106 deprecated: entry.replacement.is_some(),
107 replacement: entry.replacement,
108 };
109
110 serde_json::to_string(&response)
111 .map_err(|error| format!("KtoolDocs response serialization failed: {error}"))
112}
113
114pub fn ktool_docs(arguments: &str) -> Result<String, String> {
116 let arguments: Arguments =
117 serde_json::from_str(arguments).map_err(|_| INVALID_ARGUMENTS.to_owned())?;
118 if arguments.name.is_empty() {
119 return Err(INVALID_ARGUMENTS.to_owned());
120 }
121
122 let entry = find_entry(&CATALOG, &arguments.name).ok_or_else(|| UNKNOWN_KTOOL.to_owned())?;
123 render(entry)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use serde_json::Value;
130
131 const EXPECTED: [(&str, &str); 8] = [
132 (
133 "KtoolDocs",
134 r#"Invoke as KtoolDocs {"name":"<exact Ktool name>"}. Arguments must be exactly one nonempty string field, name; matching is exact and case-sensitive. Success returns a compact JSON object with exactly name, latest_version, docs, deprecated, and replacement. Malformed arguments return invalid KtoolDocs arguments; an unrecognized exact name returns unknown Ktool. The operation is stateless metadata lookup only and does not list, search, suggest, authorize, register, or prove live availability."#,
135 ),
136 (
137 "CurrentTime",
138 r#"Invoke as CurrentTime {}. Returns current system UTC in RFC3339 with exactly three fractional digits and Z. Any nonempty or nonobject arguments return invalid CurrentTime arguments. It is stateless and needs no Kmap authorization."#,
139 ),
140 (
141 "KmapCreateNode",
142 r#"Invoke as KmapCreateNode {"title":string,"navigation_hint":string,"narrative":string,"connections":[string,...]}. Each connection must be an exact 24-character lowercase hexadecimal Kmap node ID. Using the active Kmap authorization, it creates one node under the bound profile and policy, adds the supplied Navigation connections, marks the new node loaded, and returns Node successfully created with id <id>. Malformed arguments or IDs return invalid KmapCreateNode arguments; missing authorization and Kmap dependency failures remain errors."#,
143 ),
144 (
145 "KmapOpenNode",
146 r#"Invoke as KmapOpenNode {"node_id":string,"budget":number}. node_id must be an exact 24-character lowercase hexadecimal Kmap node ID and budget must be finite and nonnegative. It opens in Full mode at temperature 1, records loaded nodes and first-pull provenance, and returns only components not already returned during the session. If no new components remain, it returns No new Kmap node components. Malformed arguments return invalid KmapOpenNode arguments; missing authorization and Kmap dependency failures remain errors."#,
147 ),
148 (
149 "KmapUpdateNode",
150 r#"Invoke as KmapUpdateNode {"node_id":string,"title":string,"navigation_hint":string,"narrative":string,"connections":[string,...]}. All IDs must be exact 24-character lowercase hexadecimal Kmap node IDs. It replaces the node text fields, additively upserts the supplied Navigation connections, marks the updated node loaded, and returns success. Naming another node does not itself load that node. Malformed arguments return invalid KmapUpdateNode arguments; missing authorization and Kmap dependency failures remain errors."#,
151 ),
152 (
153 "KmapPenalizeNodes",
154 r#"Invoke as KmapPenalizeNodes {"node_ids":[string,...]}. Every ID must be an exact 24-character lowercase hexadecimal Kmap node ID. It deduplicates the input and applies one noncritical negative measurement to each newly penalized eligible loaded node with first-pull provenance. Penalized nodes are excluded from the session’s later KmapConnectNodes operation. Unknown, unloaded, or already penalized valid IDs have no additional effect. Success returns success. Malformed arguments return invalid KmapPenalizeNodes arguments; missing authorization and Kmap dependency failures remain errors."#,
155 ),
156 (
157 "KmapConnectNodes",
158 r#"Invoke as KmapConnectNodes {}. Using the active Kmap authorization, it adds every missing directed Automated connection among loaded, unpenalized session nodes. Existing connections remain unchanged and self-connections are not added. Success returns success. Nonempty or nonobject arguments return invalid KmapConnectNodes arguments; missing authorization and Kmap dependency failures remain errors."#,
159 ),
160 (
161 "SendMessage",
162 r#"Invoke as SendMessage {"message":string}. Arguments must contain exactly one nonempty message string. It durably appends one visible Agent Message to the authenticated current conversation between the Tool Call and Tool Result, returns success, and leaves the provider turn open for continued generation. It has no recipient argument, network effect, or cross-conversation capability. Malformed arguments return invalid SendMessage arguments without appending a message; persistence failures remain errors."#,
163 ),
164 ];
165
166 fn arguments_for(name: &str) -> String {
167 format!(
168 "{{\"name\":{}}}",
169 serde_json::to_string(name).expect("a string is JSON-serializable")
170 )
171 }
172
173 fn expected_active_json(name: &str, docs: &str) -> String {
174 format!(
175 "{{\"name\":{},\"latest_version\":\"1.0.0\",\"docs\":{},\"deprecated\":false,\"replacement\":null}}",
176 serde_json::to_string(name).expect("a string is JSON-serializable"),
177 serde_json::to_string(docs).expect("a string is JSON-serializable")
178 )
179 }
180
181 fn replacement_invariants_hold(catalog: &[Entry]) -> bool {
182 catalog.iter().all(|entry| {
183 let Some(replacement) = entry.replacement else {
184 return true;
185 };
186 if replacement.is_empty() || replacement == entry.name {
187 return false;
188 }
189 matches!(
190 find_entry(catalog, replacement),
191 Some(target) if target.replacement.is_none()
192 )
193 })
194 }
195
196 #[test]
197 fn all_exact_names_have_exact_ordered_five_field_outputs() {
198 for (name, docs) in EXPECTED {
199 let output = ktool_docs(&arguments_for(name)).expect("known name must succeed");
200 assert_eq!(output, expected_active_json(name, docs));
201
202 let value: Value = serde_json::from_str(&output).expect("response must be JSON");
203 let object = value.as_object().expect("response must be an object");
204 assert_eq!(object.len(), 5);
205 assert_eq!(object.get("name").and_then(Value::as_str), Some(name));
206 assert_eq!(
207 object.get("latest_version").and_then(Value::as_str),
208 Some("1.0.0")
209 );
210 assert_eq!(object.get("docs").and_then(Value::as_str), Some(docs));
211 assert_eq!(
212 object.get("deprecated").and_then(Value::as_bool),
213 Some(false)
214 );
215 assert!(object.get("replacement").is_some_and(Value::is_null));
216 }
217 }
218
219 #[test]
220 fn self_lookup_documents_ktool_docs() {
221 let output = ktool_docs(r#"{"name":"KtoolDocs"}"#).expect("self lookup must succeed");
222 assert_eq!(output, expected_active_json(EXPECTED[0].0, EXPECTED[0].1));
223 }
224
225 #[test]
226 fn malformed_or_nonconforming_arguments_are_strictly_rejected() {
227 let invalid = [
228 "",
229 " ",
230 "{",
231 "null",
232 "[]",
233 r#""KtoolDocs""#,
234 "0",
235 "true",
236 "{}",
237 r#"{"other":"KtoolDocs"}"#,
238 r#"{"Name":"KtoolDocs"}"#,
239 r#"{"name":null}"#,
240 r#"{"name":true}"#,
241 r#"{"name":1}"#,
242 r#"{"name":[]}"#,
243 r#"{"name":{}}"#,
244 r#"{"name":""}"#,
245 r#"{"name":"KtoolDocs","extra":false}"#,
246 r#"{"extra":false,"name":"KtoolDocs"}"#,
247 r#"{"name":"KtoolDocs","name":"CurrentTime"}"#,
248 r#"{"name":"KtoolDocs"} trailing"#,
249 ];
250
251 for input in invalid {
252 assert_eq!(
253 ktool_docs(input),
254 Err(INVALID_ARGUMENTS.to_owned()),
255 "{input:?}"
256 );
257 }
258 }
259
260 #[test]
261 fn exact_match_variants_return_only_unknown_ktool() {
262 let variants = [
263 "ktoolDocs",
264 "KTOOLDOCS",
265 " KtoolDocs",
266 "KtoolDocs ",
267 "KtoolDoc",
268 "KtoolDocs\n",
269 "Currenttime",
270 "KmapOpenNode/",
271 "SendMessage\0",
272 ];
273
274 for variant in variants {
275 assert_eq!(
276 ktool_docs(&arguments_for(variant)),
277 Err(UNKNOWN_KTOOL.to_owned()),
278 "{variant:?}"
279 );
280 }
281 }
282
283 #[test]
284 fn repeated_lookups_are_deterministic() {
285 for (name, _) in EXPECTED {
286 let arguments = arguments_for(name);
287 let first = ktool_docs(&arguments).expect("known name must succeed");
288 for _ in 0..10 {
289 assert_eq!(ktool_docs(&arguments), Ok(first.clone()));
290 }
291 }
292 }
293
294 #[test]
295 fn rendering_escapes_all_json_string_fields() {
296 let entry = Entry::new(
297 "quote\" slash\\ newline\n",
298 "7.4.2",
299 "tab\t backspace\u{0008} quote\" slash\\",
300 None,
301 );
302 let output = render(&entry).expect("supported response must serialize");
303
304 assert!(!output.contains('\n'));
305 assert!(!output.contains('\t'));
306 assert!(!output.contains('\u{0008}'));
307 assert!(output.contains("\\n"));
308 assert!(output.contains("\\t"));
309 assert!(output.contains("\\b"));
310 assert!(output.contains("\\\""));
311 assert!(output.contains("\\\\"));
312
313 let value: Value = serde_json::from_str(&output).expect("escaped response must parse");
314 assert_eq!(value.get("name").and_then(Value::as_str), Some(entry.name));
315 assert_eq!(value.get("docs").and_then(Value::as_str), Some(entry.docs));
316 }
317
318 #[test]
319 fn private_deprecated_entry_renders_replacement_and_derived_flag() {
320 let entry = Entry::new("RetiredTool", "2.1.3", "Retired docs.", Some("CurrentTime"));
321 let output = render(&entry).expect("deprecated response must serialize");
322 assert_eq!(
323 output,
324 r#"{"name":"RetiredTool","latest_version":"2.1.3","docs":"Retired docs.","deprecated":true,"replacement":"CurrentTime"}"#
325 );
326
327 let value: Value = serde_json::from_str(&output).expect("response must parse");
328 assert_eq!(
329 value.get("deprecated").and_then(Value::as_bool),
330 Some(entry.replacement.is_some())
331 );
332 }
333
334 #[test]
335 fn replacement_invariants_require_nonempty_nonself_known_active_target() {
336 let valid = [
337 Entry::new("NewTool", "3.0.0", "new", None),
338 Entry::new("OldTool", "2.0.0", "old", Some("NewTool")),
339 ];
340 assert!(replacement_invariants_hold(&CATALOG));
341 assert!(replacement_invariants_hold(&valid));
342
343 let empty = [Entry::new("OldTool", "1.0.0", "old", Some(""))];
344 let self_replacement = [Entry::new("OldTool", "1.0.0", "old", Some("OldTool"))];
345 let unknown = [Entry::new("OldTool", "1.0.0", "old", Some("MissingTool"))];
346 let deprecated_target = [
347 Entry::new("NewTool", "3.0.0", "new", None),
348 Entry::new("MiddleTool", "2.0.0", "middle", Some("NewTool")),
349 Entry::new("OldTool", "1.0.0", "old", Some("MiddleTool")),
350 ];
351
352 assert!(!replacement_invariants_hold(&empty));
353 assert!(!replacement_invariants_hold(&self_replacement));
354 assert!(!replacement_invariants_hold(&unknown));
355 assert!(!replacement_invariants_hold(&deprecated_target));
356 }
357}