mod formation;
mod maintenance;
pub mod prompts;
mod recall;
use anda_core::{
BoxError, ContentPart, Document, FunctionDefinition, Json, Message, Principal, Resource, Tool,
ToolGroupInfo, ToolOutput, Usage,
};
use anda_db::{query::Fv, schema::DocumentId};
use anda_engine::{
context::{BaseCtx, CompletionRunner},
memory::{Conversation, ConversationStatus, KipArgs, MemoryManagement},
unix_ms,
};
use anda_kip::Response;
use parking_lot::RwLock;
use std::{
collections::{BTreeMap, VecDeque},
sync::Arc,
};
use crate::kip;
pub use formation::*;
pub use maintenance::*;
pub use recall::*;
pub(crate) const PERSON_BY_KEY: &str =
r#"FIND(?person) WHERE { ?person CONCEPT {type: "Person", key: :key} } LIMIT 1"#;
pub(crate) fn first_row(result: Json) -> Json {
match result {
Json::Array(rows) => rows.into_iter().next().unwrap_or(Json::Null),
other => other,
}
}
#[derive(Clone)]
pub(crate) struct Observation(pub Option<Arc<anda_kip::IngestContext>>);
#[derive(Clone)]
pub struct GuardedMemory {
product_control: Option<Arc<crate::product::control::Control>>,
memory: Arc<MemoryManagement>,
clock: Arc<crate::runtime::BusinessClock>,
}
impl GuardedMemory {
pub(crate) fn with_product_control(
mut self,
control: Arc<crate::product::control::Control>,
) -> Self {
self.product_control = Some(control);
self
}
pub fn new(memory: Arc<MemoryManagement>) -> Self {
Self {
product_control: None,
memory,
clock: Arc::new(crate::runtime::BusinessClock::default()),
}
}
pub(crate) fn with_clock(mut self, clock: Arc<crate::runtime::BusinessClock>) -> Self {
self.clock = clock;
self
}
}
impl Tool<BaseCtx> for GuardedMemory {
type Args = KipArgs;
type Output = Response;
fn name(&self) -> String {
self.memory.name()
}
fn description(&self) -> String {
self.memory.description()
}
fn group(&self) -> Option<ToolGroupInfo> {
self.memory.group()
}
fn definition(&self) -> FunctionDefinition {
self.memory.definition()
}
async fn call(
&self,
ctx: BaseCtx,
args: Self::Args,
resources: Vec<Resource>,
) -> Result<ToolOutput<Self::Output>, BoxError> {
let _gate = if let Some(control) = &self.product_control {
let guard = control.gate.lock().await;
control.check(&ctx)?;
Some(guard)
} else {
None
};
if let Some(control) = &self.product_control
&& control.epoch() > 0
{
control.current_request(&args.clone().into_request()?)?;
}
let formation = ctx.agent == FormationAgent::NAME;
if !formation && ctx.agent != MaintenanceAgent::NAME {
return self.memory.call(ctx, args, resources).await;
}
let mut request = match args.into_request() {
Ok(request) => request,
Err(err) => return Ok(error_output(Response::from(err))),
};
self.clock.bind_read(&mut request)?;
if formation
&& let Some(Observation(Some(observation))) = ctx.get_state::<Observation>()
&& let Err(error) = kip::attach_observation(&mut request, &observation)
{
return Ok(error_output(Response::from(
anda_kip::KipError::not_authorized(error),
)));
}
let nexus = self.memory.nexus();
let nexus = nexus.as_ref();
Ok(error_output(if formation {
kip::execute_cognition_request(nexus, &request).await
} else {
kip::execute_maintenance_request(nexus, &request).await
}))
}
}
fn error_output(res: Response) -> ToolOutput<Response> {
let is_error = (!kip::succeeded(&res)).then_some(true);
let mut output = ToolOutput::new(res);
output.is_error = is_error;
output
}
#[async_trait::async_trait]
pub trait BrainHook: Send + Sync {
fn is_maintenance_processing(&self) -> bool;
async fn on_conversation_end(&self, agent_name: &str, conversation: &Conversation);
async fn try_start_formation(&self);
async fn try_start_maintenance(&self, formation_id: DocumentId) -> Option<DocumentId>;
}
pub static SELF_USER_ID: Principal = Principal::from_slice(&[1]);
const COMPACTION_CONTINUE_PROMPT: &str = "Continue the active memory-agent work from the compaction handoff. The handoff contains the conversation state immediately before compaction.";
pub(super) const PERSIST_EVERY_N_TURNS: usize = 5;
pub(super) const RUNNER_MAX_MODEL_TURNS: usize = 200;
pub(super) const RUNNER_MAX_WALL_CLOCK_MS: u64 = 30 * 60 * 1000;
pub(super) enum RunnerFlow {
Continue,
Break,
}
pub(super) trait RunnerHost {
fn label(&self) -> &'static str;
fn history(&self) -> &RwLock<VecDeque<Document>>;
async fn persist_snapshot(&self, conversation: &Conversation);
async fn mark_failed(&self, conversation: &mut Conversation, reason: String);
fn turn_is_done(&self, runner: &CompletionRunner) -> bool;
fn on_turn_success(&self, conversation: &mut Conversation);
fn after_turn(&mut self, runner: &mut CompletionRunner, is_done: bool) -> RunnerFlow;
}
pub(super) async fn drive_runner_loop<H: RunnerHost>(
host: &mut H,
runner: &mut CompletionRunner,
conversation: &mut Conversation,
) {
let started_at_ms = unix_ms();
let mut replace_initial_input = true;
let mut persisted_runner_history_len = 0;
let mut total_model_turns = 0usize;
let mut accounted_runner_turns = 0usize;
let mut unpersisted_turns = 0usize;
let failure: Option<String> = 'run: {
loop {
if total_model_turns >= RUNNER_MAX_MODEL_TURNS {
break 'run Some(format!(
"{} exceeded model turn limit of {}",
host.label(),
RUNNER_MAX_MODEL_TURNS
));
}
if unix_ms().saturating_sub(started_at_ms) >= RUNNER_MAX_WALL_CLOCK_MS {
break 'run Some(format!(
"{} exceeded wall-clock budget of {} seconds",
host.label(),
RUNNER_MAX_WALL_CLOCK_MS / 1000
));
}
match compact_runner_if_needed(runner).await {
Ok(true) => {
total_model_turns = total_model_turns.saturating_add(1);
accounted_runner_turns = runner.turns();
persisted_runner_history_len = 0;
replace_initial_input = false;
}
Ok(false) => {}
Err(err) => break 'run Some(format!("CompletionRunner error: {err:?}")),
}
match runner.next().await {
Ok(None) => break 'run None,
Ok(Some(res)) => {
let runner_turns = runner.turns();
total_model_turns = total_model_turns
.saturating_add(runner_turns.saturating_sub(accounted_runner_turns));
accounted_runner_turns = runner_turns;
let now_ms = unix_ms();
let is_done = host.turn_is_done(runner);
append_runner_history(
conversation,
&res.chat_history,
&mut persisted_runner_history_len,
&mut replace_initial_input,
);
conversation.status = if res.failed_reason.is_some() {
ConversationStatus::Failed
} else if is_done {
ConversationStatus::Completed
} else {
ConversationStatus::Working
};
conversation.usage = res.usage;
conversation.updated_at = now_ms;
if let Some(failed_reason) = res.failed_reason {
conversation.failed_reason = Some(failed_reason);
} else {
host.on_turn_success(conversation);
push_completed_history(host.history(), conversation, 2);
}
unpersisted_turns = unpersisted_turns.saturating_add(1);
if conversation.status != ConversationStatus::Working
|| unpersisted_turns >= PERSIST_EVERY_N_TURNS
{
host.persist_snapshot(conversation).await;
unpersisted_turns = 0;
}
if conversation.status == ConversationStatus::Failed {
break 'run None;
}
if let RunnerFlow::Break = host.after_turn(runner, is_done) {
break 'run None;
}
}
Err(err) => break 'run Some(format!("CompletionRunner error: {err:?}")),
}
}
};
if let Some(reason) = failure {
conversation.usage = runner.total_usage().clone();
host.mark_failed(conversation, reason).await;
}
if unpersisted_turns > 0 && conversation.status == ConversationStatus::Working {
host.persist_snapshot(conversation).await;
}
}
pub(super) async fn mark_conversation_failed<F, Fut, E>(
update: F,
label: &str,
conversation: &mut Conversation,
reason: String,
) where
F: FnOnce(u64, BTreeMap<String, Fv>) -> Fut,
Fut: Future<Output = Result<(), E>>,
{
log::error!(
target: "brain",
"{label} conversation {} failed: {}",
conversation._id,
reason
);
conversation.failed_reason = Some(reason);
conversation.status = ConversationStatus::Failed;
conversation.updated_at = unix_ms();
if let Ok(changes) = conversation.to_changes() {
let _ = update(conversation._id, changes).await;
}
}
pub(super) async fn persist_conversation_snapshot<F, Fut, E>(
update: F,
label: &str,
conversation: &Conversation,
clear_failed_reason: bool,
) where
F: FnOnce(u64, BTreeMap<String, Fv>) -> Fut,
Fut: Future<Output = Result<(), E>>,
{
match conversation.to_changes() {
Ok(mut changes) => {
if clear_failed_reason && conversation.failed_reason.is_none() {
changes.insert("failed_reason".to_string(), Fv::Null);
}
let _ = update(conversation._id, changes).await;
}
Err(err) => {
log::error!(
target: "brain",
"Failed to serialize {label} conversation {} changes: {:?}",
conversation._id,
err
);
}
}
}
fn queued_runner_tokens(runner: &CompletionRunner) -> u64 {
runner
.steering_message_iter()
.chain(runner.follow_up_message_iter())
.map(|part| part.estimated_tokens() as u64)
.sum()
}
pub(super) async fn compact_runner_if_needed(
runner: &mut CompletionRunner,
) -> Result<bool, BoxError> {
if runner.is_done() {
return Ok(false);
}
if !runner.needs_compaction_with(|| queued_runner_tokens(runner)) {
return Ok(false);
}
let pre_usage = runner.total_usage().clone();
let pre_tools_usage = runner.tools_usage().clone();
match runner.handoff(None).await {
Ok((mut compacted, output)) => {
compacted.accumulate(&output.usage);
compacted.accumulate_tools_usage(&output.tools_usage);
compacted.follow_up(ContentPart::from(COMPACTION_CONTINUE_PROMPT.to_string()));
*runner = compacted;
Ok(true)
}
Err(err) => {
if usage_is_empty(runner.total_usage()) {
runner.accumulate(&pre_usage);
}
if runner.tools_usage().is_empty() {
runner.accumulate_tools_usage(&pre_tools_usage);
}
Err(err)
}
}
}
fn usage_is_empty(usage: &Usage) -> bool {
usage.input_tokens == 0
&& usage.output_tokens == 0
&& usage.cached_tokens == 0
&& usage.requests == 0
}
pub(super) fn push_completed_history(
history: &RwLock<VecDeque<Document>>,
conversation: &Conversation,
max_len: usize,
) {
if conversation.status != ConversationStatus::Completed || max_len == 0 {
return;
}
let doc: Document = conversation.clone().into();
let mut history = history.write();
history.push_back(doc);
let len = history.len();
if len > max_len {
history.drain(0..(len - max_len));
}
}
pub(super) fn append_runner_history(
conversation: &mut Conversation,
chat_history: &[Message],
persisted_runner_history_len: &mut usize,
replace_existing: &mut bool,
) {
if chat_history.is_empty() {
return;
}
if *replace_existing {
conversation.messages.clear();
*replace_existing = false;
}
let incoming_len = chat_history.len();
let new_messages = if incoming_len >= *persisted_runner_history_len {
chat_history[*persisted_runner_history_len..].to_vec()
} else {
chat_history.to_vec()
};
conversation.append_messages(new_messages);
*persisted_runner_history_len = incoming_len;
}
#[cfg(test)]
mod tests {
use super::{append_runner_history, push_completed_history};
use anda_core::Message;
use anda_engine::memory::{Conversation, ConversationStatus};
use parking_lot::RwLock;
use std::collections::VecDeque;
#[test]
fn push_completed_history_ignores_working_conversations_and_caps_length() {
let history = RwLock::new(VecDeque::new());
let mut conversation = Conversation {
_id: 1,
status: ConversationStatus::Working,
..Default::default()
};
push_completed_history(&history, &conversation, 2);
assert!(history.read().is_empty());
conversation.status = ConversationStatus::Completed;
push_completed_history(&history, &conversation, 2);
conversation._id = 2;
push_completed_history(&history, &conversation, 2);
conversation._id = 3;
push_completed_history(&history, &conversation, 2);
assert_eq!(history.read().len(), 2);
}
#[test]
fn append_runner_history_appends_after_runner_reset_without_clearing() {
let mut conversation = Conversation::default();
let mut persisted_runner_history_len = 0;
let mut replace_existing = true;
conversation.append_messages(vec![Message {
role: "user".to_string(),
content: vec!["original input".to_string().into()],
..Default::default()
}]);
append_runner_history(
&mut conversation,
&[Message {
role: "assistant".to_string(),
content: vec!["first runner draft".to_string().into()],
..Default::default()
}],
&mut persisted_runner_history_len,
&mut replace_existing,
);
assert_eq!(conversation.messages.len(), 1);
persisted_runner_history_len = 0;
replace_existing = false;
append_runner_history(
&mut conversation,
&[Message {
role: "assistant".to_string(),
content: vec!["compacted runner summary".to_string().into()],
..Default::default()
}],
&mut persisted_runner_history_len,
&mut replace_existing,
);
let messages = serde_json::to_string(&conversation.messages).unwrap();
assert!(messages.contains("first runner draft"));
assert!(messages.contains("compacted runner summary"));
assert!(!messages.contains("original input"));
}
}