From 43b3e1fc8b9bbb697026c421294a94d6097df4b3 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:38:39 -0300 Subject: [PATCH] feat(tools): the chief can lower or raise a bot's model and effort with the owner's OK change_bot takes model and effort, and suggest_bot takes effort. The roster shows each bot's model and effort, and the chief's rules ask it to watch the cost and propose a cheaper model or effort for simple work. The owner approves on the card, which shows them before and after; the bot restarts on them once nothing is in progress. Spec 10, 10.2, 10.3. --- app/src/features/chat/BotChangeCard.tsx | 14 +++- app/src/features/chat/SuggestionCard.tsx | 31 ++++++-- app/src/features/chat/botChange.test.tsx | 20 ++++- app/src/i18n/en/chat.ts | 3 +- app/src/i18n/es/chat.ts | 3 +- app/src/i18n/pt-BR/chat.ts | 3 +- crates/botloftd/src/tools/bot_change.rs | 95 ++++++++++++++++++++---- crates/botloftd/src/tools/calls.rs | 2 + crates/botloftd/src/tools/catalog.rs | 7 ++ crates/botloftd/src/tools/suggest.rs | 50 +++++++++++-- crates/botloftd/src/workspace/files.rs | 5 ++ crates/botloftd/tests/bot_changes.rs | 40 ++++++++++ crates/botloftd/tests/crew_chief.rs | 28 +++++++ crates/botloftd/tests/tasks.rs | 3 +- docs/spec.md | 14 ++-- 15 files changed, 274 insertions(+), 44 deletions(-) diff --git a/app/src/features/chat/BotChangeCard.tsx b/app/src/features/chat/BotChangeCard.tsx index 983ca466..02fc4d94 100644 --- a/app/src/features/chat/BotChangeCard.tsx +++ b/app/src/features/chat/BotChangeCard.tsx @@ -6,7 +6,7 @@ import { Ban, Check, TimerOff, UserPen } from "lucide-react"; import { useState } from "react"; import { useT } from "../../i18n"; import { errorText } from "../../lib/api"; -import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; +import type { ApprovalItem, Bot, BotEffort, BotModel } from "../../lib/protocol.gen"; import { useApi } from "../../store/context"; import { Button } from "../../ui/Button"; import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; @@ -17,6 +17,9 @@ interface Profile { name: string; role: string; instructions: string; + /** Absent in a request from before models and efforts could change. */ + model?: BotModel; + effort?: BotEffort; } interface Asked { @@ -78,6 +81,15 @@ export function BotChangeCard({ approval, bot }: { approval: ApprovalItem; bot: const rows: [string, string, string][] = []; if (before.name !== after.name) rows.push([d.name, before.name, after.name]); if (before.role !== after.role) rows.push([d.role, before.role, after.role]); + const model = (name: BotModel) => t.chat.model.names[name]; + const effort = (level: BotEffort) => + level === "default" ? t.chat.effort.recommended : t.chat.effort.names[level]; + if (before.model && after.model && before.model !== after.model) { + rows.push([t.chat.model.title, model(before.model), model(after.model)]); + } + if (before.effort && after.effort && before.effort !== after.effort) { + rows.push([t.chat.effort.title, effort(before.effort), effort(after.effort)]); + } const self = asked.bot_id === bot.id; return ( ; } const changed = JSON.stringify(edited) !== JSON.stringify(asked); + const effortName = (level: BotEffort) => + level === "default" ? t.chat.effort.recommended : t.chat.effort.names[level]; const set = (field: keyof Suggestion) => (value: string) => setEdited((current) => ({ ...current, [field]: value })); @@ -91,18 +96,24 @@ export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot: {asked.reason}

)} -
- set("name")(event.target.value)} - /> + set("name")(event.target.value)} + /> +
set("model")(event.target.value)} options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))} /> + set("effort")(event.target.value)} + options={EFFORTS.map((value) => ({ value, label: effortName(value) }))} + />
{suggestion.role}
{s.model}
{t.chat.model.names[suggestion.model ?? "default"]}
+ {suggestion.effort && suggestion.effort !== "default" && ( + <> +
{t.chat.effort.title}
+
{t.chat.effort.names[suggestion.effort]}
+ + )}
{s.instructions}
{suggestion.instructions}
diff --git a/app/src/features/chat/botChange.test.tsx b/app/src/features/chat/botChange.test.tsx index a43c6f6d..fbc99884 100644 --- a/app/src/features/chat/botChange.test.tsx +++ b/app/src/features/chat/botChange.test.tsx @@ -6,7 +6,13 @@ import { CHANGE_BOT_TOOL } from "./BotChangeCard"; afterEach(cleanup); -const WRITER = { name: "Writer", role: "Writes posts", instructions: "Short sentences." }; +const WRITER = { + name: "Writer", + role: "Writes posts", + instructions: "Short sentences.", + model: "sonnet", + effort: "default", +}; async function openChief() { const fake = new FakeBotloft(); @@ -37,13 +43,23 @@ describe("a bot asking to change a bot", () => { bot_id: "bot_writer", name: "Writer", before: WRITER, - after: { ...WRITER, name: "Editor", instructions: "Edit, do not write." }, + after: { + ...WRITER, + name: "Editor", + instructions: "Edit, do not write.", + model: "haiku", + effort: "low", + }, reason: "The owner asked for it.", }), ); }); const card = screen.getByRole("region", { name: "Chief wants to change Writer" }); expect(within(card).getByText("Editor")).toBeDefined(); + expect(within(card).getByText("Sonnet")).toBeDefined(); + expect(within(card).getByText("Haiku")).toBeDefined(); + expect(within(card).getByText("Recommended")).toBeDefined(); + expect(within(card).getByText("Low")).toBeDefined(); expect(within(card).queryByText("Writes posts")).toBeNull(); expect(within(card).getByText("See the new instructions")).toBeDefined(); expect(within(card).getByText("The owner asked for it.")).toBeDefined(); diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 72ded18d..546791bf 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -349,7 +349,8 @@ export const chat = { titleOther: (bot: string, other: string) => `${bot} wants to change ${other}`, reason: (bot: string) => `Why, says ${bot}:`, newInstructions: "See the new instructions", - explain: "The new role and instructions count from the bot's next start.", + explain: + "The new role, instructions, model and effort count from the bot's next start, once it finishes what it's doing.", apply: "Change", decline: "Not now", noteLabel: (bot: string) => `What to tell ${bot} if you say no`, diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index 1565c5a9..5f2f6720 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -335,7 +335,8 @@ export const chat: Messages["chat"] = { titleOther: (bot, other) => `${bot} quiere cambiar a ${other}`, reason: (bot) => `Por qué, según ${bot}:`, newInstructions: "Ver las instrucciones nuevas", - explain: "El rol y las instrucciones nuevas cuentan desde el próximo inicio del bot.", + explain: + "El rol, las instrucciones, el modelo y el esfuerzo nuevos cuentan desde el próximo inicio del bot, cuando termine lo que está haciendo.", apply: "Cambiar", decline: "Ahora no", noteLabel: (bot) => `Qué decirle a ${bot} si dices que no`, diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index e3d947ef..8854f686 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -332,7 +332,8 @@ export const chat: Messages["chat"] = { titleOther: (bot, other) => `${bot} quer mudar ${other}`, reason: (bot) => `Por quê, segundo ${bot}:`, newInstructions: "Ver as instruções novas", - explain: "A função e as instruções novas valem a partir do próximo início do bot.", + explain: + "A função, as instruções, o modelo e o esforço novos valem a partir do próximo início do bot, quando ele terminar o que está fazendo.", apply: "Mudar", decline: "Agora não", noteLabel: (bot) => `O que dizer a ${bot} se você recusar`, diff --git a/crates/botloftd/src/tools/bot_change.rs b/crates/botloftd/src/tools/bot_change.rs index f47e5186..effa78d6 100644 --- a/crates/botloftd/src/tools/bot_change.rs +++ b/crates/botloftd/src/tools/bot_change.rs @@ -1,12 +1,15 @@ //! `change_bot` (spec 10.3): a bot asks the owner to rename itself or change -//! its role or instructions, and the crew's chief may ask the same for any -//! bot of its crew. Only bots of the caller's crew can be named. The owner +//! its role, instructions, model or effort, and the crew's chief may ask the +//! same for any bot of its crew. Only bots of the caller's crew can be named. The owner //! approves or declines in the caller's chat and the call waits, like //! `change_routine`; a bot that bypasses permissions does it at once. use botloft_core::chat::CHANGE_BOT_TOOL; use botloft_core::ids::BotId; -use botloft_core::protocol::{Bot, BotsUpdateParams, PermissionMode}; +use botloft_core::protocol::{ + Bot, BotEffort, BotModel, BotsSetEffortParams, BotsSetModelParams, BotsUpdateParams, + PermissionMode, +}; use botloft_core::{slug, validate}; use botloft_store::BotRecord; use serde::{Deserialize, Serialize}; @@ -15,7 +18,7 @@ use serde_json::{Value, json}; use super::calls::{explain, parse, tool_result}; use super::suggest::{INSTRUCTIONS_MAX, REASON_MAX}; use crate::approvals::{self, Answer}; -use crate::service::{ApiError, ApiResult, bots, lead}; +use crate::service::{ApiError, ApiResult, bots, lead, models}; use crate::state::Daemon; pub const CHANGE_BOT: &str = "change_bot"; @@ -27,6 +30,8 @@ struct Args { name: Option, role: Option, instructions: Option, + model: Option, + effort: Option, reason: Option, } @@ -36,6 +41,8 @@ struct Profile { name: String, role: String, instructions: String, + model: BotModel, + effort: BotEffort, } impl Profile { @@ -44,6 +51,8 @@ impl Profile { name: record.name.clone(), role: record.role.clone(), instructions: record.instructions.clone(), + model: record.model, + effort: record.effort, } } } @@ -52,10 +61,12 @@ pub(super) fn tool() -> Value { json!({ "name": CHANGE_BOT, "title": "Change a bot", - "description": "Asks the owner to rename you or change your role or instructions, \ - when the owner wants that. The crew's chief may name another bot of its crew with \ - `bot`. Give only what changes. The owner approves or declines it in your chat; the \ - call waits for that. Say it changed only after the tool says it did.", + "description": "Asks the owner to rename you or change your role, instructions, model \ + or effort, when the owner wants that. The crew's chief may name another bot of its \ + crew with `bot`, and may propose a cheaper or stronger model or effort for a bot by \ + the work it does (crew_roster shows each bot's). Give only what changes. The owner \ + approves or declines it in your chat; the call waits for that. Say it changed only \ + after the tool says it did.", "inputSchema": { "type": "object", "properties": { @@ -80,6 +91,20 @@ pub(super) fn tool() -> Value { "description": "The whole new instructions, written to the bot. They \ replace the old ones.", }, + "model": { + "type": "string", + "enum": ["default", "fable", "opus", "sonnet", "haiku"], + "description": "\"haiku\" for simple, repetitive work; \"sonnet\" for most \ + work; \"opus\" or \"fable\" for the hardest reasoning; \"default\" for \ + the owner's plan default.", + }, + "effort": { + "type": "string", + "enum": ["default", "low", "medium", "high", "xhigh", "max"], + "description": "How much the bot thinks before it answers: \"low\" for \ + quick, simple work, higher for hard problems; \"default\" for its \ + model's own level. Higher uses more of the owner's plan.", + }, "reason": { "type": "string", "maxLength": REASON_MAX, @@ -144,11 +169,27 @@ fn after(daemon: &Daemon, record: &BotRecord, args: &Args) -> ApiResult } next.instructions = validate::instructions(instructions)?; } + if let Some(model) = &args.model { + next.model = model.parse().map_err(|_| { + ApiError::validation( + "model must be \"default\", \"fable\", \"opus\", \"sonnet\" or \"haiku\"", + ) + })?; + } + if let Some(effort) = &args.effort { + next.effort = effort.parse().map_err(|_| { + ApiError::validation( + "effort must be \"default\", \"low\", \"medium\", \"high\", \"xhigh\" or \"max\"", + ) + })?; + } Ok(next) } -fn apply(daemon: &Daemon, id: &BotId, next: &Profile) -> ApiResult { - bots::update( +/// Makes the change. A new model or effort restarts the bot once nothing is +/// in progress (spec 7.4). +fn apply(daemon: &Daemon, id: &BotId, before: &Profile, next: &Profile) -> ApiResult { + let mut bot = bots::update( daemon, BotsUpdateParams { bot_id: id.clone(), @@ -157,7 +198,26 @@ fn apply(daemon: &Daemon, id: &BotId, next: &Profile) -> ApiResult { instructions: Some(next.instructions.clone()), color: None, }, - ) + )?; + if next.model != before.model { + bot = models::set_model( + daemon, + BotsSetModelParams { + bot_id: id.clone(), + model: next.model, + }, + )?; + } + if next.effort != before.effort { + bot = models::set_effort( + daemon, + BotsSetEffortParams { + bot_id: id.clone(), + effort: next.effort, + }, + )?; + } + Ok(bot) } async fn run( @@ -185,7 +245,7 @@ async fn run( .map_err(explain)? .permission_mode; if mode == PermissionMode::BypassPermissions { - let bot = apply(daemon, &record.id, &next).map_err(explain)?; + let bot = apply(daemon, &record.id, &before, &next).map_err(explain)?; return Ok(done( &bot, caller, @@ -204,7 +264,7 @@ async fn run( Some(Answer::Allowed { .. }) => { // Checked again: the crew may have changed while the owner read it. let next = after(daemon, &record, &args).map_err(explain)?; - let bot = apply(daemon, &record.id, &next).map_err(explain)?; + let bot = apply(daemon, &record.id, &before, &next).map_err(explain)?; Ok(done(&bot, caller, "The owner approved the change.")) } Some(Answer::Denied { note }) => { @@ -225,16 +285,19 @@ async fn run( fn done(bot: &Bot, caller: &BotId, how: &str) -> Value { let when = if bot.id == *caller { - "Your new instructions and role take effect when you next start; your name and \ - handle already changed." + "Your new instructions, role, model and effort take effect when you next start, once \ + this turn ends; your name and handle already changed." } else { - "The bot reads its new instructions and role when it next starts." + "The bot runs with its new instructions, role, model and effort when it next starts, \ + once nothing is in progress." }; json!({ "done": true, "handle": bot.handle, "name": bot.name, "role": bot.role, + "model": bot.model, + "effort": bot.effort, "note": format!("{how} The bot is now @{} ({}). {when}", bot.handle, bot.name), }) } diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index 0799ec9e..3791653a 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -116,6 +116,8 @@ fn roster(daemon: &Daemon, bot: &BotId) -> Outcome { "name": other.name, "role": other.role, "state": other.state, + "model": other.model, + "effort": other.effort, "chief": lead::is_lead(&crew, &other.id), }) }) diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 923b353e..893ee37a 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -189,6 +189,13 @@ fn crew_tools() -> Value { work; \"opus\" or \"fable\" for the hardest reasoning. The owner's \ plan default when absent.", }, + "effort": { + "type": "string", + "enum": ["default", "low", "medium", "high", "xhigh", "max"], + "description": "How much the bot thinks before it answers: \"low\" for \ + quick, simple work, higher for hard problems. Its model's own level \ + when absent.", + }, "reason": { "type": "string", "maxLength": REASON_MAX, diff --git a/crates/botloftd/src/tools/suggest.rs b/crates/botloftd/src/tools/suggest.rs index 81887a37..2c01e907 100644 --- a/crates/botloftd/src/tools/suggest.rs +++ b/crates/botloftd/src/tools/suggest.rs @@ -5,14 +5,14 @@ use botloft_core::chat::SUGGEST_TOOL; use botloft_core::ids::BotId; -use botloft_core::protocol::{Bot, BotModel, PermissionMode}; +use botloft_core::protocol::{Bot, BotEffort, BotModel, BotsSetEffortParams, PermissionMode}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::calls::{explain, parse, tool_result}; use crate::approvals::{self, Answer}; use crate::service::bots::{self, NewBot}; -use crate::service::lead; +use crate::service::{lead, models}; use crate::state::Daemon; /// Longest instructions a chief may write for a new bot, in characters. @@ -29,6 +29,8 @@ struct Suggestion { instructions: String, #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + effort: Option, reason: String, } @@ -55,7 +57,7 @@ async fn run( .map_err(explain)? .permission_mode; if mode == PermissionMode::BypassPermissions { - let created = lead::create_suggested(daemon, bot, new).map_err(explain)?; + let created = create(daemon, bot, new, &suggestion)?; return Ok(created_note( &created, "You bypass permissions, so it was created without asking the owner.", @@ -65,7 +67,7 @@ async fn run( let input = serde_json::to_value(&suggestion).map_err(|err| err.to_string())?; match approvals::ask(daemon, bot, generation, SUGGEST_TOOL, &input, "").await { Some(Answer::Allowed { input: None }) => { - let created = lead::create_suggested(daemon, bot, new).map_err(explain)?; + let created = create(daemon, bot, new, &suggestion)?; Ok(created_note(&created, "The owner approved it.")) } Some(Answer::Allowed { @@ -73,11 +75,11 @@ async fn run( }) => { let changed: Suggestion = serde_json::from_str(&changed) .map_err(|err| format!("the owner's changes could not be read: {err}"))?; - let created = lead::create_suggested(daemon, bot, check(&changed)?).map_err(explain)?; + let created = create(daemon, bot, check(&changed)?, &changed)?; Ok(created_note( &created, - "The owner changed the suggestion before approving it: read the name, role \ - and model above.", + "The owner changed the suggestion before approving it: read the name, role, \ + model and effort above.", )) } Some(Answer::Denied { note }) => { @@ -99,9 +101,42 @@ async fn run( } } +/// Creates the bot, then sets the effort it was suggested with. +fn create( + daemon: &Daemon, + lead_bot: &BotId, + new: NewBot, + suggestion: &Suggestion, +) -> Result { + let effort = effort_of(suggestion)?; + let created = lead::create_suggested(daemon, lead_bot, new).map_err(explain)?; + if effort == BotEffort::Default { + return Ok(created); + } + models::set_effort( + daemon, + BotsSetEffortParams { + bot_id: created.id, + effort, + }, + ) + .map_err(explain) +} + +fn effort_of(suggestion: &Suggestion) -> Result { + match suggestion.effort.as_deref() { + None => Ok(BotEffort::Default), + Some(effort) => effort.parse().map_err(|_| { + "effort must be \"default\", \"low\", \"medium\", \"high\", \"xhigh\" or \"max\"" + .to_owned() + }), + } +} + /// The suggestion as a new bot, with the limits of this tool on top of a /// bot's own. fn check(suggestion: &Suggestion) -> Result { + effort_of(suggestion)?; if suggestion.instructions.chars().count() > INSTRUCTIONS_MAX { return Err(format!( "instructions must be at most {INSTRUCTIONS_MAX} characters" @@ -136,6 +171,7 @@ fn created_note(bot: &Bot, how: &str) -> Value { "name": bot.name, "role": bot.role, "model": bot.model, + "effort": bot.effort, "note": format!( "{how} @{} is starting now. Give it work with send_message(to: \"{}\", kind: \"task\").", bot.handle, bot.handle diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 56e7622b..3f21962e 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -83,6 +83,10 @@ The owner made you the chief of this crew: its goal is in your instructions. exists (`crew_roster`). - When the owner asks you to rename or change another bot of the crew, call `change_bot` with its handle in `bot`; the owner approves that too. +- Keep an eye on the cost: `crew_roster` shows each bot's model and effort. + When a bot's work turns out simpler than its model or effort (or harder), + propose a change with `change_bot` (`model`, `effort`) and say why; the + owner approves it. Simple, repetitive work: `haiku` and `low`. - Tell the owner what each bot is doing, so they can follow and talk to each one in its own chat. "; @@ -330,5 +334,6 @@ mod tests { assert!(rules.contains("## You lead this crew")); assert!(rules.contains("`suggest_bot`")); assert!(rules.contains("handle in `bot`")); + assert!(rules.contains("propose a change with `change_bot` (`model`, `effort`)")); } } diff --git a/crates/botloftd/tests/bot_changes.rs b/crates/botloftd/tests/bot_changes.rs index 57bb4e1a..80a15b76 100644 --- a/crates/botloftd/tests/bot_changes.rs +++ b/crates/botloftd/tests/bot_changes.rs @@ -190,3 +190,43 @@ async fn only_the_chief_names_another_bot_and_only_of_its_crew() { .expect("task"); assert!(refused.expect_err("same").contains("already")); } + +#[tokio::test] +async fn the_chief_lowers_a_bots_model_and_effort_once_the_owner_allows_it() { + let mut c = crew().await; + let roster = c + .lead_mcp + .tool("crew_roster", json!({})) + .await + .expect("roster"); + assert_eq!(roster["bots"][0]["model"], "default"); + assert_eq!(roster["bots"][0]["effort"], "default"); + + let refused = call(&c.lead_mcp, json!({ "bot": "writer", "effort": "tiny" })) + .await + .expect("task"); + assert!(refused.expect_err("bad effort").contains("effort must be")); + + let waiting = call( + &c.lead_mcp, + json!({ "bot": "writer", "model": "haiku", "effort": "low", "reason": "Simple work." }), + ); + let asked = pending(&mut c.app).await; + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["before"]["model"], "default"); + assert_eq!(input["after"]["model"], "haiku"); + assert_eq!(input["after"]["effort"], "low"); + c.app + .call( + "approvals.answer", + json!({ "approvalId": asked["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + let done = waiting.await.expect("task").expect("changed"); + assert_eq!(done["model"], "haiku"); + let now = bot(&mut c.app, &c.writer).await; + assert_eq!(now["model"], "haiku"); + assert_eq!(now["effort"], "low"); + assert_eq!(now["name"], "Writer"); +} diff --git a/crates/botloftd/tests/crew_chief.rs b/crates/botloftd/tests/crew_chief.rs index b265d954..1886162f 100644 --- a/crates/botloftd/tests/crew_chief.rs +++ b/crates/botloftd/tests/crew_chief.rs @@ -287,3 +287,31 @@ async fn the_owner_moves_the_chief_and_an_archived_chief_leaves_none() { let crews = c.app.call("crews.list", json!({})).await.expect("crews"); assert_eq!(crews[0]["leadBotId"], Value::Null); } + +#[tokio::test] +async fn a_suggested_bot_starts_with_the_effort_suggested() { + let mut c = crew_with_chief().await; + let mut asked = designer(); + asked["effort"] = json!("loud"); + let refused = suggest(&c.mcp, asked.clone()).await.expect("task"); + assert!(refused.expect_err("bad effort").contains("effort must be")); + + asked["effort"] = json!("low"); + let waiting = suggest(&c.mcp, asked); + let request = pending_suggestion(&mut c.app).await; + c.app + .call( + "approvals.answer", + json!({ "approvalId": request["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + let created = waiting.await.expect("task").expect("created"); + assert_eq!(created["effort"], "low"); + let designer = crew_bots(&mut c) + .await + .into_iter() + .find(|bot| bot["name"] == "Designer") + .expect("designer"); + assert_eq!(designer["effort"], "low"); +} diff --git a/crates/botloftd/tests/tasks.rs b/crates/botloftd/tests/tasks.rs index 56dca545..b5ce20da 100644 --- a/crates/botloftd/tests/tasks.rs +++ b/crates/botloftd/tests/tasks.rs @@ -185,7 +185,8 @@ async fn mistakes_come_back_as_tool_errors() { assert_eq!(roster["you"], "lead"); assert_eq!( roster["bots"], - json!([{ "handle": "writer", "name": "Writer", "role": "Writer role", "state": "idle", "chief": false }]) + json!([{ "handle": "writer", "name": "Writer", "role": "Writer role", "state": "idle", + "model": "default", "effort": "default", "chief": false }]) ); let err = |out: Result| out.expect_err("refused"); diff --git a/docs/spec.md b/docs/spec.md index badc8d5a..81b635c1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -563,15 +563,15 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | Tool | Entrada | Saída (JSON em texto) | |---|---|---| -| `crew_roster` | `crew?` (outra crew, 10.4) | `crew`, `you`, `you_lead`, os outros bots da crew (handle, nome, papel, estado e `chief`) e `signals`, os avisos que as rotinas da crew esperam (20.13). Com `crew`: o nome dela e só os bots que o bot alcança lá | +| `crew_roster` | `crew?` (outra crew, 10.4) | `crew`, `you`, `you_lead`, os outros bots da crew (handle, nome, papel, estado, `model`, `effort` e `chief`) e `signals`, os avisos que as rotinas da crew esperam (20.13). Com `crew`: o nome dela e só os bots que o bot alcança lá | | `send_message` | `to` (handle, com ou sem `@`), `crew?` (outra crew, 10.4), `body`, `kind?` (`note` padrão, `task`), `deadline_minutes?` (só task) | `message_id`, `task_id` e `due` (task), e um lembrete de que a resposta chega depois | | `complete_task` | `task_id`, `result`, `status?` (`done` padrão, `failed`) | `task_id`, `status` e quem recebe o resultado | | `my_tasks` | `role?` (`assigned`, `requested`) | tasks `open` e `expired`: id, de, para, status, prazo relativo, hops e o pedido original | -| `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe | +| `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `effort?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model`, `effort` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe | | `schedule_routine` | `name`, `prompt` (até 8 000 caracteres), `schedule` (como em 20.2), `bot?` (handle de outro bot da crew) | `created` e, criada, `routine_id`, nome, pedido, horário, fuso e a próxima vez; recusada ou sem resposta, o aviso de que nada foi agendado (20.12) | | `ask_crew_access` | `crew` (nome da outra crew), `bot?` (handle de um bot dela), `access` (`talk`, `read`, `edit`), `why` (até 1 000) | `allowed` e, permitido, como usar; recusado ou sem resposta, o porquê (10.4) | | `crew_files`, `read_crew_file`, `write_crew_file` | `crew`, e `bot?`, `path` ou `path` e `content` | os arquivos de outra crew que o bot alcança, o texto de um, ou o aviso de que gravou (10.4) | -| `change_bot` | `bot?` (handle de outro bot da crew, só o chefe), `name?`, `role?`, `instructions?` (até 8 000 caracteres), `reason?` (até 1 000) | `done` e, mudado, `handle`, `name`, `role` e quando vale; recusado ou sem resposta, o aviso de que nada mudou (10.3) | +| `change_bot` | `bot?` (handle de outro bot da crew, só o chefe), `name?`, `role?`, `instructions?` (até 8 000 caracteres), `model?`, `effort?`, `reason?` (até 1 000) | `done` e, mudado, `handle`, `name`, `role`, `model`, `effort` e quando vale; recusado ou sem resposta, o aviso de que nada mudou (10.3) | | `share_file` | `files` (1 a 10 caminhos, absolutos ou relativos à pasta do bot) | `shown` (um `BotFile` por arquivo, 8.4) e um lembrete de que o dono vê cada um como cartão no chat; um arquivo fora das pastas do bot e não escrito por ele, ou que não existe, recusa a chamada inteira e diz qual e por quê | | `send_signal` | `name`, `note?` (até 2 000 caracteres) | o aviso normalizado e, para cada rotina da crew que o espera, se rodou e por que não (20.13) | | `ask_owner` | `question` (até 2 000 caracteres), `options?` (2 a 5, até 100 caracteres cada) | `question_id` e o lembrete de que a resposta chega depois, como mensagem; a tool não espera (23.2) | @@ -626,18 +626,18 @@ Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e e - **Regras do chefe:** além das de todo bot, uma seção "You lead this crew": planejar, dividir o trabalho em partes que rodam em paralelo e distribuí-las com `send_message` (`kind: "task"`); conferir os resultados antes de responder ao dono; quando faltar um especialista, sugerir um bot com `suggest_bot`; escolher o modelo pelo trabalho (`haiku` para o simples e repetitivo, `sonnet` para quase tudo, `opus` ou `fable` só para o raciocínio mais difícil, lembrando que gastam mais do plano); manter a crew pequena; e contar ao dono o que cada bot faz, para ele acompanhar e falar com cada um no chat dele. Os outros bots leem que, se a crew precisar de outro bot, devem pedir ao chefe, que o `crew_roster` marca com `chief`. - **`suggest_bot`:** aparece para todos os bots, mas só o chefe pode usar; os outros recebem um erro que manda pedir ao chefe. Antes de incomodar o dono, o daemon confere os campos (os de um bot, mais o limite de instruções e o porquê), que o nome está livre na crew e que a crew tem menos de `max_per_crew` bots ativos. Um pedido impossível volta como erro ao chefe. - **Aprovação:** a sugestão vira um pedido no chat do chefe, pelo mesmo caminho das aprovações (10.1): `approval` com `toolName: "mcp__botloft__suggest_bot"`, entrada até 64 KB e resumo com o nome sugerido. O chefe fica `needs_approval`, e a chamada MCP espera a resposta até `approval_timeout_minutes`. A tool vem liberada por `--allowedTools mcp__botloft`, então o Claude Code não pede permissão antes: quem decide é o dono, pelo cartão. -- **Resposta:** permitir cria o bot na crew do chefe, com modo Manual e o modelo sugerido. O dono pode mudar nome, papel, modelo e instruções antes: `approvals.answer` leva `input`, o JSON da sugestão como ele deixou, que substitui a entrada gravada (o cartão mostra o que foi criado); `input` só vale para sugestões de bot e pedidos de rotina (20.12). Negar volta ao chefe com a nota do dono, e nada é criado. Sem resposta no prazo, o chefe lê que pode sugerir de novo depois. O bot novo sobe na hora e aparece na barra lateral, com o próprio chat; o resultado da tool lembra o chefe de mandar a primeira task. +- **Resposta:** permitir cria o bot na crew do chefe, com modo Manual e o modelo e o esforço sugeridos. O dono pode mudar nome, papel, modelo, esforço e instruções antes: `approvals.answer` leva `input`, o JSON da sugestão como ele deixou, que substitui a entrada gravada (o cartão mostra o que foi criado); `input` só vale para sugestões de bot e pedidos de rotina (20.12). Negar volta ao chefe com a nota do dono, e nada é criado. Sem resposta no prazo, o chefe lê que pode sugerir de novo depois. O bot novo sobe na hora e aparece na barra lateral, com o próprio chat; o resultado da tool lembra o chefe de mandar a primeira task. - **Chefe em `bypass_permissions`:** a sugestão cria o bot na hora, sem cartão, como tudo o que esse modo faz sem perguntar (13). O resultado diz ao chefe que foi criado sem pedir. - Bots criados assim são bots comuns: o dono conversa, muda, pausa e arquiva cada um como qualquer outro. ### 10.3 Bots que se mudam -Quando o dono pede, um bot pode mudar o próprio nome, papel ou instruções, e o chefe pode mudar os de outro bot da crew, com `change_bot`. As regras de todo bot dizem para usar só quando o dono pedir; as do chefe, que ele pode nomear outro bot em `bot`. +Quando o dono pede, um bot pode mudar o próprio nome, papel, instruções, modelo ou esforço (7.4), e o chefe pode mudar os de outro bot da crew, com `change_bot`. As regras de todo bot dizem para usar só quando o dono pedir; as do chefe, que ele pode nomear outro bot em `bot` e que cuide do custo: `crew_roster` mostra o modelo e o esforço de cada bot, e quando o trabalho de um bot se mostra mais simples (ou mais difícil) que eles, o chefe propõe a troca com o porquê (trabalho simples e repetitivo: `haiku` e `low`). - **Quem e qual:** sem `bot` (ou com o próprio handle), muda quem chama. Com outro handle, só o chefe; os outros recebem um erro que manda pedir ao chefe. O handle é procurado só na crew de quem chama: um bot de outra crew é "nenhum bot da sua crew". - **Antes de perguntar:** o daemon confere os campos como `bots.update` (nome livre na crew, papel numa linha, instruções até 8 000 caracteres) e recusa um pedido que não muda nada. -- **Aprovação:** um pedido no chat de quem chama, `toolName: "mcp__botloft__change_bot"`, com entrada `bot_id`, `handle`, `name` (o nome atual, também o resumo), `before`, `after` (nome, papel e instruções) e `reason`. O cartão diz " quer mudar " ou " quer mudar a própria configuração", mostra nome e papel antes e depois, as instruções novas atrás de uma seta e o porquê, com Mudar, Agora não e o campo para dizer por que não. O dono não ajusta o pedido no cartão; pode mudar o bot depois nos detalhes. -- **Resposta:** permitir confere de novo e aplica como `bots.update`: o nome e o handle mudam na hora; papel e instruções valem a partir do próximo início do bot, como quando o dono os edita. Recusado ou sem resposta, nada muda, e o resultado diz isso ao bot. Quem chama em `bypass_permissions` muda sem perguntar. +- **Aprovação:** um pedido no chat de quem chama, `toolName: "mcp__botloft__change_bot"`, com entrada `bot_id`, `handle`, `name` (o nome atual, também o resumo), `before`, `after` (nome, papel e instruções) e `reason`. O cartão diz " quer mudar " ou " quer mudar a própria configuração", mostra nome, papel, modelo e esforço antes e depois (o esforço `default` aparece como "Recomendado"), as instruções novas atrás de uma seta e o porquê, com Mudar, Agora não e o campo para dizer por que não. O dono não ajusta o pedido no cartão; pode mudar o bot depois nos detalhes. +- **Resposta:** permitir confere de novo e aplica como `bots.update`: o nome e o handle mudam na hora; papel e instruções valem a partir do próximo início do bot, como quando o dono os edita; modelo e esforço passam por `bots.setModel` e `bots.setEffort`, e o bot reinicia neles quando nada estiver em andamento (7.4). Recusado ou sem resposta, nada muda, e o resultado diz isso ao bot. Quem chama em `bypass_permissions` muda sem perguntar. ### 10.4 Bots que alcançam outra crew