context_harness/
agent_script.rs

1//! Lua scripted agent runtime.
2//!
3//! Loads `.lua` agent scripts at startup, extracts their metadata (name,
4//! description, tools, arguments), and provides runtime resolution with a
5//! context bridge back into the Rust core (search, get, sources).
6//!
7//! # Architecture
8//!
9//! Agent scripts follow the same sandboxed Lua VM model as connectors and
10//! tools, reusing all host APIs from [`crate::lua_runtime`]. In addition,
11//! agents receive a `context` table identical to tools:
12//!
13//! - `context.search(query, opts?)` — search the knowledge base
14//! - `context.get(id)` — retrieve a document by UUID
15//! - `context.sources()` — list connector status
16//!
17//! The agent-specific config from `ctx.toml` is passed as the second
18//! argument to `agent.resolve(args, config, context)`.
19//!
20//! # Script Interface
21//!
22//! Every agent script defines a global `agent` table:
23//!
24//! ```lua
25//! agent = {
26//!     name = "my-agent",
27//!     description = "Helps with tasks",
28//!     tools = { "search", "get" },
29//!     arguments = {
30//!         { name = "topic", description = "Focus area", required = false },
31//!     },
32//! }
33//!
34//! function agent.resolve(args, config, context)
35//!     return {
36//!         system = "You are a helpful assistant.",
37//!         messages = {},
38//!     }
39//! end
40//! ```
41//!
42//! # Configuration
43//!
44//! ```toml
45//! [agents.script.my_agent]
46//! path = "agents/my-agent.lua"
47//! timeout = 30
48//! search_limit = 5
49//! ```
50//!
51//! See `docs/AGENTS.md` for the full specification.
52
53use anyhow::{bail, Context, Result};
54use async_trait::async_trait;
55use mlua::prelude::*;
56use std::path::{Path, PathBuf};
57use std::sync::Arc;
58use std::time::{Duration, Instant};
59
60use crate::agents::{Agent, AgentArgument, AgentPrompt, PromptMessage};
61use crate::config::{Config, ScriptAgentConfig};
62use crate::get::get_document;
63use crate::lua_runtime::{json_value_to_lua, register_all_host_apis, toml_table_to_lua};
64use crate::search::search_documents;
65use crate::sources::get_sources;
66use crate::traits::ToolContext;
67
68// ═══════════════════════════════════════════════════════════════════════
69// Types
70// ═══════════════════════════════════════════════════════════════════════
71
72/// Metadata extracted from a loaded Lua agent script.
73///
74/// Created at startup by [`load_agent_definitions`]. Contains everything
75/// needed to register the agent and resolve its prompt when called.
76#[derive(Debug, Clone)]
77pub struct AgentDefinition {
78    /// Agent identifier (matches the config key in `[agents.script.<name>]`).
79    pub name: String,
80    /// One-line description for agent discovery.
81    pub description: String,
82    /// Tools this agent uses.
83    pub tools: Vec<String>,
84    /// Arguments the agent accepts.
85    pub arguments: Vec<AgentArgument>,
86    /// Path to the `.lua` script file.
87    pub script_path: PathBuf,
88    /// Raw Lua source code (cached to avoid re-reading on every call).
89    pub script_source: String,
90    /// Agent-specific config keys from `ctx.toml`.
91    pub config: toml::Table,
92    /// Maximum execution time in seconds.
93    pub timeout: u64,
94}
95
96// ═══════════════════════════════════════════════════════════════════════
97// Agent trait adapter
98// ═══════════════════════════════════════════════════════════════════════
99
100/// Adapter that wraps a Lua [`AgentDefinition`] as an [`Agent`] trait object.
101///
102/// This allows Lua agents to participate in the unified agent dispatch
103/// alongside TOML and custom Rust agents.
104pub struct LuaAgentAdapter {
105    /// The underlying Lua agent definition.
106    definition: AgentDefinition,
107    /// Application config needed for the context bridge.
108    config: Arc<Config>,
109}
110
111impl LuaAgentAdapter {
112    /// Create a new adapter wrapping a Lua agent definition.
113    pub fn new(definition: AgentDefinition, config: Arc<Config>) -> Self {
114        Self { definition, config }
115    }
116}
117
118#[async_trait]
119impl Agent for LuaAgentAdapter {
120    fn name(&self) -> &str {
121        &self.definition.name
122    }
123
124    fn description(&self) -> &str {
125        &self.definition.description
126    }
127
128    fn tools(&self) -> Vec<String> {
129        self.definition.tools.clone()
130    }
131
132    fn source(&self) -> &str {
133        "lua"
134    }
135
136    fn arguments(&self) -> Vec<AgentArgument> {
137        self.definition.arguments.clone()
138    }
139
140    async fn resolve(&self, args: serde_json::Value, _ctx: &ToolContext) -> Result<AgentPrompt> {
141        resolve_agent(&self.definition, args, &self.config).await
142    }
143}
144
145// ═══════════════════════════════════════════════════════════════════════
146// Loading
147// ═══════════════════════════════════════════════════════════════════════
148
149/// Load all agent scripts from config and extract their definitions.
150///
151/// For each `[agents.script.<name>]` entry, reads the script file, creates
152/// a temporary Lua VM to extract the `agent` table metadata, and converts
153/// the argument declarations.
154///
155/// Called once at startup.
156pub fn load_agent_definitions(config: &Config) -> Result<Vec<AgentDefinition>> {
157    let mut agents = Vec::new();
158
159    for (name, agent_config) in &config.agents.script {
160        let agent_def = load_single_agent(name, agent_config)
161            .with_context(|| format!("Failed to load agent script '{}'", name))?;
162        agents.push(agent_def);
163    }
164
165    Ok(agents)
166}
167
168/// Load a single agent script and extract its definition.
169pub fn load_single_agent(name: &str, agent_config: &ScriptAgentConfig) -> Result<AgentDefinition> {
170    let script_src = std::fs::read_to_string(&agent_config.path).with_context(|| {
171        format!(
172            "Failed to read agent script: {}",
173            agent_config.path.display()
174        )
175    })?;
176
177    // Create a temporary Lua VM just to extract metadata
178    let lua = Lua::new();
179    lua.load(&script_src)
180        .set_name(agent_config.path.to_string_lossy())
181        .exec()
182        .map_err(|e| {
183            anyhow::anyhow!(
184                "Failed to execute agent script {}: {}",
185                agent_config.path.display(),
186                e
187            )
188        })?;
189
190    let agent_table: LuaTable = lua
191        .globals()
192        .get::<LuaTable>("agent")
193        .map_err(|e| anyhow::anyhow!("Script must define a global 'agent' table: {}", e))?;
194
195    let description: String = agent_table
196        .get::<String>("description")
197        .unwrap_or_else(|_| format!("Lua agent: {}", name));
198
199    // Extract tools list
200    let tools = extract_string_list(&agent_table, "tools")?;
201
202    // Extract arguments
203    let arguments = extract_arguments(&agent_table)?;
204
205    Ok(AgentDefinition {
206        name: name.to_string(),
207        description,
208        tools,
209        arguments,
210        script_path: agent_config.path.clone(),
211        script_source: script_src,
212        config: agent_config.extra.clone(),
213        timeout: agent_config.timeout,
214    })
215}
216
217/// Extract a list of strings from a Lua table field.
218fn extract_string_list(table: &LuaTable, key: &str) -> Result<Vec<String>> {
219    let mut result = Vec::new();
220    if let Ok(list) = table.get::<LuaTable>(key) {
221        let len = list.raw_len();
222        for i in 1..=len {
223            if let Ok(s) = list.raw_get::<String>(i) {
224                result.push(s);
225            }
226        }
227    }
228    Ok(result)
229}
230
231/// Extract argument definitions from a Lua `agent.arguments` table.
232fn extract_arguments(table: &LuaTable) -> Result<Vec<AgentArgument>> {
233    let mut result = Vec::new();
234    if let Ok(args_table) = table.get::<LuaTable>("arguments") {
235        let len = args_table.raw_len();
236        for i in 1..=len {
237            if let Ok(arg) = args_table.raw_get::<LuaTable>(i) {
238                let name: String = arg.get::<String>("name").map_err(|e| {
239                    anyhow::anyhow!("Argument at index {} missing 'name': {}", i, e)
240                })?;
241                let description: String = arg.get::<String>("description").unwrap_or_default();
242                let required: bool = arg.get::<bool>("required").unwrap_or(false);
243                result.push(AgentArgument {
244                    name,
245                    description,
246                    required,
247                });
248            }
249        }
250    }
251    Ok(result)
252}
253
254// ═══════════════════════════════════════════════════════════════════════
255// Resolution
256// ═══════════════════════════════════════════════════════════════════════
257
258/// Resolve an agent script's prompt.
259///
260/// Spawns a blocking thread, creates a sandboxed Lua VM with all host APIs
261/// plus the context bridge, and calls `agent.resolve(args, config, context)`.
262pub async fn resolve_agent(
263    agent: &AgentDefinition,
264    args: serde_json::Value,
265    app_config: &Config,
266) -> Result<AgentPrompt> {
267    let agent = agent.clone();
268    let config = app_config.clone();
269
270    tokio::task::spawn_blocking(move || run_lua_agent(&agent, args, &config))
271        .await
272        .context("Lua agent task panicked")?
273}
274
275/// Run the Lua agent synchronously on a blocking thread.
276fn run_lua_agent(
277    agent: &AgentDefinition,
278    args: serde_json::Value,
279    config: &Config,
280) -> Result<AgentPrompt> {
281    let script_dir = agent
282        .script_path
283        .parent()
284        .unwrap_or(Path::new("."))
285        .to_path_buf();
286
287    let lua = Lua::new();
288
289    // Set up timeout via instruction hook
290    let timeout_secs = agent.timeout;
291    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
292    lua.set_hook(
293        mlua::HookTriggers::new().every_nth_instruction(10_000),
294        move |_lua, _debug| {
295            if Instant::now() > deadline {
296                Err(mlua::Error::RuntimeError(format!(
297                    "agent timed out after {} seconds",
298                    timeout_secs
299                )))
300            } else {
301                Ok(mlua::VmState::Continue)
302            }
303        },
304    );
305
306    // Register all shared host APIs
307    let log_name = format!("agent:{}", agent.name);
308    register_all_host_apis(&lua, &log_name, &script_dir)?;
309
310    // Register context bridge (search, get, sources)
311    register_agent_context_bridge(&lua, config)?;
312
313    // Load and execute the script
314    lua.load(&agent.script_source)
315        .set_name(agent.script_path.to_string_lossy())
316        .exec()
317        .map_err(|e| {
318            anyhow::anyhow!(
319                "Failed to execute agent script {}: {}",
320                agent.script_path.display(),
321                e
322            )
323        })?;
324
325    // Get agent.resolve function
326    let agent_table: LuaTable = lua
327        .globals()
328        .get::<LuaTable>("agent")
329        .map_err(|e| anyhow::anyhow!("Script must define a global 'agent' table: {}", e))?;
330
331    let resolve_fn: LuaFunction = agent_table
332        .get::<LuaFunction>("resolve")
333        .map_err(|e| anyhow::anyhow!("agent.resolve function not defined: {}", e))?;
334
335    // Convert args to Lua
336    let args_lua = json_value_to_lua(&lua, &args)?;
337
338    // Convert agent config to Lua
339    let config_lua = toml_table_to_lua(&lua, &agent.config)?;
340
341    // Get the context table we already registered
342    let context: LuaTable = lua
343        .globals()
344        .get::<LuaTable>("context")
345        .map_err(|e| anyhow::anyhow!("context table missing: {}", e))?;
346
347    // Call agent.resolve(args, config, context)
348    let result: LuaValue = resolve_fn
349        .call::<LuaValue>((args_lua, config_lua, context))
350        .map_err(|e| {
351            anyhow::anyhow!(
352                "agent.resolve() failed in '{}': {}",
353                agent.script_path.display(),
354                e
355            )
356        })?;
357
358    // Convert result to AgentPrompt
359    lua_result_to_agent_prompt(result)
360}
361
362/// Convert the Lua `agent.resolve()` return value to an [`AgentPrompt`].
363///
364/// Expected shape:
365/// ```lua
366/// {
367///     system = "You are...",
368///     messages = {
369///         { role = "assistant", content = "I'm ready..." },
370///     }
371/// }
372/// ```
373fn lua_result_to_agent_prompt(value: LuaValue) -> Result<AgentPrompt> {
374    match value {
375        LuaValue::Table(table) => {
376            let system: String = table.get::<String>("system").map_err(|_| {
377                anyhow::anyhow!("agent.resolve() must return a table with 'system' field")
378            })?;
379
380            // Extract tools override (optional — agent might narrow its own list)
381            let tools = if let Ok(tools_table) = table.get::<LuaTable>("tools") {
382                let mut result = Vec::new();
383                let len = tools_table.raw_len();
384                for i in 1..=len {
385                    if let Ok(s) = tools_table.raw_get::<String>(i) {
386                        result.push(s);
387                    }
388                }
389                result
390            } else {
391                vec![]
392            };
393
394            // Extract messages (optional)
395            let messages = if let Ok(msgs_table) = table.get::<LuaTable>("messages") {
396                let mut result = Vec::new();
397                let len = msgs_table.raw_len();
398                for i in 1..=len {
399                    if let Ok(msg) = msgs_table.raw_get::<LuaTable>(i) {
400                        let role: String = msg
401                            .get::<String>("role")
402                            .unwrap_or_else(|_| "assistant".to_string());
403                        let content: String = msg.get::<String>("content").unwrap_or_default();
404                        result.push(PromptMessage { role, content });
405                    }
406                }
407                result
408            } else {
409                vec![]
410            };
411
412            Ok(AgentPrompt {
413                system,
414                tools,
415                messages,
416            })
417        }
418        _ => {
419            anyhow::bail!(
420                "agent.resolve() must return a table, got {:?}",
421                value.type_name()
422            );
423        }
424    }
425}
426
427// ═══════════════════════════════════════════════════════════════════════
428// Context Bridge
429// ═══════════════════════════════════════════════════════════════════════
430
431/// Register the `context` table in the Lua VM for agent scripts.
432///
433/// Provides `context.search`, `context.get`, and `context.sources`.
434/// Uses the same bridge pattern as tool scripts.
435fn register_agent_context_bridge(lua: &Lua, config: &Config) -> LuaResult<()> {
436    let ctx = lua.create_table()?;
437
438    // context.search(query, opts?) → results
439    let cfg = config.clone();
440    ctx.set(
441        "search",
442        lua.create_function(move |lua, (query, opts): (String, Option<LuaTable>)| {
443            let mode = opts
444                .as_ref()
445                .and_then(|o| o.get::<String>("mode").ok())
446                .unwrap_or_else(|| "keyword".to_string());
447            let limit = opts
448                .as_ref()
449                .and_then(|o| o.get::<i64>("limit").ok())
450                .unwrap_or(12);
451            let source = opts.as_ref().and_then(|o| o.get::<String>("source").ok());
452
453            let handle = tokio::runtime::Handle::current();
454            let results = handle
455                .block_on(async {
456                    search_documents(
457                        &cfg,
458                        &query,
459                        &mode,
460                        source.as_deref(),
461                        None,
462                        Some(limit),
463                        false,
464                    )
465                    .await
466                })
467                .map_err(mlua::Error::external)?;
468
469            search_results_to_lua(lua, &results)
470        })?,
471    )?;
472
473    // context.get(id) → document
474    let cfg = config.clone();
475    ctx.set(
476        "get",
477        lua.create_function(move |lua, id: String| {
478            let handle = tokio::runtime::Handle::current();
479            let doc = handle
480                .block_on(async { get_document(&cfg, &id).await })
481                .map_err(mlua::Error::external)?;
482
483            doc_response_to_lua(lua, &doc)
484        })?,
485    )?;
486
487    // context.sources() → sources
488    let cfg = config.clone();
489    ctx.set(
490        "sources",
491        lua.create_function(move |lua, ()| {
492            let sources = get_sources(&cfg);
493            sources_to_lua(lua, &sources)
494        })?,
495    )?;
496
497    lua.globals().set("context", ctx)?;
498    Ok(())
499}
500
501/// Convert search results to a Lua array table.
502fn search_results_to_lua(
503    lua: &Lua,
504    results: &[crate::search::SearchResultItem],
505) -> LuaResult<LuaTable> {
506    let table = lua.create_table()?;
507    for (i, item) in results.iter().enumerate() {
508        let row = lua.create_table()?;
509        row.set("id", item.id.as_str())?;
510        row.set("score", item.score)?;
511        row.set("source", item.source.as_str())?;
512        row.set("source_id", item.source_id.as_str())?;
513        row.set("updated_at", item.updated_at.as_str())?;
514        row.set("snippet", item.snippet.as_str())?;
515        if let Some(ref title) = item.title {
516            row.set("title", title.as_str())?;
517        }
518        if let Some(ref url) = item.source_url {
519            row.set("source_url", url.as_str())?;
520        }
521        table.set(i as i64 + 1, row)?;
522    }
523    Ok(table)
524}
525
526/// Convert a document response to a Lua table.
527fn doc_response_to_lua(lua: &Lua, doc: &crate::get::DocumentResponse) -> LuaResult<LuaTable> {
528    let table = lua.create_table()?;
529    table.set("id", doc.id.as_str())?;
530    table.set("source", doc.source.as_str())?;
531    table.set("source_id", doc.source_id.as_str())?;
532    table.set("content_type", doc.content_type.as_str())?;
533    table.set("body", doc.body.as_str())?;
534    table.set("created_at", doc.created_at.as_str())?;
535    table.set("updated_at", doc.updated_at.as_str())?;
536    if let Some(ref title) = doc.title {
537        table.set("title", title.as_str())?;
538    }
539    if let Some(ref author) = doc.author {
540        table.set("author", author.as_str())?;
541    }
542    if let Some(ref url) = doc.source_url {
543        table.set("source_url", url.as_str())?;
544    }
545
546    let chunks_table = lua.create_table()?;
547    for (i, chunk) in doc.chunks.iter().enumerate() {
548        let c = lua.create_table()?;
549        c.set("index", chunk.index)?;
550        c.set("text", chunk.text.as_str())?;
551        chunks_table.set(i as i64 + 1, c)?;
552    }
553    table.set("chunks", chunks_table)?;
554
555    Ok(table)
556}
557
558/// Convert source statuses to a Lua array table.
559fn sources_to_lua(lua: &Lua, sources: &[crate::sources::SourceStatus]) -> LuaResult<LuaTable> {
560    let table = lua.create_table()?;
561    for (i, s) in sources.iter().enumerate() {
562        let row = lua.create_table()?;
563        row.set("name", s.name.as_str())?;
564        row.set("configured", s.configured)?;
565        row.set("healthy", s.healthy)?;
566        if let Some(ref notes) = s.notes {
567            row.set("notes", notes.as_str())?;
568        }
569        table.set(i as i64 + 1, row)?;
570    }
571    Ok(table)
572}
573
574// ═══════════════════════════════════════════════════════════════════════
575// CLI: scaffold & test
576// ═══════════════════════════════════════════════════════════════════════
577
578/// Scaffold a new agent script from a template.
579///
580/// Creates `agents/<name>.lua` with a commented template showing the
581/// agent interface and available host APIs.
582pub fn scaffold_agent(name: &str) -> Result<()> {
583    let dir = Path::new("agents");
584    std::fs::create_dir_all(dir)?;
585
586    let filename = format!("{}.lua", name.replace('_', "-"));
587    let path = dir.join(&filename);
588
589    if path.exists() {
590        bail!("Agent script already exists: {}", path.display());
591    }
592
593    let template = format!(
594        r#"--[[
595  Context Harness Agent: {name}
596
597  Configuration (add to ctx.toml):
598
599    [agents.script.{name}]
600    path = "agents/{filename}"
601    timeout = 30
602    # search_limit = 5
603
604  Test:
605    ctx agent test {name} --arg key=value
606]]
607
608agent = {{
609    name = "{name}",
610    description = "TODO: describe what this agent does",
611    tools = {{ "search", "get" }},
612    arguments = {{
613        {{
614            name = "topic",
615            description = "Focus area for this session",
616            required = false,
617        }},
618    }},
619}}
620
621--- Resolve the agent's prompt for a conversation.
622--- @param args table User-provided argument values
623--- @param config table Agent-specific config from ctx.toml
624--- @param context table Bridge to Context Harness (search, get, sources)
625--- @return table Resolved prompt with system, tools, and messages
626function agent.resolve(args, config, context)
627    local topic = args.topic or "general"
628
629    -- Example: pre-search for relevant context
630    -- local results = context.search(topic, {{ mode = "keyword", limit = 5 }})
631    -- local context_text = ""
632    -- for _, r in ipairs(results) do
633    --     local doc = context.get(r.id)
634    --     context_text = context_text .. "\n---\n" .. doc.body
635    -- end
636
637    return {{
638        system = string.format([[
639You are a helpful assistant focused on %s.
640
641Use the search tool to find relevant documentation and ground
642your responses in the indexed knowledge base.
643        ]], topic),
644        messages = {{
645            {{
646                role = "assistant",
647                content = string.format(
648                    "I'm ready to help with %s. What would you like to know?",
649                    topic
650                ),
651            }},
652        }},
653    }}
654end
655
656return agent
657"#,
658        name = name,
659        filename = filename,
660    );
661
662    std::fs::write(&path, template)?;
663    println!("Created agent: {}", path.display());
664    println!();
665    println!("Add to your ctx.toml:");
666    println!();
667    println!("  [agents.script.{}]", name);
668    println!("  path = \"agents/{}\"", filename);
669    println!();
670    println!("Then test:");
671    println!();
672    println!("  ctx agent test {} --arg topic=\"deployment\"", name);
673
674    Ok(())
675}
676
677/// Test an agent script by resolving its prompt.
678///
679/// Loads the script, executes `agent.resolve()` with the provided arguments,
680/// and prints the resulting system prompt, tools, and messages.
681pub async fn test_agent(name: &str, args: Vec<(String, String)>, config: &Config) -> Result<()> {
682    let agent_config = config
683        .agents
684        .script
685        .get(name)
686        .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found in config", name))?;
687
688    let agent_def = load_single_agent(name, agent_config)?;
689
690    println!("Agent: {}", agent_def.name);
691    println!("Source: lua ({})", agent_def.script_path.display());
692    println!(
693        "Tools: {}",
694        if agent_def.tools.is_empty() {
695            "(none defined)".to_string()
696        } else {
697            agent_def.tools.join(", ")
698        }
699    );
700    println!();
701
702    // Build args JSON
703    let mut args_json = serde_json::Map::new();
704    for (k, v) in &args {
705        let json_val = serde_json::from_str::<serde_json::Value>(v)
706            .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
707        args_json.insert(k.clone(), json_val);
708    }
709    let args_value = serde_json::Value::Object(args_json);
710
711    let start = Instant::now();
712    let prompt = resolve_agent(&agent_def, args_value, config).await?;
713    let elapsed = start.elapsed();
714
715    println!("System prompt ({} chars):", prompt.system.len());
716    for line in prompt.system.lines() {
717        println!("  {}", line);
718    }
719
720    if !prompt.tools.is_empty() {
721        println!();
722        println!("Tools override: {}", prompt.tools.join(", "));
723    }
724
725    if !prompt.messages.is_empty() {
726        println!();
727        println!("Messages ({}):", prompt.messages.len());
728        for msg in &prompt.messages {
729            println!("  [{}] {}", msg.role, msg.content);
730        }
731    }
732
733    println!();
734    println!("Resolved in {:.0?}", elapsed);
735
736    Ok(())
737}
738
739/// List all configured agents and print their info.
740pub fn list_agents(config: &Config) -> Result<()> {
741    let mut count = 0;
742
743    println!("{:<24} {:<8} {:<44} TOOLS", "AGENT", "TYPE", "DESCRIPTION");
744
745    // TOML agents
746    for (name, cfg) in &config.agents.inline {
747        println!(
748            "{:<24} {:<8} {:<44} {}",
749            name,
750            "toml",
751            truncate(&cfg.description, 44),
752            cfg.tools.join(", ")
753        );
754        count += 1;
755    }
756
757    // Lua agents
758    let lua_defs = load_agent_definitions(config)?;
759    for def in &lua_defs {
760        println!(
761            "{:<24} {:<8} {:<44} {}",
762            def.name,
763            "lua",
764            truncate(&def.description, 44),
765            def.tools.join(", ")
766        );
767        count += 1;
768    }
769
770    if count == 0 {
771        println!("No agents configured.");
772    }
773
774    Ok(())
775}
776
777/// Truncate a string to fit in a column.
778fn truncate(s: &str, max: usize) -> String {
779    if s.len() <= max {
780        s.to_string()
781    } else {
782        format!("{}...", &s[..max.saturating_sub(3)])
783    }
784}