Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/features/chat/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -73,6 +74,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
return <RoutineChangeCard approval={approval} bot={bot} />;
case DELETE_ROUTINE_TOOL:
return <RoutineDeleteCard approval={approval} bot={bot} />;
case CHANGE_BOT_TOOL:
return <BotChangeCard approval={approval} bot={bot} />;
case SITE_TOOL:
return <SiteCard approval={approval} bot={bot} />;
case DESKTOP_TOOL:
Expand Down
144 changes: 144 additions & 0 deletions app/src/features/chat/BotChangeCard.tsx
Original file line number Diff line number Diff line change
@@ -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<Asked> {
try {
return JSON.parse(input) as Partial<Asked>;
} 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 <SettledLine icon={icon} tone={tone} text={text} note={approval.note} />;
}

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<string | null>(null);
const { before, after } = asked;
if (approval.status !== "pending" || !before || !after) {
return <Answered approval={approval} name={name} />;
}
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 (
<SectionCard
label={self ? w.titleSelf(bot.name) : w.titleOther(bot.name, name)}
icon={UserPen}
tone="accent"
footer={
<>
<NoteArea
label={w.noteLabel(bot.name)}
value={note}
onChange={setNote}
placeholder={w.notePlaceholder(bot.name)}
/>
<div className="mt-2.5 flex flex-wrap gap-2">
<Button variant="primary" icon={Check} disabled={busy} onClick={() => answer(true)}>
{w.apply}
</Button>
<Button icon={Ban} disabled={busy} onClick={() => answer(false)}>
{w.decline}
</Button>
</div>
</>
}
>
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
{rows.length > 0 && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
{rows.map(([what, was, becomes]) => (
<div key={what} className="contents">
<dt className="text-muted">{what}</dt>
<dd className="min-w-0 break-words">
<span className="text-muted line-through">{was}</span>
{" → "}
<span className="font-medium">{becomes}</span>
</dd>
</div>
))}
</dl>
)}
{before.instructions !== after.instructions && (
<details className="text-xs">
<summary className="cursor-pointer text-muted">{w.newInstructions}</summary>
<p className="mt-1 whitespace-pre-wrap text-ink-soft" data-selectable>
{after.instructions}
</p>
</details>
)}
{asked.reason && (
<p>
<span className="text-muted">{w.reason(bot.name)} </span>
<span data-selectable>{asked.reason}</span>
</p>
)}
<p className="text-muted text-xs">{w.explain}</p>
{error && (
<p role="alert" className="text-danger text-sm">
{error}
</p>
)}
</div>
</SectionCard>
);
}
2 changes: 2 additions & 0 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Monitor,
Send,
SquareTerminal,
UserPen,
UserPlus,
Users,
Wrench,
Expand Down Expand Up @@ -56,6 +57,7 @@ const ICONS: Record<string, LucideIcon> = {
Agent: Users,
send_message: Send,
suggest_bot: UserPlus,
change_bot: UserPen,
schedule_routine: AlarmClock,
my_routines: AlarmClock,
change_routine: AlarmClock,
Expand Down
77 changes: 77 additions & 0 deletions app/src/features/chat/botChange.test.tsx
Original file line number Diff line number Diff line change
@@ -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" }),
);
});
});
16 changes: 16 additions & 0 deletions app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/en/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions app/src/i18n/es/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/es/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions app/src/i18n/pt-BR/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/pt-BR/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions crates/botloft-core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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" => {
Expand Down
Loading
Loading