Skip to main content

everruns_core/capabilities/
lua_code_mode.rs

1//! Lua Code-Mode Routing Capability (experimental)
2//!
3//! Turns the sandboxed `lua` tool into the agent's *primary* action surface by
4//! hiding "non-essential" tools from the model's direct tool list. Those tools
5//! are not removed from the running session — they stay in the executable
6//! registry — they are just no longer offered to the model as direct tool
7//! calls. The agent reaches them inside a Lua script through the existing
8//! code-mode `tools.<name>(args)` table, so one script can read inputs, call
9//! several sibling tools, and shape the result in a single turn instead of a
10//! chain of individual round-trips.
11//!
12//! ## How the non-functional requirements are met
13//!
14//! 1. **Capability-extensibility only (tool filtering).** The sole mechanism is
15//!    the existing [`ToolDefinitionHook`] seam (see `specs/capabilities.md` →
16//!    Tool definition hooks). No new agent-loop plumbing: the hook runs after
17//!    the runtime agent has merged its final tool list and simply drops the
18//!    eligible definitions before they reach the model. The *executable*
19//!    `ToolRegistry` is built separately from capability `tools()` and is left
20//!    untouched, which is what keeps the hidden tools callable from Lua.
21//! 2. **Relies on the existing `lua` capability.** `lua` is declared as a hard
22//!    [`dependencies`](Capability::dependencies) entry, and its code mode
23//!    (`tools.<name>`) is what makes the hidden tools invokable. The filter
24//!    mirrors [`is_code_mode_eligible`] exactly, so the set removed from the
25//!    model is precisely the set Lua re-exposes — no tool can become
26//!    unreachable.
27//! 3. **Capabilities export their functions to Lua.** Every eligible capability
28//!    tool is surfaced inside the VM as a `tools.<name>(args)` function by the
29//!    `lua` engine. This capability is what makes that export the agent's
30//!    default path rather than an occasional optimization.
31
32use 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
42/// Behavior contract only (no tool inventory — that lives in the tool schemas
43/// and the `lua` capability's own prompt). Kept to two dense sentences per the
44/// prompt-budget guidance in `specs/capabilities.md`.
45const 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
50/// Capability that routes non-essential tool calls through the Lua sandbox.
51pub 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        // Inseparable from `lua` (scripted code execution) and reshapes how the
80        // agent acts. Admin-gated like its dependency.
81        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        // Enforce the same closed contract as `config_schema`
148        // (`additionalProperties: false`) on every write path.
149        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/// Per-agent options parsed from capability config.
216#[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/// Drops code-mode-eligible tool definitions from the model-facing tool list
241/// and grafts a catalog of them onto the `lua` tool description.
242///
243/// Tools listed in `keep_visible` are always retained. Everything that is *not*
244/// eligible (the `lua`/`bash` execution tools, destructive, approval-gated,
245/// client-side, and `cpu_bound` tools) is also retained — those stay direct
246/// tool calls.
247///
248/// Because the hidden tools' standalone schemas are removed, the model would
249/// otherwise have no way to discover their names/arguments. So the eligible set
250/// is summarized into a `tools.<name>{ args }` catalog appended to the `lua`
251/// tool's own description — the discovery surface the model reads right before
252/// it decides to call `lua`. Each entry shows a typed signature; with
253/// `full_schemas` it also embeds the tool's complete JSON Schema (lossless).
254#[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        // This is a hard routing policy (the model must go through Lua), not a
284        // schema-deferral optimization, so it applies regardless of whether the
285        // model uses hosted tool_search.
286        true
287    }
288}
289
290/// One catalog entry: `- name(a: number, b?: string) — first sentence`, with an
291/// optional indented `schema: { ... }` line when `full_schemas` is set.
292fn 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
309/// Typed argument signature from a JSON Schema object: `name: type` for required
310/// args first, then `name?: type` for optional ones. The synthetic
311/// `human_intent` narration argument (added by `human_intent`) is omitted.
312fn 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
337/// Render a JSON Schema property's `type` as a short string (e.g. `number`,
338/// `string|null`, `enum`, `object`). Falls back to `any` when unspecified.
339fn 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
352/// Minified JSON Schema for full discovery, with the synthetic `human_intent`
353/// argument stripped (it is never passed to `tools.*`).
354fn 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
367/// First sentence (up to `.` or newline), trimmed and length-capped.
368fn 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
389/// Append the catalog to the `lua` tool's description so the model can discover
390/// the in-script `tools.*` functions and their arguments.
391fn 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    // Metadata constants covered by builtin_capabilities_satisfy_registry_invariants.
428
429    #[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        // `add` is the only eligible tool → hidden. Everything else stays.
464        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        // `add` is gone from the model's tool list...
497        assert_eq!(names(&kept), vec!["lua"]);
498        // ...but discoverable from the lua tool description with typed arguments.
499        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        // Default mode does not embed the full JSON schema.
510        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        // The synthetic human_intent arg is stripped from both signature & schema.
546        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        // Closed contract: unknown keys are rejected (matches config_schema's
665        // additionalProperties: false).
666        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    // Prompt-budget ratchet (specs/capabilities.md → prompt size). Lowering is
685    // always fine; raising requires an explicit bump + justification.
686    #[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}