errand-bot 0.1.0

Run a coding agent from a chat channel, in a sandbox it cannot escape.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
//! Splits and formats agent output into messages the chat service will take.
//!
//! Splitting is by code point, never by byte, so a multi-byte character is
//! never cut in half. A split landing inside a fenced code block closes the
//! fence in the earlier message and reopens it with the same language in the
//! next, so every message posted is independently well formed.

use serde_json::Value;

use crate::agent::protocol::DialogMethod;
use crate::agent::protocol::DialogRequest;
use crate::chat::chars::{PrefixKey, prefixed};
use crate::session::event::Delegated;
use crate::session::files::Entry;
use crate::session::files::{FileContents, MAX_INLINE_BYTES};

/// The service's per-message character limit.
pub const MESSAGE_LIMIT: usize = 2000;

/// The service's thread name limit.
pub const THREAD_NAME_LIMIT: usize = 100;

/// The fence marker.
const FENCE: &str = "```";

/// The cost of closing one at the end of a chunk.
const CLOSING_COST: usize = FENCE.len() + 1;

/// Room left for a reopened fence when pre-splitting an over-long line.
const FENCE_HEADROOM: usize = 16;

/// Most entries listed in a thread before the rest is summarised.
pub const MAX_LISTED_ENTRIES: usize = 200;

/// Splits a string into pieces of at most `size` code points.
fn split_by_code_points(text: &str, size: usize) -> Vec<String> {
    let points: Vec<char> = text.chars().collect();
    if points.len() <= size {
        return vec![text.to_owned()];
    }
    points
        .chunks(size)
        .map(|chunk| chunk.iter().collect())
        .collect()
}

/// Length in code points, which is what the limit actually counts.
fn length(text: &str) -> usize {
    text.chars().count()
}

/// Returns the fence language when the line opens or closes a fenced block,
/// or nothing when it is ordinary text.
fn fence_language(line: &str) -> Option<String> {
    let trimmed = line.trim_start();
    if !trimmed.starts_with(FENCE) {
        return None;
    }
    Some(trimmed[FENCE.len()..].trim().to_owned())
}

/// Splits text into messages that each fit the limit, preferring line
/// boundaries and repairing any fence the split lands inside.
pub fn split_message(text: &str, limit: usize) -> Vec<String> {
    if length(text) <= limit {
        return if text.is_empty() {
            Vec::new()
        } else {
            vec![text.to_owned()]
        };
    }

    let mut lines = Vec::new();
    for line in text.split('\n') {
        lines.extend(split_by_code_points(line, limit - FENCE_HEADROOM));
    }

    let mut chunks: Vec<String> = Vec::new();
    let mut current: Vec<String> = Vec::new();
    let mut current_length = 0;
    let mut open_language: Option<String> = None;

    for line in &lines {
        let reserve = if open_language.is_some() {
            CLOSING_COST
        } else {
            0
        };
        let cost = if current.is_empty() {
            length(line)
        } else {
            length(line) + 1
        };
        if current_length + cost + reserve > limit {
            flush(
                &mut chunks,
                &mut current,
                &mut current_length,
                open_language.as_ref(),
            );
        }

        let first_in_chunk = current.is_empty();
        current.push(line.clone());
        current_length += if first_in_chunk {
            length(line)
        } else {
            length(line) + 1
        };

        if let Some(language) = fence_language(line) {
            open_language = if open_language.is_none() {
                Some(language)
            } else {
                None
            };
        }
    }

    if !current.is_empty() {
        chunks.push(current.join("\n"));
    }

    chunks
        .into_iter()
        .filter(|chunk| !chunk.is_empty())
        .collect()
}

/// Closes the chunk under construction, and reopens its fence in the next one
/// when a split landed inside the block.
fn flush(
    chunks: &mut Vec<String>,
    current: &mut Vec<String>,
    current_length: &mut usize,
    open_language: Option<&String>,
) {
    if current.is_empty() {
        return;
    }
    let mut body = current.join("\n");
    if open_language.is_some() {
        body.push('\n');
        body.push_str(FENCE);
    }
    chunks.push(body);
    current.clear();
    *current_length = 0;
    if let Some(language) = open_language {
        let reopened = format!("{FENCE}{language}");
        *current_length = length(&reopened);
        current.push(reopened);
    }
}

/// Derives a thread name from the first prompt and the project it runs in,
/// truncated to the limit without splitting a character.
pub fn thread_name(project: &str, prompt: &str) -> String {
    let first_line = prompt
        .split('\n')
        .find(|line| !line.trim().is_empty())
        .unwrap_or("session");
    let collapsed = collapse_spaces(first_line.trim());
    let prefix = format!("{project}: ");
    let room = THREAD_NAME_LIMIT - length(&prefix);
    let mut body: String = collapsed.chars().take(room).collect();
    while body.ends_with(|c: char| c.is_whitespace()) {
        body.pop();
    }
    if body.is_empty() {
        "session".clone_into(&mut body);
    }
    format!("{prefix}{body}")
}

/// Any run of whitespace becomes one space.
fn collapse_spaces(text: &str) -> String {
    let mut collapsed = String::with_capacity(text.len());
    let mut previous_was_space = false;
    for character in text.chars() {
        if character.is_whitespace() {
            if !previous_was_space {
                collapsed.push(' ');
            }
            previous_was_space = true;
        } else {
            collapsed.push(character);
            previous_was_space = false;
        }
    }
    collapsed.trim_end().to_owned()
}

/// Truncates tool output and says so, rather than posting a silent prefix.
pub fn truncate(text: &str, max: usize) -> String {
    let points: Vec<char> = text.chars().collect();
    if points.len() <= max {
        return text.to_owned();
    }
    let kept: String = points[..max].iter().collect();
    format!(
        "{kept}\n[truncated, {} more characters]",
        points.len() - max
    )
}

/// The line announcing that a tool call started.
///
/// The command is shown whole. Its tail is often the part that says what it
/// was for, so shortening throws away the half worth reading. Only the
/// service's own message limit ever cuts anything, and that is handled where
/// blocks are sent.
///
/// Whitespace is flattened so a multi-line command stays one entry in a block,
/// and the target is wrapped in inline code, which stops the client turning
/// any URL in it into a link.
pub fn tool_line(tool_name: &str, target: Option<&str>) -> String {
    let Some(target) = target else {
        return prefixed(PrefixKey::Tool, &format!("`{tool_name}`"));
    };
    if target.trim().is_empty() {
        return prefixed(PrefixKey::Tool, &format!("`{tool_name}`"));
    }

    let flattened = collapse_spaces(target.trim());
    // Backticks inside the target would end the inline code span early.
    let flattened = flattened.replace('`', "'");
    prefixed(PrefixKey::Tool, &format!("`{tool_name}` `{flattened}`"))
}

/// What a session has cost, in one short line.
///
/// Cached input is reported as a share of everything sent, because that is the
/// number worth watching: it is what keeps a long session affordable.
pub fn usage_summary(usage: &Usage) -> String {
    let sent = usage.input + usage.cache_read;
    #[expect(clippy::cast_possible_truncation)]
    let cached = if sent == 0.0 {
        0
    } else {
        ((usage.cache_read / sent) * 100.0).round() as i64
    };

    let mut parts = vec![
        format!("{} tokens", tokens(usage.total_tokens)),
        format!("{cached}% cached"),
    ];

    // What the conversation is carrying now, as opposed to what it has spent
    // in total. The share is the useful part: it says how much room is left.
    if usage.context_tokens > 0.0 {
        parts.push(if usage.context_window <= 0.0 {
            format!("{} context", tokens(usage.context_tokens))
        } else {
            #[expect(clippy::cast_possible_truncation)]
            let share = ((usage.context_tokens / usage.context_window) * 100.0).round() as i64;
            format!(
                "{}/{} context ({share}%)",
                tokens(usage.context_tokens),
                tokens(usage.context_window),
            )
        });
    }
    if usage.cost > 0.0 {
        parts.push(format!("${:.4}", usage.cost));
    }
    parts.join(", ")
}

/// What a session has spent, as the renderer reads it.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Usage {
    /// Input tokens the turn charged uncached.
    pub input: f64,
    /// Input tokens served from the provider's cache.
    pub cache_read: f64,
    /// Everything the turn charged.
    pub total_tokens: f64,
    /// What the turn cost, when the provider prices it.
    pub cost: f64,
    /// What the conversation is carrying now.
    pub context_tokens: f64,
    /// How much the model holds before it is compacted.
    pub context_window: f64,
}

/// Suffixes, smallest first, each a thousand times the one before.
const MAGNITUDES: [(f64, &str); 3] = [(1_000_000_000.0, "B"), (1_000_000.0, "M"), (1_000.0, "k")];

/// A count as a person would read it.
///
/// Carries up rather than growing a long number: a million tokens reads as
/// `1.0M`, not `1000.0k`. Below a thousand it is left exactly as it is, since
/// rounding a small count loses the only detail it had.
pub fn tokens(count: f64) -> String {
    let size = count.abs();
    for (index, (magnitude, suffix)) in MAGNITUDES.iter().enumerate() {
        if size < *magnitude {
            continue;
        }

        let scaled = count / magnitude;
        // One decimal until three digits, then none: 12.3M, but 123M.
        let digits = usize::from(scaled.abs() < 100.0);

        #[expect(
            clippy::uninlined_format_args,
            reason = "rounding can push a value into the next magnitude: 999,999 would read as 1000k, which is a magnitude out. Carry it up instead"
        )]
        let rounded = format!("{scaled:.digits$}", digits = digits);
        if rounded
            .trim_start_matches('-')
            .parse::<f64>()
            .is_ok_and(|value| value.abs() >= 1000.0)
            && index > 0
        {
            let (bigger, bigger_suffix) = MAGNITUDES[index - 1];
            return format!("{:.1}{}", count / bigger, bigger_suffix);
        }
        #[expect(clippy::uninlined_format_args)]
        return format!("{scaled:.digits$}{suffix}", digits = digits);
    }
    format!("{count}")
}

/// A moment, rendered so the client counts down to it in the reader's own zone.
///
/// `<t:seconds:R>` is resolved by the client, so one message reads correctly
/// for everyone and keeps reading correctly as the wait shortens. Writing the
/// time out here would be wrong for anyone in another zone and stale a minute
/// later.
pub fn when_relative(epoch_ms: i64) -> String {
    format!("<t:{}:R>", epoch_ms.div_euclid(1000))
}

/// The same moment in plain text, for a surface that renders no markup.
///
/// A bot's status is one such surface: `<t:seconds:R>` arrives there verbatim
/// and reads as punctuation. This is coarse on purpose. A status is refreshed
/// on an interval rather than per second, so a minute is the smallest unit
/// that is still true by the time anybody reads it.
pub fn when_relative_plain(epoch_ms: i64, now: i64) -> String {
    #[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
    let minutes = ((epoch_ms - now) as f64 / 60_000.0).ceil() as i64;
    if minutes <= 0 {
        return "now".to_owned();
    }
    if minutes < 60 {
        return format!("in {minutes}m");
    }
    let hours = minutes / 60;
    let rest = minutes % 60;
    if rest == 0 {
        format!("in {hours}h")
    } else {
        format!("in {hours}h {rest}m")
    }
}

/// A line saying something went wrong but the session carries on.
pub fn warning_line(text: &str) -> String {
    prefixed(PrefixKey::Warning, text)
}

/// A connection or session lifecycle line.
pub fn connection_line(text: &str) -> String {
    prefixed(PrefixKey::Connection, text)
}

/// A question the agent is asking the user.
pub fn question_line(text: &str) -> String {
    prefixed(PrefixKey::Question, text)
}

/// A bracketed ASCII marker, for states with no enumerated glyph. A queue
/// position is a number, not a state, so no emoji spells it.
pub fn marker(state: &str) -> String {
    format!("[{state}]")
}

/// What a compaction achieved, or that it did not say.
///
/// The counts are the point: a compaction that freed nothing looks exactly
/// like one that freed half the window unless the numbers are shown.
pub fn compaction_line(answer: &Value) -> String {
    if answer.get("success") == Some(&Value::Bool(false)) {
        let detail = match answer.get("error").and_then(Value::as_str) {
            Some(error) => error.to_owned(),
            None => "the agent refused".to_owned(),
        };
        return connection_line(&format!("compaction did not run: {detail}"));
    }

    let data = answer.get("data").unwrap_or(&Value::Null);
    let before = data.get("tokensBefore").and_then(Value::as_f64);
    let after = data.get("estimatedTokensAfter").and_then(Value::as_f64);

    match (before, after) {
        (Some(before), Some(after)) => connection_line(&format!(
            "compacted the conversation, about {} tokens down to {}",
            tokens(before),
            tokens(after)
        )),
        _ => connection_line("compacted the conversation"),
    }
}

/// A dialog rendered for a thread, numbering options so a reply can pick one.
///
/// The reply is free text from a person, so what an answer may look like is
/// spelled out rather than assumed: a thread has no buttons to press.
pub fn dialog_lines(request: &DialogRequest) -> String {
    let mut lines = vec![request.title.clone()];
    if let Some(message) = &request.message {
        lines.push(message.clone());
    }

    match (request.method, &request.options) {
        (DialogMethod::Select, Some(options)) => {
            for (index, option) in options.iter().enumerate() {
                lines.push(format!("{}. {option}", index + 1));
            }
            lines.push("reply with a number or the option text".to_owned());
        }
        (DialogMethod::Confirm, _) => {
            lines.push("reply yes or no".to_owned());
        }
        _ => lines.push("reply with your answer".to_owned()),
    }

    lines.join("\n")
}

/// A delegation, as one line in a conversation.
///
/// Says which model was asked and about what, so a reader can tell that part
/// of a turn was answered by something other than the session's own model.
/// What it said goes to the agent rather than here: it is working material,
/// and a thread that showed every delegated answer in full would bury the
/// conversation it belongs to.
pub fn delegation_line(delegated: &Delegated) -> String {
    if let Some(refused) = &delegated.refused {
        return prefixed(
            PrefixKey::Warning,
            &format!("a delegated question was not asked: {refused}"),
        );
    }

    let saved = match delegated.kept_out {
        None | Some(0) => String::new(),
        Some(kept_out) => format!(
            ", keeping {} out of this conversation",
            bytes(widen(kept_out as u64))
        ),
    };
    prefixed(
        PrefixKey::Delegated,
        &format!(
            "asked {} about {}{saved}",
            delegated.model.as_deref().unwrap_or(""),
            delegated.describes.as_deref().unwrap_or("")
        ),
    )
}

/// Widens a whole count for the renderer, where a byte count beyond what an
/// f64 carries is beyond any real file.
#[expect(clippy::cast_precision_loss)]
fn widen(count: u64) -> f64 {
    count as f64
}

/// A byte count, in the units a person reading a thread would use.
///
/// Decimal units, because that is what a disk quota and a file manager both
/// report, and a session's budget is written the same way.
pub fn bytes(count: f64) -> String {
    if count < 1000.0 {
        return format!("{count} B");
    }
    let units = ["kB", "MB", "GB", "TB"];
    let mut value = count / 1000.0;
    let mut unit = 0;
    while value >= 1000.0 && unit < units.len() - 1 {
        value /= 1000.0;
        unit += 1;
    }
    format!("{value:.1} {}", units[unit])
}

/// A directory as one fenced block, sizes aligned in a column.
///
/// A fence is shown in a monospaced font, which is the only way the sizes line
/// up for every reader.
pub fn directory_listing(entries: &[Entry], display_path: &str) -> String {
    if entries.is_empty() {
        return format!("`{display_path}/` is empty");
    }

    let shown = &entries[..entries.len().min(MAX_LISTED_ENTRIES)];
    let rows: Vec<(String, String)> = shown
        .iter()
        .map(|entry| {
            (
                if entry.directory {
                    format!("{}/", entry.name)
                } else {
                    entry.name.clone()
                },
                if entry.directory {
                    String::new()
                } else {
                    bytes(widen(entry.size))
                },
            )
        })
        .collect();
    let width = rows
        .iter()
        .map(|(_, size)| size.chars().count())
        .max()
        .unwrap_or(0);
    let body = rows
        .iter()
        .map(|(name, size)| format!("{size:>width$}  {name}"))
        .collect::<Vec<_>>()
        .join("\n");
    let more = if entries.len() > shown.len() {
        format!("\n... {} more", entries.len() - shown.len())
    } else {
        String::new()
    };

    format!(
        "`{display_path}/` {} entries\n```\n{body}{more}\n```",
        entries.len()
    )
}

/// A file as a fenced block, with a note when it was cut or is not text.
pub fn file_view(contents: &FileContents) -> String {
    let size = bytes(widen(contents.size));
    if contents.binary {
        return format!(
            "`{}` is binary, {size}. Use `!file` to download it.",
            contents.path
        );
    }

    let cut = if contents.truncated {
        format!(
            "\n... cut at {} of {size}, use `!file` for all of it",
            bytes(widen(MAX_INLINE_BYTES))
        )
    } else {
        String::new()
    };

    format!(
        "`{path}` {size}\n```{language}\n{text}{cut}\n```",
        path = contents.path,
        size = size,
        language = contents.language,
        text = contents.text,
        cut = cut,
    )
}

#[cfg(test)]
mod tests;