1use std::sync::Arc;
33
34use serde_json::{Value, json};
35
36use super::lua::is_code_mode_eligible;
37use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel, ToolDefinitionHook};
38use crate::tool_types::ToolDefinition;
39
40pub const LUA_CODE_MODE_CAPABILITY_ID: &str = "lua_code_mode";
41
42const SYSTEM_PROMPT: &str = "Most action tools are intentionally omitted from your direct tool list; \
46to use them, call them from inside the `lua` tool via the `tools.<name>(args)` table, where a single \
47script can chain several calls and process their results in one turn. Prefer one Lua script over a \
48sequence of separate tool calls.";
49
50pub struct LuaCodeModeCapability;
52
53impl Capability for LuaCodeModeCapability {
54 fn id(&self) -> &str {
55 LUA_CODE_MODE_CAPABILITY_ID
56 }
57
58 fn name(&self) -> &str {
59 "Lua Code Mode"
60 }
61
62 fn description(&self) -> &str {
63 r#"Route non-essential tool calls through the Lua sandbox.
64
65Hides auto, non-destructive tools from the model's direct tool list so the agent
66orchestrates them inside the `lua` tool via `tools.<name>(args)` — fewer
67round-trips, structured results.
68
69> [!NOTE]
70> Requires the `lua` capability. Execution, approval-gated, destructive, and
71> client-side tools stay directly callable."#
72 }
73
74 fn status(&self) -> CapabilityStatus {
75 CapabilityStatus::Available
76 }
77
78 fn risk_level(&self) -> RiskLevel {
79 RiskLevel::High
82 }
83
84 fn icon(&self) -> Option<&str> {
85 Some("code")
86 }
87
88 fn category(&self) -> Option<&str> {
89 Some("Execution")
90 }
91
92 fn system_prompt_addition(&self) -> Option<&str> {
93 Some(SYSTEM_PROMPT)
94 }
95
96 fn dependencies(&self) -> Vec<&'static str> {
97 vec!["lua"]
98 }
99
100 fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
101 vec![Arc::new(HideCodeModeToolsHook::default())]
102 }
103
104 fn tool_definition_hooks_with_config(
105 &self,
106 config: &Value,
107 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
108 let opts = parse_options(config);
109 vec![Arc::new(HideCodeModeToolsHook {
110 keep_visible: opts.keep_visible,
111 full_schemas: opts.full_schemas,
112 })]
113 }
114
115 fn config_schema(&self) -> Option<Value> {
116 Some(json!({
117 "type": "object",
118 "properties": {
119 "keep_visible": {
120 "type": "array",
121 "title": "Keep visible",
122 "items": {
123 "type": "string",
124 "title": "Tool name",
125 "description": "Name of a tool to keep directly callable."
126 },
127 "description": "Tool names to keep directly callable by the model even when they would otherwise be routed through Lua code mode."
128 },
129 "full_schemas": {
130 "type": "boolean",
131 "title": "Embed full schemas",
132 "default": false,
133 "description": "Embed each hidden tool's full JSON Schema in the lua tool description (lossless discovery). Off by default: a compact typed signature is shown instead."
134 }
135 },
136 "additionalProperties": false
137 }))
138 }
139
140 fn validate_config(&self, config: &Value) -> Result<(), String> {
141 if config.is_null() {
142 return Ok(());
143 }
144 let obj = config
145 .as_object()
146 .ok_or_else(|| "config must be a JSON object".to_string())?;
147 for key in obj.keys() {
150 if key != "keep_visible" && key != "full_schemas" {
151 return Err(format!("unknown config key: {key}"));
152 }
153 }
154 if let Some(keep) = obj.get("keep_visible") {
155 let arr = keep
156 .as_array()
157 .ok_or_else(|| "keep_visible must be an array of tool names".to_string())?;
158 if arr.iter().any(|v| !v.is_string()) {
159 return Err("keep_visible entries must be strings".to_string());
160 }
161 }
162 if let Some(full) = obj.get("full_schemas")
163 && !full.is_boolean()
164 {
165 return Err("full_schemas must be a boolean".to_string());
166 }
167 Ok(())
168 }
169
170 fn localizations(&self) -> Vec<CapabilityLocalization> {
171 vec![
172 CapabilityLocalization {
173 locale: "en",
174 name: None,
175 description: None,
176 config_description: Some(
177 "Controls which tools stay directly callable and whether hidden \
178 tools' full JSON Schemas are embedded in the lua tool description.",
179 ),
180 config_overlay: None,
181 },
182 CapabilityLocalization {
183 locale: "uk",
184 name: Some("Режим коду Lua"),
185 description: Some(
186 "Спрямовує виклики неосновних інструментів через пісочницю Lua: \
187 приховує їх із прямого списку інструментів моделі, тож агент \
188 викликає їх усередині інструмента lua через tools.<name>(args) — \
189 менше окремих звернень, структуровані результати.",
190 ),
191 config_description: Some(
192 "Визначає, які інструменти залишаються доступними для прямого виклику, та чи вбудовувати повні JSON-схеми прихованих інструментів в опис інструмента lua.",
193 ),
194 config_overlay: Some(json!({
195 "properties": {
196 "keep_visible": {
197 "title": "Залишити видимими",
198 "description": "Назви інструментів, які модель може викликати напряму, навіть якщо їх інакше було б спрямовано через режим коду Lua.",
199 "items": {
200 "title": "Назва інструмента",
201 "description": "Назва інструмента, що залишається доступним для прямого виклику."
202 }
203 },
204 "full_schemas": {
205 "title": "Вбудовувати повні схеми",
206 "description": "Вбудовує повну JSON-схему кожного прихованого інструмента в опис інструмента lua (виявлення без втрат). Типово вимкнено: натомість показується компактний типізований підпис."
207 }
208 }
209 })),
210 },
211 ]
212 }
213}
214
215#[derive(Default)]
217struct CodeModeOptions {
218 keep_visible: Vec<String>,
219 full_schemas: bool,
220}
221
222fn parse_options(config: &Value) -> CodeModeOptions {
223 CodeModeOptions {
224 keep_visible: config
225 .get("keep_visible")
226 .and_then(|v| v.as_array())
227 .map(|arr| {
228 arr.iter()
229 .filter_map(|v| v.as_str().map(str::to_string))
230 .collect()
231 })
232 .unwrap_or_default(),
233 full_schemas: config
234 .get("full_schemas")
235 .and_then(|v| v.as_bool())
236 .unwrap_or(false),
237 }
238}
239
240#[derive(Default)]
255struct HideCodeModeToolsHook {
256 keep_visible: Vec<String>,
257 full_schemas: bool,
258}
259
260impl ToolDefinitionHook for HideCodeModeToolsHook {
261 fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
262 let mut kept: Vec<ToolDefinition> = Vec::new();
263 let mut catalog: Vec<String> = Vec::new();
264 for def in tools {
265 let name = def.name();
266 let hidden = !self.keep_visible.iter().any(|k| k == name)
267 && is_code_mode_eligible(name, def.policy(), def.hints());
268 if hidden {
269 catalog.push(format_catalog_entry(&def, self.full_schemas));
270 } else {
271 kept.push(def);
272 }
273 }
274 if !catalog.is_empty()
275 && let Some(lua) = kept.iter_mut().find(|d| d.name() == "lua")
276 {
277 graft_catalog_onto_lua(lua, &catalog);
278 }
279 kept
280 }
281
282 fn applies_with_native_tool_search(&self) -> bool {
283 true
287 }
288}
289
290fn format_catalog_entry(def: &ToolDefinition, full_schemas: bool) -> String {
293 let sig = typed_signature(def.full_parameters());
294 let desc = first_sentence(def.description());
295 let mut entry = if desc.is_empty() {
296 format!("- {}({sig})", def.name())
297 } else {
298 format!("- {}({sig}) — {desc}", def.name())
299 };
300 if full_schemas {
301 entry.push_str(&format!(
302 "\n schema: {}",
303 compact_schema(def.full_parameters())
304 ));
305 }
306 entry
307}
308
309fn typed_signature(schema: &Value) -> String {
313 let Some(props) = schema.get("properties").and_then(|v| v.as_object()) else {
314 return String::new();
315 };
316 let required: Vec<&str> = schema
317 .get("required")
318 .and_then(|v| v.as_array())
319 .map(|a| a.iter().filter_map(|x| x.as_str()).collect())
320 .unwrap_or_default();
321 let mut parts: Vec<String> = Vec::new();
322 for r in &required {
323 if *r != crate::tool_types::HUMAN_INTENT_ARGUMENT
324 && let Some(prop) = props.get(*r)
325 {
326 parts.push(format!("{r}: {}", json_type(prop)));
327 }
328 }
329 for (key, prop) in props {
330 if key != crate::tool_types::HUMAN_INTENT_ARGUMENT && !required.contains(&key.as_str()) {
331 parts.push(format!("{key}?: {}", json_type(prop)));
332 }
333 }
334 parts.join(", ")
335}
336
337fn json_type(prop: &Value) -> String {
340 match prop.get("type") {
341 Some(Value::String(s)) => s.clone(),
342 Some(Value::Array(types)) => types
343 .iter()
344 .filter_map(|v| v.as_str())
345 .collect::<Vec<_>>()
346 .join("|"),
347 _ if prop.get("enum").is_some() => "enum".to_string(),
348 _ => "any".to_string(),
349 }
350}
351
352fn compact_schema(schema: &Value) -> String {
355 let mut schema = schema.clone();
356 if let Some(obj) = schema.as_object_mut() {
357 if let Some(props) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
358 props.remove(crate::tool_types::HUMAN_INTENT_ARGUMENT);
359 }
360 if let Some(req) = obj.get_mut("required").and_then(|v| v.as_array_mut()) {
361 req.retain(|v| v.as_str() != Some(crate::tool_types::HUMAN_INTENT_ARGUMENT));
362 }
363 }
364 serde_json::to_string(&schema).unwrap_or_else(|_| "{}".to_string())
365}
366
367fn first_sentence(description: &str) -> String {
369 let trimmed = description.trim();
370 let end = trimmed
371 .find(['.', '\n'])
372 .map(|i| i + 1)
373 .unwrap_or(trimmed.len());
374 let mut s = trimmed[..end].trim().to_string();
375 const MAX: usize = 140;
376 if s.len() > MAX {
377 let boundary = s
378 .char_indices()
379 .map(|(idx, _)| idx)
380 .take_while(|idx| *idx <= MAX)
381 .last()
382 .unwrap_or(0);
383 s.truncate(boundary);
384 s.push('…');
385 }
386 s
387}
388
389fn graft_catalog_onto_lua(def: &mut ToolDefinition, catalog: &[String]) {
392 let block = format!(
393 "\n\nThese tools are available ONLY inside this script via the `tools` table — \
394call them as `tools.<name>{{ args }}` (e.g. `local r = tools.add{{ a = 1, b = 2 }}`). \
395Argument types follow each name:\n{}",
396 catalog.join("\n")
397 );
398 match def {
399 ToolDefinition::Builtin(b) => b.description.push_str(&block),
400 ToolDefinition::ClientSide(c) => c.description.push_str(&block),
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::tool_types::{BuiltinTool, ClientSideTool, DeferrablePolicy, ToolHints, ToolPolicy};
408
409 fn builtin(name: &str, policy: ToolPolicy, hints: ToolHints) -> ToolDefinition {
410 ToolDefinition::Builtin(BuiltinTool {
411 name: name.to_string(),
412 display_name: None,
413 description: format!("{name} tool"),
414 parameters: json!({ "type": "object" }),
415 policy,
416 category: None,
417 deferrable: DeferrablePolicy::default(),
418 hints,
419 full_parameters: None,
420 })
421 }
422
423 fn names(defs: &[ToolDefinition]) -> Vec<&str> {
424 defs.iter().map(|d| d.name()).collect()
425 }
426
427 #[test]
430 fn hides_eligible_tools_keeps_lua_and_essential() {
431 let hook = HideCodeModeToolsHook {
432 keep_visible: Vec::new(),
433 full_schemas: false,
434 };
435 let defs = vec![
436 builtin("lua", ToolPolicy::Auto, ToolHints::default()),
437 builtin("bash", ToolPolicy::Auto, ToolHints::default()),
438 builtin("add", ToolPolicy::Auto, ToolHints::default()),
439 builtin(
440 "delete_file",
441 ToolPolicy::Auto,
442 ToolHints::default().with_destructive(true),
443 ),
444 builtin(
445 "transfer_funds",
446 ToolPolicy::RequiresApproval,
447 ToolHints::default(),
448 ),
449 ToolDefinition::ClientSide(ClientSideTool {
450 name: "pick_file".to_string(),
451 display_name: None,
452 description: "client".to_string(),
453 parameters: json!({ "type": "object" }),
454 category: None,
455 deferrable: DeferrablePolicy::default(),
456 hints: ToolHints::default(),
457 full_parameters: None,
458 }),
459 ];
460
461 let transformed = hook.transform(defs);
462 let kept = names(&transformed);
463 assert!(kept.contains(&"lua"));
465 assert!(kept.contains(&"bash"));
466 assert!(kept.contains(&"delete_file"));
467 assert!(kept.contains(&"transfer_funds"));
468 assert!(kept.contains(&"pick_file"));
469 assert!(!kept.contains(&"add"), "eligible tool must be hidden");
470 }
471
472 #[test]
473 fn grafts_catalog_onto_lua_description() {
474 let hook = HideCodeModeToolsHook {
475 keep_visible: Vec::new(),
476 full_schemas: false,
477 };
478 let add = ToolDefinition::Builtin(BuiltinTool {
479 name: "add".to_string(),
480 display_name: None,
481 description: "Add two numbers together and return the result.".to_string(),
482 parameters: json!({
483 "type": "object",
484 "properties": { "a": { "type": "number" }, "b": { "type": "number" } },
485 "required": ["a", "b"],
486 }),
487 policy: ToolPolicy::Auto,
488 category: None,
489 deferrable: DeferrablePolicy::default(),
490 hints: ToolHints::default(),
491 full_parameters: None,
492 });
493 let defs = vec![builtin("lua", ToolPolicy::Auto, ToolHints::default()), add];
494
495 let kept = hook.transform(defs);
496 assert_eq!(names(&kept), vec!["lua"]);
498 let lua_desc = kept[0].description();
500 assert!(lua_desc.contains("tools.<name>"), "{lua_desc}");
501 assert!(
502 lua_desc.contains("- add(a: number, b: number)"),
503 "{lua_desc}"
504 );
505 assert!(
506 lua_desc.contains("Add two numbers together"),
507 "catalog should carry the description: {lua_desc}",
508 );
509 assert!(!lua_desc.contains("schema:"), "{lua_desc}");
511 }
512
513 #[test]
514 fn full_schemas_embeds_complete_schema() {
515 let hook = HideCodeModeToolsHook {
516 keep_visible: Vec::new(),
517 full_schemas: true,
518 };
519 let add = ToolDefinition::Builtin(BuiltinTool {
520 name: "add".to_string(),
521 display_name: None,
522 description: "Add.".to_string(),
523 parameters: json!({
524 "type": "object",
525 "properties": {
526 "a": { "type": "number" },
527 "b": { "type": "number" },
528 "human_intent": { "type": "string" }
529 },
530 "required": ["a", "b"],
531 }),
532 policy: ToolPolicy::Auto,
533 category: None,
534 deferrable: DeferrablePolicy::default(),
535 hints: ToolHints::default(),
536 full_parameters: None,
537 });
538 let kept = hook.transform(vec![
539 builtin("lua", ToolPolicy::Auto, ToolHints::default()),
540 add,
541 ]);
542 let lua_desc = kept[0].description();
543 assert!(lua_desc.contains("schema:"), "{lua_desc}");
544 assert!(lua_desc.contains("\"properties\""), "{lua_desc}");
545 assert!(!lua_desc.contains("human_intent"), "{lua_desc}");
547 }
548
549 #[test]
550 fn first_sentence_truncates_multibyte_descriptions_on_char_boundary() {
551 let desc = format!("{}é", "a".repeat(139));
552
553 let truncated = first_sentence(&desc);
554
555 assert_eq!(truncated, format!("{}…", "a".repeat(139)));
556 }
557
558 #[test]
559 fn catalog_handles_mcp_like_multibyte_tool_descriptions() {
560 let hook = HideCodeModeToolsHook {
561 keep_visible: Vec::new(),
562 full_schemas: false,
563 };
564 let mcp_like_tool = ToolDefinition::Builtin(BuiltinTool {
565 name: "mcp_server_tool".to_string(),
566 display_name: None,
567 description: format!("{}é", "a".repeat(139)),
568 parameters: json!({ "type": "object" }),
569 policy: ToolPolicy::Auto,
570 category: None,
571 deferrable: DeferrablePolicy::default(),
572 hints: ToolHints::default().with_open_world(true),
573 full_parameters: None,
574 });
575
576 let kept = hook.transform(vec![
577 builtin("lua", ToolPolicy::Auto, ToolHints::default()),
578 mcp_like_tool,
579 ]);
580
581 assert_eq!(names(&kept), vec!["lua"]);
582 let lua_desc = kept[0].description();
583 assert!(lua_desc.contains("- mcp_server_tool()"), "{lua_desc}");
584 assert!(
585 lua_desc.contains(&format!("{}…", "a".repeat(139))),
586 "{lua_desc}"
587 );
588 assert!(!lua_desc.contains('é'), "{lua_desc}");
589 }
590
591 #[test]
592 fn typed_signature_handles_optional_and_union_types() {
593 let sig = typed_signature(&json!({
594 "type": "object",
595 "properties": {
596 "path": { "type": "string" },
597 "limit": { "type": ["integer", "null"] },
598 "mode": { "enum": ["a", "b"] }
599 },
600 "required": ["path"],
601 }));
602 assert!(sig.starts_with("path: string"), "{sig}");
603 assert!(sig.contains("limit?: integer|null"), "{sig}");
604 assert!(sig.contains("mode?: enum"), "{sig}");
605 }
606
607 #[test]
608 fn keep_visible_overrides_hiding() {
609 let hook = HideCodeModeToolsHook {
610 keep_visible: vec!["add".to_string()],
611 full_schemas: false,
612 };
613 let defs = vec![
614 builtin("add", ToolPolicy::Auto, ToolHints::default()),
615 builtin("multiply", ToolPolicy::Auto, ToolHints::default()),
616 ];
617 let transformed = hook.transform(defs);
618 let kept = names(&transformed);
619 assert!(kept.contains(&"add"), "keep_visible must retain the tool");
620 assert!(!kept.contains(&"multiply"));
621 }
622
623 #[test]
624 fn config_aware_hook_reads_keep_visible() {
625 let cap = LuaCodeModeCapability;
626 let hook = cap
627 .tool_definition_hooks_with_config(&json!({ "keep_visible": ["multiply"] }))
628 .pop()
629 .unwrap();
630 let defs = vec![
631 builtin("add", ToolPolicy::Auto, ToolHints::default()),
632 builtin("multiply", ToolPolicy::Auto, ToolHints::default()),
633 ];
634 let transformed = hook.transform(defs);
635 let kept = names(&transformed);
636 assert_eq!(kept, vec!["multiply"]);
637 }
638
639 #[test]
640 fn validate_config_rejects_bad_keep_visible() {
641 let cap = LuaCodeModeCapability;
642 assert!(
643 cap.validate_config(&json!({ "keep_visible": ["ok"] }))
644 .is_ok()
645 );
646 assert!(cap.validate_config(&json!({})).is_ok());
647 assert!(cap.validate_config(&Value::Null).is_ok());
648 assert!(
649 cap.validate_config(&json!({ "keep_visible": "nope" }))
650 .is_err()
651 );
652 assert!(
653 cap.validate_config(&json!({ "keep_visible": [1, 2] }))
654 .is_err()
655 );
656 assert!(
657 cap.validate_config(&json!({ "full_schemas": true }))
658 .is_ok()
659 );
660 assert!(
661 cap.validate_config(&json!({ "full_schemas": "yes" }))
662 .is_err()
663 );
664 assert!(
667 cap.validate_config(&json!({ "nope": 1 })).is_err(),
668 "unknown config keys must be rejected"
669 );
670 }
671
672 #[test]
673 fn uk_localization_resolves() {
674 let cap = LuaCodeModeCapability;
675 assert_eq!(cap.localized_name(Some("uk-UA")), "Режим коду Lua");
676 assert!(
677 cap.localized_description(Some("uk-UA"))
678 .contains("пісочницю Lua")
679 );
680 assert!(cap.describe_schema(Some("uk")).is_some());
681 assert!(cap.describe_schema(None).is_some());
682 }
683
684 #[test]
687 fn system_prompt_within_budget() {
688 let cap = LuaCodeModeCapability;
689 let prompt = cap.system_prompt_addition().unwrap();
690 assert!(
691 prompt.len() <= 500,
692 "lua_code_mode prompt grew to {} bytes (budget 500)",
693 prompt.len()
694 );
695 }
696}