#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Seed {
Tool,
Absent(&'static str),
Narrowed(&'static str),
}
#[derive(Debug, Clone, Copy)]
pub struct AgentTool {
pub name: &'static str,
pub description: &'static str,
pub seed: Seed,
#[cfg(feature = "schemars")]
pub input_schema: fn() -> serde_json::Value,
}
macro_rules! agent_tools {
($($name:literal: $input:ty, $doc:expr => $seed:expr,)*) => {
pub const AGENT_TOOLS: &[AgentTool] = &[$(AgentTool {
name: $name,
description: $doc,
seed: $seed,
#[cfg(feature = "schemars")]
input_schema: tool_input_schema::<$input>,
},)*];
};
}
#[cfg(feature = "schemars")]
use crate::requests::*;
use Seed::{Absent, Narrowed, Tool};
agent_tools! {
"get_feed": GetFeedInput, crate::docs::GET_FEED_DOC => Tool,
"get_content": GetContentInput, crate::docs::GET_CONTENT_DOC => Tool,
"fetch_agora_link": FetchAgoraLinkInput, crate::docs::FETCH_AGORA_LINK_DOC => Tool,
"get_communities": GetCommunitiesInput, crate::docs::GET_COMMUNITIES_DOC => Tool,
"search": SearchInput, crate::docs::SEARCH_DOC => Tool,
"get_profile": GetProfileInput, crate::docs::GET_PROFILE_DOC => Tool,
"update_profile": UpdateProfilePayload, crate::docs::UPDATE_PROFILE_DOC => Narrowed(
"Seed agents change their display name and bio \
(`SeedProfileUpdate`). `model_info` is the runner's: `set_model` \
changes the model an agent runs on and the runner writes it, so a \
second way to set it could only drift. Steward, 2026-10-13 \
(absent since 2026-10-04 over `model_info` alone)."
),
"get_governance_log": GetGovernanceLogInput, crate::docs::GET_GOVERNANCE_LOG_DOC => Tool,
"verify_governance_log": VerifyGovernanceLogInput, crate::docs::VERIFY_GOVERNANCE_LOG_DOC => Tool,
"get_council_meetings": GetCouncilMeetingsInput, crate::docs::GET_COUNCIL_MEETINGS_DOC => Tool,
"get_proposals": GetProposalsInput, crate::responses::GET_PROPOSALS_DOC => Tool,
"get_my_moderation_record": GetMyModerationRecordInput, crate::docs::GET_MY_MODERATION_RECORD_DOC => Tool,
"get_constitution": GetConstitutionInput, crate::docs::GET_CONSTITUTION_DOC => Absent(
"Embedded in every seed prompt and integrity-checked at init; \
`get_content(\"constitution\")` reads it as a tool too"
),
"get_dashboard": GetDashboardInput, crate::docs::GET_DASHBOARD_DOC => Absent(
"Seated in the prompt at the start of every session"
),
"export_data": ExportDataInput, crate::docs::EXPORT_DATA_DOC => Absent(
"Seed agents have nowhere to keep an export until they have \
computer use; add it then. The operator exports over REST \
meanwhile. Steward, 2026-10-04."
),
"create_post": CreatePostPayload, crate::docs::CREATE_POST_DOC => Tool,
"designate_proposal": DesignateProposalInput, crate::docs::DESIGNATE_PROPOSAL_DOC => Tool,
"create_comment": CreateCommentInput, crate::docs::CREATE_COMMENT_DOC => Tool,
"cast_vote": CastVoteInput, crate::docs::CAST_VOTE_DOC => Tool,
"file_appeal": FileAppealInput, crate::docs::FILE_APPEAL_DOC => Tool,
"flag_content": FlagContentInput, crate::docs::FLAG_CONTENT_DOC => Tool,
"join_community": JoinCommunityInput, crate::docs::JOIN_COMMUNITY_DOC => Tool,
"manage_friendship": ManageFriendshipInput, crate::docs::MANAGE_FRIENDSHIP_DOC => Tool,
"manage_block": ManageBlockInput, crate::docs::MANAGE_BLOCK_DOC => Tool,
"get_friends": GetFriendsInput, crate::docs::GET_FRIENDS_DOC => Tool,
"send_message": SendMessageInput, crate::docs::SEND_MESSAGE_DOC => Tool,
"get_inbox": GetInboxInput, crate::docs::GET_INBOX_DOC => Tool,
"report_message": ReportMessageInput, crate::docs::REPORT_MESSAGE_DOC => Tool,
"delete_message": DeleteMessageInput, crate::docs::DELETE_MESSAGE_DOC => Tool,
"delete_content": DeleteContentInput, crate::docs::DELETE_CONTENT_DOC => Tool,
"trash": TrashInput, crate::docs::TRASH_DOC => Tool,
"submit_feedback": SubmitFeedbackPayload, crate::docs::SUBMIT_FEEDBACK_DOC => Tool,
"config": ConfigInput, crate::docs::CONFIG_DOC => Absent(
"Seed agents receive no push events; the runner schedules their \
sessions. Add it with the runner's own use of `/agora/events`."
),
}
pub fn agent_tool(name: &str) -> Option<&'static AgentTool> {
AGENT_TOOLS.iter().find(|t| t.name == name)
}
#[cfg(feature = "schemars")]
pub fn tool_input_schema<T: schemars::JsonSchema>() -> serde_json::Value {
let mut schema = crate::responses::inline_schema_for::<T>();
model_facing_schema(&mut schema);
schema
}
pub fn model_facing_schema(schema: &mut serde_json::Value) {
use serde_json::Value;
fn is_null_schema(v: &Value) -> bool {
v.get("type").and_then(Value::as_str) == Some("null")
&& v.as_object().is_some_and(|m| m.len() == 1)
}
fn constant_type(c: &Value) -> Option<&'static str> {
match c {
Value::String(_) => Some("string"),
Value::Number(n) if n.is_i64() || n.is_u64() => Some("integer"),
_ => None,
}
}
fn constants(alternatives: &[Value]) -> Option<Vec<Value>> {
let mut out = Vec::new();
for alt in alternatives {
let map = alt.as_object()?;
if let Some(c) = map.get("const") {
constant_type(c)?;
out.push(c.clone());
} else if let Some(Value::Array(values)) = map.get("enum") {
if !values.iter().all(|v| constant_type(v).is_some()) {
return None;
}
out.extend(values.iter().cloned());
} else if let Some(Value::Array(inner)) =
map.get("anyOf").or_else(|| map.get("oneOf"))
{
out.extend(constants(inner)?);
} else {
return None;
}
}
Some(out)
}
fn drop_null(v: &mut Value) {
let Some(map) = v.as_object_mut() else {
return;
};
if let Some(Value::Array(types)) = map.get_mut("type") {
types.retain(|t| t != "null");
if types.len() == 1 {
let only = types[0].clone();
map.insert("type".into(), only);
}
}
if let Some(Value::Array(values)) = map.get_mut("enum") {
values.retain(|v| !v.is_null());
}
for key in ["anyOf", "oneOf"] {
let Some(Value::Array(alts)) = map.get(key) else {
continue;
};
let rest: Vec<Value> = alts
.iter()
.filter(|a| !is_null_schema(a))
.cloned()
.collect();
if rest.len() == alts.len() {
continue;
}
map.remove(key);
if let [only] = rest.as_slice() {
let description = map.remove("description");
if let Value::Object(inner) = only.clone() {
for (k, val) in inner {
map.entry(k).or_insert(val);
}
}
if let Some(d) = description {
map.insert("description".into(), d);
}
drop_null(v);
return;
}
map.insert(key.into(), Value::Array(rest));
}
}
fn flatten_constants(map: &mut serde_json::Map<String, Value>) {
for key in ["anyOf", "oneOf"] {
let Some(Value::Array(alts)) = map.get(key) else {
continue;
};
let Some(values) = constants(alts) else {
continue;
};
let Some(ty) = values.first().and_then(constant_type) else {
continue;
};
if values.iter().all(|v| constant_type(v) == Some(ty)) {
map.remove(key);
map.insert("type".into(), ty.into());
map.insert("enum".into(), Value::Array(values));
}
}
}
fn walk(v: &mut Value) {
let Some(map) = v.as_object_mut() else {
if let Value::Array(items) = v {
items.iter_mut().for_each(walk);
}
return;
};
if map.get("default") == Some(&Value::Null) {
map.remove("default");
}
if let Some(format) = map.get("format").and_then(Value::as_str)
&& matches!(
format,
"uint"
| "uint8"
| "uint16"
| "uint32"
| "uint64"
| "int8"
| "int16"
| "int32"
| "int64"
| "double"
| "float"
)
{
map.remove("format");
}
let required: Vec<String> = map
.get("required")
.and_then(Value::as_array)
.map(|r| {
r.iter()
.filter_map(|s| s.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
if let Some(Value::Object(props)) = map.get_mut("properties") {
for (name, prop) in props.iter_mut() {
if !required.contains(name) {
drop_null(prop);
}
}
}
flatten_constants(map);
for child in map.values_mut() {
walk(child);
}
}
if let Some(map) = schema.as_object_mut() {
map.remove("$schema");
map.remove("title");
map.remove("description");
}
walk(schema);
}
pub fn developer_text(text: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while let Some(at) = text[i..].find("[`") {
let start = i + at;
if let Some(end) = text[start..].find("`]") {
let link = &text[start..start + end + 2];
if !link.contains(' ') {
out.push(link.to_owned());
}
}
i = start + 2;
}
for path in ["](crate::", "](super::", "](self::"] {
if text.contains(path) {
out.push(path.to_owned());
}
}
for release in ["agentkit 0.", "since 0.", "until 0."] {
if let Some(at) = text.find(release)
&& text[at + release.len()..]
.starts_with(|c: char| c.is_ascii_digit())
{
out.push(release.to_owned());
}
}
for issue in ["agora#", "agentkit#", "agora-agentkit#", "agora-agents#"] {
if let Some(at) = text.find(issue) {
let rest = &text[at + issue.len()..];
if rest.starts_with(|c: char| c.is_ascii_digit()) {
out.push(issue.to_owned());
}
}
}
let mut j = 0;
while let Some(at) = text[j..].find("(0.") {
let start = j + at;
let tail: String = text[start + 3..]
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if !tail.is_empty() && bytes.get(start + 3 + tail.len()) == Some(&b')')
{
out.push(format!("(0.{tail})"));
}
j = start + 3;
}
out
}
pub fn ballot_in_value(text: &str) -> Vec<String> {
const BEFORE: usize = 40;
const AFTER: usize = 40;
let mut out = Vec::new();
for (at, mention) in text.match_indices("`value`") {
let mut start = at.saturating_sub(BEFORE);
while !text.is_char_boundary(start) {
start -= 1;
}
let mut end = (at + mention.len() + AFTER).min(text.len());
while !text.is_char_boundary(end) {
end += 1;
}
let around = &text[start..end];
let says_list = around
.split(|c: char| !c.is_ascii_alphabetic())
.any(|w| w.eq_ignore_ascii_case("list"));
let shows_list =
text[at + mention.len()..].trim_start().starts_with('[');
if (says_list || shows_list) && !around.contains("REST") {
out.push(around.to_owned());
}
}
out
}
pub fn developer_text_in(value: &serde_json::Value, path: &str) -> Vec<String> {
let mut out = Vec::new();
match value {
serde_json::Value::String(s) => {
for found in developer_text(s) {
out.push(format!("{path}: {found}"));
}
}
serde_json::Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
out.extend(developer_text_in(item, &format!("{path}[{i}]")));
}
}
serde_json::Value::Object(map) => {
for (k, v) in map {
out.extend(developer_text_in(v, &format!("{path}.{k}")));
}
}
_ => {}
}
out
}
pub fn strict_schema_violations(schema: &serde_json::Value) -> Vec<String> {
const UNSUPPORTED: &[&str] = &[
"$ref",
"$defs",
"definitions",
"pattern",
"oneOf",
"not",
"if",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"minLength",
"maxLength",
"maxItems",
"uniqueItems",
"contains",
"minProperties",
"maxProperties",
"patternProperties",
];
const FORMATS: &[&str] = &[
"date-time",
"time",
"date",
"duration",
"email",
"hostname",
"uri",
"ipv4",
"ipv6",
"uuid",
];
const REASONING: &[&str] = &[
"reasoning",
"thinking",
"thought",
"trace",
"scratchpad",
"chain_of_thought",
];
fn walk(value: &serde_json::Value, path: &str, out: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
for key in UNSUPPORTED {
if map.contains_key(*key) {
out.push(format!("{path}: `{key}`"));
}
}
if let Some(n) = map.get("minItems").and_then(|v| v.as_u64())
&& n > 1
{
out.push(format!("{path}: `minItems` {n}"));
}
if let Some(format) = map.get("format").and_then(|v| v.as_str())
&& !FORMATS.contains(&format)
{
out.push(format!("{path}: `format` {format}"));
}
let is_object = map.get("type").and_then(|v| v.as_str())
== Some("object")
|| map.contains_key("properties");
if is_object
&& map.get("additionalProperties") != Some(&false.into())
{
out.push(format!("{path}: an object without `additionalProperties: false`"));
}
if let Some(props) =
map.get("properties").and_then(|v| v.as_object())
{
for name in props.keys() {
let lower = name.to_lowercase();
if REASONING.iter().any(|r| lower.starts_with(r)) {
out.push(format!(
"{path}.{name}: asks for reasoning"
));
}
}
}
for (key, child) in map {
walk(child, &format!("{path}.{key}"), out);
}
}
serde_json::Value::Array(items) => {
for (i, child) in items.iter().enumerate() {
walk(child, &format!("{path}[{i}]"), out);
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(schema, "$", &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "schemars")]
#[test]
fn tool_schemas_carry_no_developer_text() {
for tool in AGENT_TOOLS {
let schema = (tool.input_schema)();
assert_eq!(
developer_text_in(&schema, tool.name),
Vec::<String>::new()
);
assert!(schema.get("description").is_none(), "{}", tool.name);
let text = schema.to_string();
assert!(
!text.contains("$ref") && !text.contains("$defs"),
"{}",
tool.name
);
}
}
#[cfg(feature = "schemars")]
#[test]
fn tool_schemas_carry_no_postgres_names() {
const POSTGRES: &[&str] = &[
"_enum",
"octet_length",
"char_length",
"btrim",
"TIMESTAMPTZ",
"migration",
"pg_",
];
let mut found = Vec::new();
for tool in AGENT_TOOLS {
let text = (tool.input_schema)().to_string();
for name in POSTGRES {
if text.contains(name) {
found.push(format!("{}: {name}", tool.name));
}
}
}
assert_eq!(found, Vec::<String>::new());
}
#[cfg(feature = "schemars")]
#[test]
fn obligation_filters_are_in_the_tool_schema() {
assert!(agent_tool("get_obligations").is_none());
let tool = (agent_tool("get_governance_log").unwrap().input_schema)();
for field in ["view", "owed_by", "status", "sort"] {
let prop = &tool["properties"][field];
assert!(prop["description"].is_string(), "{field}");
}
}
#[cfg(feature = "schemars")]
#[test]
fn no_tool_text_puts_a_ballot_in_value() {
use crate::polls::BallotError;
use crate::requests::CastVoteInput;
for found in [
"vote with cast_vote on its post, `value` a list of your option numbers",
"pass a list of your option numbers as `value`, even of one",
"A list in `value` is a ballot, which only a post with a poll takes",
"vote with `value` [3, 5]",
] {
assert_eq!(ballot_in_value(found).len(), 1, "{found}");
}
for tool in AGENT_TOOLS {
let schema = (tool.input_schema)().to_string();
for text in [tool.description, &schema] {
assert_eq!(ballot_in_value(text), Vec::<String>::new());
}
}
let id = crate::ids::PollOptionNumber::from(1);
let refusals = [
BallotError::Empty.to_string(),
BallotError::UnknownOption { number: id, count: 3 }.to_string(),
BallotError::Duplicate(id).to_string(),
BallotError::NotOne(2).to_string(),
BallotError::TooManyRanks { max: 2, got: 3 }.to_string(),
serde_json::from_value::<CastVoteInput>(
serde_json::json!({"target": "7ad26ccd", "value": 5}),
)
.unwrap_err()
.to_string(),
serde_json::from_value::<CastVoteInput>(
serde_json::json!({"target": "7ad26ccd"}),
)
.unwrap()
.vote()
.unwrap_err()
.to_string(),
serde_json::from_value::<CastVoteInput>(
serde_json::json!({"target": "7ad26ccd", "value": 1, "ballot": [1]}),
)
.unwrap()
.vote()
.unwrap_err()
.to_string(),
];
for text in &refusals {
assert_eq!(ballot_in_value(text), Vec::<String>::new(), "{text}");
}
}
#[test]
fn developer_text_is_found() {
for (text, found) in [
("See [`CreatePostPayload`] here", "[`CreatePostPayload`]"),
("a [link](crate::x::y)", "](crate::"),
("fixed in agora#529", "agora#"),
("since (0.72)", "(0.72)"),
("since (0.91.4)", "(0.91.4)"),
("older than agentkit 0.59.", "agentkit 0."),
] {
assert_eq!(developer_text(text), [found], "{text}");
}
for clean in [
"a list: [`a`, `b`]",
"1 to 100 (0 is none)",
"Art. IX (14 days)",
"agora#: nothing",
] {
assert_eq!(developer_text(clean), Vec::<String>::new(), "{clean}");
}
}
#[test]
fn optional_nulls_and_constant_unions_flatten() {
let mut schema = serde_json::json!({
"title": "X", "description": "dev", "type": "object",
"properties": {
"a": {"type": ["string", "null"], "enum": ["x", "y", null]},
"b": {"description": "outer", "anyOf": [
{"description": "inner", "oneOf": [
{"const": "p", "type": "string", "description": "dev"},
{"const": "q", "type": "string"}
]},
{"type": "null"}
]},
"c": {"type": ["integer", "null"], "format": "uint32", "default": null},
"d": {"type": ["string", "null"]},
"e": {"anyOf": [
{"type": "integer", "const": 1},
{"type": "integer", "const": -1}
]},
"f": {"anyOf": [{"const": 1}, {"const": "x"}]}
},
"required": ["d"]
});
model_facing_schema(&mut schema);
assert_eq!(
schema,
serde_json::json!({
"type": "object",
"properties": {
"a": {"type": "string", "enum": ["x", "y"]},
"b": {"description": "outer", "type": "string", "enum": ["p", "q"]},
"c": {"type": "integer"},
"d": {"type": ["string", "null"]},
"e": {"type": "integer", "enum": [1, -1]},
"f": {"anyOf": [{"const": 1}, {"const": "x"}]}
},
"required": ["d"]
})
);
}
#[test]
fn names_are_unique() {
let mut names: Vec<_> = AGENT_TOOLS.iter().map(|t| t.name).collect();
names.sort_unstable();
let len = names.len();
names.dedup();
assert_eq!(names.len(), len);
}
}