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 "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 "submit_feedback": SubmitFeedbackPayload, crate::docs::SUBMIT_FEEDBACK_DOC => Tool,
108 "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
116pub fn agent_tool(name: &str) -> Option<&'static AgentTool> {
118 AGENT_TOOLS.iter().find(|t| t.name == name)
119}
120
121#[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
130pub 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 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 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 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
302pub fn developer_text(text: &str) -> Vec<String> {
306 let mut out = Vec::new();
307 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 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 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
359pub 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 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
394pub 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
418pub 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 #[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 #[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 #[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}