From c840868c3fd9e635472ced69162d6cd7c623c78e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:49:03 -0300 Subject: [PATCH] feat(routines): bots change and delete their own routines, with the owner's ok my_routines lists a bot's routines; change_routine and delete_routine ask the owner in the chat, like schedule_routine does. The change card shows what changes, before and after, and the owner can adjust it before allowing it; the delete card shows why. A bot that bypasses permissions does it at once. --- app/src/features/chat/ApprovalCard.tsx | 10 + app/src/features/chat/ToolLines.tsx | 3 + .../features/routines/RoutineChangeCard.tsx | 289 +++++++++++++++++ .../features/routines/routineChange.test.tsx | 111 +++++++ app/src/i18n/en/chat.ts | 36 ++ app/src/i18n/en/tools.ts | 3 + app/src/i18n/es/chat.ts | 34 ++ app/src/i18n/es/tools.ts | 3 + app/src/i18n/pt-BR/chat.ts | 34 ++ app/src/i18n/pt-BR/tools.ts | 3 + crates/botloft-core/src/chat.rs | 10 +- crates/botloftd/src/approvals/mod.rs | 7 +- crates/botloftd/src/tools/calls.rs | 2 + crates/botloftd/src/tools/catalog.rs | 11 + crates/botloftd/src/tools/mod.rs | 13 + crates/botloftd/src/tools/routine_catalog.rs | 76 +++++ crates/botloftd/src/tools/routine_change.rs | 307 ++++++++++++++++++ crates/botloftd/src/workspace/files.rs | 5 +- crates/botloftd/tests/mcp.rs | 5 +- crates/botloftd/tests/routine_changes.rs | 164 ++++++++++ docs/spec.md | 5 +- 21 files changed, 1125 insertions(+), 6 deletions(-) create mode 100644 app/src/features/routines/RoutineChangeCard.tsx create mode 100644 app/src/features/routines/routineChange.test.tsx create mode 100644 crates/botloftd/src/tools/routine_catalog.rs create mode 100644 crates/botloftd/src/tools/routine_change.rs create mode 100644 crates/botloftd/tests/routine_changes.rs diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index 1536d882..0ddd5bc5 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -9,6 +9,12 @@ import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard"; import { Details } from "../../ui/Details"; import { HELP_TOOL, HelpCard } from "../browser/HelpCard"; import { SITE_TOOL, SiteCard } from "../browser/SiteCard"; +import { + CHANGE_ROUTINE_TOOL, + DELETE_ROUTINE_TOOL, + RoutineChangeCard, + RoutineDeleteCard, +} from "../routines/RoutineChangeCard"; import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard"; import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; @@ -62,6 +68,10 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B return ; case ROUTINE_TOOL: return ; + case CHANGE_ROUTINE_TOOL: + return ; + case DELETE_ROUTINE_TOOL: + return ; case SITE_TOOL: return ; case HELP_TOOL: diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 66e52bd2..cbaf3d6b 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -54,6 +54,9 @@ const ICONS: Record = { send_message: Send, suggest_bot: UserPlus, schedule_routine: AlarmClock, + my_routines: AlarmClock, + change_routine: AlarmClock, + delete_routine: AlarmClock, browser_ask_owner: Hand, }; diff --git a/app/src/features/routines/RoutineChangeCard.tsx b/app/src/features/routines/RoutineChangeCard.tsx new file mode 100644 index 00000000..8ebca71a --- /dev/null +++ b/app/src/features/routines/RoutineChangeCard.tsx @@ -0,0 +1,289 @@ +// A bot asking to change or delete one of its routines (spec 20.12). A +// change shows what changes, before and after, and the owner can adjust it +// before allowing it; a deletion shows why. Either can be refused with a +// note for the bot. Answered, each shrinks to one line. + +import { AlarmClock, Ban, Check, Pencil, TimerOff, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { type ApprovalItem, type Bot, FIELD_LIMITS, type Schedule } from "../../lib/protocol.gen"; +import { RpcError } from "../../lib/rpc"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; +import { TextArea, TextField } from "../../ui/Field"; +import { Switch } from "../../ui/Switch"; +import { describeSchedule, zoneLabel } from "./describe"; +import { formOf, type RoutineFields, type RoutineForm, scheduleOf } from "./form"; +import { MoreOptions } from "./MoreOptions"; +import { ScheduleFields } from "./ScheduleFields"; + +export const CHANGE_ROUTINE_TOOL = "mcp__botloft__change_routine"; +export const DELETE_ROUTINE_TOOL = "mcp__botloft__delete_routine"; + +type Changed = RoutineFields & { routine_id: string; enabled: boolean }; + +interface Asked { + name: string; + before: Changed | null; + after: Changed | null; +} + +function parsed(input: string): Partial { + try { + return JSON.parse(input) as Partial; + } catch { + return {}; + } +} + +/** One thing that changes, before and after, in words. */ +function useChanges(before: Changed, after: Changed): [string, string, string][] { + const t = useT(); + const w = t.chat.routineChange; + const when = (schedule: Schedule) => describeSchedule(schedule, t.routines.when); + const onOff = (on: boolean) => (on ? w.on : w.off); + const rows: [string, string, string][] = []; + if (before.name !== after.name) rows.push([t.routines.dialog.name, before.name, after.name]); + if (JSON.stringify(before.schedule) !== JSON.stringify(after.schedule)) { + rows.push([t.routines.dialog.when, when(before.schedule), when(after.schedule)]); + } + if (before.timezone !== after.timezone) { + rows.push([w.timezone, zoneLabel(before.timezone), zoneLabel(after.timezone)]); + } + if (before.enabled !== after.enabled) { + rows.push([w.state, onOff(before.enabled), onOff(after.enabled)]); + } + if (before.overlap !== after.overlap || before.missed !== after.missed) { + rows.push([w.options, w.optionsBefore, w.optionsAfter]); + } + return rows; +} + +function answerError(failure: unknown, reasons: Record, fallback: string): string { + const reason = failure instanceof RpcError ? failure.reason : undefined; + return reason && reason in reasons + ? (reasons[reason] as string) + : `${fallback}: ${errorText(failure)}`; +} + +export function RoutineChangeCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const t = useT(); + const w = t.chat.routineChange; + const d = t.routines.dialog; + const api = useApi(); + const asked = parsed(approval.input); + const before = asked.before ?? null; + const after = asked.after ?? null; + const [form, setForm] = useState(null); + const [enabled, setEnabled] = useState(after?.enabled ?? true); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const changes = useChanges(before ?? (after as Changed), after ?? (before as Changed)); + if (approval.status !== "pending" || !before || !after) { + return ; + } + const change = (patch: Partial) => + setForm((current) => (current ? { ...current, ...patch } : current)); + + const answer = async (allow: boolean) => { + setBusy(true); + setError(null); + const adjusted: Changed | null = form + ? { + routine_id: after.routine_id, + name: form.name, + prompt: form.prompt, + schedule: scheduleOf(form), + timezone: form.timezone, + overlap: form.overlap, + missed: form.missed, + enabled, + } + : null; + const trimmed = note.trim(); + try { + await api.call("approvals.answer", { + approvalId: approval.approvalId, + allow, + ...(!allow && trimmed ? { note: trimmed } : {}), + ...(allow && adjusted ? { input: JSON.stringify(adjusted) } : {}), + }); + } catch (failure) { + setError(answerError(failure, d.reasons, allow ? w.applyFailed : w.declineFailed)); + } + setBusy(false); + }; + + return ( + + + + answer(true)}> + {w.apply} + + answer(false)}> + {w.decline} + + + > + } + > + + {changes.length > 0 && ( + + {changes.map(([what, was, becomes]) => ( + + {what} + + {was} + {" → "} + {becomes} + + + ))} + + )} + {before.prompt !== after.prompt && ( + + {w.newPrompt} + + {after.prompt} + + + )} + {form ? ( + + change({ name: event.target.value })} + /> + change({ prompt: event.target.value })} + /> + + + + + ) : ( + setForm(formOf(after))} + > + {w.adjust} + + )} + {error && ( + + {error} + + )} + + + ); +} + +export function RoutineDeleteCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const t = useT(); + const w = t.chat.routineDelete; + const api = useApi(); + const asked = parsed<{ name: string; schedule: Schedule; reason: string | null }>(approval.input); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + if (approval.status !== "pending") { + 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); + }; + return ( + + + + answer(true)}> + {w.delete} + + answer(false)}> + {w.keep} + + + > + } + > + + {asked.schedule && ( + {describeSchedule(asked.schedule, t.routines.when)} + )} + {asked.reason && ( + + {w.reason(bot.name)} + {asked.reason} + + )} + {w.explain} + {error && ( + + {error} + + )} + + + ); +} + +function Answered({ approval, kind }: { approval: ApprovalItem; kind: "change" | "delete" }) { + const t = useT(); + const w = kind === "change" ? t.chat.routineChange : t.chat.routineDelete; + const name = parsed<{ name: string }>(approval.input).name ?? ""; + 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 ; +} diff --git a/app/src/features/routines/routineChange.test.tsx b/app/src/features/routines/routineChange.test.tsx new file mode 100644 index 00000000..5c7e9b47 --- /dev/null +++ b/app/src/features/routines/routineChange.test.tsx @@ -0,0 +1,111 @@ +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_ROUTINE_TOOL, DELETE_ROUTINE_TOOL } from "./RoutineChangeCard"; + +afterEach(cleanup); + +const BEFORE = { + routine_id: "rtn_1", + name: "Morning inbox", + prompt: "Read the new mail.", + schedule: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "08:00" }, + timezone: "America/Sao_Paulo", + overlap: "skip", + missed: "run_once", + enabled: true, +}; + +async function openMail() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Office"); + const mail = fake.addBot(ops.id, "Mail", "Reads the inbox"); + fake.setBotState(mail.id, "idle"); + renderApp(fake); + await crewOpened("Office"); + openBot("Mail"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, mail }; +} + +const answered = (fake: FakeBotloft) => + fake.calls.find((call) => call.method === "approvals.answer")?.params as + | { allow: boolean; input?: string; note?: string } + | undefined; + +describe("a bot changing or deleting its routine", () => { + test("the change shows before and after, and goes as asked", async () => { + const { fake, mail } = await openMail(); + const after = { + ...BEFORE, + name: "Inbox", + schedule: { kind: "interval", minutes: 120 }, + enabled: false, + }; + act(() => { + fake.chat.ask( + mail.id, + CHANGE_ROUTINE_TOOL, + "Morning inbox", + JSON.stringify({ name: "Morning inbox", before: BEFORE, after }), + ); + }); + const card = screen.getByRole("region", { + name: "Mail wants to change the routine Morning inbox", + }); + expect(within(card).getByText(/^Weekdays at 08:00/)).toBeDefined(); + expect(within(card).getByText("Every 2 hours")).toBeDefined(); + expect(within(card).getByText("Off")).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Change routine" })); + await waitFor(() => expect(answered(fake)).toMatchObject({ allow: true })); + expect(answered(fake)?.input).toBeUndefined(); + }); + + test("the owner adjusts the change before allowing it", async () => { + const { fake, mail } = await openMail(); + act(() => { + fake.chat.ask( + mail.id, + CHANGE_ROUTINE_TOOL, + "Morning inbox", + JSON.stringify({ + name: "Morning inbox", + before: BEFORE, + after: { ...BEFORE, enabled: false }, + }), + ); + }); + const card = screen.getByRole("region", { name: /wants to change/ }); + fireEvent.click(within(card).getByRole("button", { name: "Adjust before allowing" })); + fireEvent.change(within(card).getByLabelText("Name"), { target: { value: "Inbox at eight" } }); + fireEvent.click(within(card).getByRole("button", { name: "Change routine" })); + await waitFor(() => expect(answered(fake)?.input).toBeDefined()); + const input = JSON.parse(answered(fake)?.input as string); + expect(input).toMatchObject({ routine_id: "rtn_1", name: "Inbox at eight", enabled: false }); + }); + + test("a deletion shows why, and the owner may keep it with a note", async () => { + const { fake, mail } = await openMail(); + act(() => { + fake.chat.ask( + mail.id, + DELETE_ROUTINE_TOOL, + "Morning inbox", + JSON.stringify({ + routine_id: "rtn_1", + name: "Morning inbox", + schedule: BEFORE.schedule, + reason: "The client left.", + }), + ); + }); + const card = screen.getByRole("region", { + name: "Mail wants to delete the routine Morning inbox", + }); + expect(within(card).getByText("The client left.")).toBeDefined(); + fireEvent.change(within(card).getByRole("textbox"), { target: { value: "Keep it" } }); + fireEvent.click(within(card).getByRole("button", { name: "Keep it" })); + await waitFor(() => expect(answered(fake)).toMatchObject({ allow: false, note: "Keep it" })); + }); +}); diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index b2d066eb..f9975433 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -283,6 +283,42 @@ export const chat = { declined: (name: string) => `You said no to the routine ${name}`, expired: (name: string) => `The routine ${name} was not answered in time`, }, + /** A bot asking to change one of its routines (spec 20.12). */ + routineChange: { + title: (bot: string, name: string) => `${bot} wants to change the routine ${name}`, + timezone: "Time zone", + state: "On", + options: "More options", + optionsBefore: "as they were", + optionsAfter: "changed", + on: "On", + off: "Off", + newPrompt: "See the new request", + adjust: "Adjust before allowing", + apply: "Change routine", + 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)`, + applyFailed: "Could not change the routine", + declineFailed: "Could not send the answer", + done: (name: string) => `You changed the routine ${name}`, + declined: (name: string) => `You kept the routine ${name} as it was`, + expired: (name: string) => `The change to ${name} was not answered in time`, + }, + /** A bot asking to delete one of its routines (spec 20.12). */ + routineDelete: { + title: (bot: string, name: string) => `${bot} wants to delete the routine ${name}`, + reason: (bot: string) => `Why, says ${bot}:`, + explain: "Deleted, it will not run again. You can pause it in the Routines tab instead.", + delete: "Delete routine", + keep: "Keep it", + noteLabel: (bot: string) => `What to tell ${bot} if you keep it`, + notePlaceholder: (bot: string) => `If you keep it, tell ${bot} why (optional)`, + failed: "Could not send the answer", + done: (name: string) => `You deleted the routine ${name}`, + declined: (name: string) => `You kept the routine ${name}`, + expired: (name: string) => `Deleting ${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 97a6e568..bb706f5e 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -33,6 +33,9 @@ export const tools = { my_tasks: "See its tasks", suggest_bot: "Suggest a bot", schedule_routine: "Set up a routine", + my_routines: "See my routines", + change_routine: "Change a routine", + delete_routine: "Delete a routine", 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 d32c08f7..d06e6326 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -275,6 +275,40 @@ export const chat: Messages["chat"] = { declined: (name: string) => `Rechazaste la rutina ${name}`, expired: (name: string) => `La rutina ${name} no se respondió a tiempo`, }, + routineChange: { + title: (bot, name) => `${bot} quiere cambiar la rutina ${name}`, + timezone: "Zona horaria", + state: "Activa", + options: "Más opciones", + optionsBefore: "como estaban", + optionsAfter: "cambiadas", + on: "Activa", + off: "Desactivada", + newPrompt: "Ver la nueva petición", + adjust: "Ajustar antes de permitir", + apply: "Cambiar rutina", + decline: "Ahora no", + noteLabel: (bot) => `Qué decirle a ${bot} si te niegas`, + notePlaceholder: (bot) => `Si te niegas, dile a ${bot} por qué (opcional)`, + applyFailed: "No se pudo cambiar la rutina", + declineFailed: "No se pudo enviar la respuesta", + done: (name) => `Cambiaste la rutina ${name}`, + declined: (name) => `Dejaste la rutina ${name} como estaba`, + expired: (name) => `El cambio en ${name} quedó sin respuesta`, + }, + routineDelete: { + title: (bot, name) => `${bot} quiere borrar la rutina ${name}`, + reason: (bot) => `Por qué, según ${bot}:`, + explain: "Borrada, no vuelve a ejecutarse. Si prefieres, pausala en la pestaña Rutinas.", + delete: "Borrar rutina", + keep: "Mantener", + noteLabel: (bot) => `Qué decirle a ${bot} si la mantienes`, + notePlaceholder: (bot) => `Si la mantienes, dile a ${bot} por qué (opcional)`, + failed: "No se pudo enviar la respuesta", + done: (name) => `Borraste la rutina ${name}`, + declined: (name) => `Mantuviste la rutina ${name}`, + expired: (name) => `Borrar ${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 c562d89d..3f83135b 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -34,6 +34,9 @@ export const tools: Messages["tools"] = { my_tasks: "Ver sus tareas", suggest_bot: "Sugerir un bot", schedule_routine: "Crear una rutina", + my_routines: "Ver mis rutinas", + change_routine: "Cambiar una rutina", + delete_routine: "Borrar una rutina", 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 30b0efd2..320c08ac 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -272,6 +272,40 @@ export const chat: Messages["chat"] = { declined: (name: string) => `Você recusou a rotina ${name}`, expired: (name: string) => `A rotina ${name} não foi respondida a tempo`, }, + routineChange: { + title: (bot, name) => `${bot} quer mudar a rotina ${name}`, + timezone: "Fuso", + state: "Ligada", + options: "Mais opções", + optionsBefore: "como estavam", + optionsAfter: "mudadas", + on: "Ligada", + off: "Desligada", + newPrompt: "Ver o pedido novo", + adjust: "Ajustar antes de permitir", + apply: "Mudar rotina", + decline: "Agora não", + noteLabel: (bot) => `O que dizer a ${bot} se você recusar`, + notePlaceholder: (bot) => `Se recusar, diga a ${bot} por quê (opcional)`, + applyFailed: "Não foi possível mudar a rotina", + declineFailed: "Não foi possível mandar a resposta", + done: (name) => `Você mudou a rotina ${name}`, + declined: (name) => `Você manteve a rotina ${name} como estava`, + expired: (name) => `A mudança em ${name} ficou sem resposta`, + }, + routineDelete: { + title: (bot, name) => `${bot} quer apagar a rotina ${name}`, + reason: (bot) => `Por quê, segundo ${bot}:`, + explain: "Apagada, ela não roda mais. Se preferir, pause na aba Rotinas.", + delete: "Apagar rotina", + keep: "Manter", + noteLabel: (bot) => `O que dizer a ${bot} se você mantiver`, + notePlaceholder: (bot) => `Se mantiver, diga a ${bot} por quê (opcional)`, + failed: "Não foi possível mandar a resposta", + done: (name) => `Você apagou a rotina ${name}`, + declined: (name) => `Você manteve a rotina ${name}`, + expired: (name) => `Apagar ${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 c77f1552..6cc5ac43 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -33,6 +33,9 @@ export const tools: Messages["tools"] = { my_tasks: "Ver as próprias tarefas", suggest_bot: "Sugerir um bot", schedule_routine: "Criar uma rotina", + my_routines: "Ver minhas rotinas", + change_routine: "Mudar uma rotina", + delete_routine: "Apagar uma rotina", 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 f7ba588b..b2172882 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -16,6 +16,10 @@ pub const PLAN_TOOL: &str = "ExitPlanMode"; pub const SUGGEST_TOOL: &str = "mcp__botloft__suggest_bot"; /// A bot asking the owner for a routine (spec 20.12). pub const ROUTINE_TOOL: &str = "mcp__botloft__schedule_routine"; +/// A bot asking the owner to change one of its routines (spec 20.12). +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 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. @@ -60,7 +64,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 => SUGGESTION_INPUT_MAX, + SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL => SUGGESTION_INPUT_MAX, tool if is_command(tool) => COMMAND_INPUT_MAX, _ => TOOL_INPUT_MAX, } @@ -103,7 +107,9 @@ 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 => field(input, "name").map(str::to_owned), + SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL | DELETE_ROUTINE_TOOL => { + field(input, "name").map(str::to_owned) + } BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned), BROWSER_HELP_TOOL | "mcp__botloft__browser_ask_owner" => { field(input, "task").map(str::to_owned) diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index d63edd52..db43c1be 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -9,7 +9,9 @@ mod prompt; use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; -use botloft_core::chat::{ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, tool_summary}; +use botloft_core::chat::{ + CHANGE_ROUTINE_TOOL, ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, tool_summary, +}; use botloft_core::command::tool_explanation; use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; use botloft_core::protocol::{ @@ -159,6 +161,9 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult Some( crate::tools::check_changed_routine(daemon, &existing.approval.input, &input)?, ), + Some(input) if params.allow && existing.approval.tool_name == CHANGE_ROUTINE_TOOL => Some( + crate::tools::check_changed_routine_change(daemon, &existing.approval.input, &input)?, + ), _ => None, }; let Some(record) = settle(daemon, &existing, status, note.as_deref(), input.as_deref()) else { diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index 5ff17151..b77bf465 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -17,6 +17,7 @@ use tracing::warn; use super::Failure; use super::catalog::{COMPLETE_TASK, CREW_ROSTER, MY_TASKS, SEND_MESSAGE}; use super::question::{self, ASK_OWNER}; +use super::routine_change; use super::share::{self, SHARE_FILE}; use super::signal::{self, SEND_SIGNAL}; use crate::service::tasks::{self, BotMessage}; @@ -42,6 +43,7 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result parse(arguments).and_then(|args| share::share(daemon, bot, args)), ASK_OWNER => parse(arguments).and_then(|args| question::ask(daemon, bot, args)), SEND_SIGNAL => parse(arguments).and_then(|args| signal::send(daemon, bot, args)), + routine_change::MY_ROUTINES => routine_change::mine(daemon, bot), other => return Err(invalid(&format!("Unknown tool: {other}"))), }; Ok(tool_result(outcome)) diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index afb2d9ae..c52873af 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -30,12 +30,23 @@ pub fn tools() -> Value { super::signal::tool(), ] .into_iter() + .chain(super::routine_catalog::tools()) .chain(super::browser_catalog::tools()); list.splice(at..at, more); } tools } +/// The schedule's schema, as `schedule_routine` takes it, for the tools +/// that change a routine. +pub(super) fn schedule_schema() -> Value { + crew_tools() + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == SCHEDULE_ROUTINE)) + .map(|tool| tool["inputSchema"]["properties"]["schedule"].clone()) + .unwrap_or_default() +} + fn crew_tools() -> Value { json!([ { diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index 069b574c..51e040e0 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -17,6 +17,8 @@ mod catalog; mod era; mod question; mod routine; +mod routine_catalog; +mod routine_change; mod share; mod signal; mod suggest; @@ -34,6 +36,7 @@ use tracing::debug; use self::era::{Era, LEGACY, era, supported}; pub(crate) use self::routine::check_changed as check_changed_routine; +pub(crate) use self::routine_change::check_changed as check_changed_routine_change; use crate::approvals; use crate::rpc::jsonrpc::{self, Request}; use crate::state::Daemon; @@ -136,6 +139,16 @@ pub async fn handle( let result = routine::schedule(&daemon, &bot, generation, arguments(&request)).await; Ok(decorate(era, &request, result)) } + Ok(era) if called(&request) == Some(routine_change::CHANGE_ROUTINE) => { + let args = arguments(&request); + let result = routine_change::change(&daemon, &bot, generation, args).await; + Ok(decorate(era, &request, result)) + } + Ok(era) if called(&request) == Some(routine_change::DELETE_ROUTINE) => { + let args = arguments(&request); + let result = routine_change::delete(&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/tools/routine_catalog.rs b/crates/botloftd/src/tools/routine_catalog.rs new file mode 100644 index 00000000..b4649afa --- /dev/null +++ b/crates/botloftd/src/tools/routine_catalog.rs @@ -0,0 +1,76 @@ +//! The tools that let a bot see and change its own routines (spec 20.12), +//! as `tools/list` describes them. + +use serde_json::{Value, json}; + +use super::routine::PROMPT_MAX; + +pub const MY_ROUTINES: &str = "my_routines"; +pub const CHANGE_ROUTINE: &str = "change_routine"; +pub const DELETE_ROUTINE: &str = "delete_routine"; + +/// Longest reason a bot may give for deleting a routine, in characters. +pub(super) const REASON_MAX: usize = 500; + +pub(super) fn tools() -> [Value; 3] { + let id = json!({ + "type": "string", + "description": "The routine's id, as my_routines gives it.", + }); + [ + json!({ + "name": MY_ROUTINES, + "title": "My routines", + "description": "Lists your routines (work done at set times or on a signal): id, \ + name, prompt, schedule, time zone, whether it is on and when it runs next.", + "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }, + }), + json!({ + "name": CHANGE_ROUTINE, + "title": "Change a routine", + "description": "Asks the owner to change one of your routines: give its id and only \ + what changes (name, prompt, schedule, time zone, or enabled to turn it off or \ + on). The owner sees the change in your chat, may adjust it, and approves or \ + declines it; the call waits for that. Say it changed only after the tool says it \ + did.", + "inputSchema": { + "type": "object", + "properties": { + "routine_id": id, + "name": { "type": "string", "maxLength": 80 }, + "prompt": { "type": "string", "maxLength": PROMPT_MAX }, + "schedule": super::catalog::schedule_schema(), + "timezone": { + "type": "string", + "description": "IANA name, like America/Sao_Paulo.", + }, + "overlap": { "type": "string", "enum": ["skip", "queue"] }, + "missed": { "type": "string", "enum": ["run_once", "skip"] }, + "enabled": { "type": "boolean", "description": "false turns it off, true on." }, + }, + "required": ["routine_id"], + "additionalProperties": false, + }, + }), + json!({ + "name": DELETE_ROUTINE, + "title": "Delete a routine", + "description": "Asks the owner to delete one of your routines for good. The owner \ + approves or declines it in your chat; the call waits for that. To pause one \ + instead, use change_routine with enabled false.", + "inputSchema": { + "type": "object", + "properties": { + "routine_id": id, + "reason": { + "type": "string", + "maxLength": REASON_MAX, + "description": "For the owner: why it is no longer needed.", + }, + }, + "required": ["routine_id"], + "additionalProperties": false, + }, + }), + ] +} diff --git a/crates/botloftd/src/tools/routine_change.rs b/crates/botloftd/src/tools/routine_change.rs new file mode 100644 index 00000000..37281246 --- /dev/null +++ b/crates/botloftd/src/tools/routine_change.rs @@ -0,0 +1,307 @@ +//! `my_routines`, `change_routine` and `delete_routine` (spec 20.12): a bot +//! sees its own routines and asks the owner to change or delete one. The +//! owner answers in the bot's chat, and may adjust a change before allowing +//! it; the call waits, like `schedule_routine`. A bot that bypasses +//! permissions does it at once. + +use botloft_core::chat::{CHANGE_ROUTINE_TOOL, DELETE_ROUTINE_TOOL}; +use botloft_core::ids::{BotId, RoutineId}; +use botloft_core::protocol::{ + Missed, Overlap, PermissionMode, Routine, RoutineIdParams, RoutinesCreateParams, + RoutinesListParams, RoutinesSetEnabledParams, RoutinesUpdateParams, Schedule, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::calls::{explain, parse, tool_result}; +use super::routine::PROMPT_MAX; +use crate::approvals::{self, Answer}; +use crate::service::{ApiError, ApiResult, bots, routines}; +use crate::state::Daemon; + +pub(super) use super::routine_catalog::{CHANGE_ROUTINE, DELETE_ROUTINE, MY_ROUTINES, REASON_MAX}; + +/// What `change_routine` takes: the routine and only what changes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ChangeArgs { + routine_id: RoutineId, + name: Option, + prompt: Option, + schedule: Option, + timezone: Option, + overlap: Option, + missed: Option, + enabled: Option, +} + +/// The routine as it would be after the change: the request's input, and +/// what the owner sends back when they adjust it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Changed { + routine_id: RoutineId, + name: String, + prompt: String, + schedule: Schedule, + timezone: String, + overlap: Overlap, + missed: Missed, + enabled: bool, +} + +impl Changed { + fn of(routine: &Routine) -> Self { + Self { + routine_id: routine.id.clone(), + name: routine.name.clone(), + prompt: routine.prompt.clone(), + schedule: routine.schedule.clone(), + timezone: routine.timezone.clone(), + overlap: routine.overlap, + missed: routine.missed, + enabled: routine.enabled, + } + } + + fn checked(&self, daemon: &Daemon, bot: &BotId) -> ApiResult<()> { + if self.prompt.chars().count() > PROMPT_MAX { + return Err(ApiError::validation(format!( + "prompt must be at most {PROMPT_MAX} characters" + ))); + } + routines::check( + daemon, + &RoutinesCreateParams { + bot_id: bot.clone(), + name: self.name.clone(), + prompt: self.prompt.clone(), + schedule: self.schedule.clone(), + timezone: self.timezone.clone(), + overlap: Some(self.overlap), + missed: Some(self.missed), + }, + ) + } + + fn apply(self, daemon: &Daemon) -> ApiResult { + routines::update( + daemon, + RoutinesUpdateParams { + routine_id: self.routine_id.clone(), + name: Some(self.name), + prompt: Some(self.prompt), + schedule: Some(self.schedule), + timezone: Some(self.timezone), + overlap: Some(self.overlap), + missed: Some(self.missed), + }, + )?; + routines::set_enabled( + daemon, + RoutinesSetEnabledParams { + routine_id: self.routine_id, + enabled: self.enabled, + }, + ) + } +} + +/// The caller's routines, oldest first. +fn own(daemon: &Daemon, bot: &BotId) -> ApiResult> { + routines::list( + daemon, + RoutinesListParams { + bot_id: Some(bot.clone()), + }, + ) +} + +/// One of the caller's routines; another bot's is as good as missing. +fn own_one(daemon: &Daemon, bot: &BotId, id: &RoutineId) -> ApiResult { + own(daemon, bot)? + .into_iter() + .find(|routine| routine.id == *id) + .ok_or_else(|| { + ApiError::NotFound(format!( + "you have no routine {id}; call my_routines to see yours" + )) + }) +} + +fn bypasses(daemon: &Daemon, bot: &BotId) -> ApiResult { + Ok(bots::find(&daemon.store(), bot)?.permission_mode == PermissionMode::BypassPermissions) +} + +fn shown(routine: &Routine) -> Value { + json!({ + "routine_id": routine.id, + "name": routine.name, + "prompt": routine.prompt, + "schedule": routine.schedule, + "timezone": routine.timezone, + "enabled": routine.enabled, + "next_run": routines::next_run_text(routine), + }) +} + +pub(super) fn mine(daemon: &Daemon, bot: &BotId) -> Result { + own(daemon, bot) + .map(|list| json!({ "routines": list.iter().map(shown).collect::>() })) + .map_err(explain) +} + +fn declined(note: Option, what: &str) -> Value { + let why = note.map_or_else(String::new, |note| format!(" They said: {note}")); + json!({ + "done": false, + "note": format!("The owner declined to {what}.{why} Nothing changed: do not say it did."), + }) +} + +fn expired() -> Value { + json!({ + "done": false, + "note": "The owner did not answer in time. Nothing changed: tell the owner, and ask \ + again later if it still helps.", + }) +} + +pub(super) async fn change(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Value { + tool_result(run_change(daemon, bot, generation, args).await) +} + +async fn run_change( + daemon: &Daemon, + bot: &BotId, + generation: u64, + args: Value, +) -> Result { + let args: ChangeArgs = parse(args)?; + let routine = own_one(daemon, bot, &args.routine_id).map_err(explain)?; + let before = Changed::of(&routine); + let mut after = before.clone(); + after.name = args.name.unwrap_or(after.name); + after.prompt = args.prompt.unwrap_or(after.prompt); + after.schedule = args.schedule.unwrap_or(after.schedule); + after.timezone = args.timezone.unwrap_or(after.timezone); + after.overlap = args.overlap.unwrap_or(after.overlap); + after.missed = args.missed.unwrap_or(after.missed); + after.enabled = args.enabled.unwrap_or(after.enabled); + if after == before { + return Err("that is how the routine is already: nothing to change".to_owned()); + } + // Nothing impossible goes to the owner. + after.checked(daemon, bot).map_err(explain)?; + + if bypasses(daemon, bot).map_err(explain)? { + let changed = after.apply(daemon).map_err(explain)?; + return Ok(done( + &changed, + "You bypass permissions, so it changed without asking.", + )); + } + let input = json!({ "before": before, "after": after, "name": routine.name }); + match approvals::ask(daemon, bot, generation, CHANGE_ROUTINE_TOOL, &input, "").await { + Some(Answer::Allowed { input: None }) => { + let changed = after.apply(daemon).map_err(explain)?; + Ok(done(&changed, "The owner approved the change.")) + } + Some(Answer::Allowed { + input: Some(adjusted), + }) => { + let adjusted: Changed = serde_json::from_str(&adjusted) + .map_err(|err| format!("the owner's changes could not be read: {err}"))?; + let changed = adjusted.apply(daemon).map_err(explain)?; + Ok(done( + &changed, + "The owner adjusted the change before approving it: read the routine above.", + )) + } + Some(Answer::Denied { note }) => Ok(declined(note, "change this routine")), + Some(Answer::Expired) => Ok(expired()), + None => Err("Botloft could not ask the owner.".to_owned()), + } +} + +fn done(routine: &Routine, how: &str) -> Value { + let mut shown = shown(routine); + shown["done"] = json!(true); + shown["note"] = json!(format!("{how} The routine is now as above.")); + shown +} + +/// What `delete_routine` takes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeleteArgs { + routine_id: RoutineId, + reason: Option, +} + +pub(super) async fn delete(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Value { + tool_result(run_delete(daemon, bot, generation, args).await) +} + +async fn run_delete( + daemon: &Daemon, + bot: &BotId, + generation: u64, + args: Value, +) -> Result { + let args: DeleteArgs = 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 routine = own_one(daemon, bot, &args.routine_id).map_err(explain)?; + let gone = |how: &str| { + routines::archive( + daemon, + RoutineIdParams { + routine_id: routine.id.clone(), + }, + ) + .map(|_| { + json!({ + "done": true, + "note": format!("{how} The routine \"{}\" is deleted and will not run again.", routine.name), + }) + }) + .map_err(explain) + }; + if bypasses(daemon, bot).map_err(explain)? { + return gone("You bypass permissions, so it was deleted without asking."); + } + let input = json!({ + "routine_id": routine.id, + "name": routine.name, + "schedule": routine.schedule, + "reason": args.reason, + }); + match approvals::ask(daemon, bot, generation, DELETE_ROUTINE_TOOL, &input, "").await { + Some(Answer::Allowed { .. }) => gone("The owner approved it."), + Some(Answer::Denied { note }) => Ok(declined(note, "delete this routine")), + Some(Answer::Expired) => Ok(expired()), + None => Err("Botloft could not ask the owner.".to_owned()), + } +} + +/// The owner's adjustment of a change (`approvals.answer`), checked before +/// the request is settled so the card can say what is wrong. The routine +/// stays the one asked about. +pub(crate) fn check_changed(daemon: &Daemon, asked: &str, adjusted: &str) -> ApiResult { + let asked: Value = serde_json::from_str(asked) + .map_err(|_| ApiError::validation("the change asked for could not be read"))?; + let asked: Changed = serde_json::from_value(asked["after"].clone()) + .map_err(|_| ApiError::validation("the change asked for could not be read"))?; + let mut adjusted: Changed = serde_json::from_str(adjusted) + .map_err(|_| ApiError::validation("the adjusted routine is not valid"))?; + adjusted.routine_id = asked.routine_id; + // The bot is checked again when the change is made. + adjusted.checked(daemon, &BotId::generate())?; + serde_json::to_string(&adjusted).map_err(|err| ApiError::validation(err.to_string())) +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index a4936f08..19a65c8e 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -137,7 +137,9 @@ app; the other bots of your crew send you messages too. approves it in your chat and may change it; then it shows among your routines in the Botloft app. It is the only way to schedule: say it is scheduled only after the tool says it was created. With `bot`, the routine is for another bot of - your crew. + 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. - 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. @@ -288,6 +290,7 @@ mod tests { assert!(rules.contains("fill in its\n `description` for them")); assert!(rules.contains("`browser_*` tools")); assert!(rules.contains("`schedule_routine`")); + assert!(rules.contains("`change_routine` or `delete_routine`")); assert!(rules.contains(r#""#)); assert!(!rules.contains("You lead this crew")); diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index c374f015..6f3843d5 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -76,6 +76,9 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "share_file", "ask_owner", "send_signal", + "my_routines", + "change_routine", + "delete_routine", "browser_open", "browser_look", "browser_click", @@ -131,7 +134,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(21) + Some(24) ); 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/crates/botloftd/tests/routine_changes.rs b/crates/botloftd/tests/routine_changes.rs new file mode 100644 index 00000000..067cc6c5 --- /dev/null +++ b/crates/botloftd/tests/routine_changes.rs @@ -0,0 +1,164 @@ +//! Bots changing and deleting their own routines (spec 20.12): they see +//! theirs with `my_routines`, and `change_routine` and `delete_routine` +//! wait for the owner, who may adjust a change before allowing it. + +mod common; + +use common::bots::ready_bot; +use common::mcp::Mcp; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; + +struct Crew { + _t: TestDaemon, + app: Client, + bot: Value, + mcp: Mcp, + routine: Value, + other: Value, +} + +async fn crew() -> Crew { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Office" })) + .await + .expect("crew"); + let (bot, _, mcp) = ready_bot(&t, &mut app, &crew, "Mail").await; + let (writer, _, _) = ready_bot(&t, &mut app, &crew, "Writer").await; + let create = |bot: &Value, name: &str| { + json!({ + "botId": bot["id"], "name": name, "prompt": "Read the new mail.", + "schedule": { "kind": "weekly", "days": [1, 2, 3, 4, 5], "time": "08:00" }, + "timezone": "America/Sao_Paulo", + }) + }; + let routine = app + .call("routines.create", create(&bot, "Morning inbox")) + .await + .expect("routine"); + let other = app + .call("routines.create", create(&writer, "Weekly report")) + .await + .expect("other"); + Crew { + _t: t, + app, + bot, + mcp, + routine, + other, + } +} + +fn call(mcp: &Mcp, tool: &'static str, arguments: Value) -> JoinHandle> { + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + tokio::spawn(async move { mcp.tool(tool, arguments).await }) +} + +async fn pending(app: &mut Client, tool: &str) -> Value { + loop { + let changed = app.notification("chat.item").await; + let body = &changed["item"]["body"]; + if body["kind"] == "approval" && body["status"] == "pending" { + assert_eq!(body["toolName"], tool); + return body.clone(); + } + } +} + +async fn answer(app: &mut Client, asked: &Value, extra: Value) { + let mut params = json!({ "approvalId": asked["approvalId"] }); + for (key, value) in extra.as_object().expect("object") { + params[key] = value.clone(); + } + app.call("approvals.answer", params).await.expect("answer"); +} + +async fn routines(app: &mut Client, bot: &Value) -> Vec { + app.call("routines.list", json!({ "botId": bot["id"] })) + .await + .expect("routines") + .as_array() + .expect("list") + .clone() +} + +#[tokio::test] +async fn a_bot_sees_its_own_routines_only() { + let c = crew().await; + let mine = call(&c.mcp, "my_routines", json!({})) + .await + .expect("task") + .expect("list"); + let listed = mine["routines"].as_array().expect("routines"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0]["routine_id"], c.routine["id"]); + assert_eq!(listed[0]["enabled"], true); + + // Another bot's routine is as good as missing; no change is no request. + let theirs = json!({ "routine_id": c.other["id"], "enabled": false }); + let refused = call(&c.mcp, "change_routine", theirs).await.expect("task"); + assert!(refused.expect_err("not mine").contains("my_routines")); + let same = json!({ "routine_id": c.routine["id"], "name": "Morning inbox" }); + let refused = call(&c.mcp, "change_routine", same).await.expect("task"); + assert!(refused.expect_err("no change").contains("already")); +} + +#[tokio::test] +async fn the_owner_adjusts_a_change_before_allowing_it() { + let mut c = crew().await; + let change = json!({ "routine_id": c.routine["id"], "name": "Inbox", "enabled": false }); + let waiting = call(&c.mcp, "change_routine", change); + let asked = pending(&mut c.app, "mcp__botloft__change_routine").await; + assert_eq!(asked["summary"], "Morning inbox"); + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["before"]["enabled"], true); + assert_eq!(input["after"]["name"], "Inbox"); + + // The owner keeps it on, under another name. + let mut adjusted = input["after"].clone(); + adjusted["name"] = json!("Inbox at eight"); + adjusted["enabled"] = json!(true); + answer( + &mut c.app, + &asked, + json!({ "allow": true, "input": adjusted.to_string() }), + ) + .await; + let done = waiting.await.expect("task").expect("changed"); + assert_eq!(done["done"], true); + assert_eq!(done["name"], "Inbox at eight"); + let list = routines(&mut c.app, &c.bot).await; + assert_eq!(list[0]["name"], "Inbox at eight"); + assert_eq!(list[0]["enabled"], true); +} + +#[tokio::test] +async fn deleting_waits_for_the_owner_who_may_say_no() { + let mut c = crew().await; + let delete = json!({ "routine_id": c.routine["id"], "reason": "The client left." }); + let waiting = call(&c.mcp, "delete_routine", delete.clone()); + let asked = pending(&mut c.app, "mcp__botloft__delete_routine").await; + answer( + &mut c.app, + &asked, + json!({ "allow": false, "note": "Keep it" }), + ) + .await; + let kept = waiting.await.expect("task").expect("answer"); + assert_eq!(kept["done"], false); + assert!(kept["note"].as_str().expect("note").contains("Keep it")); + assert_eq!(routines(&mut c.app, &c.bot).await.len(), 1); + + let waiting = call(&c.mcp, "delete_routine", delete); + let asked = pending(&mut c.app, "mcp__botloft__delete_routine").await; + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["reason"], "The client left."); + answer(&mut c.app, &asked, json!({ "allow": true })).await; + let gone = waiting.await.expect("task").expect("deleted"); + assert_eq!(gone["done"], true); + assert!(routines(&mut c.app, &c.bot).await.is_empty()); +} diff --git a/docs/spec.md b/docs/spec.md index 4b49f001..16654d15 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1255,7 +1255,10 @@ Pedido para fazer algo em horário marcado ("confere o e-mail todo dia às 8"), - **Resposta ao bot:** criada, o id, o que ficou gravado (o dono pode ter mudado), o fuso e a próxima vez na hora local, e o lembrete de que ela está na aba Rotinas. Negada ou sem resposta, que nada foi agendado e que não deve dizer que foi. - **Bot em `bypass_permissions`:** cria na hora, sem cartão (13). - **Regras do bot** (5.1): trabalho em horário marcado é `schedule_routine`; é a única forma de agendar, e o bot só diz que agendou depois que a tool diz que criou. -- Mudar, pausar e apagar rotinas continua sendo só do dono, pelo app. +- **Mudar e apagar as próprias rotinas.** O bot vê as dele com `my_routines` (id, nome, pedido, quando, fuso, se está ligada e a próxima vez, sem pedir nada) e pede ao dono, que decide no chat: + - `change_routine {routine_id, ...}` com só o que muda (nome, pedido, `schedule`, fuso, `overlap`, `missed`, ou `enabled` para desligar ou ligar). O daemon confere, como em `routines.create`, e um pedido impossível ou sem mudança volta como erro, sem cartão. O cartão (`toolName: "mcp__botloft__change_routine"`, entrada `{name, before, after}`) mostra o que muda, antes riscado e depois, e o pedido novo dobrado; "Ajustar antes de permitir" abre os campos do editor (20.9) e o interruptor de ligada, já com o depois. "Mudar rotina" permite (com `input`, a rotina como o dono deixou, conferida antes de fechar); "Agora não" nega com nota. + - `delete_routine {routine_id, reason?}` (motivo até 500 caracteres). O cartão (`mcp__botloft__delete_routine`) mostra o nome, quando roda e o motivo; "Apagar rotina" arquiva, "Manter" nega com nota. + - Só rotinas do próprio bot: a de outro bot é tratada como inexistente. Respondido, o bot lê a rotina como ficou, ou que nada mudou e que não deve dizer que mudou. Bot em `bypass_permissions` muda e apaga na hora, sem cartão. Pausar continua no app também, como sempre. ### 20.13 Sinais entre bots
+ {after.prompt} +
+ {error} +
{describeSchedule(asked.schedule, t.routines.when)}
+ {w.reason(bot.name)} + {asked.reason} +
{w.explain}