Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/botloft-core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub const DELETE_ROUTINE_TOOL: &str = "mcp__botloft__delete_routine";
/// A bot asking to rename or change itself, or the chief another bot
/// (spec 10.3).
pub const CHANGE_BOT_TOOL: &str = "mcp__botloft__change_bot";
/// A bot asking to reach another crew (spec 10.4).
pub const CREW_ACCESS_TOOL: &str = "mcp__botloft__ask_crew_access";
/// A bot asking to use its browser on a site (spec 21.5). Not a tool the
/// model calls: the daemon opens this request from inside the `browser_*`
/// tools.
Expand Down Expand Up @@ -116,6 +118,7 @@ pub fn tool_summary(name: &str, input: &Value) -> String {
SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL | DELETE_ROUTINE_TOOL
| CHANGE_BOT_TOOL => field(input, "name").map(str::to_owned),
BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned),
CREW_ACCESS_TOOL => field(input, "crew").map(str::to_owned),
DESKTOP_TOOL => field(input, "app").map(str::to_owned),
"mcp__botloft__desktop_look" | "mcp__botloft__desktop_screenshot" => {
field(input, "why").map(str::to_owned)
Expand Down
29 changes: 27 additions & 2 deletions crates/botloft-core/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub enum Sender<'a> {
#[derive(Debug, Clone, Copy)]
pub struct Envelope<'a> {
pub from: Sender<'a>,
/// The sending bot's crew, when it is not the recipient's (spec 10.4).
pub from_crew: Option<&'a str>,
/// Display name of the crew.
pub crew: &'a str,
pub kind: MessageKind,
Expand All @@ -31,7 +33,11 @@ pub struct Envelope<'a> {
impl Envelope<'_> {
/// Renders the envelope as of `now_ms`, which sets the relative deadline.
pub fn render(&self, now_ms: i64) -> String {
let mut header = format!("[botloft] from {} · crew {}", self.sender(), self.crew);
let mut header = format!("[botloft] from {}", self.sender());
if let Some(from_crew) = self.from_crew {
header.push_str(&format!(" of crew {from_crew}"));
}
header.push_str(&format!(" · crew {}", self.crew));
if let Some(task) = self.task {
let detail = match self.kind {
MessageKind::Task => {
Expand All @@ -47,7 +53,12 @@ impl Envelope<'_> {
}
let mut text = header;
if let Sender::Bot { handle } = self.from {
text.push_str(&format!("\nReply with send_message(to: \"{handle}\")."));
match self.from_crew {
None => text.push_str(&format!("\nReply with send_message(to: \"{handle}\").")),
Some(from_crew) => text.push_str(&format!(
"\nReply with send_message(to: \"{handle}\", crew: \"{from_crew}\")."
)),
}
if let (MessageKind::Task, Some(task)) = (self.kind, self.task) {
text.push_str(&format!(
" When the task is done, call complete_task(task_id: \"{}\").",
Expand Down Expand Up @@ -225,6 +236,7 @@ mod tests {
fn envelope<'a>(from: Sender<'a>, kind: MessageKind, task: Option<&'a Task>) -> Envelope<'a> {
Envelope {
from,
from_crew: None,
crew: "Site",
kind,
task,
Expand All @@ -251,6 +263,19 @@ mod tests {
);
}

#[test]
fn a_bot_of_another_crew_is_answered_in_its_crew() {
let mut from_blog = envelope(Sender::Bot { handle: "writer" }, MessageKind::Note, None);
from_blog.from_crew = Some("Blog");
assert_eq!(
from_blog.render(0),
"[botloft] from @writer of crew Blog · crew Site\n\
Reply with send_message(to: \"writer\", crew: \"Blog\").\n\
\n\
Check the build.\nThanks!"
);
}

#[test]
fn owner_notes_have_no_reply_line() {
let text = envelope(Sender::Owner, MessageKind::Note, None).render(0);
Expand Down
4 changes: 4 additions & 0 deletions crates/botloft-core/src/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ prefixed_id!(
/// (spec 24.2).
DesktopGrantId, "desktop grant", "dsk_"
);
prefixed_id!(
/// Identifies what the owner let a bot reach in another crew (spec 10.4).
CrewAccessId, "crew access", "cxa_"
);

/// A random version 4 UUID, for Claude Code session ids and the uuid of
/// each message written to a bot (spec 7.3, 9.2).
Expand Down
14 changes: 14 additions & 0 deletions crates/botloft-store/migrations/0021_crew_access.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- What the owner let a bot reach in another crew for good (spec 10.4): the
-- whole crew, or one of its bots. Access for one turn only stays in memory.
CREATE TABLE crew_access (
id TEXT PRIMARY KEY,
bot_id TEXT NOT NULL REFERENCES bots (id),
crew_id TEXT NOT NULL REFERENCES crews (id),
target_bot_id TEXT REFERENCES bots (id),
talk INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);

-- One row per bot and reach: the crew, or one bot of it.
CREATE UNIQUE INDEX crew_access_reach
ON crew_access (bot_id, crew_id, coalesce(target_bot_id, ''));
127 changes: 127 additions & 0 deletions crates/botloft-store/src/crew_access.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! `crew_access` table: what the owner let a bot reach in another crew for
//! good (spec 10.4).

use botloft_core::ids::{BotId, CrewAccessId, CrewId};
use rusqlite::{Row, params};

use crate::{Result, Store, parse_column};

/// A bot's lasting access to another crew, or to one bot of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrewAccessRecord {
pub id: CrewAccessId,
/// The bot that asked.
pub bot_id: BotId,
pub crew_id: CrewId,
/// One bot of the crew; `None` for the whole crew.
pub target_bot_id: Option<BotId>,
/// Talking with its bots: roster, messages and tasks.
pub talk: bool,
pub created_at: i64,
}

const COLUMNS: &str = "id, bot_id, crew_id, target_bot_id, talk, created_at";

fn from_row(row: &Row<'_>) -> rusqlite::Result<CrewAccessRecord> {
let target: Option<String> = row.get(3)?;
Ok(CrewAccessRecord {
id: parse_column(row, 0)?,
bot_id: parse_column(row, 1)?,
crew_id: parse_column(row, 2)?,
target_bot_id: match target {
Some(_) => Some(parse_column(row, 3)?),
None => None,
},
talk: row.get(4)?,
created_at: row.get(5)?,
})
}

impl Store {
/// Lets `bot` reach `crew`, or only `target` in it. Access already
/// there keeps what it had and gains what is new.
pub fn grant_crew_access(
&self,
bot: &BotId,
crew: &CrewId,
target: Option<&BotId>,
talk: bool,
now: i64,
) -> Result<()> {
self.conn.execute(
"INSERT INTO crew_access (id, bot_id, crew_id, target_bot_id, talk, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT (bot_id, crew_id, coalesce(target_bot_id, '')) DO UPDATE SET \
talk = talk OR excluded.talk",
params![
CrewAccessId::generate().as_str(),
bot.as_str(),
crew.as_str(),
target.map(BotId::as_str),
talk,
now,
],
)?;
Ok(())
}

/// What `bot` may reach in other crews, oldest first.
pub fn crew_access(&self, bot: &BotId) -> Result<Vec<CrewAccessRecord>> {
let mut statement = self.conn.prepare_cached(&format!(
"SELECT {COLUMNS} FROM crew_access WHERE bot_id = ?1 ORDER BY created_at, id"
))?;
let rows = statement
.query_map([bot.as_str()], from_row)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}

/// Takes the access back; `false` if there was none with that id.
pub fn revoke_crew_access(&self, id: &CrewAccessId) -> Result<bool> {
Ok(self
.conn
.execute("DELETE FROM crew_access WHERE id = ?1", [id.as_str()])?
> 0)
}
}

#[cfg(test)]
mod tests {
use botloft_core::ids::CrewId;

use crate::tests::Fixture;

#[test]
fn access_is_kept_per_bot_and_reach_and_only_grows() {
let fx = Fixture::new();
let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id);
let blog = CrewId::generate();
fx.store
.insert_crew(&botloft_core::protocol::Crew {
id: blog.clone(),
slug: "blog".into(),
..fx.crew.clone()
})
.expect("blog");
fx.store
.grant_crew_access(scout, &blog, Some(writer), true, 10)
.expect("bot");
fx.store
.grant_crew_access(scout, &blog, None, false, 20)
.expect("crew");
fx.store
.grant_crew_access(scout, &blog, None, true, 30)
.expect("crew again");
let access = fx.store.crew_access(scout).expect("list");
assert_eq!(access.len(), 2);
assert_eq!(access[0].target_bot_id.as_ref(), Some(writer));
assert!(access[1].target_bot_id.is_none());
assert!(access[1].talk);
assert_eq!(access[1].created_at, 20);
assert!(fx.store.crew_access(writer).expect("none").is_empty());

assert!(fx.store.revoke_crew_access(&access[0].id).expect("revoke"));
assert!(!fx.store.revoke_crew_access(&access[0].id).expect("again"));
assert_eq!(fx.store.crew_access(scout).expect("list").len(), 1);
}
}
5 changes: 4 additions & 1 deletion crates/botloft-store/src/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const DELETE_BOT: &[&str] = &[
"DELETE FROM chat_items WHERE bot_id = ?1",
"DELETE FROM browser_sites WHERE bot_id = ?1",
"DELETE FROM desktop_grants WHERE bot_id = ?1",
"DELETE FROM crew_access WHERE bot_id = ?1 OR target_bot_id = ?1",
"DELETE FROM allow_rules WHERE bot_id = ?1",
"DELETE FROM routine_runs WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)",
// What the bot received goes with it.
Expand Down Expand Up @@ -74,7 +75,9 @@ impl Store {
delete_bot_in(&tx, bot)?;
}
// Every message and task of a crew is to or for one of its bots, so
// nothing of the crew is left to hold it.
// nothing of the crew is left to hold it. Other bots' access to it
// goes too.
tx.execute("DELETE FROM crew_access WHERE crew_id = ?1", [id.as_str()])?;
let deleted = tx.execute("DELETE FROM crews WHERE id = ?1", [id.as_str()])? > 0;
tx.commit()?;
Ok(deleted)
Expand Down
1 change: 1 addition & 0 deletions crates/botloft-store/src/delete/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ fn a_deleted_crew_takes_its_bots_and_leaves_other_crews_alone() {
"browser_sites",
"allow_rules",
"desktop_grants",
"crew_access",
] {
assert_eq!(count(&fx.store, table), 0, "{table}");
}
Expand Down
2 changes: 2 additions & 0 deletions crates/botloft-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod approvals;
mod bots;
mod browser_sites;
mod chat;
mod crew_access;
mod crews;
mod delete;
mod deliveries;
Expand All @@ -35,6 +36,7 @@ use rusqlite::{Connection, Row, ffi};

pub use approvals::ApprovalRecord;
pub use bots::BotRecord;
pub use crew_access::CrewAccessRecord;
pub use deliveries::DeliveryOutcome;
pub use messages::MessageFilter;
pub use migrate::LATEST_VERSION;
Expand Down
10 changes: 10 additions & 0 deletions crates/botloft-store/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ impl Store {
.optional()?)
}

/// Whether `from` sent `to` a message at `since` or later.
pub fn wrote_to_since(&self, from: &BotId, to: &BotId, since: i64) -> Result<bool> {
Ok(self.conn.query_row(
"SELECT EXISTS (SELECT 1 FROM messages \
WHERE from_bot_id = ?1 AND to_bot_id = ?2 AND created_at >= ?3)",
params![from.as_str(), to.as_str(), since],
|row| row.get(0),
)?)
}

pub fn message(&self, id: &MessageId) -> Result<Option<Message>> {
let message = self
.conn
Expand Down
1 change: 1 addition & 0 deletions crates/botloft-store/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const MIGRATIONS: &[(u32, &str)] = &[
(18, include_str!("../migrations/0018_message_reply.sql")),
(19, include_str!("../migrations/0019_reactions.sql")),
(20, include_str!("../migrations/0020_desktop_grants.sql")),
(21, include_str!("../migrations/0021_crew_access.sql")),
];

/// Schema version after every migration has run.
Expand Down
9 changes: 7 additions & 2 deletions crates/botloftd/src/approvals/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard};

use botloft_core::chat::{
CHANGE_ROUTINE_TOOL, ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, tool_summary,
CHANGE_ROUTINE_TOOL, CREW_ACCESS_TOOL, ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max,
tool_summary,
};
use botloft_core::command::tool_explanation;
use botloft_core::ids::{ApprovalId, BotId, ChatItemId};
Expand Down Expand Up @@ -146,7 +147,8 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult<Appro
.approval(&params.approval_id)?
.ok_or_else(|| ApiError::NotFound(format!("approval {}", params.approval_id)))?;
// Only a bot or routine suggestion can be changed before it is allowed
// (spec 10.2, 20.12).
// (spec 10.2, 20.12); a request to reach another crew says for how long
// (10.4).
let input = match params.input {
Some(input) if params.allow && existing.approval.tool_name == SUGGEST_TOOL => {
let value: Value = serde_json::from_str(&input)
Expand All @@ -159,6 +161,9 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult<Appro
Some(input) if params.allow && existing.approval.tool_name == CHANGE_ROUTINE_TOOL => Some(
crate::tools::check_changed_routine_change(daemon, &existing.approval.input, &input)?,
),
Some(input) if params.allow && existing.approval.tool_name == CREW_ACCESS_TOOL => Some(
crate::tools::check_crew_access_answer(&existing.approval.input, &input)?,
),
_ => None,
};
let Some(record) = settle(daemon, &existing, status, note.as_deref(), input.as_deref()) else {
Expand Down
1 change: 1 addition & 0 deletions crates/botloftd/src/chat/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
}
routines::turn_ended(daemon, bot, failed);
crate::screens::turn_ended(daemon, bot);
daemon.crew_access.end_turn(bot);
daemon.supervisor.turn_ended(bot, generation);
}

Expand Down
13 changes: 12 additions & 1 deletion crates/botloftd/src/courier/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,17 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result<Step,
None => None,
};
let sender = match (&message.from_kind, &message.from_bot_id) {
(SenderKind::Bot, Some(id)) => store.bot(id)?.map(|sender| sender.handle),
(SenderKind::Bot, Some(id)) => store.bot(id)?,
_ => None,
};
// A bot of another crew says which one, to be answered there.
let sender_crew = match &sender {
Some(sender) if sender.crew_id != bot.crew_id => {
store.crew(&sender.crew_id)?.map(|crew| crew.name)
}
_ => None,
};
let sender = sender.map(|sender| sender.handle);
let routine = context::routine(store, &message)?;
let question = context::question(store, &message)?;
let reactions = store
Expand All @@ -224,6 +232,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result<Step,
message,
crew_name: crew.name,
sender_handle: sender,
sender_crew,
task,
routine,
question,
Expand All @@ -236,6 +245,7 @@ struct Draft {
message: Message,
crew_name: String,
sender_handle: Option<String>,
sender_crew: Option<String>,
task: Option<Task>,
workspace: PathBuf,
routine: Option<context::RoutineContext>,
Expand All @@ -251,6 +261,7 @@ impl Draft {
let context = Context {
crew_name: &self.crew_name,
sender_handle: self.sender_handle.as_deref(),
sender_crew: self.sender_crew.as_deref(),
task: self.task.as_ref(),
workspace: &self.workspace,
routine: self.routine.as_ref(),
Expand Down
Loading
Loading