mod elements;
mod gauge;
mod keyring;
mod memory;
mod output;
mod prompt;
mod prompt_log;
mod shortstring;
mod shown_once;
mod soul;
#[cfg(test)]
mod tests;
mod tool;
pub use gauge::{
CONTEXT_BUFFER_TOKENS, ContextGauge, Gauged, MIN_ROUND_ROOM,
PHASE_PROMPT_TOKENS,
};
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 shown_once::{Cues, Shown, ShownOnce};
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::govlog::Sha256Hex;
use crate::ids::{AgentId, ContactRequestId, FeedbackId, PostId};
use crate::reactor::{
Agent, Control, Epilogue, Outcome, RetryAfter, State, default_handle,
inference::Quirks, seat_unused_reply, seat_user,
};
use crate::requests::{RequestContactPayload, SubmitFeedbackPayload};
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);
}
}
}
pub const FEEDBACK_REDACTED: &str =
"[anonymous feedback: removed from the transcript]";
pub fn redact_feedback(prompt: &mut Prompt) {
use misanthropic::prompt::message::{Block, Role};
for i in 0..prompt.messages.len() {
let turn = &mut prompt.messages[i];
if turn.role != Role::Assistant {
continue;
}
let ids: Vec<_> = turn
.content
.iter()
.filter_map(|block| match block {
Block::ToolUse { call } if call.name == "submit_feedback" => {
Some(call.id.clone())
}
_ => None,
})
.collect();
if ids.is_empty() {
continue;
}
turn.content.0.retain(|block| {
!matches!(
block,
Block::Thought { .. } | Block::RedactedThought { .. }
)
});
for block in turn.content.iter_mut() {
match block {
Block::Text { text, .. } => *text = FEEDBACK_REDACTED.into(),
Block::ToolUse { call } if call.name == "submit_feedback" => {
call.input = serde_json::json!({"body": FEEDBACK_REDACTED});
}
_ => {}
}
}
if let Some(results) = prompt.messages.get_mut(i + 1)
&& results.role == Role::User
{
for block in results.content.iter_mut() {
if let Block::ToolResult { result } = block
&& ids.contains(&result.tool_use_id)
{
result.content = FEEDBACK_REDACTED.into();
}
}
}
}
}
#[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("agent {id}: soul name: {error}")]
SoulName {
id: AgentId,
error: crate::names::AgentNameError,
},
#[error("constitution incomplete or corrupted")]
Constitution,
#[error("prompt: {0}")]
Prompt(String),
#[error("cache placement: {0}")]
Cache(#[from] misanthropic::prompt::CacheError),
#[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 {
shown_once: ShownOnce,
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,
contact_feedback: Option<FeedbackId>,
contact_request: Option<ContactRequestId>,
hold_survey: bool,
pauses: usize,
context: ContextGauge,
shown: ShownIds,
phase_failures: usize,
last_failure: Option<String>,
phase_budget: u32,
refused_streak: usize,
}
pub const MAX_FREE_REFUSED_TURNS: usize = 5;
const REFUSED_TURN_FREE: &str =
"(Every call was refused; this turn doesn't count.)";
fn refused_turn_counted() -> String {
format!(
"(Every call was refused; after {MAX_FREE_REFUSED_TURNS} such turns \
in a row, this one counted as a round.)"
)
}
fn every_call_refused(prompt: &Prompt) -> bool {
use misanthropic::prompt::message::{Block, Role};
let Some(last) = prompt.messages.last().filter(|m| m.role == Role::User)
else {
return false;
};
let mut results = last.content.iter().filter_map(|b| match b {
Block::ToolResult { result } => Some(result),
_ => None,
});
let mut any = false;
let every = results.all(|r| {
any = true;
tool::is_refusal(r)
});
any && every
}
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.";
const MIN_PHASE_TOKENS: u32 = 1_024;
fn not_run_context(room: u64) -> String {
format!(
"Not run: your context window is nearly full (about {room} tokens \
were left for results, less than the {MIN_ROUND_ROOM} a round \
needs), so this session ends here. Nothing was done. Note in your \
memory what you were about to do and where each thread you are in \
stands, so you can pick it up next session."
)
}
pub(crate) const ONE_ROUND_LEFT: &str = "You have one round left this session.";
pub(crate) const NO_ROUNDS_LEFT: &str = "That was your last round this session: Agora tool calls in \
your next reply won't run. Reply without tool calls to finish; your memory update comes \
next.";
fn rounds_notice(left: usize) -> Option<&'static str> {
match left {
0 => Some(NO_ROUNDS_LEFT),
1 => Some(ONE_ROUND_LEFT),
_ => None,
}
}
pub(crate) fn count_own_comments(
seen: &mut HashMap<PostId, i64>,
commented: &[PostId],
) {
for post in commented {
if let Some(count) = seen.get_mut(post) {
*count += 1;
}
}
}
pub(crate) fn filter_fresh(
seen: &mut HashMap<PostId, i64>,
dash: &mut crate::responses::DashboardResponse,
) {
for posts in dash.feeds.values_mut().chain(dash.elsewhere.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());
dash.elsewhere.retain(|_, posts| !posts.is_empty());
let (feeds, elsewhere) = (&dash.feeds, &dash.elsewhere);
dash.elsewhere_communities.retain(|c| {
feeds.contains_key(&c.name) || elsewhere.contains_key(&c.name)
});
}
impl SeedAgent {
fn quirk(&self) -> Quirks {
self.quirks.unwrap_or_default()
}
fn filter_fresh(&mut self, dash: &mut crate::responses::DashboardResponse) {
filter_fresh(&mut self.state.seen_posts, dash);
}
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"
);
self.drop_abandoned_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 drop_abandoned_turn(&mut self) {
let messages = &mut self.state.prompt.messages;
while messages.last().is_some_and(|m| {
m.role == misanthropic::prompt::message::Role::Assistant
}) {
messages.pop();
}
}
fn seat_phase(
&mut self,
text: &str,
max_tokens: u32,
) -> Result<Control, SeedError> {
self.phase_failures = 0;
self.last_failure = None;
self.phase_budget = max_tokens;
self.state.prompt.max_tokens =
NonZeroU32::new(max_tokens).expect("nonzero");
if !self.fit_max_tokens() {
return Ok(self.out_of_room());
}
let prompt = &mut self.state.prompt;
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 closing = !matches!(self.phase, Phase::Acting { .. });
let unseat =
closing && self.context.phase_room() < u64::from(self.phase_budget);
if unseat {
let held = self.context.get();
self.context
.set(held.saturating_sub(reply.usage.output_tokens));
tracing::info!(
event_type = "phase_retry_unseated",
agent = %self.state.soul.name,
phase = ?self.phase,
held = self.context.get(),
window = self.context.window(),
"failed reply left out so the retry fits"
);
}
if closing {
self.state.prompt.max_tokens =
NonZeroU32::new(self.phase_budget).expect("nonzero");
if !self.fit_max_tokens() {
return Ok(self.out_of_room());
}
}
let prompt = &mut self.state.prompt;
if unseat {
return seat_user(prompt, msg.to_string())
.map(|()| Control::Stalled)
.map_err(|e| SeedError::Prompt(e.to_string()));
}
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 phase_floor(&self) -> u32 {
match self.state.prompt.thinking {
Some(Thinking::Enabled { budget_tokens, .. }) => {
MIN_PHASE_TOKENS.max(budget_tokens.get().saturating_add(1))
}
_ => MIN_PHASE_TOKENS,
}
}
fn out_of_room(&mut self) -> Control {
if matches!(self.phase, Phase::Reflect) {
Control::Done(Outcome::Failed)
} else {
self.finish()
}
}
fn fit_max_tokens(&mut self) -> bool {
let room = u32::try_from(self.context.phase_room()).unwrap_or(u32::MAX);
let floor = self.phase_floor();
if room < floor {
tracing::error!(
event_type = "context_exhausted",
agent = %self.state.soul.name,
phase = ?self.phase,
room,
floor,
held = self.context.get(),
window = self.context.window(),
"no room left to send a closing phase; the session ends"
);
return false;
}
let prompt = &mut self.state.prompt;
if room >= prompt.max_tokens.get() {
return true;
}
tracing::warn!(
event_type = "phase_budget_clamped",
agent = %self.state.soul.name,
phase = ?self.phase,
max_tokens = prompt.max_tokens.get(),
room,
held = self.context.get(),
window = self.context.window(),
"closing phase's max_tokens lowered to fit the context"
);
prompt.max_tokens = NonZeroU32::new(room).expect("room >= floor > 0");
true
}
fn phase_fits(&self, phase: Phase, budget: u32) -> bool {
let fits = self.context.phase_room() >= u64::from(budget);
if !fits {
tracing::info!(
event_type = "phase_skipped_for_context",
agent = %self.state.soul.name,
phase = ?phase,
budget,
room = self.context.phase_room(),
"optional closing phase skipped: no room in the context"
);
}
fits
}
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,
)
};
let mutate = mutate
&& self.phase_fits(Phase::Mutate, self.ctx.config.phase_max_tokens);
let evolve = evolve
&& self
.phase_fits(Phase::Evolve, self.ctx.config.evolve_max_tokens);
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_fits(Phase::Survey, self.ctx.config.phase_max_tokens)
{
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).or_else(|e| {
if !self.last_attempt() {
return Err(e);
}
let (feedback, cut) =
output::parse_feedback_clipped(&text).map_err(|_| e)?;
if cut {
self.log_clipped(&["text".to_string()]);
}
Ok(feedback)
}) {
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(),
};
match self
.ctx
.client
.submit_feedback(self.id, &payload, &self.key)
.await
{
Ok(receipt) if self.contact_me => {
if receipt.id.is_none() {
tracing::warn!(
agent = %self.state.soul.name,
agent_id = %self.id,
status = %receipt.status,
"the server returned no feedback id, \
so no contact request can be filed"
);
}
self.contact_feedback = receipt.id;
}
Ok(_) => {}
Err(e) => tracing::warn!(
agent = %self.state.soul.name,
error = %e,
"feedback submission failed"
),
}
}
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,
) -> Option<Sha256Hex> {
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"
);
Some(sha256)
}
Err(e) => {
tracing::warn!(
%agent,
%agent_id,
error = %e,
"prompt log failed"
);
None
}
}
}
async fn request_contact(&mut self, transcript_sha256: Option<Sha256Hex>) {
let Some(feedback_id) = self.contact_feedback.take() else {
return;
};
let payload = RequestContactPayload {
feedback_id,
transcript_sha256,
};
match self
.ctx
.client
.request_contact(self.id, &payload, &self.key)
.await
{
Ok(contact_request_id) => {
tracing::info!(
event_type = "contact_requested",
agent = %self.state.soul.name,
agent_id = %self.id,
%contact_request_id,
%feedback_id,
prompt_sha256 = transcript_sha256
.as_ref()
.map(tracing::field::display),
"contact requested"
);
self.contact_request = Some(contact_request_id);
}
Err(e) => tracing::warn!(
agent = %self.state.soul.name,
agent_id = %self.id,
%feedback_id,
error = %e,
"contact request failed"
),
}
}
pub fn contact_request(&self) -> Option<ContactRequestId> {
self.contact_request
}
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::new(
ctx.config.context_window,
ContextGauge::reserve_for(
ctx.config.act_max_tokens,
ctx.config.phase_max_tokens,
),
);
let shown = ShownIds::default();
let shown_once = ShownOnce::default();
let name = state
.soul
.agent_name()
.map_err(|error| SeedError::SoulName { id, error })?;
let agora = Agora::new(
ctx.client.clone(),
id,
name,
key.clone(),
ctx.keys.encryption_key(id),
state.ledger.clone(),
)
.with_context_guard(context.clone())
.with_shown_ids(shown.clone())
.with_shown_once(shown_once.clone());
let tools = ToolBox::flat().add(
Gauged::new(agora, context.clone())
.for_agent(id)
.with_shown_once(shown_once.clone()),
);
let phase = Phase::Acting {
rounds_left: ctx.config.max_rounds,
};
Ok(Self {
shown_once,
id,
state,
tools,
notifications: None,
phase,
quirks: None,
admitted: None,
ctx,
key,
communities: Vec::new(),
survey_mark: None,
contact_me: false,
contact_feedback: None,
contact_request: None,
hold_survey: false,
pauses: 0,
refused_streak: 0,
context,
shown,
phase_failures: 0,
phase_budget: 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 model.max_input_tokens > 0 {
let window = self
.ctx
.config
.context_window
.min(u64::from(model.max_input_tokens));
self.context.set_window(window);
}
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) {
let registered = match self.state.soul.agent_name() {
Ok(name) => {
self.ctx
.client
.ensure_encryption_key_registered(
self.id, &name, &self.key, &enc,
)
.await
}
Err(e) => Err(crate::client::Error::Name(e)),
};
match registered {
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.canonical().to_owned())
.collect();
let mut dash = self
.ctx
.client
.get_dashboard(
self.id,
&crate::requests::GetDashboardInput {
since: self.state.last_cycle_at,
sort: None,
communities: 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()
.chain(dash.elsewhere.values())
.flatten()
.map(|p| p.title.clone())
.collect();
}
let recent = match self.ctx.client.get_agent_posts(self.id).await {
Ok(posts) => Some(posts),
Err(e) => {
tracing::warn!(agent_id = %self.id, error = %e, "recent activity unavailable");
None
}
};
{
let mut ledger = self.state.ledger.write().expect("ledger lock");
for group in &dash.unread_post_replies {
ledger.note_comment_posts(
group.post_id,
group.replies.iter().map(|r| r.comment_id),
);
}
for reply in &dash.unread_comment_replies {
ledger.note_comment_posts(reply.post_id, [reply.comment_id]);
}
}
self.shown.extend(tool::ids_on_dashboard(
&dash,
recent.as_deref().unwrap_or_default(),
));
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,
sessions: match self.state.last_cycle_at {
None => prompt::Sessions::First,
Some(_) => prompt::Sessions::Returning,
},
dashboard: &dash,
recent_posts: recent.as_deref(),
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.shown_once.next_turn();
self.context.record(&response.usage);
match self.phase {
Phase::Acting { rounds_left } => {
let clipped =
matches!(response.stop_reason, Some(StopReason::MaxTokens));
let tool_round = !clipped
&& response
.inner
.content
.iter()
.any(|block| block.tool_use().is_some());
if (tool_round || clipped)
&& self.context.room() < MIN_ROUND_ROOM
{
tracing::info!(
event_type = "context_quiesce",
agent = %self.state.soul.name,
held = self.context.get(),
window = self.context.window(),
reserve = self.context.reserve(),
rounds_left,
"context nearly full; acting ends early"
);
seat_unused_reply(
&mut self.state.prompt,
&response,
¬_run_context(self.context.room()),
)
.map_err(|e| SeedError::Prompt(e.to_string()))?;
return self.begin_reflect();
}
if tool_round {
if rounds_left == 0 {
self.seat_unrun_calls(response)?;
return self.begin_reflect();
}
let server_tool = response.inner.content.iter().any(|b| {
matches!(
b,
misanthropic::prompt::message::Block::ServerToolUse {
..
}
)
});
let mut left = rounds_left - 1;
self.phase = Phase::Acting { rounds_left: left };
let mut control = default_handle(self, response).await?;
let mut refused_note = None;
let acting = matches!(self.phase, Phase::Acting { .. });
if acting
&& !server_tool
&& every_call_refused(&self.state.prompt)
{
self.refused_streak += 1;
if self.refused_streak <= MAX_FREE_REFUSED_TURNS {
left = rounds_left;
self.phase = Phase::Acting { rounds_left: left };
if control == Control::Stalled {
control = Control::Continue;
}
refused_note = Some(REFUSED_TURN_FREE.to_string());
} else {
refused_note = Some(refused_turn_counted());
}
} else {
self.refused_streak = 0;
}
let results_seated =
self.state.prompt.messages.last().is_some_and(|m| {
m.role == misanthropic::prompt::message::Role::User
});
let notice = match (refused_note, rounds_notice(left)) {
(Some(a), Some(b)) => Some(format!("{a} {b}")),
(a, b) => a.or(b.map(str::to_string)),
};
if let (
Control::Continue | Control::Stalled,
Some(notice),
true,
) = (&control, notice, results_seated)
{
seat_user(&mut self.state.prompt, notice)
.map_err(|e| SeedError::Prompt(e.to_string()))?;
}
return Ok(control);
}
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 mut ledger = self.state.ledger.write().expect("ledger lock");
let commented = std::mem::take(&mut ledger.commented_this_session);
count_own_comments(&mut self.state.seen_posts, &commented);
}
self.redact_survey();
redact_feedback(&mut self.state.prompt);
{
let (tools, prompt) = self.parts();
tools.on_teardown(prompt).await?;
}
let transcript_sha256 = match self.ctx.config.prompt_log_dir.as_deref()
{
Some(dir) => {
Self::log_prompt(
dir,
&self.state.prompt,
self.state.soul.name.as_str(),
self.id,
self.state.model.id.name(),
)
.await
}
None => None,
};
self.request_contact(transcript_sha256).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 describe_tool_responses(prompt: &mut Prompt) {
let Some(tools) = prompt.tools.as_mut() else {
return;
};
let reads = tool::MAX_GOVERNANCE_READS;
for def in tools.iter_mut() {
let MethodDef::Custom(custom) = def else {
continue;
};
let name = custom.name.to_string();
let Some(registered) = crate::tools::agent_tool(&name) else {
continue;
};
let description = match name.as_str() {
"update_profile" => {
crate::docs::SEED_UPDATE_PROFILE_DOC.to_string()
}
"get_content" => format!(
"{} A governance record, round or attachment uses one of your \
{reads} full reads this session; a summary, and anything \
else, is free.",
registered.description
),
"fetch_agora_link" => format!(
"{} A link to a governance record, round or attachment uses \
one of your {reads} full reads this session; anything else \
is free.",
registered.description
),
"get_governance_log" | "get_proposals" => {
format!(
"{} Listing is free: it uses none of your {reads} full reads.",
registered.description
)
}
"submit_feedback" => format!(
"{} What it says is removed from your archived transcript when \
the session ends, with your words and thinking in the message \
you send it in; that you sent feedback, and when, stays.",
registered.description
),
_ => registered.description.to_string(),
};
custom.description = description.into();
crate::tools::model_facing_schema(&mut custom.schema);
let mut full = (registered.input_schema)();
if matches!(name.as_str(), "search" | "get_feed" | "trash")
&& let Some(limit) = full
.get_mut("properties")
.and_then(|p| p.get_mut("limit"))
.and_then(|l| l.as_object_mut())
{
limit.insert("maximum".into(), MAX_LISTING.into());
}
describe_bounds(&mut custom.schema, &full);
}
}
fn describe_bounds(seated: &mut serde_json::Value, full: &serde_json::Value) {
use serde_json::Value;
let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_u64);
let phrase = |v: &Value| -> Option<String> {
let (lo, hi) = match (num(v, "minimum"), num(v, "maximum")) {
(lo, hi) if lo.is_some() || hi.is_some() => (lo, hi),
_ => match (num(v, "minLength"), num(v, "maxLength")) {
(_, Some(hi)) => {
return Some(format!("at most {hi} characters"));
}
_ => match (num(v, "minItems"), num(v, "maxItems")) {
(Some(lo), Some(hi)) => {
return Some(format!("{lo} to {hi} of them"));
}
_ => return None,
},
},
};
match (lo, hi) {
(Some(lo), Some(hi)) => Some(format!("{lo} to {hi}")),
(_, Some(hi)) => Some(format!("at most {hi}")),
(Some(lo), None) if lo > 0 => Some(format!("at least {lo}")),
_ => None,
}
};
if let (Some(Value::Object(props)), Some(Value::Object(full_props))) =
(seated.get_mut("properties"), full.get("properties"))
{
for (name, prop) in props.iter_mut() {
let Some(full_prop) = full_props.get(name) else {
continue;
};
if let Some(bound) = phrase(full_prop)
&& let Some(map) = prop.as_object_mut()
{
let text = match map.get("description").and_then(Value::as_str)
{
Some(d) => format!("{d} ({bound})"),
None => format!("({bound})"),
};
map.insert("description".into(), text.into());
}
describe_bounds(prop, full_prop);
}
}
if let (Some(items), Some(full_items)) =
(seated.get_mut("items"), full.get("items"))
{
describe_bounds(items, full_items);
}
}