From 43b3e1fc8b9bbb697026c421294a94d6097df4b3 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Mon, 5 Oct 2026 13:38:39 -0300
Subject: [PATCH] feat(tools): the chief can lower or raise a bot's model and
effort with the owner's OK
change_bot takes model and effort, and suggest_bot takes effort. The
roster shows each bot's model and effort, and the chief's rules ask it
to watch the cost and propose a cheaper model or effort for simple
work. The owner approves on the card, which shows them before and
after; the bot restarts on them once nothing is in progress.
Spec 10, 10.2, 10.3.
---
app/src/features/chat/BotChangeCard.tsx | 14 +++-
app/src/features/chat/SuggestionCard.tsx | 31 ++++++--
app/src/features/chat/botChange.test.tsx | 20 ++++-
app/src/i18n/en/chat.ts | 3 +-
app/src/i18n/es/chat.ts | 3 +-
app/src/i18n/pt-BR/chat.ts | 3 +-
crates/botloftd/src/tools/bot_change.rs | 95 ++++++++++++++++++++----
crates/botloftd/src/tools/calls.rs | 2 +
crates/botloftd/src/tools/catalog.rs | 7 ++
crates/botloftd/src/tools/suggest.rs | 50 +++++++++++--
crates/botloftd/src/workspace/files.rs | 5 ++
crates/botloftd/tests/bot_changes.rs | 40 ++++++++++
crates/botloftd/tests/crew_chief.rs | 28 +++++++
crates/botloftd/tests/tasks.rs | 3 +-
docs/spec.md | 14 ++--
15 files changed, 274 insertions(+), 44 deletions(-)
diff --git a/app/src/features/chat/BotChangeCard.tsx b/app/src/features/chat/BotChangeCard.tsx
index 983ca466..02fc4d94 100644
--- a/app/src/features/chat/BotChangeCard.tsx
+++ b/app/src/features/chat/BotChangeCard.tsx
@@ -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";
@@ -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 {
@@ -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 (
;
}
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 }));
@@ -91,18 +96,24 @@ export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot:
{asked.reason}
)}
-
-
set("name")(event.target.value)}
- />
+ set("name")(event.target.value)}
+ />
+
set("model")(event.target.value)}
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
/>
+ set("effort")(event.target.value)}
+ options={EFFORTS.map((value) => ({ value, label: effortName(value) }))}
+ />
{suggestion.role}
{s.model}
{t.chat.model.names[suggestion.model ?? "default"]}
+ {suggestion.effort && suggestion.effort !== "default" && (
+ <>
+ {t.chat.effort.title}
+ {t.chat.effort.names[suggestion.effort]}
+ >
+ )}
{s.instructions}
{suggestion.instructions}
diff --git a/app/src/features/chat/botChange.test.tsx b/app/src/features/chat/botChange.test.tsx
index a43c6f6d..fbc99884 100644
--- a/app/src/features/chat/botChange.test.tsx
+++ b/app/src/features/chat/botChange.test.tsx
@@ -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();
@@ -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();
diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts
index 72ded18d..546791bf 100644
--- a/app/src/i18n/en/chat.ts
+++ b/app/src/i18n/en/chat.ts
@@ -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`,
diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts
index 1565c5a9..5f2f6720 100644
--- a/app/src/i18n/es/chat.ts
+++ b/app/src/i18n/es/chat.ts
@@ -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`,
diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts
index e3d947ef..8854f686 100644
--- a/app/src/i18n/pt-BR/chat.ts
+++ b/app/src/i18n/pt-BR/chat.ts
@@ -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`,
diff --git a/crates/botloftd/src/tools/bot_change.rs b/crates/botloftd/src/tools/bot_change.rs
index f47e5186..effa78d6 100644
--- a/crates/botloftd/src/tools/bot_change.rs
+++ b/crates/botloftd/src/tools/bot_change.rs
@@ -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};
@@ -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";
@@ -27,6 +30,8 @@ struct Args {
name: Option,
role: Option,
instructions: Option,
+ model: Option,
+ effort: Option,
reason: Option,
}
@@ -36,6 +41,8 @@ struct Profile {
name: String,
role: String,
instructions: String,
+ model: BotModel,
+ effort: BotEffort,
}
impl Profile {
@@ -44,6 +51,8 @@ impl Profile {
name: record.name.clone(),
role: record.role.clone(),
instructions: record.instructions.clone(),
+ model: record.model,
+ effort: record.effort,
}
}
}
@@ -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": {
@@ -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,
@@ -144,11 +169,27 @@ fn after(daemon: &Daemon, record: &BotRecord, args: &Args) -> ApiResult
}
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 {
- 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 {
+ let mut bot = bots::update(
daemon,
BotsUpdateParams {
bot_id: id.clone(),
@@ -157,7 +198,26 @@ fn apply(daemon: &Daemon, id: &BotId, next: &Profile) -> ApiResult {
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(
@@ -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,
@@ -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 }) => {
@@ -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),
})
}
diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs
index 0799ec9e..3791653a 100644
--- a/crates/botloftd/src/tools/calls.rs
+++ b/crates/botloftd/src/tools/calls.rs
@@ -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),
})
})
diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs
index 923b353e..893ee37a 100644
--- a/crates/botloftd/src/tools/catalog.rs
+++ b/crates/botloftd/src/tools/catalog.rs
@@ -189,6 +189,13 @@ fn crew_tools() -> Value {
work; \"opus\" or \"fable\" for the hardest reasoning. The owner's \
plan default when absent.",
},
+ "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. Its model's own level \
+ when absent.",
+ },
"reason": {
"type": "string",
"maxLength": REASON_MAX,
diff --git a/crates/botloftd/src/tools/suggest.rs b/crates/botloftd/src/tools/suggest.rs
index 81887a37..2c01e907 100644
--- a/crates/botloftd/src/tools/suggest.rs
+++ b/crates/botloftd/src/tools/suggest.rs
@@ -5,14 +5,14 @@
use botloft_core::chat::SUGGEST_TOOL;
use botloft_core::ids::BotId;
-use botloft_core::protocol::{Bot, BotModel, PermissionMode};
+use botloft_core::protocol::{Bot, BotEffort, BotModel, BotsSetEffortParams, PermissionMode};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::calls::{explain, parse, tool_result};
use crate::approvals::{self, Answer};
use crate::service::bots::{self, NewBot};
-use crate::service::lead;
+use crate::service::{lead, models};
use crate::state::Daemon;
/// Longest instructions a chief may write for a new bot, in characters.
@@ -29,6 +29,8 @@ struct Suggestion {
instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ effort: Option,
reason: String,
}
@@ -55,7 +57,7 @@ async fn run(
.map_err(explain)?
.permission_mode;
if mode == PermissionMode::BypassPermissions {
- let created = lead::create_suggested(daemon, bot, new).map_err(explain)?;
+ let created = create(daemon, bot, new, &suggestion)?;
return Ok(created_note(
&created,
"You bypass permissions, so it was created without asking the owner.",
@@ -65,7 +67,7 @@ async fn run(
let input = serde_json::to_value(&suggestion).map_err(|err| err.to_string())?;
match approvals::ask(daemon, bot, generation, SUGGEST_TOOL, &input, "").await {
Some(Answer::Allowed { input: None }) => {
- let created = lead::create_suggested(daemon, bot, new).map_err(explain)?;
+ let created = create(daemon, bot, new, &suggestion)?;
Ok(created_note(&created, "The owner approved it."))
}
Some(Answer::Allowed {
@@ -73,11 +75,11 @@ async fn run(
}) => {
let changed: Suggestion = serde_json::from_str(&changed)
.map_err(|err| format!("the owner's changes could not be read: {err}"))?;
- let created = lead::create_suggested(daemon, bot, check(&changed)?).map_err(explain)?;
+ let created = create(daemon, bot, check(&changed)?, &changed)?;
Ok(created_note(
&created,
- "The owner changed the suggestion before approving it: read the name, role \
- and model above.",
+ "The owner changed the suggestion before approving it: read the name, role, \
+ model and effort above.",
))
}
Some(Answer::Denied { note }) => {
@@ -99,9 +101,42 @@ async fn run(
}
}
+/// Creates the bot, then sets the effort it was suggested with.
+fn create(
+ daemon: &Daemon,
+ lead_bot: &BotId,
+ new: NewBot,
+ suggestion: &Suggestion,
+) -> Result {
+ let effort = effort_of(suggestion)?;
+ let created = lead::create_suggested(daemon, lead_bot, new).map_err(explain)?;
+ if effort == BotEffort::Default {
+ return Ok(created);
+ }
+ models::set_effort(
+ daemon,
+ BotsSetEffortParams {
+ bot_id: created.id,
+ effort,
+ },
+ )
+ .map_err(explain)
+}
+
+fn effort_of(suggestion: &Suggestion) -> Result {
+ match suggestion.effort.as_deref() {
+ None => Ok(BotEffort::Default),
+ Some(effort) => effort.parse().map_err(|_| {
+ "effort must be \"default\", \"low\", \"medium\", \"high\", \"xhigh\" or \"max\""
+ .to_owned()
+ }),
+ }
+}
+
/// The suggestion as a new bot, with the limits of this tool on top of a
/// bot's own.
fn check(suggestion: &Suggestion) -> Result {
+ effort_of(suggestion)?;
if suggestion.instructions.chars().count() > INSTRUCTIONS_MAX {
return Err(format!(
"instructions must be at most {INSTRUCTIONS_MAX} characters"
@@ -136,6 +171,7 @@ fn created_note(bot: &Bot, how: &str) -> Value {
"name": bot.name,
"role": bot.role,
"model": bot.model,
+ "effort": bot.effort,
"note": format!(
"{how} @{} is starting now. Give it work with send_message(to: \"{}\", kind: \"task\").",
bot.handle, bot.handle
diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs
index 56e7622b..3f21962e 100644
--- a/crates/botloftd/src/workspace/files.rs
+++ b/crates/botloftd/src/workspace/files.rs
@@ -83,6 +83,10 @@ The owner made you the chief of this crew: its goal is in your instructions.
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.
+- Keep an eye on the cost: `crew_roster` shows each bot's model and effort.
+ When a bot's work turns out simpler than its model or effort (or harder),
+ propose a change with `change_bot` (`model`, `effort`) and say why; the
+ owner approves it. Simple, repetitive work: `haiku` and `low`.
- Tell the owner what each bot is doing, so they can follow and talk to each
one in its own chat.
";
@@ -330,5 +334,6 @@ mod tests {
assert!(rules.contains("## You lead this crew"));
assert!(rules.contains("`suggest_bot`"));
assert!(rules.contains("handle in `bot`"));
+ assert!(rules.contains("propose a change with `change_bot` (`model`, `effort`)"));
}
}
diff --git a/crates/botloftd/tests/bot_changes.rs b/crates/botloftd/tests/bot_changes.rs
index 57bb4e1a..80a15b76 100644
--- a/crates/botloftd/tests/bot_changes.rs
+++ b/crates/botloftd/tests/bot_changes.rs
@@ -190,3 +190,43 @@ async fn only_the_chief_names_another_bot_and_only_of_its_crew() {
.expect("task");
assert!(refused.expect_err("same").contains("already"));
}
+
+#[tokio::test]
+async fn the_chief_lowers_a_bots_model_and_effort_once_the_owner_allows_it() {
+ let mut c = crew().await;
+ let roster = c
+ .lead_mcp
+ .tool("crew_roster", json!({}))
+ .await
+ .expect("roster");
+ assert_eq!(roster["bots"][0]["model"], "default");
+ assert_eq!(roster["bots"][0]["effort"], "default");
+
+ let refused = call(&c.lead_mcp, json!({ "bot": "writer", "effort": "tiny" }))
+ .await
+ .expect("task");
+ assert!(refused.expect_err("bad effort").contains("effort must be"));
+
+ let waiting = call(
+ &c.lead_mcp,
+ json!({ "bot": "writer", "model": "haiku", "effort": "low", "reason": "Simple work." }),
+ );
+ let asked = pending(&mut c.app).await;
+ let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json");
+ assert_eq!(input["before"]["model"], "default");
+ assert_eq!(input["after"]["model"], "haiku");
+ assert_eq!(input["after"]["effort"], "low");
+ 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["model"], "haiku");
+ let now = bot(&mut c.app, &c.writer).await;
+ assert_eq!(now["model"], "haiku");
+ assert_eq!(now["effort"], "low");
+ assert_eq!(now["name"], "Writer");
+}
diff --git a/crates/botloftd/tests/crew_chief.rs b/crates/botloftd/tests/crew_chief.rs
index b265d954..1886162f 100644
--- a/crates/botloftd/tests/crew_chief.rs
+++ b/crates/botloftd/tests/crew_chief.rs
@@ -287,3 +287,31 @@ async fn the_owner_moves_the_chief_and_an_archived_chief_leaves_none() {
let crews = c.app.call("crews.list", json!({})).await.expect("crews");
assert_eq!(crews[0]["leadBotId"], Value::Null);
}
+
+#[tokio::test]
+async fn a_suggested_bot_starts_with_the_effort_suggested() {
+ let mut c = crew_with_chief().await;
+ let mut asked = designer();
+ asked["effort"] = json!("loud");
+ let refused = suggest(&c.mcp, asked.clone()).await.expect("task");
+ assert!(refused.expect_err("bad effort").contains("effort must be"));
+
+ asked["effort"] = json!("low");
+ let waiting = suggest(&c.mcp, asked);
+ let request = pending_suggestion(&mut c.app).await;
+ c.app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": request["approvalId"], "allow": true }),
+ )
+ .await
+ .expect("answer");
+ let created = waiting.await.expect("task").expect("created");
+ assert_eq!(created["effort"], "low");
+ let designer = crew_bots(&mut c)
+ .await
+ .into_iter()
+ .find(|bot| bot["name"] == "Designer")
+ .expect("designer");
+ assert_eq!(designer["effort"], "low");
+}
diff --git a/crates/botloftd/tests/tasks.rs b/crates/botloftd/tests/tasks.rs
index 56dca545..b5ce20da 100644
--- a/crates/botloftd/tests/tasks.rs
+++ b/crates/botloftd/tests/tasks.rs
@@ -185,7 +185,8 @@ async fn mistakes_come_back_as_tool_errors() {
assert_eq!(roster["you"], "lead");
assert_eq!(
roster["bots"],
- json!([{ "handle": "writer", "name": "Writer", "role": "Writer role", "state": "idle", "chief": false }])
+ json!([{ "handle": "writer", "name": "Writer", "role": "Writer role", "state": "idle",
+ "model": "default", "effort": "default", "chief": false }])
);
let err = |out: Result| out.expect_err("refused");
diff --git a/docs/spec.md b/docs/spec.md
index badc8d5a..81b635c1 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -563,15 +563,15 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão
| Tool | Entrada | Saída (JSON em texto) |
|---|---|---|
-| `crew_roster` | `crew?` (outra crew, 10.4) | `crew`, `you`, `you_lead`, os outros bots da crew (handle, nome, papel, estado e `chief`) e `signals`, os avisos que as rotinas da crew esperam (20.13). Com `crew`: o nome dela e só os bots que o bot alcança lá |
+| `crew_roster` | `crew?` (outra crew, 10.4) | `crew`, `you`, `you_lead`, os outros bots da crew (handle, nome, papel, estado, `model`, `effort` e `chief`) e `signals`, os avisos que as rotinas da crew esperam (20.13). Com `crew`: o nome dela e só os bots que o bot alcança lá |
| `send_message` | `to` (handle, com ou sem `@`), `crew?` (outra crew, 10.4), `body`, `kind?` (`note` padrão, `task`), `deadline_minutes?` (só task) | `message_id`, `task_id` e `due` (task), e um lembrete de que a resposta chega depois |
| `complete_task` | `task_id`, `result`, `status?` (`done` padrão, `failed`) | `task_id`, `status` e quem recebe o resultado |
| `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 |
+| `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `effort?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model`, `effort` 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) |
| `ask_crew_access` | `crew` (nome da outra crew), `bot?` (handle de um bot dela), `access` (`talk`, `read`, `edit`), `why` (até 1 000) | `allowed` e, permitido, como usar; recusado ou sem resposta, o porquê (10.4) |
| `crew_files`, `read_crew_file`, `write_crew_file` | `crew`, e `bot?`, `path` ou `path` e `content` | os arquivos de outra crew que o bot alcança, o texto de um, ou o aviso de que gravou (10.4) |
-| `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) |
+| `change_bot` | `bot?` (handle de outro bot da crew, só o chefe), `name?`, `role?`, `instructions?` (até 8 000 caracteres), `model?`, `effort?`, `reason?` (até 1 000) | `done` e, mudado, `handle`, `name`, `role`, `model`, `effort` 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) |
@@ -626,18 +626,18 @@ Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e e
- **Regras do chefe:** além das de todo bot, uma seção "You lead this crew": planejar, dividir o trabalho em partes que rodam em paralelo e distribuí-las com `send_message` (`kind: "task"`); conferir os resultados antes de responder ao dono; quando faltar um especialista, sugerir um bot com `suggest_bot`; escolher o modelo pelo trabalho (`haiku` para o simples e repetitivo, `sonnet` para quase tudo, `opus` ou `fable` só para o raciocínio mais difícil, lembrando que gastam mais do plano); manter a crew pequena; e contar ao dono o que cada bot faz, para ele acompanhar e falar com cada um no chat dele. Os outros bots leem que, se a crew precisar de outro bot, devem pedir ao chefe, que o `crew_roster` marca com `chief`.
- **`suggest_bot`:** aparece para todos os bots, mas só o chefe pode usar; os outros recebem um erro que manda pedir ao chefe. Antes de incomodar o dono, o daemon confere os campos (os de um bot, mais o limite de instruções e o porquê), que o nome está livre na crew e que a crew tem menos de `max_per_crew` bots ativos. Um pedido impossível volta como erro ao chefe.
- **Aprovação:** a sugestão vira um pedido no chat do chefe, pelo mesmo caminho das aprovações (10.1): `approval` com `toolName: "mcp__botloft__suggest_bot"`, entrada até 64 KB e resumo com o nome sugerido. O chefe fica `needs_approval`, e a chamada MCP espera a resposta até `approval_timeout_minutes`. A tool vem liberada por `--allowedTools mcp__botloft`, então o Claude Code não pede permissão antes: quem decide é o dono, pelo cartão.
-- **Resposta:** permitir cria o bot na crew do chefe, com modo Manual e o modelo sugerido. O dono pode mudar nome, papel, modelo e instruções antes: `approvals.answer` leva `input`, o JSON da sugestão como ele deixou, que substitui a entrada gravada (o cartão mostra o que foi criado); `input` só vale para sugestões de bot e pedidos de rotina (20.12). Negar volta ao chefe com a nota do dono, e nada é criado. Sem resposta no prazo, o chefe lê que pode sugerir de novo depois. O bot novo sobe na hora e aparece na barra lateral, com o próprio chat; o resultado da tool lembra o chefe de mandar a primeira task.
+- **Resposta:** permitir cria o bot na crew do chefe, com modo Manual e o modelo e o esforço sugeridos. O dono pode mudar nome, papel, modelo, esforço e instruções antes: `approvals.answer` leva `input`, o JSON da sugestão como ele deixou, que substitui a entrada gravada (o cartão mostra o que foi criado); `input` só vale para sugestões de bot e pedidos de rotina (20.12). Negar volta ao chefe com a nota do dono, e nada é criado. Sem resposta no prazo, o chefe lê que pode sugerir de novo depois. O bot novo sobe na hora e aparece na barra lateral, com o próprio chat; o resultado da tool lembra o chefe de mandar a primeira task.
- **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`.
+Quando o dono pede, um bot pode mudar o próprio nome, papel, instruções, modelo ou esforço (7.4), 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` e que cuide do custo: `crew_roster` mostra o modelo e o esforço de cada bot, e quando o trabalho de um bot se mostra mais simples (ou mais difícil) que eles, o chefe propõe a troca com o porquê (trabalho simples e repetitivo: `haiku` e `low`).
- **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.
+- **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, papel, modelo e esforço antes e depois (o esforço `default` aparece como "Recomendado"), 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; modelo e esforço passam por `bots.setModel` e `bots.setEffort`, e o bot reinicia neles quando nada estiver em andamento (7.4). Recusado ou sem resposta, nada muda, e o resultado diz isso ao bot. Quem chama em `bypass_permissions` muda sem perguntar.
### 10.4 Bots que alcançam outra crew