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
14 changes: 13 additions & 1 deletion app/src/features/chat/BotChangeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Ban, Check, TimerOff, UserPen } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import type { ApprovalItem, Bot, BotEffort, BotModel } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";
Expand All @@ -17,6 +17,9 @@ interface Profile {
name: string;
role: string;
instructions: string;
/** Absent in a request from before models and efforts could change. */
model?: BotModel;
effort?: BotEffort;
}

interface Asked {
Expand Down Expand Up @@ -78,6 +81,15 @@ export function BotChangeCard({ approval, bot }: { approval: ApprovalItem; bot:
const rows: [string, string, string][] = [];
if (before.name !== after.name) rows.push([d.name, before.name, after.name]);
if (before.role !== after.role) rows.push([d.role, before.role, after.role]);
const model = (name: BotModel) => t.chat.model.names[name];
const effort = (level: BotEffort) =>
level === "default" ? t.chat.effort.recommended : t.chat.effort.names[level];
if (before.model && after.model && before.model !== after.model) {
rows.push([t.chat.model.title, model(before.model), model(after.model)]);
}
if (before.effort && after.effort && before.effort !== after.effort) {
rows.push([t.chat.effort.title, effort(before.effort), effort(after.effort)]);
}
const self = asked.bot_id === bot.id;
return (
<SectionCard
Expand Down
31 changes: 24 additions & 7 deletions app/src/features/chat/SuggestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { Ban, Check, TimerOff, UserPlus } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot, BotModel } from "../../lib/protocol.gen";
import type { ApprovalItem, Bot, BotEffort, BotModel } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";
import { SelectField, TextArea, TextField } from "../../ui/Field";
Expand All @@ -14,12 +14,14 @@ import { useAnswer } from "./useAnswer";
export const SUGGEST_TOOL = "mcp__botloft__suggest_bot";

const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];
const EFFORTS: BotEffort[] = ["default", "low", "medium", "high", "xhigh", "max"];

interface Suggestion {
name: string;
role: string;
instructions: string;
model?: BotModel;
effort?: BotEffort;
reason: string;
}

Expand All @@ -32,6 +34,7 @@ export function suggestionOf(input: string): Suggestion {
role: parsed.role ?? "",
instructions: parsed.instructions ?? "",
...(parsed.model && { model: parsed.model }),
...(parsed.effort && { effort: parsed.effort }),
reason: parsed.reason ?? "",
};
} catch {
Expand All @@ -52,6 +55,8 @@ export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot:
return <Answered approval={approval} />;
}
const changed = JSON.stringify(edited) !== JSON.stringify(asked);
const effortName = (level: BotEffort) =>
level === "default" ? t.chat.effort.recommended : t.chat.effort.names[level];
const set = (field: keyof Suggestion) => (value: string) =>
setEdited((current) => ({ ...current, [field]: value }));

Expand Down Expand Up @@ -91,18 +96,24 @@ export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot:
{asked.reason}
</p>
)}
<div className="grid grid-cols-[1fr_12rem] gap-3">
<TextField
label={s.name}
value={edited.name}
onChange={(event) => set("name")(event.target.value)}
/>
<TextField
label={s.name}
value={edited.name}
onChange={(event) => set("name")(event.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<SelectField
label={s.model}
value={edited.model ?? "default"}
onChange={(event) => set("model")(event.target.value)}
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
/>
<SelectField
label={t.chat.effort.title}
value={edited.effort ?? "default"}
onChange={(event) => set("effort")(event.target.value)}
options={EFFORTS.map((value) => ({ value, label: effortName(value) }))}
/>
</div>
<TextField
label={s.role}
Expand Down Expand Up @@ -145,6 +156,12 @@ function Answered({ approval }: { approval: ApprovalItem }) {
<dd>{suggestion.role}</dd>
<dt className="text-muted">{s.model}</dt>
<dd>{t.chat.model.names[suggestion.model ?? "default"]}</dd>
{suggestion.effort && suggestion.effort !== "default" && (
<>
<dt className="text-muted">{t.chat.effort.title}</dt>
<dd>{t.chat.effort.names[suggestion.effort]}</dd>
</>
)}
<dt className="text-muted">{s.instructions}</dt>
<dd className="whitespace-pre-wrap">{suggestion.instructions}</dd>
</dl>
Expand Down
20 changes: 18 additions & 2 deletions app/src/features/chat/botChange.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { CHANGE_BOT_TOOL } from "./BotChangeCard";

afterEach(cleanup);

const WRITER = { name: "Writer", role: "Writes posts", instructions: "Short sentences." };
const WRITER = {
name: "Writer",
role: "Writes posts",
instructions: "Short sentences.",
model: "sonnet",
effort: "default",
};

async function openChief() {
const fake = new FakeBotloft();
Expand Down Expand Up @@ -37,13 +43,23 @@ describe("a bot asking to change a bot", () => {
bot_id: "bot_writer",
name: "Writer",
before: WRITER,
after: { ...WRITER, name: "Editor", instructions: "Edit, do not write." },
after: {
...WRITER,
name: "Editor",
instructions: "Edit, do not write.",
model: "haiku",
effort: "low",
},
reason: "The owner asked for it.",
}),
);
});
const card = screen.getByRole("region", { name: "Chief wants to change Writer" });
expect(within(card).getByText("Editor")).toBeDefined();
expect(within(card).getByText("Sonnet")).toBeDefined();
expect(within(card).getByText("Haiku")).toBeDefined();
expect(within(card).getByText("Recommended")).toBeDefined();
expect(within(card).getByText("Low")).toBeDefined();
expect(within(card).queryByText("Writes posts")).toBeNull();
expect(within(card).getByText("See the new instructions")).toBeDefined();
expect(within(card).getByText("The owner asked for it.")).toBeDefined();
Expand Down
3 changes: 2 additions & 1 deletion app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,8 @@ export const chat = {
titleOther: (bot: string, other: string) => `${bot} wants to change ${other}`,
reason: (bot: string) => `Why, says ${bot}:`,
newInstructions: "See the new instructions",
explain: "The new role and instructions count from the bot's next start.",
explain:
"The new role, instructions, model and effort count from the bot's next start, once it finishes what it's doing.",
apply: "Change",
decline: "Not now",
noteLabel: (bot: string) => `What to tell ${bot} if you say no`,
Expand Down
3 changes: 2 additions & 1 deletion app/src/i18n/es/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ export const chat: Messages["chat"] = {
titleOther: (bot, other) => `${bot} quiere cambiar a ${other}`,
reason: (bot) => `Por qué, según ${bot}:`,
newInstructions: "Ver las instrucciones nuevas",
explain: "El rol y las instrucciones nuevas cuentan desde el próximo inicio del bot.",
explain:
"El rol, las instrucciones, el modelo y el esfuerzo nuevos cuentan desde el próximo inicio del bot, cuando termine lo que está haciendo.",
apply: "Cambiar",
decline: "Ahora no",
noteLabel: (bot) => `Qué decirle a ${bot} si dices que no`,
Expand Down
3 changes: 2 additions & 1 deletion app/src/i18n/pt-BR/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,8 @@ export const chat: Messages["chat"] = {
titleOther: (bot, other) => `${bot} quer mudar ${other}`,
reason: (bot) => `Por quê, segundo ${bot}:`,
newInstructions: "Ver as instruções novas",
explain: "A função e as instruções novas valem a partir do próximo início do bot.",
explain:
"A função, as instruções, o modelo e o esforço novos valem a partir do próximo início do bot, quando ele terminar o que está fazendo.",
apply: "Mudar",
decline: "Agora não",
noteLabel: (bot) => `O que dizer a ${bot} se você recusar`,
Expand Down
95 changes: 79 additions & 16 deletions crates/botloftd/src/tools/bot_change.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
//! `change_bot` (spec 10.3): a bot asks the owner to rename itself or change
//! its role or instructions, and the crew's chief may ask the same for any
//! bot of its crew. Only bots of the caller's crew can be named. The owner
//! its role, instructions, model or effort, and the crew's chief may ask the
//! same for any bot of its crew. Only bots of the caller's crew can be named. The owner
//! approves or declines in the caller's chat and the call waits, like
//! `change_routine`; a bot that bypasses permissions does it at once.

use botloft_core::chat::CHANGE_BOT_TOOL;
use botloft_core::ids::BotId;
use botloft_core::protocol::{Bot, BotsUpdateParams, PermissionMode};
use botloft_core::protocol::{
Bot, BotEffort, BotModel, BotsSetEffortParams, BotsSetModelParams, BotsUpdateParams,
PermissionMode,
};
use botloft_core::{slug, validate};
use botloft_store::BotRecord;
use serde::{Deserialize, Serialize};
Expand All @@ -15,7 +18,7 @@ use serde_json::{Value, json};
use super::calls::{explain, parse, tool_result};
use super::suggest::{INSTRUCTIONS_MAX, REASON_MAX};
use crate::approvals::{self, Answer};
use crate::service::{ApiError, ApiResult, bots, lead};
use crate::service::{ApiError, ApiResult, bots, lead, models};
use crate::state::Daemon;

pub const CHANGE_BOT: &str = "change_bot";
Expand All @@ -27,6 +30,8 @@ struct Args {
name: Option<String>,
role: Option<String>,
instructions: Option<String>,
model: Option<String>,
effort: Option<String>,
reason: Option<String>,
}

Expand All @@ -36,6 +41,8 @@ struct Profile {
name: String,
role: String,
instructions: String,
model: BotModel,
effort: BotEffort,
}

impl Profile {
Expand All @@ -44,6 +51,8 @@ impl Profile {
name: record.name.clone(),
role: record.role.clone(),
instructions: record.instructions.clone(),
model: record.model,
effort: record.effort,
}
}
}
Expand All @@ -52,10 +61,12 @@ pub(super) fn tool() -> Value {
json!({
"name": CHANGE_BOT,
"title": "Change a bot",
"description": "Asks the owner to rename you or change your role or instructions, \
when the owner wants that. The crew's chief may name another bot of its crew with \
`bot`. Give only what changes. The owner approves or declines it in your chat; the \
call waits for that. Say it changed only after the tool says it did.",
"description": "Asks the owner to rename you or change your role, instructions, model \
or effort, when the owner wants that. The crew's chief may name another bot of its \
crew with `bot`, and may propose a cheaper or stronger model or effort for a bot by \
the work it does (crew_roster shows each bot's). Give only what changes. The owner \
approves or declines it in your chat; the call waits for that. Say it changed only \
after the tool says it did.",
"inputSchema": {
"type": "object",
"properties": {
Expand All @@ -80,6 +91,20 @@ pub(super) fn tool() -> Value {
"description": "The whole new instructions, written to the bot. They \
replace the old ones.",
},
"model": {
"type": "string",
"enum": ["default", "fable", "opus", "sonnet", "haiku"],
"description": "\"haiku\" for simple, repetitive work; \"sonnet\" for most \
work; \"opus\" or \"fable\" for the hardest reasoning; \"default\" for \
the owner's plan default.",
},
"effort": {
"type": "string",
"enum": ["default", "low", "medium", "high", "xhigh", "max"],
"description": "How much the bot thinks before it answers: \"low\" for \
quick, simple work, higher for hard problems; \"default\" for its \
model's own level. Higher uses more of the owner's plan.",
},
"reason": {
"type": "string",
"maxLength": REASON_MAX,
Expand Down Expand Up @@ -144,11 +169,27 @@ fn after(daemon: &Daemon, record: &BotRecord, args: &Args) -> ApiResult<Profile>
}
next.instructions = validate::instructions(instructions)?;
}
if let Some(model) = &args.model {
next.model = model.parse().map_err(|_| {
ApiError::validation(
"model must be \"default\", \"fable\", \"opus\", \"sonnet\" or \"haiku\"",
)
})?;
}
if let Some(effort) = &args.effort {
next.effort = effort.parse().map_err(|_| {
ApiError::validation(
"effort must be \"default\", \"low\", \"medium\", \"high\", \"xhigh\" or \"max\"",
)
})?;
}
Ok(next)
}

fn apply(daemon: &Daemon, id: &BotId, next: &Profile) -> ApiResult<Bot> {
bots::update(
/// Makes the change. A new model or effort restarts the bot once nothing is
/// in progress (spec 7.4).
fn apply(daemon: &Daemon, id: &BotId, before: &Profile, next: &Profile) -> ApiResult<Bot> {
let mut bot = bots::update(
daemon,
BotsUpdateParams {
bot_id: id.clone(),
Expand All @@ -157,7 +198,26 @@ fn apply(daemon: &Daemon, id: &BotId, next: &Profile) -> ApiResult<Bot> {
instructions: Some(next.instructions.clone()),
color: None,
},
)
)?;
if next.model != before.model {
bot = models::set_model(
daemon,
BotsSetModelParams {
bot_id: id.clone(),
model: next.model,
},
)?;
}
if next.effort != before.effort {
bot = models::set_effort(
daemon,
BotsSetEffortParams {
bot_id: id.clone(),
effort: next.effort,
},
)?;
}
Ok(bot)
}

async fn run(
Expand Down Expand Up @@ -185,7 +245,7 @@ async fn run(
.map_err(explain)?
.permission_mode;
if mode == PermissionMode::BypassPermissions {
let bot = apply(daemon, &record.id, &next).map_err(explain)?;
let bot = apply(daemon, &record.id, &before, &next).map_err(explain)?;
return Ok(done(
&bot,
caller,
Expand All @@ -204,7 +264,7 @@ async fn run(
Some(Answer::Allowed { .. }) => {
// Checked again: the crew may have changed while the owner read it.
let next = after(daemon, &record, &args).map_err(explain)?;
let bot = apply(daemon, &record.id, &next).map_err(explain)?;
let bot = apply(daemon, &record.id, &before, &next).map_err(explain)?;
Ok(done(&bot, caller, "The owner approved the change."))
}
Some(Answer::Denied { note }) => {
Expand All @@ -225,16 +285,19 @@ async fn run(

fn done(bot: &Bot, caller: &BotId, how: &str) -> Value {
let when = if bot.id == *caller {
"Your new instructions and role take effect when you next start; your name and \
handle already changed."
"Your new instructions, role, model and effort take effect when you next start, once \
this turn ends; your name and handle already changed."
} else {
"The bot reads its new instructions and role when it next starts."
"The bot runs with its new instructions, role, model and effort when it next starts, \
once nothing is in progress."
};
json!({
"done": true,
"handle": bot.handle,
"name": bot.name,
"role": bot.role,
"model": bot.model,
"effort": bot.effort,
"note": format!("{how} The bot is now @{} ({}). {when}", bot.handle, bot.name),
})
}
2 changes: 2 additions & 0 deletions crates/botloftd/src/tools/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ fn roster(daemon: &Daemon, bot: &BotId) -> Outcome {
"name": other.name,
"role": other.role,
"state": other.state,
"model": other.model,
"effort": other.effort,
"chief": lead::is_lead(&crew, &other.id),
})
})
Expand Down
Loading
Loading