Skip to main content

atman_runtime/tools/
agent_ctrl.rs

1use crate::approval::{ApprovalOutcome, request_approval};
2use crate::error::RuntimeError;
3use crate::event::{Event, FlowRunId, FlowStatus, NodeEvent};
4use crate::message::{Message, MessagePart, MessageRole};
5use crate::provider::LlmRequest;
6use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult, ToolSpec};
7use crate::value::Value;
8use std::path::PathBuf;
9
10pub struct AgentSpawn;
11
12const DEFAULT_MAX_ITER: u64 = 20;
13const MAX_ITER_HARD_CAP: u64 = 200;
14
15impl Tool for AgentSpawn {
16    fn name(&self) -> &str {
17        "agent.spawn"
18    }
19
20    fn tier(&self) -> Tier {
21        Tier::Two
22    }
23
24    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
25        ApprovalLevel::Approve
26    }
27
28    fn description(&self) -> Option<&str> {
29        Some(
30            "Spawn an independent sub-agent to handle a focused sub-goal. The sub-agent runs its \
31             own message history and iteration counter, uses the same tool registry (or a subset \
32             you pick), and returns its final assistant text as this tool's result. Prefer this \
33             over doing large exploratory work directly when it would otherwise flood the main \
34             conversation with search output or scratch reasoning. Parameters: \
35             `goal` (required string), `tools` (optional list of tool-name strings — defaults \
36             to all tools available to you), `max_iterations` (optional int, default 20, capped \
37             at 200), `model` (optional model name — defaults to the last model this session \
38             used, then configured models, then claude-opus-4.7), `flow` (optional .at file path \
39             or command name; goal is passed as the first flow argument).",
40        )
41    }
42
43    fn input_schema(&self) -> serde_json::Value {
44        serde_json::json!({
45            "type": "object",
46            "properties": {
47                "goal": {"type": "string"},
48                "tools": {"type": "array", "items": {"type": "string"}},
49                "max_iterations": {"type": "integer"},
50                "model": {"type": "string"},
51                "flow": {"type": "string"}
52            },
53            "required": ["goal"]
54        })
55    }
56
57    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
58        Box::pin(async move { run_sub_agent(args, ctx).await })
59    }
60}
61
62async fn run_sub_agent(args: ToolArgs, ctx: &ToolCtx) -> ToolResult {
63    let goal = extract_goal(&args)?;
64    if let Some(flow) = extract_flow(&args)? {
65        return run_flow_agent(&flow, goal, ctx).await;
66    }
67    let max_iter = extract_max_iter(&args);
68    let tool_filter = extract_tool_filter(&args)?;
69    let model = pick_model(&args, ctx);
70    let Some(providers) = ctx.providers.as_ref() else {
71        return Err(RuntimeError::ToolFailed(
72            "agent.spawn: no provider registry available on ctx".into(),
73        ));
74    };
75    let Some(provider) = providers.resolve(&model) else {
76        return Ok(Value::Str(format!(
77            "[sub-agent failed: no provider for model `{model}`]"
78        )));
79    };
80    let Some(registry) = ctx.registry.as_ref() else {
81        return Err(RuntimeError::ToolFailed(
82            "agent.spawn: no tool registry available on ctx".into(),
83        ));
84    };
85    let tool_specs = build_tool_specs(registry.as_ref(), tool_filter.as_deref());
86    let child_run_id = FlowRunId::now();
87    emit_child_flow_start(ctx, &child_run_id, &goal);
88    let turn = ctx
89        .turn_id
90        .clone()
91        .unwrap_or_else(crate::event::TurnId::now);
92    let mut messages: Vec<Message> = vec![Message::user_text(turn.clone(), goal.clone())];
93    let mut final_text: Option<String> = None;
94    let mut failure_reason: Option<String> = None;
95    for iter in 0..max_iter {
96        if ctx.cancel.is_cancelled() {
97            failure_reason = Some("cancelled by parent".into());
98            break;
99        }
100        let req = LlmRequest {
101            model: model.clone(),
102            messages: messages.clone(),
103            system: None,
104            input: Value::Unit,
105            schema: None,
106            cache_prompt: false,
107            tools: tool_specs.clone(),
108            thinking_enabled: false,
109            stall_timeout_secs: 120,
110        };
111        let outcome = call_streaming_sub_agent(provider.as_ref(), req, ctx).await;
112        match outcome {
113            Ok(am) => {
114                emit_child_llm_call(ctx, &child_run_id, &model, &am);
115                let uses = extract_tool_uses(&am.message);
116                messages.push(am.message.clone());
117                emit_assistant_msg(ctx, &child_run_id, &am.message);
118                if uses.is_empty() {
119                    final_text = Some(am.text_concat());
120                    break;
121                }
122                let mut child_ctx = sanitize_child_ctx(ctx);
123                child_ctx.session_messages = Some(std::sync::Arc::new(messages.clone()));
124                let tool_results = dispatch_child_tools(&uses, registry.as_ref(), &child_ctx).await;
125                let turn_for_results = am.message.turn_id.clone();
126                let combined = Message {
127                    turn_id: turn_for_results,
128                    role: MessageRole::Tool,
129                    parts: tool_results,
130                };
131                emit_tool_result_msg(ctx, &child_run_id, &combined);
132                messages.push(combined);
133            }
134            Err(e) => {
135                failure_reason = Some(format!("provider error at iter {iter}: {e}"));
136                break;
137            }
138        }
139    }
140    let status = if final_text.is_some() {
141        FlowStatus::Ok
142    } else {
143        FlowStatus::Errored {
144            message: failure_reason
145                .clone()
146                .unwrap_or_else(|| format!("hit max iterations {max_iter} without a final answer")),
147        }
148    };
149    emit_child_flow_end(ctx, &child_run_id, &status);
150    if let Some(text) = final_text {
151        Ok(Value::Str(text))
152    } else {
153        let reason = failure_reason
154            .unwrap_or_else(|| format!("hit max iterations {max_iter} without a final answer"));
155        let last = messages
156            .iter()
157            .rev()
158            .find(|m| matches!(m.role, MessageRole::Assistant))
159            .map(|m| m.text_concat())
160            .unwrap_or_default();
161        let partial = if last.is_empty() {
162            String::new()
163        } else {
164            format!("\n[partial output: {}]", truncate(&last, 400))
165        };
166        Ok(Value::Str(format!("[sub-agent failed: {reason}]{partial}")))
167    }
168}
169
170async fn run_flow_agent(flow_ref: &str, goal: String, ctx: &ToolCtx) -> ToolResult {
171    let Some(registry) = ctx.registry.as_ref() else {
172        return Err(RuntimeError::ToolFailed(
173            "agent.spawn: no tool registry available on ctx".into(),
174        ));
175    };
176    let Some(providers) = ctx.providers.as_ref() else {
177        return Err(RuntimeError::ToolFailed(
178            "agent.spawn: no provider registry available on ctx".into(),
179        ));
180    };
181    let (path, src) = read_flow_source(flow_ref).await?;
182    let file = atman_dsl::parse::parse_file(&src).map_err(|e| {
183        RuntimeError::ToolFailed(format!("agent.spawn: parse {}: {e}", path.display()))
184    })?;
185    let Some(flow) = file.flows.first() else {
186        return Err(RuntimeError::ToolFailed(format!(
187            "agent.spawn: no flow in {}",
188            path.display()
189        )));
190    };
191    let args = flow
192        .params
193        .first()
194        .map(|(ident, _)| vec![(ident.name.clone(), Value::Str(goal))])
195        .unwrap_or_default();
196    let flows = file
197        .flows
198        .iter()
199        .map(|flow| (flow.name.name.clone(), flow.clone()))
200        .collect();
201    let run_id = FlowRunId::now();
202    emit_flow_agent_start(ctx, &run_id, &flow.name.name);
203    let mut child_ctx = sanitize_child_ctx(ctx);
204    child_ctx.session_messages = Some(std::sync::Arc::new(Vec::new()));
205    let out = crate::exec::exec_flow_with_siblings(
206        flow,
207        args,
208        registry.as_ref(),
209        &child_ctx,
210        providers.as_ref(),
211        &flows,
212        child_ctx.events.as_ref(),
213        child_ctx.turn_id.clone(),
214        Some(run_id.clone()),
215        None,
216        child_ctx.cancel.clone(),
217        None,
218        path.parent().map(|p| p.to_path_buf()),
219    )
220    .await;
221    let status = match &out {
222        Ok(_) => FlowStatus::Ok,
223        Err(e) => FlowStatus::Errored {
224            message: e.to_string(),
225        },
226    };
227    emit_child_flow_end(ctx, &run_id, &status);
228    out
229}
230
231fn extract_goal(args: &ToolArgs) -> Result<String, RuntimeError> {
232    match args.named("goal").or_else(|| args.positional.first()) {
233        Some(Value::Str(s)) if !s.trim().is_empty() => Ok(s.clone()),
234        Some(other) => Err(RuntimeError::TypeMismatch {
235            expected: "non-empty goal string".into(),
236            actual: other.kind_name().into(),
237        }),
238        None => Err(RuntimeError::MissingArg("agent.spawn.goal".into())),
239    }
240}
241
242fn extract_max_iter(args: &ToolArgs) -> u64 {
243    match args.named("max_iterations") {
244        Some(Value::Int(n)) if *n > 0 => (*n as u64).min(MAX_ITER_HARD_CAP),
245        _ => DEFAULT_MAX_ITER,
246    }
247}
248
249fn extract_flow(args: &ToolArgs) -> Result<Option<String>, RuntimeError> {
250    match args.named("flow") {
251        Some(Value::Str(s)) if !s.trim().is_empty() => Ok(Some(s.clone())),
252        Some(Value::Unit) | None => Ok(None),
253        Some(other) => Err(RuntimeError::TypeMismatch {
254            expected: "flow string".into(),
255            actual: other.kind_name().into(),
256        }),
257    }
258}
259
260fn extract_tool_filter(args: &ToolArgs) -> Result<Option<Vec<String>>, RuntimeError> {
261    match args.named("tools") {
262        Some(Value::List(items)) => {
263            let mut out = Vec::with_capacity(items.len());
264            for it in items {
265                match it {
266                    Value::Str(s) => out.push(s.clone()),
267                    other => {
268                        return Err(RuntimeError::TypeMismatch {
269                            expected: "string tool name".into(),
270                            actual: other.kind_name().into(),
271                        });
272                    }
273                }
274            }
275            Ok(Some(out))
276        }
277        Some(Value::Unit) | None => Ok(None),
278        Some(other) => Err(RuntimeError::TypeMismatch {
279            expected: "list of tool names".into(),
280            actual: other.kind_name().into(),
281        }),
282    }
283}
284
285fn pick_model(args: &ToolArgs, ctx: &ToolCtx) -> String {
286    if let Some(Value::Str(s)) = args.named("model")
287        && !s.is_empty()
288    {
289        return crate::model_registry::resolve_alias(s);
290    }
291    if let Some(model) = &ctx.current_model {
292        return model.clone();
293    }
294    if let Some((name, _)) = crate::model_registry::all_model_entries().first() {
295        return name.clone();
296    }
297    "claude-opus-4.7".into()
298}
299
300async fn read_flow_source(flow_ref: &str) -> Result<(PathBuf, String), RuntimeError> {
301    for path in flow_candidates(flow_ref) {
302        match tokio::fs::read_to_string(&path).await {
303            Ok(src) => return Ok((path, src)),
304            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
305            Err(e) => {
306                return Err(RuntimeError::ToolFailed(format!(
307                    "agent.spawn: read {}: {e}",
308                    path.display()
309                )));
310            }
311        }
312    }
313    Err(RuntimeError::ToolFailed(format!(
314        "agent.spawn: flow `{flow_ref}` not found"
315    )))
316}
317
318fn flow_candidates(flow_ref: &str) -> Vec<PathBuf> {
319    let path = PathBuf::from(flow_ref);
320    if path.is_absolute() {
321        return vec![path];
322    }
323    let file_name = if flow_ref.ends_with(".at") {
324        flow_ref.to_string()
325    } else {
326        format!("{flow_ref}.at")
327    };
328    let mut out = Vec::new();
329    if let Some(home) = std::env::var_os("HOME") {
330        out.push(
331            PathBuf::from(home)
332                .join(".config")
333                .join("atman")
334                .join("commands")
335                .join(&file_name),
336        );
337    }
338    out.push(PathBuf::from(file_name));
339    out
340}
341
342async fn call_streaming_sub_agent(
343    provider: &dyn crate::provider::Provider,
344    req: LlmRequest,
345    ctx: &ToolCtx,
346) -> Result<crate::provider::AssistantMessage, RuntimeError> {
347    let model_name = req.model.clone();
348    let obs = provider.call_streaming(req);
349    let mut events = obs.events;
350    let output = obs.output;
351    tokio::pin!(output);
352    let result = loop {
353        tokio::select! {
354            ev = events.recv() => {
355                match ev {
356                    Ok(event) => forward_stream_event(event, ctx, &model_name),
357                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
358                        // Channel closed; drain remaining then break to output.
359                        break output.await;
360                    }
361                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
362                        // Skip lagged; continue polling.
363                    }
364                }
365            }
366            result = &mut output => break result,
367        }
368    };
369    while let Ok(ev) = events.try_recv() {
370        forward_stream_event(ev, ctx, &model_name);
371    }
372    result
373}
374
375fn forward_stream_event(ev: NodeEvent, ctx: &ToolCtx, model: &str) {
376    let Some(tx) = &ctx.stream_tx else {
377        return;
378    };
379    match ev {
380        NodeEvent::LlmChunk { text, .. } => {
381            let _ = tx.send(crate::stream::StreamFrame::LlmChunk {
382                text,
383                model: model.to_string(),
384            });
385        }
386        NodeEvent::ThinkingChunk { text } => {
387            let _ = tx.send(crate::stream::StreamFrame::ThinkingChunk { text });
388        }
389        NodeEvent::LlmDone { total_tokens } => {
390            let _ = tx.send(crate::stream::StreamFrame::LlmDone { total_tokens });
391        }
392        _ => {}
393    }
394}
395
396fn build_tool_specs(
397    registry: &crate::tool::ToolRegistry,
398    filter: Option<&[String]>,
399) -> Vec<ToolSpec> {
400    let mut specs = Vec::new();
401    for (name, tool) in registry.iter() {
402        if let Some(allow) = filter
403            && !allow.iter().any(|n| n == &name)
404        {
405            continue;
406        }
407        specs.push(crate::tool::tool_spec(tool.as_ref()));
408    }
409    specs
410}
411
412fn extract_tool_uses(msg: &Message) -> Vec<(String, String, Value)> {
413    let mut out = Vec::new();
414    for part in &msg.parts {
415        if let MessagePart::ToolUse { id, name, input } = part {
416            let value = Value::from_json(input.clone());
417            out.push((id.clone(), name.clone(), value));
418        }
419    }
420    out
421}
422
423async fn dispatch_child_tools(
424    uses: &[(String, String, Value)],
425    registry: &crate::tool::ToolRegistry,
426    ctx: &ToolCtx,
427) -> Vec<MessagePart> {
428    struct Ready {
429        idx: usize,
430        id: String,
431        name: String,
432        tool: std::sync::Arc<dyn crate::tool::Tool>,
433        call_args: ToolArgs,
434    }
435    let mut out: Vec<Option<MessagePart>> = vec![None; uses.len()];
436    let mut ready: Vec<Ready> = Vec::new();
437    for (idx, (id, name, input)) in uses.iter().enumerate() {
438        let Some(tool) = registry.get(name) else {
439            out[idx] = Some(MessagePart::ToolResult {
440                tool_use_id: id.clone(),
441                content: format!("sub-agent: unknown tool `{name}`"),
442                is_error: true,
443            });
444            continue;
445        };
446        let named = match input {
447            Value::Struct(fields) => fields.clone(),
448            Value::Unit => Vec::new(),
449            _ => Vec::new(),
450        };
451        ready.push(Ready {
452            idx,
453            id: id.clone(),
454            name: name.clone(),
455            tool,
456            call_args: ToolArgs {
457                positional: Vec::new(),
458                named,
459            },
460        });
461    }
462    // Parallel: serial awaits hid all but the first pending node from the UI.
463    for r in &ready {
464        emit_tool_use_start(ctx, &r.name, &r.id, &r.call_args);
465    }
466    let gates = ready.iter().map(|r| {
467        let level = r.tool.approval_level(&r.call_args, ctx);
468        request_approval(
469            ctx,
470            &r.id,
471            &r.name,
472            &r.call_args,
473            level,
474            Some(r.tool.as_ref()),
475        )
476    });
477    let outcomes = futures::future::join_all(gates).await;
478    for (r, gate) in ready.into_iter().zip(outcomes) {
479        let part = match gate {
480            ApprovalOutcome::Deny { reason } => MessagePart::ToolResult {
481                tool_use_id: r.id.clone(),
482                content: format!("sub-agent: tool `{}` denied — {reason}", r.name),
483                is_error: true,
484            },
485            ApprovalOutcome::Approve => match r.tool.call(r.call_args, ctx).await {
486                Ok(v) => MessagePart::ToolResult {
487                    tool_use_id: r.id.clone(),
488                    content: format_value(&v),
489                    is_error: false,
490                },
491                Err(e) => MessagePart::ToolResult {
492                    tool_use_id: r.id.clone(),
493                    content: format!("{e}"),
494                    is_error: true,
495                },
496            },
497        };
498        emit_tool_use_done(ctx, &r.name, &r.id, &part);
499        out[r.idx] = Some(part);
500    }
501    out.into_iter().flatten().collect()
502}
503
504fn emit_tool_use_start(ctx: &ToolCtx, name: &str, id: &str, args: &ToolArgs) {
505    if let Some(tx) = &ctx.stream_tx {
506        let _ = tx.send(crate::stream::StreamFrame::ToolUseStart {
507            tool: name.to_string(),
508            args_preview: preview_tool_args(args),
509            id: id.to_string(),
510        });
511    }
512}
513
514fn emit_tool_use_done(ctx: &ToolCtx, name: &str, id: &str, part: &MessagePart) {
515    if let Some(tx) = &ctx.stream_tx {
516        let (ok, preview) = match part {
517            MessagePart::ToolResult {
518                content, is_error, ..
519            } => (!is_error, truncate(content, 400)),
520            _ => (false, String::new()),
521        };
522        let _ = tx.send(crate::stream::StreamFrame::ToolUseDone {
523            tool: name.to_string(),
524            ok,
525            preview,
526            id: id.to_string(),
527        });
528    }
529}
530
531fn preview_tool_args(args: &ToolArgs) -> String {
532    let mut parts: Vec<String> = args.positional.iter().map(preview_value).collect();
533    for (k, v) in &args.named {
534        parts.push(format!("{k}={}", preview_value(v)));
535    }
536    truncate(&parts.join(", "), 4000)
537}
538
539fn preview_value(v: &Value) -> String {
540    match v {
541        Value::Str(s) => format!("{s:?}"),
542        Value::Int(n) => n.to_string(),
543        Value::Bool(b) => b.to_string(),
544        Value::Float(f) => f.to_string(),
545        Value::Unit => "()".into(),
546        Value::List(items) => format!("list[{}]", items.len()),
547        Value::Struct(items) => format!("struct[{}]", items.len()),
548        Value::Message(_) => "<message>".into(),
549        Value::Path(p) => p.display().to_string(),
550        Value::EditProposal(_) => "<edit proposal>".into(),
551        Value::Err(e) => format!("error({e})"),
552    }
553}
554
555fn format_value(v: &Value) -> String {
556    match v {
557        Value::Str(s) => s.clone(),
558        other => format!("{other:?}"),
559    }
560}
561
562fn emit_child_flow_start(ctx: &ToolCtx, run_id: &FlowRunId, goal: &str) {
563    let parent_run_id = ctx.flow_run_id.clone();
564    let parent_node_id = ctx.current_node_id.clone();
565    if let Some(sink) = &ctx.events {
566        sink.emit(Event::FlowStart {
567            run_id: run_id.clone(),
568            flow_name: "agent.sub".into(),
569            parent_run_id: parent_run_id.clone(),
570            parent_node_id: parent_node_id.clone(),
571        });
572    }
573    if let Some(tx) = &ctx.stream_tx {
574        let _ = tx.send(crate::stream::StreamFrame::FlowStart {
575            run_id: run_id.0.to_string(),
576            flow_name: format!("agent.sub · {}", truncate(goal, 60)),
577            parent_run_id: parent_run_id.as_ref().map(|r| r.0.to_string()),
578            parent_node_id,
579        });
580    }
581}
582
583fn emit_flow_agent_start(ctx: &ToolCtx, run_id: &FlowRunId, flow_name: &str) {
584    let parent_run_id = ctx.flow_run_id.clone();
585    let parent_node_id = ctx.current_node_id.clone();
586    if let Some(sink) = &ctx.events {
587        sink.emit(Event::FlowStart {
588            run_id: run_id.clone(),
589            flow_name: flow_name.into(),
590            parent_run_id: parent_run_id.clone(),
591            parent_node_id: parent_node_id.clone(),
592        });
593    }
594    if let Some(tx) = &ctx.stream_tx {
595        let _ = tx.send(crate::stream::StreamFrame::FlowStart {
596            run_id: run_id.0.to_string(),
597            flow_name: flow_name.into(),
598            parent_run_id: parent_run_id.as_ref().map(|r| r.0.to_string()),
599            parent_node_id,
600        });
601    }
602}
603
604fn emit_child_flow_end(ctx: &ToolCtx, run_id: &FlowRunId, status: &FlowStatus) {
605    if let Some(sink) = &ctx.events {
606        sink.emit(Event::FlowEnd {
607            run_id: run_id.clone(),
608            flow_name: "agent.sub".into(),
609            status: status.clone(),
610        });
611    }
612    if let Some(tx) = &ctx.stream_tx {
613        let _ = tx.send(crate::stream::StreamFrame::FlowDone {
614            run_id: run_id.0.to_string(),
615            flow_name: "agent.sub".into(),
616            ok: matches!(status, FlowStatus::Ok),
617            cancelled: false,
618        });
619    }
620}
621
622fn emit_child_llm_call(
623    ctx: &ToolCtx,
624    _run_id: &FlowRunId,
625    model: &str,
626    am: &crate::provider::AssistantMessage,
627) {
628    if let Some(sink) = &ctx.events {
629        sink.emit(Event::LlmCall {
630            model: model.into(),
631            provider: "sub".into(),
632            usage: am.token_usage.clone(),
633            wallclock_ms: 0,
634            ttft_ms: am.timing.ttft_ms,
635            tokens_per_second: am.timing.tokens_per_second(am.token_usage.output),
636            status: crate::event::LlmCallStatus::Ok,
637            run_id: None,
638            node_id: None,
639        });
640    }
641}
642
643fn emit_assistant_msg(ctx: &ToolCtx, run_id: &FlowRunId, message: &Message) {
644    let turn_id = message.turn_id.clone();
645    if let Some(sink) = &ctx.events {
646        sink.emit(Event::AssistantMsg {
647            turn_id: turn_id.clone(),
648            flow_run_id: Some(run_id.clone()),
649            message: message.clone(),
650        });
651    }
652    if let Some(tx) = &ctx.stream_tx {
653        let _ = tx.send(crate::stream::StreamFrame::AssistantMsg {
654            flow_run_id: Some(run_id.0.to_string()),
655            message: message.clone(),
656        });
657    }
658}
659
660fn emit_tool_result_msg(ctx: &ToolCtx, run_id: &FlowRunId, message: &Message) {
661    let turn_id = message.turn_id.clone();
662    if let Some(sink) = &ctx.events {
663        sink.emit(Event::ToolResultMsg {
664            turn_id: turn_id.clone(),
665            flow_run_id: Some(run_id.clone()),
666            message: message.clone(),
667        });
668    }
669    if let Some(tx) = &ctx.stream_tx {
670        let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
671            flow_run_id: Some(run_id.0.to_string()),
672            message: message.clone(),
673        });
674    }
675}
676
677fn sanitize_child_ctx(parent: &ToolCtx) -> ToolCtx {
678    let mut c = parent.clone();
679    c.session_runtime = None;
680    c.session_messages_handle = None;
681    c.compact_lock_handle = None;
682    c.forms = None;
683    c.on_memory_recent = None;
684    c
685}
686
687fn truncate(s: &str, n: usize) -> String {
688    let chars: Vec<char> = s.chars().collect();
689    if chars.len() <= n {
690        s.to_string()
691    } else {
692        chars.iter().take(n).collect::<String>() + "…"
693    }
694}