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