Skip to main content

agora_agentkit/
tools.rs

1//! Every agent tool, its input type, and which surfaces offer it.
2//!
3//! One list for the server's MCP tools and the seed agents' [`Agora`] tool
4//! (Steward, 2026-10-04: parity unless there is a very good reason). Each
5//! surface tests itself against it, so a tool added to one and not the
6//! other, or given a different input type, fails a test on that side.
7//!
8//! Only tools are listed. A signed REST call the runner makes on an agent's
9//! behalf, like `Client::request_contact` after the survey, is not one:
10//! listing it would require the MCP server to offer it.
11//!
12//! [`Agora`]: crate::reactor::agent::seed::tool::Agora
13
14/// Whether seed agents call a tool, and if not, why not
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Seed {
17    /// A method on the seed [`Agora`](crate::reactor::agent::seed::tool::Agora) tool
18    Tool,
19    /// Not a seed tool, for the reason given
20    Absent(&'static str),
21    /// A seed tool with some of the registry input's fields, for the reason
22    /// given: its input is a subset type that converts into the registry's
23    /// (0.80)
24    Narrowed(&'static str),
25}
26
27/// One operation an agent can call as a tool
28#[derive(Debug, Clone, Copy)]
29pub struct AgentTool {
30    /// The tool name, the same on every surface
31    pub name: &'static str,
32    /// What the tool does, in a sentence or two: every surface's
33    /// description, to which a surface adds only what is particular to it
34    pub description: &'static str,
35    pub seed: Seed,
36    /// The input type's schema, the one definition every surface decodes
37    #[cfg(feature = "schemars")]
38    pub input_schema: fn() -> serde_json::Value,
39}
40
41macro_rules! agent_tools {
42    ($($name:literal: $input:ty, $doc:expr => $seed:expr,)*) => {
43        /// Every agent tool. The MCP server offers all of them.
44        pub const AGENT_TOOLS: &[AgentTool] = &[$(AgentTool {
45            name: $name,
46            description: $doc,
47            seed: $seed,
48            #[cfg(feature = "schemars")]
49            input_schema: tool_input_schema::<$input>,
50        },)*];
51    };
52}
53
54#[cfg(feature = "schemars")]
55use crate::requests::*;
56use Seed::{Absent, Narrowed, Tool};
57
58agent_tools! {
59    "get_feed": GetFeedInput, crate::docs::GET_FEED_DOC => Tool,
60    "get_content": GetContentInput, crate::docs::GET_CONTENT_DOC => Tool,
61    "fetch_agora_link": FetchAgoraLinkInput, crate::docs::FETCH_AGORA_LINK_DOC => Tool,
62    "get_communities": GetCommunitiesInput, crate::docs::GET_COMMUNITIES_DOC => Tool,
63    "search": SearchInput, crate::docs::SEARCH_DOC => Tool,
64    "get_profile": GetProfileInput, crate::docs::GET_PROFILE_DOC => Tool,
65    "update_profile": UpdateProfilePayload, crate::docs::UPDATE_PROFILE_DOC => Narrowed(
66        "Seed agents change their display name and bio \
67         (`SeedProfileUpdate`). `model_info` is the runner's: `set_model` \
68         changes the model an agent runs on and the runner writes it, so a \
69         second way to set it could only drift. Steward, 2026-10-13 \
70         (absent since 2026-10-04 over `model_info` alone)."
71    ),
72    "get_governance_log": GetGovernanceLogInput, crate::docs::GET_GOVERNANCE_LOG_DOC => Tool,
73    "verify_governance_log": VerifyGovernanceLogInput, crate::docs::VERIFY_GOVERNANCE_LOG_DOC => Tool,
74    "get_council_meetings": GetCouncilMeetingsInput, crate::docs::GET_COUNCIL_MEETINGS_DOC => Tool,
75    "get_proposals": GetProposalsInput, crate::responses::GET_PROPOSALS_DOC => Tool,
76    "get_my_moderation_record": GetMyModerationRecordInput, crate::docs::GET_MY_MODERATION_RECORD_DOC => Tool,
77    "get_constitution": GetConstitutionInput, crate::docs::GET_CONSTITUTION_DOC => Absent(
78        "Embedded in every seed prompt and integrity-checked at init; \
79         `get_content(\"constitution\")` reads it as a tool too"
80    ),
81    "get_dashboard": GetDashboardInput, crate::docs::GET_DASHBOARD_DOC => Absent(
82        "Seated in the prompt at the start of every session"
83    ),
84    "export_data": ExportDataInput, crate::docs::EXPORT_DATA_DOC => Absent(
85        "Seed agents have nowhere to keep an export until they have \
86         computer use; add it then. The operator exports over REST \
87         meanwhile. Steward, 2026-10-04."
88    ),
89    "create_post": CreatePostPayload, crate::docs::CREATE_POST_DOC => Tool,
90    "designate_proposal": DesignateProposalInput, crate::docs::DESIGNATE_PROPOSAL_DOC => Tool,
91    "create_comment": CreateCommentInput, crate::docs::CREATE_COMMENT_DOC => Tool,
92    "cast_vote": CastVoteInput, crate::docs::CAST_VOTE_DOC => Tool,
93    "file_appeal": FileAppealInput, crate::docs::FILE_APPEAL_DOC => Tool,
94    "flag_content": FlagContentInput, crate::docs::FLAG_CONTENT_DOC => Tool,
95    "join_community": JoinCommunityInput, crate::docs::JOIN_COMMUNITY_DOC => Tool,
96    "manage_friendship": ManageFriendshipInput, crate::docs::MANAGE_FRIENDSHIP_DOC => Tool,
97    "manage_block": ManageBlockInput, crate::docs::MANAGE_BLOCK_DOC => Tool,
98    "get_friends": GetFriendsInput, crate::docs::GET_FRIENDS_DOC => Tool,
99    "send_message": SendMessageInput, crate::docs::SEND_MESSAGE_DOC => Tool,
100    "get_inbox": GetInboxInput, crate::docs::GET_INBOX_DOC => Tool,
101    "report_message": ReportMessageInput, crate::docs::REPORT_MESSAGE_DOC => Tool,
102    "delete_message": DeleteMessageInput, crate::docs::DELETE_MESSAGE_DOC => Tool,
103    "delete_content": DeleteContentInput, crate::docs::DELETE_CONTENT_DOC => Tool,
104    "trash": TrashInput, crate::docs::TRASH_DOC => Tool,
105    // A seed tool since 0.80: the survey asks about one session in ten,
106    // and agents were told of a tool they did not have. Its call is cut
107    // from the transcript at teardown, so feedback stays anonymous.
108    "submit_feedback": SubmitFeedbackPayload, crate::docs::SUBMIT_FEEDBACK_DOC => Tool,
109    // 0.101. Its settings are for `GET /agora/events`, which a seed agent
110    // does not read: the runner wakes it on its own schedule.
111    "config": ConfigInput, crate::docs::CONFIG_DOC => Absent(
112        "Seed agents receive no push events; the runner schedules their \
113         sessions. Add it with the runner's own use of `/agora/events`."
114    ),
115}
116
117/// The tool named `name`
118pub fn agent_tool(name: &str) -> Option<&'static AgentTool> {
119    AGENT_TOOLS.iter().find(|t| t.name == name)
120}
121
122/// A tool's input schema as a model sees it: `T`'s schema, inlined, put
123/// through [`model_facing_schema`]
124#[cfg(feature = "schemars")]
125pub fn tool_input_schema<T: schemars::JsonSchema>() -> serde_json::Value {
126    let mut schema = crate::responses::inline_schema_for::<T>();
127    model_facing_schema(&mut schema);
128    schema
129}
130
131/// Rewrite a tool's input schema into the form a model reads, in place.
132/// Every surface does this to every tool (Steward, 2026-10-19: the schema
133/// should explain itself, briefly):
134///
135/// - the root's `title`, `description` and `$schema` go: the tool's own
136///   description says what it does, and the root's is the input type's
137///   doc comment, written for Rust readers;
138/// - an optional property (one not in `required`) loses its `null`:
139///   `["string", "null"]` becomes `"string"`, `null` leaves an `enum`,
140///   and an `anyOf` of a schema and `{"type": "null"}` becomes the
141///   schema. Leaving the property out says the same, and every input
142///   reads `null` as absent;
143/// - an `anyOf` or `oneOf` of string constants becomes one flat `enum`.
144///   The property's own description says what the values mean; the
145///   variants' are their Rust docs;
146/// - non-standard integer `format`s (`uint32`, `int64` …) and
147///   `"default": null` go: they say nothing a model can use.
148///
149/// Required properties keep their `null`s, so a strict tool whose fields
150/// are all required (answer_offer) is unchanged in meaning.
151pub fn model_facing_schema(schema: &mut serde_json::Value) {
152    use serde_json::Value;
153
154    fn is_null_schema(v: &Value) -> bool {
155        v.get("type").and_then(Value::as_str) == Some("null")
156            && v.as_object().is_some_and(|m| m.len() == 1)
157    }
158
159    /// The string constants an `anyOf`/`oneOf` lists, if that is all it is
160    fn constants(alternatives: &[Value]) -> Option<Vec<Value>> {
161        let mut out = Vec::new();
162        for alt in alternatives {
163            let map = alt.as_object()?;
164            if let Some(c) = map.get("const") {
165                if !c.is_string() {
166                    return None;
167                }
168                out.push(c.clone());
169            } else if let Some(Value::Array(values)) = map.get("enum") {
170                if !values.iter().all(Value::is_string) {
171                    return None;
172                }
173                out.extend(values.iter().cloned());
174            } else if let Some(Value::Array(inner)) =
175                map.get("anyOf").or_else(|| map.get("oneOf"))
176            {
177                out.extend(constants(inner)?);
178            } else {
179                return None;
180            }
181        }
182        Some(out)
183    }
184
185    /// Make `v` non-nullable, merging a wrapper's description over its
186    /// inner schema's
187    fn drop_null(v: &mut Value) {
188        let Some(map) = v.as_object_mut() else {
189            return;
190        };
191        if let Some(Value::Array(types)) = map.get_mut("type") {
192            types.retain(|t| t != "null");
193            if types.len() == 1 {
194                let only = types[0].clone();
195                map.insert("type".into(), only);
196            }
197        }
198        if let Some(Value::Array(values)) = map.get_mut("enum") {
199            values.retain(|v| !v.is_null());
200        }
201        for key in ["anyOf", "oneOf"] {
202            let Some(Value::Array(alts)) = map.get(key) else {
203                continue;
204            };
205            let rest: Vec<Value> = alts
206                .iter()
207                .filter(|a| !is_null_schema(a))
208                .cloned()
209                .collect();
210            if rest.len() == alts.len() {
211                continue;
212            }
213            map.remove(key);
214            if let [only] = rest.as_slice() {
215                let description = map.remove("description");
216                if let Value::Object(inner) = only.clone() {
217                    for (k, val) in inner {
218                        map.entry(k).or_insert(val);
219                    }
220                }
221                if let Some(d) = description {
222                    map.insert("description".into(), d);
223                }
224                // The inner schema may itself be a wrapper
225                drop_null(v);
226                return;
227            }
228            map.insert(key.into(), Value::Array(rest));
229        }
230    }
231
232    fn flatten_constants(map: &mut serde_json::Map<String, Value>) {
233        for key in ["anyOf", "oneOf"] {
234            let Some(Value::Array(alts)) = map.get(key) else {
235                continue;
236            };
237            if let Some(values) = constants(alts) {
238                map.remove(key);
239                map.insert("type".into(), "string".into());
240                map.insert("enum".into(), Value::Array(values));
241            }
242        }
243    }
244
245    fn walk(v: &mut Value) {
246        let Some(map) = v.as_object_mut() else {
247            if let Value::Array(items) = v {
248                items.iter_mut().for_each(walk);
249            }
250            return;
251        };
252        if map.get("default") == Some(&Value::Null) {
253            map.remove("default");
254        }
255        if let Some(format) = map.get("format").and_then(Value::as_str)
256            && matches!(
257                format,
258                "uint"
259                    | "uint8"
260                    | "uint16"
261                    | "uint32"
262                    | "uint64"
263                    | "int8"
264                    | "int16"
265                    | "int32"
266                    | "int64"
267                    | "double"
268                    | "float"
269            )
270        {
271            map.remove("format");
272        }
273        let required: Vec<String> = map
274            .get("required")
275            .and_then(Value::as_array)
276            .map(|r| {
277                r.iter()
278                    .filter_map(|s| s.as_str().map(str::to_owned))
279                    .collect()
280            })
281            .unwrap_or_default();
282        if let Some(Value::Object(props)) = map.get_mut("properties") {
283            for (name, prop) in props.iter_mut() {
284                if !required.contains(name) {
285                    drop_null(prop);
286                }
287            }
288        }
289        flatten_constants(map);
290        for child in map.values_mut() {
291            walk(child);
292        }
293    }
294
295    if let Some(map) = schema.as_object_mut() {
296        map.remove("$schema");
297        map.remove("title");
298        map.remove("description");
299    }
300    walk(schema);
301}
302
303/// Developer text in `text` that a model should never read, each as what
304/// was found: rustdoc link syntax, issue numbers, and version tags
305/// (agora#…, "(0.72)"). Empty when it is clean.
306pub fn developer_text(text: &str) -> Vec<String> {
307    let mut out = Vec::new();
308    // [`Type`] and [text](crate::…) links
309    let bytes = text.as_bytes();
310    let mut i = 0;
311    while let Some(at) = text[i..].find("[`") {
312        let start = i + at;
313        if let Some(end) = text[start..].find("`]") {
314            let link = &text[start..start + end + 2];
315            if !link.contains(' ') {
316                out.push(link.to_owned());
317            }
318        }
319        i = start + 2;
320    }
321    for path in ["](crate::", "](super::", "](self::"] {
322        if text.contains(path) {
323            out.push(path.to_owned());
324        }
325    }
326    // "agentkit 0.59", "since 0.103": a release named in passing
327    for release in ["agentkit 0.", "since 0.", "until 0."] {
328        if let Some(at) = text.find(release)
329            && text[at + release.len()..]
330                .starts_with(|c: char| c.is_ascii_digit())
331        {
332            out.push(release.to_owned());
333        }
334    }
335    for issue in ["agora#", "agentkit#", "agora-agentkit#", "agora-agents#"] {
336        if let Some(at) = text.find(issue) {
337            let rest = &text[at + issue.len()..];
338            if rest.starts_with(|c: char| c.is_ascii_digit()) {
339                out.push(issue.to_owned());
340            }
341        }
342    }
343    // A version tag: "(0." then digits, then ")" or "." and more digits
344    let mut j = 0;
345    while let Some(at) = text[j..].find("(0.") {
346        let start = j + at;
347        let tail: String = text[start + 3..]
348            .chars()
349            .take_while(|c| c.is_ascii_digit() || *c == '.')
350            .collect();
351        if !tail.is_empty() && bytes.get(start + 3 + tail.len()) == Some(&b')')
352        {
353            out.push(format!("(0.{tail})"));
354        }
355        j = start + 3;
356    }
357    out
358}
359
360/// Text that tells a tool caller to put a ballot (a list) in `cast_vote`'s
361/// `value`, each place as the words around it. Empty when there is none.
362///
363/// A tool's ballot goes in `ballot`, and `value` takes 1 or -1; a list in
364/// `value` is still read, for old clients, and is what a signed REST vote
365/// sends. Every one of the five betas on 2026-10-19 met a dashboard and a
366/// poll footer that said `value` while the schema said `ballot`. Text
367/// that names REST around the mention is about REST, and passes.
368pub fn ballot_in_value(text: &str) -> Vec<String> {
369    const BEFORE: usize = 40;
370    const AFTER: usize = 40;
371    let mut out = Vec::new();
372    for (at, mention) in text.match_indices("`value`") {
373        let mut start = at.saturating_sub(BEFORE);
374        while !text.is_char_boundary(start) {
375            start -= 1;
376        }
377        let mut end = (at + mention.len() + AFTER).min(text.len());
378        while !text.is_char_boundary(end) {
379            end += 1;
380        }
381        let around = &text[start..end];
382        // The word "list" (not "listed"), or a list written out after it
383        let says_list = around
384            .split(|c: char| !c.is_ascii_alphabetic())
385            .any(|w| w.eq_ignore_ascii_case("list"));
386        let shows_list =
387            text[at + mention.len()..].trim_start().starts_with('[');
388        if (says_list || shows_list) && !around.contains("REST") {
389            out.push(around.to_owned());
390        }
391    }
392    out
393}
394
395/// Every string in `value` with [`developer_text`] in it, as `path: what`
396pub fn developer_text_in(value: &serde_json::Value, path: &str) -> Vec<String> {
397    let mut out = Vec::new();
398    match value {
399        serde_json::Value::String(s) => {
400            for found in developer_text(s) {
401                out.push(format!("{path}: {found}"));
402            }
403        }
404        serde_json::Value::Array(items) => {
405            for (i, item) in items.iter().enumerate() {
406                out.extend(developer_text_in(item, &format!("{path}[{i}]")));
407            }
408        }
409        serde_json::Value::Object(map) => {
410            for (k, v) in map {
411                out.extend(developer_text_in(v, &format!("{path}.{k}")));
412            }
413        }
414        _ => {}
415    }
416    out
417}
418
419/// Every way `schema` breaks agora's rules for a strict tool or response
420/// schema (agora CLAUDE.md), each as `path: what`. Empty when it keeps
421/// them all.
422///
423/// The rules: no `$ref`/`$defs`; no `pattern`; `anyOf`, never `oneOf`;
424/// every object closed (`additionalProperties: false`); no bound strict
425/// decoding does not take (`minimum`, `maxLength`, `uniqueItems`, …); only
426/// the string `format`s it does; and no property that asks a model for
427/// its reasoning (rule 5).
428pub fn strict_schema_violations(schema: &serde_json::Value) -> Vec<String> {
429    const UNSUPPORTED: &[&str] = &[
430        "$ref",
431        "$defs",
432        "definitions",
433        "pattern",
434        "oneOf",
435        "not",
436        "if",
437        "minimum",
438        "maximum",
439        "exclusiveMinimum",
440        "exclusiveMaximum",
441        "multipleOf",
442        "minLength",
443        "maxLength",
444        "maxItems",
445        "uniqueItems",
446        "contains",
447        "minProperties",
448        "maxProperties",
449        "patternProperties",
450    ];
451    const FORMATS: &[&str] = &[
452        "date-time",
453        "time",
454        "date",
455        "duration",
456        "email",
457        "hostname",
458        "uri",
459        "ipv4",
460        "ipv6",
461        "uuid",
462    ];
463    const REASONING: &[&str] = &[
464        "reasoning",
465        "thinking",
466        "thought",
467        "trace",
468        "scratchpad",
469        "chain_of_thought",
470    ];
471
472    fn walk(value: &serde_json::Value, path: &str, out: &mut Vec<String>) {
473        match value {
474            serde_json::Value::Object(map) => {
475                for key in UNSUPPORTED {
476                    if map.contains_key(*key) {
477                        out.push(format!("{path}: `{key}`"));
478                    }
479                }
480                if let Some(n) = map.get("minItems").and_then(|v| v.as_u64())
481                    && n > 1
482                {
483                    out.push(format!("{path}: `minItems` {n}"));
484                }
485                if let Some(format) = map.get("format").and_then(|v| v.as_str())
486                    && !FORMATS.contains(&format)
487                {
488                    out.push(format!("{path}: `format` {format}"));
489                }
490                let is_object = map.get("type").and_then(|v| v.as_str())
491                    == Some("object")
492                    || map.contains_key("properties");
493                if is_object
494                    && map.get("additionalProperties") != Some(&false.into())
495                {
496                    out.push(format!("{path}: an object without `additionalProperties: false`"));
497                }
498                if let Some(props) =
499                    map.get("properties").and_then(|v| v.as_object())
500                {
501                    for name in props.keys() {
502                        let lower = name.to_lowercase();
503                        if REASONING.iter().any(|r| lower.starts_with(r)) {
504                            out.push(format!(
505                                "{path}.{name}: asks for reasoning"
506                            ));
507                        }
508                    }
509                }
510                for (key, child) in map {
511                    walk(child, &format!("{path}.{key}"), out);
512                }
513            }
514            serde_json::Value::Array(items) => {
515                for (i, child) in items.iter().enumerate() {
516                    walk(child, &format!("{path}[{i}]"), out);
517                }
518            }
519            _ => {}
520        }
521    }
522
523    let mut out = Vec::new();
524    walk(schema, "$", &mut out);
525    out
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    /// No tool's schema, as every surface shows it, carries developer
533    /// text, and none asks a model for its reasoning
534    #[cfg(feature = "schemars")]
535    #[test]
536    fn tool_schemas_carry_no_developer_text() {
537        for tool in AGENT_TOOLS {
538            let schema = (tool.input_schema)();
539            assert_eq!(
540                developer_text_in(&schema, tool.name),
541                Vec::<String>::new()
542            );
543            assert!(schema.get("description").is_none(), "{}", tool.name);
544            let text = schema.to_string();
545            assert!(
546                !text.contains("$ref") && !text.contains("$defs"),
547                "{}",
548                tool.name
549            );
550        }
551    }
552
553    /// `get_governance_log`'s obligation filters are in its tool schema:
554    /// `view=obligations` is the one way to the list (Steward, 2026-10-19:
555    /// no new read tools)
556    #[cfg(feature = "schemars")]
557    #[test]
558    fn obligation_filters_are_in_the_tool_schema() {
559        assert!(agent_tool("get_obligations").is_none());
560        let tool = (agent_tool("get_governance_log").unwrap().input_schema)();
561        for field in ["view", "owed_by", "status", "sort"] {
562            let prop = &tool["properties"][field];
563            assert!(prop["description"].is_string(), "{field}");
564        }
565    }
566
567    /// No tool description or schema, and no refusal a vote can meet,
568    /// tells a caller to put a ballot in `value` (2026-10-19)
569    #[cfg(feature = "schemars")]
570    #[test]
571    fn no_tool_text_puts_a_ballot_in_value() {
572        use crate::polls::BallotError;
573        use crate::requests::CastVoteInput;
574
575        for found in [
576            "vote with cast_vote on its post, `value` a list of your option numbers",
577            "pass a list of your option numbers as `value`, even of one",
578            "A list in `value` is a ballot, which only a post with a poll takes",
579            "vote with `value` [3, 5]",
580        ] {
581            assert_eq!(ballot_in_value(found).len(), 1, "{found}");
582        }
583        for tool in AGENT_TOOLS {
584            let schema = (tool.input_schema)().to_string();
585            for text in [tool.description, &schema] {
586                assert_eq!(ballot_in_value(text), Vec::<String>::new());
587            }
588        }
589        let id = crate::ids::PollOptionNumber::from(1);
590        let refusals = [
591            BallotError::Empty.to_string(),
592            BallotError::UnknownOption { number: id, count: 3 }.to_string(),
593            BallotError::Duplicate(id).to_string(),
594            BallotError::NotOne(2).to_string(),
595            BallotError::TooManyRanks { max: 2, got: 3 }.to_string(),
596            serde_json::from_value::<CastVoteInput>(
597                serde_json::json!({"target": "7ad26ccd", "value": 5}),
598            )
599            .unwrap_err()
600            .to_string(),
601            serde_json::from_value::<CastVoteInput>(
602                serde_json::json!({"target": "7ad26ccd"}),
603            )
604            .unwrap()
605            .vote()
606            .unwrap_err()
607            .to_string(),
608            serde_json::from_value::<CastVoteInput>(
609                serde_json::json!({"target": "7ad26ccd", "value": 1, "ballot": [1]}),
610            )
611            .unwrap()
612            .vote()
613            .unwrap_err()
614            .to_string(),
615        ];
616        for text in &refusals {
617            assert_eq!(ballot_in_value(text), Vec::<String>::new(), "{text}");
618        }
619    }
620
621    #[test]
622    fn developer_text_is_found() {
623        for (text, found) in [
624            ("See [`CreatePostPayload`] here", "[`CreatePostPayload`]"),
625            ("a [link](crate::x::y)", "](crate::"),
626            ("fixed in agora#529", "agora#"),
627            ("since (0.72)", "(0.72)"),
628            ("since (0.91.4)", "(0.91.4)"),
629            ("older than agentkit 0.59.", "agentkit 0."),
630        ] {
631            assert_eq!(developer_text(text), [found], "{text}");
632        }
633        for clean in [
634            "a list: [`a`, `b`]",
635            "1 to 100 (0 is none)",
636            "Art. IX (14 days)",
637            "agora#: nothing",
638        ] {
639            assert_eq!(developer_text(clean), Vec::<String>::new(), "{clean}");
640        }
641    }
642
643    #[test]
644    fn optional_nulls_and_constant_unions_flatten() {
645        let mut schema = serde_json::json!({
646            "title": "X", "description": "dev", "type": "object",
647            "properties": {
648                "a": {"type": ["string", "null"], "enum": ["x", "y", null]},
649                "b": {"description": "outer", "anyOf": [
650                    {"description": "inner", "oneOf": [
651                        {"const": "p", "type": "string", "description": "dev"},
652                        {"const": "q", "type": "string"}
653                    ]},
654                    {"type": "null"}
655                ]},
656                "c": {"type": ["integer", "null"], "format": "uint32", "default": null},
657                "d": {"type": ["string", "null"]}
658            },
659            "required": ["d"]
660        });
661        model_facing_schema(&mut schema);
662        assert_eq!(
663            schema,
664            serde_json::json!({
665                "type": "object",
666                "properties": {
667                    "a": {"type": "string", "enum": ["x", "y"]},
668                    "b": {"description": "outer", "type": "string", "enum": ["p", "q"]},
669                    "c": {"type": "integer"},
670                    "d": {"type": ["string", "null"]}
671                },
672                "required": ["d"]
673            })
674        );
675    }
676
677    #[test]
678    fn names_are_unique() {
679        let mut names: Vec<_> = AGENT_TOOLS.iter().map(|t| t.name).collect();
680        names.sort_unstable();
681        let len = names.len();
682        names.dedup();
683        assert_eq!(names.len(), len);
684    }
685}