mahbot 0.6.4

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
Documentation
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
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
//! `custom` — one native tool forwarding calls to admin-authored scripts in the
//! `shared/` folder of the admin's personal workspace.
//!
//! A tool is a file in that folder: one file per tool, flat (subfolders are
//! never walked), the file's name without its extension being the tool's name.
//! The file's leading comment block (`//` lines or a block comment) is the only
//! place a tool is defined — `@description …` plus one
//! `@param <name> <type> <required|optional> …` per argument. There is no
//! registry and no publishing step, and a malformed or missing header is never
//! repaired or guessed: the file is simply not described and any call to it
//! fails.
//!
//! The scripts are the product's single-file `bun` scripts, run like the
//! Assistant's own — never through a shell, so nothing in the call can be
//! reinterpreted as shell syntax. The Assistant reaches its own scripts through
//! the shell's `PATH` (where a user-installed bun can outrank the managed one);
//! a custom tool always runs the managed binary. A script receives the caller's
//! arguments as one argv entry: a JSON object of the declared parameters the
//! caller supplied, under the names it used. The payload is nested so the
//! product's own argument normalization can never rewrite it. It runs in place,
//! from the calling session's workspace root — and the Assistant is pinned to
//! its own personal workspace, so a custom call always runs in the caller's
//! `userspaces/<user>` directory.
//!
//! Availability is per user: the admin may call anything, a guest only
//! what is granted to them (`users.granted_tools`). The `<custom-tools>`
//! context block is the model-facing catalogue and follows the same split; it
//! is fixed when a session is built, so a grant change on a guest whose session
//! already exists is announced into that session ([`notify_grant_change`]),
//! carrying the granted tool's own entry so the tool is usable from the notice.

use crate::Workspace;
use crate::prompt::{load_prompt, substitute};
use crate::tools::Tool;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Map, Value, json};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

/// Folder holding the catalogue, inside the admin's personal workspace.
const SHARED_DIR: &str = "shared";

/// Extensions the managed `bun` runtime executes as a single-file script. Any
/// other extension is not a tool — not described and not callable — so a stray
/// file (a note, a module a script imports) never becomes one.
const RUNNABLE_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"];

/// How much of a tool file is read to parse its header. The header is the
/// file's leading comment block, so this is orders of magnitude more than any
/// real one, and it bounds the read of an arbitrarily large script.
const MAX_HEADER_BYTES: u64 = 16 * 1024;

// ── Header grammar ───────────────────────────────────────────────────────

/// The four argument types the shallow validation knows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParamType {
    Str,
    Integer,
    Boolean,
    List,
}

impl ParamType {
    fn parse(token: &str) -> Option<Self> {
        match token {
            "string" => Some(Self::Str),
            "integer" => Some(Self::Integer),
            "boolean" => Some(Self::Boolean),
            "list" => Some(Self::List),
            _ => None,
        }
    }

    /// Name used in the catalogue block.
    const fn as_str(self) -> &'static str {
        match self {
            Self::Str => "string",
            Self::Integer => "integer",
            Self::Boolean => "boolean",
            Self::List => "list",
        }
    }

    /// Wording for a type mismatch, for the `usage:` error.
    const fn expected(self) -> &'static str {
        match self {
            Self::Str => "a string",
            Self::Integer => "an integer",
            Self::Boolean => "a boolean",
            Self::List => "an array of strings",
        }
    }
}

/// One declared parameter.
struct Param {
    name: String,
    ty: ParamType,
    required: bool,
    /// Author-supplied prose, rendered in the catalogue block.
    description: String,
}

/// A usable custom tool: its parsed header plus the file that defines it.
struct CustomToolEntry {
    name: String,
    description: String,
    params: Vec<Param>,
    path: PathBuf,
}

/// Split `line` into its first `n` whitespace-separated words plus the rest of
/// the line (`""` when nothing follows them).
fn split_words(line: &str, n: usize) -> (Vec<&str>, &str) {
    let mut words = Vec::with_capacity(n);
    let mut rest = line;
    for _ in 0..n {
        let trimmed = rest.trim_start();
        if trimmed.is_empty() {
            rest = "";
            break;
        }
        let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
        words.push(&trimmed[..end]);
        rest = &trimmed[end..];
    }
    (words, rest.trim())
}

/// The comment openers a runnable single-file script really uses: `//`, `/*`,
/// a block-comment `*` (which also covers `*/` and `**`), and a `#!` shebang.
/// Nothing else starts a comment, so any other line ends the header block
/// instead of being folded into it.
const COMMENT_OPENERS: &[&str] = &["//", "/*", "*", "#!"];

/// The text of a comment-only line with its comment decoration stripped, or
/// `None` for a line that carries code (which ends the header block). A block
/// comment decorates both ends (`/** @description … */`), so its trailing `*/`
/// goes too — otherwise it would land in the catalogue prose. A `//` line has
/// no closer, so everything after the marker is text.
fn comment_body(line: &str) -> Option<&str> {
    let (opener, rest) = COMMENT_OPENERS
        .iter()
        .find_map(|opener| line.strip_prefix(opener).map(|rest| (*opener, rest)))?;
    let rest = if opener == "//" {
        rest
    } else {
        rest.strip_suffix("*/").unwrap_or(rest)
    };
    Some(rest.trim_start_matches('*').trim())
}

/// Parse a script's leading comment block into `(description, params)`.
/// `None` when the header is missing or malformed.
///
/// An unknown `@`-directive is malformed rather than ignored: the header *is*
/// the tool's interface, so a misspelled `@param` (`@parm`) that was silently
/// skipped would drop a declared argument, and the caller's value for it would
/// come back as an ignored argument — a quietly wrong tool. The author instead
/// sees the call fail loudly and fixes the header.
fn parse_header(source: &str) -> Option<(String, Vec<Param>)> {
    let mut description: Option<String> = None;
    let mut params: Vec<Param> = Vec::new();

    for line in source.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let Some(body) = comment_body(line) else {
            // The first line carrying code ends the header block.
            break;
        };
        let Some(directive) = body.strip_prefix('@') else {
            continue;
        };
        let (words, text) = split_words(directive, 1);
        let [keyword] = words[..] else {
            return None;
        };
        match keyword {
            "description" => {
                // A header declares the tool's description once, like each
                // parameter of it.
                if text.is_empty() || description.is_some() {
                    return None;
                }
                description = Some(text.to_string());
            }
            "param" => {
                let (words, text) = split_words(text, 3);
                let [name, ty, required] = words[..] else {
                    return None;
                };
                let ty = ParamType::parse(ty)?;
                let required = match required {
                    "required" => true,
                    "optional" => false,
                    _ => return None,
                };
                if params.iter().any(|p| p.name == name) {
                    return None;
                }
                params.push(Param {
                    name: name.to_string(),
                    ty,
                    required,
                    description: text.to_string(),
                });
            }
            _ => return None,
        }
    }

    Some((description?, params))
}

/// Whether `name` is a usable tool name: a plain file name, so no call can name
/// a path, traverse out of the folder, or address a dot-file. Discovery, the
/// call path and the grant action share this one predicate, so no surface can
/// hold or advertise a name another would refuse.
pub(crate) fn is_tool_name(name: &str) -> bool {
    !name.is_empty() && !name.starts_with('.') && !name.contains(['/', '\\'])
}

// ── Discovery ────────────────────────────────────────────────────────────

/// The catalogue folder: `<admin's personal workspace>/shared`.
fn shared_dir() -> PathBuf {
    crate::users::personal_workspace_path(crate::users::ADMIN_USER_NAME).join(SHARED_DIR)
}

/// Whether `bun` can execute `path` as a single-file script.
fn is_runnable(path: &Path) -> bool {
    path.extension()
        .and_then(|e| e.to_str())
        .is_some_and(|ext| {
            RUNNABLE_EXTENSIONS
                .iter()
                .any(|k| ext.eq_ignore_ascii_case(k))
        })
}

/// Read just enough of a tool file to parse its header.
fn read_header_source(path: &Path) -> std::io::Result<String> {
    use std::io::Read as _;
    let mut buf = Vec::new();
    std::fs::File::open(path)?
        .take(MAX_HEADER_BYTES)
        .read_to_end(&mut buf)?;
    Ok(String::from_utf8_lossy(&buf).into_owned())
}

/// Load the catalogue: every usable tool in the folder, ordered by name
/// (byte-wise — the same deterministic ordering the product's other
/// file-defined descriptions use). Files with a non-runnable extension, a
/// malformed header, or a name another file already took are skipped.
fn load_catalogue(dir: &Path) -> Vec<CustomToolEntry> {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut files: Vec<PathBuf> = entries
        .flatten()
        // `DirEntry::file_type` does not follow symlinks: only regular files in
        // the folder itself are candidates.
        .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
        .map(|e| e.path())
        .filter(|p| is_runnable(p))
        .collect();
    // Sort by path so a same-stem collision (`weather.ts` vs `weather.js`)
    // resolves the same way on every read: the first *usable* file wins, so a
    // malformed `.js` leaves the tool to a later `.ts` rather than erasing it.
    files.sort();

    let mut tools: Vec<CustomToolEntry> = Vec::new();
    for path in files {
        let Some(name) = path
            .file_stem()
            .and_then(|s| s.to_str())
            .map(str::to_string)
        else {
            continue;
        };
        if !is_tool_name(&name) || tools.iter().any(|t| t.name == name) {
            continue;
        }
        let Ok(source) = read_header_source(&path) else {
            continue;
        };
        let Some((description, params)) = parse_header(&source) else {
            continue;
        };
        tools.push(CustomToolEntry {
            name,
            description,
            params,
            path,
        });
    }
    tools.sort_by(|a, b| a.name.cmp(&b.name));
    tools
}

/// Load the catalogue off the async runtime (it reads the folder).
async fn catalogue() -> Vec<CustomToolEntry> {
    tokio::task::spawn_blocking(|| load_catalogue(&shared_dir()))
        .await
        .unwrap_or_default()
}

// ── Catalogue block ──────────────────────────────────────────────────────

/// One tool's entry: its description, then its parameters narrated in prose
/// (name, basic type, required-ness). The admin-authored text is
/// credential-scrubbed on the way into the prompt, like the product's other
/// user-provided text that enters one.
///
/// The `<custom-tools>` block and a grant notice both render a tool through
/// this, so the entry an Assistant reads in the notice is the entry its list
/// shows, character for character.
fn render_line(tool: &CustomToolEntry) -> String {
    let mut out = String::new();
    let _ = write!(
        out,
        "- {}: {}",
        tool.name,
        crate::util::scrub_credentials(&tool.description)
    );
    if !tool.params.is_empty() {
        out.push_str(" Parameters: ");
        for (i, param) in tool.params.iter().enumerate() {
            if i > 0 {
                out.push_str("; ");
            }
            let _ = write!(
                out,
                "{} ({}, {})",
                param.name,
                param.ty.as_str(),
                if param.required {
                    "required"
                } else {
                    "optional"
                }
            );
            if !param.description.is_empty() {
                let _ = write!(
                    out,
                    " — {}",
                    crate::util::scrub_credentials(&param.description)
                );
            }
        }
        out.push('.');
    }
    out
}

/// The block's listing: one entry line per tool ([`render_line`]).
fn render_lines(tools: &[&CustomToolEntry]) -> String {
    let mut out = String::new();
    for tool in tools {
        out.push_str(&render_line(tool));
        out.push('\n');
    }
    out.trim_end().to_string()
}

/// The `<custom-tools>` context block for an Assistant session.
///
/// Unlike the other assistant blocks this one is emitted even when there is
/// nothing to list — a guest with no grants (and the admin's Assistant with no
/// tools yet) still learns the feature exists.
pub(crate) async fn context_block(user_name: &str, is_admin: bool) -> String {
    let granted = if is_admin {
        Vec::new()
    } else {
        crate::users::granted_tools(user_name).await
    };
    // A caller with no grants can only receive the no-grants form, so the
    // folder is not read for them at all.
    let catalogue = if is_admin || !granted.is_empty() {
        catalogue().await
    } else {
        Vec::new()
    };
    block_for(&catalogue, &granted, is_admin)
}

/// Render the block for a loaded catalogue: every tool for the admin, only the
/// granted ones for a guest — the same split the call itself enforces. A grant
/// that matches no usable file contributes nothing, and an empty listing falls
/// back to the matching brief form.
fn block_for(catalogue: &[CustomToolEntry], granted: &[String], is_admin: bool) -> String {
    let tools: Vec<&CustomToolEntry> = catalogue
        .iter()
        .filter(|t| is_admin || granted.iter().any(|g| g == &t.name))
        .collect();
    if tools.is_empty() {
        return load_prompt(if is_admin {
            "context/custom_tools_none.md"
        } else {
            "context/custom_tools_no_grants.md"
        });
    }
    substitute(
        &load_prompt("context/custom_tools.md"),
        &[("{{tools}}", &render_lines(&tools))],
    )
}

/// Wake `user_name`'s Assistant with a notice that one of its custom-tool
/// grants changed: a tool was granted to, or revoked from, the account. The
/// admin-side `grant_tool` / `revoke_tool` action is otherwise invisible to the
/// account it applies to — the `<custom-tools>` block is fixed when the session
/// is built.
///
/// A grant notice also carries the granted tool's own entry, rendered by the
/// same [`render_line`] the block lists — the account's list cannot name a tool
/// granted after the session was built, so the notice is where the tool becomes
/// usable. A removal says only that the tool is gone: its interface is of no
/// use to an account that may no longer call it.
///
/// Nothing is announced to the admin's own Assistant (it holds every tool, so
/// a grant on that account changes nothing for it) nor to an account whose
/// Assistant has no session yet — its first session is built with the current
/// list, so there is nothing to correct.
pub(crate) async fn notify_grant_change(user_name: &str, tool: &str, granted: bool) {
    if crate::users::is_admin_name(user_name) {
        return;
    }
    let agent_id = crate::session::resolve_agent_id(
        user_name,
        crate::Role::Assistant.as_str(),
        &crate::users::personal_workspace_name(user_name),
    );
    // An emptied session (a cleared or truncated one) behaves like a first one
    // — its next turn rebuilds the whole system prompt — so it has nothing to
    // correct either.
    if !crate::session::store().has_content(&agent_id).await {
        return;
    }
    let content = if granted {
        // The catalogue's tool of that exact name, never the account's grants:
        // by now the grant is recorded and the account's own list is stale. A
        // name with no readable definition behind it — no such tool, or an
        // unreadable file — contributes nothing, and the notice then reads
        // exactly as it always has. The entry is frozen into the durable
        // envelope here, so a replay shows the same text.
        let entry = catalogue()
            .await
            .iter()
            .find(|t| t.name == tool)
            .map(|t| format!("\n{}", render_line(t)))
            .unwrap_or_default();
        substitute(
            &load_prompt("custom_tools_granted.md"),
            &[("{{tools}}", tool), ("{{entry}}", &entry)],
        )
    } else {
        substitute(
            &load_prompt("custom_tools_revoked.md"),
            &[("{{tools}}", tool)],
        )
    };
    if let Err(e) =
        crate::agent::message_router::deliver_assistant_notice(&agent_id, user_name, content).await
    {
        tracing::warn!(
            user = %user_name,
            error = %e,
            "Failed to persist the grant notice — routing best-effort"
        );
    }
}

// ── Call path ────────────────────────────────────────────────────────────

/// A resolved custom-tool call: the script to run and the arguments it receives.
pub(crate) struct ResolvedCall {
    /// The tool's file.
    pub path: PathBuf,
    /// The declared arguments the caller supplied, coerced to their types.
    pub args: Map<String, Value>,
    /// The supplied argument names the tool does not declare. The call path
    /// reports them back with the run's output; a strict caller has none,
    /// because it refuses them instead.
    pub ignored: Vec<String>,
}

impl ResolvedCall {
    /// The JSON object the script receives as its single argv entry.
    pub(crate) fn payload(&self) -> String {
        serde_json::to_string(&self.args).expect("a custom tool's arguments are serializable")
    }
}

/// Why a custom-tool call could not be resolved.
///
/// The call path renders these as its `forbidden:` / `not-found:` / `usage:`
/// refusals; an alarm's arming and firing paths turn them into their own
/// wording, which is why the reason is a value rather than a formatted error.
pub(crate) enum CallRefusal {
    /// The name is not a callable tool name at all (a path, a dot-file, empty).
    Name { name: String },
    /// The caller may not call this name: not the admin and not granted it.
    Unavailable { name: String },
    /// No usable tool of that name: no such file, or a header the catalogue
    /// refuses.
    NotUsable { name: String },
    /// The arguments do not fit the tool's declared interface.
    Arguments(anyhow::Error),
}

impl CallRefusal {
    /// The refusal as a normal call reports it to the model.
    pub(crate) fn into_error(self) -> anyhow::Error {
        match self {
            Self::Name { name } => anyhow::anyhow!(
                "forbidden: \"{name}\" is not a tool name — hint: a tool's name is the file \
                 name without its extension"
            ),
            Self::Unavailable { name } => anyhow::anyhow!(
                "forbidden: custom tool \"{name}\" is not granted to you — hint: only tools \
                 granted to your account can be called"
            ),
            Self::NotUsable { name } => anyhow::anyhow!(
                "not-found: custom tool \"{name}\" is not usable — hint: a tool is a \
                 `{name}.ts` (or .js/.tsx/…) script in the admin's `shared` folder whose \
                 leading comment block declares a `@description` and one `@param` per \
                 argument"
            ),
            Self::Arguments(e) => e,
        }
    }
}

/// Refuse a name that is not a callable tool name at all (see [`is_tool_name`]).
///
/// A caller that must settle the name before anything else about the call — the
/// normal call path, which reads the argument object next — calls this itself,
/// as [`resolve_tool_call`] does first for every path.
fn check_name(name: &str) -> Result<(), CallRefusal> {
    if is_tool_name(name) {
        Ok(())
    } else {
        Err(CallRefusal::Name {
            name: name.to_string(),
        })
    }
}

/// Resolve `name` for `caller` and validate `supplied` against the tool's
/// declared interface, returning the script to run and the arguments it gets.
///
/// Availability is settled before the catalogue is read — exactly as a normal
/// call already does it — so no path that resolves a tool can be used to learn
/// what exists. `strict` additionally refuses arguments the tool does not
/// declare; the default (a normal call) ignores them and reports them back.
pub(crate) async fn resolve_tool_call(
    caller: &str,
    name: &str,
    supplied: &Map<String, Value>,
    strict: bool,
) -> Result<ResolvedCall, CallRefusal> {
    check_name(name)?;
    // The admin may call anything; a guest only what is granted to them. The
    // refusal comes before the catalogue is read so it cannot probe which tools
    // exist — nor leak anything about their declared parameters.
    if !crate::users::is_admin(caller).await
        && !crate::users::granted_tools(caller)
            .await
            .iter()
            .any(|granted| granted == name)
    {
        return Err(CallRefusal::Unavailable {
            name: name.to_string(),
        });
    }
    let Some(tool) = catalogue().await.into_iter().find(|t| t.name == name) else {
        return Err(CallRefusal::NotUsable {
            name: name.to_string(),
        });
    };

    let ignored = ignored_arguments(&tool, supplied);
    if strict && !ignored.is_empty() {
        // The note rides the refusal, exactly as it rides a refused run: the
        // assistant is shown which arguments were ignored.
        return Err(CallRefusal::Arguments(report_ignored_failure(
            anyhow::anyhow!(
                "usage: custom tool \"{name}\" was given arguments it does not declare — \
                 hint: a trigger passes only the arguments the tool declares"
            ),
            &ignored,
        )));
    }
    let args = match check_arguments(&tool, supplied) {
        Ok(args) => args,
        Err(e) => return Err(CallRefusal::Arguments(report_ignored_failure(e, &ignored))),
    };
    Ok(ResolvedCall {
        path: tool.path,
        args,
        ignored,
    })
}

/// A JSON number that denotes an integer — `3` and `3.0` alike. The declared
/// type is a 64-bit integer, so a magnitude beyond `i64` does not denote one
/// and is reported as a type mismatch like any other wrong value.
#[expect(clippy::cast_possible_truncation)] // the guard keeps the cast exact
fn integer(value: &Value) -> Option<i64> {
    if let Some(n) = value.as_i64() {
        return Some(n);
    }
    let float = value.as_f64()?;
    // `2^53` is where an f64 stops representing integers exactly, so the cast
    // below is exact for every value that reaches it.
    let integral = float.fract() == 0.0 && float.abs() <= 9_007_199_254_740_992.0;
    integral.then_some(float as i64)
}

/// Coerce one supplied value to its parameter's declared basic type.
fn checked(param: &Param, value: &Value) -> anyhow::Result<Value> {
    let mismatch = || super::wrong_type(&param.name, param.ty.expected(), value);
    match param.ty {
        ParamType::Str => value
            .as_str()
            .map(str::to_string)
            .map(Value::from)
            .ok_or_else(mismatch),
        ParamType::Integer => integer(value).map(Value::from).ok_or_else(mismatch),
        ParamType::Boolean => value.as_bool().map(Value::from).ok_or_else(mismatch),
        ParamType::List => {
            let items = value.as_array().ok_or_else(mismatch)?;
            let mut out = Vec::with_capacity(items.len());
            for item in items {
                match item.as_str() {
                    Some(item) => out.push(Value::from(item.to_string())),
                    None => anyhow::bail!(
                        "usage: argument \"{}\" must be an array of strings, got a non-string \
                         element — hint: pass a JSON array of strings, e.g. {}: [\"a\", \"b\"]",
                        param.name,
                        param.name
                    ),
                }
            }
            Ok(Value::Array(out))
        }
    }
}

/// The whole of the argument contract: the declared parameters are checked
/// shallowly — string, integer, boolean and list-of-strings values, required
/// ones present — and anything deeper is the script's own job.
///
/// Returns the payload the script receives: only the declared parameters the
/// caller supplied, under the names it used. An explicit `null` counts as
/// omitted — the usual way a model says "no value for this one" — so it is
/// neither forwarded nor a type mismatch. The unrecognised names come from
/// [`ignored_arguments`], which the caller reports back alongside this.
fn check_arguments(
    tool: &CustomToolEntry,
    supplied: &Map<String, Value>,
) -> anyhow::Result<Map<String, Value>> {
    let mut payload = Map::new();
    for param in &tool.params {
        match supplied.get(&param.name) {
            None | Some(Value::Null) => {
                if param.required {
                    anyhow::bail!(
                        "usage: argument \"{}\" is required by \"{}\" — hint: pass it in the \
                         `args` object of the call",
                        param.name,
                        tool.name
                    );
                }
            }
            Some(value) => {
                payload.insert(param.name.clone(), checked(param, value)?);
            }
        }
    }
    Ok(payload)
}

/// The supplied argument names the tool does not declare: ignored and reported
/// back to the caller, never fatal.
fn ignored_arguments(tool: &CustomToolEntry, supplied: &Map<String, Value>) -> Vec<String> {
    let mut ignored: Vec<String> = supplied
        .keys()
        .filter(|key| !tool.params.iter().any(|p| &p.name == *key))
        .cloned()
        .collect();
    ignored.sort();
    ignored
}

/// The single native tool that forwards a call to one admin-authored script.
/// Available to every Assistant (the admin's and a guest's alike); the grant
/// decides what each call may reach.
pub(crate) struct CustomTool;

#[async_trait]
impl Tool for CustomTool {
    fn name(&self) -> &'static str {
        "custom"
    }

    fn parameters_schema(&self) -> Value {
        super::tool_params_schema(
            &json!({
                "tool": {
                    "type": "string",
                    "description": "Name of the custom tool to call — one of the names listed in the <custom-tools> block."
                },
                "args": {
                    "type": "object",
                    "description": "The tool's arguments, keyed by parameter name as declared in the <custom-tools> block. Values are validated against the declared types; unknown keys are ignored and reported back."
                }
            }),
            &["tool"],
        )
    }

    async fn execute(&self, ws: &Workspace, args: Value) -> Result<String> {
        // The caller is resolved from the live call and fails closed: an
        // unresolvable identity is refused outright, never treated as the
        // admin.
        let caller = crate::agent::tool_user_name();
        if caller.trim().is_empty() {
            anyhow::bail!(
                "forbidden: this call has no acting user — hint: custom tools are only \
                 callable from an Assistant session"
            );
        }

        let name = super::get_str(&args, "tool")?;
        // The call's identity is settled before its arguments, as it was before
        // the resolver below was extracted: a bad name outranks a malformed
        // `args`.
        check_name(name).map_err(CallRefusal::into_error)?;
        let supplied = super::get_object(&args, "args")?;
        // A normal call is not strict: an argument the tool does not declare is
        // ignored and reported back with the run rather than refused.
        let call = resolve_tool_call(&caller, name, &supplied, false)
            .await
            .map_err(CallRefusal::into_error)?;

        let Some(bun) = crate::tools::bun::bun_binary_path() else {
            return Err(report_ignored_failure(
                super::internal_fault("the managed bun runtime is unavailable"),
                &call.ignored,
            ));
        };
        let run = crate::tools::shell::run_program_with_timeout(
            ws,
            &bun,
            &[call.path.display().to_string(), call.payload()],
            &format!("custom tool \"{name}\""),
        )
        .await;
        match run {
            Ok(output) => Ok(report_ignored(&output, &call.ignored)),
            Err(e) => Err(report_ignored_failure(e, &call.ignored)),
        }
    }
}

/// Append the unrecognised-argument note to a run's output or a failure's
/// reason — the note rides whatever the caller is about to see.
fn report_ignored(text: &str, ignored: &[String]) -> String {
    if ignored.is_empty() {
        return text.to_string();
    }
    crate::tools::shell::with_note(
        text,
        &format!("[ignored arguments: {}]", ignored.join(", ")),
    )
}

/// A failure the caller sees, carrying the note when there is something
/// unrecognised to report. An empty note leaves the error exactly as it was.
///
/// The note is appended to the message because every producer on this path is
/// message-only, while anyhow's `.context` would render it before the reason it
/// annotates.
fn report_ignored_failure(e: anyhow::Error, ignored: &[String]) -> anyhow::Error {
    if ignored.is_empty() {
        return e;
    }
    anyhow::Error::msg(report_ignored(&e.to_string(), ignored))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::util::test::ProbeFile;

    #[test]
    fn header_parses_comments_and_all_four_types() {
        let source = "#!/usr/bin/env bun\n\
                      // @description Fetches the weather for a city.\n\
                      // @param city string required the city to look up\n\
                      // @param days integer optional forecast days\n\
                      /* @param metric boolean required use metric units */\n\
                      /**\n\
                       * @param tags list optional labels\n\
                       */\n\
                      \n\
                      const city = process.argv[2];\n\
                      // @param late string optional after the code\n";
        let (description, params) = parse_header(source).expect("valid header");
        assert_eq!(description, "Fetches the weather for a city.");
        // The last line sits past the first line of code, so it is not part of
        // the header block at all.
        let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
        assert_eq!(names, ["city", "days", "metric", "tags"]);
        assert_eq!(params[0].ty, ParamType::Str);
        assert!(params[0].required);
        assert_eq!(params[0].description, "the city to look up");
        assert_eq!(params[1].ty, ParamType::Integer);
        assert!(!params[1].required);
        assert_eq!(params[2].ty, ParamType::Boolean);
        // A block comment's closing punctuation is not part of its prose.
        assert_eq!(params[2].description, "use metric units");
        assert_eq!(params[3].ty, ParamType::List);
        assert_eq!(params[3].description, "labels");
    }

    #[test]
    fn malformed_headers_are_rejected() {
        let cases: [&str; 8] = [
            // no description at all
            "// @param city string required\n",
            // no header at all
            "const x = 1;\n",
            // empty description
            "// @description\n",
            // a second description
            "// @description x\n// @description y\n",
            // a misspelled directive is a typo, not a silent no-op
            "// @description x\n// @parm city string required\n",
            // unknown type
            "// @description x\n// @param city float required\n",
            // missing required/optional
            "// @description x\n// @param city string\n",
            // duplicate parameter
            "// @description x\n// @param c string required\n// @param c string optional\n",
        ];
        for case in cases {
            assert!(parse_header(case).is_none(), "must reject: {case:?}");
        }
    }

    #[test]
    fn catalogue_lines_narrate_parameters() {
        let tools = [
            CustomToolEntry {
                name: "weather".to_string(),
                description: "Fetches the weather.".to_string(),
                params: vec![
                    Param {
                        name: "city".to_string(),
                        ty: ParamType::Str,
                        required: true,
                        description: "the city to look up".to_string(),
                    },
                    Param {
                        name: "days".to_string(),
                        ty: ParamType::Integer,
                        required: false,
                        description: String::new(),
                    },
                ],
                path: PathBuf::from("weather.ts"),
            },
            CustomToolEntry {
                name: "disk".to_string(),
                description: "Reports disk usage.".to_string(),
                params: Vec::new(),
                path: PathBuf::from("disk.js"),
            },
        ];
        let refs: Vec<&CustomToolEntry> = tools.iter().collect();
        assert_eq!(
            render_lines(&refs),
            "- weather: Fetches the weather. Parameters: city (string, required) — the city to \
             look up; days (integer, optional).\n- disk: Reports disk usage."
        );
    }

    #[test]
    fn catalogue_skips_unusable_files() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let write = |name: &str, body: &str| {
            std::fs::write(tmp.path().join(name), body).expect("write");
        };
        write(
            "weather.ts",
            "// @description Weather.\n// @param city string required city\n",
        );
        write("helper.js", "// @description Helper.\n");
        // Not runnable by the runtime.
        write("notes.txt", "// @description Notes.\n");
        write("script.py", "// @description Python.\n");
        // A dot-file is not a tool: discovery and the call path share one name
        // predicate, so the block never advertises a name a call would refuse.
        write(".hidden.ts", "// @description Hidden.\n");
        // Malformed header.
        write("broken.ts", "// @param city string required\n");
        // Same stem as weather.ts — the first file by path wins, so the
        // collision is not order-dependent.
        write("weather.js", "// @description Other weather.\n");
        std::fs::create_dir(tmp.path().join("nested")).expect("mkdir");
        std::fs::write(
            tmp.path().join("nested/hidden.ts"),
            "// @description Nested.\n",
        )
        .expect("write");

        let tools = load_catalogue(tmp.path());
        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
        assert_eq!(names, ["helper", "weather"]);
        let weather = tools.iter().find(|t| t.name == "weather").expect("weather");
        assert_eq!(weather.description, "Other weather.");
        assert!(weather.path.ends_with("weather.js"));
    }

    /// The block's listing split is what keeps a guest from being told about
    /// tools it cannot call: the whole catalogue for the admin, the
    /// granted subset otherwise, and the matching brief form when nothing is
    /// left.
    #[test]
    fn block_lists_the_whole_catalogue_or_the_granted_subset() {
        let entry = |name: &str| CustomToolEntry {
            name: name.to_string(),
            description: format!("The {name} tool."),
            params: Vec::new(),
            path: PathBuf::from(format!("{name}.ts")),
        };
        let catalogue = [entry("alpha"), entry("beta")];

        let admin = block_for(&catalogue, &[], true);
        assert!(
            admin.contains("alpha") && admin.contains("beta"),
            "got: {admin}"
        );

        let granted = block_for(&catalogue, &["beta".to_string()], false);
        assert!(
            granted.contains("beta") && !granted.contains("alpha"),
            "got: {granted}"
        );

        // A grant whose file is gone contributes nothing.
        assert_eq!(
            block_for(&catalogue, &["gone".to_string()], false),
            load_prompt("context/custom_tools_no_grants.md")
        );
        assert_eq!(
            block_for(&[], &[], true),
            load_prompt("context/custom_tools_none.md")
        );
    }

    /// The access gate and the argument contract, end to end: an unresolvable
    /// identity is refused (never treated as the admin), grants are checked
    /// before the catalogue is read, only plain file names are accepted, and the
    /// unrecognised-argument note rides a refusal as well as a run.
    #[tokio::test]
    async fn call_gate_and_argument_contract() {
        crate::util::test::init_management_test_stores().await;
        let ws = crate::workspace::test_ws("/tmp/custom_tool_gate");
        let tool = CustomTool;
        let call = |args: Value| tool.execute(&ws, args);
        let as_user = |user: &str, args: Value| {
            crate::agent::CURRENT_TOOL_USER_NAME.scope(user.to_string(), call(args))
        };

        // No acting user: refused, not resolved to the admin.
        let err = call(json!({ "tool": "ghost" }))
            .await
            .unwrap_err()
            .to_string();
        assert!(err.starts_with("forbidden:"), "got: {err}");

        // An ungranted caller is refused before the tool's existence is
        // consulted, so the refusal is not an existence oracle. The name is
        // unique to this test: every test in the process shares one users
        // store, so a common name could carry another test's grants.
        let err = as_user("custom_gate_guest", json!({ "tool": "ghost" }))
            .await
            .unwrap_err()
            .to_string();
        assert!(err.contains("is not granted to you"), "got: {err}");

        // The admin bypasses the grant check and reaches existence.
        let err = as_user("admin", json!({ "tool": "ghost" }))
            .await
            .unwrap_err()
            .to_string();
        assert!(err.starts_with("not-found:"), "got: {err}");

        // Only a plain file name is a tool name, so no call can address a file
        // outside the folder.
        for bad in ["../probe", ".hidden", "a/b", "a\\b", ""] {
            let err = as_user("admin", json!({ "tool": bad }))
                .await
                .unwrap_err()
                .to_string();
            assert!(err.starts_with("forbidden:"), "{bad:?} got: {err}");
        }

        // Author a usable tool in the admin's own folder (the test root's), then
        // refuse a call to it: the declared interface decides what counts as
        // unrecognised, and the note rides the refusal.
        let dir = shared_dir();
        std::fs::create_dir_all(&dir).expect("create the shared folder");
        let probe = ProbeFile(dir.join("probe.ts"));
        std::fs::write(
            &probe.0,
            "// @description Probe.\n// @param city string required the city\n",
        )
        .expect("write the probe tool");
        let err = as_user("admin", json!({ "tool": "probe", "args": { "extra": 1 } }))
            .await
            .unwrap_err()
            .to_string();
        assert!(err.starts_with("usage: "), "got: {err}");
        assert!(err.contains("[ignored arguments: extra]"), "got: {err}");
    }

    /// The one difference a strict caller (an alarm) has: an argument the tool
    /// does not declare is refused rather than ignored, and the availability
    /// gate is settled before anything is looked up either way.
    #[tokio::test]
    async fn strict_calls_refuse_undeclared_arguments() {
        crate::util::test::init_management_test_stores().await;
        let dir = shared_dir();
        std::fs::create_dir_all(&dir).expect("create the shared folder");
        let probe = ProbeFile(dir.join("strict_probe.ts"));
        std::fs::write(
            &probe.0,
            "// @description Strict probe.\n// @param city string required the city\n",
        )
        .expect("write the probe tool");
        let supplied = json!({ "city": "Minsk", "extra": 1 });
        let supplied = supplied.as_object().unwrap();

        let Err(CallRefusal::Arguments(e)) =
            resolve_tool_call("admin", "strict_probe", supplied, true).await
        else {
            panic!("a strict call must refuse an undeclared argument");
        };
        assert!(e.to_string().starts_with("usage: "), "got: {e}");

        // The same arguments are a normal call's business as usual: they
        // resolve, and the unrecognised one comes back for the caller to see.
        let resolved = resolve_tool_call("admin", "strict_probe", supplied, false)
            .await
            .unwrap_or_else(|_| panic!("a normal call ignores an undeclared argument"));
        assert_eq!(resolved.ignored, ["extra"]);
        assert_eq!(resolved.args["city"], json!("Minsk"));

        // A guest without the grant is refused whatever the name resolves to —
        // here to nothing at all — so no path that arms an alarm can probe the
        // catalogue.
        let unavailable = resolve_tool_call("strict_probe_guest", "ghost", supplied, true).await;
        assert!(
            matches!(unavailable, Err(CallRefusal::Unavailable { .. })),
            "an ungranted caller must be refused before the tool is looked up"
        );
    }

    #[test]
    fn arguments_are_checked_shallowly() {
        let tool = CustomToolEntry {
            name: "weather".to_string(),
            description: String::new(),
            params: vec![
                Param {
                    name: "city".to_string(),
                    ty: ParamType::Str,
                    required: true,
                    description: String::new(),
                },
                Param {
                    name: "days".to_string(),
                    ty: ParamType::Integer,
                    required: false,
                    description: String::new(),
                },
                Param {
                    name: "tags".to_string(),
                    ty: ParamType::List,
                    required: false,
                    description: String::new(),
                },
            ],
            path: PathBuf::from("weather.ts"),
        };

        // Only declared parameters reach the script, under the names supplied.
        let supplied = json!({"city": "Minsk", "days": 3.0, "tags": ["a", "b"], "extra": true});
        let supplied = supplied.as_object().unwrap();
        let payload = check_arguments(&tool, supplied).expect("valid arguments");
        assert_eq!(payload["city"], json!("Minsk"));
        assert_eq!(payload["days"], json!(3));
        assert_eq!(payload["tags"], json!(["a", "b"]));
        assert_eq!(ignored_arguments(&tool, supplied), ["extra"]);

        // A missing required parameter, a wrong type and a bad list element are
        // refused with the product's `usage:` shape.
        for supplied in [
            json!({}),
            json!({"city": 5}),
            json!({"city": "x", "tags": [1]}),
        ] {
            let err =
                check_arguments(&tool, supplied.as_object().unwrap()).expect_err("must refuse");
            assert!(err.to_string().starts_with("usage: "), "{err}");
        }
    }

    /// A real grant change wakes the guest's existing Assistant session with a
    /// durable notice naming the tool and the direction; the admin is never
    /// notified and an account without an Assistant session is left alone.
    #[tokio::test]
    async fn grant_change_notice_wakes_only_an_existing_guest_session() {
        // A name no test authors a file for, so the catalogue holds no readable
        // definition for it.
        const ABSENT: &str = "notice_absent_tool";
        crate::util::test::init_management_test_stores().await;
        let store = crate::users::store();
        let guest = "grant_notice_guest";
        store.add_user(guest).await.unwrap();
        let guest_id = crate::session::resolve_agent_id(
            guest,
            crate::Role::Assistant.as_str(),
            &crate::users::personal_workspace_name(guest),
        );
        // A registered receiver captures the routed job deterministically — no
        // consumer loop (and no agent run) is spawned.
        let mut rx = crate::agent::message_router::register_agent(&guest_id);
        crate::util::test::seed_session_row(
            &crate::session::store().conn,
            &guest_id,
            "user",
            "hello",
        )
        .await;

        notify_grant_change(guest, ABSENT, true).await;
        let job = rx.try_recv().expect("an existing session is woken");
        // With no definition to render there is no entry: the notice keeps the
        // exact text it has always had — no empty slot, no blank line — and says
        // nothing about the tool's health.
        assert_eq!(
            job.content,
            format!(
                "<custom-tools-notice>\n\
                 Custom tools granted to your account: {ABSENT}\n\
                 </custom-tools-notice>\n"
            )
        );
        assert_eq!(
            job.kind,
            crate::agent::message_router::MessageKind::UserMessage
        );
        assert_eq!(job.role, crate::Role::Assistant);
        assert!(job.pending_job_id.is_some(), "the notice must be durable");

        notify_grant_change(guest, ABSENT, false).await;
        let job = rx.try_recv().expect("a revocation is announced too");
        assert_eq!(
            job.content,
            format!(
                "<custom-tools-notice>\n\
                 Custom tools removed from your account: {ABSENT}\n\
                 </custom-tools-notice>\n"
            )
        );

        // The admin holds every tool, so its own account is never a recipient —
        // not even with a live session. Asserted on the durable rows rather than
        // a registered receiver: registering the production admin id would take
        // over the shared router's entry for it and swallow other tests' jobs.
        let admin_id = crate::session::resolve_agent_id(
            crate::users::ADMIN_USER_NAME,
            crate::Role::Assistant.as_str(),
            &crate::users::personal_workspace_name(crate::users::ADMIN_USER_NAME),
        );
        crate::util::test::seed_session_row(
            &crate::session::store().conn,
            &admin_id,
            "user",
            "hello",
        )
        .await;
        notify_grant_change(crate::users::ADMIN_USER_NAME, ABSENT, true).await;
        assert!(
            !has_tool_notice(&admin_id).await,
            "the admin already holds every custom tool"
        );

        // No session yet → nothing to announce: the first one carries the list.
        let fresh = "grant_notice_no_session";
        store.add_user(fresh).await.unwrap();
        let fresh_id = crate::session::resolve_agent_id(
            fresh,
            crate::Role::Assistant.as_str(),
            &crate::users::personal_workspace_name(fresh),
        );
        notify_grant_change(fresh, ABSENT, true).await;
        assert!(!has_tool_notice(&fresh_id).await, "no session to wake");

        crate::agent::message_router::unregister_agent(&guest_id);
        clear_pending(&guest_id).await;
    }

    /// A grant notice carries the granted tool's own entry — the very line the
    /// `<custom-tools>` block renders for it, scrubbing included — so the tool
    /// is usable straight from the notice. A removal carries the name and the
    /// direction and no entry.
    #[tokio::test]
    async fn grant_notice_carries_the_tools_own_entry() {
        crate::util::test::init_management_test_stores().await;
        let dir = shared_dir();
        std::fs::create_dir_all(&dir).expect("create the shared folder");
        let probe = ProbeFile(dir.join("notice_entry_probe.ts"));
        std::fs::write(
            &probe.0,
            "// @description Reports the notice probe.\n\
             // @param city string required the city to look up\n",
        )
        .expect("write the probe tool");

        let guest = "grant_notice_entry_guest";
        crate::users::store().add_user(guest).await.unwrap();
        let guest_id = crate::session::resolve_agent_id(
            guest,
            crate::Role::Assistant.as_str(),
            &crate::users::personal_workspace_name(guest),
        );
        let mut rx = crate::agent::message_router::register_agent(&guest_id);
        crate::util::test::seed_session_row(
            &crate::session::store().conn,
            &guest_id,
            "user",
            "hello",
        )
        .await;

        let entry = load_catalogue(&dir)
            .into_iter()
            .find(|t| t.name == "notice_entry_probe")
            .expect("the probe tool is in the catalogue");

        notify_grant_change(guest, "notice_entry_probe", true).await;
        let job = rx.try_recv().expect("an existing session is woken");
        // The entry is the block's own rendering of the tool — the very text a
        // session-start list shows for it — following the notice's wording as
        // its own line.
        assert_eq!(
            job.content,
            format!(
                "<custom-tools-notice>\n\
                 Custom tools granted to your account: notice_entry_probe\n\
                 {}\n\
                 </custom-tools-notice>\n",
                render_lines(&[&entry])
            )
        );

        notify_grant_change(guest, "notice_entry_probe", false).await;
        let job = rx.try_recv().expect("a revocation is announced too");
        assert_eq!(
            job.content,
            "<custom-tools-notice>\n\
             Custom tools removed from your account: notice_entry_probe\n\
             </custom-tools-notice>\n"
        );

        crate::agent::message_router::unregister_agent(&guest_id);
        clear_pending(&guest_id).await;
    }

    /// Delete the durable notice rows addressed to `agent_id`, so other tests'
    /// boot-replay paths never pick them up.
    async fn clear_pending(agent_id: &str) {
        let conn = &crate::session::store().conn;
        for row in crate::jobs::list_pending_jobs(conn).await.unwrap() {
            if row.target_agent_id == agent_id {
                crate::jobs::delete_pending_job(conn, &row.id)
                    .await
                    .unwrap();
            }
        }
    }

    /// Whether a durable custom-tool notice is addressed to `agent_id`.
    async fn has_tool_notice(agent_id: &str) -> bool {
        crate::jobs::list_pending_jobs(&crate::session::store().conn)
            .await
            .unwrap()
            .into_iter()
            .any(|row| {
                row.target_agent_id == agent_id && row.envelope.contains("<custom-tools-notice>")
            })
    }
}