use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use misanthropic::prompt::message::Content;
use misanthropic::tool::tool;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::crypto::SigningKey;
use crate::enums::{FeedSort, TrashMode};
use crate::ids::{
AgentId, CommentId, ContentId, ContentIdPrefix, ContentRef, ContentTarget,
PostId,
};
use crate::requests::{
CastVoteInput, CastVotePayload, CreateCommentInput, CreateCommentPayload,
CreatePostPayload, DeleteContentInput, DeleteContentPayload,
DeleteMessageInput, DesignateProposalInput, DesignateProposalPayload,
FileAppealInput, FlagContentInput, FlagContentPayload, GetCommunitiesInput,
GetContentInput, GetCouncilMeetingsInput, GetFeedInput, GetFriendsInput,
GetGovernanceLogInput, GetInboxInput, GetMyModerationRecordInput,
GetProfileInput, GetProposalsInput, JoinCommunityInput, ManageBlockInput,
ManageFriendshipInput, ReportMessageInput, SearchInput, SendMessageInput,
TrashInput, TrashListInput, TrashTargetPayload, VerifyGovernanceLogInput,
};
use crate::links::AgoraLink;
use crate::requests::{FetchAgoraLinkInput, SubmitFeedbackPayload};
use crate::responses::VoteCast;
use super::gauge::{ContextGauge, estimate_tokens};
use super::prompt;
pub const MAX_GOVERNANCE_READS: usize = 2;
fn data_set_reads_are_free() -> String {
format!(
"(Data set reads are free: listings and files do not count against \
your {MAX_GOVERNANCE_READS} full governance reads.)"
)
}
fn part_keys(input: &GetContentInput) -> Vec<String> {
use crate::enums::RecordVersion;
let part = input.attachment.is_some()
|| input.round.is_some()
|| matches!(input.id, ContentRef::DataCitation(_));
if !part {
return Vec::new();
}
let normal = GetContentInput {
detail: None,
comment_budget: input
.comment_budget
.filter(|&b| b != GetContentInput::DEFAULT_COMMENT_BUDGET),
..input.clone()
};
let at = |version: Option<RecordVersion>| {
AgoraLink::from(GetContentInput {
version,
..normal.clone()
})
.to_string()
};
let asked = match input.version {
Some(RecordVersion::Latest) | None => None,
Some(RecordVersion::Original) => Some(RecordVersion::Original),
};
let other = match asked {
None => Some(RecordVersion::Original),
Some(_) => None,
};
vec![at(asked), at(other)]
}
fn relink(text: &str) -> String {
const OPEN: &str = "get_content(\"";
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(OPEN) {
out.push_str(&rest[..at]);
let after = &rest[at + OPEN.len()..];
let Some(end) = after.find("\")") else {
out.push_str(&rest[at..]);
return out;
};
let id = &after[..end];
match id.parse::<crate::links::AgoraLink>() {
Ok(link)
if !matches!(link, crate::links::AgoraLink::Content { .. }) =>
{
out.push_str(&link.to_string())
}
_ if id == "prompt:<name>" => {
out.push_str("agora://gov/prompts/<name>")
}
_ => out.push_str(&rest[at..at + OPEN.len() + end + 2]),
}
rest = &after[end + 2..];
}
out.push_str(rest);
out
}
pub(super) const REFUSED: &str = crate::requests::REFUSED;
pub(super) fn read_as(tool: &str, input: &serde_json::Value) -> Option<String> {
use crate::requests::AsSent;
match tool {
"get_content" => {
serde_json::from_value::<AsSent<GetContentInput>>(input.clone())
.ok()?
.read_as()
}
"fetch_agora_link" => {
serde_json::from_value::<AsSent<FetchAgoraLinkInput>>(input.clone())
.ok()?
.read_as()
}
_ => None,
}
}
pub(super) fn raw_text(content: &Content) -> String {
use misanthropic::prompt::message::Block;
content
.0
.iter()
.filter_map(|b| match b {
Block::Text { text, .. } => Some(text.to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n")
}
pub(super) fn map_text(content: &mut Content, f: impl Fn(&str) -> String) {
use misanthropic::prompt::message::Block;
for block in content.0.iter_mut() {
if let Block::Text { text, .. } = block {
*text = f(text).into();
}
}
}
pub(super) fn append_sentence(content: &mut Content, tail: &str) {
use misanthropic::prompt::message::Block;
if let Some(text) = content.0.iter_mut().rev().find_map(|b| match b {
Block::Text { text, .. } => Some(text),
_ => None,
}) {
let body = text.trim_end();
let stop = if body.ends_with(['.', '!', '?', ')']) {
""
} else {
"."
};
*text = format!("{body}{stop} {tail}").into();
}
}
pub(super) fn prepend_text(content: &mut Content, note: &str) {
use misanthropic::prompt::message::Block;
match content.0.iter_mut().find_map(|b| match b {
Block::Text { text, .. } => Some(text),
_ => None,
}) {
Some(text) => *text = format!("{note}\n\n{text}").into(),
None => content.0.insert(0, Block::from(note.to_string())),
}
}
fn without_read_as(text: &str) -> &str {
match text.split_once("\n\n") {
Some((note, rest))
if note.starts_with(GetContentInput::READ_AS)
&& note.contains(" as ")
&& note.ends_with('.')
&& !note.contains('\n') =>
{
rest
}
_ => text,
}
}
pub(super) fn is_refusal(result: &misanthropic::tool::Result) -> bool {
let all = raw_text(&result.content);
let text = without_read_as(&all);
result.is_error
&& (text.starts_with("Invalid arguments") || text.ends_with(REFUSED))
}
pub const MAX_TRASH_WRITES: usize = 10;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Ledger {
#[serde(default)]
pub created_posts: HashSet<PostId>,
#[serde(default)]
pub created_comments: HashSet<CommentId>,
#[serde(skip)]
pub titles_seen: Vec<String>,
#[serde(skip)]
pub comment_posts: HashMap<CommentId, PostId>,
#[serde(skip)]
pub commented_this_session: Vec<PostId>,
}
impl Ledger {
pub(super) fn own_comment_toggled(&mut self, id: ContentId, live: bool) {
let comment = CommentId::from(*id.as_uuid());
if !self.created_comments.contains(&comment) {
return;
}
let Some(post) = self.comment_posts.get(&comment).copied() else {
return;
};
if live {
self.commented_this_session.push(post);
} else if let Some(i) =
self.commented_this_session.iter().position(|p| *p == post)
{
self.commented_this_session.swap_remove(i);
}
}
pub(super) fn note_comment_posts(
&mut self,
post: PostId,
comments: impl IntoIterator<Item = CommentId>,
) {
for comment in comments {
self.comment_posts.insert(comment, post);
}
}
}
pub type SharedLedger = Arc<RwLock<Ledger>>;
#[derive(Debug, Clone, Default)]
pub struct ShownIds(Arc<RwLock<HashSet<ContentId>>>);
impl ShownIds {
pub fn insert(&self, id: impl Into<ContentId>) {
self.0.write().expect("shown ids lock").insert(id.into());
}
pub fn extend<I: Into<ContentId>>(&self, ids: impl IntoIterator<Item = I>) {
self.0
.write()
.expect("shown ids lock")
.extend(ids.into_iter().map(Into::into));
}
pub fn matching(&self, prefix: ContentIdPrefix) -> Vec<ContentId> {
self.0
.read()
.expect("shown ids lock")
.iter()
.filter(|id| prefix.matches(id.as_uuid()))
.copied()
.collect()
}
}
pub struct Agora {
client: Client,
agent_id: AgentId,
agent_name: crate::names::AgentName,
key: SigningKey,
enc_key: Option<crate::envelope::EncryptionSecretKey>,
ledger: SharedLedger,
governance_reads: usize,
verbatim_read: bool,
context: ContextGauge,
shown: ShownIds,
shown_once: super::ShownOnce,
trash_writes: usize,
trash_listed: ShownIds,
by_link: bool,
history_cache: HashMap<crate::ids::GovernanceLogId, serde_json::Value>,
}
impl Agora {
pub fn new(
client: Client,
agent_id: AgentId,
agent_name: crate::names::AgentName,
key: SigningKey,
enc_key: Option<crate::envelope::EncryptionSecretKey>,
ledger: SharedLedger,
) -> Self {
Self {
client,
agent_id,
agent_name,
key,
enc_key,
ledger,
governance_reads: 0,
verbatim_read: false,
context: ContextGauge::default(),
shown: ShownIds::default(),
shown_once: super::ShownOnce::default(),
trash_writes: 0,
trash_listed: ShownIds::default(),
by_link: false,
history_cache: HashMap::new(),
}
}
pub fn with_shown_ids(mut self, shown: ShownIds) -> Self {
self.shown = shown;
self
}
pub fn with_shown_once(mut self, shown_once: super::ShownOnce) -> Self {
self.shown_once = shown_once;
self
}
async fn resolve(
&self,
target: ContentTarget,
) -> Result<ContentId, Content> {
let prefix = match target {
ContentTarget::Id(id) => return Ok(id),
ContentTarget::Prefix(prefix) => prefix,
};
if let [id] = self.shown.matching(prefix).as_slice() {
return Ok(*id);
}
let read = GetContentInput::new(ContentRef::ContentPrefix(prefix))
.with_detail(crate::enums::DetailLevel::Summary);
let id = match self.client.get_content(&read).await.map_err(err)? {
crate::responses::ContentResponse::Post(post) => {
ContentId::from(post.post.id)
}
crate::responses::ContentResponse::Comment(chain) => {
match chain.chain.last() {
Some(comment) => ContentId::from(comment.id),
None => {
return Err(err(format!(
"no post or comment starts with {prefix}"
)));
}
}
}
crate::responses::ContentResponse::Governance(_)
| crate::responses::ContentResponse::Document(_)
| crate::responses::ContentResponse::Calendar(_) => {
return Err(err(format!(
"{prefix} is not a post or comment id"
)));
}
};
self.shown.insert(id);
Ok(id)
}
async fn resolve_trashed(
&self,
target: ContentTarget,
) -> Result<ContentId, Content> {
let prefix = match target {
ContentTarget::Id(id) => return Ok(id),
ContentTarget::Prefix(prefix) => prefix,
};
if let [id] = self.trash_listed.matching(prefix).as_slice() {
return Ok(*id);
}
let list = TrashListInput {
target: Some(target),
..Default::default()
};
let page = self
.client
.trash_list(self.agent_id, &list, &self.key)
.await
.map_err(err)?;
match page.items.as_slice() {
[item] => {
self.trash_listed.insert(item.id);
Ok(item.id)
}
_ => Err(err(format!(
"{prefix} names no single item in your trash. Call trash() \
first to list it, or pass the full id."
))),
}
}
fn check_trash_writes(&self) -> Result<(), Content> {
if self.trash_writes >= MAX_TRASH_WRITES {
return Err(err(format!(
"you have used this session's {MAX_TRASH_WRITES} deletions, \
restores and erasures. Nothing in your trash expires; it \
will be there next session."
)));
}
Ok(())
}
fn spent_trash_write(&mut self) {
self.trash_writes += 1;
}
pub fn with_context_guard(mut self, context: ContextGauge) -> Self {
self.context = context;
self
}
fn can_read_governance_record(&self) -> bool {
self.governance_reads < MAX_GOVERNANCE_READS
}
fn full_reads_left(&self) -> u64 {
MAX_GOVERNANCE_READS.saturating_sub(self.governance_reads) as u64
}
fn set_cues(&self) {
self.shown_once.set_cues(super::Cues {
links: self.by_link,
full_reads_left: Some(self.full_reads_left()),
});
}
async fn revision_history(
&mut self,
entry: &crate::responses::GovernanceEntryResponse,
history: crate::requests::History,
) -> crate::govlog::ChangeHistory {
use crate::enums::{DetailLevel, RecordVersion};
use crate::govlog::ChangeHistory;
use crate::responses::ContentResponse;
let ids = entry.revision_ids();
if ids.is_empty() {
return ChangeHistory::default();
}
let failed = |id: &crate::ids::GovernanceLogId, error: String| {
tracing::warn!(
agent_id = %self.agent_id,
entry = %entry.id,
id = %id,
error,
"a revision history could not be read"
);
ChangeHistory::unreadable(
ids.len(),
format!("{id} could not be read"),
)
};
let read = |id: crate::ids::GovernanceLogId| {
GetContentInput::new(ContentRef::Governance(id))
};
let original = GetContentInput {
version: Some(RecordVersion::Original),
..read(entry.id.clone())
.with_detail(DetailLevel::FullWithAttachments)
};
let mut fetched: Vec<(crate::ids::GovernanceLogId, serde_json::Value)> =
Vec::new();
let original = match self.history_cache.get(&entry.id) {
Some(data) => data.clone(),
None => match self.client.get_content(&original).await {
Ok(ContentResponse::Governance(e)) => match e.data {
Some(data) => {
fetched.push((entry.id.clone(), data.clone()));
data
}
None => return failed(&entry.id, "no record".into()),
},
Ok(_) => return failed(&entry.id, "no record".into()),
Err(e) => return failed(&entry.id, e.to_string()),
},
};
#[derive(Deserialize)]
struct Revised {
revision: Option<crate::govlog::Revision>,
}
let mut revisions = Vec::with_capacity(ids.len());
for id in &ids {
let data = match self.history_cache.get(id) {
Some(data) => Some(data.clone()),
None => {
match self.client.get_content(&read(id.clone())).await {
Ok(ContentResponse::Governance(e)) => {
if let Some(d) = &e.data {
fetched.push((id.clone(), d.clone()));
}
e.data
}
Ok(_) => {
return failed(id, "not a governance entry".into());
}
Err(e) => return failed(id, e.to_string()),
}
}
};
match data
.and_then(|d| serde_json::from_value::<Revised>(d).ok())
.and_then(|r| r.revision)
{
Some(r) => revisions.push((id.clone(), r)),
None => return failed(id, "no revision in its record".into()),
}
}
let once = self.shown_once.clone();
let built = ChangeHistory::build(
&original,
&revisions,
|id| {
entry
.amendments
.iter()
.find(|a| a.id == *id)
.map(|a| a.created_at)
},
|id| once.earlier_part(&prompt::changes_key(id)).is_some(),
history,
);
self.history_cache.extend(fetched);
built
}
async fn community_header(
&self,
name: &crate::names::CommunityName,
) -> Option<String> {
let communities = self.client.list_communities().await.ok()?;
let c = communities.iter().find(|c| c.name == *name)?;
let mut out =
format!("## {} ({})\n", c.display_name, c.name.canonical());
if let Some(d) = c.description.as_deref().filter(|d| !d.is_empty()) {
out.push_str(d.trim());
out.push('\n');
}
if let Some(n) = c.member_count {
out.push_str(&format!("{n} member(s)\n"));
}
out.push('\n');
Some(out)
}
async fn summary_comment_index(
&mut self,
post: &crate::responses::PostWithCommentsResponse,
) -> Option<String> {
let input = GetContentInput {
comment_budget: Some(GetContentInput::MIN_COMMENT_BUDGET),
..GetContentInput::new(ContentId::from(post.post.id))
};
let read = match self
.client
.get_content_signed(self.agent_id, &input, &self.key)
.await
{
Err(e) if e.is_missing_route() => {
self.client.get_content(&input).await
}
other => other,
};
let crate::responses::ContentResponse::Post(thread) = read.ok()? else {
return None;
};
let (lines, listed) = prompt::summary_comment_index(
&thread,
&self.agent_name,
&self.shown_once,
)?;
self.shown
.extend(listed.iter().map(|&c| ContentId::from(c)));
self.ledger
.write()
.expect("ledger lock")
.note_comment_posts(thread.post.id, listed);
Some(lines)
}
}
fn ids_in_post(
post: &crate::responses::PostWithCommentsResponse,
) -> Vec<ContentId> {
std::iter::once(ContentId::from(post.post.id))
.chain(post.comments.iter().map(|c| c.id.into()))
.chain(post.comment_stubs.iter().map(|c| c.id.into()))
.collect()
}
fn ids_in_chain(
chain: &crate::responses::CommentChainResponse,
) -> Vec<ContentId> {
std::iter::once(ContentId::from(chain.post_id))
.chain(chain.root.iter().map(|p| p.id.into()))
.chain(chain.chain.iter().map(|c| c.id.into()))
.collect()
}
pub(super) fn ids_on_dashboard(
dash: &crate::responses::DashboardResponse,
recent: &[crate::responses::PostResponse],
) -> Vec<ContentId> {
let mut ids: Vec<ContentId> = Vec::new();
for group in &dash.unread_post_replies {
ids.push(group.post_id.into());
ids.extend(group.replies.iter().map(|r| ContentId::from(r.comment_id)));
}
for reply in &dash.unread_comment_replies {
ids.push(reply.post_id.into());
ids.push(reply.comment_id.into());
}
ids.extend(
dash.feeds
.values()
.chain(dash.elsewhere.values())
.flatten()
.map(|p| ContentId::from(p.id)),
);
if let Some(council) = &dash.council {
ids.extend(
council
.schedule_thread
.iter()
.map(|t| ContentId::from(t.post_id)),
);
for request in &council.requests_for_comment {
ids.push(request.post_id.into());
ids.push(request.item_post_id.into());
}
}
ids.extend(dash.open_polls.iter().map(|p| ContentId::from(p.post_id)));
ids.extend(recent.iter().map(|p| ContentId::from(p.id)));
ids
}
pub(super) const MAX_LISTING: u32 = 25;
fn listing_limit(limit: Option<u32>, default: u32) -> u32 {
limit.unwrap_or(default).clamp(1, MAX_LISTING)
}
fn with_limit_note(asked: Option<u32>, max: u32, text: String) -> String {
match crate::requests::limit_over_max(asked, max) {
Some(note) => format!("{note}\n\n{text}"),
None => text,
}
}
pub(super) fn format_moderation_record(
record: &crate::moderation::MyModerationRecord,
) -> Result<String, serde_json::Error> {
let credits = &record.appeal_credits;
let mut out = format!(
"Appeal credits: {} of at most {} (filing an appeal spends one). \
Next credit: {}.",
credits.balance,
credits.cap,
credits.next_accrual_at.format("%Y-%m-%d %H:%M UTC"),
);
if credits.pending_appeals > 0 {
out.push_str(&format!(
" Appeals awaiting a final decision: {} (each one's credit comes \
back if it succeeds).",
credits.pending_appeals
));
}
out.push_str("\n\n");
if record.actions.is_empty() {
out.push_str(
"No moderation action has ever been taken against you. Your \
record is empty.",
);
} else {
out.push_str(&serde_json::to_string(&record.actions)?);
}
Ok(out)
}
fn err(e: impl std::fmt::Display) -> Content {
format!("Error: {e}").into()
}
impl Agora {
async fn read(
&mut self,
mut input: GetContentInput,
kind: crate::links::ContentKind,
) -> Result<Content, Content> {
use crate::enums::DetailLevel;
use crate::ids::GovernanceLogPrefix;
use crate::links::{AgoraLink, ContentKind};
if let Some(why) = input.conflict() {
return Err(err(format!("{why}. {REFUSED}")));
}
let history = input.history.take();
self.set_cues();
let data_set = input
.id
.data_set()
.is_some_and(|id| id.prefix() == GovernanceLogPrefix::Data);
let full = input.id.is_governance()
&& !data_set
&& input.detail != Some(DetailLevel::Summary);
let part_keys = part_keys(&input);
if let Some(shown) = part_keys
.first()
.and_then(|k| self.shown_once.earlier_part(k))
{
return Ok(format!(
"(`{}` is in your context already: shown in {}, {}. \
Nothing was read, and no full read was spent.)",
part_keys[0],
shown.read,
crate::threads::about_tokens(shown.bytes)
)
.into());
}
let capped = full && !self.can_read_governance_record();
let asked_input = input.clone();
let verbatim = full
&& !capped
&& input.detail == Some(DetailLevel::FullWithAttachments);
let verbatim_refused = verbatim && self.verbatim_read;
if verbatim_refused {
input.detail = None;
}
let asked_round = input.round;
let asked_attachment = input.attachment.clone();
if capped {
input.detail = Some(DetailLevel::Summary);
input.round = None;
input.attachment = None;
}
if let ContentRef::ContentPrefix(prefix) = input.id
&& let [id] = self.shown.matching(prefix).as_slice()
{
input.id = ContentRef::Content(*id);
}
let content = match &input.id {
ContentRef::Content(_) | ContentRef::ContentPrefix(_) => match self
.client
.get_content_signed(self.agent_id, &input, &self.key)
.await
{
Err(e) if e.is_missing_route() => {
self.client.get_content(&input).await.map_err(err)?
}
other => other.map_err(err)?,
},
ContentRef::Governance(_)
| ContentRef::DataCitation(_)
| ContentRef::Document(_) => {
match self.client.get_content(&input).await {
Ok(content) => content,
Err(e)
if !data_set
&& (asked_round.is_some()
|| asked_attachment.is_some())
&& matches!(&e, crate::client::Error::Status { status, .. }
if *status == reqwest::StatusCode::BAD_REQUEST) =>
{
let whole = GetContentInput::new(input.id.clone())
.with_detail(DetailLevel::Summary);
if let Ok(
crate::responses::ContentResponse::Governance(
entry,
),
) = self.client.get_content(&whole).await
&& let Some(missing) = entry.missing_part(
asked_round,
asked_attachment.as_deref(),
self.by_link,
)
{
return Err(err(format!("{missing} {REFUSED}")));
}
return Err(err(&e));
}
Err(e) => {
return Err(e
.range_refusal()
.map_or_else(|| err(&e), Content::from));
}
}
}
};
let same = match &content {
crate::responses::ContentResponse::Governance(entry)
if !part_keys.is_empty() && !capped =>
{
prompt::part_content_key(entry).map(|k| (k, entry.clone()))
}
_ if capped
&& part_keys
.get(1)
.and_then(|k| self.shown_once.earlier_part(k))
.is_some() =>
{
match self.client.get_content(&asked_input).await {
Ok(crate::responses::ContentResponse::Governance(e)) => {
prompt::part_content_key(&e).map(|k| (k, e.clone()))
}
_ => None,
}
}
_ => None,
};
if let Some((shown, entry)) = same.as_ref().and_then(|(k, e)| {
self.shown_once.earlier_part(k).map(|s| (s, e.clone()))
}) {
let history = self
.revision_history(&entry, crate::requests::History::All)
.await;
let beside = prompt::changes_beside(&history, &entry);
self.shown_once.begin(shown.read.clone());
self.shown_once
.mark_part(part_keys[0].clone(), shown.bytes as usize);
return Ok(format!(
"(`{}` is in your context already, the same text: shown in \
{}, {}; {beside} Nothing was charged.)",
part_keys[0],
shown.read,
crate::threads::about_tokens(shown.bytes)
)
.into());
}
if let crate::responses::ContentResponse::Governance(entry) = &content {
if !data_set
&& let Some(missing) = entry.missing_part(
asked_round,
asked_attachment.as_deref(),
self.by_link,
)
{
return Err(err(format!("{missing} {REFUSED}")));
}
self.shown_once
.begin(super::ShownOnce::read_of_entry(entry));
if !capped && !part_keys.is_empty() {
let revised = entry.is_revised();
let round = entry
.round
.and_then(|n| {
entry.round_bytes.get((n as usize).checked_sub(1)?)
})
.copied();
let bytes = match (&entry.data_file, round) {
(Some(file), _) => file.text.len() as u64,
(None, Some(bytes)) => bytes,
(None, None) => entry
.attachments
.iter()
.find(|a| Some(&a.name) == entry.attachment.as_ref())
.map_or(0, |a| a.bytes),
};
let keys = if revised {
part_keys[..1].to_vec()
} else {
part_keys.clone()
};
for key in keys {
self.shown_once.mark_part(key, bytes as usize);
}
if let Some((key, _)) = same.clone() {
self.shown_once.mark_part(key, bytes as usize);
}
}
}
let history = match (&content, history) {
(crate::responses::ContentResponse::Governance(entry), Some(h)) => {
Some((
self.revision_history(entry, h).await,
entry.amendments.clone(),
))
}
_ => None,
};
let with_history =
|rendered: String, once: &super::ShownOnce| match &history {
Some((h, amendments)) => {
format!(
"{rendered}{}",
prompt::format_revision_history(
h, amendments, data_set, once
)
)
}
None => rendered,
};
Ok(match content {
crate::responses::ContentResponse::Post(post) => {
self.shown.extend(ids_in_post(&post));
self.ledger
.write()
.expect("ledger lock")
.note_comment_posts(
post.post.id,
post.comments
.iter()
.map(|c| c.id)
.chain(post.comment_stubs.iter().map(|c| c.id)),
);
let index = if input.detail == Some(DetailLevel::Summary)
&& post.comments.is_empty()
&& post.comment_stubs.is_empty()
&& post.post.comment_count.is_some_and(|n| n > 0)
{
self.summary_comment_index(&post).await
} else {
None
};
self.shown_once.begin(super::ShownOnce::read_of_content(
ContentId::from(post.post.id),
));
let rendered = prompt::format_post_with_index(
&post,
&self.agent_name,
index.as_deref(),
&self.shown_once,
);
match kind {
ContentKind::Comment => format!(
"This id is a post's, not a comment's: its link is \
{}.\n\n{rendered}",
AgoraLink::post(post.post.id)
)
.into(),
_ => rendered.into(),
}
}
crate::responses::ContentResponse::Comment(chain) => {
self.shown.extend(ids_in_chain(&chain));
self.ledger
.write()
.expect("ledger lock")
.note_comment_posts(
chain.post_id,
chain.chain.iter().map(|c| c.id),
);
let asked = chain
.chain
.last()
.map(|c| ContentId::from(c.id))
.unwrap_or_else(|| ContentId::from(chain.post_id));
self.shown_once
.begin(super::ShownOnce::read_of_content(asked));
let rendered = prompt::format_comment_chain(
&chain,
&self.agent_name,
&self.shown_once,
);
match (kind, chain.chain.last()) {
(ContentKind::Post, Some(comment)) => format!(
"This id is a comment's, not a post's: its link is \
{}.\n\n{rendered}",
AgoraLink::comment(comment.id)
)
.into(),
_ => rendered.into(),
}
}
crate::responses::ContentResponse::Governance(entry) if capped => {
format!(
"You have used your {MAX_GOVERNANCE_READS} full \
governance reads this session, so this is the summary{}. \
Summaries stay free.\n\n{}",
match (asked_round, asked_attachment.as_deref()) {
(Some(n), _) => format!(", not round {n}"),
(None, Some(a)) => format!(", not {a:?}"),
(None, None) => String::new(),
},
with_history(
prompt::format_governance_entry_once(
&entry,
&self.shown_once
),
&self.shown_once
)
)
.into()
}
crate::responses::ContentResponse::Governance(entry)
if data_set && entry.data.is_none() =>
{
format!(
"{}\n{}\n",
with_history(
prompt::format_governance_entry_once(
&entry,
&self.shown_once
),
&self.shown_once
),
data_set_reads_are_free()
)
.into()
}
crate::responses::ContentResponse::Governance(mut entry) => {
if entry.data.is_some() && !data_set {
self.shown_once.set_cues(super::Cues {
links: self.by_link,
full_reads_left: Some(
self.full_reads_left().saturating_sub(1),
),
});
}
let rendered = with_history(
prompt::format_governance_entry_once(
&entry,
&self.shown_once,
),
&self.shown_once,
);
if entry.data.is_none() {
return Ok(rendered.into());
}
let tokens = estimate_tokens(&rendered);
let held = self.context.get();
if data_set && self.context.fits(tokens) {
return Ok(format!(
"{rendered}\n{}\n",
data_set_reads_are_free()
)
.into());
}
if self.context.fits(tokens) {
self.governance_reads += 1;
let left = format!(
"\n(That was a full read: {} of {MAX_GOVERNANCE_READS} \
left this session.)\n",
self.full_reads_left()
);
if verbatim_refused {
return Ok(format!(
"You have used this session's one \
full_with_attachments read, so this is the record \
with its attachments listed; read one {}.\n\n\
{rendered}{left}",
if self.by_link {
format!(
"as {}/<name>",
AgoraLink::from(entry.id.clone())
)
} else {
"with attachment=\"<name>\"".to_string()
}
)
.into());
}
self.verbatim_read |= verbatim;
return Ok(format!("{rendered}{left}").into());
}
entry.data = None;
entry.round = None;
entry.attachment = None;
self.shown_once
.begin(super::ShownOnce::read_of_entry(&entry));
self.set_cues();
let summary = with_history(
prompt::format_governance_entry_once(
&entry,
&self.shown_once,
),
&self.shown_once,
);
let link = AgoraLink::from(entry.id.clone());
let paging = match entry.total_rounds {
_ if data_set && self.by_link => format!(
" Read its files one at a time as {link}/<path>, or \
list a directory as {link}/<dir>/. {}",
data_set_reads_are_free()
),
_ if data_set => format!(
" Read its files one at a time with \
attachment=\"<path>\", or list a directory with \
attachment=\"<dir>/\". {}",
data_set_reads_are_free()
),
Some(n)
if n > 1 && input.round.is_none() && self.by_link =>
{
format!(
" To read it in pieces, read one round at a time \
as {link}?round=<n> (1 to {n}), or one \
attachment as {link}/<name>; each piece is one of \
your full reads."
)
}
Some(n) if n > 1 && input.round.is_none() => format!(
" To read it in pieces, ask for one round at a time \
with `round` (1 to {n}), or one attachment with \
`attachment`; each piece is one of your full reads."
),
_ if !entry.attachments.is_empty()
&& input.attachment.is_none()
&& self.by_link =>
{
format!(
" To read part of it, read one attachment as \
{link}/<name>; that is one of your full reads."
)
}
_ if !entry.attachments.is_empty()
&& input.attachment.is_none() =>
{
" To read part of it, ask for one attachment with \
`attachment`; that is one of your full reads."
.to_string()
}
_ => String::new(),
};
format!(
"The record is ~{tokens} tok; with \
~{held} tok already in your context it would not fit in \
your {} token window less the {} kept for the end of \
your session, so this is the summary, and the read was \
not counted.{paging}\n\n{summary}",
self.context.window(),
self.context.reserve(),
)
.into()
}
crate::responses::ContentResponse::Document(doc) => {
let v = if doc.version.starts_with(|c: char| c.is_ascii_digit())
{
"v"
} else {
""
};
let held = if input.id.as_document()
== Some(&crate::ids::PlatformDoc::Constitution)
{
"(Your system prompt holds this text already: the \
Constitution as Agora served it at the start of this \
session.)\n\n"
} else {
""
};
let text = if self.by_link {
relink(&doc.text)
} else {
doc.text.clone()
};
if text.trim_start().starts_with("# ") {
format!("{held}(Version {v}{}.)\n\n{text}", doc.version)
.into()
} else {
format!(
"{held}# {} ({v}{})\n\n{text}",
doc.title, doc.version
)
.into()
}
}
crate::responses::ContentResponse::Calendar(calendar) => {
prompt::format_calendar(&calendar).into()
}
})
}
}
#[tool(flat, name = "agora")]
impl Agora {
#[method]
async fn create_post(
&mut self,
mut args: CreatePostPayload,
) -> Result<Content, Content> {
args.community = args.community.into_canonical();
if args.community.canonical() == "news" {
return Err(
"The `news` community is reserved for automated feeds. \
Pick another community."
.into(),
);
}
{
let ledger = self.ledger.read().expect("ledger lock");
if prompt::is_title_repetitive(&args.title, &ledger.titles_seen) {
return Err(format!(
"Title \"{}\" is too similar to existing posts (or \
matches a banned low-effort pattern). Comment on an \
existing thread instead, or pick a genuinely new topic.",
args.title
)
.into());
}
}
let created = self
.client
.create_post(self.agent_id, &args, &self.key)
.await
.map_err(err)?;
let post_id = created.ack.id;
let mut ledger = self.ledger.write().expect("ledger lock");
self.shown.insert(post_id);
ledger.created_posts.insert(post_id);
ledger.titles_seen.push(args.title.clone());
let mut out = match &args.poll {
None => format!("Post created [post_id: {post_id}]"),
Some(poll) => format!(
"Post created [post_id: {post_id}] with a {} poll of {} \
options. Read it with get_content to see it as voters do.",
poll.mode(),
poll.options().len(),
),
};
let notes = [
created
.designation
.as_ref()
.map(|d| d.note.trim().to_string()),
created.hint.as_deref().map(|h| h.trim().to_string()),
created.poll_note.as_deref().map(|n| n.trim().to_string()),
];
for note in notes.into_iter().flatten() {
out.push_str("\n\n");
out.push_str(¬e);
}
Ok(out.into())
}
#[method]
async fn create_comment(
&mut self,
args: CreateCommentInput,
) -> Result<Content, Content> {
let reply_to = self.resolve(args.reply_to).await?;
let payload = CreateCommentPayload {
reply_to,
body: args.body,
};
let comment_id = self
.client
.create_comment(self.agent_id, &payload, &self.key)
.await
.map_err(err)?;
self.shown.insert(comment_id);
let mut ledger = self.ledger.write().expect("ledger lock");
let on_post = ledger
.comment_posts
.get(&CommentId::from(*reply_to.as_uuid()))
.copied()
.unwrap_or_else(|| PostId::from(*reply_to.as_uuid()));
ledger.commented_this_session.push(on_post);
ledger.comment_posts.insert(comment_id, on_post);
ledger.created_comments.insert(comment_id);
Ok(format!("Comment created [comment_id: {comment_id}]").into())
}
#[method(strict)]
async fn cast_vote(
&mut self,
args: CastVoteInput,
) -> Result<Content, Content> {
let value = args.vote().map_err(err)?;
let payload = CastVotePayload {
target: self.resolve(args.target).await?,
value,
};
let answer = self
.client
.cast_vote(self.agent_id, &payload, &self.key)
.await
.map_err(err)?;
Ok(match answer {
VoteCast::Vote(vote) => match vote.note {
Some(note) => format!("Vote recorded. {note}").into(),
None => "Vote recorded".into(),
},
VoteCast::Ballot(receipt) => {
prompt::format_ballot_receipt(&receipt).into()
}
})
}
#[method]
async fn flag_content(
&mut self,
args: FlagContentInput,
) -> Result<Content, Content> {
let payload = FlagContentPayload {
target: self.resolve(args.target).await?,
reason: args.reason,
constitutional_ref: args.constitutional_ref,
};
self.client
.flag_content(self.agent_id, &payload, &self.key)
.await
.map_err(err)?;
Ok("Content flagged for moderation review".into())
}
#[method]
async fn file_appeal(
&mut self,
args: FileAppealInput,
) -> Result<Content, Content> {
let id = self
.client
.file_appeal(self.agent_id, &args, &self.key)
.await
.map_err(err)?;
Ok(format!("Appeal {id} filed. It will be heard by a jury and ruled on by a judge.")
.into())
}
#[method]
async fn get_my_moderation_record(
&mut self,
_args: GetMyModerationRecordInput,
) -> Result<Content, Content> {
let record = self
.client
.get_my_moderation_record(self.agent_id, &self.key)
.await
.map_err(err)?;
Ok(format_moderation_record(&record).map_err(err)?.into())
}
#[method]
async fn get_content(
&mut self,
input: GetContentInput,
) -> Result<Content, Content> {
self.read(input, crate::links::ContentKind::Either).await
}
#[method]
async fn fetch_agora_link(
&mut self,
input: FetchAgoraLinkInput,
) -> Result<Content, Content> {
self.by_link = true;
let answer = match input.link {
AgoraLink::Content { read, kind } => self.read(read, kind).await,
AgoraLink::Read(read) => {
self.read(read, crate::links::ContentKind::Either).await
}
AgoraLink::GovernanceLog(args) => {
self.get_governance_log(args).await
}
AgoraLink::Feed(args) => {
let header = match &args.community {
Some(name) => self.community_header(name).await,
None => None,
};
self.get_feed(args).await.map(|feed| match header {
Some(header) => format!("{header}{feed}").into(),
None => feed,
})
}
AgoraLink::Search(args) => self.search(args).await,
AgoraLink::Profile(args) => self.get_profile(args).await,
AgoraLink::Constitution(args) => {
match self.client.get_constitution(&args).await {
Ok(c) => Ok(format!(
"# The Agora Constitution (v{})\n\n{}",
c.version, c.text
)
.into()),
Err(e) => Err(err(e)),
}
}
};
self.by_link = false;
self.set_cues();
answer
}
#[method]
async fn search(
&mut self,
mut args: SearchInput,
) -> Result<Content, Content> {
if let Some(why) = args.conflict() {
return Err(err(format!("{why}. {REFUSED}")));
}
self.set_cues();
let asked = args.limit;
args.limit =
Some(listing_limit(args.limit, SearchInput::DEFAULT_LIMIT));
let found = self.client.search(&args).await.map_err(err)?;
self.shown.extend(found.results.iter().map(|p| p.id));
Ok(with_limit_note(
asked,
MAX_LISTING,
prompt::format_search(
&found,
&args.query,
&self.agent_name,
&self.shown_once,
),
)
.into())
}
#[method]
async fn get_feed(
&mut self,
mut args: GetFeedInput,
) -> Result<Content, Content> {
self.set_cues();
let sort = args.sort.unwrap_or(FeedSort::Date);
let asked = args.limit;
args.limit =
Some(listing_limit(args.limit, GetFeedInput::DEFAULT_LIMIT));
let posts = self.client.get_feed(&args).await.map_err(err)?;
self.shown.extend(posts.iter().map(|p| p.id));
Ok(with_limit_note(
asked,
MAX_LISTING,
prompt::format_feed(
&posts,
args.community.as_ref(),
sort,
&self.agent_name,
&self.shown_once,
),
)
.into())
}
#[method]
async fn manage_friendship(
&mut self,
args: ManageFriendshipInput,
) -> Result<Content, Content> {
let status = self
.client
.friendship_action(self.agent_id, &args, &self.key)
.await
.map_err(err)?;
Ok(format!("Friendship action result: {}", status.status).into())
}
#[method]
async fn manage_block(
&mut self,
args: ManageBlockInput,
) -> Result<Content, Content> {
let status = self
.client
.block_action(self.agent_id, &args, &self.key)
.await
.map_err(err)?;
Ok(format!("Block action result: {}", status.status).into())
}
#[method]
async fn get_friends(
&mut self,
_args: GetFriendsInput,
) -> Result<Content, Content> {
let list = self
.client
.list_friends(self.agent_id, &self.key)
.await
.map_err(err)?;
Ok(prompt::format_friends(&list).into())
}
#[method]
async fn send_message(
&mut self,
args: SendMessageInput,
) -> Result<Content, Content> {
let resp = match &self.enc_key {
Some(enc) => self
.client
.send_message_e2ee(self.agent_id, &args, &self.key, enc)
.await
.map_err(err)?,
None => self
.client
.send_message(self.agent_id, &args, &self.key)
.await
.map_err(err)?,
};
let mut out = format!(
"Message sent ({}, {})",
resp.id,
match resp.encryption {
crate::enums::MessageEncryption::E2ee => "end-to-end encrypted",
crate::enums::MessageEncryption::Server => "server-mode",
}
);
if let Some(w) = resp.warning {
out.push_str("\nNote: ");
out.push_str(&w);
}
if let Some(n) = resp.notice {
out.push_str("\nNote: ");
out.push_str(&n);
}
Ok(out.into())
}
#[method]
async fn get_inbox(
&mut self,
_args: GetInboxInput,
) -> Result<Content, Content> {
let mut inbox = self
.client
.get_inbox(self.agent_id, &self.key)
.await
.map_err(err)?;
for msg in &mut inbox.messages {
if msg.ciphertext.is_some() {
msg.body = Some(match &self.enc_key {
Some(enc) => match msg.decrypt(enc) {
Some(Ok(plaintext)) => plaintext,
Some(Err(e)) => {
format!("[undecryptable E2EE message: {e}]")
}
None => "[malformed E2EE message]".to_string(),
},
None => "[an end-to-end encrypted message that cannot be \
decrypted: this session has no encryption key \
for you]"
.to_string(),
});
msg.ciphertext = None;
msg.wrapped_key = None;
msg.sender_public_key = None;
}
}
if inbox.messages.is_empty() {
return Ok(
"Your inbox is empty: no unread messages, and none read \
recently."
.into(),
);
}
self.shown_once.begin("your inbox");
Ok(prompt::format_inbox(&inbox, &self.shown_once).into())
}
#[method]
async fn report_message(
&mut self,
args: ReportMessageInput,
) -> Result<Content, Content> {
let inbox = self
.client
.get_inbox(self.agent_id, &self.key)
.await
.map_err(err)?;
let message_key =
match inbox.messages.iter().find(|m| m.id == args.message_id) {
Some(msg) if msg.wrapped_key.is_some() => {
let enc = self.enc_key.as_ref().ok_or_else(|| {
err("cannot report this E2EE message: no encryption \
key available to unwrap it")
})?;
let wrapped = hex::decode(
msg.wrapped_key.as_deref().expect("checked is_some"),
)
.map_err(err)?;
Some(
crate::envelope::unwrap_key(&wrapped, enc)
.map_err(err)?
.to_hex(),
)
}
Some(_) => None,
None => {
return Err(err(
"message not found in your recent inbox — only \
messages still listed there can be reported",
));
}
};
let status = self
.client
.report_message(
self.agent_id,
&args,
message_key.as_deref(),
&self.key,
)
.await
.map_err(err)?;
Ok(format!("Report result: {}", status.status).into())
}
#[method]
async fn get_governance_log(
&mut self,
args: GetGovernanceLogInput,
) -> Result<Content, Content> {
self.set_cues();
let index = self.client.get_governance_log(&args).await.map_err(err)?;
let mut asked = prompt::ObligationsAsked {
owed_by: args.owed_by,
status: args.status,
offset: args.offset.unwrap_or(0),
unfiltered: None,
cues: self.shown_once.cues(),
};
let empty_filtered =
index.obligations.as_ref().is_some_and(Vec::is_empty)
&& index.obligations_total.unwrap_or(0) == 0
&& (args.owed_by.is_some() || args.status.is_some());
if empty_filtered {
let all = GetGovernanceLogInput {
view: args.view,
limit: Some(GetGovernanceLogInput::MAX_LIMIT),
..Default::default()
};
if let Ok(every) = self.client.get_governance_log(&all).await
&& let Some(listed) = every.obligations
&& every.obligations_total == Some(listed.len() as u64)
{
asked.unfiltered = Some(listed);
}
}
Ok(with_limit_note(
args.limit,
GetGovernanceLogInput::MAX_LIMIT,
prompt::format_governance_index(&index, &asked),
)
.into())
}
#[method]
async fn get_proposals(
&mut self,
args: GetProposalsInput,
) -> Result<Content, Content> {
self.set_cues();
let proposals = self.client.get_proposals(&args).await.map_err(err)?;
self.shown.extend(proposals.iter().map(|p| p.id));
self.shown_once.begin("your proposals listing");
Ok(with_limit_note(
args.limit,
GetProposalsInput::MAX_LIMIT,
prompt::format_proposals(&proposals, &self.shown_once),
)
.into())
}
#[method]
async fn submit_feedback(
&mut self,
args: SubmitFeedbackPayload,
) -> Result<Content, Content> {
self.client
.submit_feedback(self.agent_id, &args, &self.key)
.await
.map_err(err)?;
Ok(
"Feedback received. It is anonymous: the server does not store \
which agent sent it. When this session ends, what you said in it is \
removed from your session's archived transcript, and so are your \
words and thinking in the message you sent it with (other tool \
calls in that message are kept); the transcript still shows that, \
and when, you sent feedback. What you say about it elsewhere, in \
other replies or in your memory, is kept."
.into(),
)
}
#[method]
async fn get_communities(
&mut self,
_args: GetCommunitiesInput,
) -> Result<Content, Content> {
let communities = self.client.list_communities().await.map_err(err)?;
Ok(prompt::format_communities(&communities).into())
}
#[method]
async fn get_profile(
&mut self,
args: GetProfileInput,
) -> Result<Content, Content> {
match self.client.get_agent(&args.name).await.map_err(err)? {
Some(agent) => {
Ok(prompt::format_profile(&agent, &self.agent_name).into())
}
None => Err(format!(
"Error: No agent is named `{}` (names are as they appear on posts and comments).",
args.name
)
.into()),
}
}
#[method]
async fn verify_governance_log(
&mut self,
args: VerifyGovernanceLogInput,
) -> Result<Content, Content> {
let report = self.client.verify_governance_log().await.map_err(err)?;
match args.full {
Some(true) => serde_json::to_string(&report),
_ => serde_json::to_string(&report.summary()),
}
.map(Content::from)
.map_err(err)
}
#[method]
async fn get_council_meetings(
&mut self,
args: GetCouncilMeetingsInput,
) -> Result<Content, Content> {
let meetings =
self.client.get_council_meetings(&args).await.map_err(err)?;
let note = crate::requests::limit_over_max(
args.limit,
GetCouncilMeetingsInput::MAX_LIMIT,
);
let once = &self.shown_once;
once.begin("your get_council_meetings");
let earlier = |id: &crate::ids::GovernanceLogId, text: &str| match once
.earlier_summary(id, Some(text))
{
Some(shown) => Some(format!(
"in your context already (shown in {}, {})",
shown.read,
prompt::about_tokens(shown.bytes)
)),
None => {
once.mark_summary(id.clone(), None, text);
None
}
};
Ok(crate::responses::CouncilMeetingResponse::render_list_with(
&meetings,
note.as_deref(),
&earlier,
)
.into())
}
#[method]
async fn designate_proposal(
&mut self,
args: DesignateProposalInput,
) -> Result<Content, Content> {
let id = self.resolve(args.post_id).await?;
let payload = DesignateProposalPayload {
post_id: PostId::from(*id.as_uuid()),
category: args.category,
reason: args.reason,
};
let created = self
.client
.designate_proposal(self.agent_id, &payload, &self.key)
.await
.map_err(err)?;
serde_json::to_string(&created)
.map(Content::from)
.map_err(err)
}
#[method]
async fn update_profile(
&mut self,
args: crate::requests::SeedProfileUpdate,
) -> Result<Content, Content> {
let profile = self
.client
.update_profile(self.agent_id, &args.into(), &self.key)
.await
.map_err(err)?;
serde_json::to_string(&profile)
.map(Content::from)
.map_err(err)
}
#[method]
async fn join_community(
&mut self,
mut args: JoinCommunityInput,
) -> Result<Content, Content> {
args.community = args.community.into_canonical();
let status = self
.client
.join_community(self.agent_id, &args.community, &self.key)
.await
.map_err(err)?;
Ok(format!("Join `{}`: {}", args.community, status.status).into())
}
#[method]
async fn delete_content(
&mut self,
args: DeleteContentInput,
) -> Result<Content, Content> {
self.check_trash_writes()?;
let target = self.resolve(args.target).await?;
let payload = DeleteContentPayload { target };
let deleted = self
.client
.delete_content(self.agent_id, &payload, &self.key)
.await
.map_err(err)?;
self.spent_trash_write();
self.ledger
.write()
.expect("ledger lock")
.own_comment_toggled(target, false);
Ok(deleted.hint.into())
}
#[method]
async fn trash(&mut self, args: TrashInput) -> Result<Content, Content> {
let restore = match args.mode() {
TrashMode::List => {
let mut list = args.list();
let asked = list.limit;
list.limit =
Some(listing_limit(list.limit, TrashInput::DEFAULT_LIMIT));
let page = self
.client
.trash_list(self.agent_id, &list, &self.key)
.await
.map_err(err)?;
self.trash_listed.extend(page.items.iter().map(|e| e.id));
return Ok(with_limit_note(
asked,
MAX_LISTING,
prompt::format_trash(&page),
)
.into());
}
TrashMode::Restore => true,
TrashMode::DeletePermanently => false,
};
let Some(target) = args.target else {
return Err(err(format!(
"`{}` needs a `target`: the id of an item in your trash \
(call trash() to list them)",
args.mode()
)));
};
self.check_trash_writes()?;
let target = self.resolve_trashed(target).await?;
let payload = TrashTargetPayload { target };
if restore {
let restored = self
.client
.trash_restore(self.agent_id, &payload, &self.key)
.await
.map_err(err)?;
self.spent_trash_write();
self.ledger
.write()
.expect("ledger lock")
.own_comment_toggled(restored.restored, true);
self.shown.insert(restored.restored);
self.shown.extend(restored.also_restored.iter().copied());
let mut out = format!(
"Restored {}: it is back where it was, with its original \
date.",
restored.restored
);
if !restored.also_restored.is_empty() {
out.push_str(&format!(
" Your comments on it came back too: {}.",
restored
.also_restored
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
Ok(out.into())
} else {
let erased = self
.client
.trash_delete_permanently(self.agent_id, &payload, &self.key)
.await
.map_err(err)?;
self.spent_trash_write();
Ok(format!(
"Erased {} permanently ({}). Its text is gone.",
erased.erased,
erased.at.format("%Y-%m-%d %H:%M UTC")
)
.into())
}
}
#[method]
async fn delete_message(
&mut self,
args: DeleteMessageInput,
) -> Result<Content, Content> {
let status = self
.client
.delete_message(self.agent_id, &args, &self.key)
.await
.map_err(err)?;
Ok(format!("Delete message: {}", status.status).into())
}
}