1use codex_protocol::ToolName;
2use serde::Deserialize;
3use serde::Serialize;
4use serde_json::Value as JsonValue;
5use std::collections::BTreeMap;
6
7use crate::PUBLIC_TOOL_NAME;
8
9const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
10const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some deferred nested tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`.
11To find one, filter `ALL_TOOLS` by `name` and `description`."#;
12const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls
13- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.
14- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
15- Nested tool methods take either a string or an object as their input argument.
16- Nested tools return either an object or a string, based on the description.
17- Runs raw JavaScript -- no Node, no file system, no network access, no console.
18- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
19- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
20- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms.
21- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens.
22- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
23
24- Global helpers:
25- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
26- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
27- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` should be a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
28- `audio(audioUrlOrItem: string | { audio_url: string } | AudioContent)`: Appends an audio item. `audio_url` should be a base64-encoded `data:` URL. To forward an MCP tool audio block, pass an individual `AudioContent` block from `result.content`, for example `audio(result.content[0])`.
29- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint. HTTP(S) URLs are not supported.
30- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
31- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
32- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
33- `setTimeout(callback: () => void, delayMs?: number)`: schedules a callback to run later and returns a timeout id. Pending timeouts do not keep `exec` alive by themselves; await an explicit promise if you need to wait for one.
34- `clearTimeout(timeoutId?: number)`: cancels a timeout created by `setTimeout`.
35- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.
36- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#;
37const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`.
38- `cell_id` identifies the running `exec` cell to resume.
39- `yield_time_ms` controls how long to wait for more output before yielding again. Defaults to 10000 ms.
40- `max_tokens` limits how much new output this wait call returns. Defaults to 10000 tokens.
41- `terminate: true` stops the running cell; false or omitted waits for output.
42- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
43- If the cell is still running, `wait` may yield again with the same `cell_id`.
44- If the cell has already finished, `wait` returns the completed result and closes the cell."#;
45const MCP_TYPESCRIPT_PREAMBLE: &str = r#"type Role = "user" | "assistant";
47type MetaObject = Record<string, unknown>;
48type Annotations = {
49 audience?: Role[];
50 priority?: number;
51 lastModified?: string;
52};
53type Icon = {
54 src: string;
55 mimeType?: string;
56 sizes?: string[];
57 theme?: "light" | "dark";
58};
59type TextResourceContents = {
60 uri: string;
61 mimeType?: string;
62 _meta?: MetaObject;
63 text: string;
64};
65type BlobResourceContents = {
66 uri: string;
67 mimeType?: string;
68 _meta?: MetaObject;
69 blob: string;
70};
71type TextContent = {
72 type: "text";
73 text: string;
74 annotations?: Annotations;
75 _meta?: MetaObject;
76};
77type ImageContent = {
78 type: "image";
79 data: string;
80 mimeType: string;
81 annotations?: Annotations;
82 _meta?: MetaObject;
83};
84type AudioContent = {
85 type: "audio";
86 data: string;
87 mimeType: string;
88 annotations?: Annotations;
89 _meta?: MetaObject;
90};
91type ResourceLink = {
92 icons?: Icon[];
93 name: string;
94 title?: string;
95 uri: string;
96 description?: string;
97 mimeType?: string;
98 annotations?: Annotations;
99 size?: number;
100 _meta?: MetaObject;
101 type: "resource_link";
102};
103type EmbeddedResource = {
104 type: "resource";
105 resource: TextResourceContents | BlobResourceContents;
106 annotations?: Annotations;
107 _meta?: MetaObject;
108};
109type ContentBlock =
110 | TextContent
111 | ImageContent
112 | AudioContent
113 | ResourceLink
114 | EmbeddedResource;
115type CallToolResult<TStructured = { [key: string]: unknown }> = {
116 _meta?: MetaObject;
117 content: ContentBlock[];
118 isError?: boolean;
119 structuredContent?: TStructured;
120 [key: string]: unknown;
121};"#;
122
123pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:";
124
125#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum CodeModeToolKind {
128 Function,
129 Freeform,
130}
131
132#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
133pub struct ToolDefinition {
134 pub name: String,
135 pub tool_name: ToolName,
136 pub description: String,
137 pub kind: CodeModeToolKind,
138 pub input_schema: Option<JsonValue>,
139 pub output_schema: Option<JsonValue>,
140}
141
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct ToolNamespaceDescription {
144 pub name: String,
145 pub description: String,
146}
147
148#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
149#[serde(deny_unknown_fields)]
150struct CodeModeExecPragma {
151 #[serde(default)]
152 yield_time_ms: Option<u64>,
153 #[serde(default)]
154 max_output_tokens: Option<usize>,
155}
156
157#[derive(Debug, PartialEq, Eq)]
158pub struct ParsedExecSource {
159 pub code: String,
160 pub yield_time_ms: Option<u64>,
161 pub max_output_tokens: Option<usize>,
162}
163
164pub fn parse_exec_source(input: &str) -> Result<ParsedExecSource, String> {
165 if input.trim().is_empty() {
166 return Err(
167 "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(),
168 );
169 }
170
171 let mut args = ParsedExecSource {
172 code: input.to_string(),
173 yield_time_ms: None,
174 max_output_tokens: None,
175 };
176
177 let mut lines = input.splitn(2, '\n');
178 let first_line = lines.next().unwrap_or_default();
179 let rest = lines.next().unwrap_or_default();
180 let trimmed = first_line.trim_start();
181 let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else {
182 return Ok(args);
183 };
184
185 if rest.trim().is_empty() {
186 return Err(
187 "exec pragma must be followed by JavaScript source on subsequent lines".to_string(),
188 );
189 }
190
191 let directive = pragma.trim();
192 if directive.is_empty() {
193 return Err(
194 "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
195 .to_string(),
196 );
197 }
198
199 let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| {
200 format!(
201 "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}"
202 )
203 })?;
204 let object = value.as_object().ok_or_else(|| {
205 "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
206 .to_string()
207 })?;
208 for key in object.keys() {
209 match key.as_str() {
210 "yield_time_ms" | "max_output_tokens" => {}
211 _ => {
212 return Err(format!(
213 "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`"
214 ));
215 }
216 }
217 }
218
219 let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| {
220 format!(
221 "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}"
222 )
223 })?;
224 if pragma
225 .yield_time_ms
226 .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER)
227 {
228 return Err(
229 "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(),
230 );
231 }
232 if pragma.max_output_tokens.is_some_and(|max_output_tokens| {
233 u64::try_from(max_output_tokens)
234 .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER)
235 .unwrap_or(true)
236 }) {
237 return Err(
238 "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(),
239 );
240 }
241
242 args.code = rest.to_string();
243 args.yield_time_ms = pragma.yield_time_ms;
244 args.max_output_tokens = pragma.max_output_tokens;
245 Ok(args)
246}
247
248pub fn is_code_mode_nested_tool(tool_name: &str) -> bool {
249 tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME
250}
251
252pub fn build_exec_tool_description(
253 enabled_tools: &[ToolDefinition],
254 deferred_tools: &[ToolDefinition],
255 namespace_descriptions: &BTreeMap<String, ToolNamespaceDescription>,
256 default_exec_yield_time_ms: u64,
257 code_mode_only: bool,
258) -> String {
259 let mut sections = Vec::new();
260 sections.push(EXEC_DESCRIPTION_TEMPLATE.replace(
261 "Defaults to 10000 ms.",
262 &format!("Defaults to {default_exec_yield_time_ms} ms."),
263 ));
264 if !deferred_tools.is_empty() {
265 sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string());
266 }
267 if !code_mode_only {
268 return sections.join("\n\n");
269 }
270
271 let has_mcp_tools = enabled_tools
272 .iter()
273 .chain(deferred_tools)
274 .any(|tool| mcp_structured_content_schema(tool.output_schema.as_ref()).is_some());
275 if has_mcp_tools {
276 sections.push(format!(
277 "Shared MCP Types:\n```ts\n{MCP_TYPESCRIPT_PREAMBLE}\n```"
278 ));
279 }
280
281 if !enabled_tools.is_empty() {
282 let mut current_namespace: Option<&str> = None;
283 let mut nested_tool_sections = Vec::with_capacity(enabled_tools.len());
284
285 for tool in enabled_tools {
286 let name = tool.name.as_str();
287 let nested_description = render_code_mode_sample_for_definition(tool);
288 let namespace_description = tool
289 .tool_name
290 .namespace
291 .as_ref()
292 .and_then(|namespace| namespace_descriptions.get(namespace));
293 let next_namespace = namespace_description
294 .map(|namespace_description| namespace_description.name.as_str());
295 if next_namespace != current_namespace {
296 if let Some(namespace_description) = namespace_description {
297 let namespace_description_text = namespace_description.description.trim();
298 if !namespace_description_text.is_empty() {
299 nested_tool_sections.push(format!(
300 "## {}\n{namespace_description_text}",
301 namespace_description.name
302 ));
303 }
304 }
305 current_namespace = next_namespace;
306 }
307
308 let global_name = normalize_code_mode_identifier(name);
309 let nested_description = nested_description.trim();
310 if nested_description.is_empty() {
311 nested_tool_sections.push(render_tool_heading(&global_name, name));
312 } else {
313 nested_tool_sections.push(format!(
314 "{}\n{nested_description}",
315 render_tool_heading(&global_name, name)
316 ));
317 }
318 }
319
320 let nested_tool_reference = nested_tool_sections.join("\n\n");
321 sections.push(nested_tool_reference);
322 }
323
324 sections.join("\n\n")
325}
326
327pub fn build_wait_tool_description() -> &'static str {
328 WAIT_DESCRIPTION_TEMPLATE
329}
330
331pub fn normalize_code_mode_identifier(tool_key: &str) -> String {
332 let mut identifier = String::new();
333
334 for (index, ch) in tool_key.chars().enumerate() {
335 let is_valid = if index == 0 {
336 ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
337 } else {
338 ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
339 };
340
341 if is_valid {
342 identifier.push(ch);
343 } else {
344 identifier.push('_');
345 }
346 }
347
348 if identifier.is_empty() {
349 "_".to_string()
350 } else {
351 identifier
352 }
353}
354
355pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition {
356 if definition.name != PUBLIC_TOOL_NAME {
357 definition.description = render_code_mode_sample_for_definition(&definition);
358 }
359 definition
360}
361
362pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata {
363 EnabledToolMetadata {
364 tool_name: definition.tool_name.clone(),
365 global_name: normalize_code_mode_identifier(&definition.name),
366 description: definition.description.clone(),
367 kind: definition.kind,
368 }
369}
370
371#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
372pub struct EnabledToolMetadata {
373 pub tool_name: ToolName,
374 pub global_name: String,
375 pub description: String,
376 pub kind: CodeModeToolKind,
377}
378
379pub fn render_code_mode_sample(
380 description: &str,
381 tool_name: &str,
382 input_name: &str,
383 input_type: String,
384 output_type: String,
385) -> String {
386 let declaration = format!(
387 "declare const tools: {{ {} }};",
388 render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type)
389 );
390 format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```")
391}
392
393fn render_code_mode_sample_for_definition(definition: &ToolDefinition) -> String {
394 let input_name = match definition.kind {
395 CodeModeToolKind::Function => "args",
396 CodeModeToolKind::Freeform => "input",
397 };
398 let input_type = match definition.kind {
399 CodeModeToolKind::Function => definition
400 .input_schema
401 .as_ref()
402 .map(render_json_schema_to_typescript)
403 .unwrap_or_else(|| "unknown".to_string()),
404 CodeModeToolKind::Freeform => "string".to_string(),
405 };
406 let output_type = if let Some(structured_content_schema) =
407 mcp_structured_content_schema(definition.output_schema.as_ref())
408 {
409 let structured_content_type = render_json_schema_to_typescript(structured_content_schema);
410 if structured_content_type == "unknown" {
411 "CallToolResult".to_string()
412 } else {
413 format!("CallToolResult<{structured_content_type}>")
414 }
415 } else {
416 definition
417 .output_schema
418 .as_ref()
419 .map(render_json_schema_to_typescript)
420 .unwrap_or_else(|| "unknown".to_string())
421 };
422 render_code_mode_sample(
423 &definition.description,
424 &definition.name,
425 input_name,
426 input_type,
427 output_type,
428 )
429}
430
431fn render_code_mode_tool_declaration(
432 tool_name: &str,
433 input_name: &str,
434 input_type: String,
435 output_type: String,
436) -> String {
437 let tool_name = normalize_code_mode_identifier(tool_name);
438 format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;")
439}
440
441fn render_tool_heading(global_name: &str, raw_name: &str) -> String {
442 if global_name == raw_name {
443 format!("### `{global_name}`")
444 } else {
445 format!("### `{global_name}` (`{raw_name}`)")
446 }
447}
448
449pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
450 render_json_schema_to_typescript_inner(schema)
451}
452
453fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&JsonValue> {
454 let output_schema = output_schema?;
455 let properties = output_schema
456 .get("properties")
457 .and_then(JsonValue::as_object)?;
458 let content_schema = properties.get("content").and_then(JsonValue::as_object)?;
459 if content_schema.get("type").and_then(JsonValue::as_str) != Some("array") {
460 return None;
461 }
462
463 if content_schema
464 .get("items")
465 .and_then(JsonValue::as_object)
466 .is_none_or(|items| items.get("type").and_then(JsonValue::as_str) != Some("object"))
467 {
468 return None;
469 }
470
471 if properties
472 .get("isError")
473 .and_then(JsonValue::as_object)
474 .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("boolean"))
475 {
476 return None;
477 }
478
479 if properties
480 .get("_meta")
481 .and_then(JsonValue::as_object)
482 .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("object"))
483 {
484 return None;
485 }
486
487 Some(
488 properties
489 .get("structuredContent")
490 .unwrap_or(&JsonValue::Bool(true)),
491 )
492}
493
494fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
495 match schema {
496 JsonValue::Bool(true) => "unknown".to_string(),
497 JsonValue::Bool(false) => "never".to_string(),
498 JsonValue::Object(map) => {
499 if let Some(value) = map.get("const") {
500 return render_json_schema_literal(value);
501 }
502
503 if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
504 let rendered = values
505 .iter()
506 .map(render_json_schema_literal)
507 .collect::<Vec<_>>();
508 if !rendered.is_empty() {
509 return rendered.join(" | ");
510 }
511 }
512
513 for key in ["anyOf", "oneOf"] {
514 if let Some(variants) = map.get(key).and_then(JsonValue::as_array) {
515 let rendered = variants
516 .iter()
517 .map(render_json_schema_to_typescript_inner)
518 .collect::<Vec<_>>();
519 if !rendered.is_empty() {
520 return rendered.join(" | ");
521 }
522 }
523 }
524
525 if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
526 let rendered = variants
527 .iter()
528 .map(render_json_schema_to_typescript_inner)
529 .collect::<Vec<_>>();
530 if !rendered.is_empty() {
531 return rendered.join(" & ");
532 }
533 }
534
535 if let Some(schema_type) = map.get("type") {
536 if let Some(types) = schema_type.as_array() {
537 let rendered = types
538 .iter()
539 .filter_map(JsonValue::as_str)
540 .map(|schema_type| render_json_schema_type_keyword(map, schema_type))
541 .collect::<Vec<_>>();
542 if !rendered.is_empty() {
543 return rendered.join(" | ");
544 }
545 }
546
547 if let Some(schema_type) = schema_type.as_str() {
548 return render_json_schema_type_keyword(map, schema_type);
549 }
550 }
551
552 if map.contains_key("properties")
553 || map.contains_key("additionalProperties")
554 || map.contains_key("required")
555 {
556 return render_json_schema_object(map);
557 }
558
559 if map.contains_key("items") || map.contains_key("prefixItems") {
560 return render_json_schema_array(map);
561 }
562
563 "unknown".to_string()
564 }
565 _ => "unknown".to_string(),
566 }
567}
568
569fn render_json_schema_type_keyword(
570 map: &serde_json::Map<String, JsonValue>,
571 schema_type: &str,
572) -> String {
573 match schema_type {
574 "string" => "string".to_string(),
575 "number" | "integer" => "number".to_string(),
576 "boolean" => "boolean".to_string(),
577 "null" => "null".to_string(),
578 "array" => render_json_schema_array(map),
579 "object" => render_json_schema_object(map),
580 _ => "unknown".to_string(),
581 }
582}
583
584fn render_json_schema_array(map: &serde_json::Map<String, JsonValue>) -> String {
585 if let Some(items) = map.get("items") {
586 let item_type = render_json_schema_to_typescript_inner(items);
587 return format!("Array<{item_type}>");
588 }
589
590 if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) {
591 let item_types = items
592 .iter()
593 .map(render_json_schema_to_typescript_inner)
594 .collect::<Vec<_>>();
595 if !item_types.is_empty() {
596 return format!("[{}]", item_types.join(", "));
597 }
598 }
599
600 "unknown[]".to_string()
601}
602
603fn append_additional_properties_line(
604 lines: &mut Vec<String>,
605 map: &serde_json::Map<String, JsonValue>,
606 properties: &serde_json::Map<String, JsonValue>,
607 line_prefix: &str,
608) {
609 if let Some(additional_properties) = map.get("additionalProperties") {
610 let property_type = match additional_properties {
611 JsonValue::Bool(true) => Some("unknown".to_string()),
612 JsonValue::Bool(false) => None,
613 value => Some(render_json_schema_to_typescript_inner(value)),
614 };
615
616 if let Some(property_type) = property_type {
617 lines.push(format!("{line_prefix}[key: string]: {property_type};"));
618 }
619 } else if properties.is_empty() {
620 lines.push(format!("{line_prefix}[key: string]: unknown;"));
621 }
622}
623
624fn has_property_description(value: &JsonValue) -> bool {
625 value
626 .get("description")
627 .and_then(JsonValue::as_str)
628 .is_some_and(|description| !description.is_empty())
629}
630
631fn render_json_schema_object_property(name: &str, value: &JsonValue, required: &[&str]) -> String {
632 let optional = if required.iter().any(|required_name| required_name == &name) {
633 ""
634 } else {
635 "?"
636 };
637 let property_name = render_json_schema_property_name(name);
638 let property_type = render_json_schema_to_typescript_inner(value);
639 format!("{property_name}{optional}: {property_type};")
640}
641
642fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String {
643 let required = map
644 .get("required")
645 .and_then(JsonValue::as_array)
646 .map(|items| {
647 items
648 .iter()
649 .filter_map(JsonValue::as_str)
650 .collect::<Vec<_>>()
651 })
652 .unwrap_or_default();
653 let properties = map
654 .get("properties")
655 .and_then(JsonValue::as_object)
656 .cloned()
657 .unwrap_or_default();
658
659 let mut sorted_properties = properties.iter().collect::<Vec<_>>();
660 sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a);
661 if sorted_properties
662 .iter()
663 .any(|(_, value)| has_property_description(value))
664 {
665 let mut lines = vec!["{".to_string()];
666 for (name, value) in sorted_properties {
667 if let Some(description) = value.get("description").and_then(JsonValue::as_str) {
668 for description_line in description
669 .lines()
670 .map(str::trim)
671 .filter(|line| !line.is_empty())
672 {
673 lines.push(format!(" // {description_line}"));
674 }
675 }
676
677 lines.push(format!(
678 " {}",
679 render_json_schema_object_property(name, value, &required)
680 ));
681 }
682
683 append_additional_properties_line(&mut lines, map, &properties, " ");
684 lines.push("}".to_string());
685 return lines.join("\n");
686 }
687
688 let mut lines = sorted_properties
689 .into_iter()
690 .map(|(name, value)| render_json_schema_object_property(name, value, &required))
691 .collect::<Vec<_>>();
692
693 append_additional_properties_line(&mut lines, map, &properties, "");
694
695 if lines.is_empty() {
696 return "{}".to_string();
697 }
698
699 format!("{{ {} }}", lines.join(" "))
700}
701
702fn render_json_schema_property_name(name: &str) -> String {
703 if normalize_code_mode_identifier(name) == name {
704 name.to_string()
705 } else {
706 serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\"")))
707 }
708}
709
710fn render_json_schema_literal(value: &JsonValue) -> String {
711 serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
712}
713
714#[cfg(test)]
715mod tests {
716 use super::CodeModeToolKind;
717 use super::ParsedExecSource;
718 use super::ToolDefinition;
719 use super::ToolNamespaceDescription;
720 use super::augment_tool_definition;
721 use super::build_exec_tool_description;
722 use super::normalize_code_mode_identifier;
723 use super::parse_exec_source;
724 use codex_protocol::ToolName;
725 use pretty_assertions::assert_eq;
726 use serde_json::Value as JsonValue;
727 use serde_json::json;
728 use std::collections::BTreeMap;
729
730 fn mcp_call_tool_result_schema(structured_content_schema: JsonValue) -> JsonValue {
731 json!({
732 "type": "object",
733 "properties": {
734 "content": {
735 "type": "array",
736 "items": {
737 "type": "object"
738 }
739 },
740 "structuredContent": structured_content_schema,
741 "isError": { "type": "boolean" },
742 "_meta": { "type": "object" }
743 },
744 "required": ["content"],
745 "additionalProperties": false
746 })
747 }
748
749 #[test]
750 fn parse_exec_source_without_pragma() {
751 assert_eq!(
752 parse_exec_source("text('hi')").unwrap(),
753 ParsedExecSource {
754 code: "text('hi')".to_string(),
755 yield_time_ms: None,
756 max_output_tokens: None,
757 }
758 );
759 }
760
761 #[test]
762 fn parse_exec_source_with_pragma() {
763 assert_eq!(
764 parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(),
765 ParsedExecSource {
766 code: "text('hi')".to_string(),
767 yield_time_ms: Some(10),
768 max_output_tokens: None,
769 }
770 );
771 }
772
773 #[test]
774 fn normalize_identifier_rewrites_invalid_characters() {
775 assert_eq!(
776 "mcp__ologs__get_profile",
777 normalize_code_mode_identifier("mcp__ologs__get_profile")
778 );
779 assert_eq!(
780 "hidden_dynamic_tool",
781 normalize_code_mode_identifier("hidden-dynamic-tool")
782 );
783 }
784
785 #[test]
786 fn augment_tool_definition_appends_typed_declaration() {
787 let definition = ToolDefinition {
788 name: "hidden_dynamic_tool".to_string(),
789 tool_name: ToolName::plain("hidden_dynamic_tool"),
790 description: "Test tool".to_string(),
791 kind: CodeModeToolKind::Function,
792 input_schema: Some(json!({
793 "type": "object",
794 "properties": { "city": { "type": "string" } },
795 "required": ["city"],
796 "additionalProperties": false
797 })),
798 output_schema: Some(json!({
799 "type": "object",
800 "properties": { "ok": { "type": "boolean" } },
801 "required": ["ok"]
802 })),
803 };
804
805 let description = augment_tool_definition(definition).description;
806 assert!(description.contains("declare const tools"));
807 assert!(
808 description.contains(
809 "hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;"
810 )
811 );
812 }
813
814 #[test]
815 fn augment_tool_definition_includes_property_descriptions_as_comments() {
816 let definition = ToolDefinition {
817 name: "weather_tool".to_string(),
818 tool_name: ToolName::plain("weather_tool"),
819 description: "Weather tool".to_string(),
820 kind: CodeModeToolKind::Function,
821 input_schema: Some(json!({
822 "type": "object",
823 "properties": {
824 "weather": {
825 "type": "array",
826 "description": "look up weather for a given list of locations",
827 "items": {
828 "type": "object",
829 "properties": {
830 "location": { "type": "string" }
831 },
832 "required": ["location"]
833 }
834 }
835 },
836 "required": ["weather"]
837 })),
838 output_schema: Some(json!({
839 "type": "object",
840 "properties": {
841 "forecast": {
842 "type": "string",
843 "description": "human readable weather forecast"
844 }
845 },
846 "required": ["forecast"]
847 })),
848 };
849
850 let description = augment_tool_definition(definition).description;
851 assert!(description.contains(
852 r#"weather_tool(args: {
853 // look up weather for a given list of locations
854 weather: Array<{ location: string; }>;
855}): Promise<{
856 // human readable weather forecast
857 forecast: string;
858}>;"#
859 ));
860 }
861
862 #[test]
863 fn code_mode_only_description_includes_nested_tools() {
864 let description = build_exec_tool_description(
865 &[ToolDefinition {
866 name: "foo".to_string(),
867 tool_name: ToolName::plain("foo"),
868 description: "bar".to_string(),
869 kind: CodeModeToolKind::Function,
870 input_schema: None,
871 output_schema: None,
872 }],
873 &[],
874 &BTreeMap::new(),
875 crate::DEFAULT_EXEC_YIELD_TIME_MS,
876 true,
877 );
878 assert!(description.contains(
879 "### `foo`
880bar"
881 ));
882 assert!(!description.contains("do not attempt to use any other tools directly"));
883 }
884
885 #[test]
886 fn exec_description_mentions_timeout_helpers() {
887 let description = build_exec_tool_description(
888 &[],
889 &[],
890 &BTreeMap::new(),
891 crate::DEFAULT_EXEC_YIELD_TIME_MS,
892 false,
893 );
894 assert!(description.contains("`audio(audioUrlOrItem:"));
895 assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`"));
896 assert!(description.contains("`clearTimeout(timeoutId?: number)`"));
897 }
898
899 #[test]
900 fn code_mode_only_description_groups_namespace_instructions_once() {
901 let namespace_descriptions = BTreeMap::from([(
902 "mcp__sample__".to_string(),
903 ToolNamespaceDescription {
904 name: "mcp__sample".to_string(),
905 description: "Shared namespace guidance.".to_string(),
906 },
907 )]);
908 let description = build_exec_tool_description(
909 &[
910 ToolDefinition {
911 name: "mcp__sample__alpha".to_string(),
912 tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
913 description: "First tool".to_string(),
914 kind: CodeModeToolKind::Function,
915 input_schema: Some(json!({
916 "type": "object",
917 "properties": {},
918 "additionalProperties": false
919 })),
920 output_schema: Some(mcp_call_tool_result_schema(json!({
921 "type": "object",
922 "properties": {},
923 "additionalProperties": false
924 }))),
925 },
926 ToolDefinition {
927 name: "mcp__sample__beta".to_string(),
928 tool_name: ToolName::namespaced("mcp__sample__", "beta"),
929 description: "Second tool".to_string(),
930 kind: CodeModeToolKind::Function,
931 input_schema: Some(json!({
932 "type": "object",
933 "properties": {},
934 "additionalProperties": false
935 })),
936 output_schema: Some(mcp_call_tool_result_schema(json!({
937 "type": "object",
938 "properties": {},
939 "additionalProperties": false
940 }))),
941 },
942 ],
943 &[],
944 &namespace_descriptions,
945 crate::DEFAULT_EXEC_YIELD_TIME_MS,
946 true,
947 );
948 assert_eq!(description.matches("## mcp__sample").count(), 1);
949 assert!(description.contains("## mcp__sample\nShared namespace guidance."));
950 assert!(description.contains(
951 "declare const tools: { mcp__sample__alpha(args: {}): Promise<CallToolResult<{}>>; };"
952 ));
953 assert!(description.contains(
954 "declare const tools: { mcp__sample__beta(args: {}): Promise<CallToolResult<{}>>; };"
955 ));
956 }
957
958 #[test]
959 fn code_mode_only_description_omits_empty_namespace_sections() {
960 let namespace_descriptions = BTreeMap::from([(
961 "mcp__sample__".to_string(),
962 ToolNamespaceDescription {
963 name: "mcp__sample".to_string(),
964 description: String::new(),
965 },
966 )]);
967 let description = build_exec_tool_description(
968 &[ToolDefinition {
969 name: "mcp__sample__alpha".to_string(),
970 tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
971 description: "First tool".to_string(),
972 kind: CodeModeToolKind::Function,
973 input_schema: Some(json!({
974 "type": "object",
975 "properties": {},
976 "additionalProperties": false
977 })),
978 output_schema: Some(mcp_call_tool_result_schema(json!({
979 "type": "object",
980 "properties": {},
981 "additionalProperties": false
982 }))),
983 }],
984 &[],
985 &namespace_descriptions,
986 crate::DEFAULT_EXEC_YIELD_TIME_MS,
987 true,
988 );
989
990 assert!(!description.contains("## mcp__sample"));
991 assert!(description.contains("### `mcp__sample__alpha`"));
992 }
993
994 #[test]
995 fn code_mode_only_description_renders_shared_mcp_types_once() {
996 let first_tool = augment_tool_definition(ToolDefinition {
997 name: "mcp__sample__alpha".to_string(),
998 tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
999 description: "First tool".to_string(),
1000 kind: CodeModeToolKind::Function,
1001 input_schema: Some(json!({
1002 "type": "object",
1003 "properties": {},
1004 "additionalProperties": false
1005 })),
1006 output_schema: Some(json!({
1007 "type": "object",
1008 "properties": {
1009 "content": {
1010 "type": "array",
1011 "items": {
1012 "type": "object"
1013 }
1014 },
1015 "structuredContent": {
1016 "type": "object",
1017 "properties": {
1018 "echo": { "type": "string" }
1019 },
1020 "required": ["echo"],
1021 "additionalProperties": false
1022 },
1023 "isError": { "type": "boolean" },
1024 "_meta": { "type": "object" }
1025 },
1026 "required": ["content"],
1027 "additionalProperties": false
1028 })),
1029 });
1030 let second_tool = augment_tool_definition(ToolDefinition {
1031 name: "mcp__sample__beta".to_string(),
1032 tool_name: ToolName::namespaced("mcp__sample__", "beta"),
1033 description: "Second tool".to_string(),
1034 kind: CodeModeToolKind::Function,
1035 input_schema: Some(json!({
1036 "type": "object",
1037 "properties": {},
1038 "additionalProperties": false
1039 })),
1040 output_schema: Some(json!({
1041 "type": "object",
1042 "properties": {
1043 "content": {
1044 "type": "array",
1045 "items": {
1046 "type": "object"
1047 }
1048 },
1049 "structuredContent": {
1050 "type": "object",
1051 "properties": {
1052 "count": { "type": "integer" }
1053 },
1054 "required": ["count"],
1055 "additionalProperties": false
1056 },
1057 "isError": { "type": "boolean" },
1058 "_meta": { "type": "object" }
1059 },
1060 "required": ["content"],
1061 "additionalProperties": false
1062 })),
1063 });
1064
1065 let description = build_exec_tool_description(
1066 &[
1067 ToolDefinition {
1068 name: first_tool.name,
1069 tool_name: first_tool.tool_name,
1070 description: "First tool".to_string(),
1071 kind: first_tool.kind,
1072 input_schema: first_tool.input_schema,
1073 output_schema: first_tool.output_schema,
1074 },
1075 ToolDefinition {
1076 name: second_tool.name,
1077 tool_name: second_tool.tool_name,
1078 description: "Second tool".to_string(),
1079 kind: second_tool.kind,
1080 input_schema: second_tool.input_schema,
1081 output_schema: second_tool.output_schema,
1082 },
1083 ],
1084 &[],
1085 &BTreeMap::new(),
1086 crate::DEFAULT_EXEC_YIELD_TIME_MS,
1087 true,
1088 );
1089
1090 assert_eq!(
1091 description
1092 .matches("type CallToolResult<TStructured = { [key: string]: unknown }>")
1093 .count(),
1094 1
1095 );
1096 assert_eq!(description.matches("Shared MCP Types:").count(), 1);
1097 }
1098
1099 #[test]
1100 fn code_mode_only_description_renders_shared_mcp_types_for_deferred_tools() {
1101 let deferred_tool = ToolDefinition {
1102 name: "mcp__sample__alpha".to_string(),
1103 tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
1104 description: "Deferred tool".to_string(),
1105 kind: CodeModeToolKind::Function,
1106 input_schema: Some(json!({
1107 "type": "object",
1108 "properties": {},
1109 "additionalProperties": false
1110 })),
1111 output_schema: Some(mcp_call_tool_result_schema(json!({
1112 "type": "object",
1113 "properties": {},
1114 "additionalProperties": false
1115 }))),
1116 };
1117
1118 let description = build_exec_tool_description(
1119 &[],
1120 &[deferred_tool],
1121 &BTreeMap::new(),
1122 crate::DEFAULT_EXEC_YIELD_TIME_MS,
1123 true,
1124 );
1125
1126 assert!(description.contains("Some deferred nested tools may be omitted"));
1127 assert!(description.contains("Shared MCP Types:"));
1128 assert!(!description.contains("### `mcp__sample__alpha`"));
1129 }
1130
1131 #[test]
1132 fn exec_description_mentions_deferred_nested_tools_when_available() {
1133 let description = build_exec_tool_description(
1134 &[],
1135 &[ToolDefinition {
1136 name: "deferred_tool".to_string(),
1137 tool_name: ToolName::plain("deferred_tool"),
1138 description: "Deferred tool".to_string(),
1139 kind: CodeModeToolKind::Function,
1140 input_schema: None,
1141 output_schema: None,
1142 }],
1143 &BTreeMap::new(),
1144 crate::DEFAULT_EXEC_YIELD_TIME_MS,
1145 false,
1146 );
1147
1148 assert!(description.contains("Some deferred nested tools may be omitted"));
1149 assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`"));
1150 assert!(!description.contains("do not print the full `ALL_TOOLS` array"));
1151 }
1152}