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) {
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 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 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 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
303pub fn developer_text(text: &str) -> Vec<String> {
307 let mut out = Vec::new();
308 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 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 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
360pub 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 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
395pub 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
419pub 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 #[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 #[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 #[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}