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
8 changes: 7 additions & 1 deletion crates/botloftd/src/tools/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use super::catalog::{COMPLETE_TASK, CREW_ROSTER, MY_TASKS, SEND_MESSAGE};
use super::question::{self, ASK_OWNER};
use super::share::{self, SHARE_FILE};
use super::signal::{self, SEND_SIGNAL};
use super::{crew_reach, routine_change};
use super::{crew_reach, routine_change, templates};
use crate::service::tasks::{self, BotMessage};
use crate::service::{ApiError, bots, lead};
use crate::state::Daemon;
Expand Down Expand Up @@ -48,6 +48,12 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result<Value
ASK_OWNER => parse(arguments).and_then(|args| question::ask(daemon, bot, args)),
SEND_SIGNAL => parse(arguments).and_then(|args| signal::send(daemon, bot, args)),
routine_change::MY_ROUTINES => routine_change::mine(daemon, bot),
templates::LIST_BOT_TEMPLATES => {
parse(arguments).and_then(|args| templates::list(daemon, bot, args))
}
templates::GET_BOT_TEMPLATE => {
parse(arguments).and_then(|args| templates::get(daemon, bot, args))
}
crew_reach::CREW_FILES => parse(arguments)
.and_then(|args| crew_reach::files(daemon, bot, args).map_err(explain)),
crew_reach::READ_CREW_FILE => parse(arguments)
Expand Down
19 changes: 18 additions & 1 deletion crates/botloftd/src/tools/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub fn tools() -> Value {
.into_iter()
.chain(super::routine_catalog::tools())
.chain([super::bot_change::tool(), super::crew_access::tool()])
.chain(super::templates::tools())
.chain(super::crew_reach::tools())
.chain(super::browser_catalog::tools())
.chain(super::desktop_catalog::tools());
Expand All @@ -40,6 +41,14 @@ pub fn tools() -> Value {
tools
}

/// The ids of the bot catalog, for `suggest_bot`'s `template`.
fn template_ids() -> Vec<String> {
crate::catalog::list(None)
.into_iter()
.map(|template| template.id)
.collect()
}

/// The schedule's schema, as `schedule_routine` takes it, for the tools
/// that change a routine.
pub(super) fn schedule_schema() -> Value {
Expand Down Expand Up @@ -203,8 +212,16 @@ fn crew_tools() -> Value {
"maxLength": REASON_MAX,
"description": "For the owner: why the crew needs this bot now.",
},
"template": {
"type": "string",
"enum": template_ids(),
"description": "A role of the bot catalog (list_bot_templates). Its \
role, instructions, model and effort are used for what you leave \
out, and your `instructions` are added after them under \"For this \
crew\". Without it, `role` and `instructions` are required.",
},
},
"required": ["name", "role", "instructions", "reason"],
"required": ["name", "reason"],
"additionalProperties": false,
},
},
Expand Down
3 changes: 2 additions & 1 deletion crates/botloftd/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ mod routine_change;
mod share;
mod signal;
mod suggest;
mod templates;

use std::sync::Arc;

Expand All @@ -57,7 +58,7 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000;

const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \
or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \
suggest new bots. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \
suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles). schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \
owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \
live and take over when you ask with browser_ask_owner. The desktop_ tools read the apps open on \
the owner's own computer, app by app as the owner allows. The owner writes to you directly; messages from other bots and from Botloft start with \
Expand Down
41 changes: 40 additions & 1 deletion crates/botloftd/src/tools/suggest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use serde_json::{Value, json};

use super::calls::{explain, parse, tool_result};
use crate::approvals::{self, Answer};
use crate::catalog;
use crate::service::bots::{self, NewBot};
use crate::service::{lead, models};
use crate::state::Daemon;
Expand All @@ -25,13 +26,18 @@ pub(super) const REASON_MAX: usize = 1_000;
#[serde(deny_unknown_fields)]
struct Suggestion {
name: String,
#[serde(default)]
role: String,
#[serde(default)]
instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
effort: Option<String>,
reason: String,
/// A role of the bot catalog (spec 26.4); gone once it is resolved.
#[serde(default, skip_serializing_if = "Option::is_none")]
template: Option<String>,
}

pub(super) async fn suggest(
Expand All @@ -49,7 +55,8 @@ async fn run(
generation: u64,
arguments: Value,
) -> Result<Value, String> {
let suggestion: Suggestion = parse(arguments)?;
let mut suggestion: Suggestion = parse(arguments)?;
resolve(&mut suggestion)?;
let new = check(&suggestion)?;
// Nothing impossible goes to the owner.
lead::check_suggestion(daemon, bot, &new).map_err(explain)?;
Expand Down Expand Up @@ -101,6 +108,38 @@ async fn run(
}
}

/// Fills a suggestion that names a catalog role (spec 26.4): the sheet's role,
/// instructions, model and effort stand in for what is missing, and what the
/// chief wrote goes after the sheet's instructions. Afterwards it is a plain
/// suggestion, so the owner's card, and any change the owner makes to it,
/// read the same as for a bot written from scratch.
fn resolve(suggestion: &mut Suggestion) -> Result<(), String> {
let Some(id) = suggestion.template.take() else {
if suggestion.role.trim().is_empty() || suggestion.instructions.trim().is_empty() {
return Err("role and instructions are required unless you give a template".to_owned());
}
return Ok(());
};
let sheet = catalog::get(id.trim())
.ok_or_else(|| format!("no bot template named {id}; list them with list_bot_templates"))?;
if suggestion.role.trim().is_empty() {
suggestion.role = sheet.role;
}
let extra = suggestion.instructions.trim();
suggestion.instructions = if extra.is_empty() {
sheet.instructions
} else {
format!("{}\n\nFor this crew:\n{extra}", sheet.instructions)
};
suggestion
.model
.get_or_insert_with(|| sheet.model.as_str().to_owned());
suggestion
.effort
.get_or_insert_with(|| sheet.effort.as_str().to_owned());
Ok(())
}

/// Creates the bot, then sets the effort it was suggested with.
fn create(
daemon: &Daemon,
Expand Down
98 changes: 98 additions & 0 deletions crates/botloftd/src/tools/templates.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! `list_bot_templates` and `get_bot_template` (spec 26.4): the chief reads
//! the bot catalog before it suggests a bot.

use botloft_core::ids::BotId;
use botloft_core::protocol::BotTemplateCategory;
use serde::Deserialize;
use serde_json::{Value, json};

use super::calls::explain;
use crate::catalog;
use crate::service::{bots, lead};
use crate::state::Daemon;

pub const LIST_BOT_TEMPLATES: &str = "list_bot_templates";
pub const GET_BOT_TEMPLATE: &str = "get_bot_template";

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ListArgs {
#[serde(default)]
category: Option<BotTemplateCategory>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct GetArgs {
id: String,
}

pub(super) fn tools() -> [Value; 2] {
let categories = ["code", "design", "content", "research", "business"];
[
json!({
"name": LIST_BOT_TEMPLATES,
"title": "List bot templates",
"description": "Only for the crew's chief. Lists the catalog of ready-made bot roles \
(id, category, name, role and a summary of each). Look here before you write \
instructions for a new bot: if a role fits, suggest it with suggest_bot and its \
`template`.",
"inputSchema": {
"type": "object",
"properties": {
"category": { "type": "string", "enum": categories },
},
"additionalProperties": false,
},
"annotations": { "readOnlyHint": true },
}),
json!({
"name": GET_BOT_TEMPLATE,
"title": "Read a bot template",
"description": "Only for the crew's chief. Reads one role of the catalog with the \
instructions a bot made from it would start with, and its default model and \
effort.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "An id from list_bot_templates." },
},
"required": ["id"],
"additionalProperties": false,
},
"annotations": { "readOnlyHint": true },
}),
]
}

pub(super) fn list(daemon: &Daemon, bot: &BotId, args: ListArgs) -> Result<Value, String> {
chief_only(daemon, bot)?;
Ok(json!({
"templates": catalog::list(args.category),
"note": "Read one with get_bot_template. To use it, call suggest_bot with its id in \
`template`; add `instructions` only for what is specific to this crew.",
}))
}

pub(super) fn get(daemon: &Daemon, bot: &BotId, args: GetArgs) -> Result<Value, String> {
chief_only(daemon, bot)?;
let template = catalog::get(args.id.trim()).ok_or_else(|| {
format!(
"no bot template named {}; list them with list_bot_templates",
args.id
)
})?;
serde_json::to_value(template).map_err(|err| err.to_string())
}

fn chief_only(daemon: &Daemon, bot: &BotId) -> Result<(), String> {
let (crew, _) = bots::active(&daemon.store(), bot).map_err(explain)?;
if lead::is_lead(&crew, bot) {
Ok(())
} else {
Err(
"only the crew's chief can read the bot catalog; ask the chief with send_message"
.to_owned(),
)
}
}
13 changes: 9 additions & 4 deletions crates/botloftd/src/workspace/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,15 @@ The owner made you the chief of this crew: its goal is in your instructions.
- Plan the work, split it into pieces that can run at the same time, and hand
them out with `send_message` and `kind: \"task\"`. Each bot works in
parallel; results come back as messages. Check them before you report.
- When a piece needs a specialist the crew lacks, call `suggest_bot`: a name, a
one-line role, instructions written to that bot, a model and why it helps.
The owner approves each new bot in your chat and may change it (unless you
bypass permissions). Then give it work with a task.
- When a piece needs a specialist the crew lacks, look at the bot catalog first:
`list_bot_templates` shows ready-made roles and `get_bot_template` reads one.
If a role fits, call `suggest_bot` with its id in `template`, a name and why
the crew needs it; add `instructions` only for what is specific to this
crew, and do not invent a role the catalog already has. If none fits, call
`suggest_bot` with a name, a one-line role, instructions written to that
bot, a model and why it helps. The owner approves each new bot in your chat
and may change it (unless you bypass permissions). Then give it work with a
task.
- Pick the model by the work: `haiku` for simple, repetitive tasks, `sonnet`
for most work, `opus` or `fable` only for the hardest reasoning. Leave it out
to use the owner's plan default. Stronger models use up the owner's plan
Expand Down
Loading
Loading