diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index 145ae85f..699a0a72 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -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; @@ -48,6 +48,12 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result 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) diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 91a8b325..5f06b1e4 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -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()); @@ -40,6 +41,14 @@ pub fn tools() -> Value { tools } +/// The ids of the bot catalog, for `suggest_bot`'s `template`. +fn template_ids() -> Vec { + 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 { @@ -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, }, }, diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index 666704a1..2c822047 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -32,6 +32,7 @@ mod routine_change; mod share; mod signal; mod suggest; +mod templates; use std::sync::Arc; @@ -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 \ diff --git a/crates/botloftd/src/tools/suggest.rs b/crates/botloftd/src/tools/suggest.rs index 2c01e907..588c356e 100644 --- a/crates/botloftd/src/tools/suggest.rs +++ b/crates/botloftd/src/tools/suggest.rs @@ -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; @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] effort: Option, 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, } pub(super) async fn suggest( @@ -49,7 +55,8 @@ async fn run( generation: u64, arguments: Value, ) -> Result { - 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)?; @@ -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, diff --git a/crates/botloftd/src/tools/templates.rs b/crates/botloftd/src/tools/templates.rs new file mode 100644 index 00000000..86d513a9 --- /dev/null +++ b/crates/botloftd/src/tools/templates.rs @@ -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, +} + +#[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 { + 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 { + 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(), + ) + } +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index da07c4cf..ce3a01b3 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -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 diff --git a/crates/botloftd/tests/chief_catalog.rs b/crates/botloftd/tests/chief_catalog.rs new file mode 100644 index 00000000..bef1642f --- /dev/null +++ b/crates/botloftd/tests/chief_catalog.rs @@ -0,0 +1,229 @@ +//! The chief and the bot catalog (spec 26.4): reading the roles, and +//! suggesting a bot from one. + +mod common; + +use botloft_core::protocol::BotState; +use common::bots::ready_bot; +use common::mcp::Mcp; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; + +struct Chief { + t: TestDaemon, + app: Client, + crew: Value, + mcp: Mcp, +} + +async fn crew_with_chief() -> Chief { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call( + "crews.create", + json!({ + "name": "Site", + "lead": { "name": "Chefe", "role": "Leads the crew", "instructions": "Build the site" }, + }), + ) + .await + .expect("crew"); + let bots = app + .call("bots.list", json!({ "crewId": crew["id"] })) + .await + .expect("bots"); + let chief = bots[0].clone(); + let process = t.process_of(&chief).await; + t.until_state(&chief, BotState::Idle).await; + let mcp = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + Chief { t, app, crew, mcp } +} + +fn suggest(mcp: &Mcp, arguments: Value) -> JoinHandle> { + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + tokio::spawn(async move { mcp.tool("suggest_bot", arguments).await }) +} + +async fn pending_suggestion(app: &mut Client) -> Value { + loop { + let changed = app.notification("chat.item").await; + let body = &changed["item"]["body"]; + if body["kind"] == "approval" && body["status"] == "pending" { + return body.clone(); + } + } +} + +async fn approve(app: &mut Client, asked: &Value) { + app.call( + "approvals.answer", + json!({ "approvalId": asked["approvalId"], "allow": true }), + ) + .await + .expect("answer"); +} + +async fn sheet(app: &mut Client, id: &str) -> Value { + app.call("catalog.get", json!({ "id": id })) + .await + .expect("sheet") +} + +#[tokio::test] +async fn the_chief_reads_the_catalog() { + let mut c = crew_with_chief().await; + + let all = c + .mcp + .tool("list_bot_templates", json!({})) + .await + .expect("list"); + let all = all["templates"].as_array().expect("templates"); + assert_eq!(all.len(), 12); + assert!(all[0].get("instructions").is_none(), "the list is light"); + + let research = c + .mcp + .tool("list_bot_templates", json!({ "category": "research" })) + .await + .expect("list"); + assert_eq!( + research["templates"].as_array().expect("templates").len(), + 2 + ); + + let designer = c + .mcp + .tool("get_bot_template", json!({ "id": "designer" })) + .await + .expect("get"); + assert_eq!(designer, sheet(&mut c.app, "designer").await); + + let unknown = c + .mcp + .tool("get_bot_template", json!({ "id": "wizard" })) + .await; + assert!(unknown.expect_err("unknown").contains("list_bot_templates")); +} + +#[tokio::test] +async fn only_the_chief_reads_the_catalog() { + let mut c = crew_with_chief().await; + let (_, _, mut writer) = ready_bot(&c.t, &mut c.app, &c.crew, "Writer").await; + for (tool, args) in [ + ("list_bot_templates", json!({})), + ("get_bot_template", json!({ "id": "designer" })), + ] { + let refused = writer.tool(tool, args).await; + assert!( + refused + .expect_err("refused") + .contains("only the crew's chief") + ); + } +} + +#[tokio::test] +async fn a_suggestion_from_a_role_carries_its_instructions_to_the_owner() { + let mut c = crew_with_chief().await; + let designer = sheet(&mut c.app, "designer").await; + let call = suggest( + &c.mcp, + json!({ "name": "Designer", "template": "designer", "reason": "The site needs a look." }), + ); + + let asked = pending_suggestion(&mut c.app).await; + let shown: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(shown["instructions"], designer["instructions"]); + assert_eq!(shown["role"], designer["role"]); + assert_eq!(shown["effort"], designer["effort"]); + assert!( + shown.get("template").is_none(), + "the card is a plain suggestion" + ); + + approve(&mut c.app, &asked).await; + let created = call.await.expect("task").expect("created"); + assert_eq!(created["created"], true); + assert_eq!(created["handle"], "designer"); + + let bots = c + .app + .call("bots.list", json!({ "crewId": c.crew["id"] })) + .await + .expect("bots"); + let new = bots + .as_array() + .expect("bots") + .iter() + .find(|bot| bot["handle"] == "designer") + .expect("designer"); + assert_eq!(new["instructions"], designer["instructions"]); + assert_eq!(new["permissionMode"], "default"); +} + +#[tokio::test] +async fn what_the_chief_writes_goes_after_the_roles_instructions() { + let mut c = crew_with_chief().await; + let reviewer = sheet(&mut c.app, "code-reviewer").await; + let call = suggest( + &c.mcp, + json!({ + "name": "Reviewer", + "template": "code-reviewer", + "instructions": "Review the bakery site's pull requests.", + "model": "sonnet", + "reason": "Someone must check the code.", + }), + ); + + let asked = pending_suggestion(&mut c.app).await; + let shown: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + let text = shown["instructions"].as_str().expect("instructions"); + assert!(text.starts_with(reviewer["instructions"].as_str().expect("sheet"))); + assert!(text.ends_with("\n\nFor this crew:\nReview the bakery site's pull requests.")); + assert_eq!(shown["model"], "sonnet", "what the chief chose wins"); + assert_eq!(shown["effort"], "high", "the role's effort fills the gap"); + + approve(&mut c.app, &asked).await; + let created = call.await.expect("task").expect("created"); + assert_eq!(created["model"], "sonnet"); + assert_eq!(created["effort"], "high"); +} + +#[tokio::test] +async fn a_suggestion_the_owner_could_not_use_never_reaches_them() { + let c = crew_with_chief().await; + let reason = "Because."; + + let unknown = suggest( + &c.mcp, + json!({ "name": "Wizard", "template": "wizard", "reason": reason }), + ) + .await + .expect("task"); + assert!( + unknown + .expect_err("unknown") + .contains("no bot template named wizard") + ); + + let bare = suggest(&c.mcp, json!({ "name": "Wizard", "reason": reason })) + .await + .expect("task"); + assert!( + bare.expect_err("bare") + .contains("unless you give a template") + ); + + let long = "x".repeat(6_500); + let too_long = suggest( + &c.mcp, + json!({ "name": "Designer", "template": "designer", "instructions": long, "reason": reason }), + ) + .await + .expect("task"); + assert!(too_long.expect_err("too long").contains("at most 8000")); +} diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 66efe818..32422718 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -81,6 +81,8 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "delete_routine", "change_bot", "ask_crew_access", + "list_bot_templates", + "get_bot_template", "crew_files", "read_crew_file", "write_crew_file", @@ -148,7 +150,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(38) + Some(40) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/docs/spec.md b/docs/spec.md index 42e7673f..3c883964 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1992,7 +1992,7 @@ instructions = """ - **Tipos** em `botloft-core`, exportados por `ts-rs`: `BotTemplate` (`id`, `category`, `name`, `role`, `summary`) e `BotTemplateFull` (os mesmos mais `model`, `effort` e `instructions`). - **Métodos** (11.2): `catalog.list {category?}`, `catalog.get {id}` (`-32002` se não existe) e `catalog.add {crewId, templateId, name, role}`. O app passa `name` e `role` já no idioma do dono, como faz com o chefe (10.2); o daemon põe as instruções, o modelo e o esforço da ficha. `catalog.add` valida como `bots.create` (nome livre na crew, papel numa linha, crew ativa; o limite `max_per_crew` é do chefe, 10.2, e não vale para o dono) e cria o bot em modo Manual, sem ferramentas conectadas e sem acesso a outras crews. Nada na ficha dá permissão a mais. - **Tools do chefe** (10): `list_bot_templates` e `get_bot_template`. Aparecem para todos os bots, mas só o chefe pode usar, como `suggest_bot`. O resultado é JSON em texto, e um `id` desconhecido volta com `isError`. -- **`suggest_bot` com `template`.** Com `template`, `role` e `instructions` ficam opcionais: o papel vem da ficha se faltar, e as instruções são as da ficha, seguidas, se o chefe mandou `instructions`, de uma linha em branco e "For this crew:" com o que ele escreveu. `model` e `effort` vêm da ficha se faltarem. O limite de 8 000 vale para o texto final. O cartão de aprovação (10.2) mostra o que será criado, e o dono pode editar tudo antes, como hoje. +- **`suggest_bot` com `template`.** Com `template`, `role` e `instructions` ficam opcionais: o papel vem da ficha se faltar, e as instruções são as da ficha, seguidas, se o chefe mandou `instructions`, de uma linha em branco e "For this crew:" com o que ele escreveu. `model` e `effort` vêm da ficha se faltarem. O limite de 8 000 vale para o texto final. O daemon resolve o `template` antes de perguntar ao dono: o cartão de aprovação (10.2) e o `input` de `approvals.answer` veem uma sugestão comum, sem `template`, com as instruções já juntas, e o dono pode editar tudo antes, como hoje. Um `template` desconhecido volta ao chefe como erro, sem incomodar o dono; sem `template`, `role` e `instructions` continuam obrigatórios. - **Regras do chefe** (5.1): olhe o catálogo antes de escrever instruções do zero; use `template` quando a função serve; acrescente em `instructions` só o que é daquela equipe; não invente papel que o catálogo já tem. ### 26.5 App