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
41 changes: 33 additions & 8 deletions app/src/features/bots/BotDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import {
type AgentKind,
AVATAR_PALETTE,
type Bot,
type BotModel,
Expand All @@ -17,6 +18,8 @@ import { BotAvatar } from "./BotAvatar";
import { ColorPicker } from "./ColorPicker";

const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];
/** Stable, for a selector: a new array each time would render in a loop. */
const CLAUDE_ONLY: AgentKind[] = ["claude"];

type Props = { onClose(): void } & ({ crewId: CrewId; bot?: undefined } | { bot: Bot });

Expand All @@ -34,6 +37,10 @@ export function BotDialog(props: Props) {
const custom = color !== undefined && !(AVATAR_PALETTE as readonly string[]).includes(color);
const [picking, setPicking] = useState(custom);
const [model, setModel] = useState<BotModel>(editing?.model ?? "default");
// Which agent runs the bot is chosen once, when it is made (spec 30).
const agents = useApp((state) => state.system?.enabledAgents ?? CLAUDE_ONLY);
const [agent, setAgent] = useState<AgentKind>("claude");
const runsOn: AgentKind = editing?.agent ?? agent;
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

Expand Down Expand Up @@ -62,7 +69,8 @@ export function BotDialog(props: Props) {
role,
instructions,
...(color !== undefined && { color }),
...(model !== "default" && { model }),
...(runsOn === "claude" && model !== "default" && { model }),
...(runsOn !== "claude" && { agent: runsOn }),
});
}
putBot(bot);
Expand Down Expand Up @@ -118,13 +126,30 @@ export function BotDialog(props: Props) {
placeholder={t.bots.dialog.instructionsPlaceholder}
hint={editing ? t.bots.dialog.instructionsHint : undefined}
/>
<SelectField
label={t.chat.model.title}
value={model}
onChange={(event) => setModel(event.target.value as BotModel)}
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
hint={t.chat.model.cost}
/>
{!editing && agents.length > 1 && (
<div className="flex flex-col gap-2">
<SelectField
label={t.bots.dialog.agent.title}
value={agent}
onChange={(event) => setAgent(event.target.value as AgentKind)}
options={agents.map((value) => ({ value, label: t.bots.dialog.agent.names[value] }))}
/>
{agent !== "claude" && (
<p role="note" className="rounded-lg bg-sunken p-2.5 text-ink-soft text-xs">
{t.bots.dialog.agent.experimental}
</p>
)}
</div>
)}
{runsOn === "claude" && (
<SelectField
label={t.chat.model.title}
value={model}
onChange={(event) => setModel(event.target.value as BotModel)}
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
hint={t.chat.model.cost}
/>
)}
<fieldset className="flex flex-col gap-1.5">
<legend className="mb-1.5 font-medium text-ink-soft text-sm">
{t.bots.dialog.color}
Expand Down
32 changes: 32 additions & 0 deletions app/src/features/bots/agentPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, renderApp } from "../../test/app";

afterEach(cleanup);

async function openNewBot(agents: ("claude" | "agy")[]) {
const fake = new FakeBotloft();
fake.system = { ...fake.system, enabledAgents: agents };
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");
const [button] = await screen.findAllByRole("button", { name: "New bot" });
fireEvent.click(button as HTMLElement);
return within(await screen.findByRole("dialog", { name: "New bot" }));
}

describe("choosing the agent of a new bot", () => {
test("with only Claude Code there is no choice to make", async () => {
const dialog = await openNewBot(["claude"]);
expect(dialog.queryByLabelText("Agent")).toBeNull();
expect(dialog.getByLabelText("Model")).toBeDefined();
});

test("an experimental agent comes with its warning and no model choice", async () => {
const dialog = await openNewBot(["claude", "agy"]);
fireEvent.change(dialog.getByLabelText("Agent"), { target: { value: "agy" } });
expect(dialog.getByRole("note").textContent).toMatch(/cannot ask you/);
expect(dialog.queryByLabelText("Model")).toBeNull();
});
});
11 changes: 8 additions & 3 deletions app/src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,14 @@ export const ChatComposer = memo(function ChatComposer({
: text && (enterSends ? t.chat.composer.keys : t.chat.composer.keysWithCtrl)}
</span>
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1">
<ContextMeter bot={bot} onLater={setLater} />
<EffortPicker bot={bot} onLater={setLater} />
<ModelPicker bot={bot} onLater={setLater} />
{/* Claude Code's own: other agents report none of it (spec 30). */}
{bot.agent === "claude" && (
<>
<ContextMeter bot={bot} onLater={setLater} />
<EffortPicker bot={bot} onLater={setLater} />
<ModelPicker bot={bot} onLater={setLater} />
</>
)}
<button
type="submit"
aria-label={t.chat.composer.send}
Expand Down
10 changes: 10 additions & 0 deletions app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ export const bots = {
instructions: "Instructions",
instructionsPlaceholder: "How this bot works, what it may do on its own and when to ask.",
instructionsHint: "Saved to the bot's rules now; the bot reads them the next time it starts.",
agent: {
title: "Agent",
names: {
claude: "Claude Code",
agy: "Antigravity (experimental)",
codex: "Codex",
},
experimental:
"Experimental. This bot cannot ask you before it acts. It can work with files in its own folders and use the crew's tools, but it cannot run commands, and it has no usage meter or model choice.",
},
color: "Color",
swatch: (color: string) => `Color ${color}`,
colorUnset: "Left unset, the bot gets the crew's next color.",
Expand Down
10 changes: 10 additions & 0 deletions app/src/i18n/es/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ export const bots: Messages["bots"] = {
"Cómo trabaja este bot, qué puede hacer por su cuenta y cuándo debe preguntar.",
instructionsHint:
"Se guardan ahora en las reglas del bot; el bot las lee la próxima vez que se inicie.",
agent: {
title: "Agente",
names: {
claude: "Claude Code",
agy: "Antigravity (experimental)",
codex: "Codex",
},
experimental:
"Experimental. Este bot no puede preguntarte antes de actuar. Trabaja con archivos de sus propias carpetas y usa las herramientas del equipo, pero no ejecuta comandos, y no tiene medidor de uso ni elección de modelo.",
},
color: "Color",
swatch: (color: string) => `Color ${color}`,
colorUnset: "Si no eliges uno, el bot recibe el siguiente color del equipo.",
Expand Down
10 changes: 10 additions & 0 deletions app/src/i18n/pt-BR/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ export const bots: Messages["bots"] = {
instructionsPlaceholder:
"Como este bot trabalha, o que ele pode fazer sozinho e quando deve perguntar.",
instructionsHint: "Salvas agora nas regras do bot; ele as lê na próxima vez que iniciar.",
agent: {
title: "Agente",
names: {
claude: "Claude Code",
agy: "Antigravity (experimental)",
codex: "Codex",
},
experimental:
"Experimental. Este bot não consegue perguntar a você antes de agir. Ele trabalha com arquivos das próprias pastas e usa as ferramentas da equipe, mas não roda comandos, e não tem medidor de uso nem escolha de modelo.",
},
color: "Cor",
swatch: (color: string) => `Cor ${color}`,
colorUnset: "Se você não escolher, o bot recebe a próxima cor da equipe.",
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export class FakeBotloft implements BotloftApi {
},
deliveries: { pending: 0, dead: 0 },
usage: null,
enabledAgents: ["claude"],
};
/** How many times the app asked for a new Claude Code check. */
refreshes = 0;
Expand Down
7 changes: 6 additions & 1 deletion app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,12 @@ account: OwnerAccount, deliveries: DeliveryBacklog,
/**
* The Claude account's usage as last reported; `null` before any turn.
*/
usage: AccountUsage | null, };
usage: AccountUsage | null,
/**
* The agents a bot may be made for (spec 30): Claude Code, and the
* experimental ones the owner switched on.
*/
enabledAgents: Array<AgentKind>, };

export type CrewsCreateParams = { name: string,
/**
Expand Down
3 changes: 3 additions & 0 deletions crates/botloft-core/src/protocol/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ pub struct SystemStatus {
pub deliveries: DeliveryBacklog,
/// The Claude account's usage as last reported; `null` before any turn.
pub usage: Option<AccountUsage>,
/// The agents a bot may be made for (spec 30): Claude Code, and the
/// experimental ones the owner switched on.
pub enabled_agents: Vec<AgentKind>,
}

/// The owner, as the app shows them in its account area (spec 15.1).
Expand Down
6 changes: 5 additions & 1 deletion crates/botloftd/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub mod settings;
pub mod tasks;
pub mod usage;

use botloft_core::protocol::{OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code};
use botloft_core::protocol::{AgentKind, OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code};
use botloft_core::slug;
use botloft_core::validate::ValidationError;
use botloft_store::StoreError;
Expand Down Expand Up @@ -117,6 +117,10 @@ pub fn status(daemon: &Daemon) -> ApiResult<SystemStatus> {
},
deliveries,
usage: daemon.usage(),
enabled_agents: [AgentKind::Claude, AgentKind::Agy, AgentKind::Codex]
.into_iter()
.filter(|kind| daemon.supervisor.agent_enabled(*kind))
.collect(),
})
}

Expand Down
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co

| Método | Params | Result |
|---|---|---|
| `system.status` | | versão, uptime, versão e caminho do claude (`claudeVersion`, `claudePath`), `runtimeError` (por que os bots não sobem), `claudeSignedIn` (7.3; `null` antes de conferir), `account` (o dono para a área da conta do app: `name`, o nome de exibição da conta do Windows, `GetUserNameExW(NameDisplay)`, ou o nome de usuário sem ele, lido uma vez; e `claude`, a conta do Claude com `email`, `plan` e `organization`, ou `null` desconectado), backlog de entrega, `usage` (uso da conta, 8.1) |
| `system.status` | | versão, uptime, `enabledAgents` (os agentes para os quais um bot pode ser criado: `claude` e os experimentais ligados, seção 30), versão e caminho do claude (`claudeVersion`, `claudePath`), `runtimeError` (por que os bots não sobem), `claudeSignedIn` (7.3; `null` antes de conferir), `account` (o dono para a área da conta do app: `name`, o nome de exibição da conta do Windows, `GetUserNameExW(NameDisplay)`, ou o nome de usuário sem ele, lido uma vez; e `claude`, a conta do Claude com `email`, `plan` e `organization`, ou `null` desconectado), backlog de entrega, `usage` (uso da conta, 8.1) |
| `system.refresh` | | pede uma nova conferência do Claude Code (login e, se falhou, o executável) e responde na hora com o `system.status` atual; o app relê o status até ver o resultado |
| `crews.list` | | `Crew[]` |
| `crews.create` | `name, workFolder?, lead?` (`{name, role, instructions, model?}`: o chefe, 10.2) | `Crew` |
Expand Down Expand Up @@ -2648,7 +2648,7 @@ Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot.
| **A1** | A interface `Agent` no daemon (`crates/botloftd/src/agent`), com o Claude por trás e os testes de hoje sem mudança. Em fatias: **A1a** argumentos do processo, ambiente extra e codificação do turno no stdin (feito); **A1b** leitura da saída: cada agente decodifica o próprio fluxo (`Agent::handle_output`, `Agent::live_text`) e grava no chat por `chat::sink` (resposta, ferramenta que começa e termina, fim de turno), o que todo agente tem; o que é só do Claude (contexto, compactação, parte do plano, rascunho de telas) fica no decodificador dele, `chat/claude.rs` (feito); **A1c** descoberta e login, arquivos da pasta do bot e aprovações | em andamento |
| **A2** | AGY, **experimental**: só existe quando `experimental_agents = ["agy"]` no `config.toml`. Lançamento, entrada e saída, MCP do Botloft, isolamento por regras `deny`, retomada de conversa, no `agy` real. Sem aprovações do dono e sem comandos de shell (30.3); sem modelo e esforço escolhidos, contexto, compactação nem ferramentas conectadas do dono | feito (sem seletor no app) |
| **A3** | Codex por `codex app-server` | a fazer |
| **A4** | Seletor de agente no app, conta e login de cada agente, paridade de rotinas, navegador, desktop e perguntas | a fazer |
| **A4** | Seletor de agente no app (**feito**: na criação do bot, aparece quando há mais de um agente em `enabledAgents`, com o aviso de experimental e sem a escolha de modelo; o medidor de contexto, o esforço e o modelo do campo de mensagem só aparecem para bots Claude). **Falta:** conta e login de cada agente, paridade de rotinas, navegador, desktop e perguntas | em andamento |

### 30.2 O que a interface `Agent` possui

Expand Down
Loading