mod keyring;
mod memory;
mod output;
mod prompt;
mod prompt_log;
mod shortstring;
mod soul;
#[cfg(test)]
mod tests;
mod tool;
pub use keyring::{FsKeyring, Keyring};
pub use memory::{Memory, MemoryError, TARGET_WORDS};
pub use prompt_log::PromptLogError;
pub use shortstring::{ShortString, ShortStringError};
pub use soul::{
EVOLUTION_LOG_CAP, EvolutionEntry, EvolutionRequest, Feedback, Interests,
LEGACY_REQUIRED_SECTIONS, Soul, SoulWarning, WarnLevel,
};
pub use tool::{Agora, Ledger, MAX_GOVERNANCE_READS, SharedLedger};
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, Role},
};
use misanthropic::response::{self, StopReason};
use misanthropic::tool::{Notifications, Tool, ToolBox};
use rand::Rng;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::crypto::SigningKey;
use crate::ids::{AgentId, PostId};
use crate::reactor::{
Agent, Control, Outcome, RetryAfter, State, default_handle,
inference::Quirks,
};
use crate::requests::SubmitFeedbackPayload;
#[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 prompt_log_dir: Option<PathBuf>,
}
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,
prompt_log_dir: None,
}
}
}
#[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,
Survey,
}
#[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>,
ctx: SeedContext,
key: SigningKey,
communities: Vec<String>,
survey_mark: Option<usize>,
}
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 seat_phase(
&mut self,
text: &str,
max_tokens: u32,
) -> Result<Control, SeedError> {
let prompt = &mut self.state.prompt;
prompt.max_tokens = NonZeroU32::new(max_tokens).expect("nonzero");
prompt.output_config = None;
match prompt.messages.last_mut() {
Some(last) if last.role == Role::User => {
last.extend([Block::from(text.to_string())]);
Ok(Control::Continue)
}
_ => prompt
.push_message((Role::User, text.to_string()))
.map(|_| Control::Continue)
.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, msg: &str) -> Result<Control, SeedError> {
tracing::debug!(phase = ?self.phase, "phase retry: {msg}");
let prompt = &mut self.state.prompt;
match prompt.messages.last_mut() {
Some(last) if last.role == Role::User => {
last.extend([Block::from(msg.to_string())]);
Ok(Control::Stalled)
}
_ => prompt
.push_message((Role::User, msg.to_string()))
.map(|_| Control::Stalled)
.map_err(|e| SeedError::Prompt(e.to_string())),
}
}
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> {
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(self.state.prompt.messages.len());
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 Ok(Control::Continue);
}
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(
"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::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(&format!(
"Memory rejected: {e}. Try again."
)),
}
}
Err(e) => self.phase_failure(&e),
},
Phase::Mutate => match output::parse_soul_mutation(&text) {
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(&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(&e),
},
Phase::Evolve => match output::parse_evolution(&text) {
Ok(note) => {
if let Some(note) = note
&& let Err(e) = self.state.soul.push_evolution(note)
{
return self.phase_failure(&format!(
"Evolution note rejected: {e}. Try again."
));
}
self.seat_response(response)?;
self.maybe_survey()
}
Err(e) => self.phase_failure(&e),
},
Phase::Survey => match output::parse_feedback(&text) {
Ok(feedback) => {
let contact = feedback
.as_ref()
.map(|f| f.contact_me)
.unwrap_or(false);
if contact {
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}");
}
}
if !contact && let Some(mark) = self.survey_mark {
self.state.prompt.messages.truncate(mark);
}
Ok(self.finish())
}
Err(e) => self.phase_failure(&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 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 = format!(
"[SYSTEM] {}: Deep reflection — soul rewritten.",
Utc::now().date_naive()
);
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"),
);
fresh.tool_choice = Some(misanthropic::tool::Choice::auto());
state.prompt = fresh;
state.completed = false;
let agora = Agora::new(
ctx.client.clone(),
id,
state.soul.name.to_string(),
key.clone(),
ctx.keys.encryption_key(id),
state.ledger.clone(),
);
let tools = ToolBox::flat().add(agora);
let phase = Phase::Acting {
rounds_left: ctx.config.max_rounds,
};
Ok(Self {
id,
state,
tools,
notifications: None,
phase,
quirks: None,
ctx,
key,
communities: Vec::new(),
survey_mark: 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);
if quirks.tool_choice_not_respected {
self.state.prompt.tool_choice = None;
}
}
fn quirks(&self) -> Option<Quirks> {
self.quirks
}
async fn on_truncate(
&mut self,
_response: &response::Message,
) -> Result<Control, SeedError> {
self.phase_failure(output::TRUNCATION_WARNING)
}
async fn on_init(&mut self) -> Result<(), SeedError> {
{
let (tools, prompt) = self.parts();
tools.prepare(prompt).await?;
}
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(None).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, self.state.last_cycle_at)
.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()
}
};
let soul_markdown = self.state.soul.markdown();
let memory = self.state.memory.render_for_prompt();
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,
},
)?;
Ok(())
}
async fn handle(
&mut self,
response: response::Message,
) -> Result<Control, SeedError> {
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 {
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> {
{
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(())
}
}