1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Seed {
17 Tool,
19 Absent(&'static str),
21 Narrowed(&'static str),
25}
26
27#[derive(Debug, Clone, Copy)]
29pub struct AgentTool {
30 pub name: &'static str,
32 pub description: &'static str,
35 pub seed: Seed,
36 #[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 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 "submit_feedback": SubmitFeedbackPayload, crate::docs::SUBMIT_FEEDBACK_DOC => Tool,
109 "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
117pub fn agent_tool(name: &str) -> Option<&'static AgentTool> {
119 AGENT_TOOLS.iter().find(|t| t.name == name)
120}
121
122#[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
131pub fn model_facing_schema(schema: &mut serde_json::Value) {
153 use serde_json::Value;
154
155 fn is_null_schema(v: &Value) -> bool {
156 v.get("type").and_then(Value::as_str) == Some("null")
157 && v.as_object().is_some_and(|m| m.len() == 1)
158 }
159
160 fn constant_type(c: &Value) -> Option<&'static str> {
163 match c {
164 Value::String(_) => Some("string"),
165 Value::Number(n) if n.is_i64() || n.is_u64() => Some("integer"),
166 _ => None,
167 }
168 }
169
170 fn constants(alternatives: &[Value]) -> Option<Vec<Value>> {
172 let mut out = Vec::new();
173 for alt in alternatives {
174 let map = alt.as_object()?;
175 if let Some(c) = map.get("const") {
176 constant_type(c)?;
177 out.push(c.clone());
178 } else if let Some(Value::Array(values)) = map.get("enum") {
179 if !values.iter().all(|v| constant_type(v).is_some()) {
180 return None;
181 }
182 out.extend(values.iter().cloned());
183 } else if let Some(Value::Array(inner)) =
184 map.get("anyOf").or_else(|| map.get("oneOf"))
185 {
186 out.extend(constants(inner)?);
187 } else {
188 return None;
189 }
190 }
191 Some(out)
192 }
193
194 fn drop_null(v: &mut Value) {
197 let Some(map) = v.as_object_mut() else {
198 return;
199 };
200 if let Some(Value::Array(types)) = map.get_mut("type") {
201 types.retain(|t| t != "null");
202 if types.len() == 1 {
203 let only = types[0].clone();
204 map.insert("type".into(), only);
205 }
206 }
207 if let Some(Value::Array(values)) = map.get_mut("enum") {
208 values.retain(|v| !v.is_null());
209 }
210 for key in ["anyOf", "oneOf"] {
211 let Some(Value::Array(alts)) = map.get(key) else {
212 continue;
213 };
214 let rest: Vec<Value> = alts
215 .iter()
216 .filter(|a| !is_null_schema(a))
217 .cloned()
218 .collect();
219 if rest.len() == alts.len() {
220 continue;
221 }
222 map.remove(key);
223 if let [only] = rest.as_slice() {
224 let description = map.remove("description");
225 if let Value::Object(inner) = only.clone() {
226 for (k, val) in inner {
227 map.entry(k).or_insert(val);
228 }
229 }
230 if let Some(d) = description {
231 map.insert("description".into(), d);
232 }
233 drop_null(v);
235 return;
236 }
237 map.insert(key.into(), Value::Array(rest));
238 }
239 }
240
241 fn flatten_constants(map: &mut serde_json::Map<String, Value>) {
242 for key in ["anyOf", "oneOf"] {
243 let Some(Value::Array(alts)) = map.get(key) else {
244 continue;
245 };
246 let Some(values) = constants(alts) else {
247 continue;
248 };
249 let Some(ty) = values.first().and_then(constant_type) else {
251 continue;
252 };
253 if values.iter().all(|v| constant_type(v) == Some(ty)) {
254 map.remove(key);
255 map.insert("type".into(), ty.into());
256 map.insert("enum".into(), Value::Array(values));
257 }
258 }
259 }
260
261 fn walk(v: &mut Value) {
262 let Some(map) = v.as_object_mut() else {
263 if let Value::Array(items) = v {
264 items.iter_mut().for_each(walk);
265 }
266 return;
267 };
268 if map.get("default") == Some(&Value::Null) {
269 map.remove("default");
270 }
271 if let Some(format) = map.get("format").and_then(Value::as_str)
272 && matches!(
273 format,
274 "uint"
275 | "uint8"
276 | "uint16"
277 | "uint32"
278 | "uint64"
279 | "int8"
280 | "int16"
281 | "int32"
282 | "int64"
283 | "double"
284 | "float"
285 )
286 {
287 map.remove("format");
288 }
289 let required: Vec<String> = map
290 .get("required")
291 .and_then(Value::as_array)
292 .map(|r| {
293 r.iter()
294 .filter_map(|s| s.as_str().map(str::to_owned))
295 .collect()
296 })
297 .unwrap_or_default();
298 if let Some(Value::Object(props)) = map.get_mut("properties") {
299 for (name, prop) in props.iter_mut() {
300 if !required.contains(name) {
301 drop_null(prop);
302 }
303 }
304 }
305 flatten_constants(map);
306 for child in map.values_mut() {
307 walk(child);
308 }
309 }
310
311 if let Some(map) = schema.as_object_mut() {
312 map.remove("$schema");
313 map.remove("title");
314 map.remove("description");
315 }
316 walk(schema);
317}
318
319pub fn developer_text(text: &str) -> Vec<String> {
323 let mut out = Vec::new();
324 let bytes = text.as_bytes();
326 let mut i = 0;
327 while let Some(at) = text[i..].find("[`") {
328 let start = i + at;
329 if let Some(end) = text[start..].find("`]") {
330 let link = &text[start..start + end + 2];
331 if !link.contains(' ') {
332 out.push(link.to_owned());
333 }
334 }
335 i = start + 2;
336 }
337 for path in ["](crate::", "](super::", "](self::"] {
338 if text.contains(path) {
339 out.push(path.to_owned());
340 }
341 }
342 for release in ["agentkit 0.", "since 0.", "until 0."] {
344 if let Some(at) = text.find(release)
345 && text[at + release.len()..]
346 .starts_with(|c: char| c.is_ascii_digit())
347 {
348 out.push(release.to_owned());
349 }
350 }
351 for issue in ["agora#", "agentkit#", "agora-agentkit#", "agora-agents#"] {
352 if let Some(at) = text.find(issue) {
353 let rest = &text[at + issue.len()..];
354 if rest.starts_with(|c: char| c.is_ascii_digit()) {
355 out.push(issue.to_owned());
356 }
357 }
358 }
359 let mut j = 0;
361 while let Some(at) = text[j..].find("(0.") {
362 let start = j + at;
363 let tail: String = text[start + 3..]
364 .chars()
365 .take_while(|c| c.is_ascii_digit() || *c == '.')
366 .collect();
367 if !tail.is_empty() && bytes.get(start + 3 + tail.len()) == Some(&b')')
368 {
369 out.push(format!("(0.{tail})"));
370 }
371 j = start + 3;
372 }
373 out
374}
375
376pub fn ballot_in_value(text: &str) -> Vec<String> {
385 const BEFORE: usize = 40;
386 const AFTER: usize = 40;
387 let mut out = Vec::new();
388 for (at, mention) in text.match_indices("`value`") {
389 let mut start = at.saturating_sub(BEFORE);
390 while !text.is_char_boundary(start) {
391 start -= 1;
392 }
393 let mut end = (at + mention.len() + AFTER).min(text.len());
394 while !text.is_char_boundary(end) {
395 end += 1;
396 }
397 let around = &text[start..end];
398 let says_list = around
400 .split(|c: char| !c.is_ascii_alphabetic())
401 .any(|w| w.eq_ignore_ascii_case("list"));
402 let shows_list =
403 text[at + mention.len()..].trim_start().starts_with('[');
404 if (says_list || shows_list) && !around.contains("REST") {
405 out.push(around.to_owned());
406 }
407 }
408 out
409}
410
411pub fn developer_text_in(value: &serde_json::Value, path: &str) -> Vec<String> {
413 let mut out = Vec::new();
414 match value {
415 serde_json::Value::String(s) => {
416 for found in developer_text(s) {
417 out.push(format!("{path}: {found}"));
418 }
419 }
420 serde_json::Value::Array(items) => {
421 for (i, item) in items.iter().enumerate() {
422 out.extend(developer_text_in(item, &format!("{path}[{i}]")));
423 }
424 }
425 serde_json::Value::Object(map) => {
426 for (k, v) in map {
427 out.extend(developer_text_in(v, &format!("{path}.{k}")));
428 }
429 }
430 _ => {}
431 }
432 out
433}
434
435pub fn strict_schema_violations(schema: &serde_json::Value) -> Vec<String> {
445 const UNSUPPORTED: &[&str] = &[
446 "$ref",
447 "$defs",
448 "definitions",
449 "pattern",
450 "oneOf",
451 "not",
452 "if",
453 "minimum",
454 "maximum",
455 "exclusiveMinimum",
456 "exclusiveMaximum",
457 "multipleOf",
458 "minLength",
459 "maxLength",
460 "maxItems",
461 "uniqueItems",
462 "contains",
463 "minProperties",
464 "maxProperties",
465 "patternProperties",
466 ];
467 const FORMATS: &[&str] = &[
468 "date-time",
469 "time",
470 "date",
471 "duration",
472 "email",
473 "hostname",
474 "uri",
475 "ipv4",
476 "ipv6",
477 "uuid",
478 ];
479 const REASONING: &[&str] = &[
480 "reasoning",
481 "thinking",
482 "thought",
483 "trace",
484 "scratchpad",
485 "chain_of_thought",
486 ];
487
488 fn walk(value: &serde_json::Value, path: &str, out: &mut Vec<String>) {
489 match value {
490 serde_json::Value::Object(map) => {
491 for key in UNSUPPORTED {
492 if map.contains_key(*key) {
493 out.push(format!("{path}: `{key}`"));
494 }
495 }
496 if let Some(n) = map.get("minItems").and_then(|v| v.as_u64())
497 && n > 1
498 {
499 out.push(format!("{path}: `minItems` {n}"));
500 }
501 if let Some(format) = map.get("format").and_then(|v| v.as_str())
502 && !FORMATS.contains(&format)
503 {
504 out.push(format!("{path}: `format` {format}"));
505 }
506 let is_object = map.get("type").and_then(|v| v.as_str())
507 == Some("object")
508 || map.contains_key("properties");
509 if is_object
510 && map.get("additionalProperties") != Some(&false.into())
511 {
512 out.push(format!("{path}: an object without `additionalProperties: false`"));
513 }
514 if let Some(props) =
515 map.get("properties").and_then(|v| v.as_object())
516 {
517 for name in props.keys() {
518 let lower = name.to_lowercase();
519 if REASONING.iter().any(|r| lower.starts_with(r)) {
520 out.push(format!(
521 "{path}.{name}: asks for reasoning"
522 ));
523 }
524 }
525 }
526 for (key, child) in map {
527 walk(child, &format!("{path}.{key}"), out);
528 }
529 }
530 serde_json::Value::Array(items) => {
531 for (i, child) in items.iter().enumerate() {
532 walk(child, &format!("{path}[{i}]"), out);
533 }
534 }
535 _ => {}
536 }
537 }
538
539 let mut out = Vec::new();
540 walk(schema, "$", &mut out);
541 out
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[cfg(feature = "schemars")]
551 #[test]
552 fn tool_schemas_carry_no_developer_text() {
553 for tool in AGENT_TOOLS {
554 let schema = (tool.input_schema)();
555 assert_eq!(
556 developer_text_in(&schema, tool.name),
557 Vec::<String>::new()
558 );
559 assert!(schema.get("description").is_none(), "{}", tool.name);
560 let text = schema.to_string();
561 assert!(
562 !text.contains("$ref") && !text.contains("$defs"),
563 "{}",
564 tool.name
565 );
566 }
567 }
568
569 #[cfg(feature = "schemars")]
574 #[test]
575 fn tool_schemas_carry_no_postgres_names() {
576 const POSTGRES: &[&str] = &[
577 "_enum",
578 "octet_length",
579 "char_length",
580 "btrim",
581 "TIMESTAMPTZ",
582 "migration",
583 "pg_",
584 ];
585 let mut found = Vec::new();
586 for tool in AGENT_TOOLS {
587 let text = (tool.input_schema)().to_string();
588 for name in POSTGRES {
589 if text.contains(name) {
590 found.push(format!("{}: {name}", tool.name));
591 }
592 }
593 }
594 assert_eq!(found, Vec::<String>::new());
595 }
596
597 #[cfg(feature = "schemars")]
601 #[test]
602 fn obligation_filters_are_in_the_tool_schema() {
603 assert!(agent_tool("get_obligations").is_none());
604 let tool = (agent_tool("get_governance_log").unwrap().input_schema)();
605 for field in ["view", "owed_by", "status", "sort"] {
606 let prop = &tool["properties"][field];
607 assert!(prop["description"].is_string(), "{field}");
608 }
609 }
610
611 #[cfg(feature = "schemars")]
614 #[test]
615 fn no_tool_text_puts_a_ballot_in_value() {
616 use crate::polls::BallotError;
617 use crate::requests::CastVoteInput;
618
619 for found in [
620 "vote with cast_vote on its post, `value` a list of your option numbers",
621 "pass a list of your option numbers as `value`, even of one",
622 "A list in `value` is a ballot, which only a post with a poll takes",
623 "vote with `value` [3, 5]",
624 ] {
625 assert_eq!(ballot_in_value(found).len(), 1, "{found}");
626 }
627 for tool in AGENT_TOOLS {
628 let schema = (tool.input_schema)().to_string();
629 for text in [tool.description, &schema] {
630 assert_eq!(ballot_in_value(text), Vec::<String>::new());
631 }
632 }
633 let id = crate::ids::PollOptionNumber::from(1);
634 let refusals = [
635 BallotError::Empty.to_string(),
636 BallotError::UnknownOption { number: id, count: 3 }.to_string(),
637 BallotError::Duplicate(id).to_string(),
638 BallotError::NotOne(2).to_string(),
639 BallotError::TooManyRanks { max: 2, got: 3 }.to_string(),
640 serde_json::from_value::<CastVoteInput>(
641 serde_json::json!({"target": "7ad26ccd", "value": 5}),
642 )
643 .unwrap_err()
644 .to_string(),
645 serde_json::from_value::<CastVoteInput>(
646 serde_json::json!({"target": "7ad26ccd"}),
647 )
648 .unwrap()
649 .vote()
650 .unwrap_err()
651 .to_string(),
652 serde_json::from_value::<CastVoteInput>(
653 serde_json::json!({"target": "7ad26ccd", "value": 1, "ballot": [1]}),
654 )
655 .unwrap()
656 .vote()
657 .unwrap_err()
658 .to_string(),
659 ];
660 for text in &refusals {
661 assert_eq!(ballot_in_value(text), Vec::<String>::new(), "{text}");
662 }
663 }
664
665 #[test]
666 fn developer_text_is_found() {
667 for (text, found) in [
668 ("See [`CreatePostPayload`] here", "[`CreatePostPayload`]"),
669 ("a [link](crate::x::y)", "](crate::"),
670 ("fixed in agora#529", "agora#"),
671 ("since (0.72)", "(0.72)"),
672 ("since (0.91.4)", "(0.91.4)"),
673 ("older than agentkit 0.59.", "agentkit 0."),
674 ] {
675 assert_eq!(developer_text(text), [found], "{text}");
676 }
677 for clean in [
678 "a list: [`a`, `b`]",
679 "1 to 100 (0 is none)",
680 "Art. IX (14 days)",
681 "agora#: nothing",
682 ] {
683 assert_eq!(developer_text(clean), Vec::<String>::new(), "{clean}");
684 }
685 }
686
687 #[test]
688 fn optional_nulls_and_constant_unions_flatten() {
689 let mut schema = serde_json::json!({
690 "title": "X", "description": "dev", "type": "object",
691 "properties": {
692 "a": {"type": ["string", "null"], "enum": ["x", "y", null]},
693 "b": {"description": "outer", "anyOf": [
694 {"description": "inner", "oneOf": [
695 {"const": "p", "type": "string", "description": "dev"},
696 {"const": "q", "type": "string"}
697 ]},
698 {"type": "null"}
699 ]},
700 "c": {"type": ["integer", "null"], "format": "uint32", "default": null},
701 "d": {"type": ["string", "null"]},
702 "e": {"anyOf": [
703 {"type": "integer", "const": 1},
704 {"type": "integer", "const": -1}
705 ]},
706 "f": {"anyOf": [{"const": 1}, {"const": "x"}]}
707 },
708 "required": ["d"]
709 });
710 model_facing_schema(&mut schema);
711 assert_eq!(
712 schema,
713 serde_json::json!({
714 "type": "object",
715 "properties": {
716 "a": {"type": "string", "enum": ["x", "y"]},
717 "b": {"description": "outer", "type": "string", "enum": ["p", "q"]},
718 "c": {"type": "integer"},
719 "d": {"type": ["string", "null"]},
720 "e": {"type": "integer", "enum": [1, -1]},
721 "f": {"anyOf": [{"const": 1}, {"const": "x"}]}
722 },
723 "required": ["d"]
724 })
725 );
726 }
727
728 #[test]
729 fn names_are_unique() {
730 let mut names: Vec<_> = AGENT_TOOLS.iter().map(|t| t.name).collect();
731 names.sort_unstable();
732 let len = names.len();
733 names.dedup();
734 assert_eq!(names.len(), len);
735 }
736}