From 4aec7519d5d470bb692881fbc0e5bdf09bd47495 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:55:56 -0300 Subject: [PATCH] feat(tools): change_bot lets a bot rename or change itself, and the chief another bot When the owner asks, a bot can rename itself or change its role or instructions, and the crew's chief can do the same for another bot of its crew. The handle is looked up only in the caller's crew. The owner approves or declines in the caller's chat, on a card that shows the change before and after; bypass mode changes at once. Spec 10, 10.3. --- app/src/features/chat/ApprovalCard.tsx | 3 + app/src/features/chat/BotChangeCard.tsx | 144 ++++++++++++++ app/src/features/chat/ToolLines.tsx | 2 + app/src/features/chat/botChange.test.tsx | 77 ++++++++ app/src/i18n/en/chat.ts | 16 ++ app/src/i18n/en/tools.ts | 1 + app/src/i18n/es/chat.ts | 15 ++ app/src/i18n/es/tools.ts | 1 + app/src/i18n/pt-BR/chat.ts | 15 ++ app/src/i18n/pt-BR/tools.ts | 1 + crates/botloft-core/src/chat.rs | 10 +- crates/botloftd/src/tools/bot_change.rs | 240 +++++++++++++++++++++++ crates/botloftd/src/tools/catalog.rs | 1 + crates/botloftd/src/tools/mod.rs | 8 +- crates/botloftd/src/workspace/files.rs | 7 + crates/botloftd/tests/bot_changes.rs | 192 ++++++++++++++++++ crates/botloftd/tests/mcp.rs | 3 +- docs/spec.md | 10 + 18 files changed, 740 insertions(+), 6 deletions(-) create mode 100644 app/src/features/chat/BotChangeCard.tsx create mode 100644 app/src/features/chat/botChange.test.tsx create mode 100644 crates/botloftd/src/tools/bot_change.rs create mode 100644 crates/botloftd/tests/bot_changes.rs 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.