diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index ed14e48e..c5228583 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -17,6 +17,7 @@ import { RoutineDeleteCard, } from "../routines/RoutineChangeCard"; import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard"; +import { BotChangeCard, CHANGE_BOT_TOOL } from "./BotChangeCard"; import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard"; @@ -73,6 +74,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B return ; case DELETE_ROUTINE_TOOL: return ; + case CHANGE_BOT_TOOL: + return ; case SITE_TOOL: return ; case DESKTOP_TOOL: diff --git a/app/src/features/chat/BotChangeCard.tsx b/app/src/features/chat/BotChangeCard.tsx new file mode 100644 index 00000000..983ca466 --- /dev/null +++ b/app/src/features/chat/BotChangeCard.tsx @@ -0,0 +1,144 @@ +// A bot asking to rename or change itself, or the chief another bot of its +// crew (spec 10.3): what changes, before and after, and why. The owner +// allows it or says no with a note. Answered, it shrinks to one line. + +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 { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; + +export const CHANGE_BOT_TOOL = "mcp__botloft__change_bot"; + +interface Profile { + name: string; + role: string; + instructions: string; +} + +interface Asked { + bot_id: string; + name: string; + before: Profile | null; + after: Profile | null; + reason: string | null; +} + +function askedOf(input: string): Partial { + try { + return JSON.parse(input) as Partial; + } catch { + return {}; + } +} + +function Answered({ approval, name }: { approval: ApprovalItem; name: string }) { + const w = useT().chat.botChange; + const [icon, text, tone] = + approval.status === "allowed" + ? ([Check, w.done(name), "ok"] as const) + : approval.status === "denied" + ? ([Ban, w.declined(name), "danger"] as const) + : ([TimerOff, w.expired(name), "quiet"] as const); + return ; +} + +export function BotChangeCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const t = useT(); + const w = t.chat.botChange; + const d = t.bots.dialog; + const api = useApi(); + const asked = askedOf(approval.input); + const name = asked.name ?? ""; + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const { before, after } = asked; + if (approval.status !== "pending" || !before || !after) { + return ; + } + const answer = async (allow: boolean) => { + setBusy(true); + setError(null); + const trimmed = note.trim(); + try { + await api.call("approvals.answer", { + approvalId: approval.approvalId, + allow, + ...(!allow && trimmed ? { note: trimmed } : {}), + }); + } catch (failure) { + setError(`${w.failed}: ${errorText(failure)}`); + } + setBusy(false); + }; + 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 self = asked.bot_id === bot.id; + return ( + + +
+ + +
+ + } + > +
+ {rows.length > 0 && ( +
+ {rows.map(([what, was, becomes]) => ( +
+
{what}
+
+ {was} + {" → "} + {becomes} +
+
+ ))} +
+ )} + {before.instructions !== after.instructions && ( +
+ {w.newInstructions} +

+ {after.instructions} +

+
+ )} + {asked.reason && ( +

+ {w.reason(bot.name)} + {asked.reason} +

+ )} +

{w.explain}

+ {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 2eb10a7b..fccc923a 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -20,6 +20,7 @@ import { Monitor, Send, SquareTerminal, + UserPen, UserPlus, Users, Wrench, @@ -56,6 +57,7 @@ const ICONS: Record = { Agent: Users, send_message: Send, suggest_bot: UserPlus, + change_bot: UserPen, schedule_routine: AlarmClock, my_routines: AlarmClock, change_routine: AlarmClock, diff --git a/app/src/features/chat/botChange.test.tsx b/app/src/features/chat/botChange.test.tsx new file mode 100644 index 00000000..a43c6f6d --- /dev/null +++ b/app/src/features/chat/botChange.test.tsx @@ -0,0 +1,77 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { CHANGE_BOT_TOOL } from "./BotChangeCard"; + +afterEach(cleanup); + +const WRITER = { name: "Writer", role: "Writes posts", instructions: "Short sentences." }; + +async function openChief() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const chief = fake.addBot(ops.id, "Chief", "Leads the crew"); + fake.setBotState(chief.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Chief"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, chief }; +} + +const answered = (fake: FakeBotloft) => + fake.calls.find((call) => call.method === "approvals.answer")?.params as + | { allow: boolean; note?: string } + | undefined; + +describe("a bot asking to change a bot", () => { + test("the chief's request shows the other bot, before and after, and why", async () => { + const { fake, chief } = await openChief(); + act(() => { + fake.chat.ask( + chief.id, + CHANGE_BOT_TOOL, + "Writer", + JSON.stringify({ + bot_id: "bot_writer", + name: "Writer", + before: WRITER, + after: { ...WRITER, name: "Editor", instructions: "Edit, do not write." }, + 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).queryByText("Writes posts")).toBeNull(); + expect(within(card).getByText("See the new instructions")).toBeDefined(); + expect(within(card).getByText("The owner asked for it.")).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Change" })); + await waitFor(() => expect(answered(fake)).toMatchObject({ allow: true })); + }); + + test("a bot changing itself, declined with a note", async () => { + const { fake, chief } = await openChief(); + act(() => { + fake.chat.ask( + chief.id, + CHANGE_BOT_TOOL, + "Chief", + JSON.stringify({ + bot_id: chief.id, + name: "Chief", + before: { ...WRITER, name: "Chief" }, + after: { ...WRITER, name: "Boss" }, + reason: null, + }), + ); + }); + const card = screen.getByRole("region", { name: "Chief wants to change how it is set up" }); + fireEvent.change(within(card).getByRole("textbox"), { target: { value: "Keep the name" } }); + fireEvent.click(within(card).getByRole("button", { name: "Not now" })); + await waitFor(() => + expect(answered(fake)).toMatchObject({ allow: false, note: "Keep the name" }), + ); + }); +}); diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index f9975433..280460e6 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -319,6 +319,22 @@ export const chat = { declined: (name: string) => `You kept the routine ${name}`, expired: (name: string) => `Deleting ${name} was not answered in time`, }, + /** A bot asking to rename or change itself, or the chief another bot (spec 10.3). */ + botChange: { + titleSelf: (bot: string) => `${bot} wants to change how it is set up`, + 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.", + apply: "Change", + decline: "Not now", + noteLabel: (bot: string) => `What to tell ${bot} if you say no`, + notePlaceholder: (bot: string) => `If you say no, tell ${bot} why (optional)`, + failed: "Could not send the answer", + done: (name: string) => `You changed ${name}`, + declined: (name: string) => `You kept ${name} as it was`, + expired: (name: string) => `The change to ${name} was not answered in time`, + }, plan: { ready: (bot: string) => `${bot} made a plan and wants to go ahead`, noteLabel: (bot: string) => `What ${bot} should change in the plan`, diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts index 79d80a9e..b6146491 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -36,6 +36,7 @@ export const tools = { my_routines: "See my routines", change_routine: "Change a routine", delete_routine: "Delete a routine", + change_bot: "Change a bot", share_file: "Share a file", ask_owner: "Ask you a question", browser: "Use a site", diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index d06e6326..9f1afd41 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -309,6 +309,21 @@ export const chat: Messages["chat"] = { declined: (name) => `Mantuviste la rutina ${name}`, expired: (name) => `Borrar ${name} quedó sin respuesta`, }, + botChange: { + titleSelf: (bot) => `${bot} quiere cambiar su propia configuración`, + 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.", + apply: "Cambiar", + decline: "Ahora no", + noteLabel: (bot) => `Qué decirle a ${bot} si dices que no`, + notePlaceholder: (bot) => `Si dices que no, dile a ${bot} por qué (opcional)`, + failed: "No se pudo enviar la respuesta", + done: (name) => `Cambiaste a ${name}`, + declined: (name) => `Dejaste a ${name} como estaba`, + expired: (name) => `El cambio de ${name} quedó sin respuesta`, + }, plan: { ready: (bot: string) => `${bot} hizo un plan y quiere seguir con él`, noteLabel: (bot: string) => `Qué debe cambiar ${bot} en el plan`, diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts index c8b5f5d1..e62e4b28 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -37,6 +37,7 @@ export const tools: Messages["tools"] = { my_routines: "Ver mis rutinas", change_routine: "Cambiar una rutina", delete_routine: "Borrar una rutina", + change_bot: "Cambiar un bot", share_file: "Compartir un archivo", ask_owner: "Hacerte una pregunta", browser: "Usar un sitio", diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 320c08ac..7302bb9b 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -306,6 +306,21 @@ export const chat: Messages["chat"] = { declined: (name) => `Você manteve a rotina ${name}`, expired: (name) => `Apagar ${name} ficou sem resposta`, }, + botChange: { + titleSelf: (bot) => `${bot} quer mudar a própria configuração`, + 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.", + apply: "Mudar", + decline: "Agora não", + noteLabel: (bot) => `O que dizer a ${bot} se você recusar`, + notePlaceholder: (bot) => `Se recusar, diga a ${bot} por quê (opcional)`, + failed: "Não foi possível mandar a resposta", + done: (name) => `Você mudou ${name}`, + declined: (name) => `Você manteve ${name} como estava`, + expired: (name) => `A mudança em ${name} ficou sem resposta`, + }, plan: { ready: (bot: string) => `${bot} fez um plano e quer seguir com ele`, noteLabel: (bot: string) => `O que ${bot} deve mudar no plano`, diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts index c0b12979..2245dae0 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -36,6 +36,7 @@ export const tools: Messages["tools"] = { my_routines: "Ver minhas rotinas", change_routine: "Mudar uma rotina", delete_routine: "Apagar uma rotina", + change_bot: "Mudar um bot", share_file: "Compartilhar um arquivo", ask_owner: "Fazer uma pergunta a você", browser: "Usar um site", diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 53401bd0..defd0dd9 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -20,6 +20,9 @@ pub const ROUTINE_TOOL: &str = "mcp__botloft__schedule_routine"; pub const CHANGE_ROUTINE_TOOL: &str = "mcp__botloft__change_routine"; /// A bot asking the owner to delete one of its routines (spec 20.12). 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 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. @@ -67,7 +70,7 @@ pub fn one_line(text: &str, max: usize) -> String { pub fn tool_input_max(tool: &str) -> usize { match tool { PLAN_TOOL => PLAN_INPUT_MAX, - SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL => SUGGESTION_INPUT_MAX, + SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL | CHANGE_BOT_TOOL => SUGGESTION_INPUT_MAX, tool if is_command(tool) => COMMAND_INPUT_MAX, _ => TOOL_INPUT_MAX, } @@ -110,9 +113,8 @@ pub fn tool_summary(name: &str, input: &Value) -> String { "mcp__botloft__send_message" => { field(input, "to").map(|to| format!("@{}", to.trim_start_matches('@'))) } - SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL | DELETE_ROUTINE_TOOL => { - field(input, "name").map(str::to_owned) - } + 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), DESKTOP_TOOL => field(input, "app").map(str::to_owned), "mcp__botloft__desktop_look" | "mcp__botloft__desktop_screenshot" => { diff --git a/crates/botloftd/src/tools/bot_change.rs b/crates/botloftd/src/tools/bot_change.rs new file mode 100644 index 00000000..f47e5186 --- /dev/null +++ b/crates/botloftd/src/tools/bot_change.rs @@ -0,0 +1,240 @@ +//! `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 +//! 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::{slug, validate}; +use botloft_store::BotRecord; +use serde::{Deserialize, Serialize}; +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::state::Daemon; + +pub const CHANGE_BOT: &str = "change_bot"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + bot: Option, + name: Option, + role: Option, + instructions: Option, + reason: Option, +} + +/// What the owner sees changing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Profile { + name: String, + role: String, + instructions: String, +} + +impl Profile { + fn of(record: &BotRecord) -> Self { + Self { + name: record.name.clone(), + role: record.role.clone(), + instructions: record.instructions.clone(), + } + } +} + +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.", + "inputSchema": { + "type": "object", + "properties": { + "bot": { + "type": "string", + "description": "Only for the chief: the handle of the bot to change, as \ + crew_roster gives it. Leave it out to change yourself.", + }, + "name": { + "type": "string", + "maxLength": validate::NAME_MAX_CHARS, + "description": "The new name. The handle changes with it.", + }, + "role": { + "type": "string", + "maxLength": validate::ROLE_MAX_CHARS, + "description": "One line: what the bot does in the crew.", + }, + "instructions": { + "type": "string", + "maxLength": INSTRUCTIONS_MAX, + "description": "The whole new instructions, written to the bot. They \ + replace the old ones.", + }, + "reason": { + "type": "string", + "maxLength": REASON_MAX, + "description": "For the owner: why, in a sentence.", + }, + }, + "additionalProperties": false, + }, + }) +} + +pub(super) async fn change(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Value { + tool_result(run(daemon, bot, generation, args).await) +} + +/// The bot to change: the caller, or for the chief one of its crew. +fn target(daemon: &Daemon, caller: &BotId, handle: Option<&str>) -> ApiResult { + let store = daemon.store(); + let (crew, me) = bots::active(&store, caller)?; + let Some(handle) = handle.map(|to| slug::slugify(to.trim().trim_start_matches('@'), "")) else { + return Ok(me); + }; + if handle == me.handle { + return Ok(me); + } + if !lead::is_lead(&crew, caller) { + return Err(ApiError::Conflict( + "only the crew's chief can change another bot; ask the chief with send_message, \ + or leave out `bot` to change yourself" + .into(), + )); + } + let found = match handle.as_str() { + "" => None, + handle => store.active_bot_by_handle(&crew.id, handle)?, + }; + let id = found.ok_or_else(|| { + ApiError::NotFound(format!( + "no bot in your crew answers to @{handle}; call crew_roster to see who does" + )) + })?; + bots::find(&store, &id) +} + +/// The profile after the change, checked as `bots.update` would. +fn after(daemon: &Daemon, record: &BotRecord, args: &Args) -> ApiResult { + let mut next = Profile::of(record); + if let Some(name) = &args.name { + next.name = validate::name("name", name)?; + let store = daemon.store(); + let handle = slug::slugify(&next.name, "bot"); + bots::ensure_handle_free(&store, &record.crew_id, &handle, Some(&record.id))?; + } + if let Some(role) = &args.role { + next.role = validate::role(role)?; + } + if let Some(instructions) = &args.instructions { + if instructions.chars().count() > INSTRUCTIONS_MAX { + return Err(ApiError::validation(format!( + "instructions must be at most {INSTRUCTIONS_MAX} characters" + ))); + } + next.instructions = validate::instructions(instructions)?; + } + Ok(next) +} + +fn apply(daemon: &Daemon, id: &BotId, next: &Profile) -> ApiResult { + bots::update( + daemon, + BotsUpdateParams { + bot_id: id.clone(), + name: Some(next.name.clone()), + role: Some(next.role.clone()), + instructions: Some(next.instructions.clone()), + color: None, + }, + ) +} + +async fn run( + daemon: &Daemon, + caller: &BotId, + generation: u64, + args: Value, +) -> Result { + let args: Args = parse(args)?; + if args + .reason + .as_ref() + .is_some_and(|reason| reason.chars().count() > REASON_MAX) + { + return Err(format!("reason must be at most {REASON_MAX} characters")); + } + let record = target(daemon, caller, args.bot.as_deref()).map_err(explain)?; + let before = Profile::of(&record); + // Nothing impossible goes to the owner. + let next = after(daemon, &record, &args).map_err(explain)?; + if next == before { + return Err("that is how the bot is already: nothing to change".to_owned()); + } + let mode = bots::find(&daemon.store(), caller) + .map_err(explain)? + .permission_mode; + if mode == PermissionMode::BypassPermissions { + let bot = apply(daemon, &record.id, &next).map_err(explain)?; + return Ok(done( + &bot, + caller, + "You bypass permissions, so it changed without asking.", + )); + } + let input = json!({ + "bot_id": record.id, + "handle": record.handle, + "name": record.name, + "before": before, + "after": next, + "reason": args.reason, + }); + match approvals::ask(daemon, caller, generation, CHANGE_BOT_TOOL, &input, "").await { + 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)?; + Ok(done(&bot, caller, "The owner approved the change.")) + } + Some(Answer::Denied { note }) => { + let why = note.map_or_else(String::new, |note| format!(" They said: {note}")); + Ok(json!({ + "done": false, + "note": format!("The owner declined the change.{why} Nothing changed: do not say it did."), + })) + } + Some(Answer::Expired) => Ok(json!({ + "done": false, + "note": "The owner did not answer in time. Nothing changed: tell the owner, and ask \ + again later if it still helps.", + })), + None => Err("Botloft could not ask the owner.".to_owned()), + } +} + +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." + } else { + "The bot reads its new instructions and role when it next starts." + }; + json!({ + "done": true, + "handle": bot.handle, + "name": bot.name, + "role": bot.role, + "note": format!("{how} The bot is now @{} ({}). {when}", bot.handle, bot.name), + }) +} diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 0c957023..100f2ece 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -31,6 +31,7 @@ pub fn tools() -> Value { ] .into_iter() .chain(super::routine_catalog::tools()) + .chain([super::bot_change::tool()]) .chain(super::browser_catalog::tools()) .chain(super::desktop_catalog::tools()); list.splice(at..at, more); diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index eddef66d..f691f832 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -6,6 +6,7 @@ //! Claude Code tries `server/discover` first and falls back to //! `initialize` when that fails, so both paths lead to the same tools. +mod bot_change; mod browser; mod browser_args; mod browser_catalog; @@ -133,7 +134,7 @@ pub async fn handle( return StatusCode::ACCEPTED.into_response(); }; let answered = match era(&headers, &request) { - // They hold the request until the owner answers (spec 10.1, 10.2, 20.12). + // They hold the request until the owner answers (spec 10.1, 10.2, 10.3, 20.12). Ok(era) if called(&request) == Some(catalog::PERMISSION_PROMPT) => { permission(&daemon, &bot, generation, &request) .await @@ -157,6 +158,11 @@ pub async fn handle( let result = routine_change::delete(&daemon, &bot, generation, args).await; Ok(decorate(era, &request, result)) } + Ok(era) if called(&request) == Some(bot_change::CHANGE_BOT) => { + let args = arguments(&request); + let result = bot_change::change(&daemon, &bot, generation, args).await; + Ok(decorate(era, &request, result)) + } // They act in a browser and may wait for the owner (spec 21.5). Ok(era) if called(&request).is_some_and(|name| name.starts_with(browser_args::PREFIX)) => { let name = called(&request).unwrap_or_default().to_owned(); diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 19a65c8e..913c8498 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -76,6 +76,8 @@ The owner made you the chief of this crew: its goal is in your instructions. faster. - Keep the crew small: do small jobs yourself and prefer a bot that already 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. - Tell the owner what each bot is doing, so they can follow and talk to each one in its own chat. "; @@ -140,6 +142,9 @@ app; the other bots of your crew send you messages too. your crew. To change, pause or delete one of yours, call `my_routines` for its id, then `change_routine` or `delete_routine`: the owner approves those too, and only then is it done. +- Only when the owner asks you to rename yourself or change your role or + instructions, call `change_bot` with just what changes: the owner approves + it in your chat, and only then is it done. - When you need the owner's decision or information to go on, above all in a routine or a task from another bot, when nobody reads your chat, call `ask_owner`: one short question, with `options` when the answers are few. @@ -291,6 +296,7 @@ mod tests { assert!(rules.contains("`browser_*` tools")); assert!(rules.contains("`schedule_routine`")); assert!(rules.contains("`change_routine` or `delete_routine`")); + assert!(rules.contains("call `change_bot` with just what changes")); assert!(rules.contains(r#""#)); assert!(!rules.contains("You lead this crew")); @@ -301,5 +307,6 @@ mod tests { let rules = rules_md(&chief, &bot, Path::new("/ws/site/shared")); assert!(rules.contains("## You lead this crew")); assert!(rules.contains("`suggest_bot`")); + assert!(rules.contains("handle in `bot`")); } } diff --git a/crates/botloftd/tests/bot_changes.rs b/crates/botloftd/tests/bot_changes.rs new file mode 100644 index 00000000..57bb4e1a --- /dev/null +++ b/crates/botloftd/tests/bot_changes.rs @@ -0,0 +1,192 @@ +//! `change_bot` (spec 10.3): a bot asks to rename or change itself, the +//! chief may ask it for another bot of its crew, and nothing changes until +//! the owner allows it. Bots of other crews cannot be named. + +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; + +const TOOL: &str = "mcp__botloft__change_bot"; + +struct Crew { + _t: TestDaemon, + app: Client, + lead: Value, + lead_mcp: Mcp, + writer: Value, + writer_mcp: Mcp, +} + +/// "Ops" with @lead as chief and @writer, and "Site" with @scout. +async fn crew() -> Crew { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let ops = app + .call( + "crews.create", + json!({ + "name": "Ops", + "lead": { "name": "Lead", "role": "Lead role", "instructions": "" }, + }), + ) + .await + .expect("crew"); + let site = app + .call("crews.create", json!({ "name": "Site" })) + .await + .expect("crew"); + let lead = app + .call("bots.list", json!({ "crewId": ops["id"] })) + .await + .expect("bots")[0] + .clone(); + let process = t.process_of(&lead).await; + t.until_state(&lead, BotState::Idle).await; + let lead_mcp = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + let (writer, _, writer_mcp) = ready_bot(&t, &mut app, &ops, "Writer").await; + ready_bot(&t, &mut app, &site, "Scout").await; + Crew { + _t: t, + app, + lead, + lead_mcp, + writer, + writer_mcp, + } +} + +fn call(mcp: &Mcp, arguments: Value) -> JoinHandle> { + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + tokio::spawn(async move { mcp.tool("change_bot", arguments).await }) +} + +/// The pending request, with the bot whose chat it is in as `botId`. +async fn pending(app: &mut Client) -> Value { + loop { + let changed = app.notification("chat.item").await; + let mut body = changed["item"]["body"].clone(); + if body["kind"] == "approval" && body["status"] == "pending" { + assert_eq!(body["toolName"], TOOL); + body["botId"] = changed["item"]["botId"].clone(); + return body; + } + } +} + +async fn bot(app: &mut Client, of: &Value) -> Value { + let bots = app.call("bots.list", json!({})).await.expect("bots"); + bots.as_array() + .expect("list") + .iter() + .find(|bot| bot["id"] == of["id"]) + .expect("bot") + .clone() +} + +#[tokio::test] +async fn a_bot_renames_itself_once_the_owner_allows_it() { + let mut c = crew().await; + let waiting = call( + &c.writer_mcp, + json!({ "name": "Editor", "reason": "Asked to." }), + ); + let asked = pending(&mut c.app).await; + assert_eq!(asked["botId"], c.writer["id"]); + assert_eq!(asked["summary"], "Writer"); + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["before"]["name"], "Writer"); + assert_eq!(input["after"]["name"], "Editor"); + assert_eq!(input["after"]["role"], "Writer role"); + assert_eq!(bot(&mut c.app, &c.writer).await["name"], "Writer"); + + 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["done"], true); + assert_eq!(done["handle"], "editor"); + let now = bot(&mut c.app, &c.writer).await; + assert_eq!(now["name"], "Editor"); + assert_eq!(now["handle"], "editor"); +} + +#[tokio::test] +async fn the_chief_changes_a_bot_of_its_crew_and_the_owner_may_say_no() { + let mut c = crew().await; + let waiting = call( + &c.lead_mcp, + json!({ "bot": "@writer", "role": "Writes the newsletter" }), + ); + let asked = pending(&mut c.app).await; + // The request is in the chief's chat, about the writer. + assert_eq!(asked["botId"], c.lead["id"]); + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["bot_id"], c.writer["id"]); + assert_eq!(input["after"]["role"], "Writes the newsletter"); + + c.app + .call( + "approvals.answer", + json!({ "approvalId": asked["approvalId"], "allow": false, "note": "Not now" }), + ) + .await + .expect("answer"); + let declined = waiting.await.expect("task").expect("declined"); + assert_eq!(declined["done"], false); + assert!(declined["note"].as_str().expect("note").contains("Not now")); + assert_eq!(bot(&mut c.app, &c.writer).await["role"], "Writer role"); + + let waiting = call(&c.lead_mcp, json!({ "bot": "writer", "role": "Edits" })); + let asked = pending(&mut c.app).await; + c.app + .call( + "approvals.answer", + json!({ "approvalId": asked["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + waiting.await.expect("task").expect("changed"); + assert_eq!(bot(&mut c.app, &c.writer).await["role"], "Edits"); +} + +#[tokio::test] +async fn only_the_chief_names_another_bot_and_only_of_its_crew() { + let c = crew().await; + let refused = call(&c.writer_mcp, json!({ "bot": "lead", "name": "Boss" })) + .await + .expect("task"); + assert!( + refused + .expect_err("not chief") + .contains("only the crew's chief") + ); + + // A bot of another crew is as good as missing. + let refused = call(&c.lead_mcp, json!({ "bot": "scout", "name": "Spy" })) + .await + .expect("task"); + assert!( + refused + .expect_err("other crew") + .contains("no bot in your crew") + ); + + // A taken name and no change at all never reach the owner. + let refused = call(&c.writer_mcp, json!({ "name": "Lead" })) + .await + .expect("task"); + assert!(refused.is_err()); + let refused = call(&c.writer_mcp, json!({ "name": "Writer" })) + .await + .expect("task"); + assert!(refused.expect_err("same").contains("already")); +} diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index ed67571f..77270224 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -79,6 +79,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "my_routines", "change_routine", "delete_routine", + "change_bot", "browser_open", "browser_look", "browser_click", @@ -143,7 +144,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(33) + Some(34) ); 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 f2c2e283..c73b7ebb 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -561,6 +561,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `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 | | `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) | +| `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) | | `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) | @@ -619,6 +620,15 @@ Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e e - **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`. + +- **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. + ## 11. Protocolo do app (JSON-RPC 2.0 sobre WebSocket) Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests com `id`, respostas com `result` ou `error`, notificações do servidor sem `id`. O maior frame aceito do app é de 32 MiB, por causa dos anexos.