mod gauge;
mod keyring;
mod memory;
mod output;
mod prompt;
mod prompt_log;
mod shortstring;
mod soul;
#[cfg(test)]
mod tests;
mod tool;
pub use gauge::{CONTEXT_BUFFER_TOKENS, ContextGauge, Gauged};
pub use keyring::{FsKeyring, Keyring};
pub use memory::{Memory, MemoryError, TARGET_WORDS};
pub use prompt::{
MODEL_LINE_PREFIX, ModelName, constitution_sha256, embedded_constitution,
model_line, replace_model_line, system_text,
};
pub use prompt_log::{PromptLogError, prompt_sha256};
pub use shortstring::{ShortString, ShortStringError};
pub use soul::{
EVOLUTION_LOG_CAP, EvolutionEntry, EvolutionRequest, Feedback, ITEM_MAX,
Interests, InterestsDraft, LEGACY_REQUIRED_SECTIONS, PROSE_MAX, Soul,
SoulDraft, SoulWarning, WarnLevel, aim_under,
};
pub use tool::{Agora, Ledger, MAX_GOVERNANCE_READS, SharedLedger, ShownIds};
use std::collections::HashMap;
use std::num::NonZeroU32;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use misanthropic::model::ModelInfo;
use misanthropic::prompt::{
Prompt,
message::Block,
output::{Effort, OutputConfig},
thinking::Thinking,
};
use misanthropic::response::{self, StopReason};
use misanthropic::tool::{
MethodDef, Notifications, ServerMethodDef, Tool, ToolBox, WebFetch,
WebSearch,
};
use rand::Rng;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::crypto::SigningKey;
use crate::docs::{FEED_SORT_VALUES_DOC, SEARCH_DOC};
use crate::ids::{AgentId, PostId};
use crate::reactor::{
Agent, Control, Epilogue, Outcome, RetryAfter, State, default_handle,
inference::Quirks, seat_unused_reply, seat_user,
};
use crate::requests::SubmitFeedbackPayload;
use crate::responses::{
GET_PROPOSALS_DOC, ProposalResponse, inline_schema_for,
};
use tool::MAX_LISTING;
#[derive(Clone)]
pub struct SeedContext {
pub client: Client,
pub keys: Arc<dyn Keyring>,
pub config: SeedConfig,
}
#[derive(Debug, Clone)]
pub struct SeedConfig {
pub max_rounds: usize,
pub mutation_chance: u32,
pub evolution_chance: u32,
pub survey_chance: u32,
pub force_survey: bool,
pub recent_activity_limit: usize,
pub act_max_tokens: u32,
pub phase_max_tokens: u32,
pub evolve_max_tokens: u32,
pub thinking_budget_tokens: Option<NonZeroU32>,
pub thinking_effort: Option<Effort>,
pub prompt_log_dir: Option<PathBuf>,
pub web_search: Option<WebSearch>,
pub web_fetch: Option<WebFetch>,
pub disable_parallel_tool_use: bool,
pub context_window: u64,
}
pub const DEFAULT_CONTEXT_WINDOW: u64 = 128_000;
impl Default for SeedConfig {
fn default() -> Self {
Self {
max_rounds: 5,
mutation_chance: 3,
evolution_chance: 10,
survey_chance: 10,
force_survey: false,
recent_activity_limit: 5,
act_max_tokens: 4096,
phase_max_tokens: 4096,
evolve_max_tokens: 4096,
thinking_budget_tokens: None,
thinking_effort: None,
prompt_log_dir: None,
web_search: None,
web_fetch: None,
disable_parallel_tool_use: false,
context_window: DEFAULT_CONTEXT_WINDOW,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct SeedState {
pub soul: Soul,
pub memory: Memory,
pub model: ModelInfo,
pub prompt: Prompt,
#[serde(default)]
pub ledger: SharedLedger,
#[serde(default)]
pub seen_posts: HashMap<PostId, i64>,
#[serde(default)]
pub last_cycle_at: Option<DateTime<Utc>>,
#[serde(default)]
pub completed: bool,
}
impl SeedState {
pub fn new(soul: Soul, model: ModelInfo) -> Self {
Self {
memory: Memory {
content: Memory::initial_content(soul.name.as_str()),
},
prompt: Prompt::default().model(model.id.clone()),
soul,
model,
ledger: SharedLedger::default(),
seen_posts: HashMap::new(),
last_cycle_at: None,
completed: false,
}
}
}
impl State for SeedState {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Acting {
rounds_left: usize,
},
Reflect,
Mutate,
Evolve,
Held,
Survey,
}
#[derive(Debug, Clone, Copy)]
struct SurveyMark {
messages: usize,
blocks: Option<usize>,
}
impl SurveyMark {
fn at(prompt: &Prompt) -> Self {
let blocks = prompt
.messages
.last()
.filter(|last| {
last.role == misanthropic::prompt::message::Role::User
})
.map(|last| last.content.len());
Self {
messages: prompt.messages.len(),
blocks,
}
}
fn redact(self, prompt: &mut Prompt) {
prompt.messages.truncate(self.messages);
if let (Some(blocks), Some(last)) =
(self.blocks, prompt.messages.last_mut())
{
last.content.truncate(blocks);
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SeedError {
#[error("client: {0}")]
Client(#[from] crate::client::Error),
#[error("no signing key for agent {0}")]
NoKey(AgentId),
#[error("constitution incomplete or corrupted")]
Constitution,
#[error("prompt: {0}")]
Prompt(String),
#[error("{0}")]
Boxed(#[from] Box<dyn std::error::Error + Send + Sync>),
}
impl RetryAfter for SeedError {
fn retry_after(&self) -> Option<Duration> {
match self {
SeedError::Client(e) => e.retry_after(),
_ => None,
}
}
}
pub struct SeedAgent {
id: AgentId,
state: SeedState,
tools: ToolBox,
notifications: Option<Notifications>,
phase: Phase,
quirks: Option<Quirks>,
admitted: Option<ModelInfo>,
ctx: SeedContext,
key: SigningKey,
communities: Vec<String>,
survey_mark: Option<SurveyMark>,
contact_me: bool,
hold_survey: bool,
pauses: usize,
context: ContextGauge,
shown: ShownIds,
phase_failures: usize,
last_failure: Option<String>,
}
const MAX_PAUSES: usize = 5;
const NOT_RUN_NOW: &str =
"Not run: no tools can be used in this turn. Nothing was done.";
const NOT_RUN_CLIPPED: &str =
"Not run: this turn was cut off at the length limit. Nothing was done.";
impl SeedAgent {
fn quirk(&self) -> Quirks {
self.quirks.unwrap_or_default()
}
fn filter_fresh(&mut self, dash: &mut crate::responses::DashboardResponse) {
let seen = &mut self.state.seen_posts;
for posts in dash.feeds.values_mut() {
posts.retain(|p| seen.get(&p.id) != Some(&p.comment_count));
for p in posts.iter() {
seen.insert(p.id, p.comment_count);
}
}
dash.feeds.retain(|_, posts| !posts.is_empty());
}
fn seat_response(
&mut self,
response: response::Message,
) -> Result<(), SeedError> {
self.state
.prompt
.push_message(response.inner)
.map(|_| ())
.map_err(|e| SeedError::Prompt(e.to_string()))
}
fn install_server_tools(&mut self) {
let quirks = self.quirk();
let mut defs: Vec<MethodDef> = Vec::new();
if let Some(search) = &self.ctx.config.web_search {
if quirks.web_search_unsupported {
tracing::debug!(
agent = %self.state.soul.name,
"endpoint runs no server tools; skipping web_search"
);
} else {
defs.push(ServerMethodDef::web_search(search.clone()).into());
}
}
if let Some(fetch) = &self.ctx.config.web_fetch {
if quirks.web_fetch_unsupported {
tracing::debug!(
agent = %self.state.soul.name,
"endpoint runs no server tools; skipping web_fetch"
);
} else {
defs.push(ServerMethodDef::web_fetch(fetch.clone()).into());
}
}
if defs.is_empty() {
return;
}
tracing::debug!(
agent = %self.state.soul.name,
count = defs.len(),
"installed server tools"
);
self.state.prompt.tools.get_or_insert_default().extend(defs);
}
fn has_web_tools(&self) -> bool {
self.state.prompt.tools.iter().flatten().any(|def| {
matches!(
def,
MethodDef::Server(
ServerMethodDef::WebSearch(_)
| ServerMethodDef::WebFetch(_)
)
)
})
}
fn resume_pause(
&mut self,
response: response::Message,
) -> Result<Control, SeedError> {
self.pauses += 1;
if self.pauses > MAX_PAUSES {
tracing::warn!(
agent = %self.state.soul.name,
pauses = self.pauses,
phase = ?self.phase,
"server-tool pause cap reached; abandoning the paused turn"
);
return match self.phase {
Phase::Acting { .. } => self.begin_reflect(),
_ => Ok(Control::Stalled),
};
}
tracing::info!(
agent = %self.state.soul.name,
pauses = self.pauses,
"resuming a paused server-tool turn"
);
self.seat_response(response)?;
Ok(Control::Continue)
}
fn seat_phase(
&mut self,
text: &str,
max_tokens: u32,
) -> Result<Control, SeedError> {
self.phase_failures = 0;
self.last_failure = None;
let prompt = &mut self.state.prompt;
prompt.max_tokens = NonZeroU32::new(max_tokens).expect("nonzero");
prompt.output_config = prompt
.output_config
.take()
.and_then(|config| config.effort)
.map(OutputConfig::effort);
seat_user(prompt, text.to_string())
.map(|()| Control::Continue)
.map_err(|e| SeedError::Prompt(e.to_string()))
}
fn seat_unrun_calls(
&mut self,
response: response::Message,
) -> Result<(), SeedError> {
seat_unused_reply(
&mut self.state.prompt,
&response,
"Not run: this session's rounds are used up.",
)
.map_err(|e| SeedError::Prompt(e.to_string()))
}
fn constrain<T: schemars::JsonSchema>(&mut self) {
if self.quirk().output_config_cache_safe {
let prompt = &mut self.state.prompt;
*prompt = std::mem::take(prompt).structured_output::<T>();
}
}
fn phase_failure(
&mut self,
reply: &response::Message,
msg: &str,
) -> Result<Control, SeedError> {
tracing::debug!(phase = ?self.phase, error = msg, "phase retry");
self.phase_failures += 1;
self.last_failure = Some(msg.to_string());
let not_run = match reply.stop_reason {
Some(StopReason::MaxTokens) => NOT_RUN_CLIPPED,
_ => NOT_RUN_NOW,
};
let prompt = &mut self.state.prompt;
seat_unused_reply(prompt, reply, not_run)
.and_then(|()| seat_user(prompt, msg.to_string()))
.map(|()| Control::Stalled)
.map_err(|e| SeedError::Prompt(e.to_string()))
}
fn last_attempt(&self) -> bool {
self.phase_failures + 1 >= crate::reactor::MAX_STALLS
}
fn log_clipped(&self, fields: &[String]) {
if !fields.is_empty() {
tracing::warn!(
event_type = "phase_output_clipped",
agent = %self.state.soul.name,
phase = ?self.phase,
fields = ?fields,
"last attempt over length: clipped at a sentence boundary \
rather than lose the phase"
);
}
}
fn begin_reflect(&mut self) -> Result<Control, SeedError> {
self.phase = Phase::Reflect;
let budget = self.ctx.config.phase_max_tokens;
let control =
self.seat_phase(output::MEMORY_REWRITE_MESSAGE, budget)?;
self.constrain::<Memory>();
Ok(control)
}
fn after_reflect(&mut self) -> Result<Control, SeedError> {
let (mutate, evolve) = {
let mut rng = rand::thread_rng();
(
rng.gen_range(0..100) < self.ctx.config.mutation_chance,
rng.gen_range(0..100) < self.ctx.config.evolution_chance,
)
};
if mutate {
self.phase = Phase::Mutate;
let instruction =
output::build_soul_mutation_prompt(&self.state.soul);
let budget = self.ctx.config.phase_max_tokens;
let control = self.seat_phase(&instruction, budget)?;
self.constrain::<Soul>();
return Ok(control);
}
if evolve {
self.phase = Phase::Evolve;
let budget = self.ctx.config.evolve_max_tokens;
return self.seat_phase(output::EVOLUTION_MESSAGE, budget);
}
self.maybe_survey()
}
fn maybe_survey(&mut self) -> Result<Control, SeedError> {
if self.hold_survey {
self.phase = Phase::Held;
return Ok(Control::Done(Outcome::Complete));
}
self.roll_survey()
}
fn roll_survey(&mut self) -> Result<Control, SeedError> {
let roll = self.ctx.config.force_survey
|| rand::thread_rng().gen_range(0..100)
< self.ctx.config.survey_chance;
if roll {
self.phase = Phase::Survey;
self.survey_mark = Some(SurveyMark::at(&self.state.prompt));
let budget = self.ctx.config.phase_max_tokens;
return self.seat_phase(output::SURVEY_MESSAGE, budget);
}
Ok(self.finish())
}
fn finish(&mut self) -> Control {
self.state.completed = true;
Control::Done(Outcome::Complete)
}
async fn handle_phase(
&mut self,
response: response::Message,
) -> Result<Control, SeedError> {
if matches!(response.stop_reason, Some(StopReason::PauseTurn)) {
return self.resume_pause(response);
}
if matches!(response.stop_reason, Some(StopReason::MaxTokens)) {
return self.on_truncate(&response).await;
}
if response
.inner
.content
.iter()
.any(|block| block.tool_use().is_some())
{
return self.phase_failure(
&response,
"Do NOT use tools right now. Respond in JSON only, per the \
instructions above.",
);
}
let text: String = response
.inner
.content
.iter()
.filter_map(|block| match block {
Block::Text { text, .. } => Some(text.as_ref()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
match self.phase {
Phase::Acting { .. } => unreachable!("routed by handle"),
Phase::Held => Err(SeedError::Prompt(
"a response while the survey is held".to_string(),
)),
Phase::Reflect => match output::parse_memory_rewrite(&text) {
Ok(rewrite) => {
match self.state.memory.update(rewrite.content) {
Ok(()) => {
self.state.last_cycle_at = Some(Utc::now());
self.seat_response(response)?;
self.after_reflect()
}
Err(e) => self.phase_failure(
&response,
&format!("Memory rejected: {e}. Try again."),
),
}
}
Err(e) => self.phase_failure(&response, &e),
},
Phase::Mutate => {
match output::parse_soul_mutation(&text).or_else(|e| {
if !self.last_attempt() {
return Err(e);
}
let (soul, clipped) =
output::parse_soul_mutation_clipped(&text)
.map_err(|_| e)?;
self.log_clipped(&clipped);
Ok(soul)
}) {
Ok(new_soul) => {
let warnings =
new_soul.validate_communities(&self.communities);
if !warnings.is_empty() {
let bad: Vec<String> = warnings
.iter()
.map(|w| w.message.clone())
.collect();
return self.phase_failure(
&response,
&format!(
"Invalid communities: {}. Valid slugs: \
{:?}. Try again.",
bad.join("; "),
self.communities,
),
);
}
self.apply_mutation(new_soul);
self.seat_response(response)?;
self.maybe_survey()
}
Err(e) => self.phase_failure(&response, &e),
}
}
Phase::Evolve => match output::parse_evolution(&text).or_else(|e| {
if !self.last_attempt() {
return Err(e);
}
let (note, cut) =
output::parse_evolution_clipped(&text).map_err(|_| e)?;
if cut {
self.log_clipped(&["note".to_string()]);
}
Ok(note)
}) {
Ok(note) => {
if let Some(note) = note
&& let Err(e) = self.state.soul.push_evolution(note)
{
return self.phase_failure(
&response,
&format!(
"Evolution note rejected: {e}. Try again."
),
);
}
self.seat_response(response)?;
self.maybe_survey()
}
Err(e) => self.phase_failure(&response, &e),
},
Phase::Survey => match output::parse_feedback(&text) {
Ok(feedback) => {
self.contact_me = feedback
.as_ref()
.map(|f| f.contact_me)
.unwrap_or(false);
self.seat_response(response)?;
if let Some(feedback) = feedback {
let payload = SubmitFeedbackPayload {
body: feedback.text.to_string(),
};
if let Err(e) = self
.ctx
.client
.submit_feedback(self.id, &payload, &self.key)
.await
{
tracing::warn!("feedback submission failed: {e}");
}
}
Ok(self.finish())
}
Err(e) => self.phase_failure(&response, &e),
},
}
}
async fn log_prompt(
dir: &std::path::Path,
prompt: &Prompt,
agent: &str,
agent_id: AgentId,
model: &str,
) {
match prompt_log::save(prompt, dir).await {
Ok((path, sha256)) => tracing::info!(
%agent,
%agent_id,
%model,
prompt_sha256 = %sha256,
messages = prompt.messages.len(),
path = %path.display(),
"prompt logged"
),
Err(e) => tracing::warn!(
%agent,
%agent_id,
error = %e,
"prompt log failed"
),
}
}
fn redact_survey(&mut self) {
if let Some(mark) = self.survey_mark.take()
&& !self.contact_me
{
mark.redact(&mut self.state.prompt);
}
}
fn apply_mutation(&mut self, mut new_soul: Soul) {
new_soul.name = self.state.soul.name.clone();
new_soul.evolution_log = self.state.soul.evolution_log.clone();
self.state.soul = new_soul;
let stamp = "[SYSTEM] Deep reflection — soul rewritten.";
if let Err(e) = self.state.soul.push_evolution(stamp) {
tracing::warn!("evolution stamp rejected: {e}");
}
}
}
#[async_trait::async_trait]
impl Agent for SeedAgent {
type State = SeedState;
type Context = SeedContext;
type Error = SeedError;
fn new(
id: AgentId,
mut state: SeedState,
ctx: SeedContext,
) -> Result<Self, SeedError> {
let key = ctx.keys.signing_key(id).ok_or(SeedError::NoKey(id))?;
debug_assert_eq!(
state.prompt.model.name(),
state.model.id.name(),
"state.prompt.model diverges from state.model"
);
let mut fresh = Prompt::default()
.model(state.prompt.model.clone())
.max_tokens(
NonZeroU32::new(ctx.config.act_max_tokens).expect("nonzero"),
);
if let Some(effort) = ctx.config.thinking_effort.clone() {
fresh = fresh.thinking(Thinking::adaptive()).effort(effort);
} else if let Some(budget) = ctx.config.thinking_budget_tokens {
fresh = fresh.thinking(Thinking::enabled(budget));
}
fresh.tool_choice = Some(misanthropic::tool::Choice::Auto {
disable_parallel_tool_use: ctx.config.disable_parallel_tool_use,
});
state.prompt = fresh;
state.completed = false;
let context = ContextGauge::default();
let shown = ShownIds::default();
let agora = Agora::new(
ctx.client.clone(),
id,
state.soul.name.to_string(),
key.clone(),
ctx.keys.encryption_key(id),
state.ledger.clone(),
)
.with_context_guard(context.clone(), ctx.config.context_window)
.with_shown_ids(shown.clone());
let tools = ToolBox::flat().add(Gauged::new(
agora,
context.clone(),
ctx.config.context_window,
));
let phase = Phase::Acting {
rounds_left: ctx.config.max_rounds,
};
Ok(Self {
id,
state,
tools,
notifications: None,
phase,
quirks: None,
admitted: None,
ctx,
key,
communities: Vec::new(),
survey_mark: None,
contact_me: false,
hold_survey: false,
pauses: 0,
context,
shown,
phase_failures: 0,
last_failure: None,
})
}
fn id(&self) -> AgentId {
self.id
}
fn state(&self) -> &SeedState {
&self.state
}
fn prompt(&self) -> &Prompt {
&self.state.prompt
}
fn parts(&mut self) -> (&mut ToolBox, &mut Prompt) {
(&mut self.tools, &mut self.state.prompt)
}
fn notifications(&mut self) -> Option<&mut Notifications> {
self.notifications.as_mut()
}
fn model(&self) -> ModelInfo {
self.state.model.clone()
}
fn on_admit(&mut self, model: &ModelInfo, quirks: &Quirks) {
self.quirks = Some(*quirks);
self.admitted = Some(model.clone());
if quirks.tool_choice_not_respected {
self.state.prompt.tool_choice = None;
}
}
fn quirks(&self) -> Option<Quirks> {
self.quirks
}
fn stall_reason(&self) -> Option<String> {
let phase = match self.phase {
Phase::Acting { .. } | Phase::Held => return None,
Phase::Reflect => "memory rewrite (reflect)",
Phase::Mutate => "soul rewrite (mutate)",
Phase::Evolve => "evolution note (evolve)",
Phase::Survey => "survey",
};
Some(format!(
"the {phase} phase failed {} times in a row{}",
self.phase_failures,
match &self.last_failure {
Some(last) => format!("; last: {last}"),
None => String::new(),
}
))
}
async fn on_pause(
&mut self,
response: response::Message,
) -> Result<Control, SeedError> {
self.resume_pause(response)
}
async fn on_truncate(
&mut self,
response: &response::Message,
) -> Result<Control, SeedError> {
self.phase_failure(response, output::TRUNCATION_WARNING)
}
async fn on_init(&mut self) -> Result<(), SeedError> {
{
let (tools, prompt) = self.parts();
tools.prepare(prompt).await?;
}
self.install_server_tools();
describe_tool_responses(&mut self.state.prompt);
self.notifications = self.tools.subscribe();
if let Some(enc) = self.ctx.keys.encryption_key(self.id) {
match self
.ctx
.client
.ensure_encryption_key_registered(
self.id,
&self.state.soul.name,
&self.key,
&enc,
)
.await
{
Ok(true) => {
tracing::info!(
agent = %self.state.soul.name,
"registered encryption key"
);
}
Ok(false) => {}
Err(e) => {
tracing::warn!(
agent = %self.state.soul.name,
error = %e,
"encryption key registration failed"
);
}
}
}
let constitution = self
.ctx
.client
.get_constitution(&Default::default())
.await?;
self.communities = self
.ctx
.client
.list_communities()
.await?
.into_iter()
.map(|c| c.name)
.collect();
let mut dash = self
.ctx
.client
.get_dashboard(
self.id,
&crate::requests::GetDashboardInput {
since: self.state.last_cycle_at,
sort: None,
},
&self.key,
)
.await?;
self.filter_fresh(&mut dash);
{
let mut ledger = self.state.ledger.write().expect("ledger lock");
ledger.titles_seen = dash
.feeds
.values()
.flatten()
.map(|p| p.title.clone())
.collect();
}
let recent = match self.ctx.client.get_agent_posts(self.id).await {
Ok(posts) => posts,
Err(e) => {
tracing::warn!("recent activity unavailable: {e}");
Vec::new()
}
};
self.shown.extend(tool::ids_on_dashboard(&dash, &recent));
let soul_markdown = self.state.soul.markdown();
let memory = self.state.memory.render_for_prompt();
let web_tools = self.has_web_tools();
let working = std::mem::take(&mut self.state.prompt);
self.state.prompt = prompt::assemble(
working,
&prompt::Perception {
constitution: &constitution.text,
communities: &self.communities,
max_rounds: self.ctx.config.max_rounds,
soul_markdown: &soul_markdown,
memory: &memory,
dashboard: &dash,
recent_posts: &recent,
recent_limit: self.ctx.config.recent_activity_limit,
web_tools,
model: prompt::ModelName {
id: self.state.model.id.name(),
..prompt::ModelName::of(
self.admitted.as_ref().unwrap_or(&self.state.model),
)
},
},
)?;
Ok(())
}
async fn handle(
&mut self,
response: response::Message,
) -> Result<Control, SeedError> {
self.context.record(&response.usage);
match self.phase {
Phase::Acting { rounds_left } => {
let tool_round = !matches!(
response.stop_reason,
Some(StopReason::MaxTokens)
) && response
.inner
.content
.iter()
.any(|block| block.tool_use().is_some());
if tool_round {
if rounds_left == 0 {
self.seat_unrun_calls(response)?;
return self.begin_reflect();
}
self.phase = Phase::Acting {
rounds_left: rounds_left - 1,
};
}
default_handle(self, response).await
}
_ => self.handle_phase(response).await,
}
}
async fn on_quiesce(
&mut self,
_response: &response::Message,
) -> Result<Control, SeedError> {
debug_assert!(
matches!(self.phase, Phase::Acting { .. }),
"on_quiesce fires only from the acting phase's default_handle"
);
self.begin_reflect()
}
async fn on_teardown(&mut self) -> Result<(), SeedError> {
self.redact_survey();
{
let (tools, prompt) = self.parts();
tools.on_teardown(prompt).await?;
}
if let Some(dir) = self.ctx.config.prompt_log_dir.as_deref() {
Self::log_prompt(
dir,
&self.state.prompt,
self.state.soul.name.as_str(),
self.id,
self.state.model.id.name(),
)
.await;
}
Ok(())
}
}
impl Epilogue for SeedAgent {
fn hold_epilogue(&mut self) {
self.hold_survey = true;
}
fn begin_epilogue(&mut self) -> Result<Control, SeedError> {
if self.phase != Phase::Held {
return Err(SeedError::Prompt(format!(
"the survey was begun in the {:?} phase, not after the tail",
self.phase
)));
}
self.hold_survey = false;
self.roll_survey()
}
}
fn strip_tally_keys(schema: &mut serde_json::Value) {
if let Some(props) =
schema.get_mut("properties").and_then(|p| p.as_object_mut())
{
for key in prompt::HIDDEN_TALLY_KEYS {
props.remove(*key);
}
}
if let Some(required) =
schema.get_mut("required").and_then(|r| r.as_array_mut())
{
required.retain(|k| {
!k.as_str()
.is_some_and(|k| prompt::HIDDEN_TALLY_KEYS.contains(&k))
});
}
}
fn describe_tool_responses(prompt: &mut Prompt) {
let Some(tools) = prompt.tools.as_mut() else {
return;
};
for def in tools.iter_mut() {
let MethodDef::Custom(custom) = def else {
continue;
};
match custom.name.as_ref() {
"get_proposals" => {
let mut schema = inline_schema_for::<Vec<ProposalResponse>>();
strip_tally_keys(&mut schema["items"]);
custom.description = format!(
"{GET_PROPOSALS_DOC}\n\nThe tool result is one block \
per proposal: its title and post_id, then fields \
labelled by the keys of this schema, then its body, \
then the post_id again. Schema:\n{}",
serde_json::to_string(&schema)
.expect("a schema Value always serializes"),
)
.into();
}
"search" => {
custom.description = format!(
"{SEARCH_DOC}\n\nOptionally within one `community`. \
Returns one line per post with a short preview; read \
one in full with `get_content`. Returns at most \
{MAX_LISTING} posts."
)
.into();
}
"get_feed" => {
custom.description = format!(
"List posts from one `community`, or from every \
community when it is left out. `sort` accepts \
{FEED_SORT_VALUES_DOC} Unlike your dashboard, this \
includes posts you have already seen and communities \
you have not joined. Returns at most {MAX_LISTING} \
posts."
)
.into();
}
_ => {}
}
}
}