use std::fmt::Write as _;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::run::Party;
use argonauts_core::events::session::{
Delivery, InputPart, InputPartKind, QueuedMessage, SessionEvent,
};
use argonauts_core::events::shared::{
BlackboardEvent, BlackboardKind, BlackboardMessage, Recipient,
};
use argonauts_core::input::MentionTarget;
use argonauts_core::names::{MessageId, RoleName, SessionKey};
use argonauts_core::session_runner::SessionControl;
use argonauts_core::settings::Settings;
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::state::blackboard::Addressee;
pub fn at_session_start(host: &Host, session: &SessionKey) -> EngineResult<Vec<InputPart>> {
let (parts, deliveries) = for_session_start(host, session)?;
deliveries.record(host)?;
Ok(parts)
}
pub fn for_session_start(
host: &Host,
session: &SessionKey,
) -> EngineResult<(Vec<InputPart>, StartDeliveries)> {
let who = addressee(host, session)?;
let blind = is_blind(host, &who);
let pending: Vec<BlackboardMessage> = host.read(|state| {
state
.blackboard
.pending_for(&who)
.into_iter()
.cloned()
.collect()
});
let mut parts = Vec::new();
let mut messages = Vec::new();
for message in pending {
let Some(text) = text_for(&message, blind) else {
continue;
};
parts.push(part(&message, text));
messages.push(message.id);
}
let deliveries = StartDeliveries {
to: Some(who),
messages,
};
Ok((parts, deliveries))
}
#[derive(Debug, Default)]
#[must_use = "record the deliveries once the session has started"]
pub struct StartDeliveries {
to: Option<Addressee>,
messages: Vec<MessageId>,
}
impl StartDeliveries {
pub fn record(self, host: &Host) -> EngineResult<()> {
let Some(who) = &self.to else {
return Ok(());
};
for message in &self.messages {
record_delivered(host, message, who)?;
}
Ok(())
}
}
pub fn queue(
host: &Host,
message: &MessageId,
session: &SessionKey,
) -> EngineResult<QueuedMessage> {
let who = addressee(host, session)?;
let posted = host
.read(|state| state.blackboard.get(message).cloned())
.ok_or_else(|| EngineError::Refused(format!("There's no message {message}")))?;
let text = text_for(&posted, is_blind(host, &who)).ok_or_else(|| {
EngineError::Refused(format!(
"{session} works blind, so it gets only your own guidance and mentions"
))
})?;
let queued = QueuedMessage {
id: posted.id.clone(),
from: posted.from.clone(),
to: target(&posted.to, &who),
text,
queued_at: host.now(),
delivery: Delivery::NextTurn,
};
if host.read(|state| state.blackboard.was_delivered(message, session)) {
return Ok(queued);
}
hand_over(host, &who, queued)
}
pub fn to_running(host: &Host, message: &MessageId) -> EngineResult<Vec<QueuedMessage>> {
let mut queued = Vec::new();
for session in host.shared().live.keys() {
let Ok(who) = addressee(host, &session) else {
continue;
};
let wanted = host.read(|state| {
state
.blackboard
.pending_for(&who)
.iter()
.any(|pending| &pending.id == message)
});
if !wanted {
continue;
}
match queue(host, message, &session) {
Ok(one) => queued.push(one),
Err(EngineError::Refused(_)) => {}
Err(error) => return Err(error),
}
}
Ok(queued)
}
pub(crate) fn hand_over(
host: &Host,
who: &Addressee,
queued: QueuedMessage,
) -> EngineResult<QueuedMessage> {
let slot = host
.reserve_control(&who.session)
.map_err(EngineError::Refused)?;
host.record(
who.task.as_ref(),
Some(&who.session),
RunEventKind::Session(SessionEvent::Queued(queued.clone())),
)?;
record_delivered(host, &queued.id, who)?;
slot.send(SessionControl::Deliver(queued.clone()));
Ok(queued)
}
fn record_delivered(host: &Host, message: &MessageId, who: &Addressee) -> EngineResult<()> {
if host.read(|state| state.blackboard.was_delivered(message, &who.session)) {
return Ok(());
}
host.record(
who.task.as_ref(),
Some(&who.session),
RunEventKind::Blackboard(BlackboardEvent::Delivered {
message: message.clone(),
to: who.session.clone(),
}),
)?;
Ok(())
}
pub fn addressee(host: &Host, session: &SessionKey) -> EngineResult<Addressee> {
host.read(|state| {
state.sessions.get(session).map(|record| Addressee {
session: session.clone(),
worker: record.started.worker.clone(),
role: record.started.role,
step: record.started.step,
task: record.task.clone(),
})
})
.ok_or_else(|| {
EngineError::Refused(format!(
"{session} has no recorded start, so no message can reach it yet"
))
})
}
fn is_blind(host: &Host, who: &Addressee) -> bool {
match host.shared().live.get(&who.session) {
Some(live) => live.blind,
None => blind_role(who.role, host.settings()),
}
}
pub fn blind_role(role: RoleName, settings: &Settings) -> bool {
match role {
RoleName::Draft => settings.drafting.blind,
RoleName::Judge => true,
RoleName::Reviewer => settings
.roles
.get(&RoleName::Reviewer)
.and_then(|reviewer| reviewer.blind)
.unwrap_or(settings.review.blind),
_ => false,
}
}
pub fn text_for(message: &BlackboardMessage, blind: bool) -> Option<String> {
if blind {
let yours = message.from == Party::User
&& matches!(
message.kind,
BlackboardKind::Guidance | BlackboardKind::Mention
);
return yours.then(|| message.text.clone());
}
Some(framed(message))
}
fn framed(message: &BlackboardMessage) -> String {
let mut text = format!(
"{} {} from {}",
kind_label(message.kind),
message.id,
party_label(&message.from)
);
if let Some(about) = &message.in_reply_to {
let _ = write!(text, ", about {about}");
}
let _ = write!(text, ":\n{}", message.text);
if !message.refs.is_empty() {
let _ = write!(text, "\nCites: {}", message.refs.join(", "));
}
match message.kind {
BlackboardKind::Question => {
let _ = write!(
text,
"\nAnswer it in your report, on its own line in `concerns`: `answer {}: <your answer>`.",
message.id
);
}
BlackboardKind::ChangeRequest => {
let _ = write!(
text,
"\nDecide it in your report, on its own line in `concerns`: `accept {id}: <why>` or `reject {id}: <why>`.",
id = message.id
);
}
_ => {}
}
text
}
fn part(message: &BlackboardMessage, text: String) -> InputPart {
InputPart {
kind: InputPartKind::Message,
label: format!("{} {}", kind_label(message.kind), message.id),
approx_tokens: u32::try_from(text.len().div_ceil(4)).unwrap_or(u32::MAX),
text,
source: Some(message.id.to_string()),
}
}
pub fn target(to: &Recipient, who: &Addressee) -> MentionTarget {
match to {
Recipient::All => MentionTarget::All,
Recipient::Task { task } => MentionTarget::Task(task.clone()),
Recipient::Role { role } => MentionTarget::Role(*role),
Recipient::Worker { worker } => MentionTarget::Worker(worker.clone()),
Recipient::Session { .. } | Recipient::User => MentionTarget::Worker(who.worker.clone()),
}
}
pub fn kind_label(kind: BlackboardKind) -> &'static str {
match kind {
BlackboardKind::Question => "Question",
BlackboardKind::Answer => "Answer",
BlackboardKind::ChangeRequest => "Change request",
BlackboardKind::Decision => "Decision",
BlackboardKind::ExperimentReport => "Experiment report",
BlackboardKind::Fallback => "Fallback",
BlackboardKind::Guidance => "Guidance",
BlackboardKind::Mention => "Message",
}
}
pub fn party_label(party: &Party) -> String {
match party {
Party::User | Party::Declined => "you".to_owned(),
Party::Rules => "your approval rules".to_owned(),
Party::Judge { model } => format!("your judge {model}"),
Party::Config => "a setting".to_owned(),
Party::Autopilot => "autopilot".to_owned(),
Party::ArgonautsCode => "Argonauts Code".to_owned(),
Party::Network => "the network".to_owned(),
Party::Agent { worker, .. } => worker.to_string(),
Party::OutsideAgent { client } => client.clone(),
}
}
#[cfg(test)]
mod tests {
use argonauts_core::names::ModelRef;
use super::*;
fn message(kind: BlackboardKind, from: Party) -> BlackboardMessage {
BlackboardMessage {
id: MessageId::numbered(4),
kind,
from,
to: Recipient::All,
text: "Keep the limiter in-process.".to_owned(),
in_reply_to: None,
refs: vec!["src/limiter/mod.rs".to_owned()],
}
}
#[test]
fn blind_sessions_get_your_text_only() {
let yours = message(BlackboardKind::Guidance, Party::User);
assert_eq!(
text_for(&yours, true).as_deref(),
Some("Keep the limiter in-process.")
);
let framed = text_for(&yours, false).unwrap();
assert!(framed.starts_with("Guidance m-004 from you:\n"));
assert!(framed.contains("Cites: src/limiter/mod.rs"));
let agent = message(
BlackboardKind::Question,
Party::Agent {
session: SessionKey::numbered(2),
worker: ModelRef::new("codex", None),
},
);
assert_eq!(text_for(&agent, true), None);
assert!(text_for(&agent, false).unwrap().contains("`answer m-004: "));
}
#[test]
fn drafting_agents_and_judges_are_blind() {
let settings = Settings::default();
assert!(blind_role(RoleName::Draft, &settings));
assert!(blind_role(RoleName::Judge, &settings));
assert!(!blind_role(RoleName::Worker, &settings));
}
}