polyc-tools 2026.9.0

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
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
//! Specs for the five own-conversation read tools.
//!
//! One family, one namespace. Every tool here reads the CALLER'S OWN
//! conversation record and nothing else: there is no conversation id in any
//! schema, and no argument that can widen what is read. Scope comes from the
//! turn's own dispatch attribution, trusted-side.
//!
//! None of these has an in-process implementation — the conversation sandbox
//! cannot reach the event-log journal, let alone a query engine over it. The
//! harness advertises them through the control-plane proxy, which forwards each
//! call up the harness stream as one generic control-plane-tool frame; the
//! control plane runs the read and replies. Adding a primitive is a spec here
//! plus a control-plane handler, never a new wire message.
//!
//! # Two backends, one contract
//!
//! [`CONVERSATION_FIND`], [`CONVERSATION_READ_TURN`], and
//! [`CONVERSATION_READ_TOOL_RESULT`] replay the journal
//! (`crate::history_nav` in `polyc-control-plane`).
//! [`CONVERSATION_RECENT_TURNS`] and [`CONVERSATION_LIST_TOOL_CALLS`] run one
//! fixed statement each through the query engine (`crate::query_nav`). The
//! model never learns there are two stores: every tool returns a named shape
//! with semantic field names, clamps its own `limit`, and answers zero matches
//! as an empty result rather than an error.
//!
//! # What replaced what
//!
//! The predecessor surface was seven tools plus a raw-SQL hatch
//! (`conversation_query`). The hatch is gone: model-authored SQL can reshape
//! content across rows of differing trust (`SUBSTR`, `GROUP_CONCAT`), so no
//! per-row provenance verdict could survive it — which is why it had to pause
//! for a human on every call, and why it went almost unused. Two ways to find a
//! message (`history_search` ranked, `history_grep` regex) collapsed into
//! [`CONVERSATION_FIND`]'s `mode`, which dispatches to the same two code paths
//! losslessly.
//!
//! # Annotations
//!
//! All five are `read_only`, none is `approval_required`, and all five set
//! `cacheable_approval` — a uniform posture, where the predecessor surface was
//! uneven for no stated reason. None sets `open_world`, including
//! [`CONVERSATION_READ_TOOL_RESULT`], which is the only one that returns a
//! recorded tool RESULT: that annotation is a static, unconditional override of
//! the per-call provenance verdict rather than a label on it, so setting it
//! would taint a conversation off first-party content and make the verdict this
//! family relays ([`RESULT_UNTRUSTED_KEY`], INV-C5) decorative. See
//! [`read_tool_result_spec`] for the mechanism.
//!
//! # The row ceilings in this copy are pinned elsewhere
//!
//! Each description states the exact number its clamp enforces, because that
//! number is a promise the model reasons over. The clamps live with the code
//! that applies them — the query-engine ones in `query_nav::narrow`, the
//! journal-replay ones in `history_nav` — which is also where the tests holding
//! the two together live: this crate is a Component and cannot depend on a
//! Container to check its own copy.

use polyc_llm::ToolSpec;
use serde::Deserialize;
use serde_json::json;

/// The `conversation_find` tool name.
pub const CONVERSATION_FIND: &str = "conversation_find";
/// The `conversation_read_turn` tool name.
pub const CONVERSATION_READ_TURN: &str = "conversation_read_turn";
/// The `conversation_read_tool_result` tool name.
pub const CONVERSATION_READ_TOOL_RESULT: &str = "conversation_read_tool_result";
/// The `conversation_recent_turns` tool name.
pub const CONVERSATION_RECENT_TURNS: &str = "conversation_recent_turns";
/// The `conversation_list_tool_calls` tool name.
pub const CONVERSATION_LIST_TOOL_CALLS: &str = "conversation_list_tool_calls";

/// Every own-conversation read tool name, for allowlist checks and dispatch.
pub const ALL: &[&str] = &[
    CONVERSATION_FIND,
    CONVERSATION_READ_TURN,
    CONVERSATION_READ_TOOL_RESULT,
    CONVERSATION_RECENT_TURNS,
    CONVERSATION_LIST_TOOL_CALLS,
];

/// The subset of [`ALL`] the journal-replay backend answers
/// (`crate::history_nav` in `polyc-control-plane`).
pub const JOURNAL_REPLAY: &[&str] = &[
    CONVERSATION_FIND,
    CONVERSATION_READ_TURN,
    CONVERSATION_READ_TOOL_RESULT,
];

/// The subset of [`ALL`] the query engine answers (`crate::query_nav` in
/// `polyc-control-plane`).
pub const QUERY_ENGINE: &[&str] = &[CONVERSATION_RECENT_TURNS, CONVERSATION_LIST_TOOL_CALLS];

/// JSON key a [`CONVERSATION_READ_TOOL_RESULT`] reply stamps with the recorded
/// result's provenance verdict (#1136, INV-C5).
///
/// `true` when the record says the result ingested untrusted content, so the
/// re-entering bytes are quarantined exactly as the originals were. Shared home
/// for both sides of the wire: the control plane
/// (`crates/control-plane/src/history_nav.rs`) writes this key into the reply
/// JSON, and the harness (`crates/harness/src/history_proxy.rs`) string-matches
/// it back out to decide whether to call
/// `polyc_agent::mark_result_untrusted`. Both crates already depend on
/// `polyc-tools`, so this is the one place a key-name drift between them would
/// be caught at compile time instead of silently at runtime.
pub const RESULT_UNTRUSTED_KEY: &str = "untrusted";

/// How much of a result comes back.
///
/// The concise/detailed pattern: [`Detail::Concise`] carries the fields that
/// answer the question the tool exists for, and [`Detail::Full`] adds the
/// identifiers needed to chain into another tool plus the outcome columns a
/// forensic question wants. Shared between the spec (which advertises the
/// enum) and the two control-plane executors (which decode it), so the wire
/// spelling exists once.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Detail {
    /// The default: enough to answer, and no more.
    #[default]
    Concise,
    /// Everything concise carries, plus chaining identifiers and outcomes.
    Full,
}

impl Detail {
    /// Whether this is [`Detail::Full`].
    #[must_use]
    pub const fn is_full(self) -> bool {
        matches!(self, Self::Full)
    }
}

/// Which of [`CONVERSATION_FIND`]'s two finders runs.
///
/// A dispatch, not a fusion: [`FindMode::Relevance`] runs the term-overlap
/// ranking and [`FindMode::Pattern`] the regular-expression scan, each exactly
/// as its predecessor tool did. Merging the two behind one name is a
/// tool-selection fix — a model asking "where did we discuss the refund" had
/// two near-identical tools to choose between — not a change to either search.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FindMode {
    /// Rank by how many of the query's words each message shares.
    #[default]
    Relevance,
    /// Match the query as a regular expression.
    Pattern,
}

/// The `detail` property every tool here but
/// [`CONVERSATION_READ_TOOL_RESULT`] accepts, with the per-tool sentence
/// describing what `full` adds.
fn detail_property(what_full_adds: &str) -> serde_json::Value {
    json!({
        "type": "string",
        "enum": ["concise", "full"],
        "description": format!("\"concise\" (the default) returns just what answers the question. \"full\" {what_full_adds}"),
    })
}

/// Every own-conversation read spec.
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
    vec![
        find_spec(),
        read_turn_spec(),
        read_tool_result_spec(),
        recent_turns_spec(),
        list_tool_calls_spec(),
    ]
}

/// `conversation_find` spec — find earlier messages of this conversation, by
/// relevance or by pattern, run trusted-side, read-only.
///
/// `mode` chooses which finder runs; nothing else about either changed. See
/// [`FindMode`] for why the two are one tool.
#[must_use]
pub fn find_spec() -> ToolSpec {
    ToolSpec::new(
        CONVERSATION_FIND,
        "Find earlier messages in this conversation and get back the matching \
         moments — a turn id and a short excerpt each. Use it when something \
         was said before it scrolled out of view and you need to locate it \
         again. `mode` picks how to look: \"relevance\" (the default) ranks by \
         how many of your words each message shares, and is what you want for \
         a topic or an idea; \"pattern\" treats the query as a regular \
         expression, and is what you want for an exact shape — an id, a URL, a \
         literal phrase. Pattern mode is case-insensitive unless the pattern \
         opts out with (?-i), and uses Rust regex syntax, so backreferences \
         and lookaround are not supported. `limit` is trimmed to 25, and \
         leaving it out returns 5. Finding nothing is an ordinary empty \
         result, not an error. When more matches exist than you asked for, \
         `truncated` comes back true — raise `limit` or narrow the query. \
         Searches this conversation and nothing else. To read a match in full, \
         pass its turn id to conversation_read_turn; to see what tools ran \
         rather than what was said, use conversation_list_tool_calls; for what \
         you already know about this person from earlier conversations, use \
         memory_recall.",
        json!({
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Words to look for, or a regular expression when mode is \"pattern\"."
                },
                "mode": {
                    "type": "string",
                    "enum": ["relevance", "pattern"],
                    "description": "\"relevance\" (the default) ranks by shared words; \"pattern\" matches the query as a regular expression."
                },
                "limit": {
                    "type": "integer",
                    "description": "Most matches to return (default 5, at most 25).",
                    "minimum": 1
                },
                "detail": detail_property(
                    "adds each match's position in the conversation, for ordering matches against each other."
                )
            },
            "required": ["query"],
            "additionalProperties": false
        }),
    )
    .titled("Find something in this conversation")
    .read_only()
    .cacheable_approval()
}

/// `conversation_read_turn` spec — pull one earlier turn back in full by its
/// turn id, run trusted-side, read-only.
#[must_use]
pub fn read_turn_spec() -> ToolSpec {
    ToolSpec::new(
        CONVERSATION_READ_TURN,
        "Read one earlier turn of this conversation in full, given the turn id \
         from a conversation_find match or a conversation_recent_turns row. Use \
         it once you have located a moment and need its exact wording rather \
         than the excerpt. A very long turn comes back with its middle \
         shortened, and says so. A turn id that names nothing in this \
         conversation is an error, not an empty answer — list valid ones with \
         conversation_recent_turns. Reads this conversation and nothing else. \
         For the recorded output of a tool call rather than the words of a \
         turn, use conversation_read_tool_result.",
        json!({
            "type": "object",
            "properties": {
                "turn_id": {
                    "type": "string",
                    "description": "The turn to read, from a conversation_find match or a conversation_recent_turns row."
                },
                "detail": detail_property(
                    "adds the turn's position in the conversation, for ordering it against other turns."
                )
            },
            "required": ["turn_id"],
            "additionalProperties": false
        }),
    )
    .titled("Read an earlier turn")
    .read_only()
    .cacheable_approval()
}

/// `conversation_read_tool_result` spec — pull back the recorded result of one
/// earlier tool call by its tool call id, run trusted-side, read-only.
///
/// This is a read of the conversation record, never a re-execution: the tool it
/// names is not dispatched again, no money moves, and nothing leaves the
/// record. The recorded result comes back with its recorded provenance verdict
/// ([`RESULT_UNTRUSTED_KEY`], INV-C5), so content that was untrusted when it
/// arrived stays untrusted when it comes back.
///
/// It deliberately does NOT set `open_world`, and the predecessor
/// `history_result_peek` did not either. `open_world` is what
/// `ToolExecutor::ingests_untrusted_content` reads, and the turn loop folds
/// that into `first_party` as a STATIC term the per-call report can only
/// narrow (`polyc_agent`): setting it here would stamp every read-back untrusted
/// whatever the record said, since the per-call report is downgrade-only and
/// could never restore the verdict. Reading back a wholly first-party result
/// would then taint the conversation and escalate every later egress call to a
/// human. The recorded verdict is the authority, per call — it already fails
/// closed on its own — and this annotation would override the very thing the
/// paragraph above credits this tool for relaying.
#[must_use]
pub fn read_tool_result_spec() -> ToolSpec {
    ToolSpec::new(
        CONVERSATION_READ_TOOL_RESULT,
        "Read the recorded result of one earlier tool call in this \
         conversation, given its tool call id. Use it when an older tool result \
         was set aside to keep the conversation inside its working window and \
         you need its content again. Returns the result exactly as it was \
         recorded — the tool is not run again, so nothing is re-fetched, \
         re-sent, or re-charged. A tool call id that names nothing here is an \
         error, not an empty answer; list valid ones with \
         conversation_list_tool_calls, which is also where to look if you only \
         need to know WHETHER something ran rather than what it returned. Reads \
         this conversation and nothing else.",
        json!({
            "type": "object",
            "properties": {
                "tool_call_id": {
                    "type": "string",
                    "description": "The tool call id shown on the result to read back, or from a conversation_list_tool_calls row."
                }
            },
            "required": ["tool_call_id"],
            "additionalProperties": false
        }),
    )
    .titled("Read an earlier tool result")
    .read_only()
    .cacheable_approval()
}

/// `conversation_recent_turns` spec — this conversation's most recent committed
/// turns and their metadata, run trusted-side, read-only.
///
/// `limit` bounds how many rows come back and `detail` how wide each row is;
/// neither can change WHAT is read. `detail: "full"` adds the turn OUTCOME —
/// why a failed turn failed — which is much of what the deleted SQL hatch was
/// reached for.
#[must_use]
pub fn recent_turns_spec() -> ToolSpec {
    ToolSpec::new(
        CONVERSATION_RECENT_TURNS,
        "List this conversation's most recent committed turns, newest first: \
         the turn id, whether it completed or failed, the model, the tokens it \
         spent, and when it started. Use it to get your bearings on what has \
         happened recently — how many turns, which ones failed, what they cost \
         — and to get a turn id you can pass to conversation_read_turn. \
         `detail: \"full\"` adds the outcome: why a failed turn failed. \
         `limit` is trimmed to 50, and \
         leaving it out returns 20. Only committed turns appear; a turn still \
         running has no row yet. Covers this conversation and nothing else. To \
         find a turn by what was said in it rather than by when it happened, \
         use conversation_find.",
        json!({
            "type": "object",
            "properties": {
                "limit": {
                    "type": "integer",
                    "description": "Most turns to return, newest first (default 20, at most 50).",
                    "minimum": 1
                },
                "detail": detail_property(
                    "adds each turn's log position and its outcome: a failed turn's failure kind and message."
                )
            },
            "additionalProperties": false
        }),
    )
    .titled("List this conversation's recent turns")
    .read_only()
    .cacheable_approval()
}

/// `conversation_list_tool_calls` spec — the tool calls recorded in this
/// conversation, run trusted-side, read-only.
///
/// Returns a short preview of each call's recorded ARGUMENTS, which the model
/// itself authored, and NEVER the recorded result at any `detail` level —
/// reading a result is [`CONVERSATION_READ_TOOL_RESULT`]'s job, on the backend
/// that stamps the recorded provenance verdict.
#[must_use]
pub fn list_tool_calls_spec() -> ToolSpec {
    ToolSpec::new(
        CONVERSATION_LIST_TOOL_CALLS,
        "List the tool calls recorded in this conversation, newest first: the \
         tool, the first 200 characters of the arguments it was called with, \
         and whether a result was ever recorded for it. Use it to check \
         whether something was already tried, and with what. The recorded \
         result is never included here — to read one back, pass its tool call \
         id to conversation_read_tool_result. `detail: \"full\"` adds that tool \
         call id, the turn and position the call sits at, and the approval \
         outcome when a human was asked. `tool_name` narrows the list to one \
         tool by exact name; `limit` is trimmed to 100, and leaving it out \
         returns 20. Neither argument can widen what is read. Covers this \
         conversation and nothing else.",
        json!({
            "type": "object",
            "properties": {
                "tool_name": {
                    "type": "string",
                    "description": "Return only calls to this exact tool name. Leave it out to see every tool."
                },
                "limit": {
                    "type": "integer",
                    "description": "Most calls to return, newest first (default 20, at most 100).",
                    "minimum": 1
                },
                "detail": detail_property(
                    "adds the tool call id to read a result back with, the turn and log position of the call, and the approval outcome when a human was asked."
                )
            },
            "additionalProperties": false
        }),
    )
    .titled("List this conversation's tool calls")
    .read_only()
    .cacheable_approval()
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;

    fn spec_named(name: &str) -> ToolSpec {
        all_specs()
            .into_iter()
            .find(|spec| spec.name == name)
            .unwrap_or_else(|| panic!("{name} must be advertised"))
    }

    /// The wire names are a contract with every transcript that ever mentions
    /// one, so a rename has to be a deliberate edit here rather than a side
    /// effect of renaming a constant.
    #[test]
    fn tool_names_are_stable() {
        assert_eq!(CONVERSATION_FIND, "conversation_find");
        assert_eq!(CONVERSATION_READ_TURN, "conversation_read_turn");
        assert_eq!(
            CONVERSATION_READ_TOOL_RESULT,
            "conversation_read_tool_result"
        );
        assert_eq!(CONVERSATION_RECENT_TURNS, "conversation_recent_turns");
        assert_eq!(CONVERSATION_LIST_TOOL_CALLS, "conversation_list_tool_calls");
        let mut advertised: Vec<String> = all_specs().into_iter().map(|spec| spec.name).collect();
        advertised.sort();
        let mut expected: Vec<String> = ALL.iter().map(|n| (*n).to_owned()).collect();
        expected.sort();
        assert_eq!(
            advertised, expected,
            "ALL and all_specs() must name the same five tools"
        );
    }

    /// The two backend subsets partition [`ALL`] exactly — a name in neither
    /// would compose, advertise, and then fail to dispatch.
    #[test]
    fn the_two_backend_subsets_partition_the_family() {
        let mut both: Vec<&str> = JOURNAL_REPLAY.iter().chain(QUERY_ENGINE).copied().collect();
        both.sort_unstable();
        let mut all: Vec<&str> = ALL.to_vec();
        all.sort_unstable();
        assert_eq!(both, all, "every tool must have exactly one backend");
    }

    /// Every tool here is a read, none pauses for a human, and all of them may
    /// remember an approval.
    ///
    /// The predecessor surface was uneven — the four `history_*` tools set
    /// `cacheable_approval`, `recent_turns`/`tool_call_history` did not, and the
    /// SQL hatch was `approval_required` and never cacheable. Nothing here is
    /// gated, so the cacheable flag is functionally inert; it is set for
    /// consistency, so a future gate on one of these does not inherit an
    /// accident.
    #[test]
    fn every_tool_is_an_ungated_cacheable_read() {
        for spec in all_specs() {
            assert!(spec.read_only, "{} reads", spec.name);
            assert!(!spec.destructive, "{} destroys nothing", spec.name);
            assert!(
                !spec.needs_approval,
                "{} must not pause for a human: it reads a fixed scope a caller can bound but \
                 not change",
                spec.name
            );
            assert!(spec.cacheable_approval, "{}", spec.name);
            assert!(spec.title.is_some(), "{} carries a title", spec.name);
        }
    }

    /// No tool in this family sets `open_world`, including the one that reads
    /// back a recorded tool RESULT.
    ///
    /// It is tempting to set it there — that is the one path by which text of
    /// external provenance re-enters context — and the predecessor
    /// `history_result_peek` was also asked this question and also answered no.
    /// The reason is mechanical, not a judgment about how risky the content is:
    /// `open_world` is what `ToolExecutor::ingests_untrusted_content` reads, and
    /// the turn loop ANDs that STATIC term with the per-call report to compute
    /// `first_party`. The report is downgrade-only. So setting it here would
    /// pin every read-back to untrusted no matter what the record said, taint
    /// the conversation off a wholly first-party result, and escalate every
    /// later egress call to a human — while making the per-call relay that
    /// carries the true verdict ([`RESULT_UNTRUSTED_KEY`], INV-C5) dead weight.
    ///
    /// The recorded verdict is the authority, per call, and it already fails
    /// closed on its own (`!first_party || trust.is_quarantined()`).
    #[test]
    fn no_conversation_read_is_open_world() {
        for name in ALL {
            assert!(
                !spec_named(name).open_world,
                "{name} must leave the recorded provenance verdict authoritative per call — \
                 `open_world` would override it unconditionally"
            );
        }
    }

    /// Each schema is closed and refuses a zero or negative `limit`, so a model
    /// cannot pass a field the handler will silently ignore.
    #[test]
    fn schemas_are_closed_and_bound_their_limits() {
        for spec in all_specs() {
            assert_eq!(
                spec.schema_json["additionalProperties"],
                json!(false),
                "{}'s schema must be closed",
                spec.name
            );
        }
        for name in [
            CONVERSATION_FIND,
            CONVERSATION_RECENT_TURNS,
            CONVERSATION_LIST_TOOL_CALLS,
        ] {
            let spec = spec_named(name);
            assert_eq!(
                spec.schema_json["properties"]["limit"]["minimum"],
                json!(1),
                "{name}'s limit must be at least 1"
            );
            assert_eq!(
                spec.schema_json["properties"]["limit"]["type"],
                json!("integer"),
                "{name}'s limit is a whole number"
            );
        }
        assert_eq!(
            spec_named(CONVERSATION_FIND).schema_json["required"],
            json!(["query"])
        );
        assert_eq!(
            spec_named(CONVERSATION_READ_TURN).schema_json["required"],
            json!(["turn_id"])
        );
        assert_eq!(
            spec_named(CONVERSATION_READ_TOOL_RESULT).schema_json["required"],
            json!(["tool_call_id"])
        );
    }

    /// `conversation_find`'s `mode` and the four `detail`-taking tools' `detail`
    /// advertise exactly the spellings [`FindMode`] and [`Detail`] decode, so a
    /// model reading the schema cannot write a value the handler rejects.
    #[test]
    fn the_enum_spellings_match_what_the_handlers_decode() {
        assert_eq!(
            spec_named(CONVERSATION_FIND).schema_json["properties"]["mode"]["enum"],
            json!(["relevance", "pattern"])
        );
        for spelling in ["relevance", "pattern"] {
            serde_json::from_value::<FindMode>(json!(spelling))
                .unwrap_or_else(|e| panic!("{spelling} must decode as a FindMode: {e}"));
        }
        for name in [
            CONVERSATION_FIND,
            CONVERSATION_READ_TURN,
            CONVERSATION_RECENT_TURNS,
            CONVERSATION_LIST_TOOL_CALLS,
        ] {
            assert_eq!(
                spec_named(name).schema_json["properties"]["detail"]["enum"],
                json!(["concise", "full"]),
                "{name}"
            );
        }
        for spelling in ["concise", "full"] {
            serde_json::from_value::<Detail>(json!(spelling))
                .unwrap_or_else(|e| panic!("{spelling} must decode as a Detail: {e}"));
        }
        assert_eq!(Detail::default(), Detail::Concise);
        assert!(!Detail::default().is_full());
        assert_eq!(FindMode::default(), FindMode::Relevance);
        assert!(
            spec_named(CONVERSATION_READ_TOOL_RESULT).schema_json["properties"]
                .get("detail")
                .is_none(),
            "a recorded result has no concise form — it comes back whole or not at all"
        );
    }

    /// `conversation_list_tool_calls` promises a preview of a call's ARGUMENTS
    /// and says the recorded result is never included — the split this whole
    /// family rests on, since only the journal-replay read stamps a recorded
    /// result's provenance verdict. The copy must not drift into promising the
    /// result too.
    #[test]
    fn the_call_list_never_offers_a_recorded_result() {
        let description = spec_named(CONVERSATION_LIST_TOOL_CALLS).description;
        assert!(
            description.contains("The recorded result is never included"),
            "the copy must say the result is not included: {description}"
        );
        assert!(
            description.contains(CONVERSATION_READ_TOOL_RESULT),
            "and must point at the tool that does return one: {description}"
        );
    }

    /// `conversation_find` points at `memory_recall` for what is known about
    /// the person across conversations, and `memory_recall` points back at
    /// `conversation_find` for what was said inside this one.
    ///
    /// [`every_description_points_at_a_sibling`] cannot catch this: it walks
    /// [`ALL`], and `memory_recall` is deliberately not in this family's
    /// namespace. Without this test the pair drifts one-way, which is the exact
    /// tool-selection failure the descriptions exist to prevent — a model that
    /// wants "what do I know about them" reaching for a history search.
    #[test]
    fn the_find_and_recall_pair_point_at_each_other() {
        assert!(
            spec_named(CONVERSATION_FIND)
                .description
                .contains(crate::memory::MEMORY_RECALL),
            "conversation_find must say where cross-conversation knowledge lives"
        );
        assert!(
            crate::memory::recall_spec()
                .description
                .contains(CONVERSATION_FIND),
            "memory_recall must say where this conversation's own words live"
        );
    }

    /// Every description names at least one sibling, so a model that reached
    /// for the wrong tool is told which one it wanted. This is the
    /// highest-leverage lever on a tool-selection problem, and it is easy to
    /// lose in a later copy edit.
    #[test]
    fn every_description_points_at_a_sibling() {
        for spec in all_specs() {
            let named: Vec<&str> = ALL
                .iter()
                .copied()
                .filter(|name| *name != spec.name && spec.description.contains(name))
                .collect();
            assert!(
                !named.is_empty(),
                "{}'s description must say when to reach for a sibling instead: {}",
                spec.name,
                spec.description
            );
        }
    }
}